feat(crowdsec): Add geo heatmap visualization for threat origins
- New heatmap.js component with SVG world map and country centroids - Colored dots show threat distribution: orange (local), cyan (CAPI), red (WAF) - Dot size scales logarithmically with threat count (4-20px) - Hover tooltips show country code and count - Added geo_local_raw and geo_capi_raw fields to RPCD backend - CAPI geo extraction from decisions with GeoIP metadata - CSS styling for heatmap container, dots, and legend Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b02503eac4
commit
f46e145927
@ -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/
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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';
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -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));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user