diff --git a/dashcaddy-api/routes/log-insights.js b/dashcaddy-api/routes/log-insights.js new file mode 100644 index 0000000..8476173 --- /dev/null +++ b/dashcaddy-api/routes/log-insights.js @@ -0,0 +1,153 @@ +const express = require('express'); +const fs = require('fs').promises; + +module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) { + const router = express.Router(); + + // GET /api/v1/log-insights โ Plain English summary of who's doing what + router.get('/log-insights', asyncHandler(async (req, res) => { + const hours = parseInt(req.query.hours) || 24; + const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + + // --- Collect data --- + const auditEntries = await auditLogger.query({ limit: 10000 }); + const recentAudit = auditEntries.filter(e => e.timestamp >= since); + + let securityEvents = []; + try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {} + + // --- Analyze IPs --- + const ipMap = {}; + recentAudit.forEach(e => { + const ip = e.ip || 'unknown'; + if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 }; + const s = ipMap[ip]; + s.count++; + const cat = (e.action || 'unknown').split('.')[0]; + s.actions[cat] = (s.actions[cat] || 0) + 1; + if (e.resource) s.resources.add(e.resource); + if (e.timestamp < s.first) s.first = e.timestamp; + if (e.timestamp > s.last) s.last = e.timestamp; + if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++; + }); + + // --- Build plain-English insights --- + const insights = []; + const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count); + + // Heavy users + ipArray.slice(0, 3).forEach(([ip, s]) => { + const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0]; + insights.push({ + severity: s.count > 500 ? 'warning' : 'info', + title: ip + ' โ ' + s.count + ' requests in ' + hours + 'h', + plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' + + (s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.' + }); + }); + + // Auth failures + const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length; + if (totalFailures > 5) { + insights.push({ + severity: totalFailures > 50 ? 'warning' : 'info', + title: totalFailures + ' failed actions', + plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' + + (totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '') + }); + } + + // Security events + const secBySev = {}; + securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; }); + if (secBySev.critical || secBySev.error) { + insights.push({ + severity: 'warning', + title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts', + plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.' + }); + } + + // Quiet / nothing + if (insights.length === 0) { + insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' }); + } + + // --- Storage info --- + const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json'; + const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl'; + let storage = {}; + try { + const a = await fs.stat(auditPath); + storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length }; + } catch {} + try { + const s = await fs.stat(secPath); + storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length }; + } catch {} + + ok(res, { + period: { hours, since, until: new Date().toISOString() }, + summary: { + totalRequests: recentAudit.length, + uniqueIPs: ipArray.length, + securityEvents: securityEvents.length, + failedActions: totalFailures + }, + topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({ + ip: ip, + count: s.count, + failures: s.failures, + topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3), + activeFrom: s.first, + lastSeen: s.last + })), + insights: insights, + storage: storage + }); + })); + + // POST /api/v1/log-insights/dispose โ Preview then confirm cleanup + router.post('/log-insights/dispose', asyncHandler(async (req, res) => { + const keepDays = parseInt(req.body.keepDays) || 30; + const confirm = req.body.confirm === true; + const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString(); + + const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json'; + const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl'; + + const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; }); + const auditData = JSON.parse(auditRaw); + const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; }); + + const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; }); + const secLines = secRaw.split('\n').filter(Boolean); + const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } }); + + if (!confirm) { + ok(res, { + preview: true, + message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.', + wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, + cutoffDate: cutoff + }); + return; + } + + // Execute cleanup + const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; }); + await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2)); + + const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } }); + await fs.writeFile(secPath, keptSec.join('\n') + '\n'); + + ok(res, { + disposed: true, + deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, + remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length }, + cutoffDate: cutoff + }); + })); + + return router; +}; diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index 834ded4..ca751ef 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events'); const workflowsRoutes = require('../routes/workflows'); const dependenciesRoutes = require('../routes/dependencies'); const securityRoutes = require('../routes/security'); +const logInsightsRoutes = require('../routes/log-insights'); const billingRoutes = require('../routes/billing'); const DependencyManager = require('./managers/dependency-manager'); const autoRestartRoutes = require('../routes/auto-restart'); @@ -753,6 +754,20 @@ async function createApp() { apiRouter.use('/security', securityRoutes({ log: ctx.log, })); + + // Log Insights โ plain English activity summary + safe log disposal + apiRouter.use(logInsightsRoutes({ + asyncHandler: ctx.asyncHandler, + ok: ctx.ok, + auditLogger: ctx.auditLogger, + securityEventStore: (function() { + try { + var getStore = require('./security/event-store').getStore; + return getStore(); + } catch (e) { return null; } + })() + })); + apiRouter.use('/dependencies', dependenciesRoutes({ dependencyManager: ctx.dependencyManager, servicesStateManager: ctx.servicesStateManager, diff --git a/status/index.html b/status/index.html index 69cdc65..78aec2a 100644 --- a/status/index.html +++ b/status/index.html @@ -206,6 +206,7 @@ + @@ -950,6 +951,7 @@ + diff --git a/status/js/log-insights.js b/status/js/log-insights.js new file mode 100644 index 0000000..1c31562 --- /dev/null +++ b/status/js/log-insights.js @@ -0,0 +1,184 @@ +// ========== LOG INSIGHTS PANEL ========== +(function() { + injectModal('log-insights-modal', `
| IP Address | Requests | Failures | Top Actions | Last Seen |
|---|---|---|---|---|
| ' + ip.ip + ' | ' + + '' + ip.count + ' | ' + + '' + ip.failures + ' | ' + + '' + actions + ' | ' + + '' + lastSeen + ' | ' + + '