From 9b25187084ae153cc3bc0054404ea047a27b54cf Mon Sep 17 00:00:00 2001 From: duffyduck Date: Sun, 1 Nov 2026 14:14:34 +0100 Subject: [PATCH] =?UTF-8?q?feat(vpn):=20WireGuard-Server=20=E2=80=94=20Kun?= =?UTF-8?q?den=20waehlen=20sich=20per=20UDP=20443=20ein,=20ein=20Verteiler?= =?UTF-8?q?=20fuer=20alle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- diagnostic/index.html | 98 +++++++++++++++++++--- diagnostic/server.js | 6 +- diagnostic/vpn.js | 131 ++++++++++++++++++++++++++++- docker-compose.yml | 20 +++++ satellite/Dockerfile.vpn | 2 +- satellite/README.md | 43 ++++++++-- satellite/vpn_entry.py | 10 ++- satellite/vpn_relay.py | 173 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 459 insertions(+), 24 deletions(-) create mode 100644 satellite/vpn_relay.py diff --git a/diagnostic/index.html b/diagnostic/index.html index 7995685..bd2d80b 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1387,14 +1387,16 @@ ' + '' + '' + - '' + + '' + '' + '' + ''; @@ -10172,6 +10178,7 @@ if (!r.ok) { uiAlert('Laden fehlgeschlagen\n\n' + (r.error || '')); return; } c = r.connection; } + if (c.kind === 'server') return _vpnServerForm(id, c); const inp = 'width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;'; _vpnModal( '' + (id ? 'Verbindung bearbeiten' : 'WireGuard-Verbindung hinzufügen') + '' + @@ -10197,6 +10204,71 @@ vpnCheckConf(); document.getElementById('vpn-f-name').focus(); } + function _vpnServerForm(id, c) { + const inp = 'width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;'; + _vpnModal( + '' + (id ? 'Einwahl bearbeiten' : 'Einwahl für einen Kunden anlegen') + '' + + '

ARIA erzeugt die Schlüssel. Danach mit ⬇ die Datei für die Gegenseite holen und dort importieren ' + + '(FritzBox: Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln → Datei übernehmen).

' + + '
Name (so heißt der VPN-Satellit)' + + '
' + + '
Kundennetz (LAN hinter der FritzBox, mehrere mit Komma)' + + '
' + + '
LAN-IP der FritzBox / des Routers (leer = .1 im Kundennetz)' + + '
' + + '
Netze zum Scannen (optional — leer = Kundennetz)' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
'); + document.getElementById('vpn-s-name').value = c.name || ''; + document.getElementById('vpn-s-lan').value = (c.lan || []).join(', '); + document.getElementById('vpn-s-router').value = c.routerIp || ''; + document.getElementById('vpn-s-nets').value = (c.scanNets && (c.lan || []).join(',') !== c.scanNets) ? c.scanNets : ''; + vpnServerHint(); + document.getElementById('vpn-s-name').focus(); + } + function vpnServerHint() { + const el = document.getElementById('vpn-s-hint'); + const lan = (document.getElementById('vpn-s-lan') || {}).value || ''; + if (!el) return; + el.innerHTML = /^192\.168\.178\./.test(lan.trim()) + ? 'Hinweis: 192.168.178.0/24 ist das FritzBox-Standardnetz. Das ist hier kein Problem — jede Einwahl hat ihren eigenen Container.' + : ''; + } + async function vpnServerSave(id) { + const name = document.getElementById('vpn-s-name').value.trim(); + const lan = document.getElementById('vpn-s-lan').value.trim(); + const routerIp = document.getElementById('vpn-s-router').value.trim(); + const scanNets = document.getElementById('vpn-s-nets').value.trim(); + const msg = document.getElementById('vpn-s-msg'); + const btn = document.getElementById('vpn-s-save'); + btn.disabled = true; msg.innerHTML = 'speichere …'; + const r = await vpnReq('vpn_save', { id, kind: 'server', name, lan, routerIp, scanNets }); + btn.disabled = false; + if (!r.ok) { msg.innerHTML = '✗ ' + escapeHtml(r.error || 'fehlgeschlagen') + ''; return; } + vpnCloseModal(); + if (!id) vpnServerDownload(r.id); + } + async function vpnServerDownload(id) { + const type = await _uiDialog({ message: 'Datei für die Gegenseite\n\n' + + 'FritzBox: Netzwerk-Kopplung (Address = LAN-IP der Box). Standard: eigene Tunnel-Adresse — ' + + 'Router/Linux muss das Kundennetz dann selbst weiterleiten.\n\nDie Datei enthält den privaten Schlüssel der Gegenseite — nur dort importieren.', + buttons: [{ label: 'Abbrechen', value: null, cancel: true }, + { label: 'Standard-WireGuard', value: 'standard' }, + { label: 'FritzBox', value: 'fritz', primary: true }] }); + if (!type) return; + const r = await vpnReq('vpn_profile', { id, type }); + if (!r.ok) { uiAlert('Download fehlgeschlagen\n\n' + (r.error || '')); return; } + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([r.profile.conf], { type: 'text/plain' })); + a.download = (r.profile.name || 'aria').replace(/[^\w.-]+/g, '_') + (type === 'fritz' ? '_fritzbox' : '') + '.conf'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + } function vpnLoadFile(input) { const f = input.files && input.files[0]; if (!f) return; @@ -10283,11 +10355,15 @@ '

Kunden wählen sich auf denselben Hostnamen wie der RVS ein, nur über UDP statt TCP. ' + 'TCP 443 (RVS, Webseiten) bleibt unverändert, beides stört sich nicht. Den Hostnamen nimmt ARIA automatisch aus den Einstellungen.

' + 'Mit Nginx Proxy Manager
    ' + - '
  1. Hosts → Streams → Add Stream: Incoming Port 443, Forward Host = ARIA-VM, Forward Port = Port des WireGuard-Verteilers, ' + + '
  2. Hosts → Streams → Add Stream: Incoming Port 443, Forward Host = ARIA-VM, Forward Port 51820 (Verteiler, VPN_RELAY_PORT), ' + 'TCP Forwarding aus, UDP Forwarding an (TCP an würde mit den Proxy-Hosts kollidieren).
  3. ' + '
  4. Im docker-compose des NPM den UDP-Port freigeben und neu starten:
    ports:\n  - \'443:443\'\n  - \'443:443/udp\'   # neu
  5. ' + '
  6. Am Router/an der Firewall UDP 443 ebenfalls auf den NPM-Rechner weiterleiten (nicht nur TCP).
' + - 'Ohne Nginx

Den RVS-Port am Router zusätzlich als UDP weiterleiten — direkt auf die ARIA-VM, Port des WireGuard-Verteilers.

' + + 'Ohne Nginx

Den RVS-Port am Router zusätzlich als UDP weiterleiten — direkt auf die ARIA-VM, Port 51820.

' + + 'Mit ufw als Firewall' + '

Hinweis: Läuft der Weg über nginx, sieht ARIA als Absender den Proxy statt der echten Kunden-IP. ' + 'Die erzeugten Profile senden alle 25 s ein Lebenszeichen, damit nginx die Verbindung nicht vergisst.

'); } diff --git a/diagnostic/server.js b/diagnostic/server.js index 0961c2b..14d2f5b 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1101,7 +1101,8 @@ const credsRevealWaiters = new Map(); function vpnRvs() { return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK, - token: RVS_TOKEN, sni: process.env.RVS_SNI || "" }; + token: RVS_TOKEN, sni: process.env.RVS_SNI || "", + publicPort: process.env.VPN_PUBLIC_PORT || "443" }; } async function broadcastVpn() { @@ -1122,6 +1123,9 @@ async function handleVpn(ws, msg) { case "vpn_get": reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) }); return; + case "vpn_profile": + reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) }); + return; case "vpn_info": reply({ ok: true, info: await vpn.info(String(msg.id || "")) }); return; diff --git a/diagnostic/vpn.js b/diagnostic/vpn.js index ae859eb..f7499f9 100644 --- a/diagnostic/vpn.js +++ b/diagnostic/vpn.js @@ -14,6 +14,11 @@ // /data/vpn//tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier) // /data/vpn//data/ Satelliten-Daten (Zugangsdaten, Namen, …) // +// Server-Verbindungen (Kunde waehlt sich ein, z.B. FritzBox): ARIA erzeugt +// beide Schluesselpaare + PSK, der Container lauscht auf 51820 mit fester IP +// im Netz aria-vpn; der Verteiler (satellite/vpn_relay.py, UDP-Port fuer +// alle) ordnet die Pakete per mac1 zu und liest dafuer /data/vpn/relay.json. +// // An = Container mit restart unless-stopped (nach Neustart wieder da). // Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf. @@ -27,6 +32,15 @@ const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest"; const NETWORK = "aria-vpn"; const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic"; const PREFIX = "aria-vpn-"; +// Netz der VPN-Container: dynamische IPs (Clients) aus der unteren Haelfte, +// Server-Verbindungen bekommen feste IPs aus der oberen (.128 + n), damit der +// Verteiler sie auch nach einem Neustart findet. +// Bewusst ausserhalb von Dockers eigenen Pools (172.17–31.x, 192.168.x), +// sonst kollidiert es mit automatisch angelegten Compose-Netzen. +const SUBNET = process.env.VPN_SUBNET || "10.213.213.0/24"; +const SUBNET_BASE = SUBNET.split("/")[0].split(".").slice(0, 3).join("."); +const SERVER_PORT = 51820; // WireGuard im Server-Container +const TUNNEL_NET = (n) => `10.222.${n}`; // je Server-Verbindung ein /24 function docker(method, apiPath, body) { return new Promise((resolve, reject) => { @@ -121,6 +135,7 @@ async function ensureNetwork() { const c = await docker("POST", "/networks/create", { Name: NETWORK, Driver: "bridge", Options: { "com.docker.network.bridge.enable_icc": "false" }, + IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] }, Labels: { "aria.vpn": "1" }, }); if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`); @@ -158,6 +173,8 @@ async function startContainer(conn, rvs) { NetworkMode: NETWORK, LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } }, }, + ...(conn.kind === "server" ? { NetworkingConfig: { EndpointsConfig: { + [NETWORK]: { IPAMConfig: { IPv4Address: serverIp(conn) } } } } } : {}), }); if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`); const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`); @@ -185,6 +202,105 @@ async function containerLogs(id, lines = 60) { return out.join(""); } +// ── Server-Verbindungen ───────────────────────────────────────── +function wgKeypair() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519"); + return { + priv: privateKey.export({ format: "der", type: "pkcs8" }).slice(-32).toString("base64"), + pub: publicKey.export({ format: "der", type: "spki" }).slice(-32).toString("base64"), + }; +} +const serverIp = (conn) => `${SUBNET_BASE}.${128 + conn.n}`; +const keysFile = (id) => path.join(tunnelDir(id), "keys.json"); + +function parseLan(v) { + const nets = String(v || "").split(/[\s,]+/).filter(Boolean); + if (!nets.length) throw new Error("Kundennetz fehlt (z.B. 192.168.178.0/24)."); + for (const n of nets) { + const m = n.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/); + if (!m || m.slice(1, 5).some((x) => +x > 255) || +m[5] < 8 || +m[5] > 32) { + throw new Error(`Kundennetz „${n}" ist kein Netz wie 192.168.178.0/24.`); + } + } + return nets; +} + +// Server-seitige wg.conf (im Container): lauscht, Gegenseite = Kunde +function serverConf(conn, k) { + const t = TUNNEL_NET(conn.n); + return ["[Interface]", `PrivateKey = ${k.srvPriv}`, `ListenPort = ${SERVER_PORT}`, `Address = ${t}.1/24`, "", + "[Peer]", `PublicKey = ${k.peerPub}`, `PresharedKey = ${k.psk}`, + `AllowedIPs = ${[`${t}.2/32`, ...conn.lan].join(", ")}`, ""].join("\n"); +} + +// Datei fuer die Gegenseite. FritzBox (Netzwerk-Kopplung): Address = eigene +// LAN-Adresse der Box, AllowedIPs = unser Netz (kein 0.0.0.0/0 — lehnt die +// Box ab). Standard: eigene Tunnel-Adresse, die Gegenseite muss ihr LAN dann +// selbst routen/maskieren. +function peerProfile(conn, k, type, rvs) { + const t = TUNNEL_NET(conn.n); + const lan0 = conn.lan[0]; + const prefix = lan0.split("/")[1]; + const addr = type === "fritz" ? `${conn.routerIp}/${prefix}` : `${t}.2/24`; + return [`# ARIA VPN-Satellit „${conn.name}" — ${type === "fritz" ? "für FRITZ!Box (Netzwerk-Kopplung)" : "Standard-WireGuard"}`, + "[Interface]", `PrivateKey = ${k.peerPriv}`, `Address = ${addr}`, "", + "[Peer]", `PublicKey = ${k.srvPub}`, `PresharedKey = ${k.psk}`, + `AllowedIPs = ${t}.0/24`, `Endpoint = ${rvs.host}:${rvs.publicPort || 443}`, + "PersistentKeepalive = 25", ""].join("\n"); +} + +function writeRelayMap() { + const rows = []; + for (const c of loadConns()) { + if (c.kind !== "server" || !c.enabled) continue; + const k = readJson(keysFile(c.id), null); + if (k) rows.push({ id: c.id, ip: serverIp(c), port: SERVER_PORT, pub: k.srvPub }); + } + writeJson(path.join(DIR, "relay.json"), rows, 0o644); +} + +async function saveServer(msg, rvs) { + const name = String(msg.name || "").trim().slice(0, 60); + if (!name) throw new Error("Name fehlt."); + const lan = parseLan(msg.lan); + const routerIp = String(msg.routerIp || "").trim() || + lan[0].split("/")[0].replace(/\.\d+$/, ".1"); + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(routerIp)) throw new Error("Router-IP ist keine IP-Adresse."); + const conns = loadConns(); + let conn = msg.id ? conns.find((c) => c.id === msg.id) : null; + if (!conn) { + const used = new Set(conns.filter((c) => c.kind === "server").map((c) => c.n)); + let n = 1; + while (used.has(n)) n++; + if (n > 126) throw new Error("Höchstens 126 Server-Verbindungen."); + conn = { id: crypto.randomBytes(4).toString("hex"), kind: "server", n, enabled: false, createdAt: Date.now() }; + conns.push(conn); + } + const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",") || lan.join(","); + Object.assign(conn, { name, lan, routerIp, scanNets, updatedAt: Date.now() }); + fs.mkdirSync(tunnelDir(conn.id), { recursive: true }); + let k = readJson(keysFile(conn.id), null); + if (!k) { + const s = wgKeypair(); const p = wgKeypair(); + k = { srvPriv: s.priv, srvPub: s.pub, peerPriv: p.priv, peerPub: p.pub, + psk: crypto.randomBytes(32).toString("base64") }; + writeJson(keysFile(conn.id), k); + } + fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), serverConf(conn, k), { mode: 0o600 }); + saveConns(conns); + if (conn.enabled) await startContainer(conn, rvs); + writeRelayMap(); + return { id: conn.id }; +} + +function profile(id, type, rvs) { + const conn = loadConns().find((c) => c.id === id); + if (!conn || conn.kind !== "server") throw new Error("Server-Verbindung unbekannt."); + const k = readJson(keysFile(id), null); + if (!k) throw new Error("Schlüssel fehlen."); + return { name: conn.name, type, conf: peerProfile(conn, k, type, rvs) }; +} + // ── API (vom ws-Handler in server.js) ─────────────────────────── async function list() { const conns = loadConns(); @@ -197,6 +313,9 @@ async function list() { } async function save(msg, rvs) { + if ((msg.kind || "") === "server" || (msg.id && (loadConns().find((c) => c.id === msg.id) || {}).kind === "server")) { + return saveServer(msg, rvs); + } const name = String(msg.name || "").trim().slice(0, 60); const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n"; if (!name) throw new Error("Name fehlt."); @@ -224,8 +343,12 @@ async function toggle(id, enabled, rvs) { if (!conn) throw new Error("Verbindung unbekannt."); conn.enabled = !!enabled; saveConns(conns); - if (conn.enabled) await startContainer(conn, rvs); - else await removeContainer(id); + try { + if (conn.enabled) await startContainer(conn, rvs); + else await removeContainer(id); + } finally { + writeRelayMap(); + } return { id, enabled: conn.enabled }; } @@ -235,12 +358,14 @@ async function remove(id) { await removeContainer(id); saveConns(conns.filter((c) => c.id !== id)); fs.rmSync(path.join(DIR, id), { recursive: true, force: true }); + writeRelayMap(); return { id }; } function getConf(id) { const conn = loadConns().find((c) => c.id === id); if (!conn) throw new Error("Verbindung unbekannt."); + if (conn.kind === "server") return { ...conn }; // Schluessel bleiben auf ARIA return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") }; } @@ -279,4 +404,4 @@ function rotate() { } setInterval(rotate, 3600 * 1000).unref(); -module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf }; +module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap }; diff --git a/docker-compose.yml b/docker-compose.yml index 15b5828..797d86c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -180,6 +180,26 @@ services: network_mode: none restart: "no" + # ─── VPN-Verteiler: EIN UDP-Port fuer alle WireGuard-Server-Verbindungen ─ + # Kunden waehlen sich auf UDP 443 am RVS-Hostnamen ein; der NPM-Stream (oder + # der Router) leitet das hierher (VPN_RELAY_PORT, Standard 51820). Der + # Verteiler ordnet jedes Paket per WireGuard-mac1 der richtigen Verbindung + # (eigener Container) zu, ohne etwas zu entschluesseln; Unbekanntes wird + # still verworfen. Host-Netz, weil er die Container-IPs im Netz aria-vpn + # erreichen muss (dort ist Container-zu-Container gesperrt). + vpn-relay: + image: aria-vpn-satellite:latest + container_name: aria-vpn-relay + depends_on: + - vpn-satellite-image + network_mode: host + command: ["python", "-u", "vpn_relay.py"] + environment: + - VPN_RELAY_PORT=${VPN_RELAY_PORT:-51820} + volumes: + - ./aria-data/config/diag-state/vpn:/vpn:ro + restart: unless-stopped + networks: aria-net: driver: bridge diff --git a/satellite/Dockerfile.vpn b/satellite/Dockerfile.vpn index 6952ac1..9c31926 100644 --- a/satellite/Dockerfile.vpn +++ b/satellite/Dockerfile.vpn @@ -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"] diff --git a/satellite/README.md b/satellite/README.md index 0648244..19a6327 100644 --- a/satellite/README.md +++ b/satellite/README.md @@ -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..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..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 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 + ``` diff --git a/satellite/vpn_entry.py b/satellite/vpn_entry.py index cdd044d..fd2bbc2 100644 --- a/satellite/vpn_entry.py +++ b/satellite/vpn_entry.py @@ -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} diff --git a/satellite/vpn_relay.py b/satellite/vpn_relay.py new file mode 100644 index 0000000..9d0ca21 --- /dev/null +++ b/satellite/vpn_relay.py @@ -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(" 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("= 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())