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:
@@ -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
@@ -0,0 +1,20 @@
|
||||
# Secrets / Zugangsdaten
|
||||
.env
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# Daten & Logs
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Screenshots
|
||||
*.png
|
||||
*.jpg
|
||||
@@ -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_id→devices, port, protocol, service, state, ts)
|
||||
|
||||
-- FritzBox-Verbindungsdaten (alle 60s)
|
||||
fritz_stats (id, ts, is_connected, uptime_s, max_up_bps, max_down_bps, total_bytes_sent, total_bytes_recv, external_ip)
|
||||
```
|
||||
|
||||
Alle Zeitstempel sind UNIX-Timestamps (Integer). Die `packets`-Tabelle verwendet `REAL` für Millisekunden-Präzision.
|
||||
|
||||
**Datenhaltung:**
|
||||
- Traffic, Ping-Log, FritzBox-Stats: automatisch nach **35 Tagen** gelöscht
|
||||
- Pakete: Rolling Window, maximal `CAPTURE_MAX_ROWS` Einträge
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- Docker + Docker Compose
|
||||
- Linux-Host (für raw sockets / packet capture)
|
||||
- Root-Rechte (für ARP-Scan, nmap, scapy)
|
||||
|
||||
### Docker Compose (empfohlen)
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
netmon:
|
||||
build: .
|
||||
container_name: netmon
|
||||
restart: unless-stopped
|
||||
network_mode: host # Pflicht für echte Interface-Daten
|
||||
privileged: true # Pflicht für scapy/nmap raw sockets
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data # DB persistent auf dem Host
|
||||
```
|
||||
|
||||
```bash
|
||||
# Starten
|
||||
docker compose up -d --build
|
||||
|
||||
# Logs anschauen
|
||||
docker logs netmon -f
|
||||
|
||||
# Neu deployen nach .env-Änderung
|
||||
docker compose restart
|
||||
|
||||
# Neu bauen nach Code-Änderung
|
||||
docker compose up -d --build
|
||||
|
||||
# Stoppen
|
||||
docker compose down
|
||||
```
|
||||
|
||||
### Ohne Docker (direkt auf dem Host)
|
||||
|
||||
```bash
|
||||
cd /root/netmon
|
||||
cp .env.example .env && nano .env
|
||||
sudo ./start.sh # sudo wegen raw sockets
|
||||
```
|
||||
|
||||
### Daten sichern
|
||||
|
||||
```bash
|
||||
# Datenbank sichern
|
||||
cp ./data/netmon.db ./data/netmon_backup_$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
### Auf anderem System deployen
|
||||
|
||||
```bash
|
||||
# Projekt kopieren
|
||||
scp -r /root/netmon user@neuer-server:/opt/netmon
|
||||
|
||||
# Auf neuem Server
|
||||
cd /opt/netmon
|
||||
cp .env.example .env
|
||||
nano .env # IP/Passwort anpassen
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
### Dashboard zeigt keine Daten
|
||||
|
||||
```bash
|
||||
docker logs netmon --tail=50
|
||||
```
|
||||
|
||||
Häufige Ursachen:
|
||||
- **FritzBox nicht erreichbar**: `FRITZ_HOST` prüfen, Passwort prüfen
|
||||
- **Falsches Interface**: `MONITOR_INTERFACE=auto` oder manuell setzen (z.B. `eth0`)
|
||||
|
||||
### Paketmitschnitt leer
|
||||
|
||||
```bash
|
||||
# Interface prüfen
|
||||
docker exec netmon python3 -c "import scapy.all as s; print(s.get_if_list())"
|
||||
|
||||
# Privileged-Mode aktiv?
|
||||
docker inspect netmon | grep Privileged
|
||||
```
|
||||
|
||||
Muss `true` sein. Falls nicht: `docker compose down && docker compose up -d`.
|
||||
|
||||
### Geräte werden nicht gefunden
|
||||
|
||||
- `NETWORK_RANGE` manuell auf das lokale Subnetz setzen: z.B. `192.168.1.0/24`
|
||||
- Container muss mit `network_mode: host` laufen
|
||||
|
||||
### Kurze Ausfälle werden nicht erkannt
|
||||
|
||||
- `OUTAGE_CHECK_INTERVAL` verringern (z.B. auf `5`)
|
||||
- `OUTAGE_THRESHOLD=1` setzen (sofort beim ersten Fehlschlag)
|
||||
- Ping-Log unter **Ausfälle → Ping-Statistik** zeigt jeden einzelnen Check
|
||||
|
||||
### DB wächst zu schnell
|
||||
|
||||
```bash
|
||||
# Aktuelle Größe
|
||||
du -sh ./data/netmon.db
|
||||
|
||||
# Weniger Pakete cachen
|
||||
# In .env: CAPTURE_MAX_ROWS=10000
|
||||
|
||||
# Daten-Retention verkürzen (in database.py cleanup_old_data aufrufen mit weniger Tagen)
|
||||
```
|
||||
|
||||
### Port 5000 bereits belegt
|
||||
|
||||
```env
|
||||
# In .env
|
||||
WEB_PORT=8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*NetMon – gebaut für 24/7-Betrieb auf Linux mit Docker.*
|
||||
+26
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 & 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 & 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 (21115–21119)</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 — Daten werden alle 30s aktualisiert —
|
||||
<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 & 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>
|
||||
@@ -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); }
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user