mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -0,0 +1,5 @@
|
|||||||
|
[Definition]
|
||||||
|
# NovaCPX panel failed login attempts
|
||||||
|
# Format written by auth.php: "FAILED LOGIN from <IP> [portal]"
|
||||||
|
failregex = ^.+ FAILED LOGIN from <HOST>
|
||||||
|
ignoreregex =
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Definition]
|
||||||
|
# NovaCPX panel failed login attempts
|
||||||
|
# Format written by auth.php: "FAILED LOGIN from <IP> [portal]"
|
||||||
|
failregex = ^.+ FAILED LOGIN from <HOST>
|
||||||
|
ignoreregex =
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Definition]
|
||||||
|
# NovaCPX panel failed login attempts
|
||||||
|
# Format written by auth.php: "FAILED LOGIN from <IP> [portal]"
|
||||||
|
failregex = ^.+ FAILED LOGIN from <HOST>
|
||||||
|
ignoreregex =
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[Definition]
|
||||||
|
# NovaCPX panel failed login attempts
|
||||||
|
# Format written by auth.php: "FAILED LOGIN from <IP> [portal]"
|
||||||
|
failregex = ^.+ FAILED LOGIN from <HOST>
|
||||||
|
ignoreregex =
|
||||||
+38
@@ -547,11 +547,30 @@ log "Firewall configured"
|
|||||||
|
|
||||||
# ── Fail2Ban ─────────────────────────────────────────────────────────────────
|
# ── Fail2Ban ─────────────────────────────────────────────────────────────────
|
||||||
step "Configuring 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 <<F2B
|
cat > /etc/fail2ban/jail.local <<F2B
|
||||||
[DEFAULT]
|
[DEFAULT]
|
||||||
bantime = 3600
|
bantime = 3600
|
||||||
findtime = 600
|
findtime = 600
|
||||||
maxretry = 5
|
maxretry = 5
|
||||||
|
ignoreip = ${LOCAL_IPS}
|
||||||
|
|
||||||
[sshd]
|
[sshd]
|
||||||
enabled = true
|
enabled = true
|
||||||
@@ -580,6 +599,25 @@ port = ${PORT_WEBMAIL}
|
|||||||
logpath = /var/log/novacpx/access.log
|
logpath = /var/log/novacpx/access.log
|
||||||
maxretry = 10
|
maxretry = 10
|
||||||
F2B
|
F2B
|
||||||
|
chown root:www-data /etc/fail2ban/jail.local
|
||||||
|
chmod 664 /etc/fail2ban/jail.local
|
||||||
|
|
||||||
|
# Install NovaCPX filter definitions
|
||||||
|
for jail in novacpx-user novacpx-reseller novacpx-admin novacpx-webmail; do
|
||||||
|
cp /opt/novacpx-src/deploy/fail2ban/${jail}.conf /etc/fail2ban/filter.d/ 2>/dev/null || \
|
||||||
|
cat > /etc/fail2ban/filter.d/${jail}.conf << 'FILTER'
|
||||||
|
[Definition]
|
||||||
|
failregex = ^.+ FAILED LOGIN from <HOST>
|
||||||
|
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 enable fail2ban >> "$LOG" 2>&1
|
||||||
systemctl restart fail2ban >> "$LOG" 2>&1
|
systemctl restart fail2ban >> "$LOG" 2>&1
|
||||||
log "Fail2Ban configured"
|
log "Fail2Ban configured"
|
||||||
|
|||||||
@@ -9,7 +9,16 @@ match ($action) {
|
|||||||
if (!$username || !$password) Response::error('Username and password required');
|
if (!$username || !$password) Response::error('Username and password required');
|
||||||
$auth = Auth::getInstance();
|
$auth = Auth::getInstance();
|
||||||
$token = $auth->attempt($username, $password);
|
$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();
|
$user = $auth->user();
|
||||||
audit('login', 'auth');
|
audit('login', 'auth');
|
||||||
Response::success([
|
Response::success([
|
||||||
|
|||||||
@@ -17,6 +17,61 @@ function fw_exec(string $cmd): string {
|
|||||||
return trim($out ?: '');
|
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 */
|
/** Parse `ufw status verbose` into structured data */
|
||||||
function ufw_status(): array {
|
function ufw_status(): array {
|
||||||
$raw = fw_exec('ufw status verbose');
|
$raw = fw_exec('ufw status verbose');
|
||||||
@@ -317,6 +372,54 @@ switch ($action) {
|
|||||||
Response::success(['output' => $out], 'Fail2Ban restarted');
|
Response::success(['output' => $out], 'Fail2Ban restarted');
|
||||||
break;
|
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) ─────────────────────────────
|
// ── UFW: raw command (admin escape hatch) ─────────────────────────────
|
||||||
case 'raw':
|
case 'raw':
|
||||||
$cmd = trim($body['cmd'] ?? '');
|
$cmd = trim($body['cmd'] ?? '');
|
||||||
|
|||||||
@@ -725,15 +725,17 @@
|
|||||||
// ── Firewall ───────────────────────────────────────────────────────────────
|
// ── Firewall ───────────────────────────────────────────────────────────────
|
||||||
// ── Firewall ───────────────────────────────────────────────────────────────
|
// ── Firewall ───────────────────────────────────────────────────────────────
|
||||||
async function 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','status'),
|
||||||
Nova.api('firewall','f2b-status'),
|
Nova.api('firewall','f2b-status'),
|
||||||
Nova.api('firewall','ip-lists'),
|
Nova.api('firewall','ip-lists'),
|
||||||
|
Nova.api('firewall','f2b-ignoreip-list'),
|
||||||
]);
|
]);
|
||||||
const fw = fwRes?.data || {};
|
const fw = fwRes?.data || {};
|
||||||
const jails = f2bRes?.data?.jails || [];
|
const jails = f2bRes?.data?.jails || [];
|
||||||
const trusted = ipRes?.data?.trusted || [];
|
const trusted = ipRes?.data?.trusted || [];
|
||||||
const blocked = ipRes?.data?.blocked || [];
|
const blocked = ipRes?.data?.blocked || [];
|
||||||
|
const fwIgnoreips = ignoreipRes?.data?.ignoreip || ignoreipRes?.data?.detected || [];
|
||||||
const rules = fw.rules || [];
|
const rules = fw.rules || [];
|
||||||
const active = fw.active;
|
const active = fw.active;
|
||||||
|
|
||||||
@@ -934,6 +936,29 @@
|
|||||||
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails configured.</p></div>`}
|
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails configured.</p></div>`}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fail2Ban Whitelist (ignoreip) -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<span class="card-title">Fail2Ban Whitelist</span>
|
||||||
|
<span class="text-muted text-sm ml-2">IPs that will <strong>never</strong> be banned</span>
|
||||||
|
<button class="btn btn-xs btn-ghost ml-auto" onclick="fwIgnoreipReset()" title="Reset to server defaults">Reset to Defaults</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" id="ignoreip-body">
|
||||||
|
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
|
||||||
|
<input id="fw-ignoreip-input" class="form-control form-control-sm"
|
||||||
|
placeholder="IP or CIDR — e.g. 192.168.1.50 or 10.0.0.0/8" style="flex:1">
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="fwIgnoreipAdd()">Add to Whitelist</button>
|
||||||
|
</div>
|
||||||
|
<div id="ignoreip-chips" style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||||||
|
${(fwIgnoreips||[]).map(ip => fwIgnoreipChip(ip)).join('')}
|
||||||
|
</div>
|
||||||
|
<p class="text-muted text-sm mt-2" style="font-size:.75rem">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- UFW Logging -->
|
<!-- UFW Logging -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span class="card-title">UFW Logging</span></div>
|
<div class="card-header"><span class="card-title">UFW Logging</span></div>
|
||||||
@@ -1149,6 +1174,46 @@ ${ips.length ? `
|
|||||||
Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : 'error');
|
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 `<span class="badge ${isLoopback ? 'badge-blue' : 'badge-green'}" style="cursor:${isLoopback?'default':'pointer'}"
|
||||||
|
${isLoopback ? '' : `onclick="fwIgnoreipRemove('${Nova.escHtml(ip)}')" title="Click to remove"`}>
|
||||||
|
${Nova.escHtml(ip)}${isLoopback ? ' 🔒' : ' ×'}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────────
|
// ── MySQL/DB Manager ───────────────────────────────────────────────────────
|
||||||
async function mysqlManager() {
|
async function mysqlManager() {
|
||||||
const res = await Nova.api('databases','list',{params:{account_id:0}});
|
const res = await Nova.api('databases','list',{params:{account_id:0}});
|
||||||
|
|||||||
Reference in New Issue
Block a user