Add project files: source code, Docker setup, docs and config templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-28 11:21:39 +02:00
parent 32012cd670
commit 69f2ee866a
18 changed files with 5216 additions and 0 deletions
+53
View File
@@ -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=
+20
View File
@@ -0,0 +1,20 @@
# Secrets / Zugangsdaten
.env
# Python
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
# Daten & Logs
data/
# IDE
.idea/
.vscode/
# Screenshots
*.png
*.jpg
+620
View File
@@ -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://<server-ip>: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://<server-ip>: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://<server-ip>: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/\<id\>/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=\<unix_ts\>&limit=200&filter=\<text\>
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_iddevices, 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.*
+26
View File
@@ -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"]
+23
View File
@@ -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"
+7
View File
@@ -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
+398
View File
@@ -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/<int:device_id>/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)
+528
View File
@@ -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]
+225
View File
@@ -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()
+67
View File
@@ -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()
+724
View File
@@ -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('<I', gw_int))
return gw
except Exception:
pass
return None
class NetworkMonitor:
def __init__(self, fritz_client=None, switch_client=None):
self.fritz = fritz_client
self.switch = switch_client
self._stop = threading.Event()
# Offenen Ausfall aus DB wiederherstellen (z.B. nach Crash/Neustart)
self._outage_id = db.get_open_outage_id()
self._internet_up = self._outage_id is None
if self._outage_id:
logger.warning(f"Offener Ausfall aus DB wiederhergestellt (ID: {self._outage_id})")
self._iface = None
self._network = None
self._threads = []
# Shared state (thread-safe reads reichen hier)
self.current_bps_up = 0.0
self.current_bps_down = 0.0
# Switch: letzter bekannter Port-Status {port_num: connected}
self._switch_port_states: dict[int, bool] = {}
# ── Öffentliche Methoden ─────────────────────────────────────────────────
def start(self):
self._iface, self._network = self._detect_network()
logger.info(f"Monitor Interface: {self._iface}, Netzwerk: {self._network}")
# Gateway automatisch zu Ping-Zielen hinzufügen
self._ping_targets = self._build_ping_targets()
logger.info(f"Ping-Ziele: {self._ping_targets}")
targets = [
(self._traffic_loop, "traffic"),
(self._outage_loop, "outage"),
(self._device_loop, "device"),
(self._device_ping_loop, "device-ping"),
(self._port_loop, "port"),
(self._fritz_loop, "fritz"),
(self._cleanup_loop, "cleanup"),
(self._capture_loop, "capture"),
(self._switch_loop, "switch"),
]
for fn, name in targets:
t = threading.Thread(target=fn, name=name, daemon=True)
t.start()
self._threads.append(t)
logger.info("Alle Monitor-Threads gestartet")
def _build_ping_targets(self) -> 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)
+148
View File
@@ -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
+163
View File
@@ -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
+149
View File
@@ -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)
+1065
View File
File diff suppressed because it is too large Load Diff
+673
View File
@@ -0,0 +1,673 @@
<!DOCTYPE html>
<html lang="de" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NetMon Netzwerk Monitor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="/static/style.css" rel="stylesheet">
</head>
<body>
<!-- Navbar -->
<nav class="navbar navbar-dark sticky-top px-3 py-2">
<div class="d-flex align-items-center gap-3">
<span class="navbar-brand mb-0 fw-bold fs-5">
<i class="bi bi-router-fill me-2 text-primary"></i>NetMon
</span>
<div id="inet-badge" class="status-badge status-unknown">
<span class="status-dot"></span>
<span class="status-text">Verbinde...</span>
</div>
</div>
<div class="d-flex align-items-center gap-3 text-muted small">
<span><i class="bi bi-arrow-up-circle text-success"></i> <span id="nav-up"></span></span>
<span><i class="bi bi-arrow-down-circle text-info"></i> <span id="nav-down"></span></span>
<span class="text-muted" id="last-update" title="Letztes Update"></span>
<button class="btn btn-sm btn-outline-secondary" onclick="openSettings()" title="Einstellungen">
<i class="bi bi-gear"></i>
</button>
</div>
</nav>
<!-- Tab-Navigation -->
<div class="tab-bar px-3">
<ul class="nav nav-tabs border-0" id="mainTabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-dashboard">
<i class="bi bi-speedometer2 me-1"></i>Dashboard
</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-devices" id="tab-devices-btn">
<i class="bi bi-pc-display me-1"></i>Geräte <span class="badge bg-secondary ms-1" id="badge-devices"></span>
</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-fritz" id="tab-fritz-btn">
<i class="bi bi-modem me-1"></i>FritzBox
</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-ping" id="tab-ping-btn">
<i class="bi bi-activity me-1"></i>Ping &amp; Ausfälle <span class="badge bg-danger ms-1" id="badge-outages"></span>
</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-switch" id="tab-switch-btn">
<i class="bi bi-diagram-3 me-1"></i>Switch
</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-capture" id="tab-capture-btn">
<i class="bi bi-reception-4 me-1"></i>Mitschnitt
<span class="badge bg-success bg-opacity-50 ms-1" id="badge-capture">LIVE</span>
</button></li>
</ul>
</div>
<div class="tab-content p-3 flex-grow-1">
<!-- ══════════════ DASHBOARD ══════════════ -->
<div class="tab-pane fade show active" id="tab-dashboard">
<!-- Status-Karten -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label"><i class="bi bi-globe2 me-1"></i>Internet</div>
<div class="stat-value" id="stat-inet"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label"><i class="bi bi-pc-display me-1"></i>Aktive Geräte</div>
<div class="stat-value" id="stat-devices"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label"><i class="bi bi-exclamation-triangle me-1"></i>Ausfälle heute</div>
<div class="stat-value text-warning" id="stat-outages"></div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="stat-card">
<div class="stat-label"><i class="bi bi-clock-history me-1"></i>Ausfallzeit heute</div>
<div class="stat-value text-danger" id="stat-outage-time"></div>
</div>
</div>
</div>
<!-- Traffic Chart -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-graph-up me-2 text-primary"></i>Netzwerk-Traffic</span>
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary range-btn active" data-range="1h">1h</button>
<button class="btn btn-outline-secondary range-btn" data-range="6h">6h</button>
<button class="btn btn-outline-secondary range-btn" data-range="24h">24h</button>
<button class="btn btn-outline-secondary range-btn" data-range="7d">7d</button>
<button class="btn btn-outline-secondary range-btn" data-range="30d">30d</button>
</div>
</div>
<div class="card-body">
<canvas id="traffic-chart" height="100"></canvas>
</div>
</div>
<!-- Letzte Ausfälle -->
<div class="card">
<div class="card-header"><i class="bi bi-lightning-charge me-2 text-warning"></i>Letzte Ausfälle</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0" id="recent-outages-table">
<thead><tr>
<th>Start</th><th>Ende</th><th>Dauer</th>
</tr></thead>
<tbody id="recent-outages-body">
<tr><td colspan="3" class="text-center text-muted py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ══════════════ GERÄTE ══════════════ -->
<div class="tab-pane fade" id="tab-devices">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<span class="me-2 text-muted small" id="devices-count"></span>
<span class="form-check form-switch d-inline-flex align-items-center gap-2">
<input class="form-check-input" type="checkbox" id="show-inactive" onchange="renderDevices()">
<label class="form-check-label small" for="show-inactive">Inaktive zeigen</label>
</span>
</div>
<input type="text" class="form-control form-control-sm w-auto" id="device-search"
placeholder="Suchen..." oninput="renderDevices()">
</div>
<div class="table-responsive">
<table class="table table-hover align-middle" id="devices-table">
<thead><tr>
<th>Status</th>
<th>Name</th>
<th>IP</th>
<th>MAC</th>
<th>Hersteller</th>
<th>Verbindung</th>
<th>Ports</th>
<th>Zuletzt gesehen</th>
</tr></thead>
<tbody id="devices-body">
<tr><td colspan="8" class="text-center text-muted py-3">Lade...</td></tr>
</tbody>
</table>
</div>
<!-- Port-Detail Modal -->
<div class="modal fade" id="portModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="portModalLabel">Offene Ports</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="portModalBody">Lade...</div>
</div>
</div>
</div>
</div>
<!-- ══════════════ FRITZBOX ══════════════ -->
<div class="tab-pane fade" id="tab-fritz">
<!-- Geräteinformationen -->
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-router me-2 text-warning"></i>Geräteinformationen</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<tbody id="fritz-device-info">
<tr><td colspan="4" class="text-center text-muted py-3">Lade…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Verbindungs-Kacheln -->
<div class="row g-3 mb-3" id="fritz-stats"></div>
<!-- Verbindungsverlauf -->
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-activity me-2 text-primary"></i>Verbindungsverlauf (24h)</div>
<div class="card-body">
<canvas id="fritz-chart" height="60"></canvas>
</div>
</div>
</div>
</div>
<!-- DSL + LAN nebeneinander -->
<div class="row g-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-diagram-2 me-2 text-info"></i>Leitungsqualität (DSL)</div>
<div class="card-body p-0">
<table class="table table-sm mb-0"><tbody id="fritz-dsl-info">
<tr><td colspan="2" class="text-center text-muted py-3"></td></tr>
</tbody></table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-hdd-network me-2 text-success"></i>DNS &amp; Verbindung</div>
<div class="card-body p-0">
<table class="table table-sm mb-0"><tbody id="fritz-lan-info">
<tr><td colspan="2" class="text-center text-muted py-3"></td></tr>
</tbody></table>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════ PING & AUSFÄLLE ══════════════ -->
<div class="tab-pane fade" id="tab-ping">
<!-- ── Ausfall-Zusammenfassung ── -->
<div class="row g-3 mb-3" id="outage-summary"></div>
<!-- ── Ausfall-Protokoll ── -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-list-ul me-2 text-danger"></i>Ausfall-Protokoll</span>
<select class="form-select form-select-sm w-auto" id="outage-filter" onchange="loadOutages()">
<option value="100">Letzte 100</option>
<option value="500">Letzte 500</option>
<option value="all">Alle</option>
</select>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th>#</th><th>Start</th><th>Ende</th><th>Dauer</th><th>Status</th>
</tr></thead>
<tbody id="outages-body">
<tr><td colspan="5" class="text-center text-muted py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── Sektion 1: .env PING_TARGETS (Perma-Ping, Live-Log) ── -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-bullseye me-2 text-danger"></i>Überwachte Ziele <span class="badge bg-secondary ms-1" id="ping-targets-badge"></span></span>
<span class="text-muted small"><i class="bi bi-circle-fill text-success me-1" style="font-size:8px"></i>Live · alle 5s</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th>Ziel</th><th>Status</th><th>Latenz</th><th>Erfolgsrate (5min)</th><th>Ø / Min / Max</th>
</tr></thead>
<tbody id="ping-targets-body">
<tr><td colspan="5" class="text-center text-muted py-3">Warte auf Daten…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Live-Log -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-terminal me-2 text-success"></i>Live-Log</span>
<div class="d-flex gap-2 align-items-center">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="ping-log-scroll" checked>
<label class="form-check-label small" for="ping-log-scroll">Auto-Scroll</label>
</div>
<button class="btn btn-outline-secondary btn-sm" onclick="clearPingLog()">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
<div class="card-body p-0" style="max-height:300px;overflow-y:auto" id="ping-log-scroll-area">
<table class="table table-sm mb-0 font-mono" id="ping-log-table">
<thead style="position:sticky;top:0;z-index:1">
<tr>
<th style="width:90px">Zeit</th>
<th style="width:180px">Ziel</th>
<th style="width:80px">Status</th>
<th>Latenz</th>
</tr>
</thead>
<tbody id="ping-log-body">
<tr><td colspan="4" class="text-center text-muted py-3">Warte auf Daten…</td></tr>
</tbody>
</table>
</div>
</div>
<!-- ── Ping-Statistik pro Ziel ── -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-bar-chart me-2 text-info"></i>Ping-Statistik pro Ziel</span>
<select class="form-select form-select-sm w-auto" id="ping-stat-filter" onchange="loadPingStats()">
<option value="60">Letzte 1h</option>
<option value="360">Letzte 6h</option>
<option value="1440" selected>Letzte 24h</option>
<option value="10080">Letzte 7d</option>
</select>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th>Ziel</th><th>Checks</th><th>Erfolgreich</th>
<th>Ausfallrate</th><th>Ø Latenz</th><th>Min / Max</th>
</tr></thead>
<tbody id="ping-stats-body">
<tr><td colspan="6" class="text-center text-muted py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- ── Sektion 2: Alle anderen Netzwerk-Geräte (30s) ── -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-pc-display me-2 text-info"></i>Netzwerk-Geräte <span class="badge bg-secondary ms-1" id="device-ping-badge"></span></span>
<span class="text-muted small"><i class="bi bi-arrow-clockwise me-1"></i>alle 30s</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th>IP</th><th>MAC</th><th>Status</th><th>Latenz</th>
<th>Erfolgsrate (30min)</th><th>Ø ms</th><th>Zuletzt</th>
</tr></thead>
<tbody id="device-ping-body">
<tr><td colspan="7" class="text-center text-muted py-3">Warte auf Daten…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ══════════════ SWITCH ══════════════ -->
<div class="tab-pane fade" id="tab-switch">
<div id="switch-disabled-msg" class="alert alert-secondary" style="display:none">
<i class="bi bi-info-circle me-2"></i>Switch-Monitoring ist deaktiviert.
Setze <code>SWITCH_ENABLED=true</code> in der <code>.env</code>.
</div>
<!-- Port-Übersicht -->
<div id="switch-content" style="display:none">
<div class="d-flex align-items-center gap-3 mb-3">
<h6 class="mb-0"><i class="bi bi-diagram-3 me-2 text-primary"></i>
<span id="switch-host-label"></span>
</h6>
<span class="text-muted small" id="switch-last-update"></span>
</div>
<!-- Port-Grid -->
<div class="card mb-3">
<div class="card-header"><i class="bi bi-ethernet me-2"></i>Port-Status</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2" id="switch-port-grid"></div>
</div>
</div>
<!-- Port-Tabelle -->
<div class="card mb-3">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr>
<th>Port</th><th>Status</th><th>Geschwindigkeit</th><th>Konfiguration</th>
</tr></thead>
<tbody id="switch-ports-body"></tbody>
</table>
</div>
</div>
</div>
<!-- Ereignis-Log -->
<div class="card">
<div class="card-header"><i class="bi bi-clock-history me-2 text-warning"></i>Port-Ereignisse (24h)</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead><tr><th>Zeit</th><th>Port</th><th>Ereignis</th><th>Geschwindigkeit</th></tr></thead>
<tbody id="switch-events-body">
<tr><td colspan="4" class="text-center text-muted py-3">Lade...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- ══════════════ MITSCHNITT ══════════════ -->
<div class="tab-pane fade" id="tab-capture">
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<!-- Filter -->
<input type="text" class="form-control form-control-sm" id="capture-filter"
placeholder="Filter: IP, Port, Protokoll..." style="max-width:260px"
oninput="onCaptureFilter()">
<!-- Anzeige-Limit -->
<select class="form-select form-select-sm w-auto" id="capture-limit">
<option value="100">100 Pakete</option>
<option value="200" selected>200 Pakete</option>
<option value="500">500 Pakete</option>
<option value="1000">1000 Pakete</option>
</select>
<!-- Live-Toggle -->
<div class="form-check form-switch d-flex align-items-center gap-2 mb-0">
<input class="form-check-input" type="checkbox" id="capture-live" checked onchange="toggleCaptureLive()">
<label class="form-check-label" for="capture-live">Live</label>
</div>
<!-- Stats -->
<span class="text-muted small ms-auto" id="capture-stats"></span>
<!-- Auto-Scroll -->
<div class="form-check form-switch d-flex align-items-center gap-2 mb-0">
<input class="form-check-input" type="checkbox" id="capture-scroll" checked>
<label class="form-check-label small" for="capture-scroll">Auto-Scroll</label>
</div>
</div>
<!-- Aktive Filter-Info -->
<div class="alert alert-secondary py-1 px-3 small mb-2">
<i class="bi bi-funnel me-1"></i>
Gefiltert: <code>ICMP/Ping</code> · <code>Zabbix (10050/10051)</code> · <code>RustDesk (2111521119)</code>
</div>
<div class="card">
<div class="card-body p-0" style="max-height:600px; overflow-y:auto" id="capture-scroll-area">
<table class="table table-sm mb-0 font-mono" id="capture-table">
<thead style="position:sticky;top:0;z-index:1">
<tr>
<th style="width:90px">Zeit</th>
<th style="width:180px">Quelle</th>
<th style="width:180px">Ziel</th>
<th style="width:80px">Proto</th>
<th style="width:65px">Länge</th>
<th>Info</th>
</tr>
</thead>
<tbody id="capture-body">
<tr><td colspan="6" class="text-center text-muted py-4">
<i class="bi bi-reception-0 me-2"></i>Warte auf Pakete...
</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Footer -->
<footer class="text-center text-muted py-2 small border-top">
NetMon &mdash; Daten werden alle 30s aktualisiert &mdash;
<span id="footer-ts"></span>
</footer>
<!-- ══════════════ EINSTELLUNGEN OFFCANVAS ══════════════ -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="settingsOffcanvas" style="width:520px">
<div class="offcanvas-header border-bottom">
<h5 class="offcanvas-title"><i class="bi bi-gear me-2"></i>Einstellungen</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas"></button>
</div>
<div class="offcanvas-body">
<div id="settings-alert" class="alert d-none mb-3"></div>
<form id="settings-form" onsubmit="saveSettings(event)">
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-router me-1"></i>FritzBox</div>
<div class="row g-2">
<div class="col-4"><label class="form-label small">Host</label>
<input class="form-control form-control-sm" name="FRITZ_HOST"></div>
<div class="col-4"><label class="form-label small">Benutzer</label>
<input class="form-control form-control-sm" name="FRITZ_USER"></div>
<div class="col-4"><label class="form-label small">Passwort</label>
<input class="form-control form-control-sm" type="password" name="FRITZ_PASSWORD" autocomplete="current-password"></div>
</div>
</div>
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-diagram-3 me-1"></i>Switch</div>
<div class="row g-2">
<div class="col-3"><label class="form-label small">Aktiviert</label>
<select class="form-select form-select-sm" name="SWITCH_ENABLED">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-5"><label class="form-label small">Host</label>
<input class="form-control form-control-sm" name="SWITCH_HOST"></div>
<div class="col-4"><label class="form-label small">Passwort</label>
<input class="form-control form-control-sm" type="password" name="SWITCH_PASSWORD" autocomplete="current-password"></div>
<div class="col-3"><label class="form-label small">Gateway-Port</label>
<input class="form-control form-control-sm" type="number" name="SWITCH_GATEWAY_PORT" min="1" max="52"></div>
<div class="col-3"><label class="form-label small">Intervall (s)</label>
<input class="form-control form-control-sm" type="number" name="SWITCH_INTERVAL" min="10"></div>
</div>
</div>
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-hdd-network me-1"></i>Netzwerk</div>
<div class="row g-2">
<div class="col-6"><label class="form-label small">Netzwerk-Range</label>
<input class="form-control form-control-sm" name="NETWORK_RANGE"></div>
<div class="col-6"><label class="form-label small">Interface</label>
<input class="form-control form-control-sm" name="MONITOR_INTERFACE"></div>
<div class="col-12"><label class="form-label small">Ping-Ziele <span class="text-muted">(kommasepariert)</span></label>
<input class="form-control form-control-sm font-mono" name="PING_TARGETS"></div>
</div>
</div>
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-clock me-1"></i>Intervalle (Sekunden)</div>
<div class="row g-2">
<div class="col-4"><label class="form-label small">Traffic</label>
<input class="form-control form-control-sm" type="number" name="TRAFFIC_INTERVAL" min="10"></div>
<div class="col-4"><label class="form-label small">Ping-Targets</label>
<input class="form-control form-control-sm" type="number" name="PING_TARGET_INTERVAL" min="1"></div>
<div class="col-4"><label class="form-label small">Device-Ping</label>
<input class="form-control form-control-sm" type="number" name="DEVICE_PING_INTERVAL" min="5"></div>
<div class="col-4"><label class="form-label small">Device-Scan</label>
<input class="form-control form-control-sm" type="number" name="DEVICE_SCAN_INTERVAL" min="60"></div>
<div class="col-4"><label class="form-label small">Port-Scan</label>
<input class="form-control form-control-sm" type="number" name="PORT_SCAN_INTERVAL" min="60"></div>
<div class="col-4"><label class="form-label small">Ausfall-Schwelle</label>
<input class="form-control form-control-sm" type="number" name="OUTAGE_THRESHOLD" min="1" max="10"></div>
</div>
</div>
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-reception-4 me-1"></i>Paketmitschnitt</div>
<div class="row g-2">
<div class="col-4"><label class="form-label small">Aktiviert</label>
<select class="form-select form-select-sm" name="CAPTURE_ENABLED">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">Max. Zeilen</label>
<input class="form-control form-control-sm" type="number" name="CAPTURE_MAX_ROWS" min="1000"></div>
<div class="col-4"><label class="form-label small">ICMP filtern</label>
<select class="form-select form-select-sm" name="CAPTURE_FILTER_ICMP">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">ARP filtern</label>
<select class="form-select form-select-sm" name="CAPTURE_FILTER_ARP">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">Zabbix filtern</label>
<select class="form-select form-select-sm" name="CAPTURE_FILTER_ZABBIX">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">RustDesk filtern</label>
<select class="form-select form-select-sm" name="CAPTURE_FILTER_RUSTDESK">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-12"><label class="form-label small">Extra BPF-Filter</label>
<input class="form-control form-control-sm font-mono" name="CAPTURE_FILTER_EXTRA" placeholder='z.B. not port 5353'></div>
</div>
</div>
<div class="mb-3">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-bell me-1"></i>Benachrichtigungen</div>
<div class="row g-2">
<div class="col-4"><label class="form-label small">Bei Ausfall</label>
<select class="form-select form-select-sm" name="NOTIFY_ON_DOWN">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">Bei Wiederkehr</label>
<select class="form-select form-select-sm" name="NOTIFY_ON_UP">
<option value="true">Ja</option><option value="false">Nein</option>
</select></div>
<div class="col-4"><label class="form-label small">Cooldown (s)</label>
<input class="form-control form-control-sm" type="number" name="NOTIFY_COOLDOWN" min="0"></div>
</div>
<!-- Pushover -->
<div class="mt-3 p-2 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small fw-semibold">Pushover</span>
<select class="form-select form-select-sm w-auto" name="PUSHOVER_ENABLED">
<option value="false">Deaktiviert</option><option value="true">Aktiviert</option>
</select>
</div>
<div class="row g-2">
<div class="col-6"><label class="form-label small text-muted">App Token</label>
<input class="form-control form-control-sm font-mono" name="PUSHOVER_APP_TOKEN" placeholder="azGDURgs..."></div>
<div class="col-6"><label class="form-label small text-muted">User Key</label>
<input class="form-control form-control-sm font-mono" name="PUSHOVER_USER_KEY" placeholder="uQiRzpo8..."></div>
</div>
</div>
<!-- Nextcloud Talk -->
<div class="mt-2 p-2 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="small fw-semibold">Nextcloud Talk</span>
<select class="form-select form-select-sm w-auto" name="NCTALK_ENABLED">
<option value="false">Deaktiviert</option><option value="true">Aktiviert</option>
</select>
</div>
<div class="row g-2">
<div class="col-12"><label class="form-label small text-muted">Nextcloud URL</label>
<input class="form-control form-control-sm" name="NCTALK_URL" placeholder="https://cloud.example.com"></div>
<div class="col-4"><label class="form-label small text-muted">Benutzer</label>
<input class="form-control form-control-sm" name="NCTALK_USER"></div>
<div class="col-4"><label class="form-label small text-muted">Passwort</label>
<input class="form-control form-control-sm" type="password" name="NCTALK_PASSWORD" autocomplete="current-password"></div>
<div class="col-4"><label class="form-label small text-muted">Raum-Token</label>
<input class="form-control form-control-sm font-mono" name="NCTALK_ROOM" placeholder="abc123xyz"></div>
</div>
</div>
<button type="button" class="btn btn-outline-info btn-sm mt-2" onclick="testNotify()">
<i class="bi bi-bell me-1"></i>Test-Nachricht senden
</button>
<span id="notify-test-result" class="small ms-2"></span>
</div>
<div class="mb-4">
<div class="fw-semibold small text-muted text-uppercase mb-2"><i class="bi bi-terminal me-1"></i>System</div>
<div class="row g-2">
<div class="col-6"><label class="form-label small">Log-Level</label>
<select class="form-select form-select-sm" name="LOG_LEVEL">
<option value="DEBUG">DEBUG</option>
<option value="INFO">INFO</option>
<option value="WARNING">WARNING</option>
<option value="ERROR">ERROR</option>
</select></div>
<div class="col-6"><label class="form-label small">Web-Port</label>
<input class="form-control form-control-sm" type="number" name="WEB_PORT" min="1" max="65535"></div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-floppy me-1"></i>Speichern
</button>
<button type="button" class="btn btn-warning btn-sm" onclick="saveAndRestart()">
<i class="bi bi-arrow-clockwise me-1"></i>Speichern &amp; Neustart
</button>
</div>
<div class="text-muted small mt-2">
<i class="bi bi-info-circle me-1"></i>Änderungen werden nach Neustart aktiv
</div>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.2/dist/chart.umd.min.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+292
View File
@@ -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); }
Executable
+35
View File
@@ -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