""" 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//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)