Files
ARIA-AGENT/satellite/vpn_entry.py
T
duffyduckandClaude Opus 5.5 627b826226 feat(vpn): VPN-Satelliten — WireGuard-Clients als Satelliten ohne Installation beim Kunden
Einstellungen → VPN → WireGuard → Clients: WireGuard-Datei des Kunden
hochladen/einfuegen (editierbar), je Verbindung Schalter (an = auch nach
Neustart, aus = bleibt aus), bearbeiten, Download, ℹ-Verlauf, loeschen;
Log-Aufbewahrung im Reiter Einstellungen. Server-Reiter mit Anleitung fuer
UDP 443 (NPM-Stream bzw. ohne Nginx), Server selbst folgt.

Jede Verbindung ist ein eigener Container (aria-vpn-<id>, Image aus
satellite/Dockerfile.vpn) mit eigenem Netz-Namensraum: WireGuard + normaler
satellite.py (kind=vpn). Firewall im Container: Tunnel nur ausgehend, keine
Verbindung Kundennetz → ARIA; ins ARIA-Netz nur RVS + Endpunkt. Routen ohne
wg-quick, RVS-Weg nie durch den Tunnel (auch bei 0.0.0.0/0). Scan ueber den
Tunnel aktiv per TCP (kein mDNS/ARP), FritzBox an Port 49000 erkannt.
Satelliten-Ansicht: eigene Gruppe VPN-Satelliten.

Lokal Ende-zu-Ende getestet (Kunde mit WG-Server + LAN, Fake-RVS):
Tunnel, Verlauf, Scan, Sperren in beide Richtungen, Volltunnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:30:10 +01:00

338 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
ARIA VPN-Satellit — ein WireGuard-Tunnel + ein ganz normaler Satellit dahinter.
Laeuft als eigener Container pro VPN-Verbindung (angelegt von der Diagnostic,
Einstellungen → VPN → WireGuard). Eigener Container = eigener Netz-Namensraum:
- ueberlappende Kundennetze (jeder hat 192.168.178.0/24) stoeren sich nicht
- lokale Satelliten (network_mode: host) sehen diese Netze nie
Ablauf:
1. /vpn/wg.conf lesen (Standard-WireGuard-Datei, wie man sie bekommt)
2. wg0 selbst aufsetzen (kein wg-quick: DNS/PostUp aus fremden Dateien
werden ignoriert, und der Weg zum RVS darf NIE durch den Tunnel)
3. Firewall (nftables, nur in diesem Container):
raus durch den Tunnel: alles; rein aus dem Tunnel: nur Antworten
raus ins ARIA-Netz/Internet: nur zum RVS und zum Tunnel-Endpunkt
4. satellite.py starten (SAT_KIND=vpn, Scan = SCAN_NETS aktiv abklopfen)
5. Tunnel beobachten: status.json + events.jsonl (verbunden/getrennt, Dauer)
fuer das ℹ-Fenster; Endpunkt mit Hostnamen (DynDNS) neu aufloesen
Env: VPN_ID, VPN_NAME, RVS_HOST/RVS_PORT/RVS_SNI (+ alles fuer satellite.py)
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import re
import signal
import socket
import subprocess
import sys
import threading
import time
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn] %(levelname)s %(message)s")
log = logging.getLogger("vpn")
VPN_DIR = os.environ.get("VPN_DIR", "/vpn")
CONF = os.path.join(VPN_DIR, "wg.conf")
STATUS = os.path.join(VPN_DIR, "status.json")
EVENTS = os.path.join(VPN_DIR, "events.jsonl")
IFACE = "wg0"
HANDSHAKE_OK_S = 180 # juenger = verbunden (WireGuard erneuert alle 2 min)
WATCH_S = 15
_stop = threading.Event()
_conn = {"connected": False, "since": 0, "rx": 0, "tx": 0} # fuer "getrennt" beim Stoppen
def sh(*args: str, check: bool = True, inp: str | None = None) -> str:
p = subprocess.run(list(args), capture_output=True, text=True, input=inp)
if check and p.returncode != 0:
raise RuntimeError(f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[:300]}")
return p.stdout
def event(kind: str, **extra) -> None:
rec = {"ts": int(time.time()), "event": kind, **extra}
try:
with open(EVENTS, "a", encoding="utf-8") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except OSError as e:
log.warning("events.jsonl nicht schreibbar: %s", e)
# ── WireGuard-Datei lesen ───────────────────────────────────────
def parse_conf(text: str) -> dict:
"""[Interface] + beliebig viele [Peer]. Schluessel ohne Gross/Klein."""
iface: dict = {}
peers: list = []
cur = None
for raw in text.splitlines():
line = raw.split("#", 1)[0].strip()
if not line:
continue
sec = re.match(r"^\[(\w+)\]$", line)
if sec:
name = sec.group(1).lower()
if name == "interface":
cur = iface
elif name == "peer":
cur = {}
peers.append(cur)
else:
cur = None
continue
if cur is None or "=" not in line:
continue
k, v = (s.strip() for s in line.split("=", 1))
k = k.lower()
if k in cur and k in ("address", "allowedips", "dns"):
cur[k] += "," + v
else:
cur[k] = v
if not iface.get("privatekey"):
raise ValueError("[Interface] PrivateKey fehlt")
if not peers or not all(p.get("publickey") for p in peers):
raise ValueError("[Peer] PublicKey fehlt")
return {"iface": iface, "peers": peers}
def _split(v: str) -> list[str]:
return [x.strip() for x in (v or "").split(",") if x.strip()]
def setconf_text(c: dict) -> str:
"""Nur was `wg setconf` versteht (Address/DNS/MTU/PostUp sind wg-quick)."""
out = ["[Interface]", f"PrivateKey = {c['iface']['privatekey']}"]
if c["iface"].get("listenport"):
out.append(f"ListenPort = {c['iface']['listenport']}")
for p in c["peers"]:
out += ["", "[Peer]", f"PublicKey = {p['publickey']}"]
for k, name in (("presharedkey", "PresharedKey"), ("endpoint", "Endpoint"),
("allowedips", "AllowedIPs")):
if p.get(k):
out.append(f"{name} = {p[k]}")
out.append(f"PersistentKeepalive = {p.get('persistentkeepalive') or 25}")
return "\n".join(out) + "\n"
def endpoint_host_port(ep: str) -> tuple[str, int]:
m = re.match(r"^\[?([^\]]+?)\]?:(\d+)$", ep.strip())
if not m:
raise ValueError(f"Endpoint unlesbar: {ep}")
return m.group(1), int(m.group(2))
def resolve4(host: str) -> list[str]:
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
try:
return sorted({a[4][0] for a in socket.getaddrinfo(host, None, socket.AF_INET)})
except OSError:
return []
# ── Netz + Firewall ─────────────────────────────────────────────
def default_gw() -> str:
m = re.search(r"default via (\S+)", sh("ip", "-4", "route", "show", "default"))
if not m:
raise RuntimeError("Container hat kein Default-Gateway")
return m.group(1)
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str]) -> None:
rvs = ", ".join(rvs_ips) if rvs_ips else "127.0.0.2"
eps = ", ".join(ep_ips) if ep_ips else "127.0.0.2"
# Nur die eigene Tabelle — "flush ruleset" wuerde Dockers NAT-Regeln fuer
# den eingebauten DNS (127.0.0.11) im Container mit loeschen.
sh("nft", "delete", "table", "inet", "vpnsat", check=False)
rules = f"""
table inet vpnsat {{
set endpoints {{ type ipv4_addr; elements = {{ {eps} }} }}
chain input {{
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
}}
chain forward {{
type filter hook forward priority 0; policy drop;
}}
chain output {{
type filter hook output priority 0; policy drop;
oif lo accept
ct state established,related accept
oifname "{IFACE}" accept
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
}}
}}
"""
sh("nft", "-f", "-", inp=rules)
log.info("Firewall aktiv: Tunnel nur ausgehend; ins ARIA-Netz nur RVS %s:%d", rvs, rvs_port)
def setup(c: dict) -> dict:
rvs_host = os.environ.get("RVS_HOST", "")
rvs_port = int(os.environ.get("RVS_PORT", "443") or 443)
rvs_ips = resolve4(rvs_host)
if not rvs_ips:
raise RuntimeError(f"RVS {rvs_host!r} nicht aufloesbar")
gw = default_gw()
ep_ips: list[str] = []
for p in c["peers"]:
if p.get("endpoint"):
h, _ = endpoint_host_port(p["endpoint"])
ep_ips += resolve4(h)
sh("ip", "link", "del", IFACE, check=False)
try:
sh("ip", "link", "add", IFACE, "type", "wireguard")
except RuntimeError as e:
raise RuntimeError(f"WireGuard im Kernel nicht verfuegbar (modprobe wireguard auf dem Host?): {e}")
sh("wg", "setconf", IFACE, "/dev/stdin", inp=setconf_text(c))
for a in _split(c["iface"].get("address", "")):
if ":" not in a:
sh("ip", "-4", "address", "add", a, "dev", IFACE)
sh("ip", "link", "set", IFACE, "mtu", c["iface"].get("mtu") or "1420", "up")
# RVS und Endpunkte immer am Tunnel vorbei
for ip in set(rvs_ips + ep_ips):
sh("ip", "route", "replace", f"{ip}/32", "via", gw, "dev", "eth0")
nets = []
for p in c["peers"]:
for n in _split(p.get("allowedips", "")):
if ":" in n:
continue
net = ipaddress.ip_network(n, strict=False)
if net.prefixlen == 0:
for half in ("0.0.0.0/1", "128.0.0.0/1"):
sh("ip", "route", "replace", half, "dev", IFACE)
else:
sh("ip", "route", "replace", str(net), "dev", IFACE)
nets.append(str(net))
firewall(rvs_ips, rvs_port, ep_ips)
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
return {"nets": nets, "endpoints": ep_ips}
def scan_nets(c: dict) -> str:
"""Was der Satellit abklopft: SCAN_NETS (aus der Diagnostic) oder die
AllowedIPs ohne 0.0.0.0/0. Zu grosse Netze kuerzt satellite.py selbst."""
if os.environ.get("SCAN_NETS", "").strip():
return os.environ["SCAN_NETS"].strip()
nets = []
for p in c["peers"]:
for n in _split(p.get("allowedips", "")):
if ":" not in n and not n.endswith("/0"):
nets.append(n)
return ",".join(nets)
# ── Beobachten ──────────────────────────────────────────────────
def watch(c: dict) -> None:
connected = False
since = 0
last_resolve = 0.0
while not _stop.is_set():
try:
dump = sh("wg", "show", IFACE, "dump").splitlines()[1:]
now = int(time.time())
hs = max((int(l.split("\t")[4]) for l in dump if l.count("\t") >= 6), default=0)
rx = sum(int(l.split("\t")[5]) for l in dump if l.count("\t") >= 6)
tx = sum(int(l.split("\t")[6]) for l in dump if l.count("\t") >= 6)
ep = ", ".join(l.split("\t")[2] for l in dump if l.count("\t") >= 6)
ok = hs > 0 and now - hs < HANDSHAKE_OK_S
if ok and not connected:
since = now
event("verbunden", endpoint=ep)
log.info("verbunden (%s)", ep)
elif connected and not ok:
event("getrennt", dauer_s=now - since, rx=rx, tx=tx)
log.info("getrennt nach %d s", now - since)
connected = ok
_conn.update(connected=ok, since=since, rx=rx, tx=tx)
st = {"state": "verbunden" if ok else "getrennt", "since": since if ok else 0,
"handshake": hs, "rx": rx, "tx": tx, "endpoint": ep, "updated": now}
tmp = STATUS + ".tmp"
with open(tmp, "w") as f:
json.dump(st, f)
os.replace(tmp, STATUS)
# DynDNS beim Kunden: ohne Verbindung den Namen neu aufloesen
if not ok and time.time() - last_resolve > 120:
last_resolve = time.time()
for p in c["peers"]:
if not p.get("endpoint"):
continue
h, port = endpoint_host_port(p["endpoint"])
for ip in resolve4(h):
sh("nft", "add", "element", "inet", "vpnsat", "endpoints", f"{{ {ip} }}", check=False)
sh("ip", "route", "replace", f"{ip}/32", "via", default_gw(), "dev", "eth0", check=False)
sh("wg", "set", IFACE, "peer", p["publickey"], "endpoint", f"{h}:{port}", check=False)
except Exception as e:
log.warning("Beobachten: %s", e)
_stop.wait(WATCH_S)
def main() -> None:
def _term(*_):
_stop.set()
signal.signal(signal.SIGTERM, _term)
signal.signal(signal.SIGINT, _term)
try:
c = parse_conf(open(CONF, encoding="utf-8").read())
info = setup(c)
except Exception as e:
log.error("Tunnel nicht aufgebaut: %s", e)
event("fehler", meldung=str(e)[:300])
with open(STATUS, "w") as f:
json.dump({"state": "fehler", "error": str(e)[:300], "updated": int(time.time())}, f)
time.sleep(60) # nicht im Sekundentakt neu starten
sys.exit(1)
event("gestartet", netze=info["nets"])
env = dict(os.environ)
env.update({
"SAT_KIND": "vpn",
"SATELLITE_ID": env.get("SATELLITE_ID") or f"vpn-{env.get('VPN_ID', 'x')}",
"SATELLITE_LOCATION": env.get("VPN_NAME") or env.get("SATELLITE_LOCATION") or "VPN",
"SCAN_NETS": scan_nets(c),
"LIST_SELF": "false",
"CONTROL_ENABLED": env.get("CONTROL_ENABLED", "true"),
})
threading.Thread(target=watch, args=(c,), daemon=True).start()
proc = None
while not _stop.is_set():
proc = subprocess.Popen([sys.executable, "-u", "/app/satellite.py"], env=env)
while proc.poll() is None and not _stop.is_set():
time.sleep(1)
if not _stop.is_set():
log.warning("satellite.py beendet (rc=%s) — starte neu", proc.returncode)
_stop.wait(5)
if proc and proc.poll() is None:
proc.terminate()
try:
proc.wait(10)
except subprocess.TimeoutExpired:
proc.kill()
if _conn["connected"]:
event("getrennt", dauer_s=int(time.time()) - _conn["since"], rx=_conn["rx"], tx=_conn["tx"])
event("gestoppt")
sh("ip", "link", "del", IFACE, check=False)
if __name__ == "__main__":
main()