373 lines
35 KiB
Python
373 lines
35 KiB
Python
from __future__ import annotations
|
|
|
|
import hmac
|
|
import json
|
|
import threading
|
|
import urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from typing import Callable
|
|
|
|
from .config import Config
|
|
from .maintenance import clear_suricata_logs
|
|
from .rules import RuleManager
|
|
from .state import RuntimeStats
|
|
from .store import AlertStore
|
|
|
|
DASHBOARD = r'''<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>RouterOS Suricata TZSP</title>
|
|
<style>
|
|
:root{color-scheme:dark}*{box-sizing:border-box}html{scroll-behavior:smooth}body{font-family:system-ui,-apple-system,sans-serif;margin:0;background:#111827;color:#e5e7eb}main{max-width:1320px;margin:auto;padding:20px 24px 34px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap}h1{margin:0 0 6px}h2{margin:4px 0 14px}h3{margin:0 0 10px;font-size:15px}.muted{color:#9ca3af;font-size:13px}.ok{color:#34d399}.bad{color:#f87171}.warn{color:#fbbf24}.off{color:#9ca3af}.menu{position:sticky;top:0;z-index:20;display:flex;gap:7px;flex-wrap:wrap;margin:18px -6px;padding:10px 6px;background:rgba(17,24,39,.96);backdrop-filter:blur(8px);border-bottom:1px solid #273449}.menu button{background:transparent}.menu button.active{background:#273449;border-color:#6b7280}.view{display:none}.view.active{display:block}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:0 0 16px}.card,.panel,.tablebox{background:#1f2937;border:1px solid #374151;border-radius:10px}.card,.panel{padding:14px}.tablebox{overflow:hidden}.value{font-size:26px;font-weight:700}.badge{display:inline-block;border:1px solid #4b5563;border-radius:999px;padding:3px 8px;font-size:11px;text-transform:uppercase;letter-spacing:.04em}table{width:100%;border-collapse:collapse;background:#1f2937}th,td{padding:9px;border-bottom:1px solid #374151;text-align:left;font-size:13px;vertical-align:top}th{color:#9ca3af}tr:last-child td{border-bottom:0}.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(390px,1fr));gap:12px}.stack{display:grid;gap:12px}.section-title{display:flex;justify-content:space-between;align-items:center;gap:10px;margin:0 0 10px}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin:8px 0}button,input,textarea{font:inherit}button{background:#273449;color:#e5e7eb;border:1px solid #4b5563;border-radius:7px;padding:7px 10px;cursor:pointer}button:hover{border-color:#6b7280}button.danger{border-color:#7f1d1d;color:#fecaca}button.small{padding:3px 7px;font-size:11px}input,textarea{background:#111827;color:#e5e7eb;border:1px solid #4b5563;border-radius:7px;padding:8px}input{min-width:260px}textarea{width:100%;min-height:220px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;resize:vertical}details{background:#1f2937;border:1px solid #374151;border-radius:10px;padding:12px}summary{cursor:pointer;font-weight:650}.notice{padding:9px 11px;border:1px solid #374151;border-radius:8px;margin:10px 0;font-size:13px;display:none}.nowrap{white-space:nowrap}.count{font-weight:700}.right{text-align:right}.spacer{height:12px}.hint{padding:10px 12px;border-left:3px solid #4b5563;background:#172033;border-radius:6px;font-size:13px;color:#cbd5e1}@media(max-width:760px){main{padding:14px}.grid2{grid-template-columns:1fr}.tablebox{overflow-x:auto}.menu{top:0;margin-left:-2px;margin-right:-2px}.cards{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
|
</style>
|
|
</head>
|
|
<body><main>
|
|
<div class="top"><div><h1>RouterOS Suricata TZSP</h1><div class="muted">TZSP → TAP → Suricata → EVE JSON → SQLite</div></div><div id="status">Loading…</div></div>
|
|
<nav class="menu" aria-label="Dashboard sections">
|
|
<button data-view="overview" onclick="showSection('overview')">Overview</button>
|
|
<button data-view="incidents" onclick="showSection('incidents')">Incidents</button>
|
|
<button data-view="statistics" onclick="showSection('statistics')">Statistics</button>
|
|
<button data-view="system" onclick="showSection('system')">System</button>
|
|
<button data-view="rules" onclick="showSection('rules')">Rules</button>
|
|
<button data-view="maintenance" onclick="showSection('maintenance')">Maintenance</button>
|
|
</nav>
|
|
<div id="notice" class="notice"></div>
|
|
|
|
<section id="view-overview" class="view">
|
|
<div class="cards">
|
|
<div class="card"><div class="muted">TZSP datagrams</div><div id="tzsp" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Frames to TAP</div><div id="frames" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Alert hits</div><div id="alerts" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Incidents</div><div id="incidents" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Alerts / 24h</div><div id="alerts24h" class="value">0</div></div>
|
|
<div class="card"><div class="muted">RouterOS blocks</div><div id="blocked" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Filtered noise</div><div id="filtered" class="value">0</div></div>
|
|
<div class="card"><div class="muted">Deduplicated</div><div id="dedup" class="value">0</div></div>
|
|
</div>
|
|
<div class="grid2">
|
|
<div class="panel"><h3>Detection profile</h3><div id="tuningText" class="muted">Loading tuning configuration…</div></div>
|
|
<div class="panel"><h3>Rules in the image</h3><div id="rulesSummary" class="muted">Loading rule status…</div></div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<div class="hint">The reserved self-test SID 1000001 only matches the explicit TZSP test payload and is filtered from the incident database. Production detections use separate SIDs.</div>
|
|
</section>
|
|
|
|
<section id="view-incidents" class="view">
|
|
<div class="section-title"><h2>Recent incidents</h2><span class="muted">Repeated matches are aggregated into one incident window.</span></div>
|
|
<div class="tablebox"><table><thead><tr><th>Last seen</th><th>Hits</th><th>Severity</th><th>Signature</th><th>Source</th><th>Destination</th><th>Action</th><th></th></tr></thead><tbody id="rows"></tbody></table></div>
|
|
</section>
|
|
|
|
<section id="view-statistics" class="view">
|
|
<h2>Extended statistics</h2>
|
|
<div class="grid2">
|
|
<div class="tablebox"><table><thead><tr><th colspan="5">Top signatures / 24h</th></tr><tr><th>SID</th><th>Signature</th><th>Severity</th><th>Hits</th><th></th></tr></thead><tbody id="signatureRows"></tbody></table></div>
|
|
<div class="tablebox"><table><thead><tr><th colspan="3">Top sources / 24h</th></tr><tr><th>Source</th><th>Hits</th><th>Last seen</th></tr></thead><tbody id="sourceRows"></tbody></table></div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<div class="grid2">
|
|
<div class="tablebox"><table><thead><tr><th colspan="3">Top destinations / 24h</th></tr><tr><th>Destination</th><th>Hits</th><th>Last seen</th></tr></thead><tbody id="destinationRows"></tbody></table></div>
|
|
<div class="tablebox"><table><thead><tr><th colspan="2">Severity distribution</th></tr><tr><th>Severity</th><th class="right">Hits</th></tr></thead><tbody id="severityRows"></tbody></table></div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<div class="grid2">
|
|
<div class="tablebox"><table><thead><tr><th>Sensor counter</th><th class="right">Value</th></tr></thead><tbody id="runtimeRows"></tbody></table></div>
|
|
<div class="tablebox"><table><thead><tr><th>Suricata counter</th><th class="right">Value</th></tr></thead><tbody id="suricataRows"></tbody></table></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="view-system" class="view">
|
|
<h2>System status</h2>
|
|
<div class="stack">
|
|
<div class="tablebox"><table><thead><tr><th>Component</th><th>Status</th><th>Details</th></tr></thead><tbody id="serviceRows"></tbody></table></div>
|
|
<div><h2>Ports</h2><div class="tablebox"><table><thead><tr><th>Service</th><th>Direction</th><th>Protocol</th><th>Address</th><th>Port</th><th>Status</th></tr></thead><tbody id="portRows"></tbody></table></div></div>
|
|
<div class="panel"><h3>Database & storage</h3><div id="storageText" class="muted">Loading database/storage state…</div></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="view-rules" class="view">
|
|
<h2>Rules & signature feeds</h2>
|
|
<div class="panel"><div class="muted">The image contains an ET/Open snapshot plus conservative local production rules. Downloaded feeds and their enabled-source configuration are persisted in <code>/var/lib/suricata</code>. Every downloaded ruleset is validated with <code>suricata -T</code> before it replaces the last known-good rules.</div><div class="toolbar"><input id="adminTokenRules" type="password" placeholder="Admin token"><button onclick="saveTokenFrom('adminTokenRules')">Use token</button><button onclick="loadRules()">Load rule editors</button><button onclick="reloadRules()">Reload rules</button></div></div>
|
|
<div class="spacer"></div>
|
|
<div class="panel">
|
|
<div class="section-title"><h3>Signature sources</h3><span id="sourceMeta" class="muted">Load the OISF source catalog to manage feeds.</span></div>
|
|
<div class="muted">The table is populated by <code>suricata-update list-sources --free</code> from the official OISF source index. ET/Open is the default feed. Other free feeds can be enabled individually; sources requiring parameters are shown but are not enabled blindly from the UI.</div>
|
|
<div class="toolbar"><button onclick="loadRuleSources()">Load sources</button><button onclick="refreshRuleSources()">Refresh OISF catalog</button><button onclick="updateRules()">Download / update active signatures</button><input id="sourceFilter" type="search" placeholder="Filter sources" oninput="renderRuleSources()"></div>
|
|
<div class="tablebox"><table><thead><tr><th>Source</th><th>Vendor</th><th>License</th><th>Tags</th><th>Status</th><th>Action</th></tr></thead><tbody id="ruleSourceRows"><tr><td colspan="6" class="muted">Source catalog not loaded yet.</td></tr></tbody></table></div>
|
|
</div>
|
|
<div class="spacer"></div>
|
|
<div class="stack">
|
|
<details open><summary>Custom Suricata signatures</summary><p class="muted">Use SIDs 1001000+ for site-specific detections. Built-in production rules are maintained by the image.</p><textarea id="customRules" spellcheck="false" placeholder='alert http $HOME_NET any -> $EXTERNAL_NET any (msg:"LOCAL SITE example"; ...; sid:1001000; rev:1;)'></textarea><div class="toolbar"><button onclick="saveCustomRules()">Validate, save & reload</button></div></details>
|
|
<details><summary>threshold.config / suppressions</summary><p class="muted">Global suppress removes alerts for a SID. Prefer source/destination-scoped suppression or rate limits when only one host is noisy.</p><textarea id="thresholdConfig" spellcheck="false"></textarea><div class="toolbar"><button onclick="saveThresholds()">Validate, save & reload</button></div></details>
|
|
</div>
|
|
</section>
|
|
|
|
<section id="view-maintenance" class="view">
|
|
<h2>Maintenance</h2>
|
|
<div class="panel">
|
|
<div class="muted">Destructive actions require <code>ADMIN_TOKEN</code>. The token is kept only in this browser session.</div>
|
|
<div class="toolbar"><input id="adminToken" type="password" placeholder="Admin token"><button onclick="saveTokenFrom('adminToken')">Use token</button></div>
|
|
<div class="toolbar"><button class="danger" onclick="clearAlerts()">Clear alerts</button><button class="danger" onclick="clearLogs()">Clear Suricata logs</button><button onclick="vacuumDb()">Compact SQLite</button><button onclick="resetCounters()">Reset runtime counters</button></div>
|
|
</div>
|
|
</section>
|
|
|
|
<script>
|
|
function valueOrDash(v){return (v===null||v===undefined||v==='')?'-':String(v)}
|
|
function esc(v){return valueOrDash(v).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
function stateClass(v){v=String(v||'').toLowerCase();if(v==='up'||v==='ok'||v==='running'||v==='configured')return'ok';if(v==='down'||v==='error'||v==='degraded')return'bad';if(v==='disabled'||v==='not configured'||v==='development')return'off';return'warn'}
|
|
function stateBadge(v){return `<span class="badge ${stateClass(v)}">${esc(v)}</span>`}
|
|
function bytes(v){v=Number(v||0);const u=['B','KiB','MiB','GiB'];let i=0;while(v>=1024&&i<u.length-1){v/=1024;i++}return `${v.toFixed(i?1:0)} ${u[i]}`}
|
|
function fmtTime(v){if(!v)return'-';const d=new Date(v);if(Number.isNaN(d.getTime()))return valueOrDash(v);return d.toLocaleString(undefined,{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'})}
|
|
function endpoint(ip,port){const base=valueOrDash(ip);return (port===null||port===undefined||port==='')?base:`${base}:${port}`}
|
|
function incidentActions(x){const sid=Number(x.signature_id)||0;let source=String(x.src_ip||'');if(!/^[0-9A-Fa-f:.]+$/.test(source))source='';const scoped=source?`<button class="small" onclick="suppressSid(${sid},'by_src','${source}')">Mute source</button>`:'';return `<div class="toolbar">${scoped}<button class="small" onclick="suppressSid(${sid})">Suppress SID</button></div>`}
|
|
function token(){return sessionStorage.getItem('adminToken')||''}
|
|
function syncTokens(){for(const id of ['adminToken','adminTokenRules']){const e=document.getElementById(id);if(e)e.value=token()}}
|
|
function saveTokenFrom(id){sessionStorage.setItem('adminToken',document.getElementById(id).value);syncTokens();notice('Admin token stored for this browser session.','ok');if(id==='adminTokenRules')loadRuleSources()}
|
|
function notice(text,kind='warn'){const n=document.getElementById('notice');if(!n)return;n.style.display='block';n.className='notice '+kind;n.textContent=text}
|
|
function showSection(name,updateHash=true){const valid=['overview','incidents','statistics','system','rules','maintenance'];if(!valid.includes(name))name='overview';document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.querySelectorAll('.menu button').forEach(x=>x.classList.toggle('active',x.dataset.view===name));document.getElementById('view-'+name).classList.add('active');if(updateHash)history.replaceState(null,'','#'+name);if(name==='rules'&&token()&&!ruleSourcesLoaded)setTimeout(loadRuleSources,0)}
|
|
async function api(url,opts={}){const headers=Object.assign({'Accept':'application/json'},opts.headers||{});if(opts.admin)headers['X-Admin-Token']=token();if(opts.body)headers['Content-Type']='application/json';const r=await fetch(url,Object.assign({},opts,{headers}));let data={};try{data=await r.json()}catch(_){}if(!r.ok)throw new Error(data.error||`HTTP ${r.status}`);return data}
|
|
async function adminPost(url,body={}){return api(url,{method:'POST',body:JSON.stringify(body),admin:true})}
|
|
function severityClass(v){v=Number(v);return v===1?'bad':v===2?'warn':'off'}
|
|
let ruleSources=[];let ruleSourcesLoaded=false;
|
|
function renderRuleSources(){const q=String(document.getElementById('sourceFilter')?.value||'').trim().toLowerCase();const rows=ruleSources.filter(s=>!q||[s.name,s.vendor,s.summary,(s.tags||[]).join(' ')].join(' ').toLowerCase().includes(q)).map(s=>{const status=s.default?'<span class="badge ok">default active</span>':s.enabled?'<span class="badge ok">enabled</span>':'<span class="badge off">disabled</span>';let action='';if(s.default){action='<button class="small" onclick="updateRules()">Update now</button>'}else if(!s.can_toggle){action='<span class="muted">Needs parameters: '+esc((s.parameters||[]).join(', '))+'</span>'}else{action=`<button class="small" onclick="toggleRuleSource('${s.name}',${!s.enabled})">${s.enabled?'Disable + rebuild':'Enable + download'}</button>`}return `<tr><td><b>${esc(s.name)}</b><br><span class="muted">${esc(s.summary)}</span></td><td>${esc(s.vendor)}</td><td>${esc(s.license)}</td><td>${esc((s.tags||[]).join(', '))}</td><td>${status}</td><td>${action}</td></tr>`}).join('');document.getElementById('ruleSourceRows').innerHTML=rows||'<tr><td colspan="6" class="muted">No matching free sources.</td></tr>'}
|
|
function runtimeTable(runtime){const keys=[['tzsp_to_tap_loss','TZSP → TAP loss'],['tzsp_decode_errors','TZSP decode errors'],['tzsp_unsupported','TZSP unsupported'],['inject_errors','TAP inject errors'],['eve_events','EVE events'],['eve_parse_errors','EVE parse errors'],['alerts_filtered','Filtered alerts'],['alerts_filtered_ignored_sid','Filtered reserved/test SID'],['alerts_deduplicated','Deduplicated alerts'],['block_attempts','Block attempts'],['block_success','Block successes'],['block_errors','Block errors'],['block_success_rate','Block success %']];return keys.map(([k,n])=>`<tr><td>${esc(n)}</td><td class="right">${esc(runtime[k]??0)}</td></tr>`).join('')}
|
|
function suricataTable(runtime){const s=runtime.suricata||{};const preferred=['decoder.pkts','decoder.bytes','capture.kernel_packets','capture.kernel_drops','detect.alert','flow.memuse','tcp.sessions','tcp.reassembly_gap'];let rows=[];for(const k of preferred){if(k in s)rows.push([k,s[k]])}if(!rows.length)rows=Object.entries(s).slice(0,8);return rows.map(([k,v])=>`<tr><td>${esc(k)}</td><td class="right">${esc(v)}</td></tr>`).join('')||'<tr><td colspan="2" class="muted">No EVE stats event received yet.</td></tr>'}
|
|
async function refresh(){
|
|
try{
|
|
const [statusData,summary,analyticsData,alertsData,config]=await Promise.all([api('/api/status'),api('/api/summary'),api('/api/stats'),api('/api/alerts?limit=100'),api('/api/config')]);
|
|
const analytics=analyticsData.analytics||{};
|
|
if(statusData.dev_mode)document.getElementById('status').innerHTML='<span class="warn">Development mode: Web UI only</span>';else if(statusData.status==='ok')document.getElementById('status').innerHTML='<span class="ok">System operational</span>';else document.getElementById('status').innerHTML='<span class="bad">System degraded</span>';
|
|
const rt=statusData.runtime||{};document.getElementById('tzsp').textContent=valueOrDash(rt.tzsp_datagrams);document.getElementById('frames').textContent=valueOrDash(rt.frames_injected);document.getElementById('alerts').textContent=valueOrDash(summary.total_alerts);document.getElementById('incidents').textContent=valueOrDash(summary.incidents);document.getElementById('alerts24h').textContent=valueOrDash(analytics.alerts_24h);document.getElementById('blocked').textContent=valueOrDash(summary.blocked_alerts);document.getElementById('filtered').textContent=valueOrDash(rt.alerts_filtered);document.getElementById('dedup').textContent=valueOrDash(rt.alerts_deduplicated);
|
|
const db=statusData.database||{},st=statusData.storage||{},rules=statusData.rules||{};
|
|
document.getElementById('tuningText').innerHTML=`Store severities <b>1-${esc(config.alert_max_severity)}</b>; aggregate identical SID/source/destination/protocol/destination-port for <b>${esc(config.alert_dedup_window_seconds)}s</b>; retention <b>${esc(config.alert_retention_days)} days</b>. Auto-block: <b>${config.auto_block?'enabled':'disabled'}</b>.`;
|
|
document.getElementById('rulesSummary').innerHTML=`Built-in local detections: <b>${esc(rules.builtin_rule_count||0)}</b>; custom detections: <b>${esc(rules.custom_rule_count||0)}</b>; threshold/suppress entries: <b>${esc(rules.threshold_entry_count||0)}</b>; vendor rules: <b>${esc(bytes(rules.vendor_rules_size_bytes||0))}</b>, last installed <b>${esc(fmtTime(rules.vendor_rules_updated_at))}</b>. Source catalog: <b>${esc(fmtTime(rules.source_index_updated_at))}</b>.`;
|
|
document.getElementById('storageText').innerHTML=`SQLite <b>${esc(db.path)}</b>: ${esc(bytes(db.size_bytes))} + WAL ${esc(bytes(db.wal_size_bytes))}; schema v${esc(db.schema_version)}; ${esc(db.rows)} incident rows. Persistent filesystem <b>${esc(st.path)}</b>: ${esc(st.used_percent)}% used; Suricata logs ${esc(bytes(st.suricata_log_bytes))}; containerized: <b>${st.containerized?'yes':'no'}</b>; host <b>${esc(st.hostname)}</b>.`;
|
|
document.getElementById('serviceRows').innerHTML=Object.entries(statusData.services||{}).map(([name,item])=>`<tr><td>${esc(item.name||name)}</td><td>${stateBadge(item.status)}</td><td>${esc(item.details)}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No service status data.</td></tr>';
|
|
document.getElementById('portRows').innerHTML=(statusData.ports||[]).map(item=>`<tr><td>${esc(item.name)}</td><td>${esc(item.direction)}</td><td>${esc(item.protocol)}</td><td>${esc(item.address)}</td><td>${esc(item.port)}</td><td>${stateBadge(item.status)}</td></tr>`).join('')||'<tr><td colspan="6" class="muted">No port data.</td></tr>';
|
|
document.getElementById('runtimeRows').innerHTML=runtimeTable(rt);document.getElementById('suricataRows').innerHTML=suricataTable(rt);
|
|
document.getElementById('signatureRows').innerHTML=(analytics.top_signatures||[]).map(x=>`<tr><td>${esc(x.signature_id)}</td><td>${esc(x.signature)}</td><td><span class="${severityClass(x.severity)}">${esc(x.severity)}</span></td><td class="count">${esc(x.count)}</td><td><button class="small" onclick="suppressSid(${Number(x.signature_id)||0})">Suppress</button></td></tr>`).join('')||'<tr><td colspan="5" class="muted">No alerts in the last 24 hours.</td></tr>';
|
|
document.getElementById('sourceRows').innerHTML=(analytics.top_sources||[]).map(x=>`<tr><td>${esc(x.src_ip)}</td><td class="count">${esc(x.count)}</td><td>${esc(fmtTime(x.last_seen))}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No source statistics yet.</td></tr>';
|
|
document.getElementById('destinationRows').innerHTML=(analytics.top_destinations||[]).map(x=>`<tr><td>${esc(x.dest_ip)}</td><td class="count">${esc(x.count)}</td><td>${esc(fmtTime(x.last_seen))}</td></tr>`).join('')||'<tr><td colspan="3" class="muted">No destination statistics yet.</td></tr>';
|
|
document.getElementById('severityRows').innerHTML=Object.entries(summary.by_severity||{}).map(([severity,count])=>`<tr><td><span class="${severityClass(severity)}">Severity ${esc(severity)}</span></td><td class="right count">${esc(count)}</td></tr>`).join('')||'<tr><td colspan="2" class="muted">No severity statistics yet.</td></tr>';
|
|
document.getElementById('rows').innerHTML=(alertsData.alerts||[]).map(x=>`<tr><td class="nowrap">${esc(fmtTime(x.last_seen||x.timestamp))}<br><span class="muted">first ${esc(fmtTime(x.first_seen||x.timestamp))}</span></td><td class="count">${esc(x.hit_count||1)}</td><td><span class="${severityClass(x.severity)}">${esc(x.severity)}</span></td><td>${esc(x.signature)}<br><span class="muted">SID ${esc(x.signature_id)} · ${esc(x.category)}</span></td><td>${esc(endpoint(x.src_ip,x.src_port))}</td><td>${esc(endpoint(x.dest_ip,x.dest_port))}</td><td>${x.blocked?'<span class="bad">BLOCK '+esc(x.block_target)+'</span>':'<span class="muted">'+esc(x.block_reason)+'</span>'}</td><td>${incidentActions(x)}</td></tr>`).join('')||'<tr><td colspan="8" class="muted">No stored incidents. Low-priority/noisy events and the reserved self-test SID may be filtered before SQLite.</td></tr>';
|
|
}catch(err){document.getElementById('status').innerHTML='<span class="bad">Application unavailable</span>'}
|
|
}
|
|
async function action(url,body,success){try{const r=await adminPost(url,body);notice(r.message||success,'ok');await refresh()}catch(e){notice(e.message,'bad')}}
|
|
async function clearAlerts(){if(confirm('Delete all alert incidents from SQLite?'))await action('/api/admin/alerts/clear',{},'Alerts cleared.')}
|
|
async function clearLogs(){if(confirm('Truncate active Suricata log files?'))await action('/api/admin/logs/clear',{},'Logs cleared.')}
|
|
async function vacuumDb(){await action('/api/admin/database/vacuum',{},'Database compacted.')}
|
|
async function resetCounters(){await action('/api/admin/runtime/reset',{},'Runtime counters reset.')}
|
|
async function reloadRules(){await action('/api/admin/rules/reload',{},'Rule reload requested.')}
|
|
async function loadRuleSources(){try{const r=await api('/api/admin/rules/sources',{admin:true});ruleSources=r.sources||[];ruleSourcesLoaded=true;const st=r.status||{};document.getElementById('sourceMeta').textContent=`${ruleSources.length} free sources · ${r.enabled_sources?.length||0} active · vendor rules ${bytes(st.vendor_rules_size_bytes||0)} · installed ${fmtTime(st.vendor_rules_updated_at)}`;renderRuleSources()}catch(e){notice(e.message,'bad')}}
|
|
async function refreshRuleSources(){if(!confirm('Refresh the rule-source index from OISF now?'))return;await action('/api/admin/rules/sources/refresh',{},'OISF source catalog refreshed.');await loadRuleSources()}
|
|
async function updateRules(){if(!confirm('Download all active signature feeds, validate them and reload Suricata?'))return;await action('/api/admin/rules/update',{},'Active signature feeds updated.');await loadRuleSources()}
|
|
async function toggleRuleSource(name,enable){const verb=enable?'Enable and download':'Disable and rebuild without';if(!confirm(`${verb} ${name}?`))return;await action(`/api/admin/rules/sources/${enable?'enable':'disable'}`,{source:name},`${name} ${enable?'enabled':'disabled'}.`);await loadRuleSources()}
|
|
async function suppressSid(sid,track='',ip=''){if(!sid)return notice('Invalid SID','bad');const scoped=track&&ip;const prompt=scoped?`Suppress SID ${sid} only for source ${ip}?`:`Globally suppress Suricata SID ${sid}? This stops alerts for that SID.`;if(confirm(prompt))await action('/api/admin/rules/suppress',{sid,track,ip},scoped?`SID ${sid} muted for ${ip}.`:`SID ${sid} suppressed.`)}
|
|
async function loadRules(){try{const r=await api('/api/admin/rules',{admin:true});document.getElementById('customRules').value=r.custom_rules||'';document.getElementById('thresholdConfig').value=r.threshold_config||'';notice('Rule editors loaded.','ok')}catch(e){notice(e.message,'bad')}}
|
|
async function saveCustomRules(){await action('/api/admin/rules/custom',{content:document.getElementById('customRules').value},'Custom rules saved and reloaded.')}
|
|
async function saveThresholds(){await action('/api/admin/rules/thresholds',{content:document.getElementById('thresholdConfig').value},'Threshold configuration saved and reloaded.')}
|
|
syncTokens();showSection((location.hash||'#overview').slice(1),false);refresh();setInterval(refresh,4000);window.addEventListener('hashchange',()=>showSection((location.hash||'#overview').slice(1),false));
|
|
</script>
|
|
</main></body></html>'''
|
|
|
|
|
|
class WebServer:
|
|
def __init__(
|
|
self,
|
|
config: Config,
|
|
store: AlertStore,
|
|
health_provider: Callable[[], dict],
|
|
stats: RuntimeStats | None = None,
|
|
rule_manager: RuleManager | None = None,
|
|
) -> None:
|
|
self.config = config
|
|
self.store = store
|
|
self.health_provider = health_provider
|
|
self.stats = stats
|
|
self.rule_manager = rule_manager
|
|
self.server = ThreadingHTTPServer((config.web_bind, config.web_port), self._handler())
|
|
self.thread = threading.Thread(target=self.server.serve_forever, name="web-ui", daemon=True)
|
|
|
|
def _handler(self):
|
|
store = self.store
|
|
config = self.config
|
|
health_provider = self.health_provider
|
|
stats = self.stats
|
|
rule_manager = self.rule_manager
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
MAX_BODY = 1024 * 1024
|
|
|
|
def do_GET(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if parsed.path == "/":
|
|
self._send(200, DASHBOARD.encode("utf-8"), "text/html; charset=utf-8")
|
|
return
|
|
if parsed.path in {"/api/health", "/api/status"}:
|
|
self._json(health_provider())
|
|
return
|
|
if parsed.path == "/api/summary":
|
|
self._json(store.summary())
|
|
return
|
|
if parsed.path == "/api/stats":
|
|
self._json({"summary": store.summary(), "analytics": store.analytics()})
|
|
return
|
|
if parsed.path == "/api/config":
|
|
self._json(config.public_dict())
|
|
return
|
|
if parsed.path == "/api/alerts":
|
|
query = urllib.parse.parse_qs(parsed.query)
|
|
try:
|
|
limit = int(query.get("limit", ["100"])[0])
|
|
except ValueError:
|
|
limit = 100
|
|
self._json({"alerts": store.recent(limit)})
|
|
return
|
|
if parsed.path == "/api/admin/rules":
|
|
if not self._require_admin():
|
|
return
|
|
if rule_manager is None:
|
|
self._json({"error": "rule manager unavailable"}, status=503)
|
|
else:
|
|
self._json(rule_manager.content())
|
|
return
|
|
if parsed.path == "/api/admin/rules/sources":
|
|
if not self._require_admin():
|
|
return
|
|
if rule_manager is None:
|
|
self._json({"error": "rule manager unavailable"}, status=503)
|
|
else:
|
|
payload = rule_manager.source_catalog()
|
|
self._json(payload, status=200 if payload.get("ok") else 503)
|
|
return
|
|
self._json({"error": "not found"}, status=404)
|
|
|
|
def do_POST(self):
|
|
parsed = urllib.parse.urlparse(self.path)
|
|
if not parsed.path.startswith("/api/admin/"):
|
|
self._json({"error": "not found"}, status=404)
|
|
return
|
|
if not self._require_admin():
|
|
return
|
|
body = self._read_json()
|
|
if body is None:
|
|
return
|
|
|
|
if parsed.path == "/api/admin/alerts/clear":
|
|
count = store.clear_alerts()
|
|
self._json({"ok": True, "message": f"Deleted {count} incident rows"})
|
|
return
|
|
if parsed.path == "/api/admin/logs/clear":
|
|
result = clear_suricata_logs(config.eve_path)
|
|
self._json({"ok": True, "message": f"Cleared {len(result['files'])} log files; freed {result['bytes_freed']} bytes", **result})
|
|
return
|
|
if parsed.path == "/api/admin/database/vacuum":
|
|
store.vacuum()
|
|
self._json({"ok": True, "message": "SQLite VACUUM completed"})
|
|
return
|
|
if parsed.path == "/api/admin/runtime/reset":
|
|
if stats is None:
|
|
self._json({"error": "runtime stats unavailable"}, status=503)
|
|
else:
|
|
stats.reset()
|
|
self._json({"ok": True, "message": "Runtime counters reset"})
|
|
return
|
|
if parsed.path.startswith("/api/admin/rules/"):
|
|
if rule_manager is None:
|
|
self._json({"error": "rule manager unavailable"}, status=503)
|
|
return
|
|
if parsed.path == "/api/admin/rules/custom":
|
|
result = rule_manager.replace_custom_rules(str(body.get("content", "")))
|
|
elif parsed.path == "/api/admin/rules/thresholds":
|
|
result = rule_manager.replace_threshold_config(str(body.get("content", "")))
|
|
elif parsed.path == "/api/admin/rules/suppress":
|
|
try:
|
|
sid = int(body.get("sid"))
|
|
except (TypeError, ValueError):
|
|
self._json({"error": "valid SID is required"}, status=400)
|
|
return
|
|
result = rule_manager.suppress_sid(sid, str(body.get("track") or ""), body.get("ip"))
|
|
elif parsed.path == "/api/admin/rules/reload":
|
|
result = rule_manager.reload()
|
|
elif parsed.path == "/api/admin/rules/update":
|
|
result = rule_manager.update_vendor_rules()
|
|
elif parsed.path == "/api/admin/rules/sources/refresh":
|
|
result = rule_manager.refresh_source_catalog()
|
|
elif parsed.path in {"/api/admin/rules/sources/enable", "/api/admin/rules/sources/disable"}:
|
|
source_name = str(body.get("source") or "")
|
|
result = rule_manager.set_source_enabled(
|
|
source_name,
|
|
parsed.path.endswith("/enable"),
|
|
)
|
|
else:
|
|
self._json({"error": "not found"}, status=404)
|
|
return
|
|
self._json(
|
|
{"ok": result.ok, "message": result.message},
|
|
status=200 if result.ok else 400,
|
|
)
|
|
return
|
|
self._json({"error": "not found"}, status=404)
|
|
|
|
def _require_admin(self) -> bool:
|
|
if not config.admin_token:
|
|
self._json(
|
|
{"error": "admin actions are disabled; set ADMIN_TOKEN in the container environment"},
|
|
status=403,
|
|
)
|
|
return False
|
|
supplied = self.headers.get("X-Admin-Token", "")
|
|
if not hmac.compare_digest(supplied, config.admin_token):
|
|
self._json({"error": "invalid admin token"}, status=403)
|
|
return False
|
|
return True
|
|
|
|
def _read_json(self):
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
length = 0
|
|
if length < 0 or length > self.MAX_BODY:
|
|
self._json({"error": "request body too large"}, status=413)
|
|
return None
|
|
raw = self.rfile.read(length) if length else b"{}"
|
|
try:
|
|
data = json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
self._json({"error": "invalid JSON body"}, status=400)
|
|
return None
|
|
if not isinstance(data, dict):
|
|
self._json({"error": "JSON body must be an object"}, status=400)
|
|
return None
|
|
return data
|
|
|
|
def _json(self, obj, status: int = 200):
|
|
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
|
self._send(status, data, "application/json; charset=utf-8")
|
|
|
|
def _send(self, status: int, data: bytes, content_type: str):
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'")
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def log_message(self, fmt, *args):
|
|
return
|
|
|
|
return Handler
|
|
|
|
def start(self) -> None:
|
|
self.thread.start()
|
|
print(f"[web] dashboard on http://{self.config.web_bind}:{self.config.web_port}", flush=True)
|
|
|
|
def stop(self) -> None:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|