Compare commits

..

2 Commits

Author SHA1 Message Date
DMO ae062a04ed Update README.md 2026-06-07 19:47:05 +02:00
dmo 5f8a43e652 feat: initial release - MorkNetVizualiser v1.0 2026-06-07 19:31:43 +02:00
19 changed files with 2961 additions and 2 deletions
+21
View File
@@ -0,0 +1,21 @@
# Environment — contains secrets, never commit
backend/.env
# GeoIP database — large binary, download separately
data/
# Node
frontend/node_modules/
frontend/build/
# Python
__pycache__/
*.pyc
*.pyo
# Docker
*.log
# OS
.DS_Store
Thumbs.db
+292 -2
View File
@@ -1,3 +1,293 @@
# webapps # MorkNetVisualizer
Personal web applications A real-time network traffic visualization tool for home and small office networks.
It displays live traffic flows as animated arcs on a world map, showing where your
devices are connecting to and where inbound traffic originates from.
---
## Important disclaimer
This project was built entirely through AI-assisted development (Claude by Anthropic).
No human has audited, reviewed, or manually written the code. Use it at your own risk
in trusted network environments. Do not expose it to the public internet without
adding authentication in front of it (e.g. via a reverse proxy with access control).
---
## Download
[Download v1.0 tar.gz](https://github.com/danielmorkcode/MorkNetVisualizer/releases/download/v1.0.0/netmapFinal.tar.gz)
Or clone the repository and build from source.
---
## What it does
- Captures real network flows via NetFlow v9/IPFIX from your router
- Geolocates source and destination IPs using a local MaxMind database
- Draws animated arcs on a world map showing traffic direction and volume
- Shows active devices, traffic volume, protocol breakdown, and top talkers
- Overlays CrowdSec banned IPs as markers on the map (optional)
- Displays a day/night overlay based on current solar position
- Supports arc history replay, connection heatmap, device grouping, and more
---
## Requirements
### Network
- A router that supports NetFlow v9 or IPFIX export (tested with UniFi Dream Router)
- The router must be able to reach the host running this tool over UDP
### Host
- Docker and Docker Compose
- At least 512 MB RAM
- Network reachable from the router for NetFlow (UDP) and from your browser over HTTP
### Accounts and files
**MaxMind GeoLite2 (required)**
NetFlow gives you IP addresses. MaxMind translates those IPs into geographic coordinates
for map display. Without it the map will show no arcs.
1. Create a free account at https://www.maxmind.com/en/geolite2/signup
2. Download GeoLite2-City in the binary .mmdb format
3. Place it at `data/GeoLite2-City.mmdb` in the project directory
The database is updated weekly by MaxMind. To refresh it, replace the file and
restart the backend container.
**UniFi API key (required)**
Used to fetch device names and active client list.
1. Log into your UniFi controller
2. Go to Settings -> System -> Advanced -> API Keys
3. Create a new API key and copy it
**CrowdSec bouncer API key (optional)**
See the CrowdSec section below.
---
## Tech stack
**Backend**
- Python 3.12
- FastAPI with uvicorn (HTTP API and WebSocket server)
- Native NetFlow v5/v9 parser written in pure Python using the struct module
- geoip2 (MaxMind GeoLite2 database reader)
- httpx (async HTTP client for UniFi and external APIs)
**Frontend**
- React 18 with Vite
- d3-geo with Natural Earth projection for the SVG world map
- topojson-client for country geometry
- WebSocket for live data streaming
- All state persistence via localStorage (no external dependencies for UI state)
**Infrastructure**
- Docker Compose with two containers: backend (Python) and frontend (nginx serving built React)
- NetFlow collected on UDP (default port 2055)
- nginx proxies WebSocket and API calls internally so only one port is exposed
---
## Installation
### 1. Clone or download the project
```
git clone <your-repo-url>
cd MorkNetVisualizer
```
### 2. Place the GeoIP database
```
mkdir -p data
cp /path/to/GeoLite2-City.mmdb data/
```
### 3. Configure the environment
```
cp backend/.env.example backend/.env
```
Edit `backend/.env` and fill in your values. See the configuration section below.
### 4. Configure NetFlow on your router
On a UniFi Dream Router:
1. Go to Settings -> CyberSecure -> Traffic Logging -> NetFlow (IPFIX)
2. Enable it
3. Set collector address to the IP of the host running this tool
4. Set collector port to 2055 (or whatever you set in NETFLOW_PORT)
5. Set version to 9
6. Set sampling to Off
### 5. Start the stack
```
docker compose up -d --build
```
The first build takes 2-3 minutes. After that, open a browser to:
```
http://<host-ip>:3500
```
---
## Configuration
All configuration lives in `backend/.env`. Copy `backend/.env.example` as a starting point.
```
# UniFi Controller
UNIFI_URL=https://192.168.1.1 # IP or hostname of your UniFi controller
UNIFI_API_KEY= # API key from UniFi settings
UNIFI_SITE=default # Site name, usually "default"
UNIFI_VERIFY_SSL=false # Set to false for self-signed certificates
# GeoIP
GEOIP_DB_PATH=/data/GeoLite2-City.mmdb
# NetFlow collector
NETFLOW_PORT=2055 # Must match what your router is configured to send to
# Home location (shown as the origin point on the map)
HOME_LAT=51.5074
HOME_LON=-0.1278
HOME_NAME=Home
# CrowdSec (optional, leave blank to disable)
CROWDSEC_URL=
CROWDSEC_API_KEY=
```
---
## CrowdSec integration (optional)
CrowdSec is an open source threat detection tool. When configured, MorkNetVisualizer will
display banned IPs as red X markers on the map, with new bans showing a brief
pulse animation. You can filter between locally detected bans and the community
blocklist.
### Setup
CrowdSec must be running on a host reachable from the MorkNetVisualizer container.
By default, CrowdSec only listens on localhost (127.0.0.1:8080). To allow
remote access from another host, edit `/etc/crowdsec/config.yaml` on the
CrowdSec host:
```yaml
api:
server:
listen_uri: 0.0.0.0:8080
```
Then restart CrowdSec:
```
sudo systemctl restart crowdsec
```
Create a bouncer API key:
```
sudo cscli bouncers add MorkNetVisualizer-reader
```
Copy the generated key. Then in `backend/.env`:
```
CROWDSEC_URL=http://<crowdsec-host-ip>:8080
CROWDSEC_API_KEY=<key from above>
```
Restart the backend:
```
docker compose restart backend
```
---
## Updating the GeoIP database
MaxMind releases updated databases weekly. To update:
1. Download the new GeoLite2-City.mmdb from your MaxMind account
2. Replace `data/GeoLite2-City.mmdb`
3. Restart the backend: `docker compose restart backend`
---
## Troubleshooting
**No arcs on the map**
- Check that NetFlow is enabled on your router and pointing at the correct host and port
- Verify the backend is receiving packets: `curl http://<host>:3500/api/status`
Look for `netflow_stats.packets` incrementing
- Confirm the GeoLite2 database is present: `docker compose exec backend ls /data`
- Check backend logs: `docker compose logs backend`
**Reconnecting shown in the UI**
- The WebSocket connection to the backend failed
- Check `docker compose logs backend` for errors
- Verify the backend container is running: `docker compose ps`
**CrowdSec shows no bans**
- Run `curl http://<crowdsec-host>:8080/v1/decisions` with the API key header
to verify the CrowdSec API is reachable
- Check that `listen_uri` in `/etc/crowdsec/config.yaml` is `0.0.0.0:8080`
and not `127.0.0.1:8080`
**ASN/Org shows Unknown**
- ip-api.com has a rate limit of 45 requests per minute on the free tier
- The backend caches results, so this only affects the first lookup per IP
- If you are clicking many different IPs quickly, wait a moment and try again
---
## Ports
| Port | Protocol | Purpose |
|------|----------|---------|
| 3500 | TCP | Web interface (nginx, proxies to backend) |
| 2055 | UDP | NetFlow/IPFIX collector |
---
## Data privacy
All data stays on your host. No traffic data, IP addresses, or device information
is sent anywhere. The only outbound requests are:
- MaxMind GeoLite2 downloads (manual, on your schedule)
- ASN lookups to ip-api.com and ipinfo.io (triggered by clicking an arc in the UI)
- World map GeoJSON loaded from a CDN on first page load (cdn.jsdelivr.net)
---
## License
MIT
+27
View File
@@ -0,0 +1,27 @@
# ── UniFi Controller (required) ────────────────────────────────────────────────
UNIFI_URL=https://192.168.1.1
UNIFI_API_KEY=your_api_key_here
UNIFI_SITE=default
UNIFI_VERIFY_SSL=false # false for self-signed cert (typical for UDR)
# ── GeoIP database (required) ──────────────────────────────────────────────────
# Download free from https://www.maxmind.com/en/geolite2/signup
GEOIP_DB_PATH=/data/GeoLite2-City.mmdb
# ── NetFlow/IPFIX (required) ───────────────────────────────────────────────────
# Must match the collector port set in UniFi → Settings → CyberSecure → NetFlow
NETFLOW_PORT=2055
# ── Home location ──────────────────────────────────────────────────────────────
HOME_LAT=59.9139
HOME_LON=10.7522
HOME_NAME=Home
# ── CrowdSec (optional) ────────────────────────────────────────────────────────
# Leave blank to disable CrowdSec integration entirely.
# All ban markers, toggles, and counters will be hidden from the UI.
# To enable: run on your CrowdSec host:
# sudo cscli bouncers add netmap-reader
# Then fill in the values below:
CROWDSEC_URL=
CROWDSEC_API_KEY=
+12
View File
@@ -0,0 +1,12 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
+598
View File
@@ -0,0 +1,598 @@
import asyncio
import json
import logging
import os
import time
from contextlib import asynccontextmanager
from collections import defaultdict
import geoip2.database
import geoip2.errors
import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
# ── Config ────────────────────────────────────────────────────────────────────
UNIFI_URL = os.getenv("UNIFI_URL", "https://192.168.1.1")
UNIFI_API_KEY = os.getenv("UNIFI_API_KEY", "")
UNIFI_SITE = os.getenv("UNIFI_SITE", "default")
UNIFI_VERIFY_SSL = os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true"
GEOIP_DB_PATH = os.getenv("GEOIP_DB_PATH", "/data/GeoLite2-City.mmdb")
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "10"))
HOME_LAT = float(os.getenv("HOME_LAT", "59.9139"))
HOME_LON = float(os.getenv("HOME_LON", "10.7522"))
HOME_NAME = os.getenv("HOME_NAME", "Home")
NETFLOW_PORT = int(os.getenv("NETFLOW_PORT", "2055"))
CROWDSEC_URL = os.getenv("CROWDSEC_URL", "")
CROWDSEC_API_KEY = os.getenv("CROWDSEC_API_KEY", "")
# ── State ─────────────────────────────────────────────────────────────────────
ws_clients: dict[int, WebSocket] = {}
geo_reader: geoip2.database.Reader | None = None
device_registry: dict[str, dict] = {}
recent_flows: list[dict] = []
MAX_RECENT = 500
ip_to_mac: dict[str, str] = {}
netflow_stats = {"packets": 0, "flows": 0, "geolocated": 0, "templates": 0}
# ── GeoIP ─────────────────────────────────────────────────────────────────────
def load_geoip():
global geo_reader
if not os.path.exists(GEOIP_DB_PATH):
log.warning(f"GeoIP database not found at {GEOIP_DB_PATH}")
return
try:
geo_reader = geoip2.database.Reader(GEOIP_DB_PATH)
log.info("GeoIP database loaded.")
except Exception as e:
log.error(f"Failed to load GeoIP: {e}")
def geolocate(ip: str) -> dict | None:
if not geo_reader:
return None
try:
r = geo_reader.city(ip)
if r.location.latitude is None:
return None
return {"lat": r.location.latitude, "lon": r.location.longitude,
"city": r.city.name or "", "country": r.country.name or "",
"country_code": r.country.iso_code or ""}
except (geoip2.errors.AddressNotFoundError, Exception):
return None
def is_private_ip(ip: str) -> bool:
import ipaddress
try:
addr = ipaddress.ip_address(ip)
return addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_multicast
except ValueError:
return True
def int_to_ip(n: int) -> str:
return f"{(n >> 24) & 0xff}.{(n >> 16) & 0xff}.{(n >> 8) & 0xff}.{n & 0xff}"
# ── NetFlow v5 parser ─────────────────────────────────────────────────────────
import struct
NF5_HEADER = struct.Struct("!HHIIIhHBB") # 24 bytes
NF5_RECORD = struct.Struct("!IIIHHIIIIHHBBBBHHBBH") # 48 bytes
def parse_netflow_v5(data: bytes) -> list[dict]:
if len(data) < 24:
return []
hdr = NF5_HEADER.unpack_from(data, 0)
count = hdr[1]
flows = []
for i in range(count):
off = 24 + i * 48
if off + 48 > len(data):
break
r = NF5_RECORD.unpack_from(data, off)
src_ip = int_to_ip(r[0])
dst_ip = int_to_ip(r[1])
packets = r[6]
octets = r[7]
proto = r[14]
src_port = r[10]
dst_port = r[11]
flows.append({"src": src_ip, "dst": dst_ip, "bytes": octets,
"packets": packets, "proto": proto,
"src_port": src_port, "dst_port": dst_port})
return flows
# ── NetFlow v9 parser ─────────────────────────────────────────────────────────
# Store templates per (src_addr, source_id, template_id)
nf9_templates: dict[tuple, dict] = {}
NF9_FIELD_TYPES = {
1: ("IN_BYTES", 4),
2: ("IN_PKTS", 4),
4: ("PROTOCOL", 1),
7: ("L4_SRC_PORT", 2),
8: ("IPV4_SRC_ADDR", 4),
11: ("L4_DST_PORT", 2),
12: ("IPV4_DST_ADDR", 4),
16: ("SRC_AS", 4),
17: ("DST_AS", 4),
21: ("LAST_SWITCHED", 4),
22: ("FIRST_SWITCHED", 4),
23: ("OUT_BYTES", 4),
24: ("OUT_PKTS", 4),
32: ("ICMP_TYPE", 2),
60: ("IP_PROTOCOL_VERSION", 1),
}
def parse_nf9_template(data: bytes, offset: int, length: int, src: str, source_id: int):
end = offset + length
pos = offset
while pos + 4 <= end:
tmpl_id = struct.unpack_from("!H", data, pos)[0]
field_count = struct.unpack_from("!H", data, pos + 2)[0]
pos += 4
fields = []
for _ in range(field_count):
if pos + 4 > end:
break
ftype = struct.unpack_from("!H", data, pos)[0]
flen = struct.unpack_from("!H", data, pos + 2)[0]
fields.append((ftype, flen))
pos += 4
key = (src, source_id, tmpl_id)
nf9_templates[key] = fields
netflow_stats["templates"] += 1
log.debug(f"Stored NF9 template {tmpl_id} from {src} ({len(fields)} fields)")
def parse_nf9_data(data: bytes, offset: int, length: int,
src: str, source_id: int, flowset_id: int) -> list[dict]:
key = (src, source_id, flowset_id)
template = nf9_templates.get(key)
if not template:
return []
record_len = sum(f[1] for f in template)
if record_len == 0:
return []
flows = []
pos = offset
end = offset + length
while pos + record_len <= end:
record = {}
rpos = pos
for ftype, flen in template:
raw = data[rpos:rpos + flen]
rpos += flen
name = NF9_FIELD_TYPES.get(ftype, (None, None))[0]
if name == "IPV4_SRC_ADDR" and flen == 4:
record["src"] = int_to_ip(struct.unpack("!I", raw)[0])
elif name == "IPV4_DST_ADDR" and flen == 4:
record["dst"] = int_to_ip(struct.unpack("!I", raw)[0])
elif name == "IN_BYTES" and flen <= 4:
record["bytes"] = int.from_bytes(raw, "big")
elif name == "IN_PKTS" and flen <= 4:
record["packets"] = int.from_bytes(raw, "big")
elif name == "PROTOCOL" and flen == 1:
record["proto"] = raw[0]
elif name == "L4_SRC_PORT" and flen == 2:
record["src_port"] = struct.unpack("!H", raw)[0]
elif name == "L4_DST_PORT" and flen == 2:
record["dst_port"] = struct.unpack("!H", raw)[0]
if "src" in record and "dst" in record:
flows.append(record)
pos += record_len
return flows
def parse_netflow_v9(data: bytes, src_addr: str) -> list[dict]:
if len(data) < 20:
return []
count = struct.unpack_from("!H", data, 2)[0]
source_id = struct.unpack_from("!I", data, 16)[0]
pos = 20
all_flows = []
for _ in range(count):
if pos + 4 > len(data):
break
flowset_id = struct.unpack_from("!H", data, pos)[0]
length = struct.unpack_from("!H", data, pos + 2)[0]
if length < 4:
break
payload_off = pos + 4
payload_len = length - 4
if flowset_id == 0:
# Template flowset
parse_nf9_template(data, payload_off, payload_len, src_addr, source_id)
elif flowset_id == 1:
pass # Options template, skip
elif flowset_id >= 256:
# Data flowset
flows = parse_nf9_data(data, payload_off, payload_len,
src_addr, source_id, flowset_id)
all_flows.extend(flows)
pos += length
# Align to 4-byte boundary
if pos % 4:
pos += 4 - (pos % 4)
return all_flows
# ── Flow processing ───────────────────────────────────────────────────────────
# Deduplicate noisy flows: track recent (src,dst) pairs
recent_pairs: dict[tuple, float] = {}
DEDUP_WINDOW = 30 # seconds — don't re-emit same src→dst within this window
def process_raw_flow(raw: dict) -> dict | None:
src = raw.get("src", "")
dst = raw.get("dst", "")
if not src or not dst:
return None
src_local = is_private_ip(src)
dst_local = is_private_ip(dst)
# Skip purely local flows
if src_local and dst_local:
return None
now_ts = time.time()
now_ms = int(now_ts * 1000)
if src_local and not dst_local:
# Outbound
pair_key = ("out", src, dst)
if now_ts - recent_pairs.get(pair_key, 0) < DEDUP_WINDOW:
return None
recent_pairs[pair_key] = now_ts
geo = geolocate(dst)
mac = ip_to_mac.get(src, "")
name = device_registry.get(mac, {}).get("name") or src
return {"id": f"nf-{src}-{dst}-{now_ms}", "type": "outbound",
"device_mac": mac, "device_name": name, "device_ip": src,
"bytes": raw.get("bytes", 0), "packets": raw.get("packets", 0),
"proto": raw.get("proto", 0),
"src_port": raw.get("src_port", 0), "dst_port": raw.get("dst_port", 0),
"remote_ip": dst, "src_lat": HOME_LAT, "src_lon": HOME_LON,
"timestamp": now_ms, "remote_geo": geo}
elif not src_local and dst_local:
# Inbound
pair_key = ("in", src, dst)
if now_ts - recent_pairs.get(pair_key, 0) < DEDUP_WINDOW:
return None
recent_pairs[pair_key] = now_ts
geo = geolocate(src)
mac = ip_to_mac.get(dst, "")
name = device_registry.get(mac, {}).get("name") or dst
return {"id": f"nf-{src}-{dst}-{now_ms}", "type": "inbound",
"device_mac": mac, "device_name": name, "device_ip": dst,
"bytes": raw.get("bytes", 0), "packets": raw.get("packets", 0),
"proto": raw.get("proto", 0),
"src_port": raw.get("src_port", 0), "dst_port": raw.get("dst_port", 0),
"remote_ip": src, "src_lat": HOME_LAT, "src_lon": HOME_LON,
"timestamp": now_ms, "remote_geo": geo}
return None
# ── NetFlow UDP server ────────────────────────────────────────────────────────
class NetFlowProtocol(asyncio.DatagramProtocol):
def __init__(self):
self._loop = asyncio.get_event_loop()
def datagram_received(self, data: bytes, addr):
netflow_stats["packets"] += 1
src_addr = addr[0]
try:
if len(data) < 2:
return
version = struct.unpack_from("!H", data, 0)[0]
if version == 5:
raw_flows = parse_netflow_v5(data)
elif version == 9:
raw_flows = parse_netflow_v9(data, src_addr)
else:
log.debug(f"Unknown NetFlow version {version}")
return
good_flows = []
for raw in raw_flows:
flow = process_raw_flow(raw)
if flow:
good_flows.append(flow)
netflow_stats["flows"] += 1
if flow.get("remote_geo"):
netflow_stats["geolocated"] += 1
if good_flows:
self._loop.create_task(emit_flows(good_flows))
except Exception as e:
log.error(f"NetFlow parse error: {e}", exc_info=True)
async def emit_flows(flows: list[dict]):
recent_flows.extend(flows)
if len(recent_flows) > MAX_RECENT:
del recent_flows[:-MAX_RECENT]
await broadcast({"type": "flows", "flows": flows})
async def start_netflow_server():
loop = asyncio.get_event_loop()
try:
await loop.create_datagram_endpoint(
NetFlowProtocol, local_addr=("0.0.0.0", NETFLOW_PORT)
)
log.info(f"NetFlow UDP collector listening on port {NETFLOW_PORT}")
except Exception as e:
log.error(f"Failed to start NetFlow listener: {e}")
# ── UniFi API ─────────────────────────────────────────────────────────────────
def make_unifi_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
base_url=UNIFI_URL,
headers={"X-API-KEY": UNIFI_API_KEY, "Accept": "application/json"},
verify=UNIFI_VERIFY_SSL, timeout=10.0,
)
async def fetch_clients(http: httpx.AsyncClient) -> list[dict]:
try:
r = await http.get(f"/proxy/network/api/s/{UNIFI_SITE}/stat/sta")
r.raise_for_status()
return r.json().get("data", [])
except Exception as e:
log.error(f"Error fetching clients: {e}")
return []
async def fetch_known_clients(http: httpx.AsyncClient) -> list[dict]:
try:
r = await http.get(f"/proxy/network/api/s/{UNIFI_SITE}/rest/user")
r.raise_for_status()
return r.json().get("data", [])
except Exception as e:
log.error(f"Error fetching known clients: {e}")
return []
# ── Broadcast ─────────────────────────────────────────────────────────────────
async def broadcast(msg: dict):
dead = []
for cid, ws in ws_clients.items():
try:
await ws.send_text(json.dumps(msg))
except Exception:
dead.append(cid)
for cid in dead:
ws_clients.pop(cid, None)
# ── Poll loop ─────────────────────────────────────────────────────────────────
async def poll_loop():
log.info("Starting UniFi poll loop...")
async with make_unifi_client() as http:
known = await fetch_known_clients(http)
for k in known:
mac = k.get("mac", "")
if mac:
device_registry[mac] = {
"mac": mac,
"name": k.get("name") or k.get("hostname") or mac,
"oui": k.get("oui", ""), "ip": k.get("last_ip", ""),
"tx_bytes": 0, "rx_bytes": 0,
}
log.info(f"Loaded {len(device_registry)} known devices.")
while True:
try:
active = await fetch_clients(http)
for sta in active:
mac = sta.get("mac", "")
if not mac:
continue
ip = sta.get("ip", "")
if ip:
ip_to_mac[ip] = mac
tx = sta.get("wired-tx_bytes") or sta.get("tx_bytes") or 0
rx = sta.get("wired-rx_bytes") or sta.get("rx_bytes") or 0
device_registry[mac] = {
"mac": mac,
"name": (sta.get("name") or sta.get("hostname") or
device_registry.get(mac, {}).get("name") or mac),
"ip": ip, "oui": sta.get("oui", ""),
"is_wired": sta.get("is_wired", False),
"uptime": sta.get("uptime"), "signal": sta.get("signal"),
"tx_bytes": tx, "rx_bytes": rx,
"last_seen": sta.get("last_seen"),
}
await broadcast({"type": "devices", "devices": list(device_registry.values())})
except Exception as e:
log.error(f"Poll loop error: {e}", exc_info=True)
await asyncio.sleep(POLL_INTERVAL)
# ── Cleanup task ─────────────────────────────────────────────────────────────
async def cleanup_loop():
"""Periodically clean up the dedup table so it doesn't grow forever."""
while True:
await asyncio.sleep(120)
cutoff = time.time() - DEDUP_WINDOW * 2
stale = [k for k, t in recent_pairs.items() if t < cutoff]
for k in stale:
recent_pairs.pop(k, None)
# ── FastAPI ───────────────────────────────────────────────────────────────────
@asynccontextmanager
async def lifespan(app: FastAPI):
load_geoip()
await start_netflow_server()
poll_task = asyncio.create_task(poll_loop())
cleanup_task = asyncio.create_task(cleanup_loop())
yield
poll_task.cancel()
cleanup_task.cancel()
app = FastAPI(lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
@app.get("/api/devices")
async def get_devices():
return {"devices": list(device_registry.values())}
@app.get("/api/status")
async def get_status():
return {
"geoip_loaded": geo_reader is not None,
"device_count": len(device_registry),
"home": {"lat": HOME_LAT, "lon": HOME_LON, "name": HOME_NAME},
"netflow_port": NETFLOW_PORT,
"netflow_stats": netflow_stats,
"templates_learned": len(nf9_templates),
}
@app.get("/api/crowdsec/enabled")
async def get_crowdsec_enabled():
"""Returns whether CrowdSec is configured."""
return {"enabled": bool(CROWDSEC_URL and CROWDSEC_API_KEY)}
@app.get("/api/crowdsec")
async def get_crowdsec(origin: str = "all"):
"""Proxy CrowdSec decisions with geo enrichment. origin=all|local|capi"""
if not CROWDSEC_URL or not CROWDSEC_API_KEY:
return []
try:
async with httpx.AsyncClient(timeout=8.0) as http:
# Fetch all decisions — filter locally since CrowdSec API origin param is unreliable
r = await http.get(
f"{CROWDSEC_URL}/v1/decisions",
headers={"X-Api-Key": CROWDSEC_API_KEY},
params={"limit": 2000}
)
if r.status_code == 204:
return []
r.raise_for_status()
decisions = r.json()
if not decisions:
return []
# Filter by origin locally — handles any casing CrowdSec uses
if origin == "local":
decisions = [d for d in decisions if d.get("origin", "").lower() not in ("capi", "lists")]
elif origin == "capi":
decisions = [d for d in decisions if d.get("origin", "").upper() == "CAPI"]
results = []
for d in decisions:
ip = d.get("value", "")
ip_clean = ip.split("/")[0]
geo = geolocate(ip_clean) if ip_clean else None
results.append({
"ip": ip,
"geo": geo,
"reason": d.get("reason", ""),
"scenario": d.get("scenario", d.get("reason", "")),
"origin": d.get("origin", ""),
"type": d.get("type", "ban"),
"expires_in": d.get("duration", ""),
"scope": d.get("scope", ""),
})
return results
except Exception as e:
log.error(f"CrowdSec fetch error: {e}")
return []
@app.get("/api/crowdsec/counts")
async def get_crowdsec_counts():
"""Return ban counts per origin without geo enrichment (fast)."""
if not CROWDSEC_URL or not CROWDSEC_API_KEY:
return {"local": 0, "capi": 0, "total": 0}
try:
async with httpx.AsyncClient(timeout=8.0) as http:
r = await http.get(
f"{CROWDSEC_URL}/v1/decisions",
headers={"X-Api-Key": CROWDSEC_API_KEY},
params={"limit": 5000}
)
if r.status_code == 204:
return {"local": 0, "capi": 0, "total": 0}
r.raise_for_status()
decisions = r.json() or []
capi = sum(1 for d in decisions if d.get("origin","").upper() == "CAPI")
local = sum(1 for d in decisions if d.get("origin","").lower() not in ("capi","lists"))
return {"local": local, "capi": capi, "total": len(decisions)}
except Exception as e:
log.error(f"CrowdSec counts error: {e}")
return {"local": 0, "capi": 0, "total": 0}
@app.get("/api/rdns/{ip}")
async def get_rdns(ip: str):
"""Reverse DNS lookup."""
import socket
try:
hostname = await asyncio.get_event_loop().run_in_executor(
None, lambda: socket.gethostbyaddr(ip)[0]
)
return {"hostname": hostname}
except Exception:
return {"hostname": None}
# Server-side ASN cache to avoid hammering ip-api.com
_asn_cache: dict[str, str] = {}
@app.get("/api/asn/{ip}")
async def get_asn(ip: str):
"""Proxy ASN/org lookup with caching and fallback."""
if ip in _asn_cache:
return {"org": _asn_cache[ip]}
try:
async with httpx.AsyncClient(timeout=5.0) as http:
# Primary: ip-api.com (45 req/min free)
r = await http.get(
f"http://ip-api.com/json/{ip}",
params={"fields": "org,isp,status"}
)
if r.status_code == 200:
d = r.json()
if d.get("status") == "success":
org = d.get("org") or d.get("isp") or "Unknown"
_asn_cache[ip] = org
return {"org": org}
# Fallback: ipinfo.io
r2 = await http.get(f"https://ipinfo.io/{ip}/json")
if r2.status_code == 200:
d2 = r2.json()
org = d2.get("org") or "Unknown"
_asn_cache[ip] = org
return {"org": org}
except Exception as e:
log.debug(f"ASN lookup error for {ip}: {e}")
return {"org": "Unknown"}
@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await ws.accept()
cid = id(ws)
ws_clients[cid] = ws
log.info(f"WS client connected. Total: {len(ws_clients)}")
try:
await ws.send_text(json.dumps({
"type": "init",
"home": {"lat": HOME_LAT, "lon": HOME_LON, "name": HOME_NAME},
"geoip_loaded": geo_reader is not None,
"devices": list(device_registry.values()),
"recent_flows": recent_flows[-50:],
}))
while True:
await ws.receive_text()
except WebSocketDisconnect:
log.info(f"WS client disconnected.")
finally:
ws_clients.pop(cid, None)
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
websockets==12.0
httpx==0.27.0
geoip2==4.8.0
python-dotenv==1.0.1
+29
View File
@@ -0,0 +1,29 @@
services:
backend:
build: ./backend
restart: unless-stopped
env_file: ./backend/.env
volumes:
- ./data:/data
ports:
- "2055:2055/udp" # NetFlow/IPFIX collector from UDR
networks:
- netmap
frontend:
build:
context: ./frontend
args:
# When nginx proxies /ws internally, the browser connects to the same host:port
REACT_APP_WS_URL: "" # leave blank — nginx proxies /ws to backend
restart: unless-stopped
ports:
- "3500:80" # Access at http://<LXC-IP>:3500
depends_on:
- backend
networks:
- netmap
networks:
netmap:
driver: bridge
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json .
RUN npm install
COPY index.html .
COPY vite.config.js .
COPY public/ public/
COPY src/ src/
ARG REACT_APP_WS_URL
ENV VITE_WS_URL=$REACT_APP_WS_URL
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NetMap — Network Traffic Globe</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600&family=Space+Grotesk:wght@300;400;500;600&display=swap" rel="stylesheet" />
</head>
<body style="margin:0;background:#000;">
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Proxy WebSocket to backend
location /ws {
proxy_pass http://backend:8000/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}
# Proxy API to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "netmap-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"d3-geo": "^3.1.1",
"topojson-client": "^3.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.0"
},
"scripts": {
"start": "vite",
"build": "vite build",
"preview": "vite preview"
}
}
+1195
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
const MIN_W = 220;
const MIN_H = 160;
export function DraggablePanel({ id, title, badge, extra, onClose, children, defaultPos }) {
const [pos, setPos] = useState(() => {
try { const s = localStorage.getItem(`panel-pos-${id}`); return s ? JSON.parse(s) : defaultPos; }
catch { return defaultPos; }
});
const [size, setSize] = useState(() => {
try { const s = localStorage.getItem(`panel-size-${id}`); return s ? JSON.parse(s) : { w: 272, h: 480 }; }
catch { return { w: 272, h: 480 }; }
});
const savePos = (p) => { try { localStorage.setItem(`panel-pos-${id}`, JSON.stringify(p)); } catch {} };
const saveSize = (s) => { try { localStorage.setItem(`panel-size-${id}`, JSON.stringify(s)); } catch {} };
const dragRef = useRef(null);
const resizeRef = useRef(null);
const panelRef = useRef(null);
// ── Drag ──────────────────────────────────────────────────────────────────
const onDragMouseDown = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
}, [pos]);
// ── Resize ────────────────────────────────────────────────────────────────
const onResizeMouseDown = useCallback((e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: size.w, origH: size.h };
}, [size]);
useEffect(() => {
const onMouseMove = (e) => {
if (dragRef.current) {
const d = dragRef.current;
const nx = Math.max(0, Math.min(window.innerWidth - size.w, d.origX + (e.clientX - d.startX)));
const ny = Math.max(0, Math.min(window.innerHeight - 60, d.origY + (e.clientY - d.startY)));
const p = { x: nx, y: ny };
setPos(p);
savePos(p);
}
if (resizeRef.current) {
const d = resizeRef.current;
const nw = Math.max(MIN_W, d.origW + (e.clientX - d.startX));
const nh = Math.max(MIN_H, d.origH + (e.clientY - d.startY));
const s = { w: nw, h: nh };
setSize(s);
saveSize(s);
}
};
const onMouseUp = () => { dragRef.current = null; resizeRef.current = null; };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
return () => { window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); };
}, [size.w]);
return (
<div
ref={panelRef}
className="floating-panel panel"
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
onClick={e => e.stopPropagation()}
>
{/* Header — drag handle */}
<div className="fp-header" onMouseDown={onDragMouseDown}>
<span className="fp-title">{title}</span>
{badge != null && <span className="tab-count">{badge}</span>}
{extra}
<button className="close-btn" onMouseDown={e=>e.stopPropagation()} onClick={onClose}></button>
</div>
{/* Content */}
<div className="fp-body">
{children}
</div>
{/* Resize handle — bottom-right corner */}
<div className="fp-resize" onMouseDown={onResizeMouseDown}></div>
</div>
);
}
+400
View File
@@ -0,0 +1,400 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--border: rgba(56,189,248,0.11);
--border-h: rgba(56,189,248,0.26);
--accent-out: #38bdf8;
--accent-in: #f472b6;
--blocked: #cc2200;
--text: #dde6f0;
--text-dim: #445566;
--mono: 'JetBrains Mono', monospace;
--ui: 'Space Grotesk', sans-serif;
--green: #4ade80;
--yellow: #fbbf24;
--panel-bg: rgba(4,8,16,0.96);
}
body { color:var(--text); font-family:var(--ui); overflow:hidden; height:100vh; width:100vw; }
::-webkit-scrollbar { width:3px; } ::-webkit-scrollbar-track { background:transparent; } ::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
.panel { background:var(--panel-bg); border:1px solid var(--border); border-radius:12px; backdrop-filter:blur(20px); }
button { font-family:var(--ui); cursor:pointer; border:none; outline:none; }
.mono { font-family:var(--mono); }
/* ── Layout ── */
.app { position:relative; width:100vw; height:100vh; overflow:hidden; }
.world-svg { position:absolute; inset:0; width:100%; height:100%; display:block; }
/* ── Zoom ── */
.zoom-controls { position:absolute; bottom:72px; right:20px; display:flex; flex-direction:column; gap:4px; z-index:20; }
.zoom-controls button { width:32px; height:32px; background:var(--panel-bg); border:1px solid var(--border); color:var(--text-dim); border-radius:8px; font-size:17px; display:flex; align-items:center; justify-content:center; transition:all 0.15s; }
.zoom-controls button:hover { border-color:var(--border-h); color:var(--accent-out); }
.zoom-reset { font-size:12px !important; }
/* ── Clock ── */
.clock { position:absolute; top:14px; left:14px; padding:8px 14px; z-index:20; display:flex; flex-direction:column; gap:2px; }
.clock-time { font-size:18px; font-weight:600; letter-spacing:0.05em; }
.clock-date { font-size:10px; color:var(--text-dim); }
/* ── Topbar ── */
.topbar { position:absolute; top:14px; left:50%; transform:translateX(-50%); display:flex; align-items:center; gap:20px; padding:9px 18px; white-space:nowrap; z-index:20; }
.topbar-left { display:flex; align-items:center; gap:12px; }
.logo { display:flex; align-items:center; gap:7px; }
.logo-icon { font-size:16px; color:var(--accent-out); filter:drop-shadow(0 0 5px var(--accent-out)); }
.logo-text { font-size:14px; font-weight:600; letter-spacing:0.08em; }
.conn-badge { display:flex; align-items:center; gap:5px; font-size:10px; font-weight:500; padding:2px 8px; border-radius:20px; }
.conn-ok { background:rgba(74,222,128,0.08); color:var(--green); border:1px solid rgba(74,222,128,0.2); }
.conn-err { background:rgba(248,113,113,0.08); color:#f87171; border:1px solid rgba(248,113,113,0.2); }
.conn-dot { width:5px; height:5px; border-radius:50%; background:currentColor; animation:pulse 2s infinite; }
.topbar-stats { display:flex; gap:16px; }
.stat { display:flex; flex-direction:column; align-items:center; gap:1px; }
.stat-label { font-size:8px; font-weight:500; letter-spacing:0.1em; color:var(--text-dim); text-transform:uppercase; }
.stat-value { font-size:12px; font-weight:600; }
.topbar-toggles { display:flex; gap:6px; align-items:center; }
.toggle-btn { padding:4px 11px; border-radius:20px; font-size:11px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.2s; }
.toggle-btn:hover { color:var(--text); }
.toggle-active { background:rgba(255,255,255,0.07) !important; }
.origin-toggle { display:flex; border-radius:20px; overflow:hidden; border:1px solid var(--border); }
.origin-toggle button { padding:4px 9px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); color:var(--text-dim); border:none; border-left:1px solid var(--border); transition:all 0.15s; }
.origin-toggle button:first-child { border-left:none; }
.origin-toggle button:hover { color:var(--text); }
.origin-toggle button.active { background:rgba(204,34,0,0.15); color:#ff6666; }
.origin-count { display:inline-block; font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; }
/* ── Left dock ── */
.left-dock {
position:absolute; left:0; top:50%; transform:translateY(-50%);
display:flex; flex-direction:column; gap:2px;
background:var(--panel-bg); border:1px solid var(--border);
border-left:none; border-radius:0 12px 12px 0;
padding:6px 4px; z-index:20;
}
.dock-btn {
display:flex; flex-direction:column; align-items:center; gap:3px;
padding:10px 10px; border-radius:8px; background:transparent;
color:var(--text-dim); transition:all 0.15s; min-width:54px;
border:1px solid transparent; position:relative;
}
.dock-btn:hover { background:rgba(255,255,255,0.05); color:var(--text); }
.dock-btn.dock-active { background:rgba(56,189,248,0.08); color:var(--accent-out); border-color:rgba(56,189,248,0.2); }
.dock-icon { font-size:16px; line-height:1; }
.dock-label { font-size:9px; font-weight:500; letter-spacing:0.04em; white-space:nowrap; }
.dock-count { position:absolute; top:5px; right:5px; font-size:8px; font-family:var(--mono); background:rgba(56,189,248,0.2); color:var(--accent-out); border-radius:8px; padding:0 4px; }
/* ── Panel stack ── */
.panel-stack {
position:absolute; left:70px; top:74px;
display:flex; flex-direction:row; gap:8px;
height:calc(100vh - 88px); z-index:20;
pointer-events:none;
}
.side-panel {
width:272px; height:100%; display:flex; flex-direction:column;
overflow:hidden; pointer-events:all; animation:fadeIn 0.15s ease;
flex-shrink:0;
}
.side-panel-header {
display:flex; align-items:center; gap:8px; padding:10px 12px;
border-bottom:1px solid var(--border); flex-shrink:0;
font-size:12px; font-weight:600;
}
.side-panel-header .tab-count {
background:rgba(56,189,248,0.1); color:var(--accent-out);
font-size:9px; padding:1px 5px; border-radius:8px; font-family:var(--mono);
}
.side-panel-header .close-btn { margin-left:auto; }
/* ── Search ── */
.search-wrap { display:flex; align-items:center; gap:6px; margin:7px 8px 3px; padding:6px 9px; background:rgba(255,255,255,0.03); border:1px solid var(--border); border-radius:7px; flex-shrink:0; transition:border-color 0.15s; }
.search-wrap:focus-within { border-color:var(--border-h); }
.search-icon { font-size:13px; color:var(--text-dim); flex-shrink:0; }
.search-input { flex:1; background:none; border:none; outline:none; font-family:var(--ui); font-size:11px; color:var(--text); }
.search-input::placeholder { color:var(--text-dim); }
.search-clear { background:none; color:var(--text-dim); font-size:10px; }
.search-clear:hover { color:var(--text); }
/* ── Device list ── */
.device-list { overflow-y:auto; flex:1; padding:3px 8px 8px; display:flex; flex-direction:column; gap:2px; }
.device-item { display:flex; align-items:center; gap:8px; padding:7px 8px; border-radius:7px; background:transparent; border:1px solid transparent; color:var(--text); text-align:left; width:100%; transition:all 0.12s; }
.device-item:hover { background:rgba(255,255,255,0.03); border-color:var(--border); }
.device-item.selected { background:rgba(56,189,248,0.07); border-color:rgba(56,189,248,0.28); }
.all-devices { border-style:dashed; border-color:var(--border); margin-bottom:3px; }
.all-devices.selected { border-style:solid; }
.device-icon { font-size:15px; flex-shrink:0; }
.device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.device-name { font-size:11px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.device-sub { font-size:9px; color:var(--text-dim); }
.device-traffic { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; }
/* ── Flow log ── */
.flow-log { overflow-y:auto; flex:1; padding:5px 8px; display:flex; flex-direction:column; gap:2px; }
.flow-entry { display:flex; align-items:center; gap:7px; padding:5px 7px; border-radius:6px; background:rgba(255,255,255,0.02); border:1px solid transparent; font-size:11px; transition:background 0.1s; }
.flow-entry:hover { background:rgba(255,255,255,0.05); }
.flow-entry.flow-blocked { border-color:rgba(204,34,0,0.2); background:rgba(204,34,0,0.05); }
.flow-dir { font-size:12px; font-weight:700; flex-shrink:0; width:13px; text-align:center; }
.flow-dir.out { color:var(--accent-out); }
.flow-dir.in { color:var(--accent-in); }
.flow-dir.blocked { color:var(--blocked); }
.flow-detail { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.flow-device { font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.flow-dest { font-size:9px; color:var(--text-dim); }
.flow-ip { font-size:8px; color:var(--text-dim); opacity:0.6; }
.flow-bytes { font-size:9px; color:var(--text-dim); flex-shrink:0; font-family:var(--mono); }
/* ── Top talkers ── */
.talkers-sub { font-size:9px; color:var(--text-dim); font-weight:400; }
.talker-list { padding:8px; display:flex; flex-direction:column; gap:8px; overflow-y:auto; flex:1; }
.talker-row { display:flex; align-items:center; gap:8px; }
.talker-rank { font-size:10px; color:var(--text-dim); font-family:var(--mono); width:12px; flex-shrink:0; }
.talker-info { flex:1; min-width:0; }
.talker-name { font-size:11px; font-weight:500; display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-bottom:4px; }
.talker-bar-wrap { height:2px; background:rgba(255,255,255,0.06); border-radius:1px; }
.talker-bar { height:100%; background:var(--accent-out); border-radius:1px; transition:width 0.5s ease; }
.talker-bytes { display:flex; flex-direction:column; align-items:flex-end; gap:1px; font-size:9px; font-family:var(--mono); flex-shrink:0; }
/* ── Arc popup ── */
.arc-popup { position:absolute; top:74px; right:14px; width:240px; padding:13px; z-index:30; animation:fadeIn 0.15s ease; }
.arc-popup-header { display:flex; align-items:center; justify-content:space-between; margin-bottom:11px; }
.arc-popup-dir { font-size:11px; font-weight:600; letter-spacing:0.05em; }
.arc-row { display:flex; justify-content:space-between; align-items:baseline; padding:4px 0; border-bottom:1px solid rgba(255,255,255,0.04); font-size:11px; }
.arc-row:last-child { border-bottom:none; }
.arc-label { color:var(--text-dim); flex-shrink:0; margin-right:8px; }
.arc-value { font-weight:500; text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:160px; }
/* ── Device detail ── */
.device-detail { position:absolute; bottom:14px; right:14px; width:230px; padding:13px; z-index:20; animation:fadeIn 0.15s ease; }
.detail-header { display:flex; align-items:center; gap:9px; margin-bottom:10px; }
.detail-icon { font-size:20px; }
.detail-name { font-size:12px; font-weight:600; }
.detail-mac { font-size:9px; color:var(--text-dim); font-family:var(--mono); }
.detail-grid { display:flex; flex-direction:column; gap:5px; }
.detail-row { display:flex; justify-content:space-between; align-items:center; font-size:11px; }
.detail-label { color:var(--text-dim); }
.detail-value { font-weight:500; }
/* ── Shared close btn ── */
.close-btn { background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); border-radius:5px; width:21px; height:21px; font-size:9px; display:flex; align-items:center; justify-content:center; transition:all 0.12s; flex-shrink:0; }
.close-btn:hover { color:var(--text); }
/* ── Color picker ── */
.color-picker { position:absolute; bottom:60px; right:14px; width:240px; padding:14px; z-index:30; animation:fadeIn 0.15s ease; }
.picker-header { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
.picker-title { font-size:12px; font-weight:600; flex:1; }
.picker-reset { font-size:10px; padding:3px 8px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text-dim); }
.picker-reset:hover { color:var(--text); }
.picker-rows { display:flex; flex-direction:column; gap:8px; margin-bottom:14px; }
.color-row { display:flex; align-items:center; justify-content:space-between; }
.color-label { font-size:11px; color:var(--text-dim); }
.color-input-wrap { display:flex; align-items:center; gap:7px; }
.color-native { width:28px; height:22px; padding:0; border:1px solid var(--border); border-radius:5px; background:none; cursor:pointer; }
.color-native::-webkit-color-swatch-wrapper { padding:2px; }
.color-native::-webkit-color-swatch { border:none; border-radius:3px; }
.color-hex { font-size:10px; color:var(--text-dim); width:54px; }
.picker-preset-label { font-size:9px; text-transform:uppercase; letter-spacing:0.1em; color:var(--text-dim); display:block; margin-bottom:7px; }
.preset-btns { display:flex; flex-wrap:wrap; gap:5px; }
.preset-btn { display:flex; align-items:center; gap:5px; padding:4px 9px; border-radius:6px; font-size:10px; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.12s; }
.preset-btn:hover { color:var(--text); border-color:var(--border-h); }
.preset-swatch { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
/* ── Floating draggable panels ── */
.floating-panel {
position: absolute;
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 25;
animation: fadeIn 0.15s ease;
min-width: 220px;
min-height: 160px;
}
.fp-header {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 12px;
border-bottom: 1px solid var(--border);
cursor: grab;
flex-shrink: 0;
user-select: none;
}
.fp-header:active { cursor: grabbing; }
.fp-title {
font-size: 12px;
font-weight: 600;
flex: 1; /* takes all space, pushes everything else right */
}
/* badge / sub-label next to title */
.fp-header .tab-count,
.fp-header .talkers-sub {
flex-shrink: 0;
}
/* export button — same height as close btn */
.export-btn-sm {
font-size: 9px;
padding: 0 8px;
height: 21px;
line-height: 1;
border-radius: 5px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--border);
color: var(--text-dim);
transition: all 0.15s;
flex-shrink: 0;
display: flex;
align-items: center;
}
.export-btn-sm:hover { color: var(--text); }
.fp-body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.fp-resize {
position: absolute;
bottom: 2px;
right: 4px;
font-size: 14px;
color: var(--text-dim);
cursor: se-resize;
opacity: 0.4;
line-height: 1;
user-select: none;
}
.fp-resize:hover { opacity: 0.9; }
/* fp-body children that need to scroll */
.fp-body .device-list,
.fp-body .flow-log,
.fp-body .talker-list {
flex: 1;
overflow-y: auto;
}
/* search inside floating panel */
.fp-body .search-wrap { flex-shrink: 0; }
/* ── Protocol breakdown ── */
.proto-list { padding:10px 12px; display:flex; flex-direction:column; gap:10px; overflow-y:auto; flex:1; }
.proto-row { display:flex; flex-direction:column; gap:4px; }
.proto-name { font-size:12px; font-weight:600; }
.proto-bar-wrap { height:4px; background:rgba(255,255,255,0.06); border-radius:2px; }
.proto-bar { height:100%; border-radius:2px; transition:width 0.4s ease; }
.proto-stats { display:flex; gap:10px; font-size:10px; color:var(--text-dim); }
.proto-pct { min-width:36px; }
.proto-bytes { font-family:var(--mono); }
.proto-count { font-family:var(--mono); opacity:0.6; }
/* ── Replay bar ── */
.replay-bar {
position:absolute; bottom:20px; left:50%; transform:translateX(-50%);
padding:10px 16px; z-index:30; min-width:480px;
animation:fadeIn 0.15s ease;
}
.replay-header { display:flex; align-items:center; gap:12px; margin-bottom:8px; font-size:11px; }
.replay-label { font-weight:600; color:var(--accent-out); }
.replay-time { color:var(--text); }
.replay-count { color:var(--text-dim); flex:1; }
.replay-live { padding:3px 10px; border-radius:6px; background:rgba(56,189,248,0.1); border:1px solid rgba(56,189,248,0.3); color:var(--accent-out); font-size:10px; }
.replay-live:hover { background:rgba(56,189,248,0.2); }
.replay-controls { display:flex; align-items:center; gap:8px; }
.replay-btn { width:28px; height:28px; border-radius:6px; background:rgba(255,255,255,0.06); border:1px solid var(--border); color:var(--text); font-size:13px; display:flex; align-items:center; justify-content:center; flex-shrink:0; }
.replay-btn:hover { border-color:var(--border-h); color:var(--accent-out); }
.replay-slider { flex:1; accent-color:var(--accent-out); cursor:pointer; }
.replay-pos { font-size:10px; color:var(--text-dim); min-width:32px; text-align:right; }
/* ── Dock divider ── */
.dock-divider { height:1px; background:var(--border); margin:4px 6px; }
/* ── Heatmap legend ── */
.heatmap-legend {
position:absolute; bottom:20px; right:70px;
padding:10px 14px; z-index:20; min-width:180px;
animation:fadeIn 0.15s ease;
}
.heatmap-title { font-size:10px; font-weight:600; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; margin-bottom:8px; }
.heatmap-scale { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
.heatmap-min, .heatmap-max { font-size:10px; font-family:var(--mono); color:var(--text-dim); flex-shrink:0; }
.heatmap-gradient {
flex:1; height:6px; border-radius:3px;
background: linear-gradient(to right, rgba(0,40,60,0.4), rgba(0,130,150,0.6), rgba(0,200,220,0.85));
}
.heatmap-top { font-size:10px; color:var(--text-dim); }
.heatmap-top strong { color:var(--accent-out); }
.heatmap-hover { font-size:10px; color:var(--text); margin-top:4px; font-family:var(--mono); }
/* ── Traffic timeline ── */
.timeline {
position:absolute; bottom:0; left:0; right:0;
padding:5px 14px 5px; z-index:15;
border-radius:0; border-left:none; border-right:none; border-bottom:none;
background:rgba(4,8,16,0.92);
user-select:none;
}
.timeline-header {
display:flex; align-items:center; gap:12px; margin-bottom:3px;
}
.timeline-label { font-size:9px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.08em; flex:1; }
.timeline-stats { display:flex; gap:12px; font-size:9px; font-family:var(--mono); }
.timeline-stat-out { color:var(--accent-out); }
.timeline-stat-in { color:var(--accent-in); }
.timeline-toggles { display:flex; gap:4px; }
.timeline-toggle {
padding:1px 7px; border-radius:10px; font-size:9px; font-weight:500;
background:rgba(255,255,255,0.04); border:1px solid var(--border);
color:var(--text-dim); transition:all 0.15s; cursor:pointer;
}
.timeline-toggle:hover { color:var(--text); }
.timeline-toggle.on-out { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); }
.timeline-toggle.on-in { background:rgba(244,114,182,0.1); color:var(--accent-in); border-color:rgba(244,114,182,0.3); }
.timeline-wrap { position:relative; }
.timeline-svg { display:block; width:100%; cursor:crosshair; }
.timeline-tooltip {
position:absolute; top:-36px; transform:translateX(-50%);
background:var(--panel-bg); border:1px solid var(--border);
padding:3px 8px; border-radius:6px; font-size:9px; font-family:var(--mono);
white-space:nowrap; pointer-events:none; z-index:5;
}
.timeline-cursor { position:absolute; top:0; bottom:0; width:1px; background:rgba(255,255,255,0.3); pointer-events:none; }
/* ── Country filter badge ── */
.country-badge {
position:absolute; top:74px; left:50%; transform:translateX(-50%);
display:flex; align-items:center; gap:10px;
padding:6px 12px; z-index:25; font-size:11px;
animation:fadeIn 0.15s ease;
}
.country-badge strong { color:var(--accent-out); }
.country-badge-count { color:var(--text-dim); font-size:10px; }
/* ── Device group dot ── */
.group-dot { display:inline-block; width:6px; height:6px; border-radius:50%; margin-right:5px; vertical-align:middle; flex-shrink:0; }
/* ── Groups panel ── */
.groups-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
.group-filters { display:flex; flex-wrap:wrap; gap:4px; padding:8px; flex-shrink:0; border-bottom:1px solid var(--border); }
.group-filter-btn { padding:3px 10px; border-radius:20px; font-size:10px; font-weight:500; background:rgba(255,255,255,0.04); border:1px solid var(--border); color:var(--text-dim); transition:all 0.15s; }
.group-filter-btn:hover { color:var(--text); }
.group-filter-btn.active { background:rgba(56,189,248,0.1); color:var(--accent-out); border-color:rgba(56,189,248,0.3); }
.group-count { font-size:8px; font-family:var(--mono); background:rgba(255,255,255,0.1); border-radius:8px; padding:0 4px; margin-left:3px; }
.group-device-list { overflow-y:auto; flex:1; padding:4px 8px; display:flex; flex-direction:column; gap:3px; }
.group-device-row { display:flex; align-items:center; gap:8px; padding:5px 6px; border-radius:6px; transition:background 0.1s; }
.group-device-row:hover { background:rgba(255,255,255,0.03); }
.group-device-info { flex:1; min-width:0; display:flex; flex-direction:column; gap:1px; }
.group-select {
background:rgba(255,255,255,0.06); border:1px solid var(--border);
color:var(--text-dim); border-radius:5px; font-size:9px; padding:2px 4px;
cursor:pointer; flex-shrink:0; font-family:var(--ui);
}
.group-select:hover { border-color:var(--border-h); }
.group-select option { background:#0d1829; }
/* ── Misc ── */
.geoip-warning { position:absolute; bottom:14px; left:50%; transform:translateX(-50%); padding:7px 14px; font-size:11px; color:var(--yellow); border-color:rgba(251,191,36,0.15); z-index:20; white-space:nowrap; }
.empty-state { color:var(--text-dim); font-size:11px; text-align:center; padding:24px 12px; }
+7
View File
@@ -0,0 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
+22
View File
@@ -0,0 +1,22 @@
import { useState, useCallback } from 'react';
export function useLocalStorage(key, defaultValue) {
const [value, setValueState] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : defaultValue;
} catch {
return defaultValue;
}
});
const setValue = useCallback((newVal) => {
setValueState(prev => {
const next = typeof newVal === 'function' ? newVal(prev) : newVal;
try { localStorage.setItem(key, JSON.stringify(next)); } catch {}
return next;
});
}, [key]);
return [value, setValue];
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState, useCallback } from 'react';
const WS_URL = import.meta.env.VITE_WS_URL ||
`${window.location.protocol === 'https:' ? 'wss' : 'ws'}://${window.location.host}/ws`;
export function useNetMapSocket() {
const [connected, setConnected] = useState(false);
const [devices, setDevices] = useState([]);
const [flows, setFlows] = useState([]);
const [home, setHome] = useState({ lat: 59.9139, lon: 10.7522, name: 'Home' });
const [geoipLoaded, setGeoipLoaded] = useState(true);
const wsRef = useRef(null);
const reconnectTimer = useRef(null);
const flowIdSet = useRef(new Set());
const addFlows = useCallback((newFlows) => {
const fresh = newFlows.filter(f => !flowIdSet.current.has(f.id));
fresh.forEach(f => flowIdSet.current.add(f.id));
// Keep flowId set from growing unbounded
if (flowIdSet.current.size > 2000) {
const arr = [...flowIdSet.current];
arr.slice(0, 500).forEach(id => flowIdSet.current.delete(id));
}
if (fresh.length === 0) return;
setFlows(prev => {
const combined = [...prev, ...fresh];
// Keep last 300 flows in state
return combined.slice(-300);
});
}, []);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(WS_URL);
wsRef.current = ws;
ws.onopen = () => {
setConnected(true);
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'init') {
setHome(msg.home);
setDevices(msg.devices || []);
setGeoipLoaded(msg.geoip_loaded !== false);
if (msg.recent_flows) addFlows(msg.recent_flows);
} else if (msg.type === 'devices') {
setDevices(msg.devices || []);
} else if (msg.type === 'flows') {
addFlows(msg.flows || []);
}
} catch (_) {
}
};
ws.onclose = () => {
setConnected(false);
reconnectTimer.current = setTimeout(connect, 3000);
};
ws.onerror = () => ws.close();
}, [addFlows]);
useEffect(() => {
connect();
return () => {
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
wsRef.current?.close();
};
}, [connect]);
return { connected, devices, flows, home, geoipLoaded };
}
+87
View File
@@ -0,0 +1,87 @@
export function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}
export function formatUptime(seconds) {
if (!seconds) return '—';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function protoName(n) {
const PROTOS = { 1:'ICMP', 2:'IGMP', 6:'TCP', 17:'UDP', 41:'IPv6', 47:'GRE', 50:'ESP', 51:'AH', 58:'ICMPv6', 89:'OSPF', 132:'SCTP' };
return PROTOS[n] || (n ? `IP/${n}` : '—');
}
export function protoColor(n) {
const COLORS = { 6:'#38bdf8', 17:'#a78bfa', 1:'#fbbf24', 58:'#fb923c' };
return COLORS[n] || '#64748b';
}
export function serviceLabel(port, proto) {
const TCP = {
20:'FTP-data', 21:'FTP', 22:'SSH', 23:'Telnet', 25:'SMTP', 53:'DNS',
80:'HTTP', 110:'POP3', 143:'IMAP', 443:'HTTPS', 465:'SMTPS', 587:'SMTP',
993:'IMAPS', 995:'POP3S', 1194:'OpenVPN', 1883:'MQTT', 3306:'MySQL',
3389:'RDP', 5900:'VNC', 6443:'K8s API', 8080:'HTTP-alt', 8443:'HTTPS-alt',
8883:'MQTT-TLS', 9000:'Custom', 51820:'WireGuard',
};
const UDP = {
53:'DNS', 67:'DHCP', 68:'DHCP', 123:'NTP', 161:'SNMP', 500:'IKE',
514:'Syslog', 1194:'OpenVPN', 4500:'IPsec', 5353:'mDNS', 51820:'WireGuard',
};
if (proto === 6 && TCP[port]) return TCP[port];
if (proto === 17 && UDP[port]) return UDP[port];
if (TCP[port]) return TCP[port];
return port ? `port ${port}` : '';
}
export const DEVICE_GROUPS = ['IoT', 'Servers', 'Mobile', 'Network', 'Desktop', 'Media'];
export const GROUP_COLORS = {
IoT:'#f59e0b', Servers:'#3b82f6', Mobile:'#ec4899',
Network:'#10b981', Desktop:'#8b5cf6', Media:'#f97316',
};
export function timeAgo(ms) {
if (!ms) return '—';
const sec = Math.floor((Date.now() - ms) / 1000);
if (sec < 10) return 'just now';
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
return `${Math.floor(min / 60)}h ago`;
}
export function deviceIcon(device) {
if (device.is_wired) return '🖥';
const oui = (device.oui || '').toLowerCase();
if (oui.includes('apple')) return '🍎';
if (oui.includes('samsung')) return '📱';
if (oui.includes('sony')) return '🎮';
if (oui.includes('lg')) return '📺';
if (oui.includes('intel')) return '💻';
if (oui.includes('raspberry')) return '🍓';
return '📡';
}
// Map app/category codes to readable names
const APP_NAMES = {
0: 'Other', 1: 'Instant messaging', 2: 'P2P', 3: 'Thumbnails',
4: 'Streaming media', 5: 'VoIP', 6: 'Gaming', 7: 'Remote access',
8: 'News feeds', 9: 'Streaming video', 10: 'Email', 11: 'News',
12: 'Downloads', 13: 'Business apps', 14: 'Social networking',
15: 'Web browsing', 255: 'Unknown',
};
export function appName(cat) {
if (typeof cat === 'number') return APP_NAMES[cat] || `Cat ${cat}`;
return cat || 'Unknown';
}
export const OUT_COLOR = '#38bdf8';
export const IN_COLOR = '#f472b6';
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'build',
},
server: {
port: 3000,
},
esbuild: {
loader: 'jsx',
include: /src\/.*\.[jt]sx?$/,
},
})