How We Built a Live Attack Map with Real-Time eBPF Data
Security dashboards are full of tables and charts. They tell you what happened, but they do not make you feel it. We wanted something different: a world map that lights up in real time as attacks hit your server. Not simulated data. Not a replay. Live incidents from real eBPF sensors, resolved to coordinates and rendered as pulsing dots on a Mercator projection.
This is how we built it, what broke along the way, and what we learned about the shape of internet attacks when you can actually see them.
Why build a live demo?
Security software has a trust problem. Vendors claim their tool detects threats, but you cannot verify it until you deploy it on your own infrastructure. By the time you realize it does not work, you have already invested hours in setup.
A live attack map solves this by showing the product working in real time, on a real server, against real attackers. Every dot on the map is an actual incident detected by Inner Warden's sensor: an SSH brute force attempt, a port scan, a web scanner probing for vulnerabilities, a credential stuffing attack. Visitors can see the threat landscape of a production server without deploying anything.
It also answers the most common question we get: "Does this actually detect anything?" The map fills up within seconds of loading. The internet is noisier than most people expect.
The data pipeline
The map starts at the kernel. Inner Warden's sensor uses eBPF tracepoints to intercept execve, connect, and openat syscalls. These raw events flow through an mpsc::channel(1024) into stateful detectors: ssh_bruteforce, credential_stuffing, port_scan, web_scan, and others. Each detector maintains its own state machine and emits incidents when thresholds are crossed.
The sensor writes two JSONL files: events-YYYY-MM-DD.jsonl for raw events and incidents-YYYY-MM-DD.jsonl for detected threats. The agent reads these files incrementally using byte-offset cursors, enriches the incidents with AbuseIPDB reputation scores and GeoIP data, then passes them through the AI triage pipeline.
kernel (eBPF tracepoints)
→ mpsc::channel(1024)
→ detectors (ssh_bruteforce, port_scan, web_scan, ...)
→ incidents-YYYY-MM-DD.jsonl
→ agent reader (byte-offset cursor)
→ enrichment (AbuseIPDB + GeoIP)
→ /api/live-feed/stream (SSE)The entire pipeline from kernel event to map dot takes under 3 seconds. Most of that time is the agent's 2-second fast loop interval.
The SSE endpoint
We needed a way to push incidents to the browser without polling. WebSockets felt like overkill for a unidirectional data stream. Server-Sent Events (SSE) are simpler: one HTTP connection, text-based protocol, automatic reconnection built into the browser's EventSource API.
We added two endpoints to the agent's dashboard server: /api/live-feed returns the last 50 incidents as a JSON array for initial page load, and /api/live-feed/stream opens an SSE connection that emits new incidents as they are detected.
event: incident
data: {"id":"a3f1","source":"ssh_bruteforce","severity":"high","ip":"185.220.101.34","country":"DE","lat":51.29,"lon":9.49,"ts":"2026-03-22T14:32:01Z"}
event: incident
data: {"id":"a3f2","source":"web_scan","severity":"medium","ip":"45.33.32.156","country":"US","lat":37.77,"lon":-122.42,"ts":"2026-03-22T14:32:03Z"}CORS was the first hurdle. The map runs on innerwarden.com but the SSE stream comes from the agent running on a different server. We added explicit CORS headers to the live-feed endpoints with Access-Control-Allow-Origin set to the site domain. Wildcard origins would work but we restricted it to prevent abuse.
Server-side GeoIP proxy
Each incident carries an IP address, but the map needs latitude and longitude. Our first approach was to resolve coordinates client-side by calling ip-api.com directly from the browser. This broke immediately.
The problem is mixed content. The site runs on HTTPS, but ip-api.com's free tier only serves HTTP. Browsers block HTTP requests from HTTPS pages. The paid tier supports HTTPS, but we wanted the free tier to work for the demo.
The solution was a server-side GeoIP proxy. The agent already has GeoIP resolution built in (it uses MaxMind's GeoLite2 database for enrichment). Instead of sending raw IPs to the browser and letting it resolve them, we resolve coordinates on the server and include lat, lon, and country in the SSE payload. No mixed content, no client-side API calls, no rate limits.
For IPs where the local GeoLite2 database has no entry, we fall back to ip-api.com's batch endpoint server-side. The agent batches up to 100 IPs per request and caches results for 24 hours. Since this happens server-to-server over HTTP, there is no mixed content issue.
1. Check MaxMind GeoLite2 local database
→ hit: use lat/lon directly (< 1ms)
→ miss: queue for batch resolution
2. Batch resolve via ip-api.com (server-side)
→ POST http://ip-api.com/batch
→ up to 100 IPs per request
→ cache results for 24 hours
3. Include coordinates in SSE payload
→ no client-side GeoIP calls
→ no mixed content issuesThe frontend
The map itself uses react-simple-maps with a Mercator projection. We chose it over Mapbox or Leaflet because we did not need interactive tiles, street-level detail, or zoom controls. A static world outline with dots plotted on it was exactly what we needed, and react-simple-maps renders it as inline SVG with no external tile server dependencies.
Each incident becomes a circle on the map, positioned by its coordinates. The color reflects severity: red for critical and high, amber for medium, cyan for low and info. New dots appear with a pulsing animation that fades over 4 seconds, so you can immediately see which attacks just arrived versus which ones have been on the map for a while.
const severityColor = (severity: string) => {
switch (severity) {
case "critical": return "#ef4444"; // red-500
case "high": return "#f87171"; // red-400
case "medium": return "#f59e0b"; // amber-500
case "low": return "#06b6d4"; // cyan-500
default: return "#22d3ee"; // cyan-400
}
};The pulsing animation uses CSS keyframes with opacity and scale transitions. Each dot starts at full opacity and scale 1.5, then shrinks to scale 1 and fades to 0.6 over 4 seconds. Dots older than 5 minutes are removed from the map to keep it readable. At peak traffic we display around 200 dots simultaneously.
<ComposableMap projection="geoMercator">
<Geographies geography={worldGeo}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
fill="#1a1a2e"
stroke="#2a2a4e"
/>
))
}
</Geographies>
{incidents.map((inc) => (
<Marker key={inc.id} coordinates={[inc.lon, inc.lat]}>
<circle
r={4}
fill={severityColor(inc.severity)}
className="animate-attack-pulse"
/>
</Marker>
))}
</ComposableMap>What we learned
Building the map confirmed what we suspected but had not visualized before: any server connected to the internet is under constant, automated attack. Here are the patterns that became obvious once we could see them:
- The map fills up fast. A fresh server starts receiving SSH brute force attempts within minutes of being provisioned. Port scans follow within the hour. Web scanners arrive as soon as any HTTP service responds. On our production server, the map typically shows 30-50 new incidents per minute.
- Most attacks come from a handful of countries. This is not a political statement. It reflects where the largest botnets and scanning infrastructure are hosted. The concentration is visually striking on the map: clusters of dots in specific regions with sparse activity elsewhere.
- Attack patterns have daily rhythms. SSH brute force peaks during certain hours. Web scanning is more evenly distributed. Credential stuffing comes in bursts that correlate with credential dump releases. Watching the map over 24 hours reveals these rhythms clearly.
- Severity distribution is predictable. Most dots are cyan (low severity port scans and info probes). Amber dots (medium severity web scans) appear regularly. Red dots (high severity brute force and credential stuffing) are less frequent but cluster in waves.
The most surprising outcome was how effective the map is at communicating urgency. Tables and log files create distance between the operator and the threat. A dot appearing on a map in real time, pulsing red, tied to a specific IP and country, creates an immediate sense that something is happening right now and the system is handling it.
Try it yourself
The live attack map is running right now against our production server. Every dot is a real incident detected by Inner Warden's eBPF sensor in real time. No synthetic data, no replays.
Visit innerwarden.com/live to see it. Leave the page open for a few minutes and watch the attacks roll in. The sidebar shows incident details as they arrive: source IP, country, attack type, severity, and the action Inner Warden took in response.
If you want the same visibility on your own server, deploy Inner Warden and point the live map at your agent's SSE endpoint. The sensor starts detecting threats immediately after installation. No configuration required for the default collectors.
curl -fsSL https://innerwarden.com/install | sudo bash
innerwarden enable ai
innerwarden enable block-ip
systemctl start innerwarden-sensor innerwarden-agent