diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 15ab7ed1..14581520 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -1,6 +1,25 @@ # SecuBox UI & Theme History -_Last updated: 2026-03-16 (Quick Access QR Page)_ +_Last updated: 2026-03-16 (CrowdSec Heatmap)_ + +0. **CrowdSec Dashboard Geo Heatmap (2026-03-16)** + - NEW: Geo heatmap visualization showing threat origins on world map + - SVG-based world map with continent outlines + - Colored dots at country centroids (orange=local bans, cyan=CAPI, red=WAF) + - Dot size scales logarithmically with threat count + - Hover tooltips show country + count + - Legend showing all three data sources + - Files: `heatmap.js` (new), `overview.js`, `dashboard.css`, `luci.crowdsec-dashboard` + - Backend: Added `geo_local_raw` and `geo_capi_raw` fields to overview cache + - CAPI geo extraction from decisions with GeoIP metadata + +0. **CrowdSec Dashboard Fixes (2026-03-16)** + - Fixed alerts/scenarios/countries stats showing 0 despite active bans + - Fixed RPCD blocking causing LuCI crashes (async refresh_cache via background subshell) + - Fixed firewall bouncer HTTP 404 (wrong API URL in UCI config: 8090 → 8190) + - Fixed WAF bans showing 0 (grep for `mitmproxy-` prefix not specific scenario) + - Fixed JSON parsing errors (removed string wrapper from array fields) + - Added cron job for background cache refresh (every minute) 0. **SecuBox Quick Access QR Page (2026-03-16)** - QR code landing page deployed at https://quick.secubox.in/ diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/dashboard.css b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/dashboard.css index 5f3303ce..8580d02c 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/dashboard.css +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/dashboard.css @@ -119,7 +119,51 @@ .cs-geo-count { font-weight: 600; } .cs-country { font-size: 0.625rem; color: var(--cs-muted); } +/* Heatmap */ +.cs-heatmap-container { + position: relative; + width: 100%; + background: var(--kiss-bg2, #1a1d21); + border-radius: 8px; + overflow: hidden; +} +.cs-heatmap-svg { + display: block; + width: 100%; + height: auto; +} +.cs-heatmap-dot { + transition: transform 0.2s ease, opacity 0.2s ease; + cursor: pointer; +} +.cs-heatmap-dot:hover { + transform: scale(1.4); + opacity: 1 !important; +} +.cs-heatmap-legend { + display: flex; + justify-content: center; + gap: 24px; + padding: 12px 0; + background: rgba(0,0,0,0.2); + border-top: 1px solid var(--kiss-border, #2d3139); +} +.cs-heatmap-legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--kiss-muted, #8b949e); +} +.cs-heatmap-legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + @media (max-width: 768px) { .cs-stats { grid-template-columns: repeat(2, 1fr); } .cs-grid-2 { grid-template-columns: 1fr; } + .cs-heatmap-legend { flex-direction: column; align-items: center; gap: 8px; } } diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/heatmap.js b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/heatmap.js new file mode 100644 index 00000000..52b3a49f --- /dev/null +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/crowdsec-dashboard/heatmap.js @@ -0,0 +1,182 @@ +'use strict'; +'require baseclass'; + +/* global baseclass */ + +/** + * CrowdSec Heatmap Component + * Renders threat origins as colored dots on a world map + */ + +// Country centroids (approximate lat/long for major countries) +var CENTROIDS = { + 'US': [39.8, -98.5], 'CN': [35.0, 105.0], 'RU': [61.5, 105.0], + 'DE': [51.0, 9.0], 'FR': [46.0, 2.0], 'GB': [54.0, -2.0], + 'JP': [36.0, 138.0], 'BR': [-14.0, -51.0], 'IN': [20.0, 77.0], + 'AU': [-25.0, 133.0], 'CA': [56.0, -106.0], 'KR': [36.0, 128.0], + 'NL': [52.0, 5.5], 'IT': [42.0, 12.0], 'ES': [40.0, -4.0], + 'PL': [52.0, 19.0], 'UA': [49.0, 32.0], 'ID': [-5.0, 120.0], + 'TR': [39.0, 35.0], 'SA': [24.0, 45.0], 'MX': [23.0, -102.0], + 'TH': [15.0, 100.0], 'VN': [16.0, 108.0], 'PH': [13.0, 122.0], + 'SG': [1.3, 103.8], 'MY': [4.0, 101.0], 'HK': [22.3, 114.2], + 'TW': [23.5, 121.0], 'AR': [-34.0, -64.0], 'CL': [-33.0, -70.0], + 'CO': [4.0, -72.0], 'PE': [-10.0, -76.0], 'VE': [7.0, -66.0], + 'ZA': [-29.0, 24.0], 'EG': [27.0, 30.0], 'NG': [10.0, 8.0], + 'KE': [-1.0, 38.0], 'MA': [32.0, -6.0], 'IR': [32.0, 53.0], + 'PK': [30.0, 69.0], 'BD': [24.0, 90.0], 'SE': [62.0, 15.0], + 'NO': [62.0, 10.0], 'FI': [64.0, 26.0], 'DK': [56.0, 10.0], + 'CH': [47.0, 8.0], 'AT': [47.5, 14.5], 'BE': [50.5, 4.5], + 'CZ': [49.8, 15.5], 'PT': [39.5, -8.0], 'GR': [39.0, 22.0], + 'RO': [46.0, 25.0], 'HU': [47.0, 20.0], 'IL': [31.0, 35.0], + 'AE': [24.0, 54.0], 'NZ': [-41.0, 174.0], 'IE': [53.0, -8.0] +}; + +// Convert lat/long to SVG coordinates (Mercator-ish projection) +function toSvgCoords(lat, lon, width, height) { + var x = (lon + 180) * (width / 360); + var latRad = lat * Math.PI / 180; + var mercN = Math.log(Math.tan((Math.PI / 4) + (latRad / 2))); + var y = (height / 2) - (width * mercN / (2 * Math.PI)); + return [x, Math.max(0, Math.min(height, y))]; +} + +// Calculate dot radius based on count (logarithmic scale) +function dotRadius(count, maxCount) { + if (count <= 0) return 4; + var normalized = Math.log(count + 1) / Math.log(maxCount + 1); + return 4 + normalized * 16; // 4px to 20px +} + +return baseclass.extend({ + /** + * Render heatmap + * @param {Object} data - { local: [{country, count}], capi: [...], waf: [...] } + * @param {Object} options - { width, height, showLegend } + */ + render: function(data, options) { + var width = options && options.width || 600; + var height = options && options.height || 300; + var showLegend = options && options.showLegend !== false; + + // Find max count for scaling + var maxCount = 1; + ['local', 'capi', 'waf'].forEach(function(key) { + if (data[key]) { + data[key].forEach(function(item) { + if (item.count > maxCount) maxCount = item.count; + }); + } + }); + + // Create SVG element + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + svg.setAttribute('class', 'cs-heatmap-svg'); + svg.style.width = '100%'; + svg.style.height = 'auto'; + + // Add world outline background + var bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + bg.setAttribute('width', width); + bg.setAttribute('height', height); + bg.setAttribute('fill', 'var(--kiss-bg2, #1a1d21)'); + svg.appendChild(bg); + + // Simple continent outlines (simplified paths) + var continents = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + continents.setAttribute('d', this.getContinentPaths(width, height)); + continents.setAttribute('fill', 'none'); + continents.setAttribute('stroke', 'var(--kiss-border, #2d3139)'); + continents.setAttribute('stroke-width', '0.5'); + svg.appendChild(continents); + + // Render dots by layer (CAPI first, then local, then WAF on top) + var layers = [ + { key: 'capi', color: 'var(--kiss-cyan, #00bcd4)', label: 'CAPI Blocklist' }, + { key: 'local', color: 'var(--kiss-orange, #ff9800)', label: 'Local Bans' }, + { key: 'waf', color: 'var(--kiss-red, #f44336)', label: 'WAF Threats' } + ]; + + var self = this; + layers.forEach(function(layer) { + if (!data[layer.key]) return; + data[layer.key].forEach(function(item) { + var centroid = CENTROIDS[item.country]; + if (!centroid) return; + + var coords = toSvgCoords(centroid[0], centroid[1], width, height); + var radius = dotRadius(item.count, maxCount); + + var circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('cx', coords[0]); + circle.setAttribute('cy', coords[1]); + circle.setAttribute('r', radius); + circle.setAttribute('fill', layer.color); + circle.setAttribute('opacity', '0.7'); + circle.setAttribute('class', 'cs-heatmap-dot'); + circle.setAttribute('data-country', item.country); + circle.setAttribute('data-count', item.count); + circle.setAttribute('data-source', layer.key); + + // Tooltip on hover + var title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); + title.textContent = item.country + ': ' + item.count + ' (' + layer.label + ')'; + circle.appendChild(title); + + svg.appendChild(circle); + }); + }); + + // Container with legend + var container = E('div', { 'class': 'cs-heatmap-container' }, [svg]); + + if (showLegend) { + var legend = E('div', { 'class': 'cs-heatmap-legend' }, layers.map(function(l) { + return E('span', { 'class': 'cs-heatmap-legend-item' }, [ + E('span', { + 'class': 'cs-heatmap-legend-dot', + 'style': 'background:' + l.color + }), + ' ' + l.label + ]); + })); + container.appendChild(legend); + } + + return container; + }, + + // Simplified continent outline paths + getContinentPaths: function(w, h) { + // Simplified world outline - major landmasses + var scale = w / 600; + return [ + // North America + 'M50,80 Q80,60 120,70 L150,90 Q140,120 100,140 L60,130 Q40,110 50,80', + // South America + 'M100,160 Q120,150 130,170 L140,220 Q130,260 110,280 L90,260 Q80,200 100,160', + // Europe + 'M280,60 Q320,50 350,60 L360,90 Q340,100 300,95 L280,80 Z', + // Africa + 'M280,120 Q320,100 350,120 L360,180 Q340,220 300,230 L270,200 Q260,150 280,120', + // Asia + 'M360,50 Q450,30 520,50 L550,100 Q530,140 480,150 L400,130 Q370,100 360,50', + // Australia + 'M480,200 Q520,190 540,210 L550,240 Q530,260 500,260 L480,240 Q470,220 480,200' + ].join(' '); + }, + + /** + * Parse geo data from various formats + */ + parseGeoData: function(raw) { + if (!raw) return []; + if (typeof raw === 'string') { + try { raw = JSON.parse(raw); } catch(e) { return []; } + } + if (!Array.isArray(raw)) return []; + return raw.filter(function(item) { + return item && item.country && typeof item.count === 'number'; + }); + } +}); diff --git a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js index 95fb472d..5ee746e3 100644 --- a/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js +++ b/package/secubox/luci-app-crowdsec-dashboard/htdocs/luci-static/resources/view/crowdsec-dashboard/overview.js @@ -3,6 +3,7 @@ 'require dom'; 'require poll'; 'require crowdsec-dashboard.api as api'; +'require crowdsec-dashboard.heatmap as heatmap'; 'require secubox/kiss-theme'; return view.extend({ @@ -81,12 +82,26 @@ return view.extend({ return Array.isArray(alerts) ? alerts : []; }, + parseGeoData: function(raw) { + if (!raw) return []; + if (typeof raw === 'string') { + try { raw = JSON.parse(raw); } catch(e) { return []; } + } + if (!Array.isArray(raw)) return []; + return raw.filter(function(item) { + return item && item.country && typeof item.count === 'number'; + }); + }, + render: function(data) { var self = this; // Ensure s is always an object (data could be error code like 5) var s = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {}; s.countries = this.parseCountries(s); s.alerts = this.parseAlerts(s); + // Parse geo data for heatmap + s.geoLocal = this.parseGeoData(s.geo_local_raw || s.top_countries_raw); + s.geoCapi = this.parseGeoData(s.geo_capi_raw); var content = [ // Header @@ -113,8 +128,8 @@ return view.extend({ KissTheme.card('System Health', this.renderHealth(s)) ]), - // Geo card - KissTheme.card('Threat Origins', E('div', { 'id': 'cs-geo' }, this.renderGeo(s.countries))) + // Geo card - heatmap + legend + KissTheme.card('Threat Origins', E('div', { 'id': 'cs-geo' }, this.renderGeo(s))) ]; poll.add(L.bind(this.pollData, this), 30); @@ -203,19 +218,33 @@ return view.extend({ })); }, - renderGeo: function(countries) { - var entries = Object.entries(countries || {}); - if (!entries.length) { + renderGeo: function(s) { + var countries = s.countries || {}; + var entries = Object.entries(countries); + var hasData = entries.length > 0 || (s.geoLocal && s.geoLocal.length > 0) || (s.geoCapi && s.geoCapi.length > 0); + + if (!hasData) { return E('div', { 'style': 'text-align: center; padding: 24px; color: var(--kiss-muted);' }, 'No geographic data'); } + + // Render heatmap + var heatmapEl = heatmap.render({ + local: s.geoLocal || [], + capi: s.geoCapi || [], + waf: [] // WAF geo data could be added later + }, { width: 600, height: 300 }); + + // Flag legend below heatmap entries.sort(function(a, b) { return b[1] - a[1]; }); - return E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px;' }, entries.slice(0, 12).map(function(e) { + var flagLegend = E('div', { 'style': 'display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 12px; margin-top: 16px;' }, entries.slice(0, 12).map(function(e) { return E('div', { 'style': 'display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: rgba(255,255,255,0.02); border-radius: 6px;' }, [ E('span', { 'style': 'font-size: 18px;' }, api.getCountryFlag(e[0])), E('span', { 'style': 'font-family: monospace; font-weight: 600; color: var(--kiss-orange);' }, String(e[1])), E('span', { 'style': 'font-size: 11px; color: var(--kiss-muted);' }, e[0]) ]); })); + + return E('div', {}, [heatmapEl, flagLegend]); }, fmt: function(n) { @@ -231,12 +260,14 @@ return view.extend({ var s = (data && typeof data === 'object' && !Array.isArray(data)) ? data : {}; s.countries = self.parseCountries(s); s.alerts = self.parseAlerts(s); + s.geoLocal = self.parseGeoData(s.geo_local_raw || s.top_countries_raw); + s.geoCapi = self.parseGeoData(s.geo_capi_raw); var el = document.getElementById('cs-stats'); if (el) dom.content(el, self.renderStats(s)); el = document.getElementById('cs-alerts'); if (el) dom.content(el, self.renderAlerts(s.alerts)); el = document.getElementById('cs-geo'); - if (el) dom.content(el, self.renderGeo(s.countries)); + if (el) dom.content(el, self.renderGeo(s)); }); }, diff --git a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard index cdb904d2..fb51c279 100755 --- a/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard +++ b/package/secubox/luci-app-crowdsec-dashboard/root/usr/libexec/rpcd/luci.crowdsec-dashboard @@ -2457,6 +2457,19 @@ refresh_overview_cache() { awk '{print "{\"country\":\"" $2 "\",\"count\":" $1 "}"}' | \ tr '\n' ',' | sed 's/,$//') + # Parse CAPI geo from decisions with origin=CAPI + # We fetch decisions again with higher limit for CAPI geo + local capi_geo="" + local capi_decisions_full + capi_decisions_full=$("$CSCLI" decisions list -o json 2>/dev/null || echo "[]") + capi_geo=$(echo "$capi_decisions_full" | \ + grep -A20 '"origin":"CAPI"' | \ + grep -A1 '"key": "IsoCode"' | grep '"value":' | \ + sed 's/.*"value"[[:space:]]*:[[:space:]]*"//;s/".*$//' | \ + grep -v '^$' | sort | uniq -c | sort -rn | head -20 | \ + awk '{print "{\"country\":\"" $2 "\",\"count\":" $1 "}"}' | \ + tr '\n' ',' | sed 's/,$//') + # Scenario count from files local sc1=$(ls -1 /etc/crowdsec/scenarios/*.yaml 2>/dev/null | wc -l) local sc2=$(ls -1 /srv/crowdsec/hub/scenarios/*/*.yaml 2>/dev/null | wc -l) @@ -2557,6 +2570,8 @@ refresh_overview_cache() { "scenario_count":$scenario_count, "top_scenarios_raw":[$scenarios], "top_countries_raw":[$countries], +"geo_local_raw":[$countries], +"geo_capi_raw":[$capi_geo], "decisions_raw":$(cat "$decisions_file"), "alerts_raw":$(cat "$alerts_file"), "logs":$logs_json,