Initial commit: Watchdog Docker v0.1
Complete OPNsense monitoring system with: - DHCP lease monitoring - New device detection (ARP) - Interface and gateway status monitoring - Web dashboard with real-time updates - Email notifications via SMTP - SQLite database for event logging - Docker deployment ready Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
a4b71f3631
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Data & Logs
|
||||||
|
data/*.db
|
||||||
|
data/*.log
|
||||||
|
data/backup.db
|
||||||
|
|
||||||
|
# Sensitive Config (optional - keep structure but remove secrets)
|
||||||
|
# config/config.yaml
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
Watchdog Docker v0.1 - OPNsense monitoring system with web dashboard and email notifications. Monitors DHCP leases, new devices (ARP), interface status changes, and gateway status changes.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** Python 3.11, Flask, Flask-Login
|
||||||
|
- **Scheduling:** APScheduler (background polling)
|
||||||
|
- **Database:** SQLite
|
||||||
|
- **Frontend:** Bootstrap 5, Vanilla JavaScript
|
||||||
|
- **API:** OPNsense REST API
|
||||||
|
- **Email:** SMTP with TLS
|
||||||
|
- **Deployment:** Docker + Docker Compose
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
# Build and start
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
```bash
|
||||||
|
# Create virtual environment
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Run app
|
||||||
|
python app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
```bash
|
||||||
|
# Test OPNsense API connection
|
||||||
|
python -c "from app.opnsense_api import OPNsenseAPI; import yaml; config = yaml.safe_load(open('config/config.yaml')); api = OPNsenseAPI(**config['opnsense']); print(api.test_connection())"
|
||||||
|
|
||||||
|
# Generate password hash
|
||||||
|
python -c "from werkzeug.security import generate_password_hash; print(generate_password_hash('your_password'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Application Flow
|
||||||
|
1. **main.py** - Flask app initialization, routes, APScheduler setup
|
||||||
|
2. **monitor.py** - OPNsenseMonitor class runs periodic checks (check_all)
|
||||||
|
3. **opnsense_api.py** - OPNsenseAPI client handles all API communication
|
||||||
|
4. **database.py** - Database class manages SQLite operations
|
||||||
|
5. **email_handler.py** - EmailHandler sends SMTP notifications
|
||||||
|
|
||||||
|
### Monitoring Cycle
|
||||||
|
- APScheduler triggers `monitor.check_all()` every N seconds (configurable)
|
||||||
|
- Each check method compares current state vs previous state
|
||||||
|
- On changes: log to database + send email (if enabled)
|
||||||
|
- State stored in memory (resets on restart)
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
- **events** - All monitored events (id, timestamp, type, interface, details, data JSON)
|
||||||
|
- **known_devices** - MAC addresses of known devices (auto-populated on first detection)
|
||||||
|
|
||||||
|
### Web Interface
|
||||||
|
- Login required (Flask-Login)
|
||||||
|
- Dashboard shows stats + filterable event table
|
||||||
|
- AJAX auto-refresh every 10s via /api/events endpoint
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
- **config/config.yaml** - ALL settings (OPNsense, monitoring, web, email, database)
|
||||||
|
- **app/main.py** - Flask app entry point
|
||||||
|
- **app/monitor.py** - Core monitoring logic with state tracking
|
||||||
|
- **app/database.py** - SQLite operations
|
||||||
|
- **app/email_handler.py** - Email notifications with HTML templates
|
||||||
|
- **app/opnsense_api.py** - OPNsense API wrapper
|
||||||
|
- **app/templates/** - Jinja2 templates (login.html, dashboard.html)
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings in `config/config.yaml`:
|
||||||
|
- OPNsense host, API credentials, SSL verification
|
||||||
|
- Monitoring interval, monitored interfaces, event toggles
|
||||||
|
- Web port, secret key, admin password hash
|
||||||
|
- SMTP settings, recipients
|
||||||
|
- Database path, retention days
|
||||||
|
- Logging level
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **State Tracking:** Monitor uses previous_* dicts to detect changes (DHCP leases, devices, interfaces, gateways)
|
||||||
|
- **Interface Filtering:** If `monitored_interfaces` is empty, monitor ALL interfaces
|
||||||
|
- **Known Devices:** New devices are auto-added to known_devices table on first detection
|
||||||
|
- **Email Logic:** Only send emails for unknown devices (new_device event with known=False)
|
||||||
|
- **Retention:** Old events auto-cleanup based on retention_days (default 90)
|
||||||
|
- **Password:** Admin password must be werkzeug scrypt hash in config.yaml
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- German language in UI/emails (Deutsch)
|
||||||
|
- Docstrings in English
|
||||||
|
- Logging: logger.info for important events, logger.debug for routine operations
|
||||||
|
- Error handling: try/except with logger.error(..., exc_info=True)
|
||||||
|
- Type hints where applicable (Dict, List, Optional)
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Add new event type
|
||||||
|
1. Add check method to `monitor.py` (check_xyz)
|
||||||
|
2. Add toggle to config.yaml under events
|
||||||
|
3. Call from check_all() if enabled
|
||||||
|
4. Update email_handler colors/formatting if needed
|
||||||
|
|
||||||
|
### Modify OPNsense API calls
|
||||||
|
- All API methods in `opnsense_api.py`
|
||||||
|
- Use requests with basic auth (key:secret)
|
||||||
|
- SSL warnings suppressed if verify_ssl=false
|
||||||
|
|
||||||
|
### Change database schema
|
||||||
|
- Update initialize() in database.py
|
||||||
|
- Add migration logic if needed (or recreate DB)
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
LABEL maintainer="Watchdog Docker"
|
||||||
|
LABEL version="0.1"
|
||||||
|
LABEL description="OPNsense Monitoring Container"
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application
|
||||||
|
COPY app/ ./app/
|
||||||
|
COPY config/ ./config/
|
||||||
|
|
||||||
|
# Create data directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD python -c "import requests; requests.get('http://localhost:5000/health', timeout=5)" || exit 1
|
||||||
|
|
||||||
|
CMD ["python", "app/main.py"]
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
# Watchdog Docker v0.1
|
||||||
|
|
||||||
|
Docker Container für OPNsense Monitoring mit Web-Interface und E-Mail-Benachrichtigungen.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔍 **DHCP Lease Monitoring** - Überwachung neuer IP-Vergaben
|
||||||
|
- 📱 **Device Detection** - Erkennung neuer Geräte via ARP (mit Abgleich bekannter Geräte)
|
||||||
|
- 🔌 **Interface Monitoring** - Status-Änderungen von Netzwerk-Interfaces
|
||||||
|
- 🌐 **Gateway Monitoring** - Überwachung von Gateway-Status
|
||||||
|
- 📧 **E-Mail Benachrichtigungen** - Automatische Alerts bei Events
|
||||||
|
- 🖥️ **Web Dashboard** - Übersichtliches Interface mit Echtzeit-Updates
|
||||||
|
- 🔐 **Passwort-Schutz** - Gesicherter Zugang zum Dashboard
|
||||||
|
|
||||||
|
## Überwachte Events
|
||||||
|
|
||||||
|
| Event-Typ | Beschreibung | Interface-Filter |
|
||||||
|
|-----------|--------------|------------------|
|
||||||
|
| DHCP Lease | Neue IP-Adresse vergeben | ✓ |
|
||||||
|
| New Device | Neues Gerät im Netzwerk erkannt | ✓ |
|
||||||
|
| Interface Status | Interface up/down Änderungen | ✓ |
|
||||||
|
| Gateway Status | Gateway Status-Änderungen | - |
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- OPNsense Firewall mit aktivierter API
|
||||||
|
- SMTP Server für E-Mail-Benachrichtigungen (optional)
|
||||||
|
|
||||||
|
## OPNsense API Setup
|
||||||
|
|
||||||
|
### 1. API Key & Secret generieren
|
||||||
|
|
||||||
|
1. In OPNsense: **System → Access → Users**
|
||||||
|
2. Wähle deinen Admin-User oder erstelle einen neuen
|
||||||
|
3. Scrolle zu **API keys** und klicke auf **+** (Add)
|
||||||
|
4. Notiere dir **Key** und **Secret** - sie werden nur einmal angezeigt!
|
||||||
|
|
||||||
|
### 2. API Zugriff testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -k -u "YOUR_KEY:YOUR_SECRET" https://192.168.1.1/api/core/menu/search/
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls erfolgreich, erhältst du JSON-Daten zurück.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Repository klonen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd watchdog-docker
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Konfiguration anpassen
|
||||||
|
|
||||||
|
Bearbeite `config/config.yaml`:
|
||||||
|
|
||||||
|
#### OPNsense Einstellungen
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
opnsense:
|
||||||
|
host: "https://192.168.1.1" # Deine OPNsense IP
|
||||||
|
api_key: "YOUR_API_KEY_HERE"
|
||||||
|
api_secret: "YOUR_API_SECRET_HERE"
|
||||||
|
verify_ssl: false # Auf true setzen bei gültigem Zertifikat
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Admin-Passwort generieren
|
||||||
|
|
||||||
|
Passwort-Hash für das Web-Interface generieren:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
# Ersetze 'dein_passwort' mit deinem gewünschten Passwort
|
||||||
|
password = "dein_passwort"
|
||||||
|
hash = generate_password_hash(password)
|
||||||
|
print(hash)
|
||||||
|
```
|
||||||
|
|
||||||
|
Kopiere den generierten Hash in die `config.yaml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
web:
|
||||||
|
admin_password_hash: "scrypt:32768:8:1$..." # Dein generierter Hash
|
||||||
|
```
|
||||||
|
|
||||||
|
#### E-Mail Konfiguration (optional)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
email:
|
||||||
|
enabled: true
|
||||||
|
smtp_server: "mail.yourdomain.com"
|
||||||
|
smtp_port: 587
|
||||||
|
smtp_use_tls: true
|
||||||
|
smtp_username: "watchdog@yourdomain.com"
|
||||||
|
smtp_password: "YOUR_PASSWORD"
|
||||||
|
from_address: "watchdog@yourdomain.com"
|
||||||
|
to_addresses:
|
||||||
|
- "admin@yourdomain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls du keine E-Mails versenden möchtest, setze `enabled: false`.
|
||||||
|
|
||||||
|
#### Monitoring-Einstellungen
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
monitoring:
|
||||||
|
polling_interval: 60 # Sekunden zwischen Checks
|
||||||
|
|
||||||
|
# Nur bestimmte Interfaces überwachen (leer = alle)
|
||||||
|
monitored_interfaces:
|
||||||
|
- lan
|
||||||
|
- wan
|
||||||
|
|
||||||
|
# Events ein/ausschalten
|
||||||
|
events:
|
||||||
|
dhcp_leases: true
|
||||||
|
new_devices: true
|
||||||
|
interface_status: true
|
||||||
|
gateway_status: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Docker Container starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container bauen und starten
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# Logs anzeigen
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Status prüfen
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Web-Interface öffnen
|
||||||
|
|
||||||
|
Öffne im Browser: `http://localhost:5000`
|
||||||
|
|
||||||
|
- **Benutzername:** `admin`
|
||||||
|
- **Passwort:** Dein konfiguriertes Passwort
|
||||||
|
|
||||||
|
## Verzeichnisstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
watchdog-docker/
|
||||||
|
├── Dockerfile # Docker Image Definition
|
||||||
|
├── docker-compose.yml # Docker Compose Konfiguration
|
||||||
|
├── requirements.txt # Python Dependencies
|
||||||
|
├── README.md # Diese Datei
|
||||||
|
├── config/
|
||||||
|
│ └── config.yaml # Hauptkonfiguration
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # Flask App & Routes
|
||||||
|
│ ├── opnsense_api.py # OPNsense API Client
|
||||||
|
│ ├── monitor.py # Event Monitoring Logik
|
||||||
|
│ ├── database.py # SQLite Datenbank Handler
|
||||||
|
│ ├── email_handler.py # E-Mail Versand
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── login.html # Login-Seite
|
||||||
|
│ └── dashboard.html # Dashboard
|
||||||
|
└── data/ # Auto-generiert beim ersten Start
|
||||||
|
├── watchdog.db # SQLite Datenbank
|
||||||
|
└── watchdog.log # Log-Datei
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Das Dashboard verwendet folgende REST-API Endpoints:
|
||||||
|
|
||||||
|
- `GET /api/events` - Events abrufen
|
||||||
|
- Parameter: `limit`, `type`, `interface`
|
||||||
|
- `GET /api/stats` - Statistiken abrufen
|
||||||
|
- `GET /health` - Health Check
|
||||||
|
|
||||||
|
Beispiel:
|
||||||
|
```bash
|
||||||
|
curl http://localhost:5000/api/events?type=dhcp_lease&limit=10
|
||||||
|
```
|
||||||
|
|
||||||
|
## Datenbank
|
||||||
|
|
||||||
|
Die SQLite-Datenbank wird automatisch beim ersten Start erstellt.
|
||||||
|
|
||||||
|
### Tabellen
|
||||||
|
|
||||||
|
#### events
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|--------|-----|--------------|
|
||||||
|
| id | INTEGER | Primary Key |
|
||||||
|
| timestamp | DATETIME | Event-Zeitpunkt |
|
||||||
|
| type | TEXT | Event-Typ |
|
||||||
|
| interface | TEXT | Interface-Name |
|
||||||
|
| details | TEXT | Event-Details |
|
||||||
|
| data | JSON | Vollständige Event-Daten |
|
||||||
|
|
||||||
|
#### known_devices
|
||||||
|
| Spalte | Typ | Beschreibung |
|
||||||
|
|--------|-----|--------------|
|
||||||
|
| mac | TEXT | MAC-Adresse (Primary Key) |
|
||||||
|
| name | TEXT | Geräte-Name |
|
||||||
|
| first_seen | DATETIME | Erstmals gesehen |
|
||||||
|
| last_seen | DATETIME | Zuletzt gesehen |
|
||||||
|
|
||||||
|
### Datenbank-Wartung
|
||||||
|
|
||||||
|
Alte Events werden automatisch nach der konfigurierten Retention-Zeit gelöscht (Standard: 90 Tage).
|
||||||
|
|
||||||
|
## E-Mail Benachrichtigungen
|
||||||
|
|
||||||
|
### Event-Typen
|
||||||
|
|
||||||
|
- **DHCP Lease** 🔵 - Neue IP-Vergabe
|
||||||
|
- **New Device** 🔴 - Unbekanntes Gerät (nur bei unbekannten Geräten)
|
||||||
|
- **Interface Status** ⚠️ - Interface up/down
|
||||||
|
- **Gateway Status** ⚠️ - Gateway-Änderungen
|
||||||
|
|
||||||
|
### E-Mail Beispiel
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Jede E-Mail enthält:
|
||||||
|
- Event-Typ mit Icon
|
||||||
|
- Zeitpunkt
|
||||||
|
- Interface/Gateway
|
||||||
|
- IP, MAC, Hostname (bei Geräten)
|
||||||
|
- Detaillierte Beschreibung
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Container startet nicht
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Logs prüfen
|
||||||
|
docker-compose logs
|
||||||
|
|
||||||
|
# Container Status
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### OPNsense API-Verbindung schlägt fehl
|
||||||
|
|
||||||
|
1. Prüfe ob OPNsense erreichbar ist:
|
||||||
|
```bash
|
||||||
|
ping 192.168.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Teste API manuell:
|
||||||
|
```bash
|
||||||
|
curl -k -u "KEY:SECRET" https://192.168.1.1/api/core/menu/search/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Prüfe Firewall-Regeln auf OPNsense
|
||||||
|
|
||||||
|
### Keine E-Mails werden versendet
|
||||||
|
|
||||||
|
1. Prüfe SMTP-Einstellungen in `config.yaml`
|
||||||
|
2. Teste SMTP-Verbindung:
|
||||||
|
```bash
|
||||||
|
telnet mail.yourdomain.com 587
|
||||||
|
```
|
||||||
|
3. Prüfe Container-Logs auf SMTP-Fehler
|
||||||
|
|
||||||
|
### Login funktioniert nicht
|
||||||
|
|
||||||
|
1. Prüfe ob Passwort-Hash korrekt generiert wurde
|
||||||
|
2. Versuche neuen Hash zu generieren:
|
||||||
|
```python
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
print(generate_password_hash("dein_passwort"))
|
||||||
|
```
|
||||||
|
3. Hash in `config.yaml` ersetzen und Container neu starten
|
||||||
|
|
||||||
|
### Dashboard zeigt keine Events
|
||||||
|
|
||||||
|
1. Prüfe ob Monitoring läuft:
|
||||||
|
```bash
|
||||||
|
docker-compose logs | grep "Monitoring started"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Prüfe ob OPNsense API antwortet:
|
||||||
|
```bash
|
||||||
|
docker-compose logs | grep "OPNsense"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Erhöhe Log-Level auf DEBUG in `config.yaml`:
|
||||||
|
```yaml
|
||||||
|
logging:
|
||||||
|
level: "DEBUG"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container stoppen
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Neueste Version pullen
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Container neu bauen und starten
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Die Datenbank (`data/watchdog.db`) bleibt bei Updates erhalten.
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
### Datenbank sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backup erstellen
|
||||||
|
docker-compose exec watchdog sqlite3 /app/data/watchdog.db ".backup /app/data/backup.db"
|
||||||
|
|
||||||
|
# Backup aus Container kopieren
|
||||||
|
docker cp watchdog:/app/data/backup.db ./backup.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Konfiguration sichern
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Config sichern
|
||||||
|
cp config/config.yaml config/config.yaml.backup
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sicherheitshinweise
|
||||||
|
|
||||||
|
- ⚠️ Ändere das `secret_key` in der `config.yaml`
|
||||||
|
- ⚠️ Verwende ein sicheres Admin-Passwort
|
||||||
|
- ⚠️ Aktiviere SSL-Verifizierung (`verify_ssl: true`) in Produktion
|
||||||
|
- ⚠️ Verwende HTTPS für das Web-Interface (z.B. mit Reverse Proxy)
|
||||||
|
- ⚠️ Speichere keine API-Secrets in Git (nutze `.env` Dateien)
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
### Lokale Entwicklung ohne Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Virtual Environment erstellen
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# App starten
|
||||||
|
python app/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OPNsense API Connection testen
|
||||||
|
python -c "from app.opnsense_api import OPNsenseAPI; import yaml; config = yaml.safe_load(open('config/config.yaml')); api = OPNsenseAPI(**config['opnsense']); print(api.test_connection())"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
Bei Problemen oder Fragen:
|
||||||
|
|
||||||
|
1. Prüfe die [Troubleshooting](#troubleshooting) Sektion
|
||||||
|
2. Aktiviere DEBUG-Logging und prüfe die Logs
|
||||||
|
3. Erstelle ein Issue mit:
|
||||||
|
- Container-Logs (`docker-compose logs`)
|
||||||
|
- Konfiguration (ohne Secrets!)
|
||||||
|
- Fehlerbeschreibung
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### v0.1 (Initial Release)
|
||||||
|
- OPNsense Monitoring (DHCP, Devices, Interfaces, Gateways)
|
||||||
|
- Web Dashboard mit Echtzeit-Updates
|
||||||
|
- E-Mail Benachrichtigungen
|
||||||
|
- SQLite Datenbank
|
||||||
|
- Docker Support
|
||||||
|
|
@ -0,0 +1,253 @@
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""SQLite database handler for Watchdog Docker"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self.db_path = db_path
|
||||||
|
logger.info(f"Database initialized at {db_path}")
|
||||||
|
|
||||||
|
def _get_connection(self):
|
||||||
|
"""Get database connection"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
def initialize(self):
|
||||||
|
"""Create database tables if they don't exist"""
|
||||||
|
logger.info("Initializing database tables")
|
||||||
|
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Events table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
interface TEXT,
|
||||||
|
details TEXT NOT NULL,
|
||||||
|
data JSON
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create index on timestamp for faster queries
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_timestamp
|
||||||
|
ON events(timestamp DESC)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Create index on type for filtering
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_type
|
||||||
|
ON events(type)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Known devices table
|
||||||
|
cursor.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS known_devices (
|
||||||
|
mac TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("Database tables initialized successfully")
|
||||||
|
|
||||||
|
def add_event(self, event: Dict):
|
||||||
|
"""Add an event to the database"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT INTO events (type, interface, details, data)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
event.get('type'),
|
||||||
|
event.get('interface'),
|
||||||
|
event.get('details'),
|
||||||
|
json.dumps(event)
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.debug(f"Event added: {event.get('type')} - {event.get('details')}")
|
||||||
|
|
||||||
|
# Auto-add new devices to known_devices if applicable
|
||||||
|
if event.get('type') == 'new_device' and not event.get('known'):
|
||||||
|
mac = event.get('mac')
|
||||||
|
hostname = event.get('hostname', 'Unknown')
|
||||||
|
if mac:
|
||||||
|
self.add_known_device(mac, hostname)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding event: {e}", exc_info=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_recent_events(self, limit: int = 100, event_type: Optional[str] = None,
|
||||||
|
interface: Optional[str] = None) -> List[Dict]:
|
||||||
|
"""Get recent events with optional filtering"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = 'SELECT * FROM events WHERE 1=1'
|
||||||
|
params = []
|
||||||
|
|
||||||
|
if event_type:
|
||||||
|
query += ' AND type = ?'
|
||||||
|
params.append(event_type)
|
||||||
|
|
||||||
|
if interface:
|
||||||
|
query += ' AND interface = ?'
|
||||||
|
params.append(interface)
|
||||||
|
|
||||||
|
query += ' ORDER BY timestamp DESC LIMIT ?'
|
||||||
|
params.append(limit)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute(query, params)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for row in rows:
|
||||||
|
event = {
|
||||||
|
'id': row['id'],
|
||||||
|
'timestamp': row['timestamp'],
|
||||||
|
'type': row['type'],
|
||||||
|
'interface': row['interface'],
|
||||||
|
'details': row['details'],
|
||||||
|
'data': json.loads(row['data']) if row['data'] else {}
|
||||||
|
}
|
||||||
|
events.append(event)
|
||||||
|
|
||||||
|
return events
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting events: {e}", exc_info=True)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_statistics(self) -> Dict:
|
||||||
|
"""Get event statistics"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Total events
|
||||||
|
cursor.execute('SELECT COUNT(*) as count FROM events')
|
||||||
|
total = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# Events today
|
||||||
|
today = datetime.now().strftime('%Y-%m-%d')
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT COUNT(*) as count FROM events WHERE DATE(timestamp) = ?',
|
||||||
|
(today,)
|
||||||
|
)
|
||||||
|
today_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# Events last hour
|
||||||
|
one_hour_ago = (datetime.now() - timedelta(hours=1)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
cursor.execute(
|
||||||
|
'SELECT COUNT(*) as count FROM events WHERE timestamp >= ?',
|
||||||
|
(one_hour_ago,)
|
||||||
|
)
|
||||||
|
hour_count = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
# Events by type
|
||||||
|
cursor.execute('''
|
||||||
|
SELECT type, COUNT(*) as count
|
||||||
|
FROM events
|
||||||
|
GROUP BY type
|
||||||
|
''')
|
||||||
|
by_type = {row['type']: row['count'] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
# Known devices count
|
||||||
|
cursor.execute('SELECT COUNT(*) as count FROM known_devices')
|
||||||
|
known_devices = cursor.fetchone()['count']
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_events': total,
|
||||||
|
'events_today': today_count,
|
||||||
|
'events_last_hour': hour_count,
|
||||||
|
'events_by_type': by_type,
|
||||||
|
'known_devices': known_devices
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting statistics: {e}", exc_info=True)
|
||||||
|
return {
|
||||||
|
'total_events': 0,
|
||||||
|
'events_today': 0,
|
||||||
|
'events_last_hour': 0,
|
||||||
|
'events_by_type': {},
|
||||||
|
'known_devices': 0
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def is_known_device(self, mac: str) -> bool:
|
||||||
|
"""Check if a device is known"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('SELECT mac FROM known_devices WHERE mac = ?', (mac,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
return result is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking known device: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def add_known_device(self, mac: str, name: str = 'Unknown'):
|
||||||
|
"""Add a device to known devices"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute('''
|
||||||
|
INSERT OR REPLACE INTO known_devices (mac, name, last_seen)
|
||||||
|
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||||
|
''', (mac, name))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Device added to known devices: {mac} ({name})")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding known device: {e}", exc_info=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def cleanup_old_events(self, retention_days: int):
|
||||||
|
"""Delete events older than retention period"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cutoff_date = (datetime.now() - timedelta(days=retention_days)).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
cursor.execute('DELETE FROM events WHERE timestamp < ?', (cutoff_date,))
|
||||||
|
deleted = cursor.rowcount
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"Cleaned up {deleted} events older than {retention_days} days")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up old events: {e}", exc_info=True)
|
||||||
|
conn.rollback()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
@ -0,0 +1,308 @@
|
||||||
|
import smtplib
|
||||||
|
import logging
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class EmailHandler:
|
||||||
|
"""Handle email notifications for Watchdog events"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict):
|
||||||
|
self.config = config
|
||||||
|
self.smtp_server = config['smtp_server']
|
||||||
|
self.smtp_port = config['smtp_port']
|
||||||
|
self.smtp_use_tls = config['smtp_use_tls']
|
||||||
|
self.smtp_username = config['smtp_username']
|
||||||
|
self.smtp_password = config['smtp_password']
|
||||||
|
self.from_address = config['from_address']
|
||||||
|
self.to_addresses = config['to_addresses']
|
||||||
|
|
||||||
|
logger.info(f"EmailHandler initialized for {self.smtp_server}:{self.smtp_port}")
|
||||||
|
|
||||||
|
def _send_email(self, subject: str, html_content: str):
|
||||||
|
"""Send an email"""
|
||||||
|
try:
|
||||||
|
# Create message
|
||||||
|
msg = MIMEMultipart('alternative')
|
||||||
|
msg['Subject'] = subject
|
||||||
|
msg['From'] = self.from_address
|
||||||
|
msg['To'] = ', '.join(self.to_addresses)
|
||||||
|
|
||||||
|
# Add HTML content
|
||||||
|
html_part = MIMEText(html_content, 'html')
|
||||||
|
msg.attach(html_part)
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
with smtplib.SMTP(self.smtp_server, self.smtp_port) as server:
|
||||||
|
if self.smtp_use_tls:
|
||||||
|
server.starttls()
|
||||||
|
|
||||||
|
server.login(self.smtp_username, self.smtp_password)
|
||||||
|
server.send_message(msg)
|
||||||
|
|
||||||
|
logger.info(f"Email sent: {subject}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send email: {e}", exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_event_notification(self, event: Dict):
|
||||||
|
"""Send notification for a single event"""
|
||||||
|
subject = self._format_subject(event)
|
||||||
|
html_content = self._format_event_email(event)
|
||||||
|
|
||||||
|
self._send_email(subject, html_content)
|
||||||
|
|
||||||
|
def send_startup_notification(self):
|
||||||
|
"""Send notification when Watchdog starts"""
|
||||||
|
subject = "🟢 Watchdog Docker gestartet"
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
background-color: #28a745;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🟢 Watchdog Docker</h1>
|
||||||
|
<p>Monitoring gestartet</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p><strong>Status:</strong> Aktiv</p>
|
||||||
|
<p><strong>Zeitpunkt:</strong> {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}</p>
|
||||||
|
<p><strong>Version:</strong> 0.1</p>
|
||||||
|
<hr>
|
||||||
|
<p>OPNsense Monitoring ist aktiv und überwacht folgende Events:</p>
|
||||||
|
<ul>
|
||||||
|
<li>DHCP Leases</li>
|
||||||
|
<li>Neue Geräte (ARP)</li>
|
||||||
|
<li>Interface Status</li>
|
||||||
|
<li>Gateway Status</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Watchdog Docker v0.1 - Automatische Benachrichtigung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
self._send_email(subject, html_content)
|
||||||
|
|
||||||
|
def _format_subject(self, event: Dict) -> str:
|
||||||
|
"""Format email subject based on event type"""
|
||||||
|
event_type = event.get('type', 'unknown')
|
||||||
|
|
||||||
|
prefixes = {
|
||||||
|
'dhcp_lease': '🔵 Neue DHCP Lease',
|
||||||
|
'new_device': '🔴 Neues Gerät erkannt' if not event.get('known') else '🟡 Bekanntes Gerät',
|
||||||
|
'interface_status': '⚠️ Interface Status',
|
||||||
|
'gateway_status': '⚠️ Gateway Status'
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix = prefixes.get(event_type, '📢 Event')
|
||||||
|
interface = event.get('interface', '')
|
||||||
|
|
||||||
|
if interface:
|
||||||
|
return f"{prefix} - {interface}"
|
||||||
|
else:
|
||||||
|
return prefix
|
||||||
|
|
||||||
|
def _format_event_email(self, event: Dict) -> str:
|
||||||
|
"""Format event as HTML email"""
|
||||||
|
event_type = event.get('type', 'unknown')
|
||||||
|
timestamp = datetime.now().strftime('%d.%m.%Y %H:%M:%S')
|
||||||
|
|
||||||
|
# Event type specific colors
|
||||||
|
colors = {
|
||||||
|
'dhcp_lease': '#0d6efd',
|
||||||
|
'new_device': '#dc3545',
|
||||||
|
'interface_status': '#ffc107',
|
||||||
|
'gateway_status': '#fd7e14'
|
||||||
|
}
|
||||||
|
color = colors.get(event_type, '#6c757d')
|
||||||
|
|
||||||
|
# Build event details HTML
|
||||||
|
details_html = self._build_event_details_html(event)
|
||||||
|
|
||||||
|
html_content = f"""
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
background-color: {color};
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.content {{
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}}
|
||||||
|
.detail-row {{
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
}}
|
||||||
|
.detail-label {{
|
||||||
|
font-weight: bold;
|
||||||
|
display: inline-block;
|
||||||
|
width: 150px;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>{self._format_subject(event)}</h1>
|
||||||
|
<p>{event.get('details', 'Event detected')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Zeitpunkt:</span>
|
||||||
|
<span>{timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Event-Typ:</span>
|
||||||
|
<span>{event_type.upper()}</span>
|
||||||
|
</div>
|
||||||
|
{details_html}
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
<p>Watchdog Docker v0.1 - Automatische Benachrichtigung</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return html_content
|
||||||
|
|
||||||
|
def _build_event_details_html(self, event: Dict) -> str:
|
||||||
|
"""Build event-specific details HTML"""
|
||||||
|
event_type = event.get('type', 'unknown')
|
||||||
|
html = ""
|
||||||
|
|
||||||
|
# Common fields
|
||||||
|
if event.get('interface'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Interface:</span>
|
||||||
|
<span>{event['interface']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Type-specific fields
|
||||||
|
if event_type in ['dhcp_lease', 'new_device']:
|
||||||
|
if event.get('ip'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">IP-Adresse:</span>
|
||||||
|
<span>{event['ip']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
if event.get('mac'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">MAC-Adresse:</span>
|
||||||
|
<span>{event['mac']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
if event.get('hostname'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Hostname:</span>
|
||||||
|
<span>{event['hostname']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if event_type == 'new_device':
|
||||||
|
known = event.get('known', False)
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Bekannt:</span>
|
||||||
|
<span style="color: {'green' if known else 'red'};">
|
||||||
|
{'✓ Ja' if known else '✗ Nein (Erstes Mal gesehen!)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if event_type in ['interface_status', 'gateway_status']:
|
||||||
|
if event.get('old_status'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Vorheriger Status:</span>
|
||||||
|
<span>{event['old_status']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
if event.get('new_status'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Neuer Status:</span>
|
||||||
|
<span>{event['new_status']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if event_type == 'gateway_status' and event.get('gateway'):
|
||||||
|
html += f"""
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Gateway:</span>
|
||||||
|
<span>{event['gateway']}</span>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
from flask import Flask, render_template, redirect, url_for, request, flash, jsonify
|
||||||
|
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
|
||||||
|
from werkzeug.security import check_password_hash
|
||||||
|
import yaml
|
||||||
|
import logging
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from database import Database
|
||||||
|
from monitor import OPNsenseMonitor
|
||||||
|
from email_handler import EmailHandler
|
||||||
|
|
||||||
|
# Load configuration
|
||||||
|
with open('config/config.yaml', 'r') as f:
|
||||||
|
config = yaml.safe_load(f)
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, config['logging']['level']),
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(config['logging']['file']),
|
||||||
|
logging.StreamHandler()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Initialize Flask
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = config['web']['secret_key']
|
||||||
|
|
||||||
|
# Initialize Flask-Login
|
||||||
|
login_manager = LoginManager()
|
||||||
|
login_manager.init_app(app)
|
||||||
|
login_manager.login_view = 'login'
|
||||||
|
|
||||||
|
# Initialize components
|
||||||
|
db = Database(config['database']['path'])
|
||||||
|
email_handler = EmailHandler(config['email']) if config['email']['enabled'] else None
|
||||||
|
monitor = OPNsenseMonitor(config, db, email_handler)
|
||||||
|
|
||||||
|
# Simple User class
|
||||||
|
class User(UserMixin):
|
||||||
|
def __init__(self, id):
|
||||||
|
self.id = id
|
||||||
|
|
||||||
|
@login_manager.user_loader
|
||||||
|
def load_user(user_id):
|
||||||
|
return User(user_id)
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
@app.route('/login', methods=['GET', 'POST'])
|
||||||
|
def login():
|
||||||
|
if current_user.is_authenticated:
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
username = request.form.get('username')
|
||||||
|
password = request.form.get('password')
|
||||||
|
|
||||||
|
# Simple authentication (username: admin)
|
||||||
|
if username == 'admin' and check_password_hash(
|
||||||
|
config['web']['admin_password_hash'], password
|
||||||
|
):
|
||||||
|
user = User('admin')
|
||||||
|
login_user(user)
|
||||||
|
logger.info(f"User {username} logged in successfully")
|
||||||
|
return redirect(url_for('dashboard'))
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed login attempt for user: {username}")
|
||||||
|
flash('Invalid username or password', 'danger')
|
||||||
|
|
||||||
|
return render_template('login.html')
|
||||||
|
|
||||||
|
@app.route('/logout')
|
||||||
|
@login_required
|
||||||
|
def logout():
|
||||||
|
logger.info(f"User {current_user.id} logged out")
|
||||||
|
logout_user()
|
||||||
|
return redirect(url_for('login'))
|
||||||
|
|
||||||
|
@app.route('/dashboard')
|
||||||
|
@login_required
|
||||||
|
def dashboard():
|
||||||
|
# Get recent events
|
||||||
|
events = db.get_recent_events(limit=100)
|
||||||
|
stats = db.get_statistics()
|
||||||
|
|
||||||
|
return render_template('dashboard.html',
|
||||||
|
events=events,
|
||||||
|
stats=stats,
|
||||||
|
config=config)
|
||||||
|
|
||||||
|
@app.route('/api/events')
|
||||||
|
@login_required
|
||||||
|
def api_events():
|
||||||
|
"""API endpoint for real-time event updates"""
|
||||||
|
limit = request.args.get('limit', 50, type=int)
|
||||||
|
event_type = request.args.get('type', None)
|
||||||
|
interface = request.args.get('interface', None)
|
||||||
|
|
||||||
|
events = db.get_recent_events(limit=limit, event_type=event_type, interface=interface)
|
||||||
|
return jsonify(events)
|
||||||
|
|
||||||
|
@app.route('/api/stats')
|
||||||
|
@login_required
|
||||||
|
def api_stats():
|
||||||
|
"""API endpoint for statistics"""
|
||||||
|
return jsonify(db.get_statistics())
|
||||||
|
|
||||||
|
@app.route('/health')
|
||||||
|
def health():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return jsonify({
|
||||||
|
'status': 'healthy',
|
||||||
|
'timestamp': datetime.now().isoformat(),
|
||||||
|
'version': '0.1'
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize scheduler
|
||||||
|
def start_monitoring():
|
||||||
|
scheduler = BackgroundScheduler()
|
||||||
|
interval = config['monitoring']['polling_interval']
|
||||||
|
|
||||||
|
scheduler.add_job(
|
||||||
|
func=monitor.check_all,
|
||||||
|
trigger="interval",
|
||||||
|
seconds=interval,
|
||||||
|
id='opnsense_monitor',
|
||||||
|
name='OPNsense Monitor',
|
||||||
|
replace_existing=True
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler.start()
|
||||||
|
logger.info(f"Monitoring started with {interval}s interval")
|
||||||
|
|
||||||
|
# Send startup notification
|
||||||
|
if email_handler and config['email']['send_on_startup']:
|
||||||
|
email_handler.send_startup_notification()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
logger.info("Starting Watchdog Docker v0.1")
|
||||||
|
|
||||||
|
# Initialize database
|
||||||
|
db.initialize()
|
||||||
|
|
||||||
|
# Start monitoring
|
||||||
|
start_monitoring()
|
||||||
|
|
||||||
|
# Run Flask
|
||||||
|
app.run(
|
||||||
|
host=config['web']['host'],
|
||||||
|
port=config['web']['port'],
|
||||||
|
debug=False
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from opnsense_api import OPNsenseAPI
|
||||||
|
from database import Database
|
||||||
|
from email_handler import EmailHandler
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class OPNsenseMonitor:
|
||||||
|
"""Monitor OPNsense events"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict, db: Database, email_handler: Optional[EmailHandler]):
|
||||||
|
self.config = config
|
||||||
|
self.db = db
|
||||||
|
self.email_handler = email_handler
|
||||||
|
|
||||||
|
# Initialize API client
|
||||||
|
opn_config = config['opnsense']
|
||||||
|
self.api = OPNsenseAPI(
|
||||||
|
host=opn_config['host'],
|
||||||
|
api_key=opn_config['api_key'],
|
||||||
|
api_secret=opn_config['api_secret'],
|
||||||
|
verify_ssl=opn_config['verify_ssl']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Previous states
|
||||||
|
self.previous_leases = {}
|
||||||
|
self.previous_devices = {}
|
||||||
|
self.previous_interfaces = {}
|
||||||
|
self.previous_gateways = {}
|
||||||
|
|
||||||
|
logger.info("OPNsense Monitor initialized")
|
||||||
|
|
||||||
|
def check_all(self):
|
||||||
|
"""Check all monitored events"""
|
||||||
|
logger.debug("Starting monitoring cycle")
|
||||||
|
|
||||||
|
events_config = self.config['monitoring']['events']
|
||||||
|
|
||||||
|
try:
|
||||||
|
if events_config.get('dhcp_leases'):
|
||||||
|
self.check_dhcp_leases()
|
||||||
|
|
||||||
|
if events_config.get('new_devices'):
|
||||||
|
self.check_new_devices()
|
||||||
|
|
||||||
|
if events_config.get('interface_status'):
|
||||||
|
self.check_interface_status()
|
||||||
|
|
||||||
|
if events_config.get('gateway_status'):
|
||||||
|
self.check_gateway_status()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in monitoring cycle: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def check_dhcp_leases(self):
|
||||||
|
"""Check for new DHCP leases"""
|
||||||
|
leases = self.api.get_dhcp_leases()
|
||||||
|
if not leases:
|
||||||
|
return
|
||||||
|
|
||||||
|
monitored_interfaces = self.config['monitoring'].get('monitored_interfaces', [])
|
||||||
|
|
||||||
|
for lease in leases:
|
||||||
|
lease_id = lease.get('address', '') + lease.get('mac', '')
|
||||||
|
interface = lease.get('if', '')
|
||||||
|
|
||||||
|
# Filter by interface if specified
|
||||||
|
if monitored_interfaces and interface not in monitored_interfaces:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if lease_id not in self.previous_leases:
|
||||||
|
# New lease detected
|
||||||
|
event = {
|
||||||
|
'type': 'dhcp_lease',
|
||||||
|
'interface': interface,
|
||||||
|
'ip': lease.get('address'),
|
||||||
|
'mac': lease.get('mac'),
|
||||||
|
'hostname': lease.get('hostname', 'Unknown'),
|
||||||
|
'details': f"New DHCP lease: {lease.get('address')} ({lease.get('hostname', 'Unknown')})"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.add_event(event)
|
||||||
|
logger.info(f"New DHCP lease: {event['details']}")
|
||||||
|
|
||||||
|
if self.email_handler:
|
||||||
|
self.email_handler.send_event_notification(event)
|
||||||
|
|
||||||
|
# Update previous state
|
||||||
|
self.previous_leases = {lease.get('address', '') + lease.get('mac', ''): lease for lease in leases}
|
||||||
|
|
||||||
|
def check_new_devices(self):
|
||||||
|
"""Check for new devices via ARP table"""
|
||||||
|
arp_entries = self.api.get_arp_table()
|
||||||
|
if not arp_entries:
|
||||||
|
return
|
||||||
|
|
||||||
|
monitored_interfaces = self.config['monitoring'].get('monitored_interfaces', [])
|
||||||
|
|
||||||
|
for entry in arp_entries:
|
||||||
|
mac = entry.get('mac', '')
|
||||||
|
interface = entry.get('intf', '')
|
||||||
|
|
||||||
|
# Filter by interface
|
||||||
|
if monitored_interfaces and interface not in monitored_interfaces:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if mac and mac not in self.previous_devices:
|
||||||
|
# Check if device is in known devices DB
|
||||||
|
is_known = self.db.is_known_device(mac)
|
||||||
|
|
||||||
|
event = {
|
||||||
|
'type': 'new_device',
|
||||||
|
'interface': interface,
|
||||||
|
'ip': entry.get('ip'),
|
||||||
|
'mac': mac,
|
||||||
|
'hostname': entry.get('hostname', 'Unknown'),
|
||||||
|
'known': is_known,
|
||||||
|
'details': f"{'Known' if is_known else 'Unknown'} device detected: {mac} ({entry.get('hostname', 'Unknown')})"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.add_event(event)
|
||||||
|
logger.info(f"New device: {event['details']}")
|
||||||
|
|
||||||
|
if self.email_handler and not is_known:
|
||||||
|
# Only send email for unknown devices
|
||||||
|
self.email_handler.send_event_notification(event)
|
||||||
|
|
||||||
|
# Update previous state
|
||||||
|
self.previous_devices = {entry.get('mac', ''): entry for entry in arp_entries}
|
||||||
|
|
||||||
|
def check_interface_status(self):
|
||||||
|
"""Check interface status changes"""
|
||||||
|
interfaces = self.api.get_interfaces()
|
||||||
|
if not interfaces:
|
||||||
|
return
|
||||||
|
|
||||||
|
monitored_interfaces = self.config['monitoring'].get('monitored_interfaces', [])
|
||||||
|
|
||||||
|
for if_name, if_data in interfaces.items():
|
||||||
|
# Filter by interface
|
||||||
|
if monitored_interfaces and if_name not in monitored_interfaces:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_status = if_data.get('status', 'unknown')
|
||||||
|
|
||||||
|
if if_name in self.previous_interfaces:
|
||||||
|
previous_status = self.previous_interfaces[if_name].get('status', 'unknown')
|
||||||
|
|
||||||
|
if current_status != previous_status:
|
||||||
|
event = {
|
||||||
|
'type': 'interface_status',
|
||||||
|
'interface': if_name,
|
||||||
|
'old_status': previous_status,
|
||||||
|
'new_status': current_status,
|
||||||
|
'details': f"Interface {if_name} changed: {previous_status} → {current_status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.add_event(event)
|
||||||
|
logger.warning(f"Interface status change: {event['details']}")
|
||||||
|
|
||||||
|
if self.email_handler:
|
||||||
|
self.email_handler.send_event_notification(event)
|
||||||
|
|
||||||
|
# Update previous state
|
||||||
|
self.previous_interfaces = interfaces
|
||||||
|
|
||||||
|
def check_gateway_status(self):
|
||||||
|
"""Check gateway status changes"""
|
||||||
|
gateways = self.api.get_gateways()
|
||||||
|
if not gateways:
|
||||||
|
return
|
||||||
|
|
||||||
|
for gw_name, gw_data in gateways.items():
|
||||||
|
current_status = gw_data.get('status', 'unknown')
|
||||||
|
|
||||||
|
if gw_name in self.previous_gateways:
|
||||||
|
previous_status = self.previous_gateways[gw_name].get('status', 'unknown')
|
||||||
|
|
||||||
|
if current_status != previous_status:
|
||||||
|
event = {
|
||||||
|
'type': 'gateway_status',
|
||||||
|
'gateway': gw_name,
|
||||||
|
'old_status': previous_status,
|
||||||
|
'new_status': current_status,
|
||||||
|
'details': f"Gateway {gw_name} changed: {previous_status} → {current_status}"
|
||||||
|
}
|
||||||
|
|
||||||
|
self.db.add_event(event)
|
||||||
|
logger.warning(f"Gateway status change: {event['details']}")
|
||||||
|
|
||||||
|
if self.email_handler:
|
||||||
|
self.email_handler.send_event_notification(event)
|
||||||
|
|
||||||
|
# Update previous state
|
||||||
|
self.previous_gateways = gateways
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
# Disable SSL warnings (only if verify_ssl is false)
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class OPNsenseAPI:
|
||||||
|
"""Client for OPNsense API"""
|
||||||
|
|
||||||
|
def __init__(self, host: str, api_key: str, api_secret: str, verify_ssl: bool = True):
|
||||||
|
self.host = host.rstrip('/')
|
||||||
|
self.api_key = api_key
|
||||||
|
self.api_secret = api_secret
|
||||||
|
self.verify_ssl = verify_ssl
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.auth = (api_key, api_secret)
|
||||||
|
self.session.verify = verify_ssl
|
||||||
|
|
||||||
|
logger.info(f"OPNsense API client initialized for {self.host}")
|
||||||
|
|
||||||
|
def _request(self, method: str, endpoint: str, **kwargs) -> Optional[Dict]:
|
||||||
|
"""Make API request"""
|
||||||
|
url = f"{self.host}/api/{endpoint}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.session.request(method, url, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.error(f"API request failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_dhcp_leases(self) -> Optional[List[Dict]]:
|
||||||
|
"""Get current DHCP leases"""
|
||||||
|
data = self._request('GET', 'dhcpv4/leases/searchLease')
|
||||||
|
if data and 'rows' in data:
|
||||||
|
return data['rows']
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_interfaces(self) -> Optional[Dict]:
|
||||||
|
"""Get interface status"""
|
||||||
|
return self._request('GET', 'diagnostics/interface/getInterfaceConfig')
|
||||||
|
|
||||||
|
def get_interface_statistics(self) -> Optional[Dict]:
|
||||||
|
"""Get interface statistics"""
|
||||||
|
return self._request('GET', 'diagnostics/traffic/interface')
|
||||||
|
|
||||||
|
def get_gateways(self) -> Optional[Dict]:
|
||||||
|
"""Get gateway status"""
|
||||||
|
return self._request('GET', 'routes/gateway/status')
|
||||||
|
|
||||||
|
def get_arp_table(self) -> Optional[List[Dict]]:
|
||||||
|
"""Get ARP table for device detection"""
|
||||||
|
data = self._request('GET', 'diagnostics/interface/search_arp')
|
||||||
|
if data and 'rows' in data:
|
||||||
|
return data['rows']
|
||||||
|
return []
|
||||||
|
|
||||||
|
def test_connection(self) -> bool:
|
||||||
|
"""Test API connection"""
|
||||||
|
try:
|
||||||
|
result = self._request('GET', 'core/firmware/status')
|
||||||
|
return result is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection test failed: {e}")
|
||||||
|
return False
|
||||||
|
|
@ -0,0 +1,363 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Dashboard - Watchdog Docker</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
}
|
||||||
|
.navbar {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.stats-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.stats-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.stats-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.events-table-card {
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.badge-dhcp_lease {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
}
|
||||||
|
.badge-new_device {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
.badge-interface_status {
|
||||||
|
background-color: #ffc107;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.badge-gateway_status {
|
||||||
|
background-color: #fd7e14;
|
||||||
|
}
|
||||||
|
.table-hover tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.filter-section {
|
||||||
|
background-color: white;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.last-updated {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navbar -->
|
||||||
|
<nav class="navbar navbar-dark mb-4">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">
|
||||||
|
<i class="bi bi-shield-check"></i> Watchdog Docker
|
||||||
|
</span>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="text-white me-3">
|
||||||
|
<i class="bi bi-person-circle"></i> {{ current_user.id }}
|
||||||
|
</span>
|
||||||
|
<a href="{{ url_for('logout') }}" class="btn btn-outline-light btn-sm">
|
||||||
|
<i class="bi bi-box-arrow-right"></i> Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Statistics Cards -->
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">Gesamt Events</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.total_events }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stats-icon text-primary">
|
||||||
|
<i class="bi bi-database"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">Heute</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.events_today }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stats-icon text-success">
|
||||||
|
<i class="bi bi-calendar-check"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">Letzte Stunde</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.events_last_hour }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stats-icon text-warning">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 mb-3">
|
||||||
|
<div class="card stats-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h6 class="text-muted mb-1">Bekannte Geräte</h6>
|
||||||
|
<h2 class="mb-0">{{ stats.known_devices }}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="stats-icon text-info">
|
||||||
|
<i class="bi bi-router"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="filterType" class="form-label mb-1">Event-Typ</label>
|
||||||
|
<select class="form-select form-select-sm" id="filterType">
|
||||||
|
<option value="">Alle Typen</option>
|
||||||
|
<option value="dhcp_lease">DHCP Lease</option>
|
||||||
|
<option value="new_device">Neues Gerät</option>
|
||||||
|
<option value="interface_status">Interface Status</option>
|
||||||
|
<option value="gateway_status">Gateway Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label for="filterInterface" class="form-label mb-1">Interface</label>
|
||||||
|
<select class="form-select form-select-sm" id="filterInterface">
|
||||||
|
<option value="">Alle Interfaces</option>
|
||||||
|
{% for interface in config.monitoring.monitored_interfaces %}
|
||||||
|
<option value="{{ interface }}">{{ interface }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label mb-1">Auto-Refresh</label>
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" id="autoRefresh" checked>
|
||||||
|
<label class="form-check-label" for="autoRefresh">
|
||||||
|
Alle 10 Sekunden
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 text-end">
|
||||||
|
<label class="form-label mb-1 d-block">Aktualisierung</label>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="refreshEvents()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> Jetzt aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Table -->
|
||||||
|
<div class="card events-table-card">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="bi bi-list-ul"></i> Aktuelle Events
|
||||||
|
</h5>
|
||||||
|
<span class="last-updated" id="lastUpdated">
|
||||||
|
Zuletzt aktualisiert: {{ stats.last_updated if stats.last_updated else 'Nie' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0" id="eventsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 180px;">Zeitpunkt</th>
|
||||||
|
<th style="width: 150px;">Typ</th>
|
||||||
|
<th style="width: 100px;">Interface</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="eventsTableBody">
|
||||||
|
{% for event in events %}
|
||||||
|
<tr data-type="{{ event.type }}" data-interface="{{ event.interface }}">
|
||||||
|
<td>
|
||||||
|
<small>{{ event.timestamp }}</small>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-{{ event.type }}">
|
||||||
|
{{ event.type.replace('_', ' ').upper() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{ event.interface or 'N/A' }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ event.details }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-inbox"></i> Keine Events vorhanden
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let autoRefreshInterval;
|
||||||
|
|
||||||
|
// Filter events
|
||||||
|
function filterEvents() {
|
||||||
|
const typeFilter = document.getElementById('filterType').value;
|
||||||
|
const interfaceFilter = document.getElementById('filterInterface').value;
|
||||||
|
const rows = document.querySelectorAll('#eventsTableBody tr');
|
||||||
|
|
||||||
|
rows.forEach(row => {
|
||||||
|
const type = row.getAttribute('data-type');
|
||||||
|
const iface = row.getAttribute('data-interface');
|
||||||
|
|
||||||
|
const typeMatch = !typeFilter || type === typeFilter;
|
||||||
|
const interfaceMatch = !interfaceFilter || iface === interfaceFilter;
|
||||||
|
|
||||||
|
if (typeMatch && interfaceMatch) {
|
||||||
|
row.style.display = '';
|
||||||
|
} else {
|
||||||
|
row.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh events via AJAX
|
||||||
|
async function refreshEvents() {
|
||||||
|
try {
|
||||||
|
const typeFilter = document.getElementById('filterType').value;
|
||||||
|
const interfaceFilter = document.getElementById('filterInterface').value;
|
||||||
|
|
||||||
|
let url = '/api/events?limit=100';
|
||||||
|
if (typeFilter) url += `&type=${typeFilter}`;
|
||||||
|
if (interfaceFilter) url += `&interface=${interfaceFilter}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const events = await response.json();
|
||||||
|
|
||||||
|
// Update table
|
||||||
|
const tbody = document.getElementById('eventsTableBody');
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-muted py-4">
|
||||||
|
<i class="bi bi-inbox"></i> Keine Events vorhanden
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = events.map(event => `
|
||||||
|
<tr data-type="${event.type}" data-interface="${event.interface || ''}">
|
||||||
|
<td><small>${event.timestamp}</small></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-${event.type}">
|
||||||
|
${event.type.replace('_', ' ').toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">${event.interface || 'N/A'}</span>
|
||||||
|
</td>
|
||||||
|
<td>${event.details}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
const now = new Date();
|
||||||
|
document.getElementById('lastUpdated').textContent =
|
||||||
|
`Zuletzt aktualisiert: ${now.toLocaleString('de-DE')}`;
|
||||||
|
|
||||||
|
// Refresh stats
|
||||||
|
const statsResponse = await fetch('/api/stats');
|
||||||
|
const stats = await statsResponse.json();
|
||||||
|
updateStats(stats);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error refreshing events:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
function updateStats(stats) {
|
||||||
|
const statsElements = document.querySelectorAll('.stats-card h2');
|
||||||
|
if (statsElements.length >= 4) {
|
||||||
|
statsElements[0].textContent = stats.total_events || 0;
|
||||||
|
statsElements[1].textContent = stats.events_today || 0;
|
||||||
|
statsElements[2].textContent = stats.events_last_hour || 0;
|
||||||
|
statsElements[3].textContent = stats.known_devices || 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh toggle
|
||||||
|
function toggleAutoRefresh() {
|
||||||
|
const checkbox = document.getElementById('autoRefresh');
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
// Start auto-refresh every 10 seconds
|
||||||
|
autoRefreshInterval = setInterval(refreshEvents, 10000);
|
||||||
|
} else {
|
||||||
|
// Stop auto-refresh
|
||||||
|
if (autoRefreshInterval) {
|
||||||
|
clearInterval(autoRefreshInterval);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.getElementById('filterType').addEventListener('change', filterEvents);
|
||||||
|
document.getElementById('filterInterface').addEventListener('change', filterEvents);
|
||||||
|
document.getElementById('autoRefresh').addEventListener('change', toggleAutoRefresh);
|
||||||
|
|
||||||
|
// Start auto-refresh on page load
|
||||||
|
toggleAutoRefresh();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - Watchdog Docker</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.login-container {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.login-header h1 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.login-header p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.btn-login {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border: none;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.btn-login:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo">🔐</div>
|
||||||
|
<h1>Watchdog Docker</h1>
|
||||||
|
<p>OPNsense Monitoring System</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('login') }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Benutzername</label>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="admin"
|
||||||
|
required
|
||||||
|
autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="password" class="form-label">Passwort</label>
|
||||||
|
<input type="password"
|
||||||
|
class="form-control"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-login">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<small class="text-muted">Version 0.1</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
# OPNsense Configuration
|
||||||
|
opnsense:
|
||||||
|
host: "https://192.168.1.1" # Your OPNsense IP/Hostname
|
||||||
|
api_key: "YOUR_API_KEY_HERE"
|
||||||
|
api_secret: "YOUR_API_SECRET_HERE"
|
||||||
|
verify_ssl: false # Set to true in production with valid cert
|
||||||
|
|
||||||
|
# Monitoring Configuration
|
||||||
|
monitoring:
|
||||||
|
polling_interval: 60 # Seconds between checks
|
||||||
|
|
||||||
|
# Interfaces to monitor (leave empty to monitor all)
|
||||||
|
monitored_interfaces:
|
||||||
|
- lan
|
||||||
|
- wan
|
||||||
|
# - opt1
|
||||||
|
# - opt2
|
||||||
|
|
||||||
|
# Events to monitor
|
||||||
|
events:
|
||||||
|
dhcp_leases: true # New IP assigned
|
||||||
|
new_devices: true # New device detected
|
||||||
|
interface_status: true # Interface up/down
|
||||||
|
gateway_status: true # Gateway status changes
|
||||||
|
|
||||||
|
# Web Interface Configuration
|
||||||
|
web:
|
||||||
|
host: "0.0.0.0"
|
||||||
|
port: 5000
|
||||||
|
secret_key: "CHANGE_THIS_SECRET_KEY_IN_PRODUCTION" # Change this!
|
||||||
|
|
||||||
|
# Login credentials (username: admin)
|
||||||
|
admin_password_hash: "scrypt:32768:8:1$CHANGEME$hash" # See README for generation
|
||||||
|
|
||||||
|
# Email Notification Configuration
|
||||||
|
email:
|
||||||
|
enabled: true
|
||||||
|
smtp_server: "mail.yourdomain.com"
|
||||||
|
smtp_port: 587
|
||||||
|
smtp_use_tls: true
|
||||||
|
smtp_username: "watchdog@yourdomain.com"
|
||||||
|
smtp_password: "YOUR_SMTP_PASSWORD"
|
||||||
|
|
||||||
|
from_address: "watchdog@yourdomain.com"
|
||||||
|
to_addresses:
|
||||||
|
- "admin@yourdomain.com"
|
||||||
|
- "security@yourdomain.com"
|
||||||
|
|
||||||
|
# Email settings
|
||||||
|
send_on_startup: true
|
||||||
|
batch_notifications: false # Group multiple events in one email
|
||||||
|
batch_interval: 300 # Seconds to wait before sending batch
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
database:
|
||||||
|
path: "/app/data/watchdog.db"
|
||||||
|
retention_days: 90 # Keep events for 90 days
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
logging:
|
||||||
|
level: "INFO" # DEBUG, INFO, WARNING, ERROR
|
||||||
|
file: "/app/data/watchdog.log"
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
watchdog:
|
||||||
|
build: .
|
||||||
|
container_name: watchdog-docker
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./config/config.yaml:/app/config/config.yaml:ro
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=Europe/Berlin
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import requests; requests.get('http://localhost:5000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- watchdog-net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
watchdog-net:
|
||||||
|
driver: bridge
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
Flask==3.0.0
|
||||||
|
Flask-Login==0.6.3
|
||||||
|
APScheduler==3.10.4
|
||||||
|
requests==2.31.0
|
||||||
|
PyYAML==6.0.1
|
||||||
|
Werkzeug==3.0.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
Loading…
Reference in New Issue