Full firewall management — UFW rules + Fail2Ban + IP lists

- New firewall.php endpoint: status, enable/disable, add-rule (full UFW
  syntax: action/direction/port/proto/from/to/comment), delete-rule by
  number, quick allow-port/deny-port, allow-ip/block-ip with DB storage,
  ip-lists, reset to defaults, default-policy, set-logging, f2b-status
  (all jails with banned counts), f2b-jail detail, f2b-ban, f2b-unban
  (single jail or all), f2b-reload, f2b-restart, raw ufw command (whitelisted)
- admin.js: full firewall page — UFW status badge + enable/disable toggle,
  default policy dropdowns, numbered rules table with delete, quick rule
  inline form, full add-rule modal, trusted IP chip list, blocked IP chip
  list, Fail2Ban jails table with banned counts, per-jail banned IP modal
  with individual unban buttons, manual ban modal, logging level control
- nova.js: add Nova.escHtml() used across all new pages
- admin.js: remove git_remote field from admin settings panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:03:35 +00:00
parent 3af01ab614
commit 910427c46c
3 changed files with 753 additions and 51 deletions
+342
View File
@@ -0,0 +1,342 @@
<?php
/**
* Firewall endpoint — UFW rules + Fail2Ban jail management
* All actions require admin role.
*/
Auth::getInstance()->require('admin');
$db = DB::getInstance();
// ── Helpers ────────────────────────────────────────────────────────────────
function fw_exec(string $cmd): string {
$out = shell_exec($cmd . ' 2>&1');
return trim($out ?: '');
}
/** Parse `ufw status verbose` into structured data */
function ufw_status(): array {
$raw = fw_exec('ufw status verbose');
$active = str_contains($raw, 'Status: active');
// Parse numbered rules from `ufw status numbered`
$numbered = fw_exec('ufw status numbered');
$rules = [];
foreach (explode("\n", $numbered) as $line) {
if (preg_match('/^\[\s*(\d+)\]\s+(.+?)\s{2,}(.+?)\s{2,}(.+)$/', $line, $m)) {
$rules[] = [
'num' => (int)$m[1],
'to' => trim($m[2]),
'action' => trim($m[3]),
'from' => trim($m[4]),
];
} elseif (preg_match('/^\[\s*(\d+)\]\s+(\S+)\s+(\S+)\s+(.+)$/', $line, $m)) {
$rules[] = [
'num' => (int)$m[1],
'to' => trim($m[2]),
'action' => trim($m[3]),
'from' => trim($m[4]),
];
}
}
// Default policy
preg_match('/Default:\s+(\w+) \(incoming\),\s+(\w+) \(outgoing\)/', $raw, $pol);
return [
'active' => $active,
'default_incoming' => $pol[1] ?? 'deny',
'default_outgoing' => $pol[2] ?? 'allow',
'rules' => $rules,
'raw' => $raw,
];
}
/** Parse fail2ban-client status output into jail list */
function f2b_jails(): array {
$raw = fw_exec('fail2ban-client status');
preg_match('/Jail list:\s*(.+)/', $raw, $m);
if (!$m[1]) return [];
return array_values(array_filter(array_map('trim', explode(',', $m[1]))));
}
/** Get details for a single jail */
function f2b_jail_detail(string $jail): array {
$raw = fw_exec('fail2ban-client status ' . escapeshellarg($jail));
preg_match('/Currently banned:\s*(\d+)/', $raw, $banned);
preg_match('/Total banned:\s*(\d+)/', $raw, $total);
preg_match('/Currently failed:\s*(\d+)/', $raw, $failed);
preg_match('/Banned IP list:\s*(.*)/', $raw, $ips);
$ipList = $ips[1] ? array_values(array_filter(explode(' ', trim($ips[1])))) : [];
preg_match('/`- Filter\s*\|-- Currently failed:\s*(\d+)/s', $raw, $cf);
return [
'jail' => $jail,
'currently_banned'=> (int)($banned[1] ?? 0),
'total_banned' => (int)($total[1] ?? 0),
'currently_failed'=> (int)($failed[1] ?? 0),
'banned_ips' => $ipList,
'raw' => $raw,
];
}
// ── Dispatch ───────────────────────────────────────────────────────────────
switch ($action) {
// ── UFW status + rules ────────────────────────────────────────────────
case 'status':
Response::json(['status' => 'ok', 'data' => ufw_status()]);
break;
// ── UFW enable/disable ────────────────────────────────────────────────
case 'enable':
fw_exec('ufw --force enable');
novacpx_log('info', 'Firewall enabled');
audit('firewall.enable', 'ufw');
Response::success(['active' => true], 'Firewall enabled');
break;
case 'disable':
fw_exec('ufw --force disable');
novacpx_log('info', 'Firewall disabled');
audit('firewall.disable', 'ufw');
Response::success(['active' => false], 'Firewall disabled');
break;
// ── Add rule ──────────────────────────────────────────────────────────
case 'add-rule':
/*
* body: action (allow|deny|reject|limit),
* direction (in|out),
* port (number or range like 8000:9000),
* proto (tcp|udp|any),
* from_ip (IP/CIDR or "any"),
* to_ip (IP/CIDR or "any"),
* comment
*/
$rAction = strtolower(trim($body['action'] ?? 'allow'));
$direction = strtolower(trim($body['direction'] ?? 'in'));
$port = trim($body['port'] ?? '');
$proto = strtolower(trim($body['proto'] ?? 'tcp'));
$fromIp = trim($body['from_ip'] ?? 'any');
$toIp = trim($body['to_ip'] ?? 'any');
$comment = preg_replace('/[^a-zA-Z0-9 _\-.]/', '', trim($body['comment'] ?? ''));
if (!in_array($rAction, ['allow','deny','reject','limit'])) Response::error('Invalid action');
if (!in_array($direction, ['in','out',''])) Response::error('Invalid direction');
// Build ufw command
$cmd = 'ufw';
if ($direction) $cmd .= " {$direction}";
$cmd .= " {$rAction}";
if ($fromIp && $fromIp !== 'any') {
$cmd .= " from " . escapeshellarg($fromIp);
if ($toIp && $toIp !== 'any') $cmd .= " to " . escapeshellarg($toIp);
}
if ($port) {
$protoStr = ($proto && $proto !== 'any') ? "/{$proto}" : '';
$cmd .= " port " . escapeshellarg($port) . $protoStr;
}
if ($comment) $cmd .= " comment " . escapeshellarg($comment);
$out = fw_exec($cmd);
novacpx_log('info', "Firewall rule added: $cmd");
audit('firewall.add-rule', $cmd);
Response::success(['output' => $out, 'cmd' => $cmd], 'Rule added');
break;
// ── Quick allow/deny (simple port shortcut) ───────────────────────────
case 'allow-port':
$portProto = trim($body['port'] ?? '');
$fromIp = trim($body['from_ip'] ?? '');
if (!$portProto) Response::error('port required');
$fromPart = $fromIp ? " from " . escapeshellarg($fromIp) : '';
$out = fw_exec("ufw allow{$fromPart} " . escapeshellarg($portProto));
audit('firewall.allow-port', $portProto);
Response::success(['output' => $out], "Allowed $portProto");
break;
case 'deny-port':
$portProto = trim($body['port'] ?? '');
$fromIp = trim($body['from_ip'] ?? '');
if (!$portProto) Response::error('port required');
$fromPart = $fromIp ? " from " . escapeshellarg($fromIp) : '';
$out = fw_exec("ufw deny{$fromPart} " . escapeshellarg($portProto));
audit('firewall.deny-port', $portProto);
Response::success(['output' => $out], "Denied $portProto");
break;
// ── Delete rule by number ─────────────────────────────────────────────
case 'delete-rule':
$num = (int)($body['num'] ?? 0);
if ($num < 1) Response::error('Invalid rule number');
$out = fw_exec("echo y | ufw delete {$num}");
novacpx_log('info', "Firewall rule #{$num} deleted");
audit('firewall.delete-rule', "rule:{$num}");
Response::success(['output' => $out], "Rule #$num deleted");
break;
// ── Set default policy ────────────────────────────────────────────────
case 'default-policy':
$direction = strtolower(trim($body['direction'] ?? 'incoming'));
$policy = strtolower(trim($body['policy'] ?? 'deny'));
if (!in_array($direction, ['incoming','outgoing','routed'])) Response::error('Invalid direction');
if (!in_array($policy, ['allow','deny','reject'])) Response::error('Invalid policy');
$out = fw_exec("ufw default {$policy} {$direction}");
audit('firewall.default-policy', "{$direction}:{$policy}");
Response::success(['output' => $out], "Default $direction set to $policy");
break;
// ── Reset UFW to defaults ─────────────────────────────────────────────
case 'reset':
$out = fw_exec('echo y | ufw reset');
// Re-apply essentials
fw_exec('ufw allow ssh');
fw_exec('ufw allow 80/tcp');
fw_exec('ufw allow 443/tcp');
foreach ([PORT_USER, PORT_RESELLER, PORT_ADMIN, PORT_WEBMAIL] as $p) {
fw_exec("ufw allow {$p}/tcp");
}
fw_exec('ufw --force enable');
novacpx_log('warn', 'Firewall reset to defaults');
audit('firewall.reset', 'ufw');
Response::success(['output' => $out], 'Firewall reset — SSH, HTTP, HTTPS, and panel ports re-applied');
break;
// ── Allow/block IP ────────────────────────────────────────────────────
case 'allow-ip':
$ip = trim($body['ip'] ?? '');
if (!$ip) Response::error('ip required');
if (!filter_var(explode('/', $ip)[0], FILTER_VALIDATE_IP)) Response::error('Invalid IP');
$out = fw_exec("ufw allow from " . escapeshellarg($ip));
// Store in trusted_ips setting
$existing = json_decode($db->fetchOne("SELECT value FROM settings WHERE `key`='trusted_ips'")['value'] ?? '[]', true) ?: [];
if (!in_array($ip, $existing)) {
$existing[] = $ip;
$db->execute("INSERT INTO settings (`key`,`value`) VALUES ('trusted_ips',?) ON DUPLICATE KEY UPDATE `value`=?", [json_encode($existing), json_encode($existing)]);
}
audit('firewall.allow-ip', $ip);
Response::success(['output' => $out], "IP $ip allowed");
break;
case 'block-ip':
$ip = trim($body['ip'] ?? '');
if (!$ip) Response::error('ip required');
if (!filter_var(explode('/', $ip)[0], FILTER_VALIDATE_IP)) Response::error('Invalid IP');
$out = fw_exec("ufw deny from " . escapeshellarg($ip));
// Store in blocked_ips setting
$existing = json_decode($db->fetchOne("SELECT value FROM settings WHERE `key`='blocked_ips'")['value'] ?? '[]', true) ?: [];
if (!in_array($ip, $existing)) {
$existing[] = $ip;
$db->execute("INSERT INTO settings (`key`,`value`) VALUES ('blocked_ips',?) ON DUPLICATE KEY UPDATE `value`=?", [json_encode($existing), json_encode($existing)]);
}
audit('firewall.block-ip', $ip);
Response::success(['output' => $out], "IP $ip blocked");
break;
case 'remove-ip':
$ip = trim($body['ip'] ?? '');
$act = trim($body['action'] ?? 'allow');
if (!$ip) Response::error('ip required');
$out = fw_exec("ufw delete {$act} from " . escapeshellarg($ip));
// Remove from DB list
foreach (['trusted_ips','blocked_ips'] as $key) {
$existing = json_decode($db->fetchOne("SELECT value FROM settings WHERE `key`=?", [$key])['value'] ?? '[]', true) ?: [];
$existing = array_values(array_filter($existing, fn($i) => $i !== $ip));
$db->execute("INSERT INTO settings (`key`,`value`) VALUES (?,?) ON DUPLICATE KEY UPDATE `value`=?", [$key, json_encode($existing), json_encode($existing)]);
}
audit('firewall.remove-ip', $ip);
Response::success(['output' => $out], "IP $ip rule removed");
break;
// ── Trusted/blocked IP lists ──────────────────────────────────────────
case 'ip-lists':
$trusted = json_decode($db->fetchOne("SELECT value FROM settings WHERE `key`='trusted_ips'")['value'] ?? '[]', true) ?: [];
$blocked = json_decode($db->fetchOne("SELECT value FROM settings WHERE `key`='blocked_ips'")['value'] ?? '[]', true) ?: [];
Response::success(['trusted' => $trusted, 'blocked' => $blocked]);
break;
// ── Fail2Ban: list jails with stats ───────────────────────────────────
case 'f2b-status':
$jails = f2b_jails();
$result = [];
foreach ($jails as $j) {
$result[] = f2b_jail_detail($j);
}
Response::success(['jails' => $result]);
break;
// ── Fail2Ban: single jail details ─────────────────────────────────────
case 'f2b-jail':
$jail = preg_replace('/[^a-z0-9_\-]/', '', trim($body['jail'] ?? ''));
if (!$jail) Response::error('jail required');
Response::success(f2b_jail_detail($jail));
break;
// ── Fail2Ban: unban IP from all or specific jail ──────────────────────
case 'f2b-unban':
$ip = trim($body['ip'] ?? '');
$jail = preg_replace('/[^a-z0-9_\-]/', '', trim($body['jail'] ?? ''));
if (!$ip) Response::error('ip required');
if (!filter_var($ip, FILTER_VALIDATE_IP)) Response::error('Invalid IP');
$results = [];
$targets = $jail ? [$jail] : f2b_jails();
foreach ($targets as $j) {
$out = fw_exec("fail2ban-client set " . escapeshellarg($j) . " unbanip " . escapeshellarg($ip));
$results[$j] = $out;
}
audit('firewall.f2b-unban', "$ip from " . ($jail ?: 'all'));
Response::success(['results' => $results], "Unbanned $ip");
break;
// ── Fail2Ban: manually ban IP ─────────────────────────────────────────
case 'f2b-ban':
$ip = trim($body['ip'] ?? '');
$jail = preg_replace('/[^a-z0-9_\-]/', '', trim($body['jail'] ?? 'sshd'));
if (!$ip) Response::error('ip required');
if (!filter_var($ip, FILTER_VALIDATE_IP)) Response::error('Invalid IP');
$out = fw_exec("fail2ban-client set " . escapeshellarg($jail) . " banip " . escapeshellarg($ip));
audit('firewall.f2b-ban', "$ip in $jail");
Response::success(['output' => $out], "Banned $ip in $jail");
break;
// ── Fail2Ban: reload config ───────────────────────────────────────────
case 'f2b-reload':
$out = fw_exec('fail2ban-client reload');
audit('firewall.f2b-reload', 'fail2ban');
Response::success(['output' => $out], 'Fail2Ban reloaded');
break;
// ── Fail2Ban: restart ─────────────────────────────────────────────────
case 'f2b-restart':
$out = fw_exec('systemctl restart fail2ban 2>&1');
audit('firewall.f2b-restart', 'fail2ban');
Response::success(['output' => $out], 'Fail2Ban restarted');
break;
// ── UFW: raw command (admin escape hatch) ─────────────────────────────
case 'raw':
$cmd = trim($body['cmd'] ?? '');
if (!$cmd) Response::error('cmd required');
// Whitelist safe ufw sub-commands only
if (!preg_match('/^ufw\s+(status|show|logging|allow|deny|reject|limit|delete|insert|prepend|route|default)\s/', "ufw $cmd")) {
Response::error('Command not permitted');
}
$out = fw_exec('ufw ' . $cmd);
audit('firewall.raw', $cmd);
Response::success(['output' => $out]);
break;
// ── UFW logging level ─────────────────────────────────────────────────
case 'set-logging':
$level = strtolower(trim($body['level'] ?? 'on'));
if (!in_array($level, ['off','on','low','medium','high','full'])) Response::error('Invalid level');
$out = fw_exec("ufw logging {$level}");
audit('firewall.logging', $level);
Response::success(['output' => $out], "Logging set to $level");
break;
default:
Response::error("Unknown firewall action: $action", 404);
}
+406 -50
View File
@@ -328,7 +328,6 @@
<div class="form-group"><label>Update Channel</label> <div class="form-group"><label>Update Channel</label>
<select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select> <select name="update_channel"><option value="stable">Stable</option><option value="beta">Beta</option></select>
</div> </div>
<div class="form-group"><label>Git Remote</label><input type="url" name="git_remote" value="https://github.com/myronblair/novacpx.git"></div>
</div> </div>
<button type="submit" class="btn btn-primary">Save Settings</button> <button type="submit" class="btn btn-primary">Save Settings</button>
</form> </form>
@@ -723,74 +722,431 @@
}, true); }, true);
}; };
// ── Firewall ───────────────────────────────────────────────────────────────
// ── Firewall ─────────────────────────────────────────────────────────────── // ── Firewall ───────────────────────────────────────────────────────────────
async function firewall() { async function firewall() {
const ufwRes = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:'status'}}).catch(()=>null); const [fwRes, f2bRes, ipRes] = await Promise.all([
Nova.api('firewall','status'),
Nova.api('firewall','f2b-status'),
Nova.api('firewall','ip-lists'),
]);
const fw = fwRes?.data || {};
const jails = f2bRes?.data?.jails || [];
const trusted = ipRes?.data?.trusted || [];
const blocked = ipRes?.data?.blocked || [];
const rules = fw.rules || [];
const active = fw.active;
const totalBanned = jails.reduce((s,j) => s + (j.currently_banned||0), 0);
return ` return `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1.5rem"> <div class="page-header mb-3">
<h2 class="page-title">Firewall</h2>
<div style="display:flex;gap:.5rem;align-items:center">
${active ? Nova.badge('UFW Active','green') : Nova.badge('UFW Disabled','red')}
${active
? `<button class="btn btn-sm btn-ghost" onclick="fwToggle(false)">Disable UFW</button>`
: `<button class="btn btn-sm btn-primary" onclick="fwToggle(true)">Enable UFW</button>`}
<button class="btn btn-sm btn-ghost" onclick="adminPage('firewall')">
<svg class="icon-xs"><use href="/assets/img/nova-icons.svg#ni-search"/></svg> Refresh
</button>
</div>
</div>
<div class="grid-2 mb-3" style="gap:1.5rem">
<!-- Default Policies -->
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">UFW Firewall</span></div> <div class="card-header">
<div style="padding:1.25rem"> <span class="card-title">Default Policies</span>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> </div>
<button class="btn btn-sm btn-primary" onclick="adminServiceAction('ufw','start')">Enable UFW</button> <div class="card-body">
<button class="btn btn-sm btn-danger" onclick="adminServiceAction('ufw','stop')">Disable UFW</button> <div class="grid-2 mb-3">
</div> <div>
<div class="form-group"><label class="form-label">Allow Port</label> <p class="text-muted text-sm">Incoming</p>
<div style="display:flex;gap:.5rem"> <select id="pol-incoming" class="form-control form-control-sm" style="width:auto">
<input id="fw-port" class="form-control" placeholder="e.g. 3306/tcp"> ${['deny','allow','reject'].map(p=>`<option value="${p}" ${fw.default_incoming===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
<button class="btn btn-sm btn-primary" onclick="adminAllowPort()">Allow</button> </select>
</div>
<div>
<p class="text-muted text-sm">Outgoing</p>
<select id="pol-outgoing" class="form-control form-control-sm" style="width:auto">
${['allow','deny','reject'].map(p=>`<option value="${p}" ${fw.default_outgoing===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
</select>
</div> </div>
</div> </div>
<div class="form-group"><label class="form-label">Block Port</label> <button class="btn btn-sm btn-primary" onclick="fwSavePolicies()">Save Policies</button>
<div style="display:flex;gap:.5rem"> </div>
<input id="fw-block" class="form-control" placeholder="e.g. 3306/tcp"> </div>
<button class="btn btn-sm btn-danger" onclick="adminBlockPort()">Block</button>
</div> <!-- Fail2Ban summary -->
<div class="card">
<div class="card-header">
<span class="card-title">Fail2Ban</span>
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('0 banned','green')}
</div>
<div class="card-body">
<div class="grid-2 mb-2">
<div><p class="text-muted text-sm">Active Jails</p><p class="font-bold">${jails.length}</p></div>
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${totalBanned}</p></div>
</div>
<div style="display:flex;gap:.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-ghost" onclick="fwF2bReload()">Reload Config</button>
<button class="btn btn-sm btn-ghost" onclick="fwF2bRestart()">Restart</button>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- UFW Rules -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">
<svg class="icon-sm mr-1"><use href="/assets/img/nova-icons.svg#ni-firewall"/></svg>
UFW Rules
</span>
<span class="text-muted text-sm ml-2">${rules.length} rule${rules.length!==1?'s':''}</span>
<button class="btn btn-sm btn-primary ml-auto" onclick="fwAddRuleModal()">+ Add Rule</button>
<button class="btn btn-sm btn-ghost" onclick="fwResetModal()">Reset to Defaults</button>
</div>
${rules.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>#</th><th>To / Port</th><th>Action</th><th>From</th><th></th></tr></thead>
<tbody>
${rules.map(r => `<tr>
<td class="text-muted text-sm">${r.num}</td>
<td><code>${Nova.escHtml(r.to)}</code></td>
<td>${fwActionBadge(r.action)}</td>
<td class="text-sm">${Nova.escHtml(r.from)}</td>
<td>
<button class="btn btn-xs btn-ghost" style="color:var(--red)" onclick="fwDeleteRule(${r.num})">Delete</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="card-body"><p class="text-muted">No rules defined.</p></div>`}
</div>
<!-- Add Quick Rule (inline) -->
<div class="card mb-3">
<div class="card-header"><span class="card-title">Quick Rule</span></div>
<div class="card-body">
<div style="display:flex;gap:.75rem;flex-wrap:wrap;align-items:flex-end">
<div class="form-group mb-0">
<label class="form-label text-sm">Action</label>
<select id="qr-action" class="form-control form-control-sm">
<option value="allow">Allow</option>
<option value="deny">Deny</option>
<option value="reject">Reject</option>
<option value="limit">Limit</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Direction</label>
<select id="qr-dir" class="form-control form-control-sm">
<option value="in">In</option>
<option value="out">Out</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Port / Service</label>
<input id="qr-port" class="form-control form-control-sm" placeholder="80, 8000:9000, ssh…" style="width:160px">
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Protocol</label>
<select id="qr-proto" class="form-control form-control-sm">
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
<option value="any">Any</option>
</select>
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">From IP (optional)</label>
<input id="qr-from" class="form-control form-control-sm" placeholder="any" style="width:150px">
</div>
<div class="form-group mb-0">
<label class="form-label text-sm">Comment</label>
<input id="qr-comment" class="form-control form-control-sm" placeholder="optional" style="width:140px">
</div>
<button class="btn btn-primary btn-sm mb-0" onclick="fwQuickRule()">Add Rule</button>
</div>
</div>
</div>
<div class="grid-2 mb-3" style="gap:1.5rem">
<!-- Trusted IPs -->
<div class="card"> <div class="card">
<div class="card-header"><span class="card-title">Fail2Ban</span></div> <div class="card-header">
<div style="padding:1.25rem"> <span class="card-title">Trusted IPs</span>
<div style="display:flex;gap:.5rem;margin-bottom:1rem"> <span class="text-muted text-sm ml-2">${trusted.length} IPs</span>
<button class="btn btn-sm" onclick="adminServiceAction('fail2ban','restart')">Restart Fail2Ban</button> </div>
<button class="btn btn-sm" onclick="adminF2bStatus()">View Jails</button> <div class="card-body">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input id="fw-trust-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 192.168.1.0/24" style="flex:1">
<button class="btn btn-sm btn-primary" onclick="fwAllowIp()">Allow</button>
</div> </div>
<div class="form-group"><label class="form-label">Unban IP</label> ${trusted.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
<div style="display:flex;gap:.5rem"> ${trusted.map(ip => `<span class="badge badge-green" style="cursor:pointer" onclick="fwRemoveIp('${ip}','allow')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
<input id="fw-unban" class="form-control" placeholder="192.168.1.100"> </div>` : `<p class="text-muted text-sm">No trusted IPs.</p>`}
<button class="btn btn-sm" onclick="adminUnban()">Unban</button> </div>
</div> </div>
<!-- Blocked IPs -->
<div class="card">
<div class="card-header">
<span class="card-title">Blocked IPs</span>
<span class="text-muted text-sm ml-2">${blocked.length} IPs</span>
</div>
<div class="card-body">
<div style="display:flex;gap:.5rem;margin-bottom:.75rem">
<input id="fw-block-ip" class="form-control form-control-sm" placeholder="IP or CIDR e.g. 1.2.3.4" style="flex:1">
<button class="btn btn-sm" style="background:var(--red);color:#fff" onclick="fwBlockIp()">Block</button>
</div> </div>
${blocked.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
${blocked.map(ip => `<span class="badge badge-red" style="cursor:pointer" onclick="fwRemoveIp('${ip}','deny')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
</div>` : `<p class="text-muted text-sm">No blocked IPs.</p>`}
</div>
</div>
</div>
<!-- Fail2Ban Jails -->
<div class="card mb-3">
<div class="card-header">
<span class="card-title">Fail2Ban Jails</span>
</div>
${jails.length ? `
<div class="table-wrap">
<table>
<thead><tr><th>Jail</th><th>Currently Banned</th><th>Total Banned</th><th>Failed</th><th>Actions</th></tr></thead>
<tbody>
${jails.map(j => `<tr>
<td><strong>${Nova.escHtml(j.jail)}</strong></td>
<td>${j.currently_banned > 0 ? `<span style="color:var(--red);font-weight:600">${j.currently_banned}</span>` : '0'}</td>
<td class="text-muted">${j.total_banned}</td>
<td class="text-muted">${j.currently_failed}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
${j.currently_banned > 0 ? `<button class="btn btn-xs btn-ghost" onclick="fwJailDetail('${j.jail}')">View Banned</button>` : ''}
<button class="btn btn-xs btn-ghost" onclick="fwManualBanModal('${j.jail}')">Ban IP</button>
</td>
</tr>`).join('')}
</tbody>
</table>
</div>` : `<div class="card-body"><p class="text-muted">Fail2Ban not running or no jails configured.</p></div>`}
</div>
<!-- UFW Logging -->
<div class="card">
<div class="card-header"><span class="card-title">UFW Logging</span></div>
<div class="card-body">
<div style="display:flex;gap:.75rem;align-items:flex-end;flex-wrap:wrap">
<div class="form-group mb-0">
<label class="form-label text-sm">Log Level</label>
<select id="fw-log-level" class="form-control form-control-sm">
${['off','on','low','medium','high','full'].map(l=>`<option value="${l}">${l.charAt(0).toUpperCase()+l.slice(1)}</option>`).join('')}
</select>
</div>
<button class="btn btn-sm btn-primary" onclick="fwSetLogging()">Apply</button>
<span class="text-muted text-sm">Logs at <code>/var/log/ufw.log</code></span>
</div> </div>
</div> </div>
</div>`; </div>`;
} }
window.adminAllowPort = async () => {
const port = document.getElementById('fw-port')?.value; function fwActionBadge(action) {
if (!port) return; const a = (action||'').toLowerCase();
const r = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:`allow ${port}`}}); if (a.includes('allow')) return Nova.badge('ALLOW','green');
Nova.toast(r?.success ? `Allowed ${port}` : r?.message,'success'); if (a.includes('deny')) return Nova.badge('DENY','red');
if (a.includes('reject'))return Nova.badge('REJECT','red');
if (a.includes('limit')) return Nova.badge('LIMIT','yellow');
return `<span class="text-sm">${Nova.escHtml(action)}</span>`;
}
window.fwToggle = async (enable) => {
const label = enable ? 'Enable' : 'Disable';
Nova.confirm(`${label} UFW firewall?`, async () => {
const r = await Nova.api('firewall', enable ? 'enable' : 'disable', {method:'POST'});
Nova.toast(r?.message || label + 'd', r?.success ? 'success' : 'error');
adminPage('firewall');
}, !enable);
}; };
window.adminBlockPort = async () => {
const port = document.getElementById('fw-block')?.value; window.fwSavePolicies = async () => {
if (!port) return; const inc = document.getElementById('pol-incoming')?.value;
const r = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:`deny ${port}`}}); const out = document.getElementById('pol-outgoing')?.value;
Nova.toast(r?.success ? `Blocked ${port}` : r?.message,'success'); await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'incoming',policy:inc}});
const r2 = await Nova.api('firewall','default-policy',{method:'POST',body:{direction:'outgoing',policy:out}});
Nova.toast(r2?.success ? 'Policies saved' : r2?.message, r2?.success ? 'success' : 'error');
adminPage('firewall');
}; };
window.adminF2bStatus = async () => {
const r = await Nova.api('system','service',{method:'POST',body:{service:'fail2ban-client',command:'status'}}); window.fwDeleteRule = (num) => {
Nova.modal('Fail2Ban Jails', `<pre style="background:var(--bg);padding:1rem;border-radius:6px;font-size:.8rem;overflow:auto">${r?.data?.output || 'No output'}</pre>`); Nova.confirm(`Delete rule #${num}? This cannot be undone.`, async () => {
const r = await Nova.api('firewall','delete-rule',{method:'POST',body:{num}});
Nova.toast(r?.message || 'Deleted', r?.success ? 'success' : 'error');
adminPage('firewall');
}, true);
}; };
window.adminUnban = async () => {
const ip = document.getElementById('fw-unban')?.value; window.fwResetModal = () => {
if (!ip) return; Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => {
Nova.toast(`Unbanning ${ip}`,'info'); Nova.toast('Resetting firewall…','info',5000);
// Unban from all jails const r = await Nova.api('firewall','reset',{method:'POST'});
for (const jail of ['sshd','novacpx-user','novacpx-admin','novacpx-reseller','novacpx-webmail']) { Nova.toast(r?.message || 'Reset complete','success');
await Nova.api('system','service',{method:'POST',body:{service:`fail2ban-client set ${jail} unbanip`,command:ip}}).catch(()=>{}); adminPage('firewall');
} }, true);
Nova.toast('Unban commands sent','success'); };
window.fwQuickRule = async () => {
const body = {
action: document.getElementById('qr-action')?.value,
direction: document.getElementById('qr-dir')?.value,
port: document.getElementById('qr-port')?.value,
proto: document.getElementById('qr-proto')?.value,
from_ip: document.getElementById('qr-from')?.value || 'any',
comment: document.getElementById('qr-comment')?.value,
};
if (!body.port) { Nova.toast('Port/service is required','error'); return; }
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAddRuleModal = () => {
Nova.modal('Add Firewall Rule',`
<div class="form-group"><label>Action</label>
<select id="m-action" class="form-control">
<option value="allow">Allow</option><option value="deny">Deny</option>
<option value="reject">Reject</option><option value="limit">Limit (rate-limit)</option>
</select></div>
<div class="form-group"><label>Direction</label>
<select id="m-dir" class="form-control">
<option value="in">Incoming</option><option value="out">Outgoing</option>
</select></div>
<div class="form-group"><label>Port / Service</label>
<input id="m-port" class="form-control" placeholder="e.g. 443, 8000:9000, ssh, smtp"></div>
<div class="form-group"><label>Protocol</label>
<select id="m-proto" class="form-control">
<option value="tcp">TCP</option><option value="udp">UDP</option><option value="any">Any</option>
</select></div>
<div class="form-group"><label>From IP / CIDR (leave blank for any)</label>
<input id="m-from" class="form-control" placeholder="any or 192.168.1.0/24"></div>
<div class="form-group"><label>To IP / CIDR (leave blank for any)</label>
<input id="m-to" class="form-control" placeholder="any"></div>
<div class="form-group"><label>Comment</label>
<input id="m-comment" class="form-control" placeholder="optional description"></div>
`,`<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn btn-primary" onclick="fwSubmitAddRule()">Add Rule</button>`);
};
window.fwSubmitAddRule = async () => {
const body = {
action: document.getElementById('m-action')?.value,
direction: document.getElementById('m-dir')?.value,
port: document.getElementById('m-port')?.value,
proto: document.getElementById('m-proto')?.value,
from_ip: document.getElementById('m-from')?.value || 'any',
to_ip: document.getElementById('m-to')?.value || 'any',
comment: document.getElementById('m-comment')?.value,
};
if (!body.port) { Nova.toast('Port is required','error'); return; }
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall','add-rule',{method:'POST',body});
Nova.toast(r?.message || (r?.success ? 'Rule added' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwAllowIp = async () => {
const ip = document.getElementById('fw-trust-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
const r = await Nova.api('firewall','allow-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP allowed' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwBlockIp = async () => {
const ip = document.getElementById('fw-block-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP or CIDR','error'); return; }
Nova.confirm(`Block ${ip}? This will deny all incoming traffic from this address.`, async () => {
const r = await Nova.api('firewall','block-ip',{method:'POST',body:{ip}});
Nova.toast(r?.message || (r?.success ? 'IP blocked' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwRemoveIp = (ip, action) => {
Nova.confirm(`Remove ${action} rule for ${ip}?`, async () => {
const r = await Nova.api('firewall','remove-ip',{method:'POST',body:{ip,action}});
Nova.toast(r?.message || 'Removed', r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
}, true);
};
window.fwJailDetail = async (jail) => {
const r = await Nova.api('firewall','f2b-jail',{method:'POST',body:{jail}});
const d = r?.data || {};
const ips = d.banned_ips || [];
Nova.modal(`Fail2Ban: ${jail}`,`
<div class="grid-2 mb-2" style="gap:.75rem">
<div><p class="text-muted text-sm">Currently Banned</p><p class="font-bold">${d.currently_banned}</p></div>
<div><p class="text-muted text-sm">Total Banned</p><p class="font-bold">${d.total_banned}</p></div>
</div>
${ips.length ? `
<table style="width:100%;font-size:.85rem">
<thead><tr><th style="text-align:left;padding:.35rem .5rem">Banned IP</th><th></th></tr></thead>
<tbody>
${ips.map(ip=>`<tr>
<td style="padding:.3rem .5rem"><code>${Nova.escHtml(ip)}</code></td>
<td style="padding:.3rem .5rem;text-align:right">
<button class="btn btn-xs btn-primary" onclick="fwUnbanIp('${Nova.escHtml(ip)}','${jail}',this)">Unban</button>
</td>
</tr>`).join('')}
</tbody>
</table>` : '<p class="text-muted">No IPs currently banned in this jail.</p>'}`);
};
window.fwUnbanIp = async (ip, jail, btn) => {
if (btn) btn.disabled = true;
const r = await Nova.api('firewall','f2b-unban',{method:'POST',body:{ip,jail}});
Nova.toast(r?.message || 'Unbanned', r?.success ? 'success' : 'error');
if (r?.success && btn) btn.closest('tr')?.remove();
};
window.fwManualBanModal = (jail) => {
Nova.modal(`Manual Ban — ${jail}`,`
<div class="form-group">
<label>IP Address to Ban</label>
<input id="mb-ip" class="form-control" placeholder="1.2.3.4" autofocus>
</div>`,`
<button class="btn btn-ghost" onclick="this.closest('.modal-overlay').remove()">Cancel</button>
<button class="btn" style="background:var(--red);color:#fff" onclick="fwSubmitManualBan('${jail}')">Ban IP</button>`);
};
window.fwSubmitManualBan = async (jail) => {
const ip = document.getElementById('mb-ip')?.value?.trim();
if (!ip) { Nova.toast('Enter an IP','error'); return; }
document.querySelector('.modal-overlay')?.remove();
const r = await Nova.api('firewall','f2b-ban',{method:'POST',body:{ip,jail}});
Nova.toast(r?.message || (r?.success ? 'Banned' : 'Failed'), r?.success ? 'success' : 'error');
if (r?.success) adminPage('firewall');
};
window.fwF2bReload = async () => {
const r = await Nova.api('firewall','f2b-reload',{method:'POST'});
Nova.toast(r?.message || 'Reloaded', r?.success ? 'success' : 'error');
};
window.fwF2bRestart = async () => {
Nova.confirm('Restart Fail2Ban? Active bans will be preserved.', async () => {
const r = await Nova.api('firewall','f2b-restart',{method:'POST'});
Nova.toast(r?.message || 'Restarted', r?.success ? 'success' : 'error');
adminPage('firewall');
});
};
window.fwSetLogging = async () => {
const level = document.getElementById('fw-log-level')?.value;
const r = await Nova.api('firewall','set-logging',{method:'POST',body:{level}});
Nova.toast(r?.message || 'Logging updated', r?.success ? 'success' : 'error');
}; };
// ── MySQL/DB Manager ─────────────────────────────────────────────────────── // ── MySQL/DB Manager ───────────────────────────────────────────────────────
+5 -1
View File
@@ -116,10 +116,14 @@ window.Nova = (() => {
return `<span class="service-dot ${cls}"></span>`; return `<span class="service-dot ${cls}"></span>`;
} }
function escHtml(str) {
return String(str ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Inject global CSS animation // Inject global CSS animation
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}'; style.textContent = '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}';
document.head.appendChild(style); document.head.appendChild(style);
return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot }; return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml };
})(); })();