diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..666c0cd --- /dev/null +++ b/.env.dist @@ -0,0 +1,49 @@ +# FritzBox Setup +FRITZ_IP=172.178.1.1 +FRITZ_USER=SYSLOG +FRITZ_PWD=DEIN_SYSLOG_PASSWORD_SECRET + +# Syslog Ziel + +SYSLOG_SENDER_NAME=fritzbox-1 +SYSLOG_SERVER=192.168.1.200 +SYSLOG_PORT=514 +SYSLOG_PROTO=UDP + +SEND_INITIAL_LOG=True +SYSLOG_EXTRACT_TIME=True +# Standard-Fallback falls Extraktion fehlschlägt +TZ=Europe/Berlin +HASH_PERSISTENT=True +HASH_LIMIT=2000 +HASH_RETAIN=1000 +# /data/ im Container muss als Volume gemountet sein +HASH_FILE=/data/hashes.db.txt +HASH_BACKUP_EVERY=20 + + +# aussehen des syslogs bestimmen, +SYSLOG_EXTRACT_TIME=True + +# achtung , entweder +# syslog mixed +# bei true wird die syslogmessage und der json gesendet +SYSLOG_MIXED_JSON=false +# oder +SYSLOG_JSON_ONLY=false + +# Trenner zwischen Klartext und JSON bei syslog_mixed_json +JSON_SEPARATOR=" -_- " + + + +# Timing & Retries +CHECK_INTERVAL=60 +MAX_RETRIES=3 + +# Neue Schalter +DEBUG_MODE=True +DEBUG2_IGNORE_CACHE=True +#REVERSE_LOG=false +# ersetzt durch neue logik ;) + diff --git a/README.md b/README.md index 85a2119..696ab25 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -# fbsyslogsender - -Dieses Projekt ermöglicht ein lückenloses Monitoring von FRITZ!Box-Systemen (DSL, VPN, WLAN, Login) via Syslog, optimiert für Graylog und Elastic. - # FRITZ!Box Enterprise Monitor (Lua-Based) Dieses Projekt ermöglicht ein lückenloses Monitoring von FRITZ!Box-Systemen (DSL, VPN, WLAN, Login) via Syslog, optimiert für Graylog und Elastic. @@ -38,9 +34,12 @@ Sicherung des aktuellen Standorts inkl. aller Hashes: + + ## Übersicht -## Dieses System überwacht eine FRITZ!Box (getestet mit FRITZ!OS 7.52+) auf System-Ereignisse und VPN-Statusänderungen. Da die Standard-Schnittstelle (TR-064) von AVM oft auf 5 Log-Einträge limitiert ist, nutzt dieses Skript die Lua-Sch> +## Dieses System überwacht eine FRITZ!Box (getestet mit FRITZ!OS 7.52+) auf System-Ereignisse und VPN-Statusänderungen. Da die Standard-Schnittstelle (TR-064) von AVM oft auf 5 Log-Einträge limitiert ist, nutzt dieses Skript die Lua-Schnittstelle (Web-UI API), um das vollständige System-Log und detaillierte VPN-Statusinformationen abzugreifen. + ## Funktionsweise Authentifizierung: Das Skript meldet sich via Challenge-Response-Verfahren an der FRITZ!Box an und erhält eine Session-ID (SID). @@ -57,7 +56,7 @@ Sicherung des aktuellen Standorts inkl. aller Hashes: ## Installation & Deployment -### 1. Umgebungsvariablen (.env) +# 1. Umgebungsvariablen (.env) Erstelle eine .env Datei mit folgendem Inhalt: env @@ -74,23 +73,21 @@ SEND_INITIAL_LOG=True DEBUG_MODE=False - - -### 2. Docker Compose +## 2. Docker Compose Starten des Containers mit: bash docker compose up -d --build -#### Troubleshooting ("Notfall-Guide") -##### Falls keine Logs mehr im Syslog-Server ankommen: +## Troubleshooting ("Notfall-Guide") +# Falls keine Logs mehr im Syslog-Server ankommen: Container-Logs prüfen: docker logs -f fritz-syslog-forwarder. SID-Fehler: Erscheint ein "Login-Fehler", prüfen ob das Passwort abgelaufen ist oder ob die FRITZ!Box eine 2FA-Bestätigung (Taste drücken) für den API-Zugriff verlangt. Netzwerk: Testen, ob der Container den Syslog-Server pingen kann. FRITZ!OS Update: Nach einem Firmware-Update könnte AVM die data.lua Pfade ändern. In diesem Fall muss die fetch_data Funktion im Skript geprüft werden. -#### Technische Details +## Technische Details Sprache: Python 3.11-slim Bibliotheken: requests, python-dotenv @@ -98,7 +95,7 @@ bash Datenquelle: data.lua?page=log und data.lua?page=vpn -### 3. Hinweis zur Häufungserkennung +## 3. Hinweis zur Häufungserkennung Die FRITZ!Box fasst identische Meldungen zusammen (z.B. Login-Versuche), um das Log nicht zu fluten. Durch das elif oben wird eine Zeile wie: Anmeldung des Benutzers syslog... [145 Meldungen seit 04.02.26 02:00:39] automatisch als Warning markiert. Das ist ideal für Graylog-Alerts, da du so sofort siehst, wenn ein Dienst (oder ein Angreifer) "Amok läuft". @@ -110,28 +107,28 @@ Zusammenfassung der Änderungen für deine Meta-Doku: Sobald du die 7.62 installierst, schau im DEBUG_MODE besonders auf die VPN-Meldungen – AVM führt dort oft neue Verschlüsselungs-Bezeichnungen ein. -### 4. Erklärung der erweiterten Umgebungsvariablen (.env) -Parameter Wert (Beispiel) Beschreibung -SEND_INITIAL_LOG True/False Wenn True, wird beim Containerstart das gesamte vorhandene Log der FRITZ!Box gesendet. Bei False werden nur Ereignisse ab Startmoment übertragen. -SYSLOG_EXTRACT_TIME True/False Extrahiert den Zeitstempel direkt aus der FRITZ!Box-Meldung (04.02.26 03:02). Garantiert sekundengenaue Logs, unabhängig von der Container-Zeit. -SYSLOG_MIXED_JSON True/False Sendet eine hybride Nachricht: Erst der Klartext, dann der Trenner, dann strukturiertes JSON. Ideal für parallele Nutzung von Syslog-Viewern und Graylog. -SYSLOG_JSON_ONLY True/False Sendet nach dem Syslog-Header ausschließlich das JSON-Objekt. Optimiert für Elasticsearch und Graylog (kein Parsing des Klartextes nötig). -JSON_SEPARATOR " -_- " Definiert die Zeichenfolge, die bei MIXED_JSON zwischen Text und Daten steht. Dient Graylog als Anker für das Splitting. -TZ Europe/Berlin Zeitzone für den Fall, dass die Zeitextraktion fehlschlägt oder VPN-Events (Echtzeit) gesendet werden. -DEBUG_MODE True/False Schaltet ausführliche Ausgaben im Container-Log (docker logs) frei (Login-Status, gesendete Payloads, Hash-Checks). -DEBUG2_IGNORE_CACHE True/False Vorsicht: Deaktiviert den Dubletten-Schutz. Sendet bei jedem Intervall das komplette Log erneut. Nur für kurzzeitige Fehlersuche nutzen! -### 5. Syslog-Prioritäten & Level-Mapping +## 4. Erklärung der erweiterten Umgebungsvariablen (.env) +Parameter Wert (Beispiel) Beschreibung +SEND_INITIAL_LOG True/False Wenn True, wird beim Containerstart das gesamte vorhandene Log der FRITZ!Box gesendet. Bei False werden nur Ereignisse ab Startmoment übertragen. +SYSLOG_EXTRACT_TIME True/False Extrahiert den Zeitstempel direkt aus der FRITZ!Box-Meldung (04.02.26 03:02). Garantiert sekundengenaue Logs, unabhängig von der Container-Zeit. +SYSLOG_MIXED_JSON True/False Sendet eine hybride Nachricht: Erst der Klartext, dann der Trenner, dann strukturiertes JSON. Ideal für parallele Nutzung von Syslog-Viewern und Graylog. +SYSLOG_JSON_ONLY True/False Sendet nach dem Syslog-Header ausschließlich das JSON-Objekt. Optimiert für Elasticsearch und Graylog (kein Parsing des Klartextes nötig). +JSON_SEPARATOR " -_- " Definiert die Zeichenfolge, die bei MIXED_JSON zwischen Text und Daten steht. Dient Graylog als Anker für das Splitting. +TZ Europe/Berlin Zeitzone für den Fall, dass die Zeitextraktion fehlschlägt oder VPN-Events (Echtzeit) gesendet werden. +DEBUG_MODE True/False Schaltet ausführliche Ausgaben im Container-Log (docker logs) frei (Login-Status, gesendete Payloads, Hash-Checks). +DEBUG2_IGNORE_CACHE True/False Vorsicht: Deaktiviert den Dubletten-Schutz. Sendet bei jedem Intervall das komplette Log erneut. Nur für kurzzeitige Fehlersuche nutzen! + +## 5. Syslog-Prioritäten & Level-Mapping Das Skript übersetzt FRITZ!Box-Ereignisse automatisch in standardisierte Syslog-Severities (RFC 5424). Dies ermöglicht in Graylog/Zabbix eine sofortige Alarmierung basierend auf dem Feld level oder der Priority-ID. -Severity ID Level Name Trigger im Skript (Keywords) Bedeutung -2 CRITICAL 0x203e, 0x2027, ike-error Kritische VPN-Handshake-Fehler. Handlungsbedarf bei Site-to-Site. -3 ERROR Fehler, Error, Failed, Abbruch, gescheitert Fehlgeschlagene Logins, DSL-Abbrüche, Internet-Trennung. -4 WARNING [... Meldungen seit ...], Warnung, Warning, ursache Häufung von Ereignissen oder instabile Leitungen (Störsicherheit). -6 INFO Alle anderen Meldungen WLAN-Anmeldungen, reguläre Reconnects, Systemstarts. +Severity ID Level Name Trigger im Skript (Keywords) Bedeutung +2 CRITICAL 0x203e, 0x2027, ike-error Kritische VPN-Handshake-Fehler. Handlungsbedarf bei Site-to-Site. +3 ERROR Fehler, Error, Failed, Abbruch, gescheitert Fehlgeschlagene Logins, DSL-Abbrüche, Internet-Trennung. +4 WARNING [... Meldungen seit ...], Warnung, Warning, ursache Häufung von Ereignissen oder instabile Leitungen (Störsicherheit). +6 INFO Alle anderen Meldungen WLAN-Anmeldungen, reguläre Reconnects, Systemstarts. ## Pro-Tipp für die Wartung: -Sollte die FRITZ!Box nach einem Update auf 7.62 oder 8.0 keine Logs mehr senden, prüfen Sie im DEBUG_MODE, ob die fetch_data Funktion noch ein gültiges JSON-Objekt von der data.lua zurückerhält. +Sollte die FRITZ!Box nach einem Update auf 7.62 oder 8.0 keine Logs mehr senden, prüfen Sie im DEBUG_MODE, ob die fetch_data Funktion noch ein gültiges JSON-Objekt von der data.lua zurückerhält. AVM neigt dazu, die Feldnamen (z.B. von vpnList zu vpn_list) bei Major-Updates leicht zu ändern. - diff --git a/compose.yaml.dist b/compose.yaml.dist new file mode 100644 index 0000000..8b690f9 --- /dev/null +++ b/compose.yaml.dist @@ -0,0 +1,12 @@ +services: + fritz-syslog: + build: . + container_name: fritz-syslog-forwarder + env_file: .env + restart: unless-stopped + environment: + - TZ=Europe/Berlin + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + - ./data:/data diff --git a/deploy-fb-monitor.sh b/deploy-fb-monitor.sh new file mode 100644 index 0000000..21d21e8 --- /dev/null +++ b/deploy-fb-monitor.sh @@ -0,0 +1,153 @@ +#!/bin/bash + +# ############################################################################# +# SCRIPT : deploy-fb-monitor.sh +# VERSION : 1.2.0 +# DESCRIPTION : Deployment- & Backup-Tool für FB-Enterprise-Monitor +# AUTHOR : Andreas O. Schmidt +# CONTACT : cert.a.o.schmidt@outlook.de +# +# ISMS/WIKI : https://wiki.deinefirma.internal +# GIT : https://github.com[DeinAccount]/[Projekt]/blob/main/deploy.sh +# +# REVISION : +# - Last : 2026-02-04 +# - Next : 2026-08-04 +# ############################################################################# + + + +# FritzBox-Monitor Deployment & Backup Tool + +PROJECT_NAME="fbsyslog-monitor" +VERSION=$2 +TAG=$3 +BACKUP_DIR="./backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +DATADIR=.data + +case "$1" in + pack) + echo "📦 Packe Monitor-Setup für neuen Standort..." + mkdir -p $BACKUP_DIR + tar -czvf "${BACKUP_DIR}/${PROJECT_NAME}_${VERSION}_${TIMESTAMP}.tar.gz" \ + syslog-fb-monitor.py dockerfile requirements.txt compose.yaml.dist .env.dist README.md deploy-fb-monitor.sh + echo "✅ Archiv erstellt: ${BACKUP_DIR}/${PROJECT_NAME}_${VERSION}_${TAG}_${TIMESTAMP}.tar.gz" + ;; + backup) + echo "📦 Packe Monitor-Backup für neuen Standort..." + mkdir -p $BACKUP_DIR + tar -czvf "${BACKUP_DIR}/${PROJECT_NAME}_${VERSION}_${TIMESTAMP}.tar.gz" \ + syslog-fb-monitor.py dockerfile requirements.txt compose.yaml .env.dist .env README.md deploy-fb-monitor.sh + echo "✅ Archiv erstellt: ${BACKUP_DIR}/${PROJECT_NAME}_${VERSION}_${TAG}_${TIMESTAMP}.tar.gz" + ;; + + deploy) + echo "🚀 Initialisiere neues Standort-Deployment..." + mkdir -p ${DATADIR} + + # 1. Interaktive Abfrage + read -p "📍 Standort-Name (z.B. hamburg1): " SITE_NAME + read -p "🌐 IP der FRITZ!Box: " FB_IP + read -p "🖥️ IP des Syslog-Servers: " SYSLOG_IP + + # 2. Dateien vorbereiten + cp .env.dist .env + sed -i "s/^FRITZ_IP=.*/FRITZ_IP=${FB_IP}/" .env + sed -i "s/^SYSLOG_SERVER=.*/SYSLOG_SERVER=${SYSLOG_IP}/" .env + sed -i "s/^SYSLOG_SENDER_NAME=.*/SYSLOG_SENDER_NAME=fritzbox_${SITE_NAME}/" .env + + C_NAME="fritz-monitor-${SITE_NAME}" + echo -e "\n# containername=${C_NAME}" >> .env + + if [ -f compose.yaml.dist ]; then + cp compose.yaml.dist compose.yaml + sed -i "s/container_name:.*/container_name: ${C_NAME}/" compose.yaml + fi + + # 3. Manuelle Kontrolle + echo "Öffne .env zur Endkontrolle..." + sleep 1 + nano .env + + # 4. Start und Erster Log-Check via Compose + # --build erzwingt frisches Image, -d läuft im Hintergrund + docker compose up -d --build + + echo "⏳ Kurzer Wait für den ersten Connect (50s)..." + sleep 50 + + echo "📊 Zeige erste Logs (Service-Level)..." + # Nutzt den Service-Namen aus der compose.yaml, ignoriert Synology-Präfixe + docker compose logs --tail 50 + + echo "⏳ Langer Wait für vollständigen Initial-Sync (200s)..." + sleep 200 + + echo "📊 Zeige finale Logs vor Abschluss..." + docker compose logs --tail 50 + read -p "wenn alles Okay ist, weiter mit enter, bei fehlern, mit Strg+C abbrechen:" + + # 5. Variable auf false setzen und finaler Restart + echo "✅ Initialer Sync verarbeitet, setze SEND_INITIAL_LOG auf false" + sed -i 's/^SEND_INITIAL_LOG=.*/SEND_INITIAL_LOG=false/' .env + + echo "Restart mit neuen Einstellungen..." + docker compose up -d + + echo "🏁 Setup für Standort ${SITE_NAME} abgeschlossen!" + # Zur Sicherheit zeigen wir an, wie der Container nun wirklich heißt + docker compose ps + ;; start) + echo "🚀 Starte Monitor-Container..." + mkdir ${DATADIR} + docker compose up -d --build + echo "📊 Zeige Logs (Abbrechen mit Strg+C)..." + sleep 20 + docker logs -f fritz-syslog-forwarder + ;; + stop) + echo "🚀 Stoppe Monitor-Container..." + docker compose logs + docker compose down + echo "📊 Zeige Logs (Abbrechen mit Strg+C)..." + sleep 2 + docker logs -f fritz-syslog-forwarder + ;; + + update) + echo "🚀 Update Monitor-Container..." + docker compose down && docker compose up -d --build + echo "📊 Zeige Logs (Abbrechen mit Strg+C)..." + sleep 2 + docker logs -f fritz-syslog-forwarder + ;; + help) + echo -e "--- 📖 Lange Hilfe: FRITZ!Box Enterprise Monitor Deployment ---" + echo -e "Dieses Skript automatisiert das Rollout von FB-Monitoren." + echo -e "\nBefehle:" + echo -e " pack : Erstellt ein sauberes Archiv ohne .env für neue Standorte." + echo -e " backup : Sichert alles inkl. .env und Hashes (Zustandssicherung)." + echo -e " deploy : Interaktives Setup (IPs, Name, Nano, Initial-Sync)." + echo -e " update : Zieht neues Image und startet Container neu." + echo -e " stop : Fährt den Stack am Standort sauber runter." + echo -e "\nParameter:" + echo -e " : Versionsnummer für das Archiv (z.B. 1.0.2)" + echo -e " : Standort oder Status (z.B. 'hamburg' oder 'stable')" + exit 0 + ;; + *) + echo -e "❌ Ungültiger Aufruf!" + echo -e "Nutzung: $0 {pack|backup|deploy|update|stop|help} [[] []]" + echo -e "\nSynopsys:" + echo -e " $0 pack 0.1.2 'release' -> Sauberes Paket packen" + echo -e " $0 backup 0.1.2 'hamburg1' -> Lokales Backup inkl. Hashes" + echo -e " $0 deploy -> Interaktives Standort-Setup" + exit 1 + ;; + #*) + # echo "Nutzung: $0 {pack|backup|setup|deploy|update|stop} [[] []] \n synopsys: \n deploy-fb-monitor.sh pack 0.1.2 'latest' \n deploy-fb-monitor.sh backup 0.1.2 'site1' \n deploy-fb-monitor.sh pack 0.1.2 \n deploy-fb-monitor.sh backup site1 " + # exit 1 + # ;; +esac + diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..e1f760a --- /dev/null +++ b/dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim +WORKDIR /app +# Verhindert, dass Python stdout/stderr puffert +ENV PYTHONUNBUFFERED=1 +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +# Nutzt -u für unbuffered binary stdout +CMD ["python", "-u", "syslog-fb-monitor.py"] + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f985edb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fritzconnection +requests +python-dotenv + diff --git a/syslog-fb-monitor.py b/syslog-fb-monitor.py new file mode 100644 index 0000000..84233e0 --- /dev/null +++ b/syslog-fb-monitor.py @@ -0,0 +1,409 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# ############################################################################# +# TOOL : FRITZ!Box Enterprise Monitor (Lua-Based) +# VERSION : 1.2.0 +# AUTHOR : [Dein Name/Kürzel] +# CONTACT : [Deine E-Mail / Abteilung] +# REPOSITORY : https://github.com[DeinAccount]/[Projekt] +# +# DOCUMENTATION: +# - WIKI : https://wiki.deinefirma.internal +# - ISMS : Asset-ID [z.B. FB-MON-2026] / Risiko-Klasse: Medium +# - MONITORING: Graylog-Stream "FritzBox-Satelliten" +# +# +# ISMS CONTEXT: +# - ASSET-ID : HOME-NET-MON-01 +# - CLASS : Internal / Private Setup +# - WIKI : https://dein-privates-wiki.internal +# +# HISTORY: +# - 2026-02-04: v1.2.0 - Refactoring auf Lua-API, JSON-Support & Persistenz +# - REVISION : Quarterly (Next: 2026-05-04) +# - Created : 2026-02-04 +# - Last Rev. : 2026-02-04 (Initial Release nach Provider-Wartung Hamburg) +# - Next Rev. : 2026-08-04 (Halbjährlicher Security-Audit & API-Check) +# +# DESCRIPTION: +# Extrahiert via Lua-Session das System-Log und VPN-Statusdaten aus +# FRITZ!Boxen und sendet diese strukturiert (JSON) an einen Syslog-Server. +# ############################################################################# + + + +import sys +import os +import time +import requests +import hashlib +import logging +import logging.handlers +import json +import re +import signal + +import xml.etree.ElementTree as ET +from datetime import datetime +from dotenv import load_dotenv + +VER="8.55_rc3 mit json, mixed, s2s, caching, errorlevels-spezial" + +# Initialisierung +print(f"SKRIPT-START: Initialisiere Fritz-Enterprise-Monitor v {VER} :...", flush=True) +load_dotenv() + +# Konfiguration aus .env +FB_IP = os.getenv("FRITZ_IP") +FB_USER = os.getenv("FRITZ_USER") +FB_PWD = os.getenv("FRITZ_PWD") +SENDER_NAME = os.getenv("SYSLOG_SENDER_NAME", "fritzbox") +SYSLOG_SERVER = os.getenv("SYSLOG_SERVER") +SYSLOG_PORT = int(os.getenv("SYSLOG_PORT", 514)) +INTERVAL = int(os.getenv("CHECK_INTERVAL", 60)) + +# Schalter +DEBUG_MODE = os.getenv("DEBUG_MODE", "False").lower() == "true" +SEND_INITIAL = os.getenv("SEND_INITIAL_LOG", "True").lower() == "true" +EXTRACT_TIME = os.getenv("SYSLOG_EXTRACT_TIME", "True").lower() == "true" +MIXED_JSON = os.getenv("SYSLOG_MIXED_JSON", "False").lower() == "true" +JSON_ONLY = os.getenv("SYSLOG_JSON_ONLY", "False").lower() == "true" +SEPARATOR = os.getenv("JSON_SEPARATOR", " -_- ") + +# Persistenz-Konfiguration +HASH_PERSISTENT = os.getenv("HASH_PERSISTENT", "False").lower() == "true" +HASH_FILE = os.getenv("HASH_FILE", "/data/hashes.db.txt") +HASH_LIMIT = int(os.getenv("HASH_LIMIT", 2000)) +HASH_RETAIN = int(os.getenv("HASH_RETAIN", 1000)) + +last_hashes = set() + + +def save_hashes(): + """ Speichert das Hash-Set in eine Datei """ + if not HASH_PERSISTENT: return + try: + os.makedirs(os.path.dirname(HASH_FILE), exist_ok=True) + with open(HASH_FILE, "w") as f: + for h in last_hashes: + f.write(f"{h}\n") + if DEBUG_MODE: print(f"[DEBUG] {len(last_hashes)} Hashes persistent gespeichert.", flush=True) + except Exception as e: + print(f"!!! Fehler beim Speichern der Hashes: {e}", flush=True) + +def load_hashes(): + """ Lädt Hashes beim Start """ + global last_hashes + if not HASH_PERSISTENT or not os.path.exists(HASH_FILE): return + try: + with open(HASH_FILE, "r") as f: + lines = f.read().splitlines() + last_hashes = set(lines) + print(f"[*] {len(last_hashes)} Hashes aus Persistenz geladen.", flush=True) + except Exception as e: + print(f"!!! Fehler beim Laden der Hashes: {e}", flush=True) + +def signal_handler(sig, frame): + """ Fängt Docker Stop Signale ab """ + print(f"[*] Signal {sig} empfangen. Beende sicher...", flush=True) + save_hashes() + sys.exit(0) + +# Signale registrieren (für sauberes Docker Down) +signal.signal(signal.SIGTERM, signal_handler) +signal.signal(signal.SIGINT, signal_handler) + + + + +# Syslog Handler +handler = logging.handlers.SysLogHandler(address=(SYSLOG_SERVER, SYSLOG_PORT)) + +class VPNErrorLookup: + """ Übersetzt kryptische Fritzbox VPN Hex-Codes in Klartext """ +# ERRORS = { +# "0x203e": "IKE-Error: Timeout. Gegenstelle antwortet nicht (DNS prüfen oder Host down).", +# "0x2027": "IKE-Error: Connection refused. Gegenstelle lehnt Verbindung aktiv ab (IP-Mismatch?).", +# "0x201c": "IKE-Error: Authentisierungsfehler. Shared Secret (PSK) prüfen!", +# "0x2026": "IKE-Error: Remote-ID mismatch. Identität passt nicht zur Konfiguration.", +# "0x2020": "IKE-Error: Proposal mismatch. Verschlüsselungs-SAs passen nicht zusammen.", +# "0x2028": "IKE-Error: Initial-Contact fehlgeschlagen (Gegenstelle instabil).", +# "0x2033": "IKE-Error: Lifetime-Timeout (Re-Keying fehlgeschlagen).", +# "0x1": "VPN-Fehler: Interner Fehler oder DNS-Auflösung fehlgeschlagen.", +# "3 IKE server": "VPN-Trennung durch lokalen Server (z.B. Reconnect/Zwangstrennung)." +# } + + ERRORS = { + # Verbindungsprobleme + "0x203e": "IKE-Error: Timeout. Gegenstelle antwortet nicht (DNS prüfen oder Host down).", + "0x2027": "IKE-Error: Connection refused. Gegenstelle lehnt Verbindung aktiv ab.", + "0x1": "VPN-Fehler: DNS-Fehler oder interner Fehler beim Verbindungsaufbau.", + "3 IKE server": "VPN-Trennung durch lokalen Server (z.B. Zwangstrennung/Reconnect).", + + # Authentifizierung & Schlüssel + "0x201c": "IKE-Error: Authentisierungsfehler. Shared Secret (PSK) prüfen!", + "0x2026": "IKE-Error: Remote-ID mismatch. Identität der Gegenstelle passt nicht zur Konfiguration.", + + # Protokoll- & Konfigurationskonflikte + "0x2020": "IKE-Error: Proposal mismatch. Verschlüsselungs-Algorithmen (IKE/IPsec SAs) passen nicht zusammen.", + "0x2005": "IKE-Error: Keine virtuelle IP-Adresse für VPN-Nutzer festlegbar.", + "0x203d": "IKE-Error: Peer-Authentifizierungs-Problem (Zertifikat oder ID fehlerhaft).", + "0x200A": "IKE-Error: Invalid exchange type (Protokollfehler).", + "0x200D": "IKE-Error: Invalid minor version.", + "0x200E": "IKE-Error: Invalid protocol version.", + "0x000F": "IKE-Error: Payloads not encrypted.", + "0x0010": "IKE-Error: Payloads are encrypted (obwohl unverschlüsselt erwartet).", + "0x2028": "IKE-Error: Initial-Contact fehlgeschlagen (Gegenstelle evtl. instabil).", + "0x2033": "IKE-Error: Lifetime-Timeout (Re-Keying fehlgeschlagen)." + } + + @classmethod + def translate(cls, msg): + for code, description in cls.ERRORS.items(): + if code.lower() in msg.lower(): + return f"{msg} | INFO: {description}" + return msg + +def get_meta(msg): + """ Kategorisierung und Severity-Mapping """ + m = msg.lower() + cat = "SYSTEM" + if any(x in m for x in ["anmeldung", "zugang", "benutzeroberfläche"]): cat = "LOGIN" + #elif any(x in m for x in ["dyndns", "wlan", "funknetz"]): cat = "DynDNS" + elif any(x in m for x in ["ghz", "wlan", "funknetz"]): cat = "WLAN" + elif any(x in m for x in ["vpn", "ike", "ipsec", "wireguard"]): cat = "VPN" + elif any(x in m for x in ["dsl", "internetverbindung", "synchronisierung", "breitband"]): cat = "UPLINK" + elif any(x in m for x in ["einstellung", "konfiguration", "geändert"]): cat = "CONFIG" + elif "dyndns" in m: cat = "DynDNS" + + # Severity Mapping (RFC 5424) + sev, lvl_name = 6, "INFO" + + ## Kritisch: VPN-Fehler während Site-to-Site Phase + ## noch etwas zu hart gefasst + #if "vpn" in m and any(x in m for x in ["0x", "fehler", "error", "ike"]): + # sev, lvl_name = 2, "CRITICAL" + #elif any(x in m for x in ["fehler", "error", "failed", "abbruch", "gescheitert"]): + # sev, lvl_name = 3, "ERROR" + #elif (any(x in m for x in ["warnung", "warning", "ursache", "seit"]) and "[" in m): + # sev, lvl_name = 4, "WARNING" + # + + # hier eine entschärftere version gegen false positive bei VPN + # KRITISCH: VPN-Fehler, aber NUR wenn NICHT "erfolgreich" im Text steht + # Das verhindert, dass der Tunnel-Aufbau als Fehler markiert wird. + if "vpn" in m and any(x in m for x in ["0x", "fehler", "error", "ike"]): + if "erfolgreich" not in m: + sev, lvl_name = 2, "CRITICAL" + else: + sev, lvl_name = 6, "INFO" # Erfolgreicher Aufbau ist nur eine Info + + elif any(x in m for x in ["fehler", "error", "failed", "abbruch", "gescheitert"]): + sev, lvl_name = 3, "ERROR" + + elif any(x in m for x in ["warnung", "warning", "ursache", "seit"]) and "[" in m: + sev, lvl_name = 4, "WARNING" + + # DynDNS-Logik (Dein neues Beispiel) + elif "dyndns" in m: + # Fehlerszenarien: "nicht aufgelöst werden" oder klassische Fehler-Keywords + if any(x in m for x in ["fehler", "error", "nicht aufgelöst", "deaktiviert", "gescheitert"]): + sev, lvl_name = 3, "ERROR" + elif "erfolgreich" in m: + sev, lvl_name = 6, "INFO" + else: + sev, lvl_name = 4, "WARNING" # Fallback für unklare Meldungen + + # Allgemeine Fehlerprüfung für andere Kategorien + elif any(x in m for x in ["fehler", "error", "failed", "abbruch", "gescheitert"]): + sev, lvl_name = 3, "ERROR" + + elif any(x in m for x in ["warnung", "warning", "ursache", "seit"]) and "[" in m: + sev, lvl_name = 4, "WARNING" + + + + + # ausgabe der ergebnisse + return cat, sev, lvl_name + +def send_payload(msg, date_str=None, time_str=None, force_cat=None): + """ Baut das finale Paket und sendet an Syslog """ + enriched_msg = VPNErrorLookup.translate(msg) + category, severity, lvl_name = get_meta(enriched_msg) + if force_cat: category = force_cat + + # Zeitstempel + if EXTRACT_TIME and date_str and time_str: + try: + dt = datetime.strptime(f"{date_str} {time_str}", "%d.%m.%y %H:%M:%S") + ts = dt.strftime("%b %d %H:%M:%S") + except: ts = datetime.now().strftime("%b %d %H:%M:%S") + else: ts = datetime.now().strftime("%b %d %H:%M:%S") + + # Basis JSON Objekt + log_obj = { + "timestamp_raw": f"{date_str} {time_str}" if date_str else ts, + "hostname": SENDER_NAME, + "category": category, + "level": lvl_name, + "message": enriched_msg.strip() + } + + + # Erweiterte VPN-Metadaten + if category == "VPN": + p_ip, p_name = "0.0.0.0", "unknown" + ip_match = re.search(r"\[(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]", msg) + if ip_match: p_ip = ip_match.group(1) + name_match = re.search(r"zu\s+([^\s\[]+)", msg) + if name_match: p_name = name_match.group(1).strip(',').strip() + + log_obj.update({ + "vpn_peer_ip": p_ip, + "vpn_peer_name": p_name, + "vpn_error_code": re.search(r"0x[0-9a-fA-F]+", msg).group(0) if "0x" in msg else "None", + "vpn_phase": "Phase 1 (IKE)" if "IKE SA" in msg else "Phase 2 (IPsec)" + }) + + + + # Paketbau + pri = (1 * 8) + severity + header = f"<{pri}>{ts} {SENDER_NAME} FRITZ_{category}: " + + if JSON_ONLY: + packet = f"{header}{json.dumps(log_obj)}" + elif MIXED_JSON: + packet = f"{header}{enriched_msg.strip()}{SEPARATOR}{json.dumps(log_obj)}" + else: + packet = f"{header}{enriched_msg.strip()}" + + if DEBUG_MODE: print(f"[DEBUG] OUT: {packet}", flush=True) + + try: + handler.socket.sendto(packet.encode('utf-8'), (SYSLOG_SERVER, SYSLOG_PORT)) + except Exception as e: + print(f"!!! Sendefehler: {e}", flush=True) + +class FritzLuaSession: + def __init__(self, host, user, pwd): + self.host, self.user, self.pwd, self.sid = host, user, pwd, "0000000000000000" + + def get_sid(self): + url = f"http://{self.host}/login_sid.lua" + try: + r = requests.get(url, timeout=5) + challenge = ET.fromstring(r.text).find('Challenge').text + response = f"{challenge}-{hashlib.md5(f'{challenge}-{self.pwd}'.encode('utf-16-le')).hexdigest()}" + r = requests.get(f"{url}?username={self.user}&response={response}", timeout=5) + self.sid = ET.fromstring(r.text).find('SID').text + if DEBUG_MODE: print(f"[DEBUG] Login OK, SID: {self.sid}", flush=True) + return self.sid + except Exception as e: + print(f"!!! Login-Fehler: {e}", flush=True); return "0000000000000000" + + def fetch_data(self, page): + if self.sid == "0000000000000000": self.get_sid() + try: + r = requests.post(f"http://{self.host}/data.lua", data={"sid": self.sid, "page": page}, timeout=10) + d = r.json() + if 'data' not in d and 'sid' in d: self.get_sid(); return None + return d + except: return None + + + + +def main(): + global last_hashes + print(f"=== Enterprise Monitor aktiv: {SENDER_NAME} ===", flush=True) + + # 1. Hashes aus Persistenz laden (Zustand vor dem Stopp/Absturz) + if HASH_PERSISTENT: + load_hashes() + + # 2. Verhalten bei SEND_INITIAL_LOG steuern + # Wenn True, wird der lokale Cache ignoriert/gelöscht, um alles in der Box neu zu pushen + if SEND_INITIAL: + print("[!] SEND_INITIAL_LOG aktiv: Lokaler Cache wird ignoriert -> Kompletter Neu-Push aller Box-Einträge.", flush=True) + last_hashes = set() + + session = FritzLuaSession(FB_IP, FB_USER, FB_PWD) + vpn_states = {} + is_initial = True + new_hashes_since_backup = 0 + + # Backup-Intervall aus ENV (z.B. alle 20 neuen Hashes sichern) + backup_every = int(os.getenv("HASH_BACKUP_EVERY", 20)) + + while True: + # --- TEIL 1: SYSTEM LOGS (Chronologischer Rückwärts-Sync) --- + log_data = session.fetch_data("log") + if log_data and 'data' in log_data and 'log' in log_data['data']: + entries = log_data['data']['log'] + entries.reverse() # Wichtig: Alt nach Neu verarbeiten + + for e in entries: + m, d, t = e.get('msg', ''), e.get('date', ''), e.get('time', '') + h = hashlib.sha1(f"{d}{t}{m}".encode()).hexdigest() + + if h not in last_hashes: + # Senden wenn: + # a) Es kein Initial-Lauf ist (Normalbetrieb) + # b) Es der Initial-Lauf ist UND SEND_INITIAL_LOG auf True steht + if not is_initial or SEND_INITIAL: + send_payload(m, d, t) + + # Neuen Hash in Cache aufnehmen und Zähler erhöhen + last_hashes.add(h) + new_hashes_since_backup += 1 + + # Inkrementelles Backup während des Laufs (beugt Datenverlust bei Absturz vor) + if HASH_PERSISTENT and new_hashes_since_backup >= backup_every: + save_hashes() + new_hashes_since_backup = 0 + + # --- TEIL 2: VPN MONITORING (Site-to-Site Fokus) --- + vpn_data = session.fetch_data("vpn") + if vpn_data and 'data' in vpn_data and 'vpnList' in vpn_data['data']: + for t in vpn_data['data']['vpnList']: + n, c = t.get('name'), t.get('connected', False) + + # Statusänderung erkennen + if vpn_states.get(n) != c: + # VPN-Alarme nur nach dem ersten Scan senden (Spamschutz beim Boot) + if not is_initial: + v_msg = f"VPN Tunnel {n} ist {'UP' if c else 'DOWN'}" + send_payload(v_msg, force_cat="VPN") + + vpn_states[n] = c + + # --- TEIL 3: INITIAL-ABSCHLUSS & CACHE-PFLEGE --- + + # Nach dem allerersten Durchlauf: Inkrementelle Hashes sofort sichern + if is_initial: + if HASH_PERSISTENT and new_hashes_since_backup > 0: + save_hashes() + new_hashes_since_backup = 0 + is_initial = False + + # Speichermanagement: Cache-Limit prüfen und ggf. rotieren + if len(last_hashes) > HASH_LIMIT: + # Behalte nur die neuesten X Hashes laut HASH_RETAIN + sorted_hashes = list(last_hashes) + last_hashes = set(sorted_hashes[-HASH_RETAIN:]) + + if DEBUG_MODE: + print(f"[DEBUG] Cache-Limit ({HASH_LIMIT}) erreicht. Bereinigt auf {HASH_RETAIN}.", flush=True) + + # Nach der Bereinigung den neuen kompakten Stand sichern + if HASH_PERSISTENT: + save_hashes() + + # Pause bis zum nächsten Polling-Intervall + time.sleep(INTERVAL) + +if __name__ == "__main__": + main() +