Files
NetMon/src/api.py
T
2026-04-28 11:21:39 +02:00

399 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)