Files
duffyduckandClaude Opus 5.5 9b25187084 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>
2026-11-01 14:14:34 +01:00

408 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ─────────────
//
// Jede VPN-Verbindung ist ein eigener Container (Image aria-vpn-satellite,
// satellite/Dockerfile.vpn): WireGuard-Tunnel + normaler Satellit dahinter,
// Kategorie "VPN-Satellit". Eigener Container = eigener Netz-Namensraum —
// ueberlappende Kundennetze stoeren sich nicht, und lokale Satelliten sehen
// die VPN-Netze nie.
//
// Ablage (Bind-Mount /data der Diagnostic):
// /data/vpn/connections.json Liste {id, kind, name, enabled, scanNets, …}
// /data/vpn/settings.json {keepDays}
// /data/vpn/<id>/tunnel/wg.conf die WireGuard-Datei (0600)
// /data/vpn/<id>/tunnel/status.json schreibt der Container (ℹ-Fenster)
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
// /data/vpn/<id>/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.
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const DIR = "/data/vpn";
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) => {
const data = body ? JSON.stringify(body) : "";
const req = http.request({
socketPath: "/var/run/docker.sock", path: apiPath, method,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
}, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const buf = Buffer.concat(chunks);
let json = null;
try { json = JSON.parse(buf.toString() || "null"); } catch { /* Logs sind kein JSON */ }
resolve({ status: res.statusCode, json, buf });
});
});
req.on("error", reject);
req.setTimeout(30000, () => req.destroy(new Error("Docker antwortet nicht")));
if (data) req.write(data);
req.end();
});
}
// ── Ablage ──────────────────────────────────────────────────────
function readJson(p, fallback) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
}
function writeJson(p, v, mode = 0o600) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2), { mode });
fs.renameSync(p + ".tmp", p);
}
const connsFile = () => path.join(DIR, "connections.json");
const loadConns = () => readJson(connsFile(), []);
const saveConns = (list) => writeJson(connsFile(), list);
const tunnelDir = (id) => path.join(DIR, id, "tunnel");
function settings() {
return { keepDays: 30, ...readJson(path.join(DIR, "settings.json"), {}) };
}
// Host-Pfad von /data — die VPN-Container brauchen Bind-Mounts mit HOST-Pfaden.
let _hostData = "";
async function hostDataDir() {
if (_hostData) return _hostData;
const r = await docker("GET", `/containers/${SELF}/json`);
const m = ((r.json && r.json.Mounts) || []).find((x) => x.Destination === "/data");
if (!m) throw new Error("Host-Pfad von /data unbekannt (Diagnostic ohne Bind-Mount?)");
_hostData = m.Source;
return _hostData;
}
// ── WireGuard-Datei pruefen ─────────────────────────────────────
function parseConf(text) {
const iface = {}; const peers = []; let cur = null;
for (const raw of String(text || "").split(/\r?\n/)) {
const line = raw.split("#")[0].trim();
if (!line) continue;
const sec = line.match(/^\[(\w+)\]$/);
if (sec) {
const n = sec[1].toLowerCase();
cur = n === "interface" ? iface : n === "peer" ? (peers.push({}), peers[peers.length - 1]) : null;
continue;
}
const i = line.indexOf("=");
if (!cur || i < 0) continue;
const k = line.slice(0, i).trim().toLowerCase(); const v = line.slice(i + 1).trim();
cur[k] = cur[k] && ["address", "allowedips", "dns"].includes(k) ? cur[k] + "," + v : v;
}
return { iface, peers };
}
function checkConf(text) {
const c = parseConf(text);
if (!c.iface.privatekey) return { error: "In [Interface] fehlt PrivateKey." };
if (!c.peers.length || c.peers.some((p) => !p.publickey)) return { error: "In [Peer] fehlt PublicKey." };
if (!c.peers.some((p) => p.endpoint)) return { error: "Kein Endpoint — wohin soll sich der Tunnel verbinden?" };
const nets = [];
for (const p of c.peers) {
for (const n of String(p.allowedips || "").split(",").map((s) => s.trim()).filter(Boolean)) {
if (!n.includes(":") && !n.endsWith("/0")) nets.push(n);
}
}
const all = c.peers.some((p) => /(^|,)\s*0\.0\.0\.0\/0/.test(p.allowedips || ""));
return { ok: true, nets, all, endpoint: c.peers.map((p) => p.endpoint).filter(Boolean).join(", ") };
}
// ── Container ───────────────────────────────────────────────────
async function ensureNetwork() {
const r = await docker("GET", `/networks/${NETWORK}`);
if (r.status === 200) return;
// ICC aus: VPN-Container sehen sich gegenseitig nicht.
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)}`);
}
async function removeContainer(id) {
await docker("DELETE", `/containers/${PREFIX}${id}?force=true`);
}
async function startContainer(conn, rvs) {
const img = await docker("GET", `/images/${encodeURIComponent(IMAGE)}/json`);
if (img.status !== 200) {
throw new Error(`Image ${IMAGE} fehlt. Auf der ARIA-VM: docker compose up -d --build vpn-satellite-image`);
}
await ensureNetwork();
const host = await hostDataDir();
fs.mkdirSync(path.join(DIR, conn.id, "data"), { recursive: true });
const env = {
VPN_ID: conn.id, VPN_NAME: conn.name, SATELLITE_ID: `vpn-${conn.id}`,
SATELLITE_LOCATION: conn.name, SCAN_NETS: conn.scanNets || "",
SCAN_MAX_SEC: "180", CONTROL_ENABLED: "true",
RVS_HOST: rvs.host, RVS_PORT: String(rvs.port), RVS_TLS: String(rvs.tls),
RVS_TLS_FALLBACK: String(rvs.tlsFallback), RVS_TOKEN: rvs.token,
...(rvs.sni ? { RVS_SNI: rvs.sni } : {}),
};
await removeContainer(conn.id);
const c = await docker("POST", `/containers/create?name=${PREFIX}${conn.id}`, {
Image: IMAGE,
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
Labels: { "aria.vpn": conn.id, "aria.vpn.name": conn.name },
HostConfig: {
Binds: [`${host}/vpn/${conn.id}/tunnel:/vpn`, `${host}/vpn/${conn.id}/data:/data`],
CapAdd: ["NET_ADMIN"],
RestartPolicy: { Name: "unless-stopped" },
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`);
if (s.status >= 300) throw new Error(`Container starten: ${JSON.stringify(s.json).slice(0, 300)}`);
}
async function containerState(id) {
const r = await docker("GET", `/containers/${PREFIX}${id}/json`);
if (r.status !== 200) return { exists: false };
const st = r.json.State || {};
return { exists: true, running: !!st.Running, restarting: !!st.Restarting,
startedAt: st.StartedAt, exitCode: st.ExitCode };
}
async function containerLogs(id, lines = 60) {
const r = await docker("GET", `/containers/${PREFIX}${id}/logs?stdout=true&stderr=true&tail=${lines}&timestamps=true`);
if (r.status !== 200) return "";
// Docker-Multiplex-Format: 8 Byte Kopf je Block
const out = []; let i = 0; const b = r.buf;
while (i + 8 <= b.length) {
const len = b.readUInt32BE(i + 4);
out.push(b.slice(i + 8, i + 8 + len).toString());
i += 8 + len;
}
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();
const res = [];
for (const c of conns) {
const st = readJson(path.join(tunnelDir(c.id), "status.json"), {});
res.push({ ...c, status: st, container: await containerState(c.id) });
}
return { connections: res, settings: settings() };
}
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.");
const chk = checkConf(conf);
if (chk.error) throw new Error(chk.error);
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",");
const conns = loadConns();
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
if (!conn) {
conn = { id: crypto.randomBytes(4).toString("hex"), kind: msg.kind || "client",
enabled: false, createdAt: Date.now() };
conns.push(conn);
}
Object.assign(conn, { name, scanNets, updatedAt: Date.now() });
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), conf, { mode: 0o600 });
saveConns(conns);
if (conn.enabled) await startContainer(conn, rvs); // Aenderung greift sofort
return { id: conn.id };
}
async function toggle(id, enabled, rvs) {
const conns = loadConns();
const conn = conns.find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
conn.enabled = !!enabled;
saveConns(conns);
try {
if (conn.enabled) await startContainer(conn, rvs);
else await removeContainer(id);
} finally {
writeRelayMap();
}
return { id, enabled: conn.enabled };
}
async function remove(id) {
const conns = loadConns();
if (!conns.some((c) => c.id === id)) throw new Error("Verbindung unbekannt.");
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") };
}
async function info(id) {
const conn = loadConns().find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
const dir = tunnelDir(id);
let events = [];
try {
events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8").trim().split("\n")
.filter(Boolean).slice(-300).map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean);
} catch { /* noch keine */ }
return { ...conn, status: readJson(path.join(dir, "status.json"), {}), events,
container: await containerState(id), logs: await containerLogs(id) };
}
function setSettings(s) {
const keepDays = Math.max(1, Math.min(3650, parseInt(s.keepDays, 10) || 30));
writeJson(path.join(DIR, "settings.json"), { keepDays });
rotate();
return { keepDays };
}
// Log-Rotation: Ereignisse aelter als keepDays fliegen raus (stuendlich).
function rotate() {
const cut = Date.now() / 1000 - settings().keepDays * 86400;
for (const c of loadConns()) {
const p = path.join(tunnelDir(c.id), "events.jsonl");
try {
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
const keep = lines.filter((l) => { try { return JSON.parse(l).ts >= cut; } catch { return false; } });
if (keep.length !== lines.length) fs.writeFileSync(p, keep.join("\n") + (keep.length ? "\n" : ""));
} catch { /* keine Datei */ }
}
}
setInterval(rotate, 3600 * 1000).unref();
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap };