pfannkuchen/stream-control/app.py

123 lines
5 KiB
Python

"""Network profile control surface; backend access is restricted to a forced SSH command."""
from __future__ import annotations
import json, os, secrets, subprocess, time
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
app = Flask(__name__)
app.secret_key = os.environ["SESSION_SECRET"]
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True)
PROFILES = {
"strict": {"label": "Strikt", "ch": 0.5, "vpn": 0.5, "color": "#ff667d"},
"standard": {"label": "Standard", "ch": 1.0, "vpn": 1.0, "color": "#a88dff"},
"relaxed": {"label": "Entspannt", "ch": 5.0, "vpn": 5.0, "color": "#32d6a3"},
"open": {"label": "Off-Peak", "ch": 10.0, "vpn": 10.0, "color": "#26c6da"},
}
_CACHE = {"at": 0.0, "data": None}
def rpc(command: str, timeout: int = 25):
p = subprocess.run(
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command],
capture_output=True, text=True, timeout=timeout,
)
raw = p.stdout.strip()
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {"ok": False, "error": (p.stderr or raw or "invalid response")[:300]}
if p.returncode and data.get("ok") is not False:
data = {"ok": False, "error": (p.stderr or raw or f"rpc exit {p.returncode}")[:300]}
return data
def get_state(force=False):
now = time.monotonic()
if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3:
return _CACHE["data"]
try:
data = rpc("state")
except Exception as exc:
data = {"ok": False, "error": str(exc)[:300], "limits": {}, "tc": {}, "services": {}, "policy": {}, "ipsets": {}}
_CACHE.update(at=now, data=data)
return data
def csrf_token():
token = session.get("csrf")
if not token:
token = session["csrf"] = secrets.token_urlsafe(32)
return token
app.jinja_env.globals["csrf_token"] = csrf_token
@app.before_request
def protect_post():
if request.method == "POST" and not secrets.compare_digest(request.form.get("csrf", ""), session.get("csrf", "invalid")):
return "invalid request token", 400
@app.get("/")
def dashboard():
state = get_state()
return render_template("dashboard.html", state=state, profiles=PROFILES)
@app.post("/profile/<name>")
def apply_profile(name):
profile = PROFILES.get(name)
if not profile:
flash("Unbekanntes Profil", "error")
return redirect(url_for("dashboard"))
result = rpc(f"set-limits {profile['ch']} {profile['vpn']}")
_CACHE["data"] = None
flash(f"Profil {profile['label']} aktiviert" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error")
return redirect(url_for("dashboard"))
@app.post("/limits")
def custom_limits():
try:
ch = float(request.form["ch"]); vpn = float(request.form["vpn"])
if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100:
raise ValueError
except (KeyError, ValueError):
flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error")
return redirect(url_for("dashboard"))
result = rpc(f"set-limits {ch:g} {vpn:g}")
_CACHE["data"] = None
flash("Benutzerdefinierte Limits gesetzt" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error")
return redirect(url_for("dashboard"))
@app.post("/policy")
def policy():
mode = request.form.get("mode", "")
try:
min_sessions = int(request.form.get("min_sessions", "2"))
block_minutes = int(request.form.get("block_minutes", "10"))
if mode not in {"off", "monitor", "block"} or not 2 <= min_sessions <= 8 or not 1 <= block_minutes <= 1440:
raise ValueError
except ValueError:
flash("Ungültige Policy-Werte", "error")
return redirect(url_for("dashboard"))
result = rpc(f"set-policy {mode} {min_sessions} {block_minutes}")
_CACHE["data"] = None
flash("Session-Policy aktualisiert" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error")
return redirect(url_for("dashboard"))
@app.post("/maintenance/<action>")
def maintenance(action):
mapping = {
"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop",
"worker": "restart worker", "admin": "restart admin",
}
command = mapping.get(action)
if not command:
return "unknown action", 404
result = rpc(command)
_CACHE["data"] = None
flash(f"Aktion {action} gestartet" if result.get("ok") else f"Fehler: {result.get('error','unbekannt')}", "success" if result.get("ok") else "error")
return redirect(url_for("dashboard"))
@app.get("/api/status")
def api_status():
return jsonify(get_state())
@app.get("/health")
def health():
state = get_state()
return jsonify({"ok": bool(state.get("ok")), "backend": "reachable" if state else "unreachable"}), 200 if state.get("ok") else 503