// ── 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, …) // // 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-"; 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" }, 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" } }, }, }); 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(""); } // ── 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) { 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); if (conn.enabled) await startContainer(conn, rvs); else await removeContainer(id); 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 }); return { id }; } function getConf(id) { const conn = loadConns().find((c) => c.id === id); if (!conn) throw new Error("Verbindung unbekannt."); 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 };