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:
+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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user