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:
2026-11-01 13:30:10 +01:00
co-authored by Claude Opus 5.5
parent ca35f5f1e0
commit 627b826226
9 changed files with 1155 additions and 12 deletions
+76 -6
View File
@@ -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),
})