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:
Schulz 2026-02-16 21:00:32 +01:00
commit a4b71f3631
15 changed files with 2141 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -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

134
CLAUDE.md Normal file
View File

@ -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)

30
Dockerfile Normal file
View File

@ -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"]

382
README.md Normal file
View File

@ -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
![Email Notification](docs/email-example.png)
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

253
app/database.py Normal file
View File

@ -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()

308
app/email_handler.py Normal file
View File

@ -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

161
app/main.py Normal file
View File

@ -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
)

197
app/monitor.py Normal file
View File

@ -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

70
app/opnsense_api.py Normal file
View File

@ -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

View File

@ -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>

116
app/templates/login.html Normal file
View File

@ -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>

62
config/config.yaml Normal file
View File

@ -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
data/.gitkeep Normal file
View File

27
docker-compose.yml Normal file
View File

@ -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

7
requirements.txt Normal file
View File

@ -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