mirror of
https://github.com/myronblair/novacpx
synced 2026-06-30 17:50:41 -05:00
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:
@@ -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);
|
||||||
|
}
|
||||||
+407
-51
@@ -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">
|
|
||||||
<button class="btn btn-sm btn-primary" onclick="adminServiceAction('ufw','start')">Enable UFW</button>
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="adminServiceAction('ufw','stop')">Disable UFW</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label class="form-label">Allow Port</label>
|
<div class="card-body">
|
||||||
<div style="display:flex;gap:.5rem">
|
<div class="grid-2 mb-3">
|
||||||
<input id="fw-port" class="form-control" placeholder="e.g. 3306/tcp">
|
<div>
|
||||||
<button class="btn btn-sm btn-primary" onclick="adminAllowPort()">Allow</button>
|
<p class="text-muted text-sm">Incoming</p>
|
||||||
</div>
|
<select id="pol-incoming" class="form-control form-control-sm" style="width:auto">
|
||||||
</div>
|
${['deny','allow','reject'].map(p=>`<option value="${p}" ${fw.default_incoming===p?'selected':''}>${p.charAt(0).toUpperCase()+p.slice(1)}</option>`).join('')}
|
||||||
<div class="form-group"><label class="form-label">Block Port</label>
|
</select>
|
||||||
<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>
|
<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>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="fwSavePolicies()">Save Policies</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fail2Ban summary -->
|
||||||
<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">Fail2Ban</span>
|
||||||
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
|
${totalBanned > 0 ? Nova.badge(totalBanned + ' banned', 'red') : Nova.badge('0 banned','green')}
|
||||||
<button class="btn btn-sm" onclick="adminServiceAction('fail2ban','restart')">Restart Fail2Ban</button>
|
|
||||||
<button class="btn btn-sm" onclick="adminF2bStatus()">View Jails</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label class="form-label">Unban IP</label>
|
<div class="card-body">
|
||||||
<div style="display:flex;gap:.5rem">
|
<div class="grid-2 mb-2">
|
||||||
<input id="fw-unban" class="form-control" placeholder="192.168.1.100">
|
<div><p class="text-muted text-sm">Active Jails</p><p class="font-bold">${jails.length}</p></div>
|
||||||
<button class="btn btn-sm" onclick="adminUnban()">Unban</button>
|
<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>
|
</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-header">
|
||||||
|
<span class="card-title">Trusted IPs</span>
|
||||||
|
<span class="text-muted text-sm ml-2">${trusted.length} IPs</span>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
${trusted.length ? `<div style="display:flex;flex-wrap:wrap;gap:.35rem">
|
||||||
|
${trusted.map(ip => `<span class="badge badge-green" style="cursor:pointer" onclick="fwRemoveIp('${ip}','allow')" title="Click to remove">${Nova.escHtml(ip)} ×</span>`).join('')}
|
||||||
|
</div>` : `<p class="text-muted text-sm">No trusted IPs.</p>`}
|
||||||
|
</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>
|
||||||
|
${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>`;
|
||||||
}
|
}
|
||||||
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');
|
||||||
window.adminBlockPort = async () => {
|
if (a.includes('limit')) return Nova.badge('LIMIT','yellow');
|
||||||
const port = document.getElementById('fw-block')?.value;
|
return `<span class="text-sm">${Nova.escHtml(action)}</span>`;
|
||||||
if (!port) return;
|
|
||||||
const r = await Nova.api('system','service',{method:'POST',body:{service:'ufw',command:`deny ${port}`}});
|
|
||||||
Nova.toast(r?.success ? `Blocked ${port}` : r?.message,'success');
|
|
||||||
};
|
|
||||||
window.adminF2bStatus = async () => {
|
|
||||||
const r = await Nova.api('system','service',{method:'POST',body:{service:'fail2ban-client',command:'status'}});
|
|
||||||
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>`);
|
|
||||||
};
|
|
||||||
window.adminUnban = async () => {
|
|
||||||
const ip = document.getElementById('fw-unban')?.value;
|
|
||||||
if (!ip) return;
|
|
||||||
Nova.toast(`Unbanning ${ip}…`,'info');
|
|
||||||
// Unban from all jails
|
|
||||||
for (const jail of ['sshd','novacpx-user','novacpx-admin','novacpx-reseller','novacpx-webmail']) {
|
|
||||||
await Nova.api('system','service',{method:'POST',body:{service:`fail2ban-client set ${jail} unbanip`,command:ip}}).catch(()=>{});
|
|
||||||
}
|
}
|
||||||
Nova.toast('Unban commands sent','success');
|
|
||||||
|
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.fwSavePolicies = async () => {
|
||||||
|
const inc = document.getElementById('pol-incoming')?.value;
|
||||||
|
const out = document.getElementById('pol-outgoing')?.value;
|
||||||
|
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.fwDeleteRule = (num) => {
|
||||||
|
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.fwResetModal = () => {
|
||||||
|
Nova.confirm('Reset ALL firewall rules to NovaCPX defaults? SSH, HTTP, HTTPS, and panel ports will be re-allowed automatically.', async () => {
|
||||||
|
Nova.toast('Resetting firewall…','info',5000);
|
||||||
|
const r = await Nova.api('firewall','reset',{method:'POST'});
|
||||||
|
Nova.toast(r?.message || 'Reset complete','success');
|
||||||
|
adminPage('firewall');
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ───────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
|
|
||||||
// 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 };
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user