Add Security Center — multi-source event pipeline with dashboard UI
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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:
hermes
2026-07-13 02:28:56 -07:00
parent f405186eb8
commit c9d067c2f0
15 changed files with 2387 additions and 0 deletions
+1
View File
@@ -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'),
+1
View File
@@ -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>
+343
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function debounce(fn, ms) {
let t; return function() { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); };
}
})();