- Hier wählen sich Kunden (z.B. per FritzBox) bei ARIA ein — über UDP 443 auf dem RVS-Hostnamen.
- Jede Verbindung wird ebenfalls ein VPN-Satellit; aus dem Kundennetz führt keine Verbindung zu ARIA.
- Kommt im nächsten Schritt.
+ Hier wählen sich Kunden (z.B. per FritzBox) bei ARIA ein — alle über UDP 443 auf dem RVS-Hostnamen.
+ ARIA erzeugt die Schlüssel; ⬇ liefert die Datei für die Gegenseite (FritzBox oder Standard-WireGuard).
+ Jede Verbindung wird ein VPN-Satellit; aus dem Kundennetz führt keine Verbindung zu ARIA.
+
+
(lade …)
@@ -10106,10 +10108,14 @@
return ['#FFD60A', 'wartet auf Gegenstelle'];
}
function renderVpn() {
- const box = document.getElementById('vpn-client-list');
+ _renderVpnList('vpn-client-list', 'client', 'Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.');
+ _renderVpnList('vpn-server-list', 'server', 'Noch keine Einwahl. „+ Hinzufügen“, Kundennetz eintragen, dann die Datei für die FritzBox herunterladen.');
+ }
+ function _renderVpnList(boxId, kind, empty) {
+ const box = document.getElementById(boxId);
if (!box) return;
- const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === 'client');
- if (!list.length) { box.innerHTML = 'Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.'; return; }
+ const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === kind);
+ if (!list.length) { box.innerHTML = '' + empty + ''; return; }
box.innerHTML = list.map(c => {
const [col, txt] = _vpnState(c);
const st = c.status || {};
@@ -10119,11 +10125,11 @@
'
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)' +
+ '
');
+ 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' +
- '
Hosts → Streams → Add Stream: Incoming Port 443, Forward Host = ARIA-VM, Forward Port = Port des WireGuard-Verteilers, ' +
+ '
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).
' +
'
Im docker-compose des NPM den UDP-Port freigeben und neu starten:
ports:\n - \'443:443\'\n - \'443:443/udp\' # neu
' +
'
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
' +
+ '
Auf dem NPM-Rechner UDP 443 aus dem Internet erlauben:
ufw allow 443/udp comment \'WireGuard -> ARIA\'
' +
+ '
Auf der ARIA-VM den Verteiler-Port nur vom NPM erlauben (sonst niemand):
ufw allow proto udp from <IP-des-NPM> to any port 51820 comment \'VPN-Verteiler nur vom NPM\'
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())