Add Security Center — multi-source event pipeline with dashboard UI
Introduces a unified security event store and HTTP API that ingests events from any of the configured sources (API audit, Caddy access log, fail2ban, shared_bans, future remote agents) and surfaces them in the dashboard. New files: src/security/event-store.js JSONL-backed store + in-memory query index src/security/host-registry.js Registered hosts with per-host API keys src/security/event-workers.js Tail-followers for Caddy/fail2ban/shared_bans logs routes/security.js Events, hosts, ingest, SSE stream endpoints status/js/security-center.js Dashboard modal with Overview/Events/Hosts tabs SECURITY-FEATURE.md Full feature documentation DEAD-CODE.md, DUP-CODE.md, HARDENING.md Prior audits Modified: src/app.js Mount /api/v1/security/* src/utilities/middleware.js Add ingest endpoints to PUBLIC_ROUTES src/security/audit-logger.js Mirror audit events into security store server.js Start security workers on boot status/build.js Bundle security-center.js status/index.html Add Security button to nav
This commit is contained in:
@@ -56,6 +56,7 @@ const bundles = {
|
||||
JS('compose-import.js'),
|
||||
JS('container-exec.js'),
|
||||
JS('audit-log.js'),
|
||||
JS('security-center.js'),
|
||||
JS('weather.js'),
|
||||
JS('clock.js'),
|
||||
JS('card-badges.js'),
|
||||
|
||||
@@ -205,6 +205,7 @@
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
// ========== SECURITY CENTER ==========
|
||||
// Multi-source security event viewer + host registry UI.
|
||||
//
|
||||
// Shows live events from any source (API audit, Caddy access log, fail2ban,
|
||||
// shared_bans) on any registered host (this DashCaddy, future remote agents).
|
||||
//
|
||||
// Live-tail uses SSE (/api/v1/security/events/stream).
|
||||
(function() {
|
||||
injectModal('security-modal', `<div id="security-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
||||
<h3>🛡️ Security Center</h3>
|
||||
<p class="modal-subtitle">
|
||||
Live events from API, Caddy, fail2ban & shared_bans. Live-tail via SSE.
|
||||
</p>
|
||||
|
||||
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
|
||||
<button class="sec-tab active" data-tab="overview">Overview</button>
|
||||
<button class="sec-tab" data-tab="events">Events</button>
|
||||
<button class="sec-tab" data-tab="hosts">Hosts</button>
|
||||
</div>
|
||||
|
||||
<!-- OVERVIEW TAB -->
|
||||
<div class="sec-panel" data-panel="overview">
|
||||
<div class="sec-stats" id="sec-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px;">
|
||||
<div class="sec-stat" data-key="total">— events</div>
|
||||
<div class="sec-stat" data-key="warn">— warnings</div>
|
||||
<div class="sec-stat" data-key="error">— errors</div>
|
||||
<div class="sec-stat" data-key="denied">— denied</div>
|
||||
<div class="sec-stat" data-key="hosts">— hosts</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
||||
<div>
|
||||
<h4 style="margin:8px 0 6px;">Top Actors (24h)</h4>
|
||||
<div id="sec-top-actors" class="scroll-container" style="max-height:240px;">—</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin:8px 0 6px;">Top Targets (24h)</h4>
|
||||
<div id="sec-top-targets" class="scroll-container" style="max-height:240px;">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EVENTS TAB -->
|
||||
<div class="sec-panel" data-panel="events" style="display:none;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
|
||||
<select id="sec-filter-source" class="sec-filter">
|
||||
<option value="">All sources</option>
|
||||
<option value="api">API</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
<option value="fail2ban">fail2ban</option>
|
||||
<option value="shared-bans">shared_bans</option>
|
||||
<option value="agent">Agent</option>
|
||||
</select>
|
||||
<select id="sec-filter-severity" class="sec-filter">
|
||||
<option value="">All severities</option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="error">error</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="notice">notice</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
<select id="sec-filter-host" class="sec-filter">
|
||||
<option value="">All hosts</option>
|
||||
</select>
|
||||
<input id="sec-filter-actor" class="sec-filter" placeholder="actor (IP or user)" style="padding:6px 10px;">
|
||||
<button id="sec-refresh-btn" class="btn-sm">🔄 Refresh</button>
|
||||
<label style="margin-left:auto;display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="sec-live-tail" checked>
|
||||
<span>Live tail</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="sec-events-container" class="scroll-container" style="max-height:480px;">Loading…</div>
|
||||
</div>
|
||||
|
||||
<!-- HOSTS TAB -->
|
||||
<div class="sec-panel" data-panel="hosts" style="display:none;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
||||
<button id="sec-host-register-btn" class="btn-sm">➕ Register Host</button>
|
||||
<button id="sec-hosts-refresh" class="btn-sm">🔄 Refresh</button>
|
||||
</div>
|
||||
<div id="sec-hosts-container" class="scroll-container" style="max-height:480px;">Loading…</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="sec-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
// ============= STATE =============
|
||||
const modal = document.getElementById('security-modal');
|
||||
const openBtn = document.getElementById('security-center-btn');
|
||||
const cancelBtn = document.getElementById('sec-cancel');
|
||||
const tabs = modal.querySelectorAll('.sec-tab');
|
||||
const panels = modal.querySelectorAll('.sec-panel');
|
||||
|
||||
let allEvents = []; // newest first
|
||||
let allHosts = [];
|
||||
let sseSource = null;
|
||||
|
||||
// ============= TAB SWITCHING =============
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach(t => t.classList.toggle('active', t === tab));
|
||||
panels.forEach(p => p.style.display = p.dataset.panel === tab.dataset.tab ? '' : 'none');
|
||||
if (tab.dataset.tab === 'overview') refreshOverview();
|
||||
if (tab.dataset.tab === 'events') refreshEvents();
|
||||
if (tab.dataset.tab === 'hosts') refreshHosts();
|
||||
});
|
||||
});
|
||||
|
||||
// ============= OPEN/CLOSE =============
|
||||
if (openBtn) {
|
||||
openBtn.addEventListener('click', () => {
|
||||
modal.classList.add('show');
|
||||
refreshOverview();
|
||||
startLiveTail();
|
||||
});
|
||||
}
|
||||
cancelBtn.addEventListener('click', closeModal);
|
||||
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
|
||||
function closeModal() {
|
||||
modal.classList.remove('show');
|
||||
stopLiveTail();
|
||||
}
|
||||
|
||||
// ============= LIVE TAIL (SSE) =============
|
||||
function startLiveTail() {
|
||||
stopLiveTail();
|
||||
if (!document.getElementById('sec-live-tail').checked) return;
|
||||
if (typeof EventSource === 'undefined') return; // browser doesn't support
|
||||
try {
|
||||
sseSource = new EventSource('/api/v1/security/events/stream');
|
||||
sseSource.addEventListener('init', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
allEvents = data.events || [];
|
||||
renderEvents();
|
||||
} catch {}
|
||||
});
|
||||
sseSource.addEventListener('security', (e) => {
|
||||
try {
|
||||
const ev = JSON.parse(e.data);
|
||||
allEvents.unshift(ev);
|
||||
if (allEvents.length > 500) allEvents.length = 500;
|
||||
// Auto-refresh whichever panel is showing
|
||||
const active = modal.querySelector('.sec-tab.active')?.dataset?.tab;
|
||||
if (active === 'events') renderEvents();
|
||||
else if (active === 'overview') refreshOverview();
|
||||
} catch {}
|
||||
});
|
||||
sseSource.onerror = () => { /* browser auto-reconnects */ };
|
||||
} catch (e) {
|
||||
console.warn('[security] SSE failed:', e.message);
|
||||
}
|
||||
}
|
||||
function stopLiveTail() {
|
||||
if (sseSource) { try { sseSource.close(); } catch {} sseSource = null; }
|
||||
}
|
||||
document.getElementById('sec-live-tail').addEventListener('change', () => {
|
||||
if (modal.classList.contains('show')) startLiveTail();
|
||||
});
|
||||
|
||||
// ============= OVERVIEW =============
|
||||
async function refreshOverview() {
|
||||
try {
|
||||
const since = new Date(Date.now() - 24*60*60*1000).toISOString();
|
||||
const [statsRes, hostsRes, eventsRes] = await Promise.all([
|
||||
fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(since)}`),
|
||||
fetch('/api/v1/security/hosts'),
|
||||
fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(since)}`),
|
||||
]);
|
||||
const stats = (await statsRes.json()).data || {};
|
||||
const hosts = (await hostsRes.json()).data?.hosts || [];
|
||||
const eventsCount = (await eventsRes.json()).data?.total || 0;
|
||||
|
||||
document.querySelector('#sec-stats [data-key="total"]').textContent = `${eventsCount} events (24h)`;
|
||||
document.querySelector('#sec-stats [data-key="warn"]').textContent = `${stats.by_severity?.warn || 0} warnings`;
|
||||
document.querySelector('#sec-stats [data-key="error"]').textContent = `${stats.by_severity?.error || 0} errors`;
|
||||
document.querySelector('#sec-stats [data-key="denied"]').textContent = `${stats.by_outcome?.denied || 0} denied`;
|
||||
document.querySelector('#sec-stats [data-key="hosts"]').textContent = `${hosts.length} hosts`;
|
||||
|
||||
renderTopList('sec-top-actors', stats.top_actors || []);
|
||||
renderTopList('sec-top-targets', stats.top_targets || []);
|
||||
} catch (e) {
|
||||
console.warn('[security] refreshOverview failed:', e.message);
|
||||
}
|
||||
}
|
||||
function renderTopList(id, items) {
|
||||
const el = document.getElementById(id);
|
||||
if (!items.length) { el.innerHTML = '<div class="panel-empty">No data</div>'; return; }
|
||||
el.innerHTML = '<table style="width:100%;font-size:0.85rem;">' +
|
||||
items.map(it => `<tr><td style="padding:3px 0;word-break:break-all;">${escapeHtml(String(it.key))}</td><td style="text-align:right;color:var(--muted);">${it.count}</td></tr>`).join('') +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
// ============= EVENTS =============
|
||||
const filterSource = document.getElementById('sec-filter-source');
|
||||
const filterSeverity = document.getElementById('sec-filter-severity');
|
||||
const filterHost = document.getElementById('sec-filter-host');
|
||||
const filterActor = document.getElementById('sec-filter-actor');
|
||||
const refreshBtn = document.getElementById('sec-refresh-btn');
|
||||
|
||||
[filterSource, filterSeverity, filterHost].forEach(el => el.addEventListener('change', refreshEvents));
|
||||
filterActor.addEventListener('input', debounce(refreshEvents, 250));
|
||||
refreshBtn.addEventListener('click', refreshEvents);
|
||||
|
||||
async function refreshEvents() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', '200');
|
||||
if (filterSource.value) params.set('source_type', filterSource.value);
|
||||
if (filterSeverity.value) params.set('severity', filterSeverity.value);
|
||||
if (filterHost.value) params.set('source_host', filterHost.value);
|
||||
if (filterActor.value) params.set('actor_prefix', filterActor.value);
|
||||
|
||||
const res = await fetch(`/api/v1/security/events?${params}`);
|
||||
const data = (await res.json()).data;
|
||||
allEvents = data.events || [];
|
||||
renderEvents();
|
||||
|
||||
// Refresh host dropdown if we don't have it yet
|
||||
if (!filterHost.options.length || filterHost.options.length === 1) {
|
||||
await refreshHostFilter();
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('sec-events-container').innerHTML = '<div class="panel-empty">Load failed: '+escapeHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const el = document.getElementById('sec-events-container');
|
||||
if (!allEvents.length) { el.innerHTML = '<div class="panel-empty">No events</div>'; return; }
|
||||
el.innerHTML = allEvents.slice(0, 200).map(renderEventRow).join('');
|
||||
}
|
||||
|
||||
function renderEventRow(ev) {
|
||||
const sev = ev.severity || 'info';
|
||||
const sevColor = {
|
||||
critical: '#c0392b', error: '#e74c3c', warn: '#f39c12',
|
||||
notice: '#3498db', info: '#7f8c8d',
|
||||
}[sev] || '#7f8c8d';
|
||||
const time = ev.ts ? new Date(ev.ts).toLocaleTimeString() : '';
|
||||
const source = ev.source_type || '';
|
||||
const actor = ev.actor || '—';
|
||||
const target = ev.target || '';
|
||||
const action = ev.action || '';
|
||||
const outcome = ev.outcome || '';
|
||||
return `<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${sevColor};font-weight:600;">${escapeHtml(sev)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${escapeHtml(source)}</span>
|
||||
<span title="${escapeHtml(actor)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(actor)}</span>
|
||||
<span title="${escapeHtml(target)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(action)} ${escapeHtml(target)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${escapeHtml(outcome)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${escapeHtml(time)}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function refreshHostFilter() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/security/hosts');
|
||||
const hosts = (await res.json()).data?.hosts || [];
|
||||
const current = filterHost.value;
|
||||
filterHost.innerHTML = '<option value="">All hosts</option>' +
|
||||
hosts.map(h => `<option value="${escapeHtml(h.id)}">${escapeHtml(h.label || h.id)}</option>`).join('');
|
||||
if (current) filterHost.value = current;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ============= HOSTS =============
|
||||
document.getElementById('sec-host-register-btn').addEventListener('click', registerHostPrompt);
|
||||
document.getElementById('sec-hosts-refresh').addEventListener('click', refreshHosts);
|
||||
|
||||
async function refreshHosts() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/security/hosts');
|
||||
const hosts = (await res.json()).data?.hosts || [];
|
||||
allHosts = hosts;
|
||||
const el = document.getElementById('sec-hosts-container');
|
||||
if (!hosts.length) { el.innerHTML = '<div class="panel-empty">No hosts registered. Click ➕ Register Host to add one.</div>'; return; }
|
||||
el.innerHTML = hosts.map(h => {
|
||||
const status = !h.enabled ? '🔴 disabled'
|
||||
: !h.last_seen_at ? '⚪ registered'
|
||||
: (Date.now() - Date.parse(h.last_seen_at) > 30*60*1000) ? '🟡 stale'
|
||||
: '🟢 online';
|
||||
return `<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${escapeHtml(h.label || h.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${escapeHtml(h.type)}</span>
|
||||
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
||||
id: ${escapeHtml(h.id)} ·
|
||||
registered ${new Date(h.registered_at).toLocaleDateString()} ·
|
||||
last seen ${h.last_seen_at ? new Date(h.last_seen_at).toLocaleString() : 'never'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${status}</span>
|
||||
${h.id === 'self' ? '' : `<button class="btn-sm sec-host-del" data-id="${escapeHtml(h.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
el.querySelectorAll('.sec-host-del').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm(`Remove host ${btn.dataset.id}? Events already received will remain in the store.`)) return;
|
||||
await fetch(`/api/v1/security/hosts/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
|
||||
refreshHosts();
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('sec-hosts-container').innerHTML = '<div class="panel-empty">Load failed: '+escapeHtml(e.message)+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function registerHostPrompt() {
|
||||
const id = prompt('Host id (lowercase, no spaces):');
|
||||
if (!id) return;
|
||||
const label = prompt('Display label:', id) || id;
|
||||
const type = prompt('Type ("dashcaddy", "service", or "agent"):', 'agent') || 'agent';
|
||||
try {
|
||||
const res = await fetch('/api/v1/security/hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, label, type }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) { alert('Failed: ' + (data?.error?.message || res.statusText)); return; }
|
||||
// Show the api_key ONCE in a dialog
|
||||
alert(`✅ Host registered!\n\nid: ${data.data.host.id}\nlabel: ${data.data.host.label}\ntype: ${data.data.host.type}\n\n🔑 API KEY (save this NOW — won't be shown again):\n\n${data.data.api_key}\n\nSend this key as: Authorization: Bearer <api_key>\nTo endpoint: POST /api/v1/security/events/ingest or /events/batch`);
|
||||
refreshHosts();
|
||||
} catch (e) {
|
||||
alert('Failed: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ============= HELPERS =============
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
}
|
||||
function debounce(fn, ms) {
|
||||
let t; return function() { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); };
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user