// ── 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//tunnel/wg.conf die WireGuard-Datei (0600) // /data/vpn//tunnel/status.json schreibt der Container (ℹ-Fenster) // /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. 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}×tamps=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 };