From 69f2ee866a8e945d8a6e5d4a8dd78d2369486ae6 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Apr 2026 11:21:39 +0200 Subject: [PATCH] Add project files: source code, Docker setup, docs and config templates Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 53 +++ .gitignore | 20 + DOKU.md | 620 ++++++++++++++++++++++++++ Dockerfile | 26 ++ docker-compose.yml | 23 + requirements.txt | 7 + src/api.py | 398 +++++++++++++++++ src/database.py | 528 ++++++++++++++++++++++ src/fritz.py | 225 ++++++++++ src/main.py | 67 +++ src/monitor.py | 724 ++++++++++++++++++++++++++++++ src/notify.py | 148 ++++++ src/scanner.py | 163 +++++++ src/switch.py | 149 +++++++ src/web/app.js | 1065 ++++++++++++++++++++++++++++++++++++++++++++ src/web/index.html | 673 ++++++++++++++++++++++++++++ src/web/style.css | 292 ++++++++++++ start.sh | 35 ++ 18 files changed, 5216 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DOKU.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 src/api.py create mode 100644 src/database.py create mode 100644 src/fritz.py create mode 100644 src/main.py create mode 100644 src/monitor.py create mode 100644 src/notify.py create mode 100644 src/scanner.py create mode 100644 src/switch.py create mode 100644 src/web/app.js create mode 100644 src/web/index.html create mode 100644 src/web/style.css create mode 100755 start.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d180647 --- /dev/null +++ b/.env.example @@ -0,0 +1,53 @@ +# FritzBox Zugangsdaten +FRITZ_HOST=192.168.178.1 +FRITZ_USER= +FRITZ_PASSWORD=dein_passwort_hier + +# Netzwerk Einstellungen +MONITOR_INTERFACE=auto +NETWORK_RANGE=auto + +# Monitoring Intervalle (Sekunden) +TRAFFIC_INTERVAL=60 +OUTAGE_CHECK_INTERVAL=30 +DEVICE_SCAN_INTERVAL=300 +PORT_SCAN_INTERVAL=3600 +FRITZ_POLL_INTERVAL=60 +# Perma-Ping der .env Ziele (Sekunden) +PING_TARGET_INTERVAL=5 +# Ping aller anderen Netzwerk-Geräte (Sekunden) +DEVICE_PING_INTERVAL=30 + +# Ping-Ziele zur Ausfallserkennung (kommasepariert) +PING_TARGETS=8.8.8.8,1.1.1.1,9.9.9.9 + +# Datenbank +DB_PATH=/data/netmon.db + +# Web Server +WEB_HOST=0.0.0.0 +WEB_PORT=5000 + +# Log Level: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=INFO + +# Switch Monitoring (Netgear ProSafe Plus) +SWITCH_ENABLED=false +SWITCH_HOST=192.168.1.33 +SWITCH_PASSWORD=password +# Switch-Port an dem das Gateway (FritzBox) hängt +SWITCH_GATEWAY_PORT=1 +# Poll-Intervall in Sekunden +SWITCH_INTERVAL=30 + +# Paketmitschnitt +CAPTURE_ENABLED=true +CAPTURE_MAX_ROWS=50000 +# Einzelne Protokolle/Ports rausfiltern (true/false) +CAPTURE_FILTER_ICMP=true +CAPTURE_FILTER_ARP=true +CAPTURE_FILTER_ZABBIX=true +CAPTURE_FILTER_RUSTDESK=true +# Zusätzliche BPF-Filter (werden mit AND verknüpft, leer lassen wenn nicht benötigt) +# Beispiel: "not port 5353 and not port 1900" +CAPTURE_FILTER_EXTRA= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..898800e --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Secrets / Zugangsdaten +.env + +# Python +.venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd + +# Daten & Logs +data/ + +# IDE +.idea/ +.vscode/ + +# Screenshots +*.png +*.jpg diff --git a/DOKU.md b/DOKU.md new file mode 100644 index 0000000..9ac34bc --- /dev/null +++ b/DOKU.md @@ -0,0 +1,620 @@ +# NetMon – Netzwerk Monitor + +Vollständiges Netzwerk-Monitoring-System mit Live-Paketmitschnitt, Ausfallserkennung, Geräteverfolgung und FritzBox-Integration. Läuft 24/7 als Docker-Container und speichert alle Daten in einer SQLite-Datenbank. Auswertung über ein Web-Dashboard. + +--- + +## Inhaltsverzeichnis + +1. [Schnellstart](#1-schnellstart) +2. [Architektur](#2-architektur) +3. [Konfiguration (.env)](#3-konfiguration-env) +4. [Funktionen](#4-funktionen) +5. [Web-Dashboard](#5-web-dashboard) +6. [REST-API](#6-rest-api) +7. [Datenbank-Schema](#7-datenbank-schema) +8. [Deployment](#8-deployment) +9. [Troubleshooting](#9-troubleshooting) + +--- + +## 1. Schnellstart + +```bash +cd /root/netmon + +# 1. Konfiguration anlegen +cp .env.example .env +nano .env # mindestens FRITZ_PASSWORD setzen + +# 2. Starten +docker compose up -d --build + +# 3. Dashboard öffnen +# http://:5000 +``` + +--- + +## 2. Architektur + +``` +┌─────────────────────────────────────────────────────────┐ +│ main.py (Einstiegspunkt) │ +│ .env laden → DB initialisieren → Threads starten │ +│ → Flask-Webserver starten │ +└────────────────────────┬────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ┌────▼─────┐ ┌────▼────┐ ┌────▼────┐ + │ monitor │ │ api │ │database │ + │ 7 Threads│ │ Flask │ │ SQLite │ + └────┬─────┘ └────┬────┘ └─────────┘ + │ │ + │ ┌──────────┴──────────┐ + │ │ REST-API-Endpunkte │ + │ │ Statische Dateien │ + │ └─────────────────────┘ + │ + ┌────┴──────────────────────────────────┐ + │ Thread 1 │ Traffic-Loop (60s) │ + │ Thread 2 │ Outage-Loop (5s) │ + │ Thread 3 │ Device-Loop (300s) │ + │ Thread 4 │ Port-Loop (3600s) │ + │ Thread 5 │ FritzBox-Loop (60s) │ + │ Thread 6 │ Cleanup-Loop (24h) │ + │ Thread 7 │ Capture-Loop (dauerhaft)│ + └───────────────────────────────────────┘ +``` + +### Komponenten + +| Datei | Aufgabe | +|---|---| +| `src/main.py` | Einstiegspunkt, startet alles | +| `src/monitor.py` | Alle 7 Monitoring-Threads | +| `src/database.py` | SQLite-Zugriff, alle DB-Funktionen | +| `src/fritz.py` | FritzBox TR-064 Client | +| `src/scanner.py` | ARP-Scan, nmap Port-Scan, Vendor-Lookup | +| `src/api.py` | Flask REST-API + statische Dateien | +| `src/web/` | HTML/CSS/JS Frontend | + +### Technologie-Stack + +| Bereich | Technologie | +|---|---| +| Backend | Python 3.11, Flask 3.0 | +| Datenbank | SQLite (WAL-Mode, thread-safe) | +| Paketmitschnitt | scapy | +| Port-Scan | python-nmap | +| Traffic-Messung | psutil | +| Router-Integration | fritzconnection (TR-064) | +| Frontend | Bootstrap 5.3 (Dark Theme), Chart.js 4 | +| Container | Docker, host-network, privileged | + +--- + +## 3. Konfiguration (.env) + +Alle Einstellungen erfolgen ausschließlich über die `.env`-Datei im Projektordner. + +### FritzBox + +```env +FRITZ_HOST=192.168.178.1 # IP der FritzBox (Standard AVM: 192.168.178.1) +FRITZ_USER= # Benutzername (leer = kein Auth) +FRITZ_PASSWORD=dein_passwort # Passwort – PFLICHTFELD für FritzBox-Integration +``` + +> Ohne `FRITZ_PASSWORD` läuft das System weiter, FritzBox-Daten fehlen aber im Dashboard. + +### Netzwerk + +```env +MONITOR_INTERFACE=auto # "auto" = primäres Interface, oder z.B. "eth0", "ens18" +NETWORK_RANGE=auto # "auto" = wird aus Interface berechnet, oder "192.168.1.0/24" +``` + +### Monitoring-Intervalle (Sekunden) + +```env +TRAFFIC_INTERVAL=60 # Bandbreiten-Messung alle 60s +OUTAGE_CHECK_INTERVAL=5 # Ping-Prüfung alle 5s +DEVICE_SCAN_INTERVAL=300 # Geräte-Scan alle 5 Minuten +PORT_SCAN_INTERVAL=3600 # Port-Scan alle 60 Minuten +FRITZ_POLL_INTERVAL=60 # FritzBox-Abfrage alle 60s +OUTAGE_THRESHOLD=2 # Aufeinanderfolgende Fehlschläge bis Ausfall erkannt wird +``` + +### Ausfalls-Erkennung + +```env +PING_TARGETS=8.8.8.8,1.1.1.1,9.9.9.9 # Kommaseparierte Ping-Ziele + # Gateway wird automatisch hinzugefügt +``` + +> Der Default-Gateway wird immer automatisch erkannt und als erstes Ziel hinzugefügt. + +### Paketmitschnitt + +```env +CAPTURE_ENABLED=true # Mitschnitt ein/aus + +# Protokoll-Filter (true = herausfiltern, false = mitschneiden) +CAPTURE_FILTER_ICMP=true # Ping/ICMP ausblenden +CAPTURE_FILTER_ARP=true # ARP ausblenden +CAPTURE_FILTER_ZABBIX=true # Zabbix (Port 10050/10051) ausblenden +CAPTURE_FILTER_RUSTDESK=true # RustDesk (Port 21115-21119) ausblenden + +# Eigener BPF-Filter (wird mit AND angehängt) +CAPTURE_FILTER_EXTRA= # z.B.: not port 5353 and not port 1900 + +CAPTURE_MAX_ROWS=50000 # Max. Einträge in DB (Rolling Window) +``` + +**BPF-Filter Beispiele für `CAPTURE_FILTER_EXTRA`:** + +```bash +# mDNS und SSDP rausfiltern +CAPTURE_FILTER_EXTRA=not port 5353 and not port 1900 + +# Nur lokalen Traffic anzeigen +CAPTURE_FILTER_EXTRA=dst net 192.168.1.0/24 + +# Bestimmten Host ausblenden +CAPTURE_FILTER_EXTRA=not host 192.168.1.50 +``` + +### Datenbank & Web + +```env +DB_PATH=/data/netmon.db # Pfad zur SQLite-Datenbank im Container +WEB_HOST=0.0.0.0 # Bind-Adresse (0.0.0.0 = alle Interfaces) +WEB_PORT=5000 # HTTP-Port +LOG_LEVEL=INFO # DEBUG / INFO / WARNING / ERROR +``` + +--- + +## 4. Funktionen + +### Traffic-Messung + +Misst alle 60 Sekunden die Bandbreite auf dem überwachten Interface via `psutil`. Gespeichert werden: +- Bytes/s (Upload + Download) +- Paketanzahl +- Delta-Bytes seit letzter Messung + +Daten werden 35 Tage aufbewahrt und täglich automatisch bereinigt. + +### Ausfalls-Erkennung + +Alle 5 Sekunden werden alle Ping-Ziele **gleichzeitig** (parallel) angepingt: + +- Timeout: 1 Sekunde pro Ping +- Erst wenn **alle** Ziele nicht antworten, wird ein Ausfall registriert +- Nach `OUTAGE_THRESHOLD` aufeinanderfolgenden Fehlschlägen → Ausfall-Eintrag in DB +- Jeder einzelne Ping-Versuch wird geloggt (auch kurze Blips unter der Ausfall-Schwelle sichtbar) +- Der Default-Gateway wird automatisch zu den Zielen hinzugefügt + +**Warum 2-fache Schwelle?** Verhindert Fehlalarme durch einzelne Paketverluste im Netz. + +### Geräte-Erkennung + +Alle 5 Minuten: +1. **ARP-Scan** des gesamten Netzwerks via scapy → erkennt alle aktiven Geräte +2. **Fallback auf nmap** wenn scapy nicht verfügbar/keine Rechte +3. **FritzBox-Abgleich** → Gerätenamen und Verbindungstyp (LAN/WLAN/Gast) +4. **Reverse-DNS** → Hostname-Auflösung +5. **MAC-Vendor-Lookup** → Hersteller aus MAC-Präfix (Apple, Samsung, Raspberry Pi, etc.) + +Geräte die nicht mehr gesehen werden, bekommen `is_active=0`. + +### Port-Scan + +Jede Stunde wird für alle aktiven Geräte ein **nmap-Quick-Scan** durchgeführt: + +Gescannte Ports: `21-23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 548, 993, 995, 1080, 1194, 1433, 1883, 2049, 3306, 3389, 4000, 5000, 5432, 5900, 6379, 8080, 8443, 8883, 9100, 27017` + +Ergebnisse werden pro Gerät gespeichert und im Dashboard angezeigt. + +### FritzBox-Integration + +Alle 60 Sekunden werden via TR-064 API abgefragt: +- Verbindungsstatus (online/offline) +- Verbindungszeit (Uptime seit letztem Reconnect) +- Maximale Up-/Downloadrate (vom Provider) +- Gesamte übertragene Datenmenge +- Aktuelle externe IP-Adresse +- Liste aller bekannten Geräte (Name, IP, MAC, Interface-Typ) + +Bei Verbindungsverlust zur FritzBox wird automatisch alle 10 Minuten ein Reconnect versucht. + +### Live-Paketmitschnitt + +Dauerhafter Mitschnitt via scapy auf dem konfigurierten Interface. Gespeichert wird nur **Metadaten** (kein Payload): + +| Feld | Inhalt | +|---|---| +| Zeitstempel | Millisekunden-Präzision | +| Quelle | IP:Port | +| Ziel | IP:Port | +| Protokoll | TCP/UDP/DNS/HTTP/HTTPS/SSH/SMB/... | +| Länge | Bytes | +| Flags | TCP-Flags (SYN/ACK/FIN/RST/PSH) | +| Info | Lesbare Zusammenfassung | + +Erkannte Protokolle: `TCP, UDP, HTTP, HTTPS, SSH, FTP, SMTP, DNS, DHCP, NTP, mDNS, SSDP, SMB, RDP, VNC, MySQL, PostgreSQL, Redis, MongoDB, MQTT, AFP` + +Rolling Window: Älteste Einträge werden gelöscht wenn `CAPTURE_MAX_ROWS` erreicht. + +--- + +## 5. Web-Dashboard + +Erreichbar unter `http://:5000` + +### Tab: Dashboard + +- **Status-Badge** (Navbar): Internet Online/Offline + aktuelle Geschwindigkeit +- **Stat-Karten**: Internet-Status · Aktive Geräte · Ausfälle heute · Ausfallzeit heute +- **Traffic-Chart**: Zeitreihe Upload/Download in kbps (Zeitbereiche: 1h / 6h / 24h / 7d / 30d) +- **Letzte Ausfälle**: Tabelle der 10 letzten Ausfälle mit Dauer + +### Tab: Geräte + +- Vollständige Geräteliste mit Status, IP, MAC, Hersteller, Verbindungstyp +- Suchfeld (filtert nach Name, IP, MAC, Hersteller) +- Toggle für inaktive Geräte +- **Port-Modal**: Klick auf Port-Anzahl → alle offenen Ports mit Service-Name + +### Tab: Ausfälle + +- **Ping-Statistik** pro Ziel: Checks · Erfolgsrate · Ausfallrate · Latenz (Ø/Min/Max) +- Zeitbereich wählbar (1h / 6h / 24h / 7d) +- **Ausfall-Protokoll**: Komplette History mit Start, Ende, Dauer, Status + +### Tab: FritzBox + +- Verbindungsstatus · Externe IP · Max. Download/Upload · Verbindungszeit +- Gesamter Datenverbrauch (seit letztem Router-Reset) +- **Verbindungsverlauf** (letzte 24h) als Chart + +### Tab: Mitschnitt + +- **Live-Paketliste** (aktualisiert alle 2 Sekunden) +- Protokoll-Farben (wie Wireshark): + - Blau = TCP · Grün = HTTP/HTTPS/SSH · Lila = UDP · Orange = DNS/DHCP · Rot = SMB/RDP +- Filterfeld: Echtzeit-Filter nach IP, Port, Protokoll +- Anzeige-Limit wählbar (100 / 200 / 500 / 1000 Pakete) +- Live/Pause-Toggle +- Auto-Scroll (optional) + +--- + +## 6. REST-API + +Basis-URL: `http://:5000/api` + +### GET /api/status + +Aktueller Gesamtstatus. + +```json +{ + "internet_up": true, + "bps_up": 125000, + "bps_down": 890000, + "active_devices": 12, + "total_devices": 18, + "outages_today": 2, + "total_outage_seconds_today": 37, + "current_outage_since": null, + "fritz": { + "available": true, + "is_connected": 1, + "external_ip": "93.x.x.x", + "uptime_s": 86400, + "max_up_bps": 10000000, + "max_down_bps": 50000000 + } +} +``` + +### GET /api/traffic?range=1h + +Traffic-Zeitreihe. `range`: `1h` | `6h` | `24h` | `7d` | `30d` + +```json +{ + "labels": ["12:00", "12:01", "12:02"], + "upload": [12.5, 8.3, 45.1], + "download": [89.2, 102.4, 78.9] +} +``` + +### GET /api/outages?limit=100&since=1700000000 + +Ausfall-History. + +```json +[ + { + "id": 42, + "start_ts": 1700012345, + "end_ts": 1700012382, + "duration_s": 37, + "duration_human": "37s", + "start_human": "15.11.2024 09:12:25", + "end_human": "15.11.2024 09:13:02" + } +] +``` + +### GET /api/devices + +Alle Geräte. + +```json +[ + { + "id": 1, + "mac": "aa:bb:cc:dd:ee:ff", + "ip": "192.168.1.42", + "name": "Mein Laptop", + "hostname": "laptop.local", + "fritz_name": "Mein Laptop", + "vendor": "Apple", + "iface_type": "802.11 ax", + "is_active": true, + "first_seen": 1700000000, + "last_seen": 1700012345, + "last_seen_human": "15.11.2024 09:12:25", + "port_count": 3 + } +] +``` + +### GET /api/devices/\/ports + +Offene Ports eines Geräts. + +```json +[ + {"port": 22, "protocol": "tcp", "service": "ssh", "state": "open"}, + {"port": 80, "protocol": "tcp", "service": "http", "state": "open"}, + {"port": 443, "protocol": "tcp", "service": "https", "state": "open"} +] +``` + +### GET /api/packets?since=\&limit=200&filter=\ + +Live-Paketmitschnitt. `since` = UNIX-Timestamp (float), nur neuere Pakete werden zurückgegeben. + +```json +[ + { + "ts": 1700012345.123, + "iface": "ens18", + "src_ip": "192.168.1.42", + "dst_ip": "1.1.1.1", + "src_port": 54321, + "dst_port": 443, + "protocol": "HTTPS", + "length": 128, + "flags": "SYN", + "info": "54321→443 [SYN] Seq=0" + } +] +``` + +### GET /api/ping_log?minutes=60 + +Ping-Log der letzten N Minuten + Statistik pro Ziel. + +```json +{ + "log": [ + {"ts": 1700012345, "target": "8.8.8.8", "success": 1, "latency_ms": 12.4} + ], + "stats": [ + { + "target": "8.8.8.8", + "total": 720, + "ok": 718, + "avg_ms": 14.2, + "min_ms": 9.1, + "max_ms": 89.3 + } + ] +} +``` + +### GET /api/fritz?hours=24 + +FritzBox-Status und Verbindungsverlauf. + +```json +{ + "latest": { + "ts": 1700012345, + "is_connected": 1, + "uptime_s": 86400, + "max_up_bps": 10000000, + "max_down_bps": 50000000, + "total_bytes_sent": 15000000000, + "total_bytes_recv": 120000000000, + "external_ip": "93.x.x.x" + }, + "history": [ + {"bucket": 1700010000, "connected": 1.0} + ] +} +``` + +--- + +## 7. Datenbank-Schema + +SQLite-Datenbank unter `./data/netmon.db` (im Container: `/data/netmon.db`). + +```sql +-- Bandbreiten-Messungen (alle 60s) +traffic (id, ts, iface, bytes_sent, bytes_recv, pkts_sent, pkts_recv, bps_sent, bps_recv) + +-- Internetausfälle +outages (id, start_ts, end_ts, duration_s) + +-- Jeder einzelne Ping-Versuch +ping_log (id, ts, target, success, latency_ms) + +-- Paketmitschnitt-Metadaten (Rolling Window) +packets (id, ts, iface, src_ip, dst_ip, src_port, dst_port, protocol, length, flags, info) + +-- Erkannte Geräte +devices (id, mac, ip, hostname, vendor, fritz_name, iface_type, first_seen, last_seen, is_active) + +-- Offene Ports pro Gerät +ports (id, device_id→devices, port, protocol, service, state, ts) + +-- FritzBox-Verbindungsdaten (alle 60s) +fritz_stats (id, ts, is_connected, uptime_s, max_up_bps, max_down_bps, total_bytes_sent, total_bytes_recv, external_ip) +``` + +Alle Zeitstempel sind UNIX-Timestamps (Integer). Die `packets`-Tabelle verwendet `REAL` für Millisekunden-Präzision. + +**Datenhaltung:** +- Traffic, Ping-Log, FritzBox-Stats: automatisch nach **35 Tagen** gelöscht +- Pakete: Rolling Window, maximal `CAPTURE_MAX_ROWS` Einträge + +--- + +## 8. Deployment + +### Voraussetzungen + +- Docker + Docker Compose +- Linux-Host (für raw sockets / packet capture) +- Root-Rechte (für ARP-Scan, nmap, scapy) + +### Docker Compose (empfohlen) + +```yaml +# docker-compose.yml +services: + netmon: + build: . + container_name: netmon + restart: unless-stopped + network_mode: host # Pflicht für echte Interface-Daten + privileged: true # Pflicht für scapy/nmap raw sockets + env_file: .env + volumes: + - ./data:/data # DB persistent auf dem Host +``` + +```bash +# Starten +docker compose up -d --build + +# Logs anschauen +docker logs netmon -f + +# Neu deployen nach .env-Änderung +docker compose restart + +# Neu bauen nach Code-Änderung +docker compose up -d --build + +# Stoppen +docker compose down +``` + +### Ohne Docker (direkt auf dem Host) + +```bash +cd /root/netmon +cp .env.example .env && nano .env +sudo ./start.sh # sudo wegen raw sockets +``` + +### Daten sichern + +```bash +# Datenbank sichern +cp ./data/netmon.db ./data/netmon_backup_$(date +%Y%m%d).db +``` + +### Auf anderem System deployen + +```bash +# Projekt kopieren +scp -r /root/netmon user@neuer-server:/opt/netmon + +# Auf neuem Server +cd /opt/netmon +cp .env.example .env +nano .env # IP/Passwort anpassen +docker compose up -d --build +``` + +--- + +## 9. Troubleshooting + +### Dashboard zeigt keine Daten + +```bash +docker logs netmon --tail=50 +``` + +Häufige Ursachen: +- **FritzBox nicht erreichbar**: `FRITZ_HOST` prüfen, Passwort prüfen +- **Falsches Interface**: `MONITOR_INTERFACE=auto` oder manuell setzen (z.B. `eth0`) + +### Paketmitschnitt leer + +```bash +# Interface prüfen +docker exec netmon python3 -c "import scapy.all as s; print(s.get_if_list())" + +# Privileged-Mode aktiv? +docker inspect netmon | grep Privileged +``` + +Muss `true` sein. Falls nicht: `docker compose down && docker compose up -d`. + +### Geräte werden nicht gefunden + +- `NETWORK_RANGE` manuell auf das lokale Subnetz setzen: z.B. `192.168.1.0/24` +- Container muss mit `network_mode: host` laufen + +### Kurze Ausfälle werden nicht erkannt + +- `OUTAGE_CHECK_INTERVAL` verringern (z.B. auf `5`) +- `OUTAGE_THRESHOLD=1` setzen (sofort beim ersten Fehlschlag) +- Ping-Log unter **Ausfälle → Ping-Statistik** zeigt jeden einzelnen Check + +### DB wächst zu schnell + +```bash +# Aktuelle Größe +du -sh ./data/netmon.db + +# Weniger Pakete cachen +# In .env: CAPTURE_MAX_ROWS=10000 + +# Daten-Retention verkürzen (in database.py cleanup_old_data aufrufen mit weniger Tagen) +``` + +### Port 5000 bereits belegt + +```env +# In .env +WEB_PORT=8080 +``` + +--- + +*NetMon – gebaut für 24/7-Betrieb auf Linux mit Docker.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..99d38c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y \ + nmap \ + iputils-ping \ + net-tools \ + iproute2 \ + libpcap-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +RUN mkdir -p /data + +ENV PYTHONUNBUFFERED=1 +ENV DB_PATH=/data/netmon.db + +EXPOSE 5000 + +CMD ["python", "src/main.py"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..260e986 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + netmon: + build: . + container_name: netmon + restart: unless-stopped + # host network für ARP-Scans und echte Interface-Daten + network_mode: host + # privileged für raw sockets (scapy/nmap) + privileged: true + cap_add: + - NET_ADMIN + - NET_RAW + env_file: .env + volumes: + - ./data:/data + - ./.env:/app/.env + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6655dd1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +flask>=3.0.0 +fritzconnection>=1.13.2 +psutil>=5.9.8 +scapy>=2.5.0 +python-nmap>=0.7.1 +python-dotenv>=1.0.1 +requests>=2.31.0 diff --git a/src/api.py b/src/api.py new file mode 100644 index 0000000..50173ea --- /dev/null +++ b/src/api.py @@ -0,0 +1,398 @@ +""" +Flask REST-API und statische Dateien. +""" +import os +import signal +import threading +import time +from datetime import datetime, timezone +from flask import Flask, jsonify, send_from_directory, abort, request as flask_request + +import database as db + +app = Flask(__name__, static_folder='web', static_url_path='/static') + +# Wird vom main.py gesetzt +_monitor = None + + +def set_monitor(monitor): + global _monitor + _monitor = monitor + + +# ── Statische Dateien ──────────────────────────────────────────────────────── + +@app.route('/') +def index(): + return send_from_directory('web', 'index.html') + + +# ── API Endpunkte ──────────────────────────────────────────────────────────── + +@app.route('/api/status') +def api_status(): + """Aktueller Systemstatus.""" + mon_status = _monitor.status() if _monitor else {} + traffic = db.query_current_traffic() + fritz = db.query_fritz_latest() + + # Heutige Ausfälle + today_start = int(datetime.now(timezone.utc).replace(hour=0, minute=0, second=0).timestamp()) + outages_today = db.query_outages(since_ts=today_start, limit=500) + total_outage_s = sum(o['duration_s'] or 0 for o in outages_today if o['end_ts']) + current_outage = next((o for o in outages_today if not o['end_ts']), None) + + devices = db.query_devices() + active_count = sum(1 for d in devices if d['is_active']) + + return jsonify({ + 'internet_up': mon_status.get('internet_up', True), + 'bps_up': traffic.get('bps_sent', 0), + 'bps_down': traffic.get('bps_recv', 0), + 'active_devices': active_count, + 'total_devices': len(devices), + 'outages_today': len([o for o in outages_today if o['end_ts']]), + 'total_outage_seconds_today': total_outage_s, + 'current_outage_since': current_outage['start_ts'] if current_outage else None, + 'fritz': { + 'available': fritz is not None, + 'is_connected': fritz['is_connected'] if fritz else None, + 'external_ip': fritz['external_ip'] if fritz else None, + 'uptime_s': fritz['uptime_s'] if fritz else None, + 'max_up_bps': fritz['max_up_bps'] if fritz else None, + 'max_down_bps': fritz['max_down_bps'] if fritz else None, + }, + }) + + +@app.route('/api/traffic') +def api_traffic(): + """Traffic-Zeitreihe. ?range=1h|6h|24h|7d|30d""" + from flask import request + range_map = { + '1h': (3600, 60), + '6h': (21600, 300), + '24h': (86400, 900), + '7d': (604800, 3600), + '30d': (2592000, 14400), + } + r = request.args.get('range', '1h') + seconds, bucket = range_map.get(r, (3600, 60)) + since = int(time.time()) - seconds + + rows = db.query_traffic(since_ts=since, bucket_size=bucket) + labels = [_fmt_ts(row['bucket'], r) for row in rows] + upload = [round(row['bps_sent'] / 1000, 2) for row in rows] # kbit/s + download = [round(row['bps_recv'] / 1000, 2) for row in rows] + + return jsonify({'labels': labels, 'upload': upload, 'download': download}) + + +@app.route('/api/outages') +def api_outages(): + """Ausfalls-Historie.""" + from flask import request + limit = int(request.args.get('limit', 100)) + since = request.args.get('since') + since_ts = int(since) if since else None + + rows = db.query_outages(since_ts=since_ts, limit=limit) + result = [] + for r in rows: + duration = r['duration_s'] + result.append({ + 'id': r['id'], + 'start_ts': r['start_ts'], + 'end_ts': r['end_ts'], + 'duration_s': duration, + 'duration_human': _human_duration(duration) if duration else 'Läuft noch', + 'start_human': _fmt_dt(r['start_ts']), + 'end_human': _fmt_dt(r['end_ts']) if r['end_ts'] else 'Läuft noch', + }) + return jsonify(result) + + +@app.route('/api/devices') +def api_devices(): + """Alle Geräte mit Status und Port-Anzahl.""" + devices = db.query_devices() + result = [] + for d in devices: + result.append({ + 'id': d['id'], + 'mac': d['mac'], + 'ip': d['ip'] or '', + 'name': d['fritz_name'] or d['hostname'] or d['ip'] or d['mac'], + 'hostname': d['hostname'] or '', + 'fritz_name': d['fritz_name'] or '', + 'vendor': d['vendor'] or '', + 'iface_type': d['iface_type'] or '', + 'is_active': bool(d['is_active']), + 'first_seen': d['first_seen'], + 'last_seen': d['last_seen'], + 'last_seen_human': _fmt_dt(d['last_seen']), + 'port_count': d['port_count'], + }) + return jsonify(result) + + +@app.route('/api/devices//ports') +def api_device_ports(device_id): + """Offene Ports eines Geräts.""" + ports = db.query_device_ports(device_id) + return jsonify(ports) + + +@app.route('/api/packets') +def api_packets(): + """Live-Paketmitschnitt. ?since=UNIX_TS&limit=200&filter=text""" + from flask import request + since = float(request.args.get('since', time.time() - 30)) + limit = int(request.args.get('limit', 200)) + filter_text = request.args.get('filter', '').strip() or None + rows = db.query_packets(since_ts=since, limit=limit, filter_text=filter_text) + # Neueste zuletzt (für Append im Frontend) + rows.reverse() + return jsonify(rows) + + +@app.route('/api/ping_log') +def api_ping_log(): + """Letzter Ping-Log – zeigt auch kurze Ausfälle. ?minutes=60""" + from flask import request + minutes = int(request.args.get('minutes', 60)) + since = int(time.time()) - minutes * 60 + rows = db.query_ping_log(since_ts=since, limit=2000) + stats = db.query_ping_stats(since_ts=since) + return jsonify({'log': rows, 'stats': stats}) + + +_ENV_PATH = '/app/.env' + +# Whitelist aller erlaubten .env-Keys (verhindert willkürliche Datei-Injection) +_ALLOWED_KEYS = { + 'FRITZ_HOST', 'FRITZ_USER', 'FRITZ_PASSWORD', + 'MONITOR_INTERFACE', 'NETWORK_RANGE', + 'TRAFFIC_INTERVAL', 'OUTAGE_CHECK_INTERVAL', 'DEVICE_SCAN_INTERVAL', + 'PORT_SCAN_INTERVAL', 'FRITZ_POLL_INTERVAL', + 'PING_TARGET_INTERVAL', 'DEVICE_PING_INTERVAL', 'PING_TARGETS', + 'DB_PATH', 'WEB_HOST', 'WEB_PORT', 'LOG_LEVEL', + 'SWITCH_ENABLED', 'SWITCH_HOST', 'SWITCH_PASSWORD', + 'SWITCH_GATEWAY_PORT', 'SWITCH_INTERVAL', + 'CAPTURE_ENABLED', 'CAPTURE_MAX_ROWS', + 'CAPTURE_FILTER_ICMP', 'CAPTURE_FILTER_ARP', + 'CAPTURE_FILTER_ZABBIX', 'CAPTURE_FILTER_RUSTDESK', 'CAPTURE_FILTER_EXTRA', + 'OUTAGE_THRESHOLD', + # Benachrichtigungen + 'NOTIFY_ON_DOWN', 'NOTIFY_ON_UP', 'NOTIFY_COOLDOWN', + 'PUSHOVER_ENABLED', 'PUSHOVER_APP_TOKEN', 'PUSHOVER_USER_KEY', + 'NCTALK_ENABLED', 'NCTALK_URL', 'NCTALK_USER', 'NCTALK_PASSWORD', 'NCTALK_ROOM', +} + + +def _read_env_file() -> list[str]: + try: + with open(_ENV_PATH, 'r') as f: + return f.readlines() + except FileNotFoundError: + return [] + + +def _parse_env(lines: list[str]) -> dict: + result = {} + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#') and '=' in stripped: + key, _, val = stripped.partition('=') + result[key.strip()] = val.strip() + return result + + +def _write_env(updates: dict) -> None: + """Schreibt aktualisierte Werte in .env, behält Kommentare und Struktur.""" + lines = _read_env_file() + updated_keys = set() + + new_lines = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#') and '=' in stripped: + key = stripped.split('=', 1)[0].strip() + if key in updates and key in _ALLOWED_KEYS: + new_lines.append(f'{key}={updates[key]}\n') + updated_keys.add(key) + continue + new_lines.append(line) + + # Neue Keys anhängen die noch nicht in der Datei waren + for key, val in updates.items(): + if key in _ALLOWED_KEYS and key not in updated_keys: + new_lines.append(f'{key}={val}\n') + + with open(_ENV_PATH, 'w') as f: + f.writelines(new_lines) + + +@app.route('/api/config', methods=['GET']) +def api_config_get(): + """Aktuelle .env-Konfiguration auslesen.""" + lines = _read_env_file() + config = _parse_env(lines) + return jsonify({k: v for k, v in config.items() if k in _ALLOWED_KEYS}) + + +@app.route('/api/config', methods=['POST']) +def api_config_post(): + """Konfiguration speichern.""" + data = flask_request.get_json(force=True) + if not data or not isinstance(data, dict): + return jsonify({'error': 'Ungültige Daten'}), 400 + + # Nur erlaubte Keys + updates = {k: str(v) for k, v in data.items() if k in _ALLOWED_KEYS} + if not updates: + return jsonify({'error': 'Keine gültigen Keys'}), 400 + + _write_env(updates) + return jsonify({'ok': True, 'saved': list(updates.keys())}) + + +@app.route('/api/restart', methods=['POST']) +def api_restart(): + """Container neu starten (Docker restart: unless-stopped).""" + def _do_restart(): + time.sleep(0.8) + os.kill(os.getpid(), signal.SIGTERM) + + threading.Thread(target=_do_restart, daemon=True).start() + return jsonify({'ok': True, 'message': 'Neustart eingeleitet…'}) + + +@app.route('/api/notify_test', methods=['POST']) +def api_notify_test(): + """Sendet Test-Benachrichtigung über alle aktivierten Kanäle.""" + import notify + results = notify.test_notification() + return jsonify(results) + + +@app.route('/api/ping_targets_live') +def api_ping_targets_live(): + """.env PING_TARGETS: Live-Log der letzten N Sekunden.""" + from flask import request + seconds = int(request.args.get('seconds', 120)) + since = int(time.time()) - seconds + rows = db.query_ping_log(since_ts=since, limit=2000) + # Neueste zuletzt für Live-Append + rows.reverse() + targets = [t.strip() for t in os.getenv('PING_TARGETS', '').split(',') if t.strip()] + stats = db.query_ping_stats(since_ts=int(time.time()) - 300) # letzte 5min + return jsonify({ + 'targets': targets, + 'log': rows, + 'stats': {s['target']: s for s in stats}, + }) + + +@app.route('/api/device_pings') +def api_device_pings(): + """Alle Netzwerk-Geräte (nicht in PING_TARGETS): letztes Ping-Ergebnis + Statistik.""" + from flask import request + minutes = int(request.args.get('minutes', 30)) + since = int(time.time()) - minutes * 60 + latest = db.query_device_pings_latest() + stats = db.query_device_ping_stats(since_ts=since) + stats_map = {s['ip']: s for s in stats} + result = [] + for row in latest: + s = stats_map.get(row['ip'], {}) + result.append({ + 'ip': row['ip'], + 'mac': row['mac'], + 'success': bool(row['success']), + 'latency_ms': row['latency_ms'], + 'ts': row['ts'], + 'ts_human': _fmt_dt(row['ts']), + 'total': s.get('total', 0), + 'ok': s.get('ok', 0), + 'avg_ms': s.get('avg_ms'), + 'min_ms': s.get('min_ms'), + 'max_ms': s.get('max_ms'), + }) + return jsonify(result) + + +@app.route('/api/switch') +def api_switch(): + """Switch Port-Status und Ereignisse.""" + from flask import request + ports = db.query_switch_latest() + hours = int(request.args.get('hours', 24)) + since = int(time.time()) - hours * 3600 + events = db.query_switch_events(since_ts=since, limit=200) + + return jsonify({ + 'enabled': bool(os.getenv('SWITCH_ENABLED', 'false').lower() == 'true'), + 'host': os.getenv('SWITCH_HOST', ''), + 'gateway_port': int(os.getenv('SWITCH_GATEWAY_PORT', '') or 1), + 'ports': [dict(p) for p in ports], + 'events': [ + {**dict(e), 'ts_human': _fmt_dt(e['ts'])} + for e in events + ], + }) + + +@app.route('/api/fritz') +def api_fritz(): + """FritzBox-Verlauf, aktueller Status und Geräteinformationen.""" + from flask import request + hours = int(request.args.get('hours', 24)) + since = int(time.time()) - hours * 3600 + history = db.query_fritz_history(since_ts=since, bucket_size=300) + latest = db.query_fritz_latest() + + # Live-Infos direkt vom Gerät abrufen + full_info = None + if _monitor and hasattr(_monitor, 'fritz') and _monitor.fritz: + full_info = _monitor.fritz.get_full_info() + + return jsonify({ + 'latest': dict(latest) if latest else None, + 'history': history, + 'info': full_info, + 'host': os.getenv('FRITZ_HOST', ''), + }) + + +# ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +def _fmt_ts(ts, range_key): + """Formatiert Timestamp je nach Zeitbereich.""" + dt = datetime.fromtimestamp(ts) + if range_key in ('1h', '6h'): + return dt.strftime('%H:%M') + elif range_key == '24h': + return dt.strftime('%H:%M') + else: + return dt.strftime('%d.%m %H:%M') + + +def _fmt_dt(ts): + if not ts: + return '' + return datetime.fromtimestamp(ts).strftime('%d.%m.%Y %H:%M:%S') + + +def _human_duration(seconds): + if not seconds: + return '< 1s' + parts = [] + h = seconds // 3600 + m = (seconds % 3600) // 60 + s = seconds % 60 + if h: parts.append(f"{h}h") + if m: parts.append(f"{m}min") + if s or not parts: parts.append(f"{s}s") + return ' '.join(parts) diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..535a24c --- /dev/null +++ b/src/database.py @@ -0,0 +1,528 @@ +import sqlite3 +import os +import threading +from contextlib import contextmanager + +DB_PATH = os.getenv('DB_PATH', '/data/netmon.db') +_local = threading.local() + + +@contextmanager +def get_db(): + """Thread-safe SQLite connection mit WAL mode.""" + conn = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("PRAGMA cache_size=-32000") # 32MB cache + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + +def init_db(): + """Erstellt alle Tabellen falls nicht vorhanden.""" + with get_db() as db: + db.executescript(""" + -- Gesamter Netzwerk-Traffic (pro Minute) + CREATE TABLE IF NOT EXISTS traffic ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + iface TEXT NOT NULL DEFAULT 'total', + bytes_sent BIGINT DEFAULT 0, + bytes_recv BIGINT DEFAULT 0, + pkts_sent BIGINT DEFAULT 0, + pkts_recv BIGINT DEFAULT 0, + bps_sent REAL DEFAULT 0, + bps_recv REAL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_traffic_ts ON traffic(ts); + + -- Internetausfälle + CREATE TABLE IF NOT EXISTS outages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + start_ts INTEGER NOT NULL, + end_ts INTEGER, + duration_s INTEGER + ); + CREATE INDEX IF NOT EXISTS idx_outages_start ON outages(start_ts); + + -- Jeder einzelne Ping-Versuch (für lückenlose Aufzeichnung auch kurzer Ausfälle) + CREATE TABLE IF NOT EXISTS ping_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + target TEXT NOT NULL, + success INTEGER NOT NULL, + latency_ms REAL + ); + CREATE INDEX IF NOT EXISTS idx_ping_ts ON ping_log(ts); + + -- Paketmitschnitt (Metadaten, kein Payload – rollierendes Fenster) + CREATE TABLE IF NOT EXISTS packets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + iface TEXT, + src_ip TEXT, + dst_ip TEXT, + src_port INTEGER, + dst_port INTEGER, + protocol TEXT, + length INTEGER, + flags TEXT, + info TEXT + ); + CREATE INDEX IF NOT EXISTS idx_packets_ts ON packets(ts); + + -- Geräte im Netzwerk + CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mac TEXT UNIQUE NOT NULL, + ip TEXT, + hostname TEXT, + vendor TEXT, + fritz_name TEXT, + iface_type TEXT, + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + is_active INTEGER DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_devices_mac ON devices(mac); + CREATE INDEX IF NOT EXISTS idx_devices_active ON devices(is_active); + + -- Offene Ports pro Gerät + CREATE TABLE IF NOT EXISTS ports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id INTEGER NOT NULL, + port INTEGER NOT NULL, + protocol TEXT NOT NULL DEFAULT 'tcp', + service TEXT, + state TEXT DEFAULT 'open', + ts INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE, + UNIQUE(device_id, port, protocol) + ); + CREATE INDEX IF NOT EXISTS idx_ports_device ON ports(device_id); + + -- FritzBox Verbindungsdaten + CREATE TABLE IF NOT EXISTS fritz_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + is_connected INTEGER DEFAULT 0, + uptime_s INTEGER DEFAULT 0, + max_up_bps BIGINT DEFAULT 0, + max_down_bps BIGINT DEFAULT 0, + total_bytes_sent BIGINT DEFAULT 0, + total_bytes_recv BIGINT DEFAULT 0, + external_ip TEXT + ); + CREATE INDEX IF NOT EXISTS idx_fritz_ts ON fritz_stats(ts); + + -- Switch Port-Status (aktueller Zustand pro Port) + CREATE TABLE IF NOT EXISTS switch_ports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + port INTEGER NOT NULL, + connected INTEGER NOT NULL DEFAULT 0, + speed_config TEXT, + link_speed TEXT, + flow_control INTEGER DEFAULT 0, + UNIQUE(ts, port) + ); + CREATE INDEX IF NOT EXISTS idx_switch_ports_ts ON switch_ports(ts); + + -- Switch Port-Ereignisse (Up/Down-Wechsel) + CREATE TABLE IF NOT EXISTS switch_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + port INTEGER NOT NULL, + event TEXT NOT NULL, -- 'up' oder 'down' + link_speed TEXT + ); + CREATE INDEX IF NOT EXISTS idx_switch_events_ts ON switch_events(ts); + + -- Ping-Log für Netzwerk-Geräte (nicht in PING_TARGETS) + CREATE TABLE IF NOT EXISTS device_ping_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + ip TEXT NOT NULL, + mac TEXT, + success INTEGER NOT NULL, + latency_ms REAL + ); + CREATE INDEX IF NOT EXISTS idx_dev_ping_ts ON device_ping_log(ts); + CREATE INDEX IF NOT EXISTS idx_dev_ping_ip ON device_ping_log(ip); + """) + + +def cleanup_old_data(days=7): + """Löscht Einträge älter als N Tage aus den wachsenden Log-Tabellen.""" + cutoff = int(__import__('time').time()) - days * 86400 + with get_db() as db: + db.execute("DELETE FROM ping_log WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM device_ping_log WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM packets WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM traffic WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM fritz_stats WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM switch_ports WHERE ts < ?", (cutoff,)) + db.execute("DELETE FROM switch_events WHERE ts < ?", (cutoff,)) + db.commit() + db.execute("PRAGMA wal_checkpoint(PASSIVE)") + + +# ── Hilfsfunktionen ────────────────────────────────────────────────────────── + +def insert_ping_log(ts, target, success, latency_ms=None): + with get_db() as db: + db.execute( + "INSERT INTO ping_log (ts, target, success, latency_ms) VALUES (?,?,?,?)", + (ts, target, int(success), latency_ms) + ) + + +def query_ping_log(since_ts, limit=500): + with get_db() as db: + rows = db.execute( + "SELECT ts, target, success, latency_ms FROM ping_log " + "WHERE ts >= ? ORDER BY ts DESC LIMIT ?", + (since_ts, limit) + ).fetchall() + return [dict(r) for r in rows] + + +def query_ping_stats(since_ts): + """Erfolgsrate pro Ziel seit since_ts.""" + with get_db() as db: + rows = db.execute( + "SELECT target, " + "COUNT(*) AS total, " + "SUM(success) AS ok, " + "AVG(CASE WHEN success=1 THEN latency_ms END) AS avg_ms, " + "MIN(CASE WHEN success=1 THEN latency_ms END) AS min_ms, " + "MAX(CASE WHEN success=1 THEN latency_ms END) AS max_ms " + "FROM ping_log WHERE ts >= ? GROUP BY target", + (since_ts,) + ).fetchall() + return [dict(r) for r in rows] + + +def insert_packet(ts, iface, src_ip, dst_ip, src_port, dst_port, + protocol, length, flags, info): + with get_db() as db: + db.execute( + "INSERT INTO packets (ts,iface,src_ip,dst_ip,src_port,dst_port," + "protocol,length,flags,info) VALUES (?,?,?,?,?,?,?,?,?,?)", + (ts, iface, src_ip, dst_ip, src_port, dst_port, protocol, length, flags, info) + ) + + +def insert_packets_bulk(rows): + """Schreibt mehrere Pakete in einem einzigen DB-Commit (deutlich effizienter).""" + if not rows: + return + with get_db() as db: + db.executemany( + "INSERT INTO packets (ts,iface,src_ip,dst_ip,src_port,dst_port," + "protocol,length,flags,info) VALUES (?,?,?,?,?,?,?,?,?,?)", + rows + ) + + +def query_packets(since_ts, limit=500, filter_text=None): + with get_db() as db: + if filter_text: + f = f"%{filter_text}%" + rows = db.execute( + "SELECT * FROM packets WHERE ts >= ? AND (" + "src_ip LIKE ? OR dst_ip LIKE ? OR protocol LIKE ? OR info LIKE ? OR " + "CAST(src_port AS TEXT) LIKE ? OR CAST(dst_port AS TEXT) LIKE ?) " + "ORDER BY ts DESC LIMIT ?", + (since_ts, f, f, f, f, f, f, limit) + ).fetchall() + else: + rows = db.execute( + "SELECT * FROM packets WHERE ts >= ? ORDER BY ts DESC LIMIT ?", + (since_ts, limit) + ).fetchall() + return [dict(r) for r in rows] + + +def trim_packets(max_rows=50000): + """Hält die packets-Tabelle auf max_rows begrenzt (rolling window).""" + with get_db() as db: + count = db.execute("SELECT COUNT(*) FROM packets").fetchone()[0] + if count > max_rows: + db.execute( + "DELETE FROM packets WHERE id IN " + "(SELECT id FROM packets ORDER BY ts ASC LIMIT ?)", + (count - max_rows,) + ) + + +def insert_traffic(ts, iface, bytes_sent, bytes_recv, pkts_sent, pkts_recv, + bps_sent, bps_recv): + with get_db() as db: + db.execute( + "INSERT INTO traffic (ts,iface,bytes_sent,bytes_recv,pkts_sent,pkts_recv,bps_sent,bps_recv) " + "VALUES (?,?,?,?,?,?,?,?)", + (ts, iface, bytes_sent, bytes_recv, pkts_sent, pkts_recv, bps_sent, bps_recv) + ) + + +def start_outage(ts): + with get_db() as db: + db.execute("INSERT INTO outages (start_ts) VALUES (?)", (ts,)) + return db.execute("SELECT last_insert_rowid()").fetchone()[0] + + +def end_outage(outage_id, ts): + with get_db() as db: + db.execute( + "UPDATE outages SET end_ts=?, duration_s=?-start_ts WHERE id=?", + (ts, ts, outage_id) + ) + + +def upsert_device(mac, ip, hostname, vendor, fritz_name, iface_type, now_ts): + with get_db() as db: + existing = db.execute("SELECT id FROM devices WHERE mac=?", (mac,)).fetchone() + if existing: + db.execute( + "UPDATE devices SET ip=?,hostname=?,vendor=?,fritz_name=?,iface_type=?," + "last_seen=?,is_active=1 WHERE mac=?", + (ip, hostname, vendor, fritz_name, iface_type, now_ts, mac) + ) + return existing['id'] + else: + db.execute( + "INSERT INTO devices (mac,ip,hostname,vendor,fritz_name,iface_type,first_seen,last_seen,is_active) " + "VALUES (?,?,?,?,?,?,?,?,1)", + (mac, ip, hostname, vendor, fritz_name, iface_type, now_ts, now_ts) + ) + return db.execute("SELECT last_insert_rowid()").fetchone()[0] + + +def set_devices_inactive(active_macs, now_ts): + """Alle Geräte die nicht in active_macs sind auf inaktiv setzen.""" + with get_db() as db: + if active_macs: + placeholders = ','.join('?' * len(active_macs)) + db.execute( + f"UPDATE devices SET is_active=0 WHERE mac NOT IN ({placeholders})", + list(active_macs) + ) + else: + db.execute("UPDATE devices SET is_active=0") + + +def upsert_port(device_id, port, protocol, service, state, ts): + with get_db() as db: + db.execute( + "INSERT INTO ports (device_id,port,protocol,service,state,ts) VALUES (?,?,?,?,?,?) " + "ON CONFLICT(device_id,port,protocol) DO UPDATE SET service=excluded.service," + "state=excluded.state,ts=excluded.ts", + (device_id, port, protocol, service, state, ts) + ) + + +def remove_closed_ports(device_id, open_ports_set, ts_threshold): + """Entfernt Ports die beim letzten Scan nicht mehr offen waren.""" + with get_db() as db: + db.execute( + "DELETE FROM ports WHERE device_id=? AND ts < ?", + (device_id, ts_threshold) + ) + + +def insert_fritz_stats(ts, is_connected, uptime_s, max_up, max_down, + total_sent, total_recv, external_ip): + with get_db() as db: + db.execute( + "INSERT INTO fritz_stats (ts,is_connected,uptime_s,max_up_bps,max_down_bps," + "total_bytes_sent,total_bytes_recv,external_ip) VALUES (?,?,?,?,?,?,?,?)", + (ts, is_connected, uptime_s, max_up, max_down, total_sent, total_recv, external_ip) + ) + + +# ── Query-Funktionen für die API ───────────────────────────────────────────── + +def query_traffic(since_ts, bucket_size=60): + """Traffic aggregiert in Zeitbuckets.""" + with get_db() as db: + rows = db.execute( + "SELECT (ts/:bucket)*:bucket AS bucket, " + "AVG(bps_sent) AS bps_sent, AVG(bps_recv) AS bps_recv, " + "SUM(bytes_sent) AS bytes_sent, SUM(bytes_recv) AS bytes_recv " + "FROM traffic WHERE ts >= :since " + "GROUP BY bucket ORDER BY bucket", + {'bucket': bucket_size, 'since': since_ts} + ).fetchall() + return [dict(r) for r in rows] + + +def query_current_traffic(): + with get_db() as db: + row = db.execute( + "SELECT bps_sent, bps_recv FROM traffic ORDER BY ts DESC LIMIT 1" + ).fetchone() + return dict(row) if row else {'bps_sent': 0, 'bps_recv': 0} + + +def get_open_outage_id(): + """Gibt die ID eines noch offenen Ausfalls zurück (end_ts IS NULL), oder None.""" + with get_db() as db: + row = db.execute( + "SELECT id FROM outages WHERE end_ts IS NULL ORDER BY start_ts DESC LIMIT 1" + ).fetchone() + return row['id'] if row else None + + +def query_outages(since_ts=None, limit=100): + with get_db() as db: + if since_ts: + rows = db.execute( + "SELECT * FROM outages WHERE start_ts >= ? ORDER BY start_ts DESC LIMIT ?", + (since_ts, limit) + ).fetchall() + else: + rows = db.execute( + "SELECT * FROM outages ORDER BY start_ts DESC LIMIT ?", (limit,) + ).fetchall() + return [dict(r) for r in rows] + + +def query_devices(): + with get_db() as db: + rows = db.execute( + "SELECT d.*, " + "(SELECT COUNT(*) FROM ports p WHERE p.device_id=d.id) AS port_count " + "FROM devices d ORDER BY d.is_active DESC, d.last_seen DESC" + ).fetchall() + return [dict(r) for r in rows] + + +def query_device_ports(device_id): + with get_db() as db: + rows = db.execute( + "SELECT port, protocol, service, state, ts FROM ports WHERE device_id=? ORDER BY port", + (device_id,) + ).fetchall() + return [dict(r) for r in rows] + + +def query_fritz_latest(): + with get_db() as db: + row = db.execute( + "SELECT * FROM fritz_stats ORDER BY ts DESC LIMIT 1" + ).fetchone() + return dict(row) if row else None + + +def query_fritz_history(since_ts, bucket_size=300): + with get_db() as db: + rows = db.execute( + "SELECT (ts/:b)*:b AS bucket, AVG(is_connected) AS connected " + "FROM fritz_stats WHERE ts >= :since GROUP BY bucket ORDER BY bucket", + {'b': bucket_size, 'since': since_ts} + ).fetchall() + return [dict(r) for r in rows] + + +def insert_device_ping(ts, ip, mac, success, latency_ms=None): + with get_db() as db: + db.execute( + "INSERT INTO device_ping_log (ts, ip, mac, success, latency_ms) VALUES (?,?,?,?,?)", + (ts, ip, mac, int(success), latency_ms) + ) + + +def query_device_pings_latest() -> list: + """Letztes Ping-Ergebnis pro IP.""" + with get_db() as db: + rows = db.execute( + "SELECT ip, mac, success, latency_ms, ts " + "FROM device_ping_log " + "WHERE ts = (SELECT MAX(ts) FROM device_ping_log d2 WHERE d2.ip = device_ping_log.ip) " + "GROUP BY ip ORDER BY ip" + ).fetchall() + return [dict(r) for r in rows] + + +def query_device_ping_stats(since_ts) -> list: + """Erfolgsrate + Latenzen pro IP seit since_ts.""" + with get_db() as db: + rows = db.execute( + "SELECT ip, mac, COUNT(*) AS total, SUM(success) AS ok, " + "AVG(CASE WHEN success=1 THEN latency_ms END) AS avg_ms, " + "MIN(CASE WHEN success=1 THEN latency_ms END) AS min_ms, " + "MAX(CASE WHEN success=1 THEN latency_ms END) AS max_ms " + "FROM device_ping_log WHERE ts >= ? GROUP BY ip ORDER BY ip", + (since_ts,) + ).fetchall() + return [dict(r) for r in rows] + + +def insert_switch_ports(ts, ports: list): + """Speichert kompletten Port-Snapshot.""" + with get_db() as db: + for p in ports: + db.execute( + "INSERT OR IGNORE INTO switch_ports (ts,port,connected,speed_config,link_speed,flow_control) " + "VALUES (?,?,?,?,?,?)", + (ts, p['port'], int(p['connected']), p.get('speed_config'), + p.get('link_speed'), int(p.get('flow_control', False))) + ) + + +def insert_switch_event(ts, port, event, link_speed=None): + with get_db() as db: + db.execute( + "INSERT INTO switch_events (ts,port,event,link_speed) VALUES (?,?,?,?)", + (ts, port, event, link_speed) + ) + + +def query_switch_latest() -> list: + """Letzter Snapshot aller Ports.""" + with get_db() as db: + max_ts = db.execute("SELECT MAX(ts) FROM switch_ports").fetchone()[0] + if max_ts is None: + return [] + rows = db.execute( + "SELECT * FROM switch_ports WHERE ts=? ORDER BY port", (max_ts,) + ).fetchall() + return [dict(r) for r in rows] + + +def query_switch_events(since_ts=None, port=None, limit=200) -> list: + with get_db() as db: + conds, params = [], [] + if since_ts: + conds.append("ts >= ?"); params.append(since_ts) + if port: + conds.append("port = ?"); params.append(port) + where = ("WHERE " + " AND ".join(conds)) if conds else "" + params.append(limit) + rows = db.execute( + f"SELECT * FROM switch_events {where} ORDER BY ts DESC LIMIT ?", params + ).fetchall() + return [dict(r) for r in rows] + + +def query_switch_history(port, since_ts, bucket_size=60) -> list: + """Verbindungsrate eines Ports über Zeit.""" + with get_db() as db: + rows = db.execute( + "SELECT (ts/:b)*:b AS bucket, AVG(connected) AS connected " + "FROM switch_ports WHERE port=:port AND ts>=:since " + "GROUP BY bucket ORDER BY bucket", + {'b': bucket_size, 'port': port, 'since': since_ts} + ).fetchall() + return [dict(r) for r in rows] + + diff --git a/src/fritz.py b/src/fritz.py new file mode 100644 index 0000000..efac5f6 --- /dev/null +++ b/src/fritz.py @@ -0,0 +1,225 @@ +import os +import logging +import time + +logger = logging.getLogger(__name__) + +FRITZ_HOST = os.getenv('FRITZ_HOST', '192.168.178.1') +FRITZ_USER = os.getenv('FRITZ_USER', '') +FRITZ_PASSWORD = os.getenv('FRITZ_PASSWORD', '') + + +def _safe(fn, default=None): + try: + return fn() + except Exception: + return default + + +class FritzClient: + def __init__(self): + self._status = None + self._hosts = None + self._conn = None + self._available = False + self._connect() + + def _connect(self): + if not FRITZ_PASSWORD: + logger.warning("FRITZ_PASSWORD nicht gesetzt – FritzBox-Integration deaktiviert") + return + try: + from fritzconnection.lib.fritzstatus import FritzStatus + from fritzconnection.lib.fritzhosts import FritzHosts + from fritzconnection.core.fritzconnection import FritzConnection + args = dict(address=FRITZ_HOST, user=FRITZ_USER, + password=FRITZ_PASSWORD, timeout=10, use_tls=True) + self._status = FritzStatus(**args) + self._hosts = FritzHosts(**args) + self._conn = FritzConnection(**args) + self._available = True + logger.info(f"FritzBox verbunden (TLS): {FRITZ_HOST}") + except Exception as e: + logger.warning(f"FritzBox nicht erreichbar: {e}") + self._available = False + + def is_available(self): + return self._available + + def get_connection_stats(self): + """Verbindungsdaten für DB-Speicherung (wird jede Minute aufgerufen).""" + if not self._available: + return None + try: + fs = self._status + is_conn = fs.is_connected + uptime = _safe(lambda: fs.connection_uptime, 0) if is_conn else 0 + rates = _safe(lambda: fs.max_bit_rate, (0, 0)) + return { + 'is_connected': int(is_conn), + 'uptime_s': uptime or 0, + 'max_up_bps': rates[0] if rates else 0, + 'max_down_bps': rates[1] if rates else 0, + 'total_bytes_sent': _safe(lambda: fs.bytes_sent, 0), + 'total_bytes_recv': _safe(lambda: fs.bytes_received, 0), + 'external_ip': _safe(lambda: fs.external_ip, ''), + } + except Exception as e: + logger.error(f"FritzBox stats Fehler: {e}") + self._available = False + return None + + def get_full_info(self): + """Alle verfügbaren Infos für die Web-Anzeige.""" + if not self._available: + return None + try: + fs = self._status + fc = self._conn + is_conn = fs.is_connected + + # ── Geräteinformationen ────────────────────────────────────────── + model = _safe(lambda: fc.modelname, '') + fw_ver = _safe(lambda: fc.system_version, '') + dev_info = {'model': model, 'firmware': fw_ver} + + # Detailliertere Geräteinfo via TR-064 DeviceInfo1 + try: + raw = fc.call_action('DeviceInfo1', 'GetInfo') + dev_info.update({ + 'serial': raw.get('NewSerialNumber', ''), + 'firmware': raw.get('NewSoftwareVersion', '') or fw_ver, + 'hardware': raw.get('NewHardwareVersion', ''), + 'uptime_sys': int(raw.get('NewUpTime', 0)), + 'model': raw.get('NewModelName', '') or model, + 'description': raw.get('NewDescription', ''), + }) + except Exception: + pass + + # ── WAN-Leitungsinfos ──────────────────────────────────────────── + wan_info = {} + try: + raw = fc.call_action('WANCommonIFC1', 'GetCommonLinkProperties') + wan_info['access_type'] = raw.get('NewWANAccessType', '') + wan_info['link_status'] = raw.get('NewPhysicalLinkStatus', '') + wan_info['max_up_bps'] = raw.get('NewLayer1UpstreamMaxBitRate', 0) + wan_info['max_down_bps'] = raw.get('NewLayer1DownstreamMaxBitRate', 0) + except Exception: + pass + + # Addon-Infos: aktuelle Raten, DNS, Totalbytes + addon = {} + try: + raw = fc.call_action('WANCommonIFC1', 'GetAddonInfos') + addon = raw + except Exception: + pass + + # ── Verbindungsdetails ─────────────────────────────────────────── + cur_rates = _safe(lambda: fs.transmission_rate, (0, 0)) + ext_ip = _safe(lambda: fs.external_ip, '') + ext_ip6 = _safe(lambda: fs.external_ipv6, '') + uptime_c = _safe(lambda: fs.connection_uptime, 0) if is_conn else 0 + # Aktuelle Raten aus AddonInfos sind in Byte/s + cur_up = addon.get('NewByteSendRate', cur_rates[0] if cur_rates else 0) + cur_down = addon.get('NewByteReceiveRate', cur_rates[1] if cur_rates else 0) + total_sent = int(addon.get('NewX_AVM_DE_TotalBytesSent64', + addon.get('NewTotalBytesSent', _safe(lambda: fs.bytes_sent, 0)))) + total_recv = int(addon.get('NewX_AVM_DE_TotalBytesReceived64', + addon.get('NewTotalBytesReceived', _safe(lambda: fs.bytes_received, 0)))) + + # Verbindungsstatus via WANIPConn1 + conn_uptime = uptime_c or 0 + last_error = '' + try: + raw = fc.call_action('WANIPConn1', 'GetStatusInfo') + conn_uptime = raw.get('NewUptime', conn_uptime) + last_error = raw.get('NewLastConnectionError', '') + except Exception: + pass + + # ── DNS ───────────────────────────────────────────────────────── + dns = {} + dns1 = addon.get('NewDNSServer1', '') + dns2 = addon.get('NewDNSServer2', '') + if dns1: + dns['primary'] = dns1 + dns['secondary'] = dns2 + else: + try: + raw = fc.call_action('WANIPConn1', 'X_AVM_DE_GetDNSServer') + dns['primary'] = raw.get('NewIPv4DNSServer1', '') + dns['secondary'] = raw.get('NewIPv4DNSServer2', '') + except Exception: + pass + + # ── DSL-Leitungsstatistik (nur bei DSL-Verbindungen) ───────────── + dsl = {} + if wan_info.get('access_type', '').upper() in ('DSL', 'ADSL', 'VDSL'): + try: + raw = fc.call_action('WANDSLInterfaceConfig1', 'GetStatisticsTotal') + dsl['cells_sent'] = raw.get('NewCellsSent', 0) + dsl['cells_received'] = raw.get('NewCellsReceived', 0) + except Exception: + pass + try: + raw = fc.call_action('WANDSLInterfaceConfig1', 'GetInfo') + dsl['status'] = raw.get('NewStatus', '') + dsl['upstream_noise'] = raw.get('NewUpstreamNoiseMargin', 0) / 10 + dsl['downstream_noise'] = raw.get('NewDownstreamNoiseMargin', 0) / 10 + dsl['upstream_atten'] = raw.get('NewUpstreamAttenuation', 0) / 10 + dsl['downstream_atten'] = raw.get('NewDownstreamAttenuation', 0) / 10 + dsl['upstream_power'] = raw.get('NewUpstreamPower', 0) / 10 + dsl['downstream_power'] = raw.get('NewDownstreamPower', 0) / 10 + except Exception: + pass + + return { + 'device': dev_info, + 'connection': { + 'is_connected': is_conn, + 'uptime_s': conn_uptime, + 'external_ip': ext_ip, + 'external_ipv6': ext_ip6, + 'max_up_bps': wan_info.get('max_up_bps', 0), + 'max_down_bps': wan_info.get('max_down_bps', 0), + 'cur_up_bps': cur_up, + 'cur_down_bps': cur_down, + 'total_sent': total_sent, + 'total_recv': total_recv, + 'access_type': wan_info.get('access_type', ''), + 'link_status': wan_info.get('link_status', ''), + 'last_error': last_error, + }, + 'dns': dns, + 'dsl': dsl, + } + except Exception as e: + logger.error(f"FritzBox full_info Fehler: {e}") + return None + + def get_hosts(self): + """Gibt alle bekannten Geräte aus der FritzBox zurück.""" + if not self._available or not self._hosts: + return [] + try: + hosts = self._hosts.get_hosts_info() + result = [] + for h in hosts: + result.append({ + 'ip': h.get('ip', ''), + 'mac': (h.get('mac') or '').lower(), + 'name': h.get('name', ''), + 'status': h.get('status', False), + 'interface': h.get('interface_type', ''), + }) + return result + except Exception as e: + logger.error(f"FritzBox hosts Fehler: {e}") + return [] + + def try_reconnect(self): + """Versucht die Verbindung wieder herzustellen.""" + if not self._available: + self._connect() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..ed0eefb --- /dev/null +++ b/src/main.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +NetMon - Netzwerk Monitor +Einstiegspunkt: Startet Monitoring-Threads und Flask-Webserver. +""" +import logging +import os +import sys + +from dotenv import load_dotenv + +# .env laden (aus Projektroot oder aktuellem Verzeichnis) +for env_path in ['/app/.env', '.env', '../.env']: + if os.path.exists(env_path): + load_dotenv(env_path) + break + +# Logging konfigurieren +log_level = getattr(logging, os.getenv('LOG_LEVEL', 'INFO').upper(), logging.INFO) +logging.basicConfig( + level=log_level, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s', + datefmt='%Y-%m-%d %H:%M:%S', + stream=sys.stdout, +) +logger = logging.getLogger(__name__) + + +def main(): + logger.info("=" * 50) + logger.info(" NetMon - Netzwerk Monitor startet") + logger.info("=" * 50) + + # Datenbank initialisieren + import database as db + db.init_db() + logger.info(f"Datenbank: {os.getenv('DB_PATH', '/data/netmon.db')}") + + # FritzBox-Client + from fritz import FritzClient + fritz = FritzClient() + + # Switch-Client + import switch + sw = switch.create_client() + if sw: + logger.info(f"Switch-Monitoring aktiviert: {os.getenv('SWITCH_HOST')}") + + # Monitoring starten + from monitor import NetworkMonitor + mon = NetworkMonitor(fritz_client=fritz, switch_client=sw) + mon.start() + + # Flask-API konfigurieren + import api + api.set_monitor(mon) + + host = os.getenv('WEB_HOST', '0.0.0.0') + port = int(os.getenv('WEB_PORT', 5000)) + logger.info(f"Web-UI erreichbar unter: http://{host}:{port}") + + # Flask starten (blockiert) + api.app.run(host=host, port=port, threaded=True, use_reloader=False) + + +if __name__ == '__main__': + main() diff --git a/src/monitor.py b/src/monitor.py new file mode 100644 index 0000000..2efd2d6 --- /dev/null +++ b/src/monitor.py @@ -0,0 +1,724 @@ +""" +Monitoring-Daemon: Startet alle Überwachungs-Threads. +""" +import logging +import os +import socket +import struct +import subprocess +import threading +import time + +import psutil + +import database as db +import notify +import scanner + +logger = logging.getLogger(__name__) + +def _env_int(key, default): + v = os.getenv(key, '') + try: + return int(v) if v.strip() else default + except ValueError: + return default + +# Konfiguration aus .env +TRAFFIC_INTERVAL = _env_int('TRAFFIC_INTERVAL', 60) +OUTAGE_INTERVAL = _env_int('OUTAGE_CHECK_INTERVAL', 5) +DEVICE_INTERVAL = _env_int('DEVICE_SCAN_INTERVAL', 300) +PORT_INTERVAL = _env_int('PORT_SCAN_INTERVAL', 3600) +FRITZ_INTERVAL = _env_int('FRITZ_POLL_INTERVAL', 60) +PING_TARGETS = [t for t in os.getenv('PING_TARGETS', '8.8.8.8,1.1.1.1').split(',') if t.strip()] +MONITOR_INTERFACE = os.getenv('MONITOR_INTERFACE', 'auto') +NETWORK_RANGE = os.getenv('NETWORK_RANGE', 'auto') +OUTAGE_THRESHOLD = _env_int('OUTAGE_THRESHOLD', 2) +CAPTURE_MAX_ROWS = _env_int('CAPTURE_MAX_ROWS', 50000) +CAPTURE_ENABLED = os.getenv('CAPTURE_ENABLED', 'true').lower() == 'true' +SWITCH_INTERVAL = _env_int('SWITCH_INTERVAL', 30) +SWITCH_ENABLED = os.getenv('SWITCH_ENABLED', 'false').lower() == 'true' +SWITCH_GATEWAY_PORT = _env_int('SWITCH_GATEWAY_PORT', 1) +PING_TARGET_INTERVAL = _env_int('PING_TARGET_INTERVAL', 5) +DEVICE_PING_INTERVAL = _env_int('DEVICE_PING_INTERVAL', 30) + + +def _build_bpf_filter() -> str: + """BPF-Filter dynamisch aus .env zusammenbauen.""" + parts = [] + + if os.getenv('CAPTURE_FILTER_ICMP', 'true').lower() == 'true': + parts.append("not icmp and not icmp6") + + if os.getenv('CAPTURE_FILTER_ARP', 'true').lower() == 'true': + parts.append("not arp") + + if os.getenv('CAPTURE_FILTER_ZABBIX', 'true').lower() == 'true': + parts.append("not (port 10050 or port 10051)") + + if os.getenv('CAPTURE_FILTER_RUSTDESK', 'true').lower() == 'true': + parts.append("not (port 21115 or port 21116 or port 21117 or port 21118 or port 21119)") + + extra = os.getenv('CAPTURE_FILTER_EXTRA', '').strip() + if extra: + parts.append(extra) + + return ' and '.join(parts) if parts else '' + + +CAPTURE_BPF = _build_bpf_filter() + + +def _get_default_gateway() -> str | None: + """Liest den Default-Gateway aus /proc/net/route.""" + try: + with open('/proc/net/route') as f: + for line in f: + fields = line.strip().split() + if len(fields) >= 3 and fields[1] == '00000000': + gw_hex = fields[2] + if gw_hex == '00000000': + continue + gw_int = int(gw_hex, 16) + gw = socket.inet_ntoa(struct.pack(' list[str]: + """Kombiniert konfigurierte Ziele + Auto-Gateway. + Wenn Switch aktiv: Gateway-IP aus Ping-Zielen entfernen (Erreichbarkeit + wird stattdessen über Switch-Port-Status geprüft). + """ + targets = [t.strip() for t in PING_TARGETS if t.strip()] + gw = _get_default_gateway() + + # Gateway immer automatisch hinzufügen (auch wenn Switch aktiv) + if gw and gw not in targets: + targets.insert(0, gw) + logger.info(f"Gateway {gw} automatisch zu Ping-Zielen hinzugefügt") + if SWITCH_ENABLED and self.switch and self.switch.is_available(): + logger.info(f"Switch aktiv – Port {SWITCH_GATEWAY_PORT} zusätzlich per Switch überwacht") + + return targets + + def stop(self): + self._stop.set() + + def status(self): + return { + 'internet_up': self._internet_up, + 'bps_up': self.current_bps_up, + 'bps_down': self.current_bps_down, + 'interface': self._iface, + 'network': self._network, + } + + # ── Private Helper ─────────────────────────────────────────────────────── + + def _detect_network(self): + iface = MONITOR_INTERFACE if MONITOR_INTERFACE != 'auto' else scanner.get_primary_interface() + network = NETWORK_RANGE if NETWORK_RANGE != 'auto' else ( + scanner.get_network_range(iface) if iface else None + ) + return iface, network + + def _sleep(self, seconds): + """Unterbrechbarer Sleep.""" + self._stop.wait(timeout=seconds) + + # ── Traffic Loop ───────────────────────────────────────────────────────── + + def _traffic_loop(self): + logger.info("Traffic-Loop gestartet") + last = None + last_ts = None + + while not self._stop.is_set(): + try: + iface = self._iface or 'total' + if self._iface and self._iface in psutil.net_io_counters(pernic=True): + counters = psutil.net_io_counters(pernic=True)[self._iface] + else: + counters = psutil.net_io_counters() + + now = int(time.time()) + + if last is not None: + dt = now - last_ts + if dt > 0: + d_sent = max(0, counters.bytes_sent - last.bytes_sent) + d_recv = max(0, counters.bytes_recv - last.bytes_recv) + d_psnt = max(0, counters.packets_sent - last.packets_sent) + d_prcv = max(0, counters.packets_recv - last.packets_recv) + bps_s = d_sent / dt + bps_r = d_recv / dt + self.current_bps_up = bps_s + self.current_bps_down = bps_r + db.insert_traffic(now, iface, d_sent, d_recv, d_psnt, d_prcv, bps_s, bps_r) + + last = counters + last_ts = now + except Exception as e: + logger.error(f"Traffic-Loop Fehler: {e}") + + self._sleep(TRAFFIC_INTERVAL) + + # ── Outage Loop ────────────────────────────────────────────────────────── + + def _outage_loop(self): + logger.info(f"Outage-Loop gestartet (Intervall: {PING_TARGET_INTERVAL}s, Schwelle: {OUTAGE_THRESHOLD} Fehlschläge)") + consecutive_failures = 0 + # Pro Target: Anzahl aufeinanderfolgender Fehlschläge und letzter bekannter Zustand + target_failures: dict[str, int] = {} + target_up: dict[str, bool] = {} + + while not self._stop.is_set(): + try: + results = self._ping_all_parallel() + now = int(time.time()) + + for target, success, latency in results: + db.insert_ping_log(now, target, success, latency) + + # ── Per-Target Benachrichtigung ────────────────────────── + if success: + if target_up.get(target) is False: + # War down, jetzt wieder up + logger.info(f"Ping wieder OK: {target}") + notify.notify_up(target) + target_failures[target] = 0 + target_up[target] = True + else: + fails = target_failures.get(target, 0) + 1 + target_failures[target] = fails + if fails >= OUTAGE_THRESHOLD and target_up.get(target) is not False: + # Erst jetzt als down markieren und benachrichtigen + logger.warning(f"Ping dauerhaft ausgefallen: {target}") + notify.notify_down(target) + target_up[target] = False + + # ── Globale Ausfall-Erkennung (alle Ziele gleichzeitig down) ─ + any_up = any(s for _, s, _ in results) + if not any_up: + consecutive_failures += 1 + if consecutive_failures >= OUTAGE_THRESHOLD and self._internet_up: + self._internet_up = False + self._outage_id = db.start_outage(now) + logger.warning(f"INTERNET AUSFALL (ID: {self._outage_id})") + notify.send( + title="🔴 NetMon: INTERNET AUSFALL", + message=f"Alle Ping-Ziele nicht erreichbar.", + priority=1, + cooldown_key="internet_down", + ) + else: + if consecutive_failures > 0: + logger.info(f"Ping wieder OK nach {consecutive_failures} Fehlschlägen") + consecutive_failures = 0 + if not self._internet_up: + if self._outage_id: + db.end_outage(self._outage_id, now) + logger.info(f"Internet wieder online (ID: {self._outage_id})") + self._outage_id = None + self._internet_up = True + + except Exception as e: + logger.error(f"Outage-Loop Fehler: {e}") + + self._sleep(PING_TARGET_INTERVAL) + + def _ping_all_parallel(self) -> list[tuple[str, bool, float | None]]: + """ + Pingt alle Ziele GLEICHZEITIG (parallel). + Gibt Liste von (target, success, latency_ms) zurück. + Timeout: 1s pro Ping. + """ + targets = getattr(self, '_ping_targets', PING_TARGETS) + results = [] + lock = threading.Lock() + + def do_ping(target): + t0 = time.monotonic() + success = False + latency = None + try: + ret = subprocess.run( + ['ping', '-c', '1', '-W', '1', target], + capture_output=True, timeout=2 + ) + elapsed = (time.monotonic() - t0) * 1000 + if ret.returncode == 0: + success = True + latency = round(elapsed, 1) + except Exception: + pass + with lock: + results.append((target, success, latency)) + + threads = [threading.Thread(target=do_ping, args=(t,), daemon=True) + for t in targets] + for t in threads: + t.start() + for t in threads: + t.join(timeout=3) # max 3s auf alle warten + + return results + + # ── Device Loop ────────────────────────────────────────────────────────── + + def _device_loop(self): + logger.info("Device-Loop gestartet") + # Erst kurz warten damit alles initialisiert ist + self._sleep(15) + while not self._stop.is_set(): + try: + self._scan_devices() + except Exception as e: + logger.error(f"Device-Loop Fehler: {e}") + self._sleep(DEVICE_INTERVAL) + + def _scan_devices(self): + now = int(time.time()) + found_macs = set() + + # FritzBox NUR für Metadaten (Name, Interface-Typ) – NICHT als Gerätequelle! + # Die FritzBox speichert historische Geräte, daher würden inaktive Geräte + # fälschlicherweise als aktiv erscheinen. + fritz_map = {} # mac -> {name, interface} + if self.fritz and self.fritz.is_available(): + for h in self.fritz.get_hosts(): + if h['mac']: + fritz_map[h['mac']] = h + + # ARP-Scan ist die einzige Wahrheitsquelle für aktive Geräte + if self._network: + arp_devices = scanner.arp_scan(self._network) + else: + arp_devices = [] + logger.warning("Kein Netzwerkbereich – Device-Scan übersprungen") + + # Nur ARP-Ergebnisse verwenden (keine FritzBox-Ergänzung) + all_devices = {d['mac']: d for d in arp_devices if d['mac']} + logger.info(f"ARP-Scan: {len(all_devices)} aktive Geräte gefunden") + + for mac, dev in all_devices.items(): + # Metadaten aus FritzBox anreichern falls vorhanden + fritz_info = fritz_map.get(mac, {}) + fritz_name = fritz_info.get('name', '') + iface_type = fritz_info.get('interface', '') + hostname = dev.get('hostname') or scanner.resolve_hostname(dev['ip']) + vendor = scanner.get_vendor(mac) + + db.upsert_device( + mac=mac, + ip=dev['ip'], + hostname=hostname, + vendor=vendor, + fritz_name=fritz_name, + iface_type=iface_type, + now_ts=now, + ) + found_macs.add(mac) + + # Nicht mehr gesehene Geräte auf inaktiv setzen + db.set_devices_inactive(found_macs, now) + logger.info(f"Device-Scan: {len(found_macs)} Geräte gefunden") + + # ── Port Loop ──────────────────────────────────────────────────────────── + + def _port_loop(self): + logger.info("Port-Loop gestartet") + self._sleep(60) # Erst nach Device-Scan starten + while not self._stop.is_set(): + try: + self._scan_ports() + except Exception as e: + logger.error(f"Port-Loop Fehler: {e}") + self._sleep(PORT_INTERVAL) + + def _scan_ports(self): + devices = db.query_devices() + active = [d for d in devices if d['is_active'] and d.get('ip')] + logger.info(f"Port-Scan: {len(active)} aktive Geräte") + now = int(time.time()) + + for dev in active: + ip = dev['ip'] + device_id = dev['id'] + try: + ports = scanner.port_scan(ip) + for p in ports: + db.upsert_port( + device_id=device_id, + port=p['port'], + protocol=p['protocol'], + service=p['service'], + state=p['state'], + ts=now, + ) + # Alte Ports entfernen + db.remove_closed_ports(device_id, set(), now - 10) + except Exception as e: + logger.error(f"Port-Scan Fehler für {ip}: {e}") + + # Nicht zu aggressiv scannen + self._sleep(2) + + # ── Device Ping Loop ───────────────────────────────────────────────────── + + def _device_ping_loop(self): + """Pingt alle aktiven Netzwerk-Geräte die NICHT in PING_TARGETS sind, alle 30s.""" + logger.info(f"Device-Ping-Loop gestartet (Intervall: {DEVICE_PING_INTERVAL}s)") + self._sleep(20) # kurz warten bis Device-Scan erste Ergebnisse hat + while not self._stop.is_set(): + try: + ping_target_set = set(getattr(self, '_ping_targets', PING_TARGETS)) + devices = db.query_devices() + # Nur aktive Geräte die eine IP haben und nicht schon in PING_TARGETS sind + to_ping = [ + d for d in devices + if d['is_active'] and d.get('ip') and d['ip'] not in ping_target_set + ] + if to_ping: + now = int(time.time()) + results = self._ping_devices_parallel(to_ping) + for ip, mac, success, latency in results: + db.insert_device_ping(now, ip, mac, success, latency) + logger.debug(f"Device-Ping: {len(results)} Geräte gepingt") + except Exception as e: + logger.error(f"Device-Ping-Loop Fehler: {e}") + self._sleep(DEVICE_PING_INTERVAL) + + def _ping_devices_parallel(self, devices) -> list[tuple]: + """Pingt Liste von Geräten parallel. Gibt (ip, mac, success, latency) zurück.""" + results = [] + lock = threading.Lock() + + def do_ping(dev): + ip = dev['ip'] + mac = dev.get('mac', '') + t0 = time.monotonic() + success = False + latency = None + try: + ret = subprocess.run( + ['ping', '-c', '1', '-W', '1', ip], + capture_output=True, timeout=2 + ) + elapsed = (time.monotonic() - t0) * 1000 + if ret.returncode == 0: + success = True + latency = round(elapsed, 1) + except Exception: + pass + with lock: + results.append((ip, mac, success, latency)) + + threads = [threading.Thread(target=do_ping, args=(d,), daemon=True) for d in devices] + for t in threads: + t.start() + for t in threads: + t.join(timeout=3) + return results + + # ── FritzBox Loop ──────────────────────────────────────────────────────── + + def _fritz_loop(self): + logger.info("FritzBox-Loop gestartet") + reconnect_counter = 0 + while not self._stop.is_set(): + try: + if self.fritz: + if not self.fritz.is_available() and reconnect_counter % 10 == 0: + self.fritz.try_reconnect() + + stats = self.fritz.get_connection_stats() + if stats: + db.insert_fritz_stats( + ts=int(time.time()), + is_connected=stats['is_connected'], + uptime_s=stats['uptime_s'], + max_up=stats['max_up_bps'], + max_down=stats['max_down_bps'], + total_sent=stats['total_bytes_sent'], + total_recv=stats['total_bytes_recv'], + external_ip=stats['external_ip'], + ) + reconnect_counter += 1 + except Exception as e: + logger.error(f"FritzBox-Loop Fehler: {e}") + + self._sleep(FRITZ_INTERVAL) + + # ── Cleanup Loop ───────────────────────────────────────────────────────── + + def _cleanup_loop(self): + """Räumt alte Daten täglich auf.""" + # Sofort beim Start aufräumen + try: + db.cleanup_old_data(days=7) + logger.info("Startup-Cleanup: alte Daten bereinigt") + except Exception as e: + logger.error(f"Startup-Cleanup Fehler: {e}") + while not self._stop.is_set(): + self._sleep(86400) # 24h + try: + db.cleanup_old_data(days=7) + logger.info("Alte Daten bereinigt (7 Tage Retention)") + except Exception as e: + logger.error(f"Cleanup Fehler: {e}") + + # ── Capture Loop ───────────────────────────────────────────────────────── + + def _capture_loop(self): + """ + Live-Paketmitschnitt via scapy – wie Wireshark. + Filtert ICMP (Ping), Zabbix (10050/10051) und RustDesk (21115-21119) raus. + Speichert Paket-Metadaten (kein Payload) in DB. + """ + if not CAPTURE_ENABLED: + logger.info("Paketmitschnitt deaktiviert (CAPTURE_ENABLED=false)") + return + + iface = self._iface + if not iface: + logger.warning("Kein Interface – Paketmitschnitt übersprungen") + return + + logger.info(f"Paketmitschnitt gestartet auf {iface} | Filter: {CAPTURE_BPF}") + + try: + from scapy.all import sniff, IP, IPv6, TCP, UDP, ARP, Ether, ICMP + except ImportError: + logger.error("scapy nicht verfügbar – Paketmitschnitt deaktiviert") + return + + # Batch-Puffer: Pakete sammeln und gebündelt schreiben (reduziert DB-Verbindungen) + _batch: list = [] + _batch_lock = threading.Lock() + _last_flush = [time.time()] + _trim_counter = [0] + BATCH_SIZE = 50 # max. Pakete pro Batch + BATCH_SECS = 1.0 # max. Wartezeit bis Flush + + def _flush_batch(): + with _batch_lock: + if not _batch: + return + rows = _batch[:] + _batch.clear() + try: + db.insert_packets_bulk(rows) + _trim_counter[0] += len(rows) + if _trim_counter[0] >= 2000: + _trim_counter[0] = 0 + db.trim_packets(CAPTURE_MAX_ROWS) + except Exception as e: + logger.debug(f"Batch-Flush Fehler: {e}") + + def handle_pkt(pkt): + try: + ts = float(pkt.time) + src_ip = dst_ip = src_port = dst_port = flags = None + protocol = "OTHER" + length = len(pkt) + info_parts = [] + + if pkt.haslayer(IP): + ip = pkt[IP] + src_ip = ip.src + dst_ip = ip.dst + protocol = ip.proto + elif pkt.haslayer(IPv6): + ip6 = pkt[IPv6] + src_ip = ip6.src + dst_ip = ip6.dst + protocol = "IPv6" + elif pkt.haslayer(ARP): + arp = pkt[ARP] + src_ip = arp.psrc + dst_ip = arp.pdst + protocol = "ARP" + op = "Who has" if arp.op == 1 else "Is at" + info_parts.append(f"{op} {dst_ip}") + + if pkt.haslayer(TCP): + tcp = pkt[TCP] + src_port = tcp.sport + dst_port = tcp.dport + protocol = "TCP" + flag_map = {'F':'FIN','S':'SYN','R':'RST','P':'PSH', + 'A':'ACK','U':'URG','E':'ECE','C':'CWR'} + f_str = ''.join(v for k, v in flag_map.items() + if k in str(tcp.flags)) + flags = f_str or str(tcp.flags) + info_parts.append(f"{src_port}→{dst_port} [{flags}] Seq={tcp.seq}") + known = {80:'HTTP', 443:'HTTPS', 22:'SSH', 21:'FTP', + 25:'SMTP', 53:'DNS', 3306:'MySQL', 5432:'PostgreSQL', + 6379:'Redis', 27017:'MongoDB', 1883:'MQTT', + 8080:'HTTP-Alt', 8443:'HTTPS-Alt', 445:'SMB', + 3389:'RDP', 5900:'VNC', 548:'AFP'} + for p in (src_port, dst_port): + if p in known: + protocol = known[p] + break + elif pkt.haslayer(UDP): + udp = pkt[UDP] + src_port = udp.sport + dst_port = udp.dport + protocol = "UDP" + info_parts.append(f"{src_port}→{dst_port} Len={udp.len}") + known_udp = {53:'DNS', 67:'DHCP', 68:'DHCP', 123:'NTP', + 5353:'mDNS', 1900:'SSDP', 514:'Syslog'} + for p in (src_port, dst_port): + if p in known_udp: + protocol = known_udp[p] + break + + info = ' | '.join(info_parts) if info_parts else protocol + + row = (ts, iface, src_ip, dst_ip, src_port, dst_port, + protocol, length, flags, info) + with _batch_lock: + _batch.append(row) + now = time.time() + if len(_batch) >= BATCH_SIZE or (now - _last_flush[0]) >= BATCH_SECS: + _last_flush[0] = now + rows = _batch[:] + _batch.clear() + else: + rows = None + + if rows: + try: + db.insert_packets_bulk(rows) + _trim_counter[0] += len(rows) + if _trim_counter[0] >= 2000: + _trim_counter[0] = 0 + db.trim_packets(CAPTURE_MAX_ROWS) + except Exception as e: + logger.debug(f"Batch-Flush Fehler: {e}") + + except Exception as e: + logger.debug(f"Paket-Parse Fehler: {e}") + + # scapy sniff – läuft bis stop_filter True zurückgibt + while not self._stop.is_set(): + try: + sniff( + iface=iface, + filter=CAPTURE_BPF, + prn=handle_pkt, + store=False, + timeout=10, + stop_filter=lambda _: self._stop.is_set(), + ) + _flush_batch() # Rest nach timeout schreiben + except Exception as e: + logger.error(f"Capture Fehler: {e} – retry in 10s") + self._sleep(10) + + # ── Switch Loop ────────────────────────────────────────────────────────── + + def _switch_loop(self): + """Pollt Switch-Port-Status und speichert Änderungen in DB.""" + if not SWITCH_ENABLED or not self.switch or not self.switch.is_available(): + logger.info("Switch-Loop deaktiviert (SWITCH_ENABLED=false oder kein Client)") + return + + logger.info(f"Switch-Loop gestartet (Intervall: {SWITCH_INTERVAL}s, Gateway-Port: {SWITCH_GATEWAY_PORT})") + + while not self._stop.is_set(): + try: + ports = self.switch.get_port_status() + if ports: + now = int(time.time()) + db.insert_switch_ports(now, ports) + + for p in ports: + port_num = p['port'] + connected = p['connected'] + prev = self._switch_port_states.get(port_num) + + if prev is None: + # Erstmaliger Wert – kein Event, nur Zustand merken + self._switch_port_states[port_num] = connected + continue + + if connected != prev: + event = 'up' if connected else 'down' + db.insert_switch_event(now, port_num, event, p.get('link_speed')) + self._switch_port_states[port_num] = connected + logger.info( + f"Switch Port {port_num}: {event.upper()}" + + (f" ({p['link_speed']})" if p.get('link_speed') else "") + ) + + # Wenn Gateway-Port ausfällt: Ausfall auch in outages loggen + if port_num == SWITCH_GATEWAY_PORT: + if event == 'down' and self._internet_up: + self._internet_up = False + self._outage_id = db.start_outage(now) + logger.warning(f"INTERNET AUSFALL via Switch Port {port_num} (ID: {self._outage_id})") + elif event == 'up' and not self._internet_up: + if self._outage_id: + db.end_outage(self._outage_id, now) + logger.info(f"Internet wieder online via Switch Port {port_num} (ID: {self._outage_id})") + self._outage_id = None + self._internet_up = True + else: + logger.warning("Switch: Keine Port-Daten erhalten") + + except Exception as e: + logger.error(f"Switch-Loop Fehler: {e}") + + self._sleep(SWITCH_INTERVAL) diff --git a/src/notify.py b/src/notify.py new file mode 100644 index 0000000..670d206 --- /dev/null +++ b/src/notify.py @@ -0,0 +1,148 @@ +""" +Benachrichtigungen via Pushover und/oder Nextcloud Talk. +Sendet Alarm wenn überwachte IPs aus- oder wieder online gehen. +""" +import logging +import os +import time + +import requests + +logger = logging.getLogger(__name__) + +# ── Cooldown: verhindert Nachrichten-Spam bei kurzen Ausfällen ─────────────── +_last_sent: dict[str, float] = {} # key → letzter Sendezeitpunkt + + +def _cooldown_ok(key: str, cooldown_s: int) -> bool: + now = time.time() + if now - _last_sent.get(key, 0) >= cooldown_s: + _last_sent[key] = now + return True + return False + + +# ── Pushover ────────────────────────────────────────────────────────────────── + +def _send_pushover(token: str, user: str, title: str, message: str, + priority: int = 0) -> bool: + try: + r = requests.post( + 'https://api.pushover.net/1/messages.json', + data={'token': token, 'user': user, + 'title': title, 'message': message, 'priority': priority}, + timeout=10, + ) + if r.status_code == 200: + logger.info(f"Pushover gesendet: {title}") + return True + logger.warning(f"Pushover Fehler {r.status_code}: {r.text}") + return False + except Exception as e: + logger.error(f"Pushover Exception: {e}") + return False + + +# ── Nextcloud Talk ──────────────────────────────────────────────────────────── + +def _send_nctalk(url: str, user: str, password: str, room: str, + message: str) -> bool: + """Sendet Nachricht in einen Nextcloud-Talk-Raum (Token oder Alias).""" + try: + endpoint = f"{url.rstrip('/')}/ocs/v2.php/apps/spreed/api/v1/chat/{room}" + r = requests.post( + endpoint, + data={'message': message}, + auth=(user, password), + headers={ + 'OCS-APIRequest': 'true', + 'Accept': 'application/json', + }, + timeout=10, + ) + if r.status_code in (200, 201): + logger.info(f"Nextcloud Talk gesendet: {message[:60]}") + return True + logger.warning(f"Nextcloud Talk Fehler {r.status_code}: {r.text[:200]}") + return False + except Exception as e: + logger.error(f"Nextcloud Talk Exception: {e}") + return False + + +# ── Öffentliche API ─────────────────────────────────────────────────────────── + +def send(title: str, message: str, priority: int = 0, + cooldown_key: str | None = None) -> None: + """ + Sendet Benachrichtigung über alle konfigurierten Kanäle. + cooldown_key: eindeutiger Key für Cooldown-Prüfung (None = immer senden). + """ + cooldown_s = int(os.getenv('NOTIFY_COOLDOWN', '') or 300) + if cooldown_key and not _cooldown_ok(cooldown_key, cooldown_s): + logger.debug(f"Benachrichtigung unterdrückt (Cooldown): {title}") + return + + if os.getenv('PUSHOVER_ENABLED', 'false').lower() == 'true': + token = os.getenv('PUSHOVER_APP_TOKEN', '') + user = os.getenv('PUSHOVER_USER_KEY', '') + if token and user: + _send_pushover(token, user, title, message, priority) + else: + logger.warning("Pushover aktiviert aber Token/User fehlt") + + if os.getenv('NCTALK_ENABLED', 'false').lower() == 'true': + url = os.getenv('NCTALK_URL', '') + nc_user = os.getenv('NCTALK_USER', '') + nc_pass = os.getenv('NCTALK_PASSWORD', '') + room = os.getenv('NCTALK_ROOM', '') + if url and nc_user and nc_pass and room: + _send_nctalk(url, nc_user, nc_pass, room, f"{title}: {message}") + else: + logger.warning("Nextcloud Talk aktiviert aber Zugangsdaten fehlen") + + +def notify_down(target: str) -> None: + if os.getenv('NOTIFY_ON_DOWN', 'true').lower() != 'true': + return + send( + title=f"⚠️ NetMon: {target} OFFLINE", + message=f"{target} antwortet nicht mehr auf Pings.", + priority=1, + cooldown_key=f"down:{target}", + ) + + +def notify_up(target: str) -> None: + if os.getenv('NOTIFY_ON_UP', 'true').lower() != 'true': + return + send( + title=f"✅ NetMon: {target} wieder online", + message=f"{target} ist wieder erreichbar.", + priority=0, + cooldown_key=f"up:{target}", + ) + + +def test_notification() -> dict: + """Sendet Test-Nachricht über alle aktivierten Kanäle, gibt Ergebnisse zurück.""" + results = {} + + if os.getenv('PUSHOVER_ENABLED', 'false').lower() == 'true': + token = os.getenv('PUSHOVER_APP_TOKEN', '') + user = os.getenv('PUSHOVER_USER_KEY', '') + ok = _send_pushover(token, user, 'NetMon Test', 'Benachrichtigungen funktionieren ✓') + results['pushover'] = 'ok' if ok else 'fehler' + + if os.getenv('NCTALK_ENABLED', 'false').lower() == 'true': + url = os.getenv('NCTALK_URL', '') + nc_user = os.getenv('NCTALK_USER', '') + nc_pass = os.getenv('NCTALK_PASSWORD', '') + room = os.getenv('NCTALK_ROOM', '') + ok = _send_nctalk(url, nc_user, nc_pass, room, 'NetMon Test: Benachrichtigungen funktionieren ✓') + results['nctalk'] = 'ok' if ok else 'fehler' + + if not results: + results['info'] = 'Kein Kanal aktiviert' + + return results diff --git a/src/scanner.py b/src/scanner.py new file mode 100644 index 0000000..038cb5f --- /dev/null +++ b/src/scanner.py @@ -0,0 +1,163 @@ +import logging +import socket +import time + +logger = logging.getLogger(__name__) + +# Bekannte MAC-Prefixes (OUI) für Vendor-Erkennung +MAC_VENDORS = { + '00:50:56': 'VMware', '00:0c:29': 'VMware', '00:1c:14': 'VMware', + 'b8:27:eb': 'Raspberry Pi', 'dc:a6:32': 'Raspberry Pi', 'e4:5f:01': 'Raspberry Pi', + '00:1a:11': 'Google', 'f4:f5:d8': 'Google', + '3c:22:fb': 'Apple', '00:17:f2': 'Apple', 'a4:c3:f0': 'Apple', + '00:1b:63': 'Apple', 'ac:de:48': 'Apple', '28:cf:e9': 'Apple', + '18:65:90': 'Apple', '70:3e:ac': 'Apple', + '00:e0:4c': 'Realtek', '52:54:00': 'QEMU/KVM', + '00:16:3e': 'Xen', '02:42': 'Docker', + '00:25:90': 'Super Micro', '00:1d:09': 'Dell', + 'f8:b7:97': 'AVM', '00:04:0e': 'AVM Fritz!Box', + 'c4:27:95': 'Samsung', '00:12:47': 'Samsung', + 'ec:f4:bb': 'Amazon', '00:bb:3a': 'Amazon', + '18:74:2e': 'TP-Link', 'f4:ec:38': 'TP-Link', + '00:1e:e5': 'Cisco', '00:0f:90': 'Netgear', + '00:26:b9': 'Dell', '14:18:77': 'Huawei', + 'cc:2d:e0': 'Shelly', '98:f4:ab': 'Espressif (ESP32)', +} + + +def get_vendor(mac: str) -> str: + if not mac: + return '' + prefix = mac[:8].upper().replace('-', ':') + for k, v in MAC_VENDORS.items(): + if prefix.startswith(k.upper()): + return v + return '' + + +def resolve_hostname(ip: str) -> str: + try: + return socket.gethostbyaddr(ip)[0] + except Exception: + return '' + + +def arp_scan(network: str) -> list[dict]: + """ + ARP-Scan mit scapy. Gibt Liste von {ip, mac, hostname} zurück. + Fällt auf nmap zurück wenn scapy nicht verfügbar. + """ + try: + from scapy.all import ARP, Ether, srp + logger.debug(f"ARP-Scan: {network}") + pkt = Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(pdst=network) + answered, _ = srp(pkt, timeout=3, verbose=0, retry=2) + devices = [] + for _, resp in answered: + ip = resp.psrc + mac = resp.hwsrc.lower() + hostname = resolve_hostname(ip) + devices.append({'ip': ip, 'mac': mac, 'hostname': hostname}) + logger.info(f"ARP-Scan gefunden: {len(devices)} Geräte") + return devices + except ImportError: + logger.warning("scapy nicht verfügbar – nutze nmap für Discovery") + return _nmap_discovery(network) + except PermissionError: + logger.warning("Keine Berechtigung für ARP-Scan – nutze nmap") + return _nmap_discovery(network) + except Exception as e: + logger.error(f"ARP-Scan Fehler: {e}") + return _nmap_discovery(network) + + +def _nmap_discovery(network: str) -> list[dict]: + """Geräteerkennung via nmap -sn (Ping-Scan).""" + try: + import nmap + nm = nmap.PortScanner() + nm.scan(hosts=network, arguments='-sn --send-ip') + devices = [] + for host in nm.all_hosts(): + addrs = nm[host].get('addresses', {}) + mac = addrs.get('mac', '').lower() + hostname = nm[host].hostname() or resolve_hostname(host) + devices.append({'ip': host, 'mac': mac, 'hostname': hostname}) + logger.info(f"nmap Discovery gefunden: {len(devices)} Geräte") + return devices + except Exception as e: + logger.error(f"nmap Discovery Fehler: {e}") + return [] + + +def port_scan(ip: str) -> list[dict]: + """ + Schneller Port-Scan der häufigsten Ports mit nmap. + Gibt Liste von {port, protocol, service, state} zurück. + """ + # Wichtigste Ports: Web, SSH, SMB, RDP, DNS, Mail, DB, etc. + common_ports = ( + "21-23,25,53,80,110,111,135,139,143,443,445,548," + "993,995,1080,1194,1433,1883,2049,3306,3389,4000," + "5000,5432,5900,6379,8080,8443,8883,9100,27017" + ) + try: + import nmap + nm = nmap.PortScanner() + nm.scan(ip, common_ports, arguments='-T4 --open -sV --version-intensity 2') + result = [] + if ip not in nm.all_hosts(): + return result + for proto in nm[ip].all_protocols(): + for port, info in sorted(nm[ip][proto].items()): + if info['state'] == 'open': + result.append({ + 'port': port, + 'protocol': proto, + 'service': info.get('name', ''), + 'state': 'open', + }) + logger.debug(f"Port-Scan {ip}: {len(result)} offene Ports") + return result + except Exception as e: + logger.error(f"Port-Scan Fehler für {ip}: {e}") + return [] + + +def get_network_range(iface: str) -> str | None: + """Berechnet den Netzwerkbereich für ein Interface (z.B. 192.168.178.0/24).""" + try: + import psutil, ipaddress + addrs = psutil.net_if_addrs().get(iface, []) + for addr in addrs: + if addr.family == socket.AF_INET and not addr.address.startswith('127.'): + net = ipaddress.IPv4Network( + f"{addr.address}/{addr.netmask}", strict=False + ) + return str(net) + except Exception as e: + logger.error(f"Netzwerkbereich für {iface} nicht ermittelbar: {e}") + return None + + +def get_primary_interface() -> str | None: + """Findet das primäre Netzwerk-Interface (mit Default-Route).""" + try: + with open('/proc/net/route') as f: + for line in f: + fields = line.strip().split() + if len(fields) >= 4 and fields[1] == '00000000': + if int(fields[3], 16) & 2: # RTF_GATEWAY + return fields[0] + except Exception: + pass + # Fallback: psutil + try: + import psutil + stats = psutil.net_if_stats() + for iface, s in stats.items(): + if s.isup and iface not in ('lo', 'docker0') and not iface.startswith('veth'): + return iface + except Exception: + pass + return None diff --git a/src/switch.py b/src/switch.py new file mode 100644 index 0000000..712de64 --- /dev/null +++ b/src/switch.py @@ -0,0 +1,149 @@ +""" +Netgear JGS524Ev2 Switch-Integration. +Login via HMAC-MD5, Port-Status via /config/status_status.htm scraping. +""" +import hashlib +import hmac +import logging +import os +import re +import time + +import requests + +logger = logging.getLogger(__name__) + +SWITCH_HOST = os.getenv('SWITCH_HOST', '') +SWITCH_PASSWORD = os.getenv('SWITCH_PASSWORD', 'password') +SWITCH_ENABLED = os.getenv('SWITCH_ENABLED', 'false').lower() == 'true' + +_HMAC_KEY = 'YOU_CAN_NOT_PASS' + + +def _calc_password(password: str) -> str: + """Netgear ProSafe Plus HMAC-MD5 Passwort-Hash.""" + buf = b'' + pw = password.encode() + while len(buf) <= (2048 - (len(pw) + 1)): + buf += pw + b'\x00' + while len(buf) < 2048: + buf += b'\x00' + return hmac.new(_HMAC_KEY.encode(), buf, hashlib.md5).hexdigest() + + +class SwitchClient: + def __init__(self, host: str, password: str): + self.host = host + self.password = password + self._session = None + self._last_login = 0 + self._session_ttl = 270 # Switch-Session läuft nach ~5min ab + + def _login(self) -> bool: + """Neuen Session-Cookie holen.""" + try: + s = requests.Session() + pw_hash = _calc_password(self.password) + r = s.post( + f'http://{self.host}/login.htm', + data=f'submitId=pwdLogin&password={pw_hash}&submitEnd=', + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=10, + ) + if r.status_code == 200 and s.cookies.get('SID'): + self._session = s + self._last_login = time.time() + logger.debug(f"Switch Login OK (SID={s.cookies.get('SID')[:8]}...)") + return True + logger.warning(f"Switch Login fehlgeschlagen: HTTP {r.status_code}") + return False + except Exception as e: + logger.error(f"Switch Login Fehler: {e}") + return False + + def _ensure_session(self) -> bool: + if self._session is None or (time.time() - self._last_login) > self._session_ttl: + return self._login() + return True + + def get_port_status(self) -> list[dict] | None: + """ + Gibt Liste aller Ports zurück: + [{'port': 1, 'connected': True, 'speed': '1000M', 'speed_config': 'Auto', 'flow_control': False}, ...] + """ + if not self._ensure_session(): + return None + + try: + r = self._session.get( + f'http://{self.host}/config/status_status.htm', + headers={'Referer': f'http://{self.host}/index.htm'}, + timeout=15, + ) + if r.status_code == 403: + # Session abgelaufen – neu einloggen + self._session = None + if not self._login(): + return None + r = self._session.get( + f'http://{self.host}/config/status_status.htm', + headers={'Referer': f'http://{self.host}/index.htm'}, + timeout=15, + ) + + if r.status_code != 200: + logger.warning(f"Switch Status-Seite: HTTP {r.status_code}") + return None + + return _parse_port_status(r.text) + + except Exception as e: + logger.error(f"Switch get_port_status Fehler: {e}") + self._session = None # Session zurücksetzen bei Fehler + return None + + def is_available(self) -> bool: + return bool(self.host and SWITCH_ENABLED) + + +def _parse_port_status(html: str) -> list[dict]: + """ + Parst portConfigEntry-Array aus Switch-HTML. + Format: 'portNum??Status?SpeedConfig?LinkSpeed?FlowControl' + Status: 'Aktiv'/'Inaktiv' (DE), 'Active'/'Inactive' (EN) + """ + entries = re.findall(r"portConfigEntry\[\d+\]\s*=\s*'([^']+)'", html) + ports = [] + for entry in entries: + parts = entry.split('?') + if len(parts) < 6: + continue + port_num = int(parts[0]) if parts[0].isdigit() else None + status_str = parts[2].strip().lower() + speed_cfg = parts[3].strip() + link_speed = parts[4].strip() + flow_ctrl = parts[5].strip().lower() + + if port_num is None: + continue + + connected = status_str in ('aktiv', 'active', 'enabled') + ports.append({ + 'port': port_num, + 'connected': connected, + 'speed_config': speed_cfg, + 'link_speed': link_speed if connected else None, + 'flow_control': flow_ctrl not in ('deaktiviert', 'disabled', ''), + }) + return ports + + +def create_client() -> SwitchClient | None: + """Factory: gibt Client zurück wenn Switch aktiviert und Host gesetzt.""" + if not SWITCH_ENABLED: + return None + if not SWITCH_HOST: + logger.warning("SWITCH_ENABLED=true aber SWITCH_HOST nicht gesetzt") + return None + logger.info(f"Switch-Client erstellt für {SWITCH_HOST}") + return SwitchClient(SWITCH_HOST, SWITCH_PASSWORD) diff --git a/src/web/app.js b/src/web/app.js new file mode 100644 index 0000000..1f8aa81 --- /dev/null +++ b/src/web/app.js @@ -0,0 +1,1065 @@ +'use strict'; + +// ── State ───────────────────────────────────────────────────────────────── +let trafficChart = null; +let fritzChart = null; +let currentRange = '1h'; +let allDevices = []; +let refreshTimer = null; +let countdownTimer = null; +let countdownVal = 30; +const portModal = new bootstrap.Modal('#portModal'); + +// Ping-Monitor State +let pingLiveTimer = null; +let pingLogLastTs = 0; +let pingLogRows = 0; +const PING_LOG_MAX = 500; + +// ── Init ────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initTrafficChart(); + loadStatus(); + loadTraffic('1h'); + loadRecentOutages(); + + // Range-Buttons + document.querySelectorAll('.range-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + loadTraffic(btn.dataset.range); + }); + }); + + // Capture/Ping stoppen wenn Tab verlassen wird + document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => { + btn.addEventListener('hide.bs.tab', (e) => { + if (e.target.id === 'tab-capture-btn') stopCapture(); + if (e.target.id === 'tab-ping-btn') stopPingMonitor(); + }); + }); + + // Tab-Events: Daten beim Öffnen laden + document.getElementById('tab-devices-btn').addEventListener('click', loadDevices); + document.getElementById('tab-fritz-btn').addEventListener('click', loadFritz); + document.getElementById('tab-ping-btn').addEventListener('click', startPingMonitor); + document.getElementById('tab-switch-btn').addEventListener('click', loadSwitch); + document.getElementById('tab-capture-btn').addEventListener('click', () => { + if (!captureTimer && !capturePaused) startCapture(); + }); + + // Auto-Refresh alle 30s + refreshTimer = setInterval(() => { + loadStatus(); + loadTraffic(currentRange); + const active = document.querySelector('.nav-link.active'); + if (active) { + const target = active.dataset.bsTarget; + if (target === '#tab-devices') loadDevices(); + if (target === '#tab-fritz') loadFritz(); + if (target === '#tab-switch') loadSwitch(); + // Ping-Tab hat eigenen Timer für Live-Daten, statische Teile hier manuell aktualisieren + if (target === '#tab-ping') { loadOutages(); loadOutageSummary(); loadPingStats(); } + } + }, 30000); +}); + +// ── API Helper ──────────────────────────────────────────────────────────── +async function apiFetch(url) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } catch (e) { + console.error('API Fehler:', url, e); + return null; + } +} + +// ── Status ──────────────────────────────────────────────────────────────── +async function loadStatus() { + const data = await apiFetch('/api/status'); + if (!data) return; + + // Internet-Badge + const badge = document.getElementById('inet-badge'); + const dot = badge.querySelector('.status-dot'); + const text = badge.querySelector('.status-text'); + badge.className = 'status-badge ' + (data.internet_up ? 'status-up' : 'status-down'); + text.textContent = data.internet_up ? 'Online' : 'OFFLINE'; + + // Navbar Speeds + document.getElementById('nav-up').textContent = fmtSpeed(data.bps_up); + document.getElementById('nav-down').textContent = fmtSpeed(data.bps_down); + + // Stat Cards + document.getElementById('stat-inet').innerHTML = + data.internet_up + ? 'Online' + : 'OFFLINE'; + + document.getElementById('stat-devices').textContent = + `${data.active_devices} / ${data.total_devices}`; + + document.getElementById('stat-outages').textContent = data.outages_today; + + document.getElementById('stat-outage-time').textContent = + data.total_outage_seconds_today > 0 + ? fmtDuration(data.total_outage_seconds_today) + : '–'; + + // Badges in Tabs + document.getElementById('badge-devices').textContent = data.active_devices; + document.getElementById('badge-outages').textContent = data.outages_today || '0'; + + // Laufender Ausfall? + if (data.current_outage_since) { + const since = new Date(data.current_outage_since * 1000); + const ago = Math.round((Date.now() - since) / 1000); + document.getElementById('stat-inet').innerHTML = + `OFFLINE seit ${fmtDuration(ago)}`; + } + + // Update-Timestamp + const now = new Date(); + document.getElementById('last-update').textContent = now.toLocaleTimeString('de-DE'); + document.getElementById('footer-ts').textContent = 'Stand: ' + now.toLocaleString('de-DE'); +} + +// ── Traffic Chart ───────────────────────────────────────────────────────── +function initTrafficChart() { + const ctx = document.getElementById('traffic-chart').getContext('2d'); + + const commonOpts = { + tension: 0.3, + borderWidth: 2, + pointRadius: 0, + pointHoverRadius: 4, + fill: true, + }; + + trafficChart = new Chart(ctx, { + type: 'line', + data: { + labels: [], + datasets: [ + { + label: 'Download (kbps)', + ...commonOpts, + borderColor: '#58a6ff', + backgroundColor: 'rgba(88,166,255,0.08)', + data: [], + }, + { + label: 'Upload (kbps)', + ...commonOpts, + borderColor: '#3fb950', + backgroundColor: 'rgba(63,185,80,0.08)', + data: [], + }, + ], + }, + options: { + responsive: true, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + labels: { color: '#8b949e', boxWidth: 12, font: { size: 12 } }, + }, + tooltip: { + backgroundColor: '#21262d', + borderColor: '#30363d', + borderWidth: 1, + titleColor: '#e6edf3', + bodyColor: '#8b949e', + callbacks: { + label: ctx => ` ${ctx.dataset.label}: ${fmtKbps(ctx.raw)}`, + }, + }, + }, + scales: { + x: { + grid: { color: '#21262d' }, + ticks: { color: '#8b949e', maxTicksLimit: 10, font: { size: 11 } }, + }, + y: { + grid: { color: '#21262d' }, + ticks: { color: '#8b949e', font: { size: 11 }, + callback: v => fmtKbps(v) }, + min: 0, + }, + }, + }, + }); +} + +async function loadTraffic(range) { + currentRange = range; + const data = await apiFetch(`/api/traffic?range=${range}`); + if (!data || !trafficChart) return; + trafficChart.data.labels = data.labels; + trafficChart.data.datasets[0].data = data.download; + trafficChart.data.datasets[1].data = data.upload; + trafficChart.update('active'); +} + +// ── Letzte Ausfälle (Dashboard) ─────────────────────────────────────────── +async function loadRecentOutages() { + const data = await apiFetch('/api/outages?limit=10'); + const tbody = document.getElementById('recent-outages-body'); + if (!data || data.length === 0) { + tbody.innerHTML = 'Keine Ausfälle – alles grün!'; + return; + } + tbody.innerHTML = data.map(o => ` + + ${o.start_human} + ${o.end_human} + ${o.duration_human} + + `).join(''); +} + +// ── Geräte ──────────────────────────────────────────────────────────────── +async function loadDevices() { + const data = await apiFetch('/api/devices'); + if (!data) return; + allDevices = data; + renderDevices(); +} + +function renderDevices() { + const showInactive = document.getElementById('show-inactive').checked; + const search = document.getElementById('device-search').value.toLowerCase(); + + let devices = allDevices.filter(d => { + if (!showInactive && !d.is_active) return false; + if (search) { + const hay = [d.name, d.ip, d.mac, d.vendor, d.hostname].join(' ').toLowerCase(); + if (!hay.includes(search)) return false; + } + return true; + }); + + const active = devices.filter(d => d.is_active).length; + document.getElementById('devices-count').textContent = + `${active} aktiv / ${allDevices.length} gesamt`; + + const tbody = document.getElementById('devices-body'); + if (devices.length === 0) { + tbody.innerHTML = 'Keine Geräte gefunden'; + return; + } + + tbody.innerHTML = devices.map(d => { + const dotClass = d.is_active ? 'device-active' : 'device-inactive'; + const name = esc(d.fritz_name || d.hostname || d.ip || d.mac); + const hostname = d.fritz_name && d.hostname && d.fritz_name !== d.hostname + ? `${esc(d.hostname)}` : ''; + + const ifaceBadge = d.iface_type + ? `` + + esc(d.iface_type) + '' + : '–'; + + const portBadge = d.port_count > 0 + ? `` + : ''; + + return ` + + ${name}${hostname} + ${esc(d.ip)} + ${esc(d.mac)} + ${esc(d.vendor) || ''} + ${ifaceBadge} + ${portBadge} + ${esc(d.last_seen_human)} + `; + }).join(''); +} + +async function showPorts(deviceId, deviceName) { + document.getElementById('portModalLabel').textContent = `Ports: ${deviceName}`; + document.getElementById('portModalBody').innerHTML = '
Lade...
'; + portModal.show(); + + const ports = await apiFetch(`/api/devices/${deviceId}/ports`); + if (!ports || ports.length === 0) { + document.getElementById('portModalBody').innerHTML = + '
Keine offenen Ports gefunden
'; + return; + } + + const rows = ports.map(p => ` + + ${p.port} + ${p.protocol.toUpperCase()} + ${esc(p.service) || '–'} + ${p.state} + ${p.ts ? new Date(p.ts * 1000).toLocaleString('de-DE') : '–'} + + `).join(''); + + document.getElementById('portModalBody').innerHTML = ` + + + ${rows} +
PortProtokollServiceStatusGescannt
`; +} + +// ── Ausfälle ────────────────────────────────────────────────────────────── +async function loadOutageSummary() { + const data = await apiFetch('/api/outages?limit=500'); + if (!data) return; + + const total = data.length; + const totalSec = data.reduce((s, o) => s + (o.duration_s || 0), 0); + const maxSec = data.reduce((m, o) => Math.max(m, o.duration_s || 0), 0); + + // Heute + const todayStart = new Date(); todayStart.setHours(0,0,0,0); + const todayTs = todayStart.getTime() / 1000; + const today = data.filter(o => o.start_ts >= todayTs); + + document.getElementById('outage-summary').innerHTML = ` +
+
+
Ausfälle gesamt
+
${total}
+
+
+
+
+
Ausfälle heute
+
${today.length}
+
+
+
+
+
Gesamtausfallzeit
+
${fmtDuration(totalSec)}
+
+
+
+
+
Längster Ausfall
+
${fmtDuration(maxSec)}
+
+
`; +} + +async function loadOutages() { + const limit = document.getElementById('outage-filter')?.value || '100'; + const url = limit === 'all' ? '/api/outages?limit=9999' : `/api/outages?limit=${limit}`; + const data = await apiFetch(url); + const tbody = document.getElementById('outages-body'); + + if (!data || data.length === 0) { + tbody.innerHTML = 'Keine Ausfälle aufgezeichnet'; + return; + } + + tbody.innerHTML = data.map((o, i) => ` + + ${o.id} + ${o.start_human} + ${o.end_ts ? o.end_human : 'Läuft noch!'} + ${o.duration_human} + ${o.end_ts + ? 'Behoben' + : 'Aktiv'} + + `).join(''); +} + +// ── Mitschnitt / Packet Capture ─────────────────────────────────────────── +let captureTimer = null; +let captureLastTs = 0; +let capturePaused = false; +let captureRows = 0; +let captureFilter = ''; + +const PROTO_CLASS = { + 'TCP':'proto-tcp','UDP':'proto-udp','HTTP':'proto-http','HTTPS':'proto-https', + 'SSH':'proto-ssh','DNS':'proto-dns','ARP':'proto-arp','DHCP':'proto-dhcp', + 'mDNS':'proto-mdns','SSDP':'proto-ssdp','SMB':'proto-smb','RDP':'proto-rdp', + 'NTP':'proto-ntp','MQTT':'proto-mqtt','FTP':'proto-tcp','MySQL':'proto-tcp', + 'PostgreSQL':'proto-tcp','Redis':'proto-tcp','MongoDB':'proto-tcp', +}; + +function startCapture() { + captureLastTs = Date.now() / 1000 - 5; // letzte 5s beim Start + captureRows = 0; + if (captureTimer) clearInterval(captureTimer); + captureTimer = setInterval(fetchCapture, 2000); + fetchCapture(); +} + +function stopCapture() { + if (captureTimer) { clearInterval(captureTimer); captureTimer = null; } +} + +function toggleCaptureLive() { + capturePaused = !document.getElementById('capture-live').checked; + if (!capturePaused) { + captureLastTs = Date.now() / 1000 - 2; + startCapture(); + } else { + stopCapture(); + document.getElementById('badge-capture').textContent = 'PAUSE'; + document.getElementById('badge-capture').className = 'badge bg-warning bg-opacity-75 ms-1'; + } +} + +function onCaptureFilter() { + captureFilter = document.getElementById('capture-filter').value.trim(); +} + +async function fetchCapture() { + if (capturePaused) return; + const limit = document.getElementById('capture-limit')?.value || 200; + const fParam = captureFilter ? `&filter=${encodeURIComponent(captureFilter)}` : ''; + const data = await apiFetch(`/api/packets?since=${captureLastTs.toFixed(3)}&limit=${limit}${fParam}`); + if (!data || data.length === 0) return; + + // Timestamp für nächsten Request + captureLastTs = data[data.length - 1].ts + 0.001; + + const tbody = document.getElementById('capture-body'); + + // Placeholder entfernen + const placeholder = tbody.querySelector('td[colspan]'); + if (placeholder) tbody.innerHTML = ''; + + const fragment = document.createDocumentFragment(); + for (const pkt of data) { + const tr = buildPacketRow(pkt); + fragment.appendChild(tr); + captureRows++; + } + tbody.appendChild(fragment); + + // Limit einhalten + const maxRows = parseInt(limit) * 3; + while (tbody.children.length > maxRows) { + tbody.removeChild(tbody.firstChild); + } + + // Auto-scroll + if (document.getElementById('capture-scroll').checked) { + const area = document.getElementById('capture-scroll-area'); + area.scrollTop = area.scrollHeight; + } + + // Stats aktualisieren + document.getElementById('capture-stats').textContent = `${tbody.children.length} Zeilen`; + document.getElementById('badge-capture').textContent = 'LIVE'; + document.getElementById('badge-capture').className = 'badge bg-success bg-opacity-50 ms-1'; +} + +function buildPacketRow(pkt) { + const tr = document.createElement('tr'); + const proto = (pkt.protocol || 'OTHER').toUpperCase(); + const cls = PROTO_CLASS[proto] || 'proto-other'; + + const t = new Date(pkt.ts * 1000); + const timeStr = t.toLocaleTimeString('de-DE', {hour12:false}) + + '.' + String(t.getMilliseconds()).padStart(3,'0'); + + const srcStr = pkt.src_ip ? (pkt.src_port ? `${pkt.src_ip}:${pkt.src_port}` : pkt.src_ip) : '–'; + const dstStr = pkt.dst_ip ? (pkt.dst_port ? `${pkt.dst_ip}:${pkt.dst_port}` : pkt.dst_ip) : '–'; + + tr.innerHTML = ` + ${timeStr} + ${esc(srcStr)} + ${esc(dstStr)} + ${esc(proto)} + ${pkt.length} + ${esc(pkt.info || '')}`; + return tr; +} + +// ── Ping Statistik ─────────────────────────────────────────────────────── +async function loadPingStats() { + const minutes = document.getElementById('ping-stat-filter')?.value || 1440; + const data = await apiFetch(`/api/ping_log?minutes=${minutes}`); + const tbody = document.getElementById('ping-stats-body'); + if (!data || !data.stats || data.stats.length === 0) { + tbody.innerHTML = 'Noch keine Ping-Daten – läuft nach ~10s an'; + return; + } + + tbody.innerHTML = data.stats.map(s => { + const failRate = s.total > 0 ? ((s.total - s.ok) / s.total * 100) : 0; + const failClass = failRate > 5 ? 'text-danger' : failRate > 1 ? 'text-warning' : 'text-success'; + const successPct = s.total > 0 ? (s.ok / s.total * 100).toFixed(1) : '–'; + + // Farbbalken für Erfolgsrate + const barColor = failRate > 5 ? '#f85149' : failRate > 1 ? '#d29922' : '#3fb950'; + const bar = `
+
`; + + return ` + ${esc(s.target)} + ${s.total} + ${s.ok} (${successPct}%)${bar} + ${failRate.toFixed(2)}% + ${s.avg_ms ? s.avg_ms.toFixed(1) + ' ms' : '–'} + ${s.min_ms ? s.min_ms.toFixed(1) : '–'} / ${s.max_ms ? s.max_ms.toFixed(1) : '–'} ms + `; + }).join(''); +} + +// ── FritzBox ────────────────────────────────────────────────────────────── +async function loadFritz() { + const data = await apiFetch('/api/fritz'); + if (!data) return; + + const f = data.latest; + const info = data.info; + const statsEl = document.getElementById('fritz-stats'); + + if (!f && !info) { + statsEl.innerHTML = `
+
+ + FritzBox nicht verbunden. FRITZ_PASSWORD in .env prüfen. +
`; + return; + } + + // ── Geräteinformationen ─────────────────────────────────────────────── + const dev = info?.device || {}; + const con = info?.connection || {}; + document.getElementById('fritz-device-info').innerHTML = ` + + Modell + ${esc(dev.model || dev.description || '–')} + Firmware + ${esc(dev.firmware || '–')} + + + Hardware + ${esc(dev.hardware || '–')} + Seriennummer + ${esc(dev.serial || '–')} + + + System-Uptime + ${dev.uptime_sys ? fmtDuration(dev.uptime_sys) : '–'} + Host + ${esc(data.host || '')} + `; + + // ── Verbindungs-Kacheln ─────────────────────────────────────────────── + const isConn = con.is_connected ?? f?.is_connected; + const extIp = con.external_ip || f?.external_ip || '–'; + const extIp6 = con.external_ipv6 || '–'; + const uptimeC = con.uptime_s ?? f?.uptime_s ?? 0; + const maxDown = con.max_down_bps ?? f?.max_down_bps ?? 0; + const maxUp = con.max_up_bps ?? f?.max_up_bps ?? 0; + const curDown = con.cur_down_bps ?? 0; + const curUp = con.cur_up_bps ?? 0; + const totSent = con.total_sent ?? f?.total_bytes_sent ?? 0; + const totRecv = con.total_recv ?? f?.total_bytes_recv ?? 0; + + statsEl.innerHTML = ` +
+
+
Verbindung
+
${isConn ? 'Online' : 'Offline'}
+
+
+
+
+
Online seit
+
${uptimeC ? fmtDuration(uptimeC) : '–'}
+
+
+
+
+
Externe IP
+
${esc(extIp)}
+
+
+
+
+
IPv6
+
${esc(extIp6 !== '–' ? extIp6.split('/')[0] : '–')}
+
+
+
+
+
Max. ↓ / ↑
+
${fmtSpeed(maxDown)} / ${fmtSpeed(maxUp)}
+
+
+
+
+
Aktuell ↓ / ↑
+
${fmtSpeed(curDown*8)} / ${fmtSpeed(curUp*8)}
+
+
+
+
+
Gesamt empfangen
+
${fmtBytes(totRecv)}
+
+
+
+
+
Gesamt gesendet
+
${fmtBytes(totSent)}
+
+
`; + + // ── Verlaufs-Chart ──────────────────────────────────────────────────── + if (data.history?.length) renderFritzChart(data.history); + + // ── DSL-Leitungsqualität ────────────────────────────────────────────── + const dsl = info?.dsl || {}; + const dslRows = [ + ['Status', dsl.status || '–'], + ['Downstream Rauschen', dsl.downstream_noise != null ? dsl.downstream_noise.toFixed(1) + ' dB' : '–'], + ['Upstream Rauschen', dsl.upstream_noise != null ? dsl.upstream_noise.toFixed(1) + ' dB' : '–'], + ['Downstream Dämpfung', dsl.downstream_atten != null ? dsl.downstream_atten.toFixed(1) + ' dB' : '–'], + ['Upstream Dämpfung', dsl.upstream_atten != null ? dsl.upstream_atten.toFixed(1) + ' dB' : '–'], + ['Downstream Power', dsl.downstream_power != null ? dsl.downstream_power.toFixed(1) + ' dBm' : '–'], + ['Upstream Power', dsl.upstream_power != null ? dsl.upstream_power.toFixed(1) + ' dBm' : '–'], + ['Zellen gesendet', dsl.cells_sent != null ? dsl.cells_sent.toLocaleString('de') : '–'], + ['Zellen empfangen', dsl.cells_received != null ? dsl.cells_received.toLocaleString('de') : '–'], + ]; + document.getElementById('fritz-dsl-info').innerHTML = + dslRows.map(([k, v]) => + `${k}${v}` + ).join(''); + + // ── DNS & LAN ───────────────────────────────────────────────────────── + const dns = info?.dns || {}; + const lanRows = [ + ['DNS Primär', dns.primary || '–'], + ['DNS Sekundär', dns.secondary || '–'], + ['Anschlusstyp', esc(con.access_type || '–')], + ['Leitungsstatus', esc(con.link_status || '–')], + ['Letzter Fehler', esc(con.last_error || '–')], + ]; + document.getElementById('fritz-lan-info').innerHTML = + lanRows.map(([k, v]) => + `${k}${esc(String(v))}` + ).join(''); +} + +function renderFritzChart(history) { + const ctx = document.getElementById('fritz-chart').getContext('2d'); + const labels = history.map(h => new Date(h.bucket * 1000).toLocaleTimeString('de-DE', {hour:'2-digit',minute:'2-digit'})); + const values = history.map(h => h.connected * 100); + + if (fritzChart) fritzChart.destroy(); + fritzChart = new Chart(ctx, { + type: 'line', + data: { + labels, + datasets: [{ + label: 'Verbindung (%)', + data: values, + borderColor: '#3fb950', + backgroundColor: 'rgba(63,185,80,0.1)', + borderWidth: 2, + fill: true, + tension: 0, + pointRadius: 0, + stepped: true, + }], + }, + options: { + responsive: true, + plugins: { + legend: { labels: { color: '#8b949e', boxWidth: 12 } }, + tooltip: { + backgroundColor: '#21262d', + borderColor: '#30363d', + borderWidth: 1, + titleColor: '#e6edf3', + bodyColor: '#8b949e', + }, + }, + scales: { + x: { grid: { color: '#21262d' }, ticks: { color: '#8b949e', maxTicksLimit: 12 } }, + y: { grid: { color: '#21262d' }, ticks: { color: '#8b949e', callback: v => v + '%' }, min: 0, max: 100 }, + }, + }, + }); +} + +// ── Einstellungen ───────────────────────────────────────────────────────── + +let _settingsOffcanvas = null; + +function openSettings() { + if (!_settingsOffcanvas) { + _settingsOffcanvas = new bootstrap.Offcanvas(document.getElementById('settingsOffcanvas')); + } + loadSettings(); + _settingsOffcanvas.show(); +} + +async function loadSettings() { + const data = await apiFetch('/api/config'); + if (!data) return; + + const form = document.getElementById('settings-form'); + for (const [key, val] of Object.entries(data)) { + const el = form.elements[key]; + if (!el) continue; + if (el.tagName === 'SELECT') { + el.value = val; + } else { + el.value = val; + } + } +} + +async function saveSettings(e) { + if (e) e.preventDefault(); + const form = document.getElementById('settings-form'); + const data = {}; + for (const el of form.elements) { + if (el.name) data[el.name] = el.value; + } + + const res = await fetch('/api/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data), + }); + const json = await res.json(); + showSettingsAlert(res.ok + ? `Gespeichert: ${json.saved?.join(', ')}. Neustart erforderlich damit Änderungen aktiv werden.` + : `Fehler: ${json.error}`, + res.ok ? 'success' : 'danger' + ); +} + +async function saveAndRestart() { + // Erst speichern + const form = document.getElementById('settings-form'); + const data = {}; + for (const el of form.elements) { + if (el.name) data[el.name] = el.value; + } + const saveRes = await fetch('/api/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(data), + }); + if (!saveRes.ok) { + const j = await saveRes.json(); + showSettingsAlert('Fehler beim Speichern: ' + j.error, 'danger'); + return; + } + + showSettingsAlert('Neustart wird eingeleitet…', 'warning'); + + await fetch('/api/restart', { method: 'POST' }); + + // Warte und lade Seite neu + setTimeout(() => { + const alert = document.getElementById('settings-alert'); + let sec = 8; + const tick = setInterval(() => { + sec--; + if (alert) alert.innerHTML = `Neustart… Seite lädt in ${sec}s neu`; + if (sec <= 0) { + clearInterval(tick); + window.location.reload(); + } + }, 1000); + }, 500); +} + +async function testNotify() { + const el = document.getElementById('notify-test-result'); + el.textContent = 'Sende…'; + el.className = 'small ms-2 text-muted'; + const res = await fetch('/api/notify_test', { method: 'POST' }); + const json = await res.json(); + const results = Object.entries(json).map(([k, v]) => `${k}: ${v}`).join(' · '); + el.textContent = results; + el.className = `small ms-2 ${res.ok ? 'text-success' : 'text-danger'}`; +} + +function showSettingsAlert(msg, type) { + const el = document.getElementById('settings-alert'); + el.className = `alert alert-${type}`; + el.innerHTML = msg; + el.classList.remove('d-none'); + if (type === 'success') { + setTimeout(() => el.classList.add('d-none'), 5000); + } +} + +// ── Ping Monitor ───────────────────────────────────────────────────────── + +function startPingMonitor() { + stopPingMonitor(); + pingLogLastTs = Math.floor(Date.now() / 1000) - 30; + // Sofort alles laden + fetchPingLive(); + loadDevicePings(); + loadOutages(); + loadOutageSummary(); + loadPingStats(); + // Live-Poll alle 5s + pingLiveTimer = setInterval(fetchPingLive, 5000); + // Statische Teile alle 30s + if (!window._devicePingTimer) { + window._devicePingTimer = setInterval(() => { + loadDevicePings(); + loadOutages(); + loadOutageSummary(); + loadPingStats(); + }, 30000); + } +} + +function stopPingMonitor() { + if (pingLiveTimer) { clearInterval(pingLiveTimer); pingLiveTimer = null; } + if (window._devicePingTimer) { clearInterval(window._devicePingTimer); window._devicePingTimer = null; } +} + +function clearPingLog() { + document.getElementById('ping-log-body').innerHTML = + 'Log geleert – warte auf neue Daten…'; + pingLogLastTs = Math.floor(Date.now() / 1000); + pingLogRows = 0; +} + +async function fetchPingLive() { + const data = await apiFetch('/api/ping_targets_live?seconds=120'); + if (!data) return; + + // ── Status-Tabelle ── + const tbody = document.getElementById('ping-targets-body'); + document.getElementById('ping-targets-badge').textContent = data.targets.length; + if (data.targets.length === 0) { + tbody.innerHTML = 'Keine Ziele in PING_TARGETS'; + } else { + tbody.innerHTML = data.targets.map(target => { + // Letzten Eintrag für dieses Ziel aus dem Log finden + const last = [...data.log].reverse().find(l => l.target === target); + const s = data.stats[target]; + const ok = last && last.success; + const statusBadge = ok + ? 'UP' + : (last ? 'DOWN' : ''); + const latency = last && last.latency_ms ? last.latency_ms.toFixed(1) + ' ms' : '–'; + const total = s ? s.total : 0; + const okCnt = s ? s.ok : 0; + const pct = total > 0 ? (okCnt / total * 100) : 100; + const barColor = pct < 95 ? '#f85149' : pct < 99 ? '#d29922' : '#3fb950'; + const bar = `
+
`; + const avgMs = s && s.avg_ms ? s.avg_ms.toFixed(1) : '–'; + const minMax = s ? `${s.min_ms ? s.min_ms.toFixed(1) : '–'} / ${s.max_ms ? s.max_ms.toFixed(1) : '–'} ms` : '–'; + return ` + ${esc(target)} + ${statusBadge} + ${latency} + ${pct.toFixed(1)}% (${okCnt}/${total})${bar} + ${avgMs} / ${minMax} + `; + }).join(''); + } + + // ── Live-Log: nur neue Einträge seit letztem Poll ── + const newEntries = data.log.filter(e => e.ts > pingLogLastTs); + if (newEntries.length === 0) return; + pingLogLastTs = newEntries[newEntries.length - 1].ts; + + const logBody = document.getElementById('ping-log-body'); + const placeholder = logBody.querySelector('td[colspan]'); + if (placeholder) logBody.innerHTML = ''; + + const frag = document.createDocumentFragment(); + for (const entry of newEntries) { + const tr = document.createElement('tr'); + const t = new Date(entry.ts * 1000); + const timeStr = t.toLocaleTimeString('de-DE', {hour12: false}); + const ok = entry.success; + tr.className = ok ? '' : 'table-danger'; + tr.innerHTML = ` + ${timeStr} + ${esc(entry.target)} + ${ok ? 'OK' : 'FAIL'} + ${entry.latency_ms ? entry.latency_ms.toFixed(1) + ' ms' : '–'}`; + frag.appendChild(tr); + pingLogRows++; + } + logBody.appendChild(frag); + + // Max-Zeilen einhalten + while (logBody.children.length > PING_LOG_MAX) { + logBody.removeChild(logBody.firstChild); + } + + // Auto-Scroll + if (document.getElementById('ping-log-scroll')?.checked) { + const area = document.getElementById('ping-log-scroll-area'); + area.scrollTop = area.scrollHeight; + } +} + +async function loadDevicePings() { + const data = await apiFetch('/api/device_pings?minutes=30'); + const tbody = document.getElementById('device-ping-body'); + document.getElementById('device-ping-badge').textContent = data ? data.length : '–'; + + if (!data || data.length === 0) { + tbody.innerHTML = 'Noch keine Gerät-Pings – startet nach erstem Device-Scan (ca. 20s)'; + return; + } + + tbody.innerHTML = data.map(d => { + const pct = d.total > 0 ? (d.ok / d.total * 100) : 100; + const barColor = pct < 80 ? '#f85149' : pct < 95 ? '#d29922' : '#3fb950'; + const bar = `
+
`; + const statusBadge = d.success + ? 'UP' + : 'DOWN'; + return ` + ${esc(d.ip)} + ${esc(d.mac || '–')} + ${statusBadge} + ${d.latency_ms ? d.latency_ms.toFixed(1) + ' ms' : '–'} + ${pct.toFixed(1)}% (${d.ok}/${d.total})${bar} + ${d.avg_ms ? d.avg_ms.toFixed(1) : '–'} + ${esc(d.ts_human)} + `; + }).join(''); +} + +// ── Switch ──────────────────────────────────────────────────────────────── +async function loadSwitch() { + const data = await apiFetch('/api/switch'); + if (!data) return; + + if (!data.enabled) { + document.getElementById('switch-disabled-msg').style.display = ''; + document.getElementById('switch-content').style.display = 'none'; + return; + } + document.getElementById('switch-disabled-msg').style.display = 'none'; + document.getElementById('switch-content').style.display = ''; + document.getElementById('switch-host-label').textContent = `Netgear Switch (${data.host})`; + document.getElementById('switch-last-update').textContent = + data.ports.length ? `${data.ports.length} Ports` : ''; + + // Port-Grid + const grid = document.getElementById('switch-port-grid'); + grid.innerHTML = ''; + if (data.ports.length === 0) { + grid.innerHTML = 'Noch keine Port-Daten (warte auf ersten Poll…)'; + } else { + for (const p of data.ports) { + const isGw = p.port === data.gateway_port; + const cls = p.connected + ? (isGw ? 'border-warning text-warning' : 'border-success text-success') + : 'border-secondary text-muted'; + const icon = p.connected ? 'bi-ethernet' : 'bi-x-circle'; + const speed = p.connected && p.link_speed ? p.link_speed : ''; + grid.innerHTML += ` +
+ + ${p.port} + ${isGw ? '
GW' : ''} + ${speed ? `
${speed}` : ''} +
`; + } + } + + // Port-Tabelle + const tbody = document.getElementById('switch-ports-body'); + if (data.ports.length === 0) { + tbody.innerHTML = 'Warte auf Daten…'; + } else { + tbody.innerHTML = data.ports.map(p => { + const isGw = p.port === data.gateway_port; + const statusBadge = p.connected + ? `Verbunden` + : `Getrennt`; + return ` + + ${p.port} + ${isGw ? ' Gateway' : ''} + + ${statusBadge} + ${p.link_speed || '–'} + ${p.speed_config || '–'} + `; + }).join(''); + } + + // Ereignis-Log + const evbody = document.getElementById('switch-events-body'); + if (!data.events || data.events.length === 0) { + evbody.innerHTML = 'Keine Ereignisse in den letzten 24h'; + } else { + evbody.innerHTML = data.events.map(e => { + const isUp = e.event === 'up'; + const badge = isUp + ? 'UP' + : 'DOWN'; + const isGw = e.port === data.gateway_port; + return ` + ${esc(e.ts_human)} + Port ${e.port}${isGw ? ' GW' : ''} + ${badge} + ${esc(e.link_speed || '–')} + `; + }).join(''); + } +} + +// ── Formatierung ────────────────────────────────────────────────────────── +function fmtSpeed(bps) { + if (!bps && bps !== 0) return '–'; + if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' Gbps'; + if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' Mbps'; + if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' kbps'; + return bps.toFixed(0) + ' bps'; +} + +function fmtKbps(kbps) { + if (!kbps && kbps !== 0) return '–'; + if (kbps >= 1e6) return (kbps / 1e6).toFixed(1) + ' Gbps'; + if (kbps >= 1000) return (kbps / 1000).toFixed(1) + ' Mbps'; + return kbps.toFixed(0) + ' kbps'; +} + +function fmtBytes(bytes) { + if (!bytes) return '0 B'; + const u = ['B','KB','MB','GB','TB']; + let i = 0; + while (bytes >= 1024 && i < u.length - 1) { bytes /= 1024; i++; } + return bytes.toFixed(1) + ' ' + u[i]; +} + +function fmtDuration(s) { + if (!s) return '–'; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}h ${m}min`; + if (m > 0) return `${m}min ${sec}s`; + return `${sec}s`; +} + +function esc(str) { + if (!str) return ''; + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..61d3112 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,673 @@ + + + + + + NetMon – Netzwerk Monitor + + + + + + + + + + +
+ +
+ +
+ + +
+ + +
+
+
+
Internet
+
+
+
+
+
+
Aktive Geräte
+
+
+
+
+
+
Ausfälle heute
+
+
+
+
+
+
Ausfallzeit heute
+
+
+
+
+ + +
+
+ Netzwerk-Traffic +
+ + + + + +
+
+
+ +
+
+ + +
+
Letzte Ausfälle
+
+
+ + + + + + + +
StartEndeDauer
Lade...
+
+
+
+
+ + +
+
+
+ + + + + +
+ +
+
+ + + + + + + + + + + + + + +
StatusNameIPMACHerstellerVerbindungPortsZuletzt gesehen
Lade...
+
+ + + +
+ + +
+ + +
+
+
+
Geräteinformationen
+
+
+ + + + +
Lade…
+
+
+
+
+
+ + +
+ + +
+
+
+
Verbindungsverlauf (24h)
+
+ +
+
+
+
+ + +
+
+
+
Leitungsqualität (DSL)
+
+ + +
+
+
+
+
+
+
DNS & Verbindung
+
+ + +
+
+
+
+
+
+ + +
+ + +
+ + +
+
+ Ausfall-Protokoll + +
+
+
+ + + + + + + +
#StartEndeDauerStatus
Lade...
+
+
+
+ + +
+
+ Überwachte Ziele + Live · alle 5s +
+
+
+ + + + + + + +
ZielStatusLatenzErfolgsrate (5min)Ø / Min / Max
Warte auf Daten…
+
+
+
+ + +
+
+ Live-Log +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + +
ZeitZielStatusLatenz
Warte auf Daten…
+
+
+ + +
+
+ Ping-Statistik pro Ziel + +
+
+
+ + + + + + + + +
ZielChecksErfolgreichAusfallrateØ LatenzMin / Max
Lade...
+
+
+
+ + +
+
+ Netzwerk-Geräte + alle 30s +
+
+
+ + + + + + + + +
IPMACStatusLatenzErfolgsrate (30min)Ø msZuletzt
Warte auf Daten…
+
+
+
+
+ + +
+ + + + +
+ + +
+
+ + + + + +
+ + +
+ + + +
+ + +
+
+ + +
+ + Gefiltert: ICMP/Ping · Zabbix (10050/10051) · RustDesk (21115–21119) +
+ +
+
+ + + + + + + + + + + + + + +
ZeitQuelleZielProtoLängeInfo
+ Warte auf Pakete... +
+
+
+
+ +
+ + +
+ NetMon — Daten werden alle 30s aktualisiert — + +
+ + +
+
+
Einstellungen
+ +
+
+
+
+ +
+
FritzBox
+
+
+
+
+
+
+
+
+
+ +
+
Switch
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Netzwerk
+
+
+
+
+
+
+
+
+
+ +
+
Intervalle (Sekunden)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Paketmitschnitt
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
Benachrichtigungen
+
+
+
+
+
+
+
+
+ + +
+
+ Pushover + +
+
+
+
+
+
+
+
+ + +
+
+ Nextcloud Talk + +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+ +
+
System
+
+
+
+
+
+
+
+ +
+ + +
+
+ Änderungen werden nach Neustart aktiv +
+
+
+
+ + + + + + diff --git a/src/web/style.css b/src/web/style.css new file mode 100644 index 0000000..c87a656 --- /dev/null +++ b/src/web/style.css @@ -0,0 +1,292 @@ +/* ── Basis ── */ +:root { + --bg: #0d1117; + --bg2: #161b22; + --bg3: #21262d; + --border: #30363d; + --text: #e6edf3; + --text-muted: #8b949e; + --primary: #58a6ff; + --success: #3fb950; + --danger: #f85149; + --warning: #d29922; + --info: #79c0ff; + + /* Bootstrap 5.3 dark-theme Feintuning */ + --bs-body-bg: var(--bg); + --bs-body-color: var(--text); + --bs-border-color: var(--border); + --bs-card-bg: var(--bg2); + --bs-card-border-color: var(--border); + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: var(--border); + --bs-table-hover-bg: var(--bg3); + --bs-table-striped-bg: #1a1f27; + --bs-secondary-bg: var(--bg2); + --bs-tertiary-bg: var(--bg3); + --bs-link-color: var(--primary); + --bs-link-hover-color: var(--info); +} + +* { box-sizing: border-box; } + +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Navbar ── */ +.navbar { + background: var(--bg2) !important; + border-bottom: 1px solid var(--border); +} + +/* ── Status Badge ── */ +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + font-size: 13px; + font-weight: 500; + border: 1px solid transparent; +} +.status-dot { + width: 8px; height: 8px; + border-radius: 50%; + display: inline-block; +} +.status-up { color: var(--success); border-color: var(--success); background: #3fb95015; } +.status-up .status-dot { background: var(--success); box-shadow: 0 0 6px var(--success); animation: pulse 2s infinite; } +.status-down { color: var(--danger); border-color: var(--danger); background: #f8514915; } +.status-down .status-dot { background: var(--danger); } +.status-unknown { color: var(--text-muted); border-color: var(--border); } +.status-unknown .status-dot { background: var(--text-muted); } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Tab Bar ── */ +.tab-bar { + background: var(--bg2); + border-bottom: 1px solid var(--border); +} +.nav-tabs .nav-link { + color: var(--text-muted); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + padding: 10px 16px; + font-size: 13px; + transition: color .2s; +} +.nav-tabs .nav-link:hover { color: var(--text); border-bottom-color: var(--border); } +.nav-tabs .nav-link.active { color: var(--primary); border-bottom-color: var(--primary); background: none; } + +/* ── Cards ── */ +.card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); +} +.card-header { + background: var(--bg3); + border-bottom: 1px solid var(--border); + color: var(--text); + padding: 10px 16px; + font-size: 13px; + font-weight: 600; +} +.card-body { + padding: 16px; + color: var(--text); +} + +/* ── Stat Cards ── */ +.stat-card { + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + text-align: center; + transition: border-color .2s; +} +.stat-card:hover { border-color: var(--primary); } +.stat-label { color: var(--text-muted); font-size: 12px; margin-bottom: 8px; } +.stat-value { font-size: 24px; font-weight: 700; line-height: 1; color: var(--text); } + +/* ── Tables ── */ +.table { + color: var(--text); + --bs-table-color: var(--text); + --bs-table-bg: transparent; + --bs-table-border-color: var(--border); + --bs-table-hover-bg: var(--bg3); + --bs-table-hover-color: var(--text); + margin: 0; +} +.table thead tr { background: var(--bg3); } +.table thead th { + background: var(--bg3); + border-color: var(--border); + color: var(--text-muted); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 8px 12px; + white-space: nowrap; +} +.table td { + border-color: var(--border); + color: var(--text); + padding: 8px 12px; + vertical-align: middle; +} +.table tbody tr { background: transparent; } +.table-sm td, .table-sm th { padding: 5px 12px; } + +/* ── Badges ── */ +.badge { font-size: 11px; font-weight: 500; } + +/* ── Device Status ── */ +.device-dot { + width: 10px; height: 10px; + border-radius: 50%; + display: inline-block; +} +.device-active { background: var(--success); box-shadow: 0 0 5px var(--success); } +.device-inactive { background: var(--text-muted); } + +/* ── Port Tags ── */ +.port-tag { + display: inline-block; + background: var(--bg3); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 1px 6px; + font-size: 11px; + font-family: monospace; + cursor: pointer; + transition: border-color .15s, color .15s; +} +.port-tag:hover { border-color: var(--primary); color: var(--primary); } + +/* ── Outage Status ── */ +.outage-active { color: var(--danger); font-weight: 600; } +.outage-resolved { color: var(--success); } + +/* ── Buttons & Form ── */ +.btn-outline-secondary { + color: var(--text-muted); + border-color: var(--border); + background: none; +} +.btn-outline-secondary:hover, +.btn-outline-secondary.active { + background: var(--bg3); + border-color: var(--primary); + color: var(--primary); +} +.form-control, .form-select { + background: var(--bg3); + border-color: var(--border); + color: var(--text); + font-size: 13px; +} +.form-control:focus, .form-select:focus { + background: var(--bg3); + border-color: var(--primary); + color: var(--text); + box-shadow: 0 0 0 0.2rem rgba(88, 166, 255, 0.15); +} +.form-check-input { background-color: var(--bg3); border-color: var(--border); } +.form-check-input:checked { background-color: var(--primary); border-color: var(--primary); } + +/* ── Modal ── */ +.modal-content { + background: var(--bg2); + border: 1px solid var(--border); + color: var(--text); +} +.modal-header { border-bottom-color: var(--border); } + +/* ── Chart ── */ +canvas { max-width: 100%; } + +/* ── Footer ── */ +footer { + background: var(--bg2); + border-top: 1px solid var(--border); + color: var(--text-muted); +} + +/* ── Scrollbar ── */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg); } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ── Responsive ── */ +@media (max-width: 576px) { + .stat-value { font-size: 18px; } + .table thead th { font-size: 11px; padding: 6px 8px; } + .table td { padding: 6px 8px; } +} + +/* ── Utilities ── */ +.text-primary { color: var(--primary) !important; } +.text-success { color: var(--success) !important; } +.text-danger { color: var(--danger) !important; } +.text-warning { color: var(--warning) !important; } +.text-info { color: var(--info) !important; } +.text-muted { color: var(--text-muted) !important; } +.border-top { border-top: 1px solid var(--border) !important; } + +/* code-Elemente in Tabellen lesbar halten */ +code { color: var(--info); background: transparent; } + +/* ── Mitschnitt / Capture ── */ +.font-mono td, .font-mono th { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-size: 12px; } + +/* Protokoll-Farben (wie Wireshark) */ +.proto-tcp { color: #79c0ff; } +.proto-http { color: #56d364; } +.proto-https { color: #3fb950; } +.proto-udp { color: #d2a8ff; } +.proto-dns { color: #ffa657; } +.proto-arp { color: #f0883e; } +.proto-ssh { color: #56d364; } +.proto-dhcp { color: #ffa657; } +.proto-mdns { color: #ffa657; } +.proto-ssdp { color: #e3b341; } +.proto-smb { color: #f85149; } +.proto-rdp { color: #f85149; } +.proto-ntp { color: #8b949e; } +.proto-mqtt { color: #a5d6ff; } +.proto-other { color: #8b949e; } + +.proto-badge { + display: inline-block; + padding: 1px 5px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + font-family: monospace; + background: #21262d; + border: 1px solid #30363d; +} + +/* Zeilen-Highlight bei Hover */ +#capture-table tbody tr:hover td { background: rgba(88,166,255,0.06); } diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..3bc9013 --- /dev/null +++ b/start.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# NetMon starten (ohne Docker, direkt auf dem System) +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# .env prüfen +if [ ! -f .env ]; then + echo "[!] .env nicht gefunden – kopiere .env.example nach .env" + cp .env.example .env + echo "[!] Bitte .env anpassen und danach erneut starten:" + echo " nano .env" + exit 1 +fi + +# venv prüfen +if [ ! -d .venv ]; then + echo "[*] Erstelle virtualenv..." + python3 -m venv .venv + echo "[*] Installiere Abhängigkeiten..." + .venv/bin/pip install -q -r requirements.txt +fi + +# data/ Verzeichnis +mkdir -p data + +# Root-Prüfung (nmap/scapy braucht root für raw sockets) +if [ "$EUID" -ne 0 ]; then + echo "[!] WARNUNG: Kein root – ARP-Scan und Port-Scan könnten fehlschlagen." + echo " Empfohlen: sudo $0" +fi + +echo "[*] NetMon startet..." +exec .venv/bin/python3 src/main.py