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>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# ARIA VPN-Satellit — WireGuard-Tunnel + Satellit in EINEM Container pro
|
||||
# Verbindung (angelegt von der Diagnostic). Laeuft NICHT im Host-Netz, sondern
|
||||
# im Docker-Netz aria-vpn; braucht nur NET_ADMIN (wg0, Routen, nftables).
|
||||
# Das WireGuard-Kernelmodul kommt vom Host.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends wireguard-tools iproute2 nftables snmp adb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py vpn_entry.py ./
|
||||
|
||||
CMD ["python", "-u", "vpn_entry.py"]
|
||||
@@ -117,3 +117,46 @@ Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar —
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
sonst `false` und nur beobachten.
|
||||
|
||||
## VPN-Satelliten (WireGuard, ohne Installation beim Kunden)
|
||||
|
||||
Ein VPN-Satellit ist ein Satellit, der **nicht im Kundennetz** läuft, sondern auf ARIA —
|
||||
verbunden über einen WireGuard-Tunnel. Angelegt wird er in der Diagnostic unter
|
||||
**Einstellungen → VPN → WireGuard**; im Reiter Satelliten steht er unter „VPN-Satelliten 🔐“.
|
||||
|
||||
**Clients** (ARIA wählt sich beim Kunden ein): die WireGuard-Datei des Kunden hochladen
|
||||
oder einfügen, Namen vergeben, einschalten.
|
||||
- **An** = Container mit Neustart-Automatik (verbindet sich nach einem Neustart wieder).
|
||||
**Aus** = Container weg, Datei und Log bleiben — Kundennetze nur bei Bedarf.
|
||||
- ✏️ bearbeiten · ⬇ Datei wieder herunterladen · ℹ Verlauf (wann verbunden, wie lange,
|
||||
Datenmenge) · 🗑 löschen. Log-Aufbewahrung im Reiter Einstellungen.
|
||||
|
||||
**Wie es gebaut ist** (`Dockerfile.vpn`, `vpn_entry.py`, `diagnostic/vpn.js`):
|
||||
- **Ein Container pro Verbindung** (`aria-vpn-<id>`, Netz `aria-vpn`, nur `NET_ADMIN`).
|
||||
Eigener Netz-Namensraum: überlappende Kundennetze (überall `192.168.178.0/24`) stören
|
||||
sich nicht, und lokale Satelliten im Host-Netz sehen die VPN-Netze nie.
|
||||
- **Firewall im Container:** durch den Tunnel nur ausgehend, zurück kommen nur Antworten —
|
||||
auch wenn die Gegenseite den Tunnel aufgebaut hat, führt keine Verbindung vom Kundennetz
|
||||
zu ARIA. Ins ARIA-Netz/Internet darf der Container nur zum RVS und zum Tunnel-Endpunkt.
|
||||
- `wg0` wird selbst aufgesetzt, nicht per `wg-quick`: `DNS`/`PostUp` aus fremden Dateien
|
||||
werden ignoriert, und bei `AllowedIPs = 0.0.0.0/0` bleibt der Weg zum RVS trotzdem
|
||||
außerhalb des Tunnels. Endpunkte mit Hostnamen (DynDNS) werden neu aufgelöst, solange
|
||||
keine Verbindung steht.
|
||||
- **Scan:** über WireGuard gibt es kein mDNS/SSDP/ARP — der VPN-Satellit klopft die Netze
|
||||
aktiv ab (`SCAN_NETS`, Standard = AllowedIPs ohne `0.0.0.0/0`; typische Ports, auch
|
||||
„Verbindung abgelehnt“ zählt als Gerät). FritzBoxen erkennt er an Port 49000.
|
||||
- Daten pro Verbindung unter `aria-data/config/diag-state/vpn/<id>/` (`tunnel/wg.conf`
|
||||
mit privatem Schlüssel, `status.json`, `events.jsonl`, `data/` = Zugangsdaten usw.).
|
||||
|
||||
**Voraussetzungen auf der ARIA-VM:**
|
||||
- WireGuard-Kernelmodul: `modprobe wireguard` und dauerhaft
|
||||
`echo wireguard > /etc/modules-load.d/wireguard.conf`
|
||||
- Image bauen: `docker compose up -d --build vpn-satellite-image` (baut nur das Image,
|
||||
der Container beendet sich sofort wieder — das ist richtig so).
|
||||
|
||||
**Server** (Kunden wählen sich bei ARIA ein, z.B. per FritzBox) kommt als nächster Schritt.
|
||||
Erreichbar über **UDP 443** auf dem RVS-Hostnamen (TCP 443 bleibt beim RVS):
|
||||
- *Mit Nginx Proxy Manager:* Hosts → Streams → Incoming Port 443, Forward Host = ARIA-VM,
|
||||
**nur UDP** (TCP würde mit den Proxy-Hosts kollidieren); im NPM-Compose `'443:443/udp'`
|
||||
ergänzen; am Router UDP 443 ebenfalls auf den NPM-Rechner.
|
||||
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** direkt auf die ARIA-VM weiterleiten.
|
||||
|
||||
+76
-6
@@ -131,6 +131,16 @@ if RVS_TLS and RVS_SNI:
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
|
||||
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
|
||||
|
||||
# VPN-Satellit (satellite/vpn_entry.py): sitzt nicht IM Netz, sondern hinter
|
||||
# einem WireGuard-Tunnel davor. Ueber den Tunnel kommen kein mDNS/SSDP/ARP —
|
||||
# gescannt wird darum aktiv: die Netze aus SCAN_NETS werden per TCP abgeklopft.
|
||||
SAT_KIND = (os.environ.get("SAT_KIND") or "lan").strip().lower()
|
||||
SCAN_NETS = [n.strip() for n in os.environ.get("SCAN_NETS", "").split(",") if n.strip()]
|
||||
SCAN_MAX_HOSTS = int(os.environ.get("SCAN_MAX_HOSTS", "1024") or "1024")
|
||||
SCAN_PORTS = [int(p) for p in os.environ.get(
|
||||
"SCAN_PORTS", "80,443,22,49000,445,3389,8080,8443,9100,5555,161,53,23,21,25,139,631,5000,8006").split(",")
|
||||
if p.strip().isdigit()]
|
||||
|
||||
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
||||
CONTROL_ALLOWLIST = [
|
||||
a.strip() for a in
|
||||
@@ -417,6 +427,9 @@ def _net_context() -> dict:
|
||||
ips.insert(0, primary)
|
||||
p = primary or (ips[0] if ips else "")
|
||||
warning = ""
|
||||
if SAT_KIND == "vpn":
|
||||
# Laeuft absichtlich in einem Docker-Netz, das Ziel liegt hinter dem Tunnel.
|
||||
return {"primary_ip": p, "ips": ips, "mac": "", "warning": "", "vpnNets": SCAN_NETS}
|
||||
if p.startswith("192.168.65.") or _in_docker_bridge(p):
|
||||
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
|
||||
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
|
||||
@@ -679,6 +692,58 @@ def _discover_arp() -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
async def _discover_routed(nets: list[str]) -> list[dict]:
|
||||
"""Aktiver Scan fuer VPN-Satelliten: jede Adresse der Netze auf typische
|
||||
Ports abklopfen. Auch "Verbindung abgelehnt" heisst: da ist ein Geraet.
|
||||
Antwortet 49000 mit einer FritzBox-Selbstbeschreibung, ist es eine."""
|
||||
import ipaddress
|
||||
hosts: list[str] = []
|
||||
for n in nets:
|
||||
try:
|
||||
net = ipaddress.ip_network(n, strict=False)
|
||||
except ValueError:
|
||||
continue
|
||||
if net.version != 4:
|
||||
continue
|
||||
for h in (net.hosts() if net.num_addresses > 2 else [net.network_address]):
|
||||
if len(hosts) >= SCAN_MAX_HOSTS:
|
||||
break
|
||||
hosts.append(str(h))
|
||||
sem = asyncio.Semaphore(256)
|
||||
found: dict[str, set] = {}
|
||||
|
||||
async def probe(ip: str, port: int) -> None:
|
||||
async with sem:
|
||||
try:
|
||||
_r, w = await asyncio.wait_for(asyncio.open_connection(ip, port), 1.2)
|
||||
found.setdefault(ip, set()).add(port)
|
||||
w.close()
|
||||
except ConnectionRefusedError:
|
||||
found.setdefault(ip, set())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
await asyncio.gather(*(probe(ip, p) for ip in hosts for p in SCAN_PORTS))
|
||||
out = []
|
||||
loop = asyncio.get_event_loop()
|
||||
for ip in sorted(found, key=lambda a: tuple(int(x) for x in a.split("."))):
|
||||
ports = sorted(found[ip])
|
||||
d = {"id": _slug(f"host-{ip}"), "name": ip, "type": "host", "ip": ip,
|
||||
"via": "vpn-scan", "ports": ports}
|
||||
if 49000 in ports:
|
||||
try:
|
||||
xml = await loop.run_in_executor(None, _fb_get, ip, "/tr64desc.xml")
|
||||
fn = _xml_tag(xml, "friendlyName") or _xml_tag(xml, "modelName")
|
||||
if "fritz" in (xml or "").lower():
|
||||
d.update({"type": "router", "name": fn or "FRITZ!Box",
|
||||
"model": _xml_tag(xml, "modelName") or fn})
|
||||
except Exception:
|
||||
pass
|
||||
out.append(d)
|
||||
logger.info("[scan] VPN: %d Adressen in %s abgeklopft, %d Geraete", len(hosts), ",".join(nets), len(out))
|
||||
return out
|
||||
|
||||
|
||||
def _merge_devices(*lists) -> list[dict]:
|
||||
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||
by_ip: dict[str, dict] = {}
|
||||
@@ -2626,12 +2691,16 @@ class Satellite:
|
||||
async def _scan_inner(self) -> None:
|
||||
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
|
||||
loop = asyncio.get_event_loop()
|
||||
self._scan_stage = "mdns"
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "ssdp"
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "arp"
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
if SAT_KIND == "vpn":
|
||||
self._scan_stage = "vpn"
|
||||
mdns, ssdp, arp = [], [], await _discover_routed(SCAN_NETS)
|
||||
else:
|
||||
self._scan_stage = "mdns"
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "ssdp"
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "arp"
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
_self = [_self_device()] if LIST_SELF else []
|
||||
_self = [d for d in _self if d]
|
||||
devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
|
||||
@@ -2718,6 +2787,7 @@ class Satellite:
|
||||
"caps": CAPABILITIES,
|
||||
"control": CONTROL_ENABLED,
|
||||
"net": NET,
|
||||
"kind": SAT_KIND,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user