618 lines
37 KiB
HTML
618 lines
37 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>DockVisor — Firewall Monitor</title>
|
||
<style>
|
||
:root {
|
||
--bg: #0a0e14; --bg2: #11161f; --bg3: #1a2130; --bg4: #232c3d;
|
||
--border: #1f2937; --border2: #2b3648;
|
||
--text: #e6edf3; --text2: #8b98a9; --text3: #5b6675;
|
||
--accent: #3b82f6; --accent2: #2563eb;
|
||
--green: #22c55e; --yellow: #f59e0b; --red: #ef4444; --cyan: #06b6d4; --purple: #8b5cf6;
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
html, body { height: 100%; }
|
||
body {
|
||
background: var(--bg); color: var(--text);
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
font-size: 14px; line-height: 1.5;
|
||
display: flex; overflow: hidden;
|
||
}
|
||
|
||
/* ===== SIDEBAR ===== */
|
||
.sidebar {
|
||
width: 248px; min-width: 248px; height: 100vh;
|
||
background: var(--bg2); border-right: 1px solid var(--border);
|
||
display: flex; flex-direction: column; padding: 20px 14px;
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 10px; padding: 4px 8px 20px; }
|
||
.brand .logo {
|
||
width: 34px; height: 34px; border-radius: 9px;
|
||
background: linear-gradient(135deg, var(--accent), var(--purple));
|
||
display: flex; align-items: center; justify-content: center; font-weight: 700; color: #fff;
|
||
}
|
||
.brand .name { font-size: 16px; font-weight: 700; letter-spacing: -0.3px; }
|
||
.brand .sub { font-size: 10px; color: var(--text3); text-transform: uppercase; letter-spacing: 1px; }
|
||
|
||
.nav-group-label { font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px; color: var(--text3); font-weight: 600; margin: 16px 8px 6px; }
|
||
.nav-item {
|
||
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
|
||
border-radius: 8px; cursor: pointer; font-size: 13px; font-weight: 500;
|
||
color: var(--text2); transition: all .15s; margin-bottom: 2px; border: 1px solid transparent;
|
||
}
|
||
.nav-item:hover { background: var(--bg3); color: var(--text); }
|
||
.nav-item.active { background: var(--bg4); color: var(--text); border-color: var(--border2); }
|
||
.nav-item svg { color: var(--text3); flex-shrink: 0; }
|
||
.nav-item.active svg { color: var(--accent); }
|
||
.nav-item .badge { margin-left: auto; background: var(--bg4); color: var(--text2); font-size: 10px; padding: 1px 7px; border-radius: 9px; font-weight: 600; }
|
||
.nav-item.active .badge { background: rgba(59,130,246,.15); color: var(--accent); }
|
||
|
||
.servers { margin-top: 4px; }
|
||
.server-row { display: flex; align-items: center; gap: 8px; padding: 6px 12px; font-size: 12px; color: var(--text2); }
|
||
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
||
.dot.online { background: var(--green); box-shadow: 0 0 6px rgba(34,197,94,.6); }
|
||
.dot.offline { background: var(--text3); }
|
||
|
||
.sidebar-footer { margin-top: auto; padding-top: 14px; border-top: 1px solid var(--border); }
|
||
.refresh-row { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text3); padding: 0 8px; }
|
||
select {
|
||
background: var(--bg3); color: var(--text); border: 1px solid var(--border2);
|
||
border-radius: 6px; padding: 4px 8px; font-family: inherit; font-size: 12px;
|
||
}
|
||
|
||
/* ===== MAIN ===== */
|
||
.main { flex: 1; height: 100vh; overflow-y: auto; display: flex; flex-direction: column; }
|
||
.topbar {
|
||
position: sticky; top: 0; z-index: 10;
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 18px 28px; background: rgba(10,14,20,.9); backdrop-filter: blur(8px);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.topbar h1 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; }
|
||
.topbar .subtitle { font-size: 12px; color: var(--text3); margin-top: 2px; }
|
||
.topbar .actions { display: flex; gap: 8px; align-items: center; }
|
||
.clock { font-size: 12px; color: var(--text3); margin-right: 8px; }
|
||
|
||
.content { padding: 24px 28px; }
|
||
|
||
/* ===== COMPONENTS ===== */
|
||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 14px; margin-bottom: 22px; }
|
||
.card {
|
||
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||
padding: 18px; position: relative; overflow: hidden;
|
||
}
|
||
.card .label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: var(--text3); font-weight: 600; margin-bottom: 6px; }
|
||
.card .value { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
|
||
.card .sub { font-size: 12px; color: var(--text2); margin-top: 4px; }
|
||
.card .icon {
|
||
position: absolute; top: 14px; right: 14px; width: 34px; height: 34px; border-radius: 9px;
|
||
display: flex; align-items: center; justify-content: center; opacity: .9;
|
||
}
|
||
.icon.blue { background: rgba(59,130,246,.14); color: var(--accent); }
|
||
.icon.green { background: rgba(34,197,94,.14); color: var(--green); }
|
||
.icon.red { background: rgba(239,68,68,.14); color: var(--red); }
|
||
.icon.yellow { background: rgba(245,158,11,.14); color: var(--yellow); }
|
||
.icon.cyan { background: rgba(6,182,212,.14); color: var(--cyan); }
|
||
.icon.purple { background: rgba(139,92,246,.14); color: var(--purple); }
|
||
|
||
.section {
|
||
background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
|
||
margin-bottom: 20px; overflow: hidden;
|
||
}
|
||
.section-header { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-bottom: 1px solid var(--border); }
|
||
.section-header h3 { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||
.section-header .count { font-size: 11px; color: var(--text3); font-weight: 500; }
|
||
|
||
.table-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||
th { text-align: left; padding: 10px 18px; color: var(--text3); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid var(--border); font-weight: 600; white-space: nowrap; }
|
||
td { padding: 11px 18px; border-bottom: 1px solid var(--bg3); white-space: nowrap; }
|
||
tr:last-child td { border-bottom: none; }
|
||
tbody tr { transition: background .12s; }
|
||
tbody tr:hover { background: var(--bg3); }
|
||
tbody tr.flagged { background: rgba(239,68,68,.06); }
|
||
|
||
.tag { background: var(--bg4); color: var(--text2); padding: 2px 9px; border-radius: 10px; font-size: 11px; font-family: 'SF Mono', Menlo, monospace; }
|
||
.badge { padding: 2px 9px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.badge.green { background: rgba(34,197,94,.15); color: var(--green); }
|
||
.badge.blue { background: rgba(59,130,246,.15); color: var(--accent); }
|
||
.badge.yellow { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||
.badge.red { background: rgba(239,68,68,.15); color: var(--red); }
|
||
.badge.gray { background: var(--bg4); color: var(--text2); }
|
||
|
||
.method { font-weight: 600; font-size: 11px; padding: 2px 7px; border-radius: 5px; }
|
||
.method.GET { background: rgba(34,197,94,.15); color: var(--green); }
|
||
.method.POST { background: rgba(59,130,246,.15); color: var(--accent); }
|
||
.method.PUT, .method.PATCH { background: rgba(245,158,11,.15); color: var(--yellow); }
|
||
.method.DELETE { background: rgba(239,68,68,.15); color: var(--red); }
|
||
|
||
.verdict-banner {
|
||
display: flex; align-items: center; gap: 14px; padding: 20px;
|
||
border-radius: 12px; margin-bottom: 22px; border: 1px solid;
|
||
}
|
||
.verdict-banner.clean { background: rgba(34,197,94,.07); border-color: rgba(34,197,94,.3); }
|
||
.verdict-banner.warn { background: rgba(239,68,68,.07); border-color: rgba(239,68,68,.3); }
|
||
.verdict-banner .big { font-size: 20px; font-weight: 700; }
|
||
.verdict-banner .msg { font-size: 13px; color: var(--text2); }
|
||
|
||
.btn {
|
||
background: var(--bg3); border: 1px solid var(--border2); color: var(--text);
|
||
border-radius: 7px; padding: 6px 13px; cursor: pointer; font-family: inherit;
|
||
font-size: 12px; font-weight: 500; transition: all .15s; white-space: nowrap;
|
||
}
|
||
.btn:hover { background: var(--bg4); }
|
||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||
.btn-primary:hover { background: var(--accent2); }
|
||
.btn-danger { background: var(--red); border-color: var(--red); color: #fff; }
|
||
.btn-danger:hover { background: #dc2626; }
|
||
.btn-ghost { background: transparent; }
|
||
.btn-xs { padding: 4px 10px; font-size: 11px; }
|
||
.fw-input {
|
||
background: var(--bg3); border: 1px solid var(--border2); border-radius: 7px;
|
||
padding: 6px 11px; color: var(--text); font-family: inherit; font-size: 12px; min-width: 180px;
|
||
}
|
||
.fw-input:focus { outline: none; border-color: var(--accent); }
|
||
|
||
.empty { padding: 34px; text-align: center; color: var(--text3); font-size: 13px; }
|
||
.error-line { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text2); padding: 7px 18px; border-bottom: 1px solid var(--bg3); white-space: pre-wrap; }
|
||
.shimmer { color: var(--text3); text-align: center; padding: 36px; animation: pulse 1.2s infinite; font-size: 13px; }
|
||
@keyframes pulse { 0%,100% { opacity: .35; } 50% { opacity: 1; } }
|
||
|
||
.chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 20px 16px; }
|
||
.footer { text-align: center; padding: 18px; color: var(--text3); font-size: 11px; }
|
||
a { color: var(--accent); text-decoration: none; }
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 4px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside class="sidebar">
|
||
<div class="brand">
|
||
<div class="logo">D</div>
|
||
<div>
|
||
<div class="name">DockVisor</div>
|
||
<div class="sub">Firewall Monitor</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="nav-group-label">Monitor</div>
|
||
<div class="nav-item active" id="nav-overview" onclick="switchView('overview')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg>
|
||
Overview
|
||
</div>
|
||
<div class="nav-item" id="nav-firewall" onclick="switchView('firewall')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||
Firewall Control
|
||
</div>
|
||
<div class="nav-item" id="nav-intrusion" onclick="switchView('intrusion')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4l3 3"/></svg>
|
||
Intrusion Watch
|
||
</div>
|
||
<div class="nav-item" id="nav-web" onclick="switchView('web')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||
Web Traffic
|
||
</div>
|
||
|
||
<div class="nav-group-label">Servers</div>
|
||
<div class="servers" id="sidebar-servers"><div class="server-row" style="color:var(--text3)">Loading…</div></div>
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="refresh-row" style="margin-bottom:8px">
|
||
<span>Auto-refresh</span>
|
||
<select id="interval" onchange="setInterval(this.value)">
|
||
<option value="15">15s</option>
|
||
<option value="30" selected>30s</option>
|
||
<option value="60">1m</option>
|
||
<option value="300">5m</option>
|
||
</select>
|
||
</div>
|
||
<button class="btn btn-ghost btn-xs" onclick="refreshAll()" style="width:100%">↻ Refresh now</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN -->
|
||
<main class="main">
|
||
<div class="topbar">
|
||
<div>
|
||
<h1 id="page-title">Overview</h1>
|
||
<div class="subtitle" id="page-subtitle">Security & firewall status across all servers</div>
|
||
</div>
|
||
<div class="actions">
|
||
<span class="clock" id="clock"></span>
|
||
<button class="btn btn-primary" onclick="refreshAll()">Refresh</button>
|
||
</div>
|
||
</div>
|
||
<div class="content" id="view-container"><div class="shimmer">Loading…</div></div>
|
||
</main>
|
||
|
||
<!-- IP logs modal -->
|
||
<div id="ip-modal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.65);z-index:100;align-items:center;justify-content:center">
|
||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;max-width:720px;width:92%;max-height:80vh;display:flex;flex-direction:column">
|
||
<div style="padding:16px 20px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||
<h3 id="ip-modal-title" style="font-size:15px;font-weight:600">Connection attempts</h3>
|
||
<button class="btn btn-ghost btn-xs" onclick="closeIpModal()">✕ Close</button>
|
||
</div>
|
||
<div id="ip-modal-body" style="padding:16px 20px;overflow-y:auto;font-family:'SF Mono',Menlo,monospace;font-size:12px"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let currentView = 'overview';
|
||
let timer = null;
|
||
let cachedFirewall = [];
|
||
|
||
const TITLES = {
|
||
overview: ['Overview', 'Security & firewall status across all servers'],
|
||
firewall: ['Firewall Control', 'Manage blocks, countries, and IPs per server'],
|
||
intrusion: ['Intrusion Watch', 'Live VPN connection attempts — geo-located'],
|
||
web: ['Web Traffic', 'Apache proxy requests and client IPs'],
|
||
};
|
||
|
||
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||
|
||
function switchView(v) {
|
||
currentView = v;
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
document.getElementById(`nav-${v}`).classList.add('active');
|
||
document.getElementById('page-title').textContent = TITLES[v][0];
|
||
document.getElementById('page-subtitle').textContent = TITLES[v][1];
|
||
renderView();
|
||
}
|
||
|
||
function renderView() {
|
||
const c = document.getElementById('view-container');
|
||
c.innerHTML = '<div class="shimmer">Loading…</div>';
|
||
if (currentView === 'overview') renderOverview();
|
||
else if (currentView === 'firewall') loadFirewall();
|
||
else if (currentView === 'intrusion') loadIntrusion();
|
||
else if (currentView === 'web') loadWeb();
|
||
}
|
||
|
||
function refreshAll() {
|
||
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
||
loadServers();
|
||
renderView();
|
||
}
|
||
|
||
async function loadServers() {
|
||
try {
|
||
const s = await fetch('/api/firewall').then(r => r.json());
|
||
cachedFirewall = s;
|
||
document.getElementById('sidebar-servers').innerHTML = s.map(x =>
|
||
`<div class="server-row"><span class="dot ${x.reachable ? 'online' : 'offline'}"></span>${esc(x.name.split(' ')[0])}</div>`
|
||
).join('');
|
||
} catch(e) {}
|
||
}
|
||
|
||
function setInterval(sec) {
|
||
if (timer) clearInterval(timer);
|
||
timer = setInterval(refreshAll, sec * 1000);
|
||
}
|
||
|
||
/* ===== OVERVIEW ===== */
|
||
async function renderOverview() {
|
||
const c = document.getElementById('view-container');
|
||
c.innerHTML = '<div class="shimmer">Loading overview…</div>';
|
||
try {
|
||
const [status, fw, sec, vpsStats, webStats] = await Promise.all([
|
||
fetch('/api/security/status').then(r => r.json()),
|
||
fetch('/api/firewall').then(r => r.json()),
|
||
fetch('/api/security').then(r => r.json()),
|
||
fetch('/api/vps/stats').then(r => r.json()),
|
||
fetch('/api/stats').then(r => r.json()),
|
||
]);
|
||
cachedFirewall = fw;
|
||
const clean = status.verdict === 'clean';
|
||
const ssh = status.ssh || {};
|
||
const nodes = status.headscale_nodes || [];
|
||
const nc = status.nextcloud || {};
|
||
const fl = sec.flagged || {};
|
||
const onlineNodes = nodes.filter(n => n.online).length;
|
||
const topCountry = sec.countries && sec.countries[0];
|
||
|
||
const serverCards = fw.map(s => `
|
||
<div class="card">
|
||
<div class="icon ${s.reachable ? 'green' : 'red'}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg></div>
|
||
<div class="label">${s.reachable ? '🟢' : '🔴'} ${esc(s.name)}</div>
|
||
<div class="value">${(s.country_cidrs + s.scanner_ips).toLocaleString()}</div>
|
||
<div class="sub">${s.country_block ? '🌍' : ''}${s.country_cidrs.toLocaleString()} CIDRs · ${s.scanner_block ? '🛡️' : ''}${s.scanner_ips} IPs</div>
|
||
</div>`).join('');
|
||
|
||
c.innerHTML = `
|
||
<div class="verdict-banner ${clean ? 'clean' : 'warn'}">
|
||
<div style="font-size:32px">${clean ? '✅' : '⚠️'}</div>
|
||
<div>
|
||
<div class="big">${clean ? 'No signs of compromise' : 'Review needed'}</div>
|
||
<div class="msg">${(status.warnings || []).join(' · ') || 'All systems verified clean'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-header"><h3>🖥️ Servers</h3><span class="count">${fw.length} monitored</span></div>
|
||
<div class="grid" style="padding:18px;margin-bottom:0">${serverCards}</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">VPN Nodes</div><div class="value">${onlineNodes}/${nodes.length}</div><div class="sub">online / authorized</div></div>
|
||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">SSH Logins</div><div class="value">${ssh.total_accepted ?? 0}</div><div class="sub">${ssh.total_failed ?? 0} failed</div></div>
|
||
<div class="card"><div class="icon purple"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg></div><div class="label">Nextcloud Users</div><div class="value">${(nc.users || []).length}</div><div class="sub">${(nc.users || []).join(', ')}</div></div>
|
||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Intrusion Attempts</div><div class="value">${(sec.total_attempts || 0).toLocaleString()}</div><div class="sub">${sec.unique_ips || 0} IPs · top: ${topCountry ? esc(topCountry.country) : '—'}</div></div>
|
||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attacks</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${(fl.countries || []).join(', ')}</div></div>
|
||
<div class="card"><div class="icon cyan"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">VPS Web Requests</div><div class="value">${(vpsStats.total || 0).toLocaleString()}</div><div class="sub">${vpsStats.errors_4xx || 0} 4xx · ${vpsStats.errors_5xx || 0} 5xx</div></div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-header"><h3>🛡️ Security Details</h3><span class="count">live</span></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Check</th><th>Detail</th></tr></thead>
|
||
<tbody>
|
||
<tr><td style="font-weight:500">SSH source IPs</td><td style="color:var(--text2)">${(ssh.accepted || []).slice(0,6).map(a => `${esc(a.ip)} ×${a.count}`).join(', ') || 'none'}</td></tr>
|
||
<tr><td style="font-weight:500">VPN nodes</td><td style="color:var(--text2)">${nodes.map(n => `${esc(n.given_name || n.name)} → ${(n.ip_addresses || []).map(esc).join(', ')}`).join(' | ') || 'none'}</td></tr>
|
||
<tr><td style="font-weight:500">Top attacker countries</td><td style="color:var(--text2)">${(sec.countries || []).slice(0,6).map(x => `${esc(x.country)} (${x.count})`).join(', ') || 'none'}</td></tr>
|
||
<tr><td style="font-weight:500">Nextcloud failed logins</td><td style="color:var(--text2)">${(nc.failed_logins || []).slice(0,6).map(f => `${esc(f.ip || '?')} (${esc(f.user || '?')})`).join(', ') || 'none'}</td></tr>
|
||
</tbody>
|
||
</table></div>
|
||
</div>
|
||
`;
|
||
} catch(e) {
|
||
c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* ===== FIREWALL ===== */
|
||
async function loadFirewall() {
|
||
const c = document.getElementById('view-container');
|
||
c.innerHTML = '<div class="shimmer">Loading firewall…</div>';
|
||
try {
|
||
const servers = await fetch('/api/firewall').then(r => r.json());
|
||
cachedFirewall = servers;
|
||
renderFirewall(servers, c);
|
||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function renderFirewall(servers, c) {
|
||
c.innerHTML = servers.map((s, i) => `
|
||
<div class="section">
|
||
<div class="section-header">
|
||
<h3>${s.reachable ? '🟢' : '🔴'} ${esc(s.name)} <span style="font-weight:400;color:var(--text3);font-size:12px">(${esc(s.host)})</span></h3>
|
||
<span class="count">${s.reachable ? '' : 'unreachable'}</span>
|
||
</div>
|
||
<div style="padding:18px 20px">
|
||
<div class="grid" style="margin-bottom:16px">
|
||
<div class="card"><div class="label">Country Block</div><div class="value" style="color:${s.country_block ? 'var(--green)' : 'var(--text3)'}">${s.country_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.country_cidrs} CIDRs</div></div>
|
||
<div class="card"><div class="label">Scanner Block</div><div class="value" style="color:${s.scanner_block ? 'var(--green)' : 'var(--text3)'}">${s.scanner_block ? 'ACTIVE' : 'OFF'}</div><div class="sub">${s.scanner_ips} IPs</div></div>
|
||
<div class="card"><div class="label">Total Blocked</div><div class="value">${s.country_cidrs + s.scanner_ips}</div><div class="sub">combined rules</div></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
|
||
<button class="btn ${s.country_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'country',${!s.country_block})">${s.country_block ? '⏸ Disable Country' : '▶ Enable Country'}</button>
|
||
<button class="btn ${s.scanner_block ? 'btn-danger' : 'btn-primary'}" onclick="toggleFw(${i},'scanner',${!s.scanner_block})">${s.scanner_block ? '⏸ Disable Scanner' : '▶ Enable Scanner'}</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<input id="fw-ip-${i}" class="fw-input" placeholder="IP to block/unblock">
|
||
<button class="btn" onclick="blockIp(${i})">Block IP</button>
|
||
<button class="btn" onclick="unblockIp(${i})">Unblock IP</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('') || '<div class="empty">No servers configured</div>';
|
||
}
|
||
|
||
async function toggleFw(i, type, enabled) {
|
||
await fetch(`/api/firewall/${i}/toggle`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ type, enabled }) });
|
||
loadFirewall();
|
||
}
|
||
async function blockIp(i) {
|
||
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
|
||
if (!ip) return;
|
||
await fetch(`/api/firewall/${i}/block`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
|
||
loadFirewall();
|
||
}
|
||
async function unblockIp(i) {
|
||
const ip = document.getElementById(`fw-ip-${i}`).value.trim();
|
||
if (!ip) return;
|
||
await fetch(`/api/firewall/${i}/unblock`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ ip }) });
|
||
loadFirewall();
|
||
}
|
||
|
||
/* ===== INTRUSION ===== */
|
||
async function loadIntrusion() {
|
||
const c = document.getElementById('view-container');
|
||
c.innerHTML = '<div class="shimmer">Scanning intrusion attempts…</div>';
|
||
try {
|
||
const sec = await fetch('/api/security').then(r => r.json());
|
||
renderIntrusion(sec, c);
|
||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function renderIntrusion(sec, c) {
|
||
if (sec.error) { c.innerHTML = `<div class="empty">⚠️ ${esc(sec.error)}</div>`; return; }
|
||
const top = sec.countries[0];
|
||
const fl = sec.flagged || {};
|
||
c.innerHTML = `
|
||
<div class="grid">
|
||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">Failed Attempts</div><div class="value">${sec.total_attempts}</div><div class="sub">last ${sec.window_hours}h</div></div>
|
||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div><div class="label">Unique Source IPs</div><div class="value">${sec.unique_ips}</div><div class="sub">all failed handshakes</div></div>
|
||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></div><div class="label">Top Country</div><div class="value">${top ? esc(top.country) : '—'}</div><div class="sub">${top ? top.count + ' hits' : ''}</div></div>
|
||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg></div><div class="label">Flagged Attempts</div><div class="value">${fl.attempts ?? 0}</div><div class="sub">${fl.ips ?? 0} IPs · ${(fl.countries || []).join(', ')}</div></div>
|
||
</div>
|
||
<div class="section">
|
||
<div class="section-header"><h3>🌍 Source Countries</h3><span class="count">${sec.countries.length} distinct</span></div>
|
||
<div class="chips">${sec.countries.slice(0,24).map(x => `<span class="tag">${esc(x.country)} · ${x.count}</span>`).join('')}</div>
|
||
</div>
|
||
<div class="section">
|
||
<div class="section-header"><h3>🚨 Intrusion Sources</h3><span class="count">${sec.unique_ips} IPs · <a href="/api/threats/export" download style="color:var(--accent)">⬇ Export CSV</a></span></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Source IP</th><th>Country</th><th>Organization</th><th>Attempts</th><th>First</th><th>Last</th></tr></thead>
|
||
<tbody>${sec.attempts.map(a => `
|
||
<tr class="${a.flagged ? 'flagged' : ''}">
|
||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showIpLogs('${a.ip}')">${esc(a.ip)}</span></td>
|
||
<td>${a.flagged ? '🔴 ' : ''}${a.cc ? `<span class="badge yellow">${esc(a.cc)}</span> ` : ''}${esc(a.country)}</td>
|
||
<td style="color:var(--text2);max-width:260px;overflow:hidden;text-overflow:ellipsis">${esc(a.org)}</td>
|
||
<td><b>${a.count}</b></td>
|
||
<td style="color:var(--text3);font-size:12px">${esc(a.first)}</td>
|
||
<td style="color:var(--text3);font-size:12px">${esc(a.last)}</td>
|
||
</tr>`).join('') || '<tr><td class="empty" colspan="6">No intrusion attempts</td></tr>'}
|
||
</tbody>
|
||
</table></div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/* ===== WEB TRAFFIC ===== */
|
||
async function loadWeb() {
|
||
const c = document.getElementById('view-container');
|
||
c.innerHTML = '<div class="shimmer">Loading web traffic…</div>';
|
||
try {
|
||
const [vpsStats, vpsLogs, stats, logs, errors] = await Promise.all([
|
||
fetch('/api/vps/stats').then(r => r.json()),
|
||
fetch('/api/vps/logs?lines=300').then(r => r.json()),
|
||
fetch('/api/stats').then(r => r.json()),
|
||
fetch('/api/logs?lines=200').then(r => r.json()),
|
||
fetch('/api/errors?lines=20').then(r => r.json()),
|
||
]);
|
||
renderWeb(vpsStats, vpsLogs, stats, logs, errors, c);
|
||
} catch(e) { c.innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`; }
|
||
}
|
||
|
||
function statusBadge(s) {
|
||
const cls = s.startsWith('5') ? 'red' : s.startsWith('4') ? 'yellow' : s.startsWith('3') ? 'blue' : 'green';
|
||
return `<span class="badge ${cls}">${s}</span>`;
|
||
}
|
||
|
||
function renderWeb(vpsStats, vpsLogs, stats, logs, errors, c) {
|
||
const vs = vpsStats.error ? null : vpsStats;
|
||
const vr = Array.isArray(vpsLogs) ? vpsLogs : [];
|
||
const vhosts = stats.vhosts || [];
|
||
const hits = Object.fromEntries((stats.per_vhost || []).map(v => [v.vhost, v.count]));
|
||
c.innerHTML = `
|
||
<div class="section">
|
||
<div class="section-header"><h3>🌐 Public Web Server — ubuntu VPS (100.64.0.2)</h3><span class="count">Apache</span></div>
|
||
${vs ? `
|
||
<div class="grid" style="padding:18px 20px 0">
|
||
<div class="card"><div class="icon blue"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div><div class="label">Recent Requests</div><div class="value">${vs.total}</div><div class="sub">sampled</div></div>
|
||
<div class="card"><div class="icon green"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></div><div class="label">Unique Clients</div><div class="value">${vs.unique_ips}</div><div class="sub">distinct IPs</div></div>
|
||
<div class="card"><div class="icon yellow"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">4xx</div><div class="value">${vs.errors_4xx}</div><div class="sub">client errors</div></div>
|
||
<div class="card"><div class="icon red"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div><div class="label">5xx</div><div class="value">${vs.errors_5xx}</div><div class="sub">server errors</div></div>
|
||
</div>
|
||
` : `<div class="empty">❌ ${esc(vpsStats.error)}</div>`}
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th><th>Bytes</th></tr></thead>
|
||
<tbody>${vr.map(r => `<tr>
|
||
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
|
||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
|
||
<td><span class="method ${r.method}">${r.method}</span></td>
|
||
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
|
||
<td>${statusBadge(r.status)}</td>
|
||
<td style="color:var(--text2)">${esc(r.bytes)}</td>
|
||
</tr>`).join('') || '<tr><td class="empty" colspan="6">No requests</td></tr>'}</tbody>
|
||
</table></div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-header"><h3>🔀 Internal Proxy — ubuntu-web (.65)</h3><span class="count">apache-proxy-manager</span></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Domain</th><th>Target</th><th>SSL</th><th>Hits</th></tr></thead>
|
||
<tbody>${vhosts.map(v => `<tr><td style="font-weight:500">${esc(v.domain)}</td><td><span class="tag">${esc(v.target)}</span></td><td>${v.ssl ? '<span class="badge green">HTTPS</span>' : '<span class="badge blue">HTTP</span>'}</td><td>${hits[v.domain] || 0}</td></tr>`).join('') || '<tr><td class="empty" colspan="4">No proxies.json</td></tr>'}</tbody>
|
||
</table></div>
|
||
<div class="table-wrap"><table>
|
||
<thead><tr><th>Time</th><th>Client IP</th><th>Method</th><th>Path</th><th>Status</th></tr></thead>
|
||
<tbody>${(logs || []).map(r => `<tr>
|
||
<td style="color:var(--text3);font-size:12px">${esc(r.ts.split(' ')[0])}</td>
|
||
<td><span class="tag" style="cursor:pointer;text-decoration:underline;color:var(--accent)" onclick="showApacheLogs('${r.ip}')">${esc(r.ip)}</span></td>
|
||
<td><span class="method ${r.method}">${r.method}</span></td>
|
||
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis">${esc(r.path)}</td>
|
||
<td>${statusBadge(r.status)}</td>
|
||
</tr>`).join('') || '<tr><td class="empty" colspan="5">No requests</td></tr>'}</tbody>
|
||
</table></div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="section-header"><h3>⚠️ Internal Error Log</h3><span class="count">${errors.length} lines</span></div>
|
||
${errors.length ? errors.map(e => `<div class="error-line">${esc(e)}</div>`).join('') : '<div class="empty">No errors</div>'}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function geoHeader(ip, g) {
|
||
const geo = (g && g.country) ? `<span class="tag">${esc(g.country)}</span> <span class="tag">${esc(g.org || g.as || '')}</span>` : '';
|
||
return `<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;gap:10px;flex-wrap:wrap">
|
||
<div>${geo}</div>
|
||
<button class="btn btn-danger btn-xs" onclick="blockIpModal('${ip}')">🛡️ Block IP</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function blockIpModal(ip) {
|
||
try {
|
||
const r = await fetch(`/api/block/${ip}`, { method: 'POST' }).then(r => r.json());
|
||
const msg = r.map(x => `${x.server}: ${x.result.status || x.result.error || '?'}`).join(' · ');
|
||
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin',
|
||
`<div style="padding:10px 14px;border-radius:8px;background:rgba(34,197,94,.12);color:var(--green);margin-bottom:12px;font-size:12px">🛡️ ${esc(msg)}</div>`);
|
||
} catch(e) {
|
||
document.getElementById('ip-modal-body').insertAdjacentHTML('afterbegin', `<div class="empty">❌ ${esc(e.message)}</div>`);
|
||
}
|
||
}
|
||
|
||
/* ===== IP LOGS MODAL ===== */
|
||
async function showIpLogs(ip) {
|
||
document.getElementById('ip-modal-title').textContent = `Connection attempts from ${ip}`;
|
||
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
|
||
document.getElementById('ip-modal').style.display = 'flex';
|
||
try {
|
||
const [d, g] = await Promise.all([
|
||
fetch(`/api/security/ip/${ip}`).then(r => r.json()),
|
||
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
|
||
]);
|
||
if (d.error) {
|
||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(d.error)}</div>`;
|
||
return;
|
||
}
|
||
const rows = (d.attempts || []).map(a =>
|
||
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(a.time)}</span> — <span style="color:var(--red)">${esc(a.event)}</span></div>`
|
||
).join('');
|
||
document.getElementById('ip-modal-body').innerHTML =
|
||
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${d.count}</b> attempts (last 7 days, max 500 shown)</div>${rows || '<div class="empty">No attempts found</div>'}`;
|
||
} catch(e) {
|
||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function closeIpModal() {
|
||
document.getElementById('ip-modal').style.display = 'none';
|
||
}
|
||
|
||
async function showApacheLogs(ip) {
|
||
document.getElementById('ip-modal-title').textContent = `Apache requests from ${ip}`;
|
||
document.getElementById('ip-modal-body').innerHTML = '<div class="shimmer">Loading…</div>';
|
||
document.getElementById('ip-modal').style.display = 'flex';
|
||
try {
|
||
const [local, vps, g] = await Promise.all([
|
||
fetch(`/api/logs/ip/${ip}`).then(r => r.json()),
|
||
fetch(`/api/vps/logs/ip/${ip}`).then(r => r.json()),
|
||
fetch(`/api/geo/${ip}`).then(r => r.json()).catch(() => ({})),
|
||
]);
|
||
const all = [...(Array.isArray(local) ? local : []), ...(Array.isArray(vps) ? vps : [])];
|
||
const rows = all.map(r =>
|
||
`<div style="padding:5px 0;border-bottom:1px solid var(--bg3)"><span style="color:var(--text3)">${esc(r.ts)}</span> — <span class="method ${r.method}" style="font-size:11px">${r.method}</span> <span style="color:var(--accent)">${esc(r.path)}</span> <span class="badge ${r.status.startsWith('5') ? 'red' : r.status.startsWith('4') ? 'yellow' : 'green'}">${r.status}</span> <span style="color:var(--text3)">${esc(r.bytes || '')}</span></div>`
|
||
).join('');
|
||
document.getElementById('ip-modal-body').innerHTML =
|
||
`${geoHeader(ip, g)}<div style="margin-bottom:12px;color:var(--text2)"><b>${all.length}</b> requests</div>${rows || '<div class="empty">No Apache requests for this IP</div>'}`;
|
||
} catch(e) {
|
||
document.getElementById('ip-modal-body').innerHTML = `<div class="empty">❌ ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
/* ===== INIT ===== */
|
||
document.getElementById('clock').textContent = new Date().toLocaleTimeString();
|
||
setInterval(() => { document.getElementById('clock').textContent = new Date().toLocaleTimeString(); }, 1000);
|
||
loadServers();
|
||
renderView();
|
||
setInterval(30);
|
||
</script>
|
||
</body>
|
||
</html>
|