Files
ARIA-AGENT/diagnostic/vpn.js
T
duffyduckandClaude Opus 5.5 627b826226 feat(vpn): VPN-Satelliten — WireGuard-Clients als Satelliten ohne Installation beim Kunden
Einstellungen → VPN → WireGuard → Clients: WireGuard-Datei des Kunden
hochladen/einfuegen (editierbar), je Verbindung Schalter (an = auch nach
Neustart, aus = bleibt aus), bearbeiten, Download, ℹ-Verlauf, loeschen;
Log-Aufbewahrung im Reiter Einstellungen. Server-Reiter mit Anleitung fuer
UDP 443 (NPM-Stream bzw. ohne Nginx), Server selbst folgt.

Jede Verbindung ist ein eigener Container (aria-vpn-<id>, Image aus
satellite/Dockerfile.vpn) mit eigenem Netz-Namensraum: WireGuard + normaler
satellite.py (kind=vpn). Firewall im Container: Tunnel nur ausgehend, keine
Verbindung Kundennetz → ARIA; ins ARIA-Netz nur RVS + Endpunkt. Routen ohne
wg-quick, RVS-Weg nie durch den Tunnel (auch bei 0.0.0.0/0). Scan ueber den
Tunnel aktiv per TCP (kein mDNS/ARP), FritzBox an Port 49000 erkannt.
Satelliten-Ansicht: eigene Gruppe VPN-Satelliten.

Lokal Ende-zu-Ende getestet (Kunde mit WG-Server + LAN, Fake-RVS):
Tunnel, Verlauf, Scan, Sperren in beide Richtungen, Volltunnel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:30:10 +01:00

283 lines
12 KiB
JavaScript
Raw 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, …)
//
// 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}&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("");
}
// ── 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 };