From 8daf83198321c340d6b08f6b3fd80845e558c42f Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 7 Sep 2026 09:03:56 +0200 Subject: [PATCH] feat(control): add stream-control/app.py --- stream-control/app.py | 123 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 stream-control/app.py diff --git a/stream-control/app.py b/stream-control/app.py new file mode 100644 index 0000000..5dc1203 --- /dev/null +++ b/stream-control/app.py @@ -0,0 +1,123 @@ +"""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/") +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/") +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