Files
dashcaddy/status/js/security-center.js
T
hermes c9d067c2f0
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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
2026-07-13 02:28:56 -07:00

343 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========== 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); };
}
})();