from flask import Flask, render_template, request, redirect, url_for, flash import json import os import re import subprocess from pathlib import Path APP_TITLE = "RelayDock" CONFIG_PATH = Path(os.environ.get("RELAYDOCK_CONFIG", "./config.json")) RECONCILE_CMD = os.environ.get("RELAYDOCK_RECONCILE", "./relaydock-reconcile.sh") SERVICE_PREFIX = os.environ.get("RELAYDOCK_PREFIX", "relaydock") app = Flask(__name__) app.secret_key = os.environ.get("FLASK_SECRET_KEY", "change-me") NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$") PROTO_SET = {"udp", "tcp"} ROLE_SET = {"oracle", "pi"} def default_config(): return {"role": "oracle", "rules": []} def load_config(): if not CONFIG_PATH.exists(): return default_config() with CONFIG_PATH.open("r", encoding="utf-8") as f: return json.load(f) def save_config(data): CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) with CONFIG_PATH.open("w", encoding="utf-8") as f: json.dump(data, f, indent=2) f.write("\n") def validate_rule(rule, config): errors = [] name = rule.get("name", "").strip() proto = rule.get("proto", "").strip().lower() target_host = rule.get("target_host", "").strip() try: listen_port = int(rule.get("listen_port", 0)) target_port = int(rule.get("target_port", 0)) except ValueError: errors.append("Ports müssen Zahlen sein.") return errors if not NAME_RE.match(name): errors.append("Name darf nur Buchstaben, Zahlen, Punkt, Unterstrich und Bindestrich enthalten.") if proto not in PROTO_SET: errors.append("Proto muss udp oder tcp sein.") if not (1 <= listen_port <= 65535 and 1 <= target_port <= 65535): errors.append("Ports müssen zwischen 1 und 65535 liegen.") if not target_host: errors.append("Target Host darf nicht leer sein.") existing = [r["name"] for r in config.get("rules", [])] if name in existing: errors.append("Name existiert bereits.") return errors def run_cmd(cmd): return subprocess.run(cmd, text=True, capture_output=True) def unit_name(name): return f"{SERVICE_PREFIX}@{name}.service" @app.route("/") def index(): config = load_config() rules = [] for rule in config.get("rules", []): svc = unit_name(rule["name"]) state = run_cmd(["systemctl", "is-active", svc]) enabled = run_cmd(["systemctl", "is-enabled", svc]) rules.append({ **rule, "service": svc, "active": state.stdout.strip() if state.returncode == 0 else state.stdout.strip() or "inactive", "enabled": enabled.stdout.strip() if enabled.returncode == 0 else enabled.stdout.strip() or "disabled", }) return render_template("index.html", title=APP_TITLE, config=config, rules=rules) @app.post("/role") def set_role(): config = load_config() role = request.form.get("role", "").strip().lower() if role not in ROLE_SET: flash("Ungültige Rolle.", "error") return redirect(url_for("index")) config["role"] = role save_config(config) flash("Rolle gespeichert.", "success") return redirect(url_for("index")) @app.post("/rules/add") def add_rule(): config = load_config() rule = { "name": request.form.get("name", ""), "proto": request.form.get("proto", ""), "listen_port": request.form.get("listen_port", "0"), "target_host": request.form.get("target_host", ""), "target_port": request.form.get("target_port", "0"), } errors = validate_rule(rule, config) if errors: for err in errors: flash(err, "error") return redirect(url_for("index")) rule["listen_port"] = int(rule["listen_port"]) rule["target_port"] = int(rule["target_port"]) config.setdefault("rules", []).append(rule) save_config(config) flash("Regel hinzugefügt.", "success") return redirect(url_for("index")) @app.post("/rules//delete") def delete_rule(name): config = load_config() before = len(config.get("rules", [])) config["rules"] = [r for r in config.get("rules", []) if r["name"] != name] save_config(config) if len(config["rules"]) < before: flash(f"Regel {name} gelöscht.", "success") else: flash("Regel nicht gefunden.", "error") return redirect(url_for("index")) @app.post("/rules//toggle") def toggle_rule(name): svc = unit_name(name) state = run_cmd(["systemctl", "is-active", svc]) if state.returncode == 0 and state.stdout.strip() == "active": result = run_cmd(["sudo", "systemctl", "stop", svc]) flash(result.stderr.strip() or result.stdout.strip() or f"{svc} gestoppt.", "success" if result.returncode == 0 else "error") else: result = run_cmd(["sudo", "systemctl", "start", svc]) flash(result.stderr.strip() or result.stdout.strip() or f"{svc} gestartet.", "success" if result.returncode == 0 else "error") return redirect(url_for("index")) @app.post("/rules//restart") def restart_rule(name): svc = unit_name(name) result = run_cmd(["sudo", "systemctl", "restart", svc]) flash(result.stderr.strip() or result.stdout.strip() or f"{svc} neugestartet.", "success" if result.returncode == 0 else "error") return redirect(url_for("index")) @app.post("/reconcile") def reconcile(): result = run_cmd(["sudo", RECONCILE_CMD, str(CONFIG_PATH)]) flash(result.stderr.strip() or result.stdout.strip() or "Synchronisierung ausgeführt.", "success" if result.returncode == 0 else "error") return redirect(url_for("index")) @app.post("/daemon-reload") def daemon_reload(): result = run_cmd(["sudo", "systemctl", "daemon-reload"]) flash(result.stderr.strip() or result.stdout.strip() or "systemd daemon-reload ausgeführt.", "success" if result.returncode == 0 else "error") return redirect(url_for("index")) if __name__ == "__main__": app.run(host="0.0.0.0", port=8080, debug=True)