Initial Commit

This commit is contained in:
2026-05-21 23:14:36 +02:00
parent 2fc1bc480c
commit 2b77272263
10 changed files with 469 additions and 2 deletions
+51 -2
View File
@@ -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. ## 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.
+175
View File
@@ -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/<name>/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/<name>/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/<name>/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)
+19
View File
@@ -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
}
]
}
+30
View File
@@ -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
+23
View File
@@ -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
+17
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[Unit]
Description=All configured Pterodactyl forward instances
+16
View File
@@ -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
+38
View File
@@ -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; } }
+98
View File
@@ -0,0 +1,98 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<main class="wrap">
<header class="hero card">
<div>
<h1>{{ title }}</h1>
<p>JSON verwalten, systemd-Template-Instanzen synchronisieren und einzelne Weiterleitungen starten oder stoppen.</p>
</div>
<div class="actions">
<form method="post" action="{{ url_for('daemon_reload') }}"><button>systemd reload</button></form>
<form method="post" action="{{ url_for('reconcile') }}"><button class="primary">JSON synchronisieren</button></form>
</div>
</header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<section class="stack">
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
</section>
{% endif %}
{% endwith %}
<section class="grid two">
<article class="card">
<h2>Rolle</h2>
<form method="post" action="{{ url_for('set_role') }}" class="stack">
<select name="role">
<option value="oracle" {% if config.role == 'oracle' %}selected{% endif %}>oracle</option>
<option value="pi" {% if config.role == 'pi' %}selected{% endif %}>pi</option>
</select>
<button>Rolle speichern</button>
</form>
</article>
<article class="card">
<h2>Neue Regel</h2>
<form method="post" action="{{ url_for('add_rule') }}" class="stack">
<input name="name" placeholder="icarus-game" required>
<select name="proto"><option value="udp">udp</option><option value="tcp">tcp</option></select>
<input name="listen_port" placeholder="17777" type="number" min="1" max="65535" required>
<input name="target_host" placeholder="2001:db8::1234 oder 192.168.178.50" required>
<input name="target_port" placeholder="17777" type="number" min="1" max="65535" required>
<button class="primary">Regel hinzufügen</button>
</form>
</article>
</section>
<section class="card">
<h2>Weiterleitungen</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Name</th>
<th>Proto</th>
<th>Listen</th>
<th>Ziel</th>
<th>Aktiv</th>
<th>Enabled</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<td>{{ rule.name }}</td>
<td>{{ rule.proto }}</td>
<td>{{ rule.listen_port }}</td>
<td>{{ rule.target_host }}:{{ rule.target_port }}</td>
<td><span class="badge {{ rule.active }}">{{ rule.active }}</span></td>
<td><span class="badge muted">{{ rule.enabled }}</span></td>
<td>
<div class="row-actions">
<form method="post" action="{{ url_for('toggle_rule', name=rule.name) }}"><button>{% if rule.active == 'active' %}Stop{% else %}Start{% endif %}</button></form>
<form method="post" action="{{ url_for('restart_rule', name=rule.name) }}"><button>Restart</button></form>
<form method="post" action="{{ url_for('delete_rule', name=rule.name) }}" onsubmit="return confirm('Regel wirklich löschen?');"><button class="danger">Löschen</button></form>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7">Noch keine Regeln vorhanden.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</main>
</body>
</html>