// ========== 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', `

πŸ›‘οΈ Security Center

β€” events
β€” warnings
β€” errors
β€” denied
β€” hosts

Top Actors (24h)

β€”

Top Targets (24h)

β€”
`); // ============= 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 = '
No data
'; return; } el.innerHTML = '' + items.map(it => ``).join('') + '
${escapeHtml(String(it.key))}${it.count}
'; } // ============= 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 = '
Load failed: '+escapeHtml(e.message)+'
'; } } function renderEvents() { const el = document.getElementById('sec-events-container'); if (!allEvents.length) { el.innerHTML = '
No events
'; 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 `
${escapeHtml(sev)} ${escapeHtml(source)} ${escapeHtml(actor)} ${escapeHtml(action)} ${escapeHtml(target)} ${escapeHtml(outcome)} ${escapeHtml(time)}
`; } 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 = '' + hosts.map(h => ``).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 = '
No hosts registered. Click βž• Register Host to add one.
'; 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 `
${escapeHtml(h.label || h.id)} ${escapeHtml(h.type)}
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'}
${status} ${h.id === 'self' ? '' : ``}
`; }).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 = '
Load failed: '+escapeHtml(e.message)+'
'; } } 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 \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); }; } })();