Initial Commit
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+30
@@ -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
|
||||
Executable
+23
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
[Unit]
|
||||
Description=All configured Pterodactyl forward instances
|
||||
@@ -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
|
||||
@@ -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; } }
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user