diff --git a/README.md b/README.md index cac62d3..a7fcf8d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,52 @@ -# Port-Forwarding-Services +# RelayDock -This is a Full Stack Port Forwarding Service. You can run it on a linux machine and forward a port with a extra systemd service. \ No newline at end of file +## Pakete installieren + +```bash +sudo apt update +sudo apt install -y python3-flask socat jq +``` + +## Dateien kopieren + +```bash +sudo mkdir -p /opt/relaydock-web /etc/relaydock +sudo cp -r ./* /opt/relaydock-web/ +sudo cp /opt/relaydock-web/config.json /etc/relaydock/config.json +sudo cp /opt/relaydock-web/relaydock@.service /etc/systemd/system/ +sudo cp /opt/relaydock-web/relaydock.target /etc/systemd/system/ +sudo cp /opt/relaydock-web/relaydock-web.service /etc/systemd/system/ +sudo cp /opt/relaydock-web/relaydock-instance.sh /usr/local/bin/ +sudo cp /opt/relaydock-web/relaydock-reconcile.sh /usr/local/bin/ +sudo chmod +x /usr/local/bin/relaydock-instance.sh /usr/local/bin/relaydock-reconcile.sh +``` + +## sudoers für Web-UI + +```bash +sudo visudo +``` + +Dann ergänzen: + +```text +www-data ALL=(ALL) NOPASSWD: /bin/systemctl start relaydock@*.service +www-data ALL=(ALL) NOPASSWD: /bin/systemctl stop relaydock@*.service +www-data ALL=(ALL) NOPASSWD: /bin/systemctl restart relaydock@*.service +www-data ALL=(ALL) NOPASSWD: /bin/systemctl daemon-reload +www-data ALL=(ALL) NOPASSWD: /usr/local/bin/relaydock-reconcile.sh * +``` + +Wenn du den Webdienst als root laufen lässt, brauchst du das nicht, sicherer ist aber ein eigener User mit sauberem sudoers-Eintrag. + +## Start + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now relaydock-web.service +sudo /usr/local/bin/relaydock-reconcile.sh /etc/relaydock/config.json +``` + +## Aufruf + +Weboberfläche dann auf Port 8080 öffnen. diff --git a/app.py b/app.py new file mode 100644 index 0000000..a74106c --- /dev/null +++ b/app.py @@ -0,0 +1,175 @@ +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) diff --git a/config.json b/config.json new file mode 100644 index 0000000..d0e4f6c --- /dev/null +++ b/config.json @@ -0,0 +1,19 @@ +{ + "role": "oracle", + "rules": [ + { + "name": "icarus-game", + "proto": "udp", + "listen_port": 17777, + "target_host": "2001:db8::1234", + "target_port": 17777 + }, + { + "name": "steam-query", + "proto": "udp", + "listen_port": 27015, + "target_host": "2001:db8::1234", + "target_port": 27015 + } + ] +} diff --git a/relaydock-instance.sh b/relaydock-instance.sh new file mode 100755 index 0000000..1b554dd --- /dev/null +++ b/relaydock-instance.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAME="${1:?instance name missing}" +CONFIG_FILE="${CONFIG_FILE:-/etc/relaydock/config.json}" + +command -v jq >/dev/null 2>&1 || { echo "Fehlt: jq" >&2; exit 1; } +command -v socat >/dev/null 2>&1 || { echo "Fehlt: socat" >&2; exit 1; } +[[ -f "$CONFIG_FILE" ]] || { echo "Config fehlt: $CONFIG_FILE" >&2; exit 1; } + +ROLE=$(jq -r '.role' "$CONFIG_FILE") +RULE=$(jq -e --arg name "$NAME" '.rules[] | select(.name == $name)' "$CONFIG_FILE") +PROTO=$(jq -r '.proto' <<<"$RULE") +LISTEN_PORT=$(jq -r '.listen_port' <<<"$RULE") +TARGET_HOST=$(jq -r '.target_host' <<<"$RULE") +TARGET_PORT=$(jq -r '.target_port' <<<"$RULE") + +if [[ "$ROLE" == "oracle" ]]; then + if [[ "$PROTO" == "udp" ]]; then + exec socat "UDP4-LISTEN:${LISTEN_PORT},fork,reuseaddr" "UDP6:[${TARGET_HOST}]:${TARGET_PORT}" + else + exec socat "TCP4-LISTEN:${LISTEN_PORT},fork,reuseaddr" "TCP6:[${TARGET_HOST}]:${TARGET_PORT}" + fi +else + if [[ "$PROTO" == "udp" ]]; then + exec socat "UDP6-LISTEN:${LISTEN_PORT},fork,reuseaddr" "UDP4:${TARGET_HOST}:${TARGET_PORT}" + else + exec socat "TCP6-LISTEN:${LISTEN_PORT},fork,reuseaddr" "TCP4:${TARGET_HOST}:${TARGET_PORT}" + fi +fi diff --git a/relaydock-reconcile.sh b/relaydock-reconcile.sh new file mode 100755 index 0000000..72cb3bb --- /dev/null +++ b/relaydock-reconcile.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="${1:-/etc/relaydock/config.json}" +PREFIX="${RELAYDOCK_PREFIX:-relaydock}" + +command -v jq >/dev/null 2>&1 || { echo "Fehlt: jq" >&2; exit 1; } +[[ -f "$CONFIG_FILE" ]] || { echo "Config fehlt: $CONFIG_FILE" >&2; exit 1; } + +mapfile -t desired < <(jq -r '.rules[].name' "$CONFIG_FILE" | sort -u) +mapfile -t current < <(systemctl list-units --full --all "${PREFIX}@*.service" --no-legend | awk '{print $1}' | sed -E "s/^${PREFIX}@(.*)\\.service$/\\1/" | sort -u) + +for name in "${desired[@]}"; do + systemctl enable --now "${PREFIX}@${name}.service" +done + +for name in "${current[@]}"; do + if ! printf '%s\n' "${desired[@]}" | grep -qx "$name"; then + systemctl disable --now "${PREFIX}@${name}.service" || true + else + systemctl restart "${PREFIX}@${name}.service" + fi +done diff --git a/relaydock-web.service b/relaydock-web.service new file mode 100644 index 0000000..260c186 --- /dev/null +++ b/relaydock-web.service @@ -0,0 +1,17 @@ +[Unit] +Description=RelayDock Web UI +After=network.target + +[Service] +User=root +WorkingDirectory=/opt/relaydock-web +Environment=FLASK_SECRET_KEY=change-me +Environment=RELAYDOCK_CONFIG=/etc/relaydock/config.json +Environment=RELAYDOCK_RECONCILE=/usr/local/bin/relaydock-reconcile.sh +Environment=RELAYDOCK_PREFIX=relaydock +ExecStart=/usr/bin/python3 /opt/relaydock-web/app.py +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/relaydock.target b/relaydock.target new file mode 100644 index 0000000..cc5e984 --- /dev/null +++ b/relaydock.target @@ -0,0 +1,2 @@ +[Unit] +Description=All configured Pterodactyl forward instances diff --git a/relaydock@.service b/relaydock@.service new file mode 100644 index 0000000..5dea9e8 --- /dev/null +++ b/relaydock@.service @@ -0,0 +1,16 @@ +[Unit] +Description=Pterodactyl forward %I +After=network-online.target +Wants=network-online.target +PartOf=relaydock.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/relaydock-instance.sh %i +Restart=always +RestartSec=2 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=relaydock.target diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..28ef6f9 --- /dev/null +++ b/static/style.css @@ -0,0 +1,38 @@ +:root { + color-scheme: dark; + --bg:#0b1220; + --panel:#121a2b; + --panel-2:#182338; + --text:#ecf3ff; + --muted:#a8b6cf; + --line:#263247; + --accent:#16c7b7; + --danger:#dc5d68; + --warn:#e0a648; + --ok:#2bb673; +} +* { box-sizing:border-box; } +body { margin:0; font-family:Inter,system-ui,sans-serif; background:linear-gradient(180deg,#09101d,#0e1728); color:var(--text); } +.wrap { max-width:1200px; margin:0 auto; padding:24px; } +.card { background:rgba(18,26,43,.92); border:1px solid var(--line); border-radius:16px; padding:20px; box-shadow:0 10px 30px rgba(0,0,0,.2); } +.hero { display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:20px; } +.grid { display:grid; gap:20px; } +.grid.two { grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); margin-bottom:20px; } +.stack { display:grid; gap:12px; } +.actions, .row-actions { display:flex; gap:10px; flex-wrap:wrap; } +input, select, button { width:100%; border-radius:12px; border:1px solid var(--line); background:var(--panel-2); color:var(--text); padding:12px 14px; } +button { width:auto; cursor:pointer; } +button.primary { background:var(--accent); color:#06231f; border-color:transparent; font-weight:700; } +button.danger { background:var(--danger); color:white; border-color:transparent; } +table { width:100%; border-collapse:collapse; } +th, td { text-align:left; padding:12px; border-top:1px solid var(--line); vertical-align:top; } +.table-wrap { overflow:auto; } +.badge { display:inline-block; padding:6px 10px; border-radius:999px; background:#25324a; border:1px solid var(--line); } +.badge.active { background:rgba(43,182,115,.15); color:#92efbf; border-color:rgba(43,182,115,.35); } +.badge.inactive, .badge.failed { background:rgba(224,166,72,.12); color:#ffd28b; border-color:rgba(224,166,72,.35); } +.badge.muted { color:var(--muted); } +.flash { padding:12px 14px; border-radius:12px; margin-bottom:12px; } +.flash.success { background:rgba(43,182,115,.14); border:1px solid rgba(43,182,115,.3); } +.flash.error { background:rgba(220,93,104,.14); border:1px solid rgba(220,93,104,.3); } +p { color:var(--muted); } +@media (max-width: 760px) { .hero { flex-direction:column; } th:nth-child(6), td:nth-child(6) { display:none; } } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ff08d0f --- /dev/null +++ b/templates/index.html @@ -0,0 +1,98 @@ + + + + + + {{ title }} + + + +
+
+
+

{{ title }}

+

JSON verwalten, systemd-Template-Instanzen synchronisieren und einzelne Weiterleitungen starten oder stoppen.

+
+
+
+
+
+
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+

Rolle

+
+ + +
+
+ +
+

Neue Regel

+
+ + + + + + +
+
+
+ +
+

Weiterleitungen

+
+ + + + + + + + + + + + + + {% for rule in rules %} + + + + + + + + + + {% else %} + + {% endfor %} + +
NameProtoListenZielAktivEnabledAktionen
{{ rule.name }}{{ rule.proto }}{{ rule.listen_port }}{{ rule.target_host }}:{{ rule.target_port }}{{ rule.active }}{{ rule.enabled }} +
+
+
+
+
+
Noch keine Regeln vorhanden.
+
+
+
+ +