Compare commits
No commits in common. "2d36b64a587b57948d07c2c55c47e66addc3255e" and "cae78557888e2c6efebe02b2af19a2c35642aa69" have entirely different histories.
2d36b64a58
...
cae7855788
6 changed files with 43 additions and 231 deletions
|
|
@ -33,60 +33,15 @@ def rate_map():
|
||||||
found[target] = value
|
found[target] = value
|
||||||
return found, rc
|
return found, rc
|
||||||
|
|
||||||
|
|
||||||
def tc_metrics():
|
|
||||||
rc, out, _ = run(["sudo", "tc", "-s", "class", "show", "dev", DEV])
|
|
||||||
result = {}
|
|
||||||
current = None
|
|
||||||
for line in out.splitlines():
|
|
||||||
m = re.search(r"^class htb (1:\w+).*?\brate\s+([0-9.]+)([KMG])bit", line, re.I)
|
|
||||||
if m:
|
|
||||||
current = next((key for key, value in CLASSES.items() if value == m.group(1)), m.group(1))
|
|
||||||
rate = float(m.group(2)) * {"K": .001, "M": 1, "G": 1000}[m.group(3).upper()]
|
|
||||||
result[current] = {"rate_mbit": rate, "bytes": 0, "packets": 0, "drops": 0}
|
|
||||||
continue
|
|
||||||
if current:
|
|
||||||
sent = re.search(r"Sent\s+(\d+)\s+bytes\s+(\d+)\s+pkt.*?dropped\s+(\d+)", line)
|
|
||||||
if sent:
|
|
||||||
result[current].update(bytes=int(sent.group(1)), packets=int(sent.group(2)), drops=int(sent.group(3)))
|
|
||||||
current = None
|
|
||||||
return result, rc
|
|
||||||
|
|
||||||
|
|
||||||
def host_metrics():
|
|
||||||
up = int(float(open("/proc/uptime").read().split()[0]))
|
|
||||||
load = [round(value, 2) for value in os.getloadavg()]
|
|
||||||
mem = {}
|
|
||||||
for line in open("/proc/meminfo"):
|
|
||||||
key, value = line.split(":", 1)
|
|
||||||
mem[key] = int(value.strip().split()[0])
|
|
||||||
disk = os.statvfs("/")
|
|
||||||
return {
|
|
||||||
"uptime_seconds": up,
|
|
||||||
"load": load,
|
|
||||||
"memory_total": mem.get("MemTotal", 0) * 1024,
|
|
||||||
"memory_used": (mem.get("MemTotal", 0) - mem.get("MemAvailable", 0)) * 1024,
|
|
||||||
"disk_total": disk.f_blocks * disk.f_frsize,
|
|
||||||
"disk_used": (disk.f_blocks - disk.f_bavail) * disk.f_frsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
def state():
|
def state():
|
||||||
c = db()
|
c = db()
|
||||||
limits = {r["target"]: r["limit_mbit"] for r in c.execute("SELECT target,limit_mbit FROM global_limits")}
|
limits = {r["target"]: r["limit_mbit"] for r in c.execute("SELECT target,limit_mbit FROM global_limits")}
|
||||||
settings = {r["key"]: r["value"] for r in c.execute("SELECT key,value FROM settings")}
|
settings = {r["key"]: r["value"] for r in c.execute("SELECT key,value FROM settings")}
|
||||||
active_sessions = c.execute("SELECT count(*) FROM session_history WHERE active=1").fetchone()[0]
|
active_sessions = c.execute("SELECT count(*) FROM session_history WHERE active=1").fetchone()[0]
|
||||||
session_rows = [dict(r) for r in c.execute("SELECT user_name,device,client,ip,title,series,started_at,last_seen,country,city,asn,bytes FROM session_history WHERE active=1 ORDER BY last_seen DESC LIMIT 20")]
|
|
||||||
recent = [dict(r) for r in c.execute("SELECT ts,user_name,kind,detail FROM multisession_events ORDER BY id DESC LIMIT 8")]
|
recent = [dict(r) for r in c.execute("SELECT ts,user_name,kind,detail FROM multisession_events ORDER BY id DESC LIMIT 8")]
|
||||||
active_blocks = c.execute("SELECT count(*) FROM multisession_blocks WHERE active=1 AND expires_at>strftime('%s','now')").fetchone()[0]
|
active_blocks = c.execute("SELECT count(*) FROM multisession_blocks WHERE active=1 AND expires_at>strftime('%s','now')").fetchone()[0]
|
||||||
totals = {
|
|
||||||
"sessions": c.execute("SELECT count(*) FROM session_history").fetchone()[0],
|
|
||||||
"known_ips": c.execute("SELECT count(*) FROM ip_history").fetchone()[0],
|
|
||||||
"policy_events": c.execute("SELECT count(*) FROM multisession_events").fetchone()[0],
|
|
||||||
"blocks": c.execute("SELECT count(*) FROM multisession_blocks").fetchone()[0],
|
|
||||||
}
|
|
||||||
c.close()
|
c.close()
|
||||||
tc_detail, tc_rc = tc_metrics()
|
tc, tc_rc = rate_map()
|
||||||
tc = {key: value["rate_mbit"] for key, value in tc_detail.items()}
|
|
||||||
ipsets = {}
|
ipsets = {}
|
||||||
for name in ("geo-a-v4", "geo-a-v6", "cloud-v4", "cloud-v6", "persist-v4", "persist-v6"):
|
for name in ("geo-a-v4", "geo-a-v6", "cloud-v4", "cloud-v6", "persist-v4", "persist-v6"):
|
||||||
rc, out, _ = run(["sudo", "ipset", "list", name, "-t"])
|
rc, out, _ = run(["sudo", "ipset", "list", name, "-t"])
|
||||||
|
|
@ -99,9 +54,8 @@ def state():
|
||||||
rc, rules, _ = run(["sudo", "iptables", "-t", "mangle", "-S"])
|
rc, rules, _ = run(["sudo", "iptables", "-t", "mangle", "-S"])
|
||||||
return {
|
return {
|
||||||
"ok": tc_rc == 0 and all(v == "active" for k, v in services.items() if k != "netqos"),
|
"ok": tc_rc == 0 and all(v == "active" for k, v in services.items() if k != "netqos"),
|
||||||
"ts": int(time.time()), "host": host_metrics(), "limits": limits, "tc": tc, "tc_detail": tc_detail, "ipsets": ipsets,
|
"ts": int(time.time()), "limits": limits, "tc": tc, "ipsets": ipsets,
|
||||||
"services": services, "active_sessions": active_sessions, "sessions": session_rows,
|
"services": services, "active_sessions": active_sessions, "active_blocks": active_blocks,
|
||||||
"active_blocks": active_blocks, "totals": totals,
|
|
||||||
"policy": {
|
"policy": {
|
||||||
"enabled": settings.get("multisession_enabled", "0") == "1",
|
"enabled": settings.get("multisession_enabled", "0") == "1",
|
||||||
"mode": settings.get("multisession_mode", "monitor"),
|
"mode": settings.get("multisession_mode", "monitor"),
|
||||||
|
|
@ -113,29 +67,6 @@ def state():
|
||||||
"recent_events": recent,
|
"recent_events": recent,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def history():
|
|
||||||
c = db()
|
|
||||||
data = {
|
|
||||||
"sessions": [dict(r) for r in c.execute(
|
|
||||||
"SELECT user_name,device,client,ip,title,series,started_at,ended_at,last_seen,bytes,country,city,asn,active "
|
|
||||||
"FROM session_history ORDER BY last_seen DESC LIMIT 150"
|
|
||||||
)],
|
|
||||||
"ips": [dict(r) for r in c.execute(
|
|
||||||
"SELECT user_name,ip,device,client,first_seen,last_seen,sightings,limited_sightings,last_classification,country,city,asn "
|
|
||||||
"FROM ip_history ORDER BY last_seen DESC LIMIT 150"
|
|
||||||
)],
|
|
||||||
"events": [dict(r) for r in c.execute(
|
|
||||||
"SELECT ts,user_name,kind,detail FROM multisession_events ORDER BY id DESC LIMIT 150"
|
|
||||||
)],
|
|
||||||
"blocks": [dict(r) for r in c.execute(
|
|
||||||
"SELECT ip,user_name,reason,blocked_at,expires_at,active,manual_allow_until FROM multisession_blocks ORDER BY blocked_at DESC LIMIT 150"
|
|
||||||
)],
|
|
||||||
}
|
|
||||||
c.close()
|
|
||||||
return {"ok": True, "history": data, "generated_at": int(time.time())}
|
|
||||||
|
|
||||||
|
|
||||||
def set_limits(ch, vpn):
|
def set_limits(ch, vpn):
|
||||||
values = {"ch": float(ch), "vpn": float(vpn)}
|
values = {"ch": float(ch), "vpn": float(vpn)}
|
||||||
if any(not 0.1 <= value <= 100 for value in values.values()):
|
if any(not 0.1 <= value <= 100 for value in values.values()):
|
||||||
|
|
@ -202,8 +133,6 @@ def main():
|
||||||
if not argv: argv = ["state"]
|
if not argv: argv = ["state"]
|
||||||
if argv == ["state"]:
|
if argv == ["state"]:
|
||||||
result = state()
|
result = state()
|
||||||
elif argv == ["history"]:
|
|
||||||
result = history()
|
|
||||||
elif len(argv) == 3 and argv[0] == "set-limits":
|
elif len(argv) == 3 and argv[0] == "set-limits":
|
||||||
result = set_limits(argv[1], argv[2])
|
result = set_limits(argv[1], argv[2])
|
||||||
elif argv[0] == "set-policy" and 2 <= len(argv) <= 4:
|
elif argv[0] == "set-policy" and 2 <= len(argv) <= 4:
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,8 @@
|
||||||
"""Network profile control surface; backend access is restricted to a forced SSH command."""
|
"""Network profile control surface; backend access is restricted to a forced SSH command."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json, os, secrets, sqlite3, subprocess, time
|
import json, os, secrets, subprocess, time
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
AUDIT_DB = os.path.join(BASE_DIR, "control-history.db")
|
|
||||||
TZ = ZoneInfo("Europe/Berlin")
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.secret_key = os.environ["SESSION_SECRET"]
|
app.secret_key = os.environ["SESSION_SECRET"]
|
||||||
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True)
|
app.config.update(SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Strict", SESSION_COOKIE_SECURE=True)
|
||||||
|
|
@ -20,42 +15,28 @@ PROFILES = {
|
||||||
}
|
}
|
||||||
_CACHE = {"at": 0.0, "data": None}
|
_CACHE = {"at": 0.0, "data": None}
|
||||||
|
|
||||||
def init_audit():
|
|
||||||
with sqlite3.connect(AUDIT_DB) as db:
|
|
||||||
db.execute("CREATE TABLE IF NOT EXISTS audit(id INTEGER PRIMARY KEY AUTOINCREMENT,ts INTEGER NOT NULL,action TEXT NOT NULL,detail TEXT,ok INTEGER NOT NULL,error TEXT,remote_ip TEXT)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit(ts DESC)")
|
|
||||||
|
|
||||||
def audit(action, detail, result):
|
|
||||||
try:
|
|
||||||
remote = (request.headers.get("X-Forwarded-For") or request.remote_addr or "").split(",")[0].strip()
|
|
||||||
with sqlite3.connect(AUDIT_DB) as db:
|
|
||||||
db.execute("INSERT INTO audit(ts,action,detail,ok,error,remote_ip) VALUES(?,?,?,?,?,?)", (int(time.time()), action, detail[:500], 1 if result.get("ok") else 0, str(result.get("error", ""))[:500], remote[:80]))
|
|
||||||
db.execute("DELETE FROM audit WHERE id NOT IN (SELECT id FROM audit ORDER BY id DESC LIMIT 2000)")
|
|
||||||
except Exception:
|
|
||||||
app.logger.exception("audit write failed")
|
|
||||||
|
|
||||||
init_audit()
|
|
||||||
|
|
||||||
def rpc(command: str, timeout: int = 25):
|
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:
|
try:
|
||||||
p = subprocess.run(["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "edge-maint", command], capture_output=True, text=True, timeout=timeout)
|
data = json.loads(raw) if raw else {}
|
||||||
raw = p.stdout.strip()
|
except json.JSONDecodeError:
|
||||||
try:
|
data = {"ok": False, "error": (p.stderr or raw or "invalid response")[:300]}
|
||||||
data = json.loads(raw) if raw else {}
|
if p.returncode and data.get("ok") is not False:
|
||||||
except json.JSONDecodeError:
|
data = {"ok": False, "error": (p.stderr or raw or f"rpc exit {p.returncode}")[:300]}
|
||||||
data = {"ok": False, "error": (p.stderr or raw or "invalid response")[:300]}
|
return data
|
||||||
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
|
|
||||||
except Exception as exc:
|
|
||||||
return {"ok": False, "error": str(exc)[:300]}
|
|
||||||
|
|
||||||
def get_state(force=False):
|
def get_state(force=False):
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3:
|
if not force and _CACHE["data"] is not None and now - _CACHE["at"] < 3:
|
||||||
return _CACHE["data"]
|
return _CACHE["data"]
|
||||||
data = rpc("state")
|
try:
|
||||||
data.setdefault("limits", {}); data.setdefault("tc", {}); data.setdefault("services", {}); data.setdefault("policy", {}); data.setdefault("ipsets", {})
|
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)
|
_CACHE.update(at=now, data=data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
@ -67,26 +48,6 @@ def csrf_token():
|
||||||
|
|
||||||
app.jinja_env.globals["csrf_token"] = csrf_token
|
app.jinja_env.globals["csrf_token"] = csrf_token
|
||||||
|
|
||||||
@app.template_filter("dt")
|
|
||||||
def fmt_datetime(value):
|
|
||||||
try: return datetime.fromtimestamp(int(value), TZ).strftime("%d.%m.%Y %H:%M:%S")
|
|
||||||
except (TypeError, ValueError, OSError): return "—"
|
|
||||||
|
|
||||||
@app.template_filter("bytes")
|
|
||||||
def fmt_bytes(value):
|
|
||||||
try: number = float(value or 0)
|
|
||||||
except (TypeError, ValueError): return "—"
|
|
||||||
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
||||||
if number < 1024 or unit == "TiB": return f"{number:.1f} {unit}"
|
|
||||||
number /= 1024
|
|
||||||
|
|
||||||
@app.template_filter("duration")
|
|
||||||
def fmt_duration(value):
|
|
||||||
try: seconds = max(0, int(value))
|
|
||||||
except (TypeError, ValueError): return "—"
|
|
||||||
days, seconds = divmod(seconds, 86400); hours, seconds = divmod(seconds, 3600); minutes, _ = divmod(seconds, 60)
|
|
||||||
return (f"{days}d {hours}h" if days else f"{hours}h {minutes}m" if hours else f"{minutes}m")
|
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
def protect_post():
|
def protect_post():
|
||||||
if request.method == "POST" and not secrets.compare_digest(request.form.get("csrf", ""), session.get("csrf", "invalid")):
|
if request.method == "POST" and not secrets.compare_digest(request.form.get("csrf", ""), session.get("csrf", "invalid")):
|
||||||
|
|
@ -94,25 +55,16 @@ def protect_post():
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def dashboard():
|
def dashboard():
|
||||||
return render_template("dashboard.html", state=get_state(), profiles=PROFILES, page="dashboard")
|
state = get_state()
|
||||||
|
return render_template("dashboard.html", state=state, profiles=PROFILES)
|
||||||
@app.get("/history")
|
|
||||||
def history_page():
|
|
||||||
remote = rpc("history", timeout=35)
|
|
||||||
with sqlite3.connect(AUDIT_DB) as db:
|
|
||||||
db.row_factory = sqlite3.Row
|
|
||||||
local_audit = [dict(row) for row in db.execute("SELECT ts,action,detail,ok,error,remote_ip FROM audit ORDER BY id DESC LIMIT 250")]
|
|
||||||
history = remote.get("history", {}) if remote.get("ok") else {}
|
|
||||||
for name in ("sessions", "ips", "events", "blocks"): history.setdefault(name, [])
|
|
||||||
return render_template("history.html", state=get_state(), history=history, audit=local_audit, history_error=remote.get("error"), page="history")
|
|
||||||
|
|
||||||
@app.post("/profile/<name>")
|
@app.post("/profile/<name>")
|
||||||
def apply_profile(name):
|
def apply_profile(name):
|
||||||
profile = PROFILES.get(name)
|
profile = PROFILES.get(name)
|
||||||
if not profile:
|
if not profile:
|
||||||
flash("Unbekanntes Profil", "error"); return redirect(url_for("dashboard"))
|
flash("Unbekanntes Profil", "error")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
result = rpc(f"set-limits {profile['ch']} {profile['vpn']}")
|
result = rpc(f"set-limits {profile['ch']} {profile['vpn']}")
|
||||||
audit("profile", f"{profile['label']}: Region {profile['ch']} / Cloud {profile['vpn']} Mbit", result)
|
|
||||||
_CACHE["data"] = None
|
_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")
|
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"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
@ -121,11 +73,12 @@ def apply_profile(name):
|
||||||
def custom_limits():
|
def custom_limits():
|
||||||
try:
|
try:
|
||||||
ch = float(request.form["ch"]); vpn = float(request.form["vpn"])
|
ch = float(request.form["ch"]); vpn = float(request.form["vpn"])
|
||||||
if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100: raise ValueError
|
if not 0.1 <= ch <= 100 or not 0.1 <= vpn <= 100:
|
||||||
|
raise ValueError
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
flash("Limits müssen zwischen 0,1 und 100 Mbit liegen", "error"); return redirect(url_for("dashboard"))
|
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}")
|
result = rpc(f"set-limits {ch:g} {vpn:g}")
|
||||||
audit("limits", f"Region {ch:g} / Cloud {vpn:g} Mbit", result)
|
|
||||||
_CACHE["data"] = None
|
_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")
|
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"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
@ -134,29 +87,35 @@ def custom_limits():
|
||||||
def policy():
|
def policy():
|
||||||
mode = request.form.get("mode", "")
|
mode = request.form.get("mode", "")
|
||||||
try:
|
try:
|
||||||
min_sessions = int(request.form.get("min_sessions", "2")); block_minutes = int(request.form.get("block_minutes", "10"))
|
min_sessions = int(request.form.get("min_sessions", "2"))
|
||||||
if mode not in {"off", "monitor", "block"} or not 2 <= min_sessions <= 8 or not 1 <= block_minutes <= 1440: raise ValueError
|
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:
|
except ValueError:
|
||||||
flash("Ungültige Policy-Werte", "error"); return redirect(url_for("dashboard"))
|
flash("Ungültige Policy-Werte", "error")
|
||||||
|
return redirect(url_for("dashboard"))
|
||||||
result = rpc(f"set-policy {mode} {min_sessions} {block_minutes}")
|
result = rpc(f"set-policy {mode} {min_sessions} {block_minutes}")
|
||||||
audit("policy", f"Modus {mode}, ab {min_sessions} Streams, {block_minutes} Minuten", result)
|
|
||||||
_CACHE["data"] = None
|
_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")
|
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"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
@app.post("/maintenance/<action>")
|
@app.post("/maintenance/<action>")
|
||||||
def maintenance(action):
|
def maintenance(action):
|
||||||
mapping = {"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop", "worker": "restart worker", "admin": "restart admin"}
|
mapping = {
|
||||||
|
"refresh": "limiter refresh", "start": "limiter start", "stop": "limiter stop",
|
||||||
|
"worker": "restart worker", "admin": "restart admin",
|
||||||
|
}
|
||||||
command = mapping.get(action)
|
command = mapping.get(action)
|
||||||
if not command: return "unknown action", 404
|
if not command:
|
||||||
|
return "unknown action", 404
|
||||||
result = rpc(command)
|
result = rpc(command)
|
||||||
audit("maintenance", action, result)
|
|
||||||
_CACHE["data"] = None
|
_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")
|
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"))
|
return redirect(url_for("dashboard"))
|
||||||
|
|
||||||
@app.get("/api/status")
|
@app.get("/api/status")
|
||||||
def api_status(): return jsonify(get_state())
|
def api_status():
|
||||||
|
return jsonify(get_state())
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,3 @@
|
||||||
|
|
||||||
.sidebar-foot{margin-top:auto;padding:14px;color:var(--muted);display:flex;align-items:center;gap:9px}.status-dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px rgba(255,102,125,.5)}.status-dot.ok{background:var(--green);box-shadow:0 0 12px rgba(50,214,163,.5)}.control-profiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:18px}.profile-card{position:relative;padding:18px;border:1px solid var(--border);border-top:3px solid var(--accent);border-radius:12px;background:var(--panel2);display:flex;flex-direction:column;gap:8px}.profile-card h3{margin:0}.profile-card strong{font-size:22px}.profile-card small{color:var(--muted)}.profile-dot{position:absolute;right:16px;top:16px;width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}.profile-card button{margin-top:8px}.compact-form{padding:18px}.compact-form label{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:12px}.compact-form input,.compact-form select{width:100%;background:var(--bg2);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:11px}.compact-form button.full{grid-column:1/-1}.action-row{display:flex;gap:8px;flex-wrap:wrap;padding:14px}.event-list{padding:6px 18px 18px}.event{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--border)}.event:last-child{border:0}.event-kind{min-width:66px;height:max-content;padding:4px 7px;border-radius:6px;background:rgba(120,87,246,.15);color:var(--violet2);font:10px ui-monospace,monospace;text-transform:uppercase}.event div{min-width:0}.event strong,.event small{display:block}.event small{color:var(--muted);margin-top:4px;overflow-wrap:anywhere}.muted{color:var(--muted)}
|
.sidebar-foot{margin-top:auto;padding:14px;color:var(--muted);display:flex;align-items:center;gap:9px}.status-dot{width:9px;height:9px;border-radius:50%;background:var(--red);box-shadow:0 0 12px rgba(255,102,125,.5)}.status-dot.ok{background:var(--green);box-shadow:0 0 12px rgba(50,214,163,.5)}.control-profiles{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;padding:18px}.profile-card{position:relative;padding:18px;border:1px solid var(--border);border-top:3px solid var(--accent);border-radius:12px;background:var(--panel2);display:flex;flex-direction:column;gap:8px}.profile-card h3{margin:0}.profile-card strong{font-size:22px}.profile-card small{color:var(--muted)}.profile-dot{position:absolute;right:16px;top:16px;width:10px;height:10px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}.profile-card button{margin-top:8px}.compact-form{padding:18px}.compact-form label{display:flex;flex-direction:column;gap:7px;color:var(--muted);font-size:12px}.compact-form input,.compact-form select{width:100%;background:var(--bg2);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:11px}.compact-form button.full{grid-column:1/-1}.action-row{display:flex;gap:8px;flex-wrap:wrap;padding:14px}.event-list{padding:6px 18px 18px}.event{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--border)}.event:last-child{border:0}.event-kind{min-width:66px;height:max-content;padding:4px 7px;border-radius:6px;background:rgba(120,87,246,.15);color:var(--violet2);font:10px ui-monospace,monospace;text-transform:uppercase}.event div{min-width:0}.event strong,.event small{display:block}.event small{color:var(--muted);margin-top:4px;overflow-wrap:anywhere}.muted{color:var(--muted)}
|
||||||
@media(max-width:900px){.control-profiles{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.control-profiles{grid-template-columns:1fr}.action-row form,.action-row button{width:100%}}
|
@media(max-width:900px){.control-profiles{grid-template-columns:repeat(2,1fr)}}@media(max-width:560px){.control-profiles{grid-template-columns:1fr}.action-row form,.action-row button{width:100%}}
|
||||||
|
|
||||||
|
|
||||||
/* center-history-v2 */
|
|
||||||
.detail-strip{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;margin:0 0 18px;background:var(--border);border:1px solid var(--border);border-radius:12px;overflow:hidden}.detail-strip>div{padding:14px 16px;background:var(--panel)}.detail-strip span,.detail-strip strong{display:block}.detail-strip span{color:var(--muted);font-size:10px;letter-spacing:1px;text-transform:uppercase}.detail-strip strong{margin-top:5px;font-size:13px}.cell-sub{display:block;color:var(--muted);font-size:10px;margin-top:4px}.text-link{color:var(--violet2);font-size:12px}.nowrap{white-space:nowrap}.break{word-break:break-all;max-width:230px}.danger-text{color:var(--red)}.history-toolbar{display:flex;justify-content:space-between;align-items:center;padding:12px;margin-bottom:16px}.history-tabs{display:flex;gap:6px;flex-wrap:wrap}.history-tab{border:1px solid var(--border);background:var(--panel2);color:var(--muted);padding:9px 14px;border-radius:8px;cursor:pointer}.history-tab.active{background:rgba(120,87,246,.2);border-color:var(--violet);color:var(--text)}#history-search{width:min(360px,100%);background:var(--bg2);border:1px solid var(--border);border-radius:9px;color:var(--text);padding:10px 12px}.history-pane{display:none}.history-pane.active{display:block}.history-split{padding:0}.history-split>.table-wrap{margin:0}.history-split>.event-list{max-height:560px;overflow:auto;padding:12px 18px}.filter-row[hidden]{display:none}
|
|
||||||
@media(max-width:1100px){.detail-strip{grid-template-columns:repeat(2,1fr)}.detail-strip>div:last-child{grid-column:1/-1}}@media(max-width:760px){.history-toolbar{align-items:stretch;flex-direction:column;gap:10px}.history-tabs{display:grid;grid-template-columns:repeat(2,1fr)}#history-search{width:100%}.detail-strip{grid-template-columns:1fr 1fr}.table-wrap{overflow-x:auto}.table-wrap table{min-width:800px}}
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,12 @@
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a>
|
<a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a>
|
||||||
<p class="nav-label">CONTROL</p>
|
<p class="nav-label">CONTROL</p>
|
||||||
<nav><a class="nav-item {{ 'active' if page=='dashboard' }}" href="/"><i>⌁</i>Übersicht</a><a class="nav-item {{ 'active' if page=='history' }}" href="/history"><i>◷</i>History</a><a class="nav-item" href="/api/status"><i>{ }</i>Status API</a></nav>
|
<nav><a class="nav-item active" href="/"><i>⌁</i>Traffic Policy</a><a class="nav-item" href="/api/status"><i>{ }</i>Status API</a></nav>
|
||||||
<div class="sidebar-foot"><span class="status-dot {{ 'ok' if state.get('ok') else 'down' }}"></span><small>Edge {{ 'verbunden' if state.get('ok') else 'nicht erreichbar' }}</small></div>
|
<div class="sidebar-foot"><span class="status-dot {{ 'ok' if state.get('ok') else 'down' }}"></span><small>Edge {{ 'verbunden' if state.get('ok') else 'nicht erreichbar' }}</small></div>
|
||||||
</aside>
|
</aside>
|
||||||
<header class="mobile-head"><a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a></header>
|
<header class="mobile-head"><a class="brand" href="/"><span class="brand-mark">◈</span><span>Stream<span>Scope</span></span></a></header>
|
||||||
<main>
|
<main>
|
||||||
<div class="topbar"><div><p class="eyebrow">{% block eyebrow %}REMOTE CONTROL{% endblock %}</p><h1>{% block heading %}TV & Session Policy{% endblock %}</h1></div><div class="top-actions"><span class="status-pill {{ 'ok' if state.get('ok') else 'down' }}">{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}</span></div></div>
|
<div class="topbar"><div><p class="eyebrow">REMOTE CONTROL</p><h1>TV & Session Policy</h1></div><div class="top-actions"><span class="status-pill {{ 'ok' if state.get('ok') else 'down' }}">{{ 'OVH ONLINE' if state.get('ok') else 'OVH OFFLINE' }}</span></div></div>
|
||||||
{% with messages=get_flashed_messages(with_categories=true) %}{% for category,message in messages %}<div class="notice {{ category }}"><strong>{{ '✓' if category=='success' else '!' }}</strong><p>{{ message }}</p></div>{% endfor %}{% endwith %}
|
{% with messages=get_flashed_messages(with_categories=true) %}{% for category,message in messages %}<div class="notice {{ category }}"><strong>{{ '✓' if category=='success' else '!' }}</strong><p>{{ message }}</p></div>{% endfor %}{% endwith %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -7,23 +7,6 @@
|
||||||
<article class="kpi"><span>POLICY</span><strong>{{ ('AUS' if not policy.get('enabled') else policy.get('mode','—')|upper) }}</strong><small>ab {{ policy.get('min_sessions','—') }} Streams</small></article>
|
<article class="kpi"><span>POLICY</span><strong>{{ ('AUS' if not policy.get('enabled') else policy.get('mode','—')|upper) }}</strong><small>ab {{ policy.get('min_sessions','—') }} Streams</small></article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% set host=state.get('host',{}) %}{% set tc_detail=state.get('tc_detail',{}) %}
|
|
||||||
<section class="detail-strip">
|
|
||||||
<div><span>OVH Uptime</span><strong>{{ host.get('uptime_seconds',0)|duration }}</strong></div>
|
|
||||||
<div><span>Load 1/5/15</span><strong>{{ (host.get('load') or ['—','—','—'])|join(' / ') }}</strong></div>
|
|
||||||
<div><span>RAM</span><strong>{{ host.get('memory_used',0)|bytes }} / {{ host.get('memory_total',0)|bytes }}</strong></div>
|
|
||||||
<div><span>Root-Disk</span><strong>{{ host.get('disk_used',0)|bytes }} / {{ host.get('disk_total',0)|bytes }}</strong></div>
|
|
||||||
<div><span>Historie</span><strong>{{ state.get('totals',{}).get('sessions',0) }} Sessions · {{ state.get('totals',{}).get('known_ips',0) }} IPs</strong></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{% if state.get('sessions') %}
|
|
||||||
<section class="panel"><div class="panel-head"><div><p class="eyebrow">LIVE</p><h2>Aktive Sessions</h2></div><a class="text-link" href="/history#sessions">vollständige History →</a></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Benutzer</th><th>Titel</th><th>Gerät</th><th>Standort</th><th>IP</th><th>Seit</th></tr></thead><tbody>
|
|
||||||
{% for s in state.get('sessions',[]) %}<tr><td><strong>{{ s.user_name }}</strong></td><td>{{ s.series or s.title }}{% if s.series %}<small class="cell-sub">{{ s.title }}</small>{% endif %}</td><td>{{ s.device }}<small class="cell-sub">{{ s.client }}</small></td><td>{{ [s.city,s.country]|select|join(', ') }}<small class="cell-sub">{{ s.asn }}</small></td><td class="mono">{{ s.ip }}</td><td>{{ s.started_at|dt }}</td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<section class="panel"><div class="panel-head"><div><p class="eyebrow">SCHNELLWAHL</p><h2>Traffic-Profile</h2></div><small>Wirkt sofort auf die aktiven tc-Klassen am OVH</small></div>
|
<section class="panel"><div class="panel-head"><div><p class="eyebrow">SCHNELLWAHL</p><h2>Traffic-Profile</h2></div><small>Wirkt sofort auf die aktiven tc-Klassen am OVH</small></div>
|
||||||
<div class="profile-grid control-profiles">
|
<div class="profile-grid control-profiles">
|
||||||
{% for key,p in profiles.items() %}
|
{% for key,p in profiles.items() %}
|
||||||
|
|
@ -61,7 +44,6 @@
|
||||||
<tr><td>Markierungsregeln</td><td><strong>{{ state.get('mark_rules','—') }}</strong></td></tr>
|
<tr><td>Markierungsregeln</td><td><strong>{{ state.get('mark_rules','—') }}</strong></td></tr>
|
||||||
<tr><td>Region-Netze</td><td><strong>{{ state.get('ipsets',{}).get('geo-a-v4',0)+state.get('ipsets',{}).get('geo-a-v6',0) }}</strong></td></tr>
|
<tr><td>Region-Netze</td><td><strong>{{ state.get('ipsets',{}).get('geo-a-v4',0)+state.get('ipsets',{}).get('geo-a-v6',0) }}</strong></td></tr>
|
||||||
<tr><td>Cloud-Netze</td><td><strong>{{ state.get('ipsets',{}).get('cloud-v4',0)+state.get('ipsets',{}).get('cloud-v6',0) }}</strong></td></tr>
|
<tr><td>Cloud-Netze</td><td><strong>{{ state.get('ipsets',{}).get('cloud-v4',0)+state.get('ipsets',{}).get('cloud-v6',0) }}</strong></td></tr>
|
||||||
{% for key,label in [('ch','Region-Traffic'),('vpn','Cloud-Traffic'),('default','Standard-Traffic')] %}{% set c=tc_detail.get(key,{}) %}<tr><td>{{ label }}</td><td><strong>{{ c.get('bytes',0)|bytes }}</strong><small class="cell-sub">{{ c.get('packets',0) }} Pakete · {{ c.get('drops',0) }} Drops</small></td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
</tbody></table></div>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
{% for action,label,cls in [('refresh','Netzlisten aktualisieren','ghost'),('worker','Worker neu starten','ghost'),('admin','Admin neu starten','ghost')] %}<form method="post" action="{{ url_for('maintenance',action=action) }}"><input type="hidden" name="csrf" value="{{ csrf_token() }}"><button class="{{ cls }}">{{ label }}</button></form>{% endfor %}
|
{% for action,label,cls in [('refresh','Netzlisten aktualisieren','ghost'),('worker','Worker neu starten','ghost'),('admin','Admin neu starten','ghost')] %}<form method="post" action="{{ url_for('maintenance',action=action) }}"><input type="hidden" name="csrf" value="{{ csrf_token() }}"><button class="{{ cls }}">{{ label }}</button></form>{% endfor %}
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
{% extends 'base.html' %}{% block eyebrow %}AUDIT & FORENSIK{% endblock %}{% block heading %}History{% endblock %}{% block content %}
|
|
||||||
<section class="kpi-grid">
|
|
||||||
<article class="kpi"><span>SESSIONS</span><strong>{{ history.sessions|length }}</strong><small>letzte Datensätze</small></article>
|
|
||||||
<article class="kpi"><span>BEKANNTE IPs</span><strong>{{ history.ips|length }}</strong><small>letzte Datensätze</small></article>
|
|
||||||
<article class="kpi"><span>POLICY-EVENTS</span><strong>{{ history.events|length }}</strong><small>Block / Freigabe</small></article>
|
|
||||||
<article class="kpi"><span>CONTROL-AKTIONEN</span><strong>{{ audit|length }}</strong><small>lokales Audit</small></article>
|
|
||||||
</section>
|
|
||||||
{% if history_error %}<div class="notice error"><strong>!</strong><p>{{ history_error }}</p></div>{% endif %}
|
|
||||||
|
|
||||||
<section class="history-toolbar panel">
|
|
||||||
<div class="history-tabs" role="tablist">
|
|
||||||
<button class="history-tab active" data-tab="control">Control</button>
|
|
||||||
<button class="history-tab" data-tab="sessions">Sessions</button>
|
|
||||||
<button class="history-tab" data-tab="ips">IP-History</button>
|
|
||||||
<button class="history-tab" data-tab="policy">Policy</button>
|
|
||||||
</div>
|
|
||||||
<input id="history-search" type="search" placeholder="Benutzer, IP, Titel, Gerät, ASN …" autocomplete="off">
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel history-pane active" data-pane="control">
|
|
||||||
<div class="panel-head"><div><p class="eyebrow">CENTER AUDIT</p><h2>Ausgeführte Änderungen</h2></div><small>{{ audit|length }} Einträge</small></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Aktion</th><th>Details</th><th>Quelle</th><th>Ergebnis</th></tr></thead><tbody>
|
|
||||||
{% for row in audit %}<tr class="filter-row"><td class="nowrap">{{ row.ts|dt }}</td><td><span class="event-kind">{{ row.action }}</span></td><td>{{ row.detail }}{% if row.error %}<small class="cell-sub danger-text">{{ row.error }}</small>{% endif %}</td><td class="mono">{{ row.remote_ip }}</td><td><span class="badge {{ 'active' if row.ok else 'danger' }}">{{ 'OK' if row.ok else 'FEHLER' }}</span></td></tr>{% else %}<tr><td colspan="5">Noch keine Control-Aktionen protokolliert.</td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="sessions" class="panel history-pane" data-pane="sessions">
|
|
||||||
<div class="panel-head"><div><p class="eyebrow">PLAYBACK</p><h2>Session-History</h2></div><small>{{ history.sessions|length }} Einträge</small></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Zuletzt</th><th>Benutzer</th><th>Titel</th><th>Gerät</th><th>Standort / ASN</th><th>IP</th><th>Traffic</th><th>Status</th></tr></thead><tbody>
|
|
||||||
{% for row in history.sessions %}<tr class="filter-row"><td class="nowrap">{{ row.last_seen|dt }}</td><td><strong>{{ row.user_name }}</strong></td><td>{{ row.series or row.title }}{% if row.series %}<small class="cell-sub">{{ row.title }}</small>{% endif %}</td><td>{{ row.device }}<small class="cell-sub">{{ row.client }}</small></td><td>{{ [row.city,row.country]|select|join(', ') }}<small class="cell-sub">{{ row.asn }}</small></td><td class="mono break">{{ row.ip }}</td><td>{{ row.bytes|bytes }}</td><td><span class="badge {{ 'active' if row.active else '' }}">{{ 'AKTIV' if row.active else 'BEENDET' }}</span></td></tr>{% else %}<tr><td colspan="8">Keine Sessions vorhanden.</td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel history-pane" data-pane="ips">
|
|
||||||
<div class="panel-head"><div><p class="eyebrow">CLIENT FORENSIK</p><h2>IP-History</h2></div><small>{{ history.ips|length }} Einträge</small></div>
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Zuletzt</th><th>Benutzer</th><th>IP</th><th>Gerät</th><th>Standort / ASN</th><th>Klasse</th><th>Sichtungen</th></tr></thead><tbody>
|
|
||||||
{% for row in history.ips %}<tr class="filter-row"><td class="nowrap">{{ row.last_seen|dt }}<small class="cell-sub">zuerst {{ row.first_seen|dt }}</small></td><td><strong>{{ row.user_name }}</strong></td><td class="mono break">{{ row.ip }}</td><td>{{ row.device }}<small class="cell-sub">{{ row.client }}</small></td><td>{{ [row.city,row.country]|select|join(', ') }}<small class="cell-sub">{{ row.asn }}</small></td><td><span class="badge">{{ row.last_classification }}</span></td><td>{{ row.sightings }}<small class="cell-sub">{{ row.limited_sightings }} limitiert</small></td></tr>{% else %}<tr><td colspan="7">Keine IP-History vorhanden.</td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="panel history-pane" data-pane="policy">
|
|
||||||
<div class="panel-head"><div><p class="eyebrow">POLICY AUDIT</p><h2>Blocks & Ereignisse</h2></div><small>{{ history.blocks|length }} Blocks · {{ history.events|length }} Events</small></div>
|
|
||||||
<div class="grid two history-split">
|
|
||||||
<div class="table-wrap"><table><thead><tr><th>Zeit</th><th>Benutzer / IP</th><th>Grund</th><th>Status</th></tr></thead><tbody>
|
|
||||||
{% for row in history.blocks %}<tr class="filter-row"><td class="nowrap">{{ row.blocked_at|dt }}</td><td><strong>{{ row.user_name or '—' }}</strong><small class="cell-sub mono">{{ row.ip }}</small></td><td>{{ row.reason }}</td><td><span class="badge {{ 'danger' if row.active else '' }}">{{ 'AKTIV' if row.active else 'ABGELAUFEN' }}</span></td></tr>{% else %}<tr><td colspan="4">Keine Blocks.</td></tr>{% endfor %}
|
|
||||||
</tbody></table></div>
|
|
||||||
<div class="event-list">{% for row in history.events %}<div class="event filter-row"><span class="event-kind">{{ row.kind }}</span><div><strong>{{ row.user_name or 'System' }}</strong><small>{{ row.ts|dt }} · {{ row.detail }}</small></div></div>{% else %}<p class="muted">Keine Policy-Ereignisse.</p>{% endfor %}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<script>
|
|
||||||
(()=>{const tabs=[...document.querySelectorAll('.history-tab')],panes=[...document.querySelectorAll('.history-pane')],search=document.querySelector('#history-search');function select(name){tabs.forEach(x=>x.classList.toggle('active',x.dataset.tab===name));panes.forEach(x=>x.classList.toggle('active',x.dataset.pane===name));location.hash=name==='control'?'':name}tabs.forEach(x=>x.addEventListener('click',()=>select(x.dataset.tab)));search.addEventListener('input',()=>{const q=search.value.toLocaleLowerCase('de');document.querySelectorAll('.filter-row').forEach(r=>r.hidden=q&&!r.textContent.toLocaleLowerCase('de').includes(q))});const initial=location.hash.slice(1);if(tabs.some(x=>x.dataset.tab===initial))select(initial)})();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue