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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-04-28 11:21:39 +02:00
parent 32012cd670
commit 69f2ee866a
18 changed files with 5216 additions and 0 deletions
+398
View File
@@ -0,0 +1,398 @@
"""
Flask REST-API und statische Dateien.
"""
import os
import signal
import threading
import time
from datetime import datetime, timezone
from flask import Flask, jsonify, send_from_directory, abort, request as flask_request
import database as db
app = Flask(__name__, static_folder='web', static_url_path='/static')
# Wird vom main.py gesetzt
_monitor = None
def set_monitor(monitor):
global _monitor
_monitor = monitor
# ── Statische Dateien ────────────────────────────────────────────────────────
@app.route('/')
def index():
return send_from_directory('web', 'index.html')
# ── API Endpunkte ────────────────────────────────────────────────────────────
@app.route('/api/status')
def api_status():
"""Aktueller Systemstatus."""
mon_status = _monitor.status() if _monitor else {}
traffic = db.query_current_traffic()
fritz = db.query_fritz_latest()
# Heutige Ausfälle
today_start = int(datetime.now(timezone.utc).replace(hour=0, minute=0, second=0).timestamp())
outages_today = db.query_outages(since_ts=today_start, limit=500)
total_outage_s = sum(o['duration_s'] or 0 for o in outages_today if o['end_ts'])
current_outage = next((o for o in outages_today if not o['end_ts']), None)
devices = db.query_devices()
active_count = sum(1 for d in devices if d['is_active'])
return jsonify({
'internet_up': mon_status.get('internet_up', True),
'bps_up': traffic.get('bps_sent', 0),
'bps_down': traffic.get('bps_recv', 0),
'active_devices': active_count,
'total_devices': len(devices),
'outages_today': len([o for o in outages_today if o['end_ts']]),
'total_outage_seconds_today': total_outage_s,
'current_outage_since': current_outage['start_ts'] if current_outage else None,
'fritz': {
'available': fritz is not None,
'is_connected': fritz['is_connected'] if fritz else None,
'external_ip': fritz['external_ip'] if fritz else None,
'uptime_s': fritz['uptime_s'] if fritz else None,
'max_up_bps': fritz['max_up_bps'] if fritz else None,
'max_down_bps': fritz['max_down_bps'] if fritz else None,
},
})
@app.route('/api/traffic')
def api_traffic():
"""Traffic-Zeitreihe. ?range=1h|6h|24h|7d|30d"""
from flask import request
range_map = {
'1h': (3600, 60),
'6h': (21600, 300),
'24h': (86400, 900),
'7d': (604800, 3600),
'30d': (2592000, 14400),
}
r = request.args.get('range', '1h')
seconds, bucket = range_map.get(r, (3600, 60))
since = int(time.time()) - seconds
rows = db.query_traffic(since_ts=since, bucket_size=bucket)
labels = [_fmt_ts(row['bucket'], r) for row in rows]
upload = [round(row['bps_sent'] / 1000, 2) for row in rows] # kbit/s
download = [round(row['bps_recv'] / 1000, 2) for row in rows]
return jsonify({'labels': labels, 'upload': upload, 'download': download})
@app.route('/api/outages')
def api_outages():
"""Ausfalls-Historie."""
from flask import request
limit = int(request.args.get('limit', 100))
since = request.args.get('since')
since_ts = int(since) if since else None
rows = db.query_outages(since_ts=since_ts, limit=limit)
result = []
for r in rows:
duration = r['duration_s']
result.append({
'id': r['id'],
'start_ts': r['start_ts'],
'end_ts': r['end_ts'],
'duration_s': duration,
'duration_human': _human_duration(duration) if duration else 'Läuft noch',
'start_human': _fmt_dt(r['start_ts']),
'end_human': _fmt_dt(r['end_ts']) if r['end_ts'] else 'Läuft noch',
})
return jsonify(result)
@app.route('/api/devices')
def api_devices():
"""Alle Geräte mit Status und Port-Anzahl."""
devices = db.query_devices()
result = []
for d in devices:
result.append({
'id': d['id'],
'mac': d['mac'],
'ip': d['ip'] or '',
'name': d['fritz_name'] or d['hostname'] or d['ip'] or d['mac'],
'hostname': d['hostname'] or '',
'fritz_name': d['fritz_name'] or '',
'vendor': d['vendor'] or '',
'iface_type': d['iface_type'] or '',
'is_active': bool(d['is_active']),
'first_seen': d['first_seen'],
'last_seen': d['last_seen'],
'last_seen_human': _fmt_dt(d['last_seen']),
'port_count': d['port_count'],
})
return jsonify(result)
@app.route('/api/devices/<int:device_id>/ports')
def api_device_ports(device_id):
"""Offene Ports eines Geräts."""
ports = db.query_device_ports(device_id)
return jsonify(ports)
@app.route('/api/packets')
def api_packets():
"""Live-Paketmitschnitt. ?since=UNIX_TS&limit=200&filter=text"""
from flask import request
since = float(request.args.get('since', time.time() - 30))
limit = int(request.args.get('limit', 200))
filter_text = request.args.get('filter', '').strip() or None
rows = db.query_packets(since_ts=since, limit=limit, filter_text=filter_text)
# Neueste zuletzt (für Append im Frontend)
rows.reverse()
return jsonify(rows)
@app.route('/api/ping_log')
def api_ping_log():
"""Letzter Ping-Log zeigt auch kurze Ausfälle. ?minutes=60"""
from flask import request
minutes = int(request.args.get('minutes', 60))
since = int(time.time()) - minutes * 60
rows = db.query_ping_log(since_ts=since, limit=2000)
stats = db.query_ping_stats(since_ts=since)
return jsonify({'log': rows, 'stats': stats})
_ENV_PATH = '/app/.env'
# Whitelist aller erlaubten .env-Keys (verhindert willkürliche Datei-Injection)
_ALLOWED_KEYS = {
'FRITZ_HOST', 'FRITZ_USER', 'FRITZ_PASSWORD',
'MONITOR_INTERFACE', 'NETWORK_RANGE',
'TRAFFIC_INTERVAL', 'OUTAGE_CHECK_INTERVAL', 'DEVICE_SCAN_INTERVAL',
'PORT_SCAN_INTERVAL', 'FRITZ_POLL_INTERVAL',
'PING_TARGET_INTERVAL', 'DEVICE_PING_INTERVAL', 'PING_TARGETS',
'DB_PATH', 'WEB_HOST', 'WEB_PORT', 'LOG_LEVEL',
'SWITCH_ENABLED', 'SWITCH_HOST', 'SWITCH_PASSWORD',
'SWITCH_GATEWAY_PORT', 'SWITCH_INTERVAL',
'CAPTURE_ENABLED', 'CAPTURE_MAX_ROWS',
'CAPTURE_FILTER_ICMP', 'CAPTURE_FILTER_ARP',
'CAPTURE_FILTER_ZABBIX', 'CAPTURE_FILTER_RUSTDESK', 'CAPTURE_FILTER_EXTRA',
'OUTAGE_THRESHOLD',
# Benachrichtigungen
'NOTIFY_ON_DOWN', 'NOTIFY_ON_UP', 'NOTIFY_COOLDOWN',
'PUSHOVER_ENABLED', 'PUSHOVER_APP_TOKEN', 'PUSHOVER_USER_KEY',
'NCTALK_ENABLED', 'NCTALK_URL', 'NCTALK_USER', 'NCTALK_PASSWORD', 'NCTALK_ROOM',
}
def _read_env_file() -> list[str]:
try:
with open(_ENV_PATH, 'r') as f:
return f.readlines()
except FileNotFoundError:
return []
def _parse_env(lines: list[str]) -> dict:
result = {}
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith('#') and '=' in stripped:
key, _, val = stripped.partition('=')
result[key.strip()] = val.strip()
return result
def _write_env(updates: dict) -> None:
"""Schreibt aktualisierte Werte in .env, behält Kommentare und Struktur."""
lines = _read_env_file()
updated_keys = set()
new_lines = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith('#') and '=' in stripped:
key = stripped.split('=', 1)[0].strip()
if key in updates and key in _ALLOWED_KEYS:
new_lines.append(f'{key}={updates[key]}\n')
updated_keys.add(key)
continue
new_lines.append(line)
# Neue Keys anhängen die noch nicht in der Datei waren
for key, val in updates.items():
if key in _ALLOWED_KEYS and key not in updated_keys:
new_lines.append(f'{key}={val}\n')
with open(_ENV_PATH, 'w') as f:
f.writelines(new_lines)
@app.route('/api/config', methods=['GET'])
def api_config_get():
"""Aktuelle .env-Konfiguration auslesen."""
lines = _read_env_file()
config = _parse_env(lines)
return jsonify({k: v for k, v in config.items() if k in _ALLOWED_KEYS})
@app.route('/api/config', methods=['POST'])
def api_config_post():
"""Konfiguration speichern."""
data = flask_request.get_json(force=True)
if not data or not isinstance(data, dict):
return jsonify({'error': 'Ungültige Daten'}), 400
# Nur erlaubte Keys
updates = {k: str(v) for k, v in data.items() if k in _ALLOWED_KEYS}
if not updates:
return jsonify({'error': 'Keine gültigen Keys'}), 400
_write_env(updates)
return jsonify({'ok': True, 'saved': list(updates.keys())})
@app.route('/api/restart', methods=['POST'])
def api_restart():
"""Container neu starten (Docker restart: unless-stopped)."""
def _do_restart():
time.sleep(0.8)
os.kill(os.getpid(), signal.SIGTERM)
threading.Thread(target=_do_restart, daemon=True).start()
return jsonify({'ok': True, 'message': 'Neustart eingeleitet…'})
@app.route('/api/notify_test', methods=['POST'])
def api_notify_test():
"""Sendet Test-Benachrichtigung über alle aktivierten Kanäle."""
import notify
results = notify.test_notification()
return jsonify(results)
@app.route('/api/ping_targets_live')
def api_ping_targets_live():
""".env PING_TARGETS: Live-Log der letzten N Sekunden."""
from flask import request
seconds = int(request.args.get('seconds', 120))
since = int(time.time()) - seconds
rows = db.query_ping_log(since_ts=since, limit=2000)
# Neueste zuletzt für Live-Append
rows.reverse()
targets = [t.strip() for t in os.getenv('PING_TARGETS', '').split(',') if t.strip()]
stats = db.query_ping_stats(since_ts=int(time.time()) - 300) # letzte 5min
return jsonify({
'targets': targets,
'log': rows,
'stats': {s['target']: s for s in stats},
})
@app.route('/api/device_pings')
def api_device_pings():
"""Alle Netzwerk-Geräte (nicht in PING_TARGETS): letztes Ping-Ergebnis + Statistik."""
from flask import request
minutes = int(request.args.get('minutes', 30))
since = int(time.time()) - minutes * 60
latest = db.query_device_pings_latest()
stats = db.query_device_ping_stats(since_ts=since)
stats_map = {s['ip']: s for s in stats}
result = []
for row in latest:
s = stats_map.get(row['ip'], {})
result.append({
'ip': row['ip'],
'mac': row['mac'],
'success': bool(row['success']),
'latency_ms': row['latency_ms'],
'ts': row['ts'],
'ts_human': _fmt_dt(row['ts']),
'total': s.get('total', 0),
'ok': s.get('ok', 0),
'avg_ms': s.get('avg_ms'),
'min_ms': s.get('min_ms'),
'max_ms': s.get('max_ms'),
})
return jsonify(result)
@app.route('/api/switch')
def api_switch():
"""Switch Port-Status und Ereignisse."""
from flask import request
ports = db.query_switch_latest()
hours = int(request.args.get('hours', 24))
since = int(time.time()) - hours * 3600
events = db.query_switch_events(since_ts=since, limit=200)
return jsonify({
'enabled': bool(os.getenv('SWITCH_ENABLED', 'false').lower() == 'true'),
'host': os.getenv('SWITCH_HOST', ''),
'gateway_port': int(os.getenv('SWITCH_GATEWAY_PORT', '') or 1),
'ports': [dict(p) for p in ports],
'events': [
{**dict(e), 'ts_human': _fmt_dt(e['ts'])}
for e in events
],
})
@app.route('/api/fritz')
def api_fritz():
"""FritzBox-Verlauf, aktueller Status und Geräteinformationen."""
from flask import request
hours = int(request.args.get('hours', 24))
since = int(time.time()) - hours * 3600
history = db.query_fritz_history(since_ts=since, bucket_size=300)
latest = db.query_fritz_latest()
# Live-Infos direkt vom Gerät abrufen
full_info = None
if _monitor and hasattr(_monitor, 'fritz') and _monitor.fritz:
full_info = _monitor.fritz.get_full_info()
return jsonify({
'latest': dict(latest) if latest else None,
'history': history,
'info': full_info,
'host': os.getenv('FRITZ_HOST', ''),
})
# ── Hilfsfunktionen ──────────────────────────────────────────────────────────
def _fmt_ts(ts, range_key):
"""Formatiert Timestamp je nach Zeitbereich."""
dt = datetime.fromtimestamp(ts)
if range_key in ('1h', '6h'):
return dt.strftime('%H:%M')
elif range_key == '24h':
return dt.strftime('%H:%M')
else:
return dt.strftime('%d.%m %H:%M')
def _fmt_dt(ts):
if not ts:
return ''
return datetime.fromtimestamp(ts).strftime('%d.%m.%Y %H:%M:%S')
def _human_duration(seconds):
if not seconds:
return '< 1s'
parts = []
h = seconds // 3600
m = (seconds % 3600) // 60
s = seconds % 60
if h: parts.append(f"{h}h")
if m: parts.append(f"{m}min")
if s or not parts: parts.append(f"{s}s")
return ' '.join(parts)