feat(vpn): WireGuard-Server — Kunden waehlen sich per UDP 443 ein, ein Verteiler fuer alle
Einstellungen → VPN → WireGuard → Server: Einwahl anlegen (Name, Kundennetz, LAN-IP der FritzBox); ARIA erzeugt beide Schluesselpaare + PSK, ⬇ liefert die Datei fuer die Gegenseite (FritzBox-Netzwerk-Kopplung oder Standard-WireGuard). Jede Einwahl = eigener VPN-Satellit-Container mit fester IP im Netz aria-vpn (10.213.213.0/24, ausserhalb von Dockers Pools). Neuer Verteiler (satellite/vpn_relay.py, aria-vpn-relay, Host-Netz, VPN_RELAY_PORT 51820): ordnet jedes Paket per WireGuard-mac1 bzw. Sitzungsnummer dem richtigen Container zu, ohne zu entschluesseln; Fremdes wird still verworfen. Anleitung NPM-Stream / ohne Nginx / ufw im ℹ des Server-Reiters und im satellite/README. Lokal Ende-zu-Ende getestet: zwei Kunden mit demselben Netz 192.168.178.0/24 gleichzeitig ueber einen Port, jeder erreicht nur sein eigenes LAN; kein Weg Kunde → ARIA, Kunde 1 → Einwahl 2, Container → Internet; fremder Schluessel und Muell am Verteiler ohne Antwort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,6 @@ RUN apt-get update \
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY satellite.py vpn_entry.py ./
|
||||
COPY satellite.py vpn_entry.py vpn_relay.py ./
|
||||
|
||||
CMD ["python", "-u", "vpn_entry.py"]
|
||||
|
||||
+37
-6
@@ -154,9 +154,40 @@ oder einfügen, Namen vergeben, einschalten.
|
||||
- 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.
|
||||
**Server** (Kunden wählen sich bei ARIA ein, z.B. per FritzBox): im Reiter Server
|
||||
„+ Hinzufügen“, Namen, Kundennetz (z.B. `192.168.178.0/24`) und LAN-IP der FritzBox eintragen.
|
||||
ARIA erzeugt beide Schlüsselpaare und einen Pre-Shared-Key; ⬇ liefert die Datei für die
|
||||
Gegenseite:
|
||||
- **FritzBox** (Netzwerk-Kopplung): `Address` = LAN-IP der Box, `AllowedIPs` = ARIAs
|
||||
Tunnelnetz `10.222.<n>.0/24` (kein `0.0.0.0/0` — das lehnt die FritzBox ab). Import:
|
||||
Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln →
|
||||
Einstellungen aus Datei übernehmen. *Noch nicht an einer echten FritzBox getestet.*
|
||||
- **Standard-WireGuard** (Linux, andere Router): eigene Tunnel-Adresse `10.222.<n>.2`; das
|
||||
Kundennetz muss die Gegenseite dann selbst weiterleiten bzw. maskieren.
|
||||
|
||||
Alle Einwahlen kommen über **einen** Port: Der **Verteiler** (`vpn_relay.py`, Container
|
||||
`aria-vpn-relay`, Host-Netz, `VPN_RELAY_PORT`, Standard 51820) ordnet jedes Paket anhand der
|
||||
WireGuard-Prüfsumme `mac1` der richtigen Verbindung zu — jede Einwahl hat ihr eigenes
|
||||
Server-Schlüsselpaar und ihren eigenen Container mit fester IP im Netz `aria-vpn`
|
||||
(`10.213.213.0/24`, änderbar mit `VPN_SUBNET`). Entschlüsselt wird dabei nichts; fremde
|
||||
Schlüssel und alles andere verwirft er still. Auch zwei Kunden mit demselben Netz
|
||||
(`192.168.178.0/24`) funktionieren gleichzeitig.
|
||||
|
||||
**Erreichbar machen — UDP 443 auf dem RVS-Hostnamen** (TCP 443 bleibt beim RVS):
|
||||
- *Mit Nginx Proxy Manager:* Hosts → **Streams** → Add Stream: Incoming Port `443`,
|
||||
Forward Host = ARIA-VM, Forward Port `51820`, **TCP Forwarding aus, UDP Forwarding an**
|
||||
(TCP würde mit den Proxy-Hosts kollidieren). Im docker-compose des NPM `- '443:443/udp'`
|
||||
ergänzen und neu starten. Am Router UDP 443 ebenfalls auf den NPM-Rechner leiten.
|
||||
Hinweis: ARIA sieht dann als Absender den NPM statt der Kunden-IP; die erzeugten Dateien
|
||||
senden alle 25 s ein Lebenszeichen, damit nginx die Zuordnung behält.
|
||||
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** weiterleiten — direkt auf die
|
||||
ARIA-VM, Port `51820`.
|
||||
- *Mit ufw als Firewall:*
|
||||
```bash
|
||||
# auf dem NPM-Rechner: UDP 443 aus dem Internet erlauben
|
||||
ufw allow 443/udp comment 'WireGuard -> ARIA'
|
||||
# auf der ARIA-VM: Verteiler-Port NUR vom NPM erlauben
|
||||
ufw allow proto udp from <IP-des-NPM> to any port 51820 comment 'VPN-Verteiler nur vom NPM'
|
||||
# ohne Nginx statt dessen (der Router leitet direkt auf die VM):
|
||||
ufw allow 51820/udp
|
||||
```
|
||||
|
||||
@@ -149,9 +149,13 @@ def default_gw() -> str:
|
||||
return m.group(1)
|
||||
|
||||
|
||||
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str]) -> None:
|
||||
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str], listen: int = 0) -> 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"
|
||||
# Server-Verbindung: der Verteiler (vpn_relay.py) reicht die Pakete der
|
||||
# Gegenseite an diesen Port weiter — nur WireGuard, nur ueber eth0.
|
||||
lin = f'iifname "eth0" udp dport {listen} accept' if listen else ""
|
||||
lout = f'oifname "eth0" udp sport {listen} accept' if listen else ""
|
||||
# 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)
|
||||
@@ -163,6 +167,7 @@ table inet vpnsat {{
|
||||
iif lo accept
|
||||
ct state established,related accept
|
||||
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
|
||||
{lin}
|
||||
}}
|
||||
chain forward {{
|
||||
type filter hook forward priority 0; policy drop;
|
||||
@@ -174,6 +179,7 @@ table inet vpnsat {{
|
||||
oifname "{IFACE}" accept
|
||||
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
|
||||
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
|
||||
{lout}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
@@ -221,7 +227,7 @@ def setup(c: dict) -> dict:
|
||||
else:
|
||||
sh("ip", "route", "replace", str(net), "dev", IFACE)
|
||||
nets.append(str(net))
|
||||
firewall(rvs_ips, rvs_port, ep_ips)
|
||||
firewall(rvs_ips, rvs_port, ep_ips, int(c["iface"].get("listenport") or 0))
|
||||
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
|
||||
return {"nets": nets, "endpoints": ep_ips}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
ARIA VPN-Verteiler — EIN UDP-Port fuer alle WireGuard-Server-Verbindungen.
|
||||
|
||||
Kunden waehlen sich alle auf denselben Port ein (UDP 443 am RVS-Hostnamen,
|
||||
per NPM-Stream bzw. Router hierher weitergeleitet). Jede Server-Verbindung
|
||||
laeuft aber in ihrem EIGENEN Container (eigener Netz-Namensraum, wegen
|
||||
ueberlappender Kundennetze). Der Verteiler ordnet die Pakete zu, ohne etwas
|
||||
zu entschluesseln:
|
||||
|
||||
Typ 1 (Handshake-Anfang): mac1 = BLAKE2s(Schluessel aus dem oeffentlichen
|
||||
Schluessel des EMPFAENGERS, Nachricht). Jede Verbindung hat ein eigenes
|
||||
Server-Schluesselpaar → der Verteiler rechnet mac1 fuer jede bekannte
|
||||
Verbindung nach und weiss, wohin das Paket gehoert.
|
||||
Typ 2 (Antwort des Containers): merkt sich dessen Sitzungsnummer.
|
||||
Typ 3/4 (Cookie/Daten): nach Absender-Adresse, sonst nach Sitzungsnummer
|
||||
(Kunde hat die Adresse gewechselt).
|
||||
|
||||
Jeder Absender bekommt einen eigenen Upstream-Socket zum Container, so finden
|
||||
die Antworten zurueck. Unbekanntes wird still verworfen — wie WireGuard selbst
|
||||
(ein Portscan sieht nichts).
|
||||
|
||||
Laeuft mit network_mode: host (erreicht die Container-IPs im Netz aria-vpn,
|
||||
dort ist Container-zu-Container gesperrt). Liest /vpn/relay.json, das die
|
||||
Diagnostic schreibt: [{id, ip, port, pub}].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn-relay] %(levelname)s %(message)s")
|
||||
log = logging.getLogger("vpn-relay")
|
||||
|
||||
LISTEN_PORT = int(os.environ.get("VPN_RELAY_PORT", "51820") or 51820)
|
||||
MAP_FILE = os.environ.get("VPN_RELAY_MAP", "/vpn/relay.json")
|
||||
IDLE_S = 600
|
||||
MAX_CLIENTS = 2048
|
||||
|
||||
_targets: list[dict] = [] # [{id, addr:(ip,port), mac1key}]
|
||||
_map_mtime = 0.0
|
||||
_by_index: dict[int, str] = {} # Sitzungsnummer des Containers -> id
|
||||
_clients: dict = {} # Kunden-Adresse -> Upstream
|
||||
|
||||
|
||||
def _mac1_key(pub_b64: str) -> bytes:
|
||||
pub = base64.b64decode(pub_b64)
|
||||
return hashlib.blake2s(b"mac1----" + pub).digest()
|
||||
|
||||
|
||||
def load_map() -> None:
|
||||
global _targets, _map_mtime
|
||||
try:
|
||||
m = os.path.getmtime(MAP_FILE)
|
||||
except OSError:
|
||||
if _targets:
|
||||
log.info("keine Zuordnung mehr (%s fehlt)", MAP_FILE)
|
||||
_targets, _map_mtime = [], 0.0
|
||||
return
|
||||
if m == _map_mtime:
|
||||
return
|
||||
try:
|
||||
rows = json.load(open(MAP_FILE))
|
||||
_targets = [{"id": r["id"], "addr": (r["ip"], int(r.get("port", 51820))),
|
||||
"mac1key": _mac1_key(r["pub"])} for r in rows if r.get("ip") and r.get("pub")]
|
||||
_map_mtime = m
|
||||
log.info("%d Server-Verbindung(en): %s", len(_targets),
|
||||
", ".join(f"{t['id']}→{t['addr'][0]}" for t in _targets) or "-")
|
||||
except Exception as e:
|
||||
log.warning("relay.json unlesbar: %s", e)
|
||||
|
||||
|
||||
def target_for_init(data: bytes) -> dict | None:
|
||||
if len(data) != 148:
|
||||
return None
|
||||
for t in _targets:
|
||||
mac = hashlib.blake2s(data[:116], key=t["mac1key"], digest_size=16).digest()
|
||||
if mac == data[116:132]:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
class Upstream(asyncio.DatagramProtocol):
|
||||
"""Ein Socket je Kunde → Container; leitet Antworten zurueck."""
|
||||
|
||||
def __init__(self, front, client_addr, target):
|
||||
self.front, self.client, self.target = front, client_addr, target
|
||||
self.transport = None
|
||||
self.last = time.time()
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
self.last = time.time()
|
||||
if len(data) >= 8 and data[0] == 2: # Antwort: Sitzungsnummer merken
|
||||
_by_index[struct.unpack_from("<I", data, 4)[0]] = self.target["id"]
|
||||
self.front.sendto(data, self.client)
|
||||
|
||||
def error_received(self, exc):
|
||||
pass
|
||||
|
||||
|
||||
class Front(asyncio.DatagramProtocol):
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
asyncio.ensure_future(self.handle(data, addr))
|
||||
|
||||
async def handle(self, data: bytes, addr) -> None:
|
||||
if len(data) < 4:
|
||||
return
|
||||
load_map()
|
||||
kind = data[0]
|
||||
up = _clients.get(addr)
|
||||
target = None
|
||||
if kind == 1:
|
||||
target = target_for_init(data)
|
||||
if not target:
|
||||
return # fremder Schluessel → still verwerfen
|
||||
elif up is None and kind == 4 and len(data) >= 8:
|
||||
tid = _by_index.get(struct.unpack_from("<I", data, 4)[0])
|
||||
target = next((t for t in _targets if t["id"] == tid), None)
|
||||
if not target:
|
||||
return
|
||||
elif up is None:
|
||||
return
|
||||
if up and target and up.target["id"] != target["id"]:
|
||||
up.transport.close()
|
||||
up = None
|
||||
if up is None or up.target["addr"] != (target or up.target)["addr"]:
|
||||
if up:
|
||||
up.transport.close()
|
||||
if len(_clients) >= MAX_CLIENTS:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
t = target or up.target
|
||||
_, up = await loop.create_datagram_endpoint(
|
||||
lambda: Upstream(self.transport, addr, t), remote_addr=t["addr"])
|
||||
_clients[addr] = up
|
||||
up.last = time.time()
|
||||
up.transport.sendto(data)
|
||||
|
||||
|
||||
async def janitor() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(60)
|
||||
now = time.time()
|
||||
for a, up in list(_clients.items()):
|
||||
if now - up.last > IDLE_S:
|
||||
up.transport.close()
|
||||
del _clients[a]
|
||||
if len(_by_index) > 8192:
|
||||
_by_index.clear()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_map()
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.create_datagram_endpoint(Front, local_addr=("0.0.0.0", LISTEN_PORT))
|
||||
log.info("lausche auf UDP %d", LISTEN_PORT)
|
||||
await janitor()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user