diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py
index be73c8f..f0321fe 100644
--- a/bridge/aria_bridge.py
+++ b/bridge/aria_bridge.py
@@ -3745,6 +3745,8 @@ class ARIABridge:
"location": payload.get("location") or sid,
"caps": payload.get("caps") or [],
"control": bool(payload.get("control")),
+ # "vpn" = VPN-Satellit (Kundennetz hinter WireGuard, laeuft auf ARIA)
+ "kind": payload.get("kind") or "lan",
"last_seen": time.time(),
}
cur = self._satellites[sid]
@@ -5229,6 +5231,7 @@ class ARIABridge:
"id": s["id"], "location": alias or own,
"agentName": own, "alias": alias,
"caps": s.get("caps") or [], "control": bool(s.get("control")),
+ "kind": s.get("kind") or "lan",
"online": (now - s.get("last_seen", 0)) < 300,
})
return out
diff --git a/diagnostic/index.html b/diagnostic/index.html
index 51cbe3d..7995685 100644
--- a/diagnostic/index.html
+++ b/diagnostic/index.html
@@ -688,6 +688,7 @@
+
@@ -1359,6 +1360,57 @@
+
+
+
+ WireGuard
+
+
+
+
+
+
+
+
+
+
+
+ WireGuard-Dateien, die Du von Kunden bekommst. Jede Verbindung wird ein VPN-Satellit
+ (eigener Container auf ARIA): gleiche Funktionen wie ein Satellit, ohne beim Kunden etwas zu installieren.
+ An = verbindet sich auch nach einem Neustart wieder · Aus = bleibt aus.
+
+
+
+
(lade …)
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
+
+
+
+
+
+
Ältere Einträge (verbunden/getrennt) werden stündlich gelöscht.
+
+
+
+
+
@@ -1895,6 +1947,21 @@
(Lade...)
+
+
VPN-Satelliten 🔐
+
+
+
+
+ Kundennetze über WireGuard — laufen auf ARIA, nichts beim Kunden installiert. Gleiche Funktionen
+ wie ein Satellit; gescannt wird aktiv über den Tunnel (kein mDNS/ARP). Aus dem Kundennetz
+ führt keine Verbindung zurück zu ARIA.
+
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, ' +
+ '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.
' +
+ '
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.
');
+ }
+
// ── llama.cpp/llama-swap der LLM-Boxen (Modell-Katalog → ⚙ llama.cpp) ──
// Pruefen/Aktualisieren/Zuruecksetzen macht auf der Box der llm-updater
// (einziger Dienst mit Docker-Zugriff). Ob es etwas Neues gibt, entscheidet
diff --git a/diagnostic/server.js b/diagnostic/server.js
index eb12031..0961c2b 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -17,6 +17,7 @@ const { WebSocket, WebSocketServer } = require("ws");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
+const vpn = require("./vpn");
// ── Konfiguration ───────────────────────────────────────
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
@@ -1090,7 +1091,7 @@ function satelliteList() {
return Array.from(satellites.values()).map(s => ({
id: s.id, location: alias[s.id] || s.location,
agentName: s.location, alias: alias[s.id] || "",
- caps: s.caps, control: s.control, net: s.net || null,
+ caps: s.caps, control: s.control, net: s.net || null, kind: s.kind || "lan",
online: (now - (s.last_seen || 0)) < 300000,
}));
}
@@ -1098,6 +1099,58 @@ function satelliteList() {
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
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 || "" };
+}
+
+async function broadcastVpn() {
+ try { broadcast({ type: "vpn_list", ...(await vpn.list()) }); }
+ catch (e) { log("warn", "vpn", "Liste: " + e.message); }
+}
+
+async function handleVpn(ws, msg) {
+ const reply = (body) => { try { ws.send(JSON.stringify({ type: "vpn_result", requestId: msg.requestId || "", action: msg.action, ...body })); } catch { /* weg */ } };
+ try {
+ switch (msg.action) {
+ case "vpn_list":
+ ws.send(JSON.stringify({ type: "vpn_list", ...(await vpn.list()) }));
+ return;
+ case "vpn_check":
+ reply({ ok: true, check: vpn.checkConf(msg.conf) });
+ return;
+ case "vpn_get":
+ reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
+ return;
+ case "vpn_info":
+ reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
+ return;
+ case "vpn_save":
+ reply({ ok: true, ...(await vpn.save(msg, vpnRvs())) });
+ log("info", "vpn", `Verbindung gespeichert: ${String(msg.name || "").slice(0, 60)}`);
+ break;
+ case "vpn_toggle":
+ reply({ ok: true, ...(await vpn.toggle(String(msg.id || ""), !!msg.enabled, vpnRvs())) });
+ log("info", "vpn", `Verbindung ${msg.id} ${msg.enabled ? "an" : "aus"}`);
+ break;
+ case "vpn_delete":
+ reply({ ok: true, ...(await vpn.remove(String(msg.id || ""))) });
+ log("info", "vpn", `Verbindung ${msg.id} geloescht`);
+ break;
+ case "vpn_settings":
+ reply({ ok: true, settings: vpn.setSettings(msg.settings || {}) });
+ break;
+ default:
+ reply({ ok: false, error: "unbekannte Aktion" });
+ return;
+ }
+ broadcastVpn();
+ } catch (e) {
+ reply({ ok: false, error: e.message });
+ broadcastVpn();
+ }
+}
+
function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() });
}
@@ -1644,6 +1697,7 @@ function connectRVS(forcePlain) {
satellites.set(p.id, {
id: p.id, location: p.location || p.id,
caps: p.caps || [], control: !!p.control, net: p.net || null,
+ kind: p.kind || "lan",
last_seen: Date.now(),
});
broadcastSatellites();
@@ -3695,6 +3749,10 @@ wss.on("connection", (ws) => {
},
timestamp: Date.now(),
});
+ } else if (typeof msg.action === "string" && msg.action.startsWith("vpn_")) {
+ // VPN-Satelliten (Einstellungen → VPN). Antworten nur an DIESEN Browser —
+ // vpn_get enthaelt den privaten Schluessel. Aenderungen → Liste an alle.
+ handleVpn(ws, msg);
} else if (msg.action === "llm_runtime") {
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
const op = String(msg.op || "status");
diff --git a/diagnostic/vpn.js b/diagnostic/vpn.js
new file mode 100644
index 0000000..ae859eb
--- /dev/null
+++ b/diagnostic/vpn.js
@@ -0,0 +1,282 @@
+// ── 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 };
diff --git a/docker-compose.yml b/docker-compose.yml
index a4fa38d..15b5828 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -164,6 +164,22 @@ services:
- RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped
+ # ─── VPN-Satelliten: nur das Image bauen ───────────────
+ # Die eigentlichen Container (einer pro WireGuard-Verbindung, aria-vpn-)
+ # legt die Diagnostic an (Einstellungen → VPN). Dieser Eintrag sorgt nur
+ # dafuer, dass `docker compose up -d --build` das Image mitbaut; der
+ # Container selbst beendet sich sofort wieder.
+ # Voraussetzung auf dem Host: WireGuard-Kernelmodul (modprobe wireguard).
+ vpn-satellite-image:
+ build:
+ context: ./satellite
+ dockerfile: Dockerfile.vpn
+ image: aria-vpn-satellite:latest
+ container_name: aria-vpn-satellite-image
+ entrypoint: ["true"]
+ network_mode: none
+ restart: "no"
+
networks:
aria-net:
driver: bridge
diff --git a/satellite/Dockerfile.vpn b/satellite/Dockerfile.vpn
new file mode 100644
index 0000000..6952ac1
--- /dev/null
+++ b/satellite/Dockerfile.vpn
@@ -0,0 +1,18 @@
+# ARIA VPN-Satellit — WireGuard-Tunnel + Satellit in EINEM Container pro
+# Verbindung (angelegt von der Diagnostic). Laeuft NICHT im Host-Netz, sondern
+# im Docker-Netz aria-vpn; braucht nur NET_ADMIN (wg0, Routen, nftables).
+# Das WireGuard-Kernelmodul kommt vom Host.
+FROM python:3.12-slim
+
+WORKDIR /app
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends wireguard-tools iproute2 nftables snmp adb \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY satellite.py vpn_entry.py ./
+
+CMD ["python", "-u", "vpn_entry.py"]
diff --git a/satellite/README.md b/satellite/README.md
index bd6b979..0648244 100644
--- a/satellite/README.md
+++ b/satellite/README.md
@@ -117,3 +117,46 @@ Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar —
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
sonst `false` und nur beobachten.
+
+## VPN-Satelliten (WireGuard, ohne Installation beim Kunden)
+
+Ein VPN-Satellit ist ein Satellit, der **nicht im Kundennetz** läuft, sondern auf ARIA —
+verbunden über einen WireGuard-Tunnel. Angelegt wird er in der Diagnostic unter
+**Einstellungen → VPN → WireGuard**; im Reiter Satelliten steht er unter „VPN-Satelliten 🔐“.
+
+**Clients** (ARIA wählt sich beim Kunden ein): die WireGuard-Datei des Kunden hochladen
+oder einfügen, Namen vergeben, einschalten.
+- **An** = Container mit Neustart-Automatik (verbindet sich nach einem Neustart wieder).
+ **Aus** = Container weg, Datei und Log bleiben — Kundennetze nur bei Bedarf.
+- ✏️ bearbeiten · ⬇ Datei wieder herunterladen · ℹ Verlauf (wann verbunden, wie lange,
+ Datenmenge) · 🗑 löschen. Log-Aufbewahrung im Reiter Einstellungen.
+
+**Wie es gebaut ist** (`Dockerfile.vpn`, `vpn_entry.py`, `diagnostic/vpn.js`):
+- **Ein Container pro Verbindung** (`aria-vpn-`, Netz `aria-vpn`, nur `NET_ADMIN`).
+ Eigener Netz-Namensraum: überlappende Kundennetze (überall `192.168.178.0/24`) stören
+ sich nicht, und lokale Satelliten im Host-Netz sehen die VPN-Netze nie.
+- **Firewall im Container:** durch den Tunnel nur ausgehend, zurück kommen nur Antworten —
+ auch wenn die Gegenseite den Tunnel aufgebaut hat, führt keine Verbindung vom Kundennetz
+ zu ARIA. Ins ARIA-Netz/Internet darf der Container nur zum RVS und zum Tunnel-Endpunkt.
+- `wg0` wird selbst aufgesetzt, nicht per `wg-quick`: `DNS`/`PostUp` aus fremden Dateien
+ werden ignoriert, und bei `AllowedIPs = 0.0.0.0/0` bleibt der Weg zum RVS trotzdem
+ außerhalb des Tunnels. Endpunkte mit Hostnamen (DynDNS) werden neu aufgelöst, solange
+ keine Verbindung steht.
+- **Scan:** über WireGuard gibt es kein mDNS/SSDP/ARP — der VPN-Satellit klopft die Netze
+ aktiv ab (`SCAN_NETS`, Standard = AllowedIPs ohne `0.0.0.0/0`; typische Ports, auch
+ „Verbindung abgelehnt“ zählt als Gerät). FritzBoxen erkennt er an Port 49000.
+- Daten pro Verbindung unter `aria-data/config/diag-state/vpn//` (`tunnel/wg.conf`
+ mit privatem Schlüssel, `status.json`, `events.jsonl`, `data/` = Zugangsdaten usw.).
+
+**Voraussetzungen auf der ARIA-VM:**
+- WireGuard-Kernelmodul: `modprobe wireguard` und dauerhaft
+ `echo wireguard > /etc/modules-load.d/wireguard.conf`
+- 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.
diff --git a/satellite/satellite.py b/satellite/satellite.py
index f693b3d..25905ae 100644
--- a/satellite/satellite.py
+++ b/satellite/satellite.py
@@ -131,6 +131,16 @@ if RVS_TLS and RVS_SNI:
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_ID).strip()
+# VPN-Satellit (satellite/vpn_entry.py): sitzt nicht IM Netz, sondern hinter
+# einem WireGuard-Tunnel davor. Ueber den Tunnel kommen kein mDNS/SSDP/ARP —
+# gescannt wird darum aktiv: die Netze aus SCAN_NETS werden per TCP abgeklopft.
+SAT_KIND = (os.environ.get("SAT_KIND") or "lan").strip().lower()
+SCAN_NETS = [n.strip() for n in os.environ.get("SCAN_NETS", "").split(",") if n.strip()]
+SCAN_MAX_HOSTS = int(os.environ.get("SCAN_MAX_HOSTS", "1024") or "1024")
+SCAN_PORTS = [int(p) for p in os.environ.get(
+ "SCAN_PORTS", "80,443,22,49000,445,3389,8080,8443,9100,5555,161,53,23,21,25,139,631,5000,8006").split(",")
+ if p.strip().isdigit()]
+
CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [
a.strip() for a in
@@ -417,6 +427,9 @@ def _net_context() -> dict:
ips.insert(0, primary)
p = primary or (ips[0] if ips else "")
warning = ""
+ if SAT_KIND == "vpn":
+ # Laeuft absichtlich in einem Docker-Netz, das Ziel liegt hinter dem Tunnel.
+ return {"primary_ip": p, "ips": ips, "mac": "", "warning": "", "vpnNets": SCAN_NETS}
if p.startswith("192.168.65.") or _in_docker_bridge(p):
warning = (f"Satellit laeuft in einem Docker-/NAT-Netz ({p}), NICHT im echten LAN. "
"mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
@@ -679,6 +692,58 @@ def _discover_arp() -> list[dict]:
return out
+async def _discover_routed(nets: list[str]) -> list[dict]:
+ """Aktiver Scan fuer VPN-Satelliten: jede Adresse der Netze auf typische
+ Ports abklopfen. Auch "Verbindung abgelehnt" heisst: da ist ein Geraet.
+ Antwortet 49000 mit einer FritzBox-Selbstbeschreibung, ist es eine."""
+ import ipaddress
+ hosts: list[str] = []
+ for n in nets:
+ try:
+ net = ipaddress.ip_network(n, strict=False)
+ except ValueError:
+ continue
+ if net.version != 4:
+ continue
+ for h in (net.hosts() if net.num_addresses > 2 else [net.network_address]):
+ if len(hosts) >= SCAN_MAX_HOSTS:
+ break
+ hosts.append(str(h))
+ sem = asyncio.Semaphore(256)
+ found: dict[str, set] = {}
+
+ async def probe(ip: str, port: int) -> None:
+ async with sem:
+ try:
+ _r, w = await asyncio.wait_for(asyncio.open_connection(ip, port), 1.2)
+ found.setdefault(ip, set()).add(port)
+ w.close()
+ except ConnectionRefusedError:
+ found.setdefault(ip, set())
+ except Exception:
+ pass
+
+ await asyncio.gather(*(probe(ip, p) for ip in hosts for p in SCAN_PORTS))
+ out = []
+ loop = asyncio.get_event_loop()
+ for ip in sorted(found, key=lambda a: tuple(int(x) for x in a.split("."))):
+ ports = sorted(found[ip])
+ d = {"id": _slug(f"host-{ip}"), "name": ip, "type": "host", "ip": ip,
+ "via": "vpn-scan", "ports": ports}
+ if 49000 in ports:
+ try:
+ xml = await loop.run_in_executor(None, _fb_get, ip, "/tr64desc.xml")
+ fn = _xml_tag(xml, "friendlyName") or _xml_tag(xml, "modelName")
+ if "fritz" in (xml or "").lower():
+ d.update({"type": "router", "name": fn or "FRITZ!Box",
+ "model": _xml_tag(xml, "modelName") or fn})
+ except Exception:
+ pass
+ out.append(d)
+ logger.info("[scan] VPN: %d Adressen in %s abgeklopft, %d Geraete", len(hosts), ",".join(nets), len(out))
+ return out
+
+
def _merge_devices(*lists) -> list[dict]:
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
by_ip: dict[str, dict] = {}
@@ -2626,12 +2691,16 @@ class Satellite:
async def _scan_inner(self) -> None:
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
loop = asyncio.get_event_loop()
- self._scan_stage = "mdns"
- mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
- self._scan_stage = "ssdp"
- ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
- self._scan_stage = "arp"
- arp = await loop.run_in_executor(None, _discover_arp)
+ if SAT_KIND == "vpn":
+ self._scan_stage = "vpn"
+ mdns, ssdp, arp = [], [], await _discover_routed(SCAN_NETS)
+ else:
+ self._scan_stage = "mdns"
+ mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
+ self._scan_stage = "ssdp"
+ ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
+ self._scan_stage = "arp"
+ arp = await loop.run_in_executor(None, _discover_arp)
_self = [_self_device()] if LIST_SELF else []
_self = [d for d in _self if d]
devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
@@ -2718,6 +2787,7 @@ class Satellite:
"caps": CAPABILITIES,
"control": CONTROL_ENABLED,
"net": NET,
+ "kind": SAT_KIND,
},
"timestamp": int(time.time() * 1000),
})
diff --git a/satellite/vpn_entry.py b/satellite/vpn_entry.py
new file mode 100644
index 0000000..cdd044d
--- /dev/null
+++ b/satellite/vpn_entry.py
@@ -0,0 +1,337 @@
+"""
+ARIA VPN-Satellit — ein WireGuard-Tunnel + ein ganz normaler Satellit dahinter.
+
+Laeuft als eigener Container pro VPN-Verbindung (angelegt von der Diagnostic,
+Einstellungen → VPN → WireGuard). Eigener Container = eigener Netz-Namensraum:
+ - ueberlappende Kundennetze (jeder hat 192.168.178.0/24) stoeren sich nicht
+ - lokale Satelliten (network_mode: host) sehen diese Netze nie
+
+Ablauf:
+ 1. /vpn/wg.conf lesen (Standard-WireGuard-Datei, wie man sie bekommt)
+ 2. wg0 selbst aufsetzen (kein wg-quick: DNS/PostUp aus fremden Dateien
+ werden ignoriert, und der Weg zum RVS darf NIE durch den Tunnel)
+ 3. Firewall (nftables, nur in diesem Container):
+ raus durch den Tunnel: alles; rein aus dem Tunnel: nur Antworten
+ raus ins ARIA-Netz/Internet: nur zum RVS und zum Tunnel-Endpunkt
+ 4. satellite.py starten (SAT_KIND=vpn, Scan = SCAN_NETS aktiv abklopfen)
+ 5. Tunnel beobachten: status.json + events.jsonl (verbunden/getrennt, Dauer)
+ fuer das ℹ-Fenster; Endpunkt mit Hostnamen (DynDNS) neu aufloesen
+
+Env: VPN_ID, VPN_NAME, RVS_HOST/RVS_PORT/RVS_SNI (+ alles fuer satellite.py)
+"""
+
+from __future__ import annotations
+
+import ipaddress
+import json
+import logging
+import os
+import re
+import signal
+import socket
+import subprocess
+import sys
+import threading
+import time
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn] %(levelname)s %(message)s")
+log = logging.getLogger("vpn")
+
+VPN_DIR = os.environ.get("VPN_DIR", "/vpn")
+CONF = os.path.join(VPN_DIR, "wg.conf")
+STATUS = os.path.join(VPN_DIR, "status.json")
+EVENTS = os.path.join(VPN_DIR, "events.jsonl")
+IFACE = "wg0"
+HANDSHAKE_OK_S = 180 # juenger = verbunden (WireGuard erneuert alle 2 min)
+WATCH_S = 15
+
+_stop = threading.Event()
+_conn = {"connected": False, "since": 0, "rx": 0, "tx": 0} # fuer "getrennt" beim Stoppen
+
+
+def sh(*args: str, check: bool = True, inp: str | None = None) -> str:
+ p = subprocess.run(list(args), capture_output=True, text=True, input=inp)
+ if check and p.returncode != 0:
+ raise RuntimeError(f"{' '.join(args[:4])}: {(p.stderr or p.stdout).strip()[:300]}")
+ return p.stdout
+
+
+def event(kind: str, **extra) -> None:
+ rec = {"ts": int(time.time()), "event": kind, **extra}
+ try:
+ with open(EVENTS, "a", encoding="utf-8") as f:
+ f.write(json.dumps(rec, ensure_ascii=False) + "\n")
+ except OSError as e:
+ log.warning("events.jsonl nicht schreibbar: %s", e)
+
+
+# ── WireGuard-Datei lesen ───────────────────────────────────────
+
+def parse_conf(text: str) -> dict:
+ """[Interface] + beliebig viele [Peer]. Schluessel ohne Gross/Klein."""
+ iface: dict = {}
+ peers: list = []
+ cur = None
+ for raw in text.splitlines():
+ line = raw.split("#", 1)[0].strip()
+ if not line:
+ continue
+ sec = re.match(r"^\[(\w+)\]$", line)
+ if sec:
+ name = sec.group(1).lower()
+ if name == "interface":
+ cur = iface
+ elif name == "peer":
+ cur = {}
+ peers.append(cur)
+ else:
+ cur = None
+ continue
+ if cur is None or "=" not in line:
+ continue
+ k, v = (s.strip() for s in line.split("=", 1))
+ k = k.lower()
+ if k in cur and k in ("address", "allowedips", "dns"):
+ cur[k] += "," + v
+ else:
+ cur[k] = v
+ if not iface.get("privatekey"):
+ raise ValueError("[Interface] PrivateKey fehlt")
+ if not peers or not all(p.get("publickey") for p in peers):
+ raise ValueError("[Peer] PublicKey fehlt")
+ return {"iface": iface, "peers": peers}
+
+
+def _split(v: str) -> list[str]:
+ return [x.strip() for x in (v or "").split(",") if x.strip()]
+
+
+def setconf_text(c: dict) -> str:
+ """Nur was `wg setconf` versteht (Address/DNS/MTU/PostUp sind wg-quick)."""
+ out = ["[Interface]", f"PrivateKey = {c['iface']['privatekey']}"]
+ if c["iface"].get("listenport"):
+ out.append(f"ListenPort = {c['iface']['listenport']}")
+ for p in c["peers"]:
+ out += ["", "[Peer]", f"PublicKey = {p['publickey']}"]
+ for k, name in (("presharedkey", "PresharedKey"), ("endpoint", "Endpoint"),
+ ("allowedips", "AllowedIPs")):
+ if p.get(k):
+ out.append(f"{name} = {p[k]}")
+ out.append(f"PersistentKeepalive = {p.get('persistentkeepalive') or 25}")
+ return "\n".join(out) + "\n"
+
+
+def endpoint_host_port(ep: str) -> tuple[str, int]:
+ m = re.match(r"^\[?([^\]]+?)\]?:(\d+)$", ep.strip())
+ if not m:
+ raise ValueError(f"Endpoint unlesbar: {ep}")
+ return m.group(1), int(m.group(2))
+
+
+def resolve4(host: str) -> list[str]:
+ try:
+ ipaddress.ip_address(host)
+ return [host]
+ except ValueError:
+ pass
+ try:
+ return sorted({a[4][0] for a in socket.getaddrinfo(host, None, socket.AF_INET)})
+ except OSError:
+ return []
+
+
+# ── Netz + Firewall ─────────────────────────────────────────────
+
+def default_gw() -> str:
+ m = re.search(r"default via (\S+)", sh("ip", "-4", "route", "show", "default"))
+ if not m:
+ raise RuntimeError("Container hat kein Default-Gateway")
+ return m.group(1)
+
+
+def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str]) -> 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"
+ # 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)
+ rules = f"""
+table inet vpnsat {{
+ set endpoints {{ type ipv4_addr; elements = {{ {eps} }} }}
+ chain input {{
+ type filter hook input priority 0; policy drop;
+ iif lo accept
+ ct state established,related accept
+ iifname "eth0" ip saddr @endpoints meta l4proto udp accept
+ }}
+ chain forward {{
+ type filter hook forward priority 0; policy drop;
+ }}
+ chain output {{
+ type filter hook output priority 0; policy drop;
+ oif lo accept
+ ct state established,related accept
+ oifname "{IFACE}" accept
+ oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
+ oifname "eth0" ip daddr @endpoints meta l4proto udp accept
+ }}
+}}
+"""
+ sh("nft", "-f", "-", inp=rules)
+ log.info("Firewall aktiv: Tunnel nur ausgehend; ins ARIA-Netz nur RVS %s:%d", rvs, rvs_port)
+
+
+def setup(c: dict) -> dict:
+ rvs_host = os.environ.get("RVS_HOST", "")
+ rvs_port = int(os.environ.get("RVS_PORT", "443") or 443)
+ rvs_ips = resolve4(rvs_host)
+ if not rvs_ips:
+ raise RuntimeError(f"RVS {rvs_host!r} nicht aufloesbar")
+ gw = default_gw()
+
+ ep_ips: list[str] = []
+ for p in c["peers"]:
+ if p.get("endpoint"):
+ h, _ = endpoint_host_port(p["endpoint"])
+ ep_ips += resolve4(h)
+
+ sh("ip", "link", "del", IFACE, check=False)
+ try:
+ sh("ip", "link", "add", IFACE, "type", "wireguard")
+ except RuntimeError as e:
+ raise RuntimeError(f"WireGuard im Kernel nicht verfuegbar (modprobe wireguard auf dem Host?): {e}")
+ sh("wg", "setconf", IFACE, "/dev/stdin", inp=setconf_text(c))
+ for a in _split(c["iface"].get("address", "")):
+ if ":" not in a:
+ sh("ip", "-4", "address", "add", a, "dev", IFACE)
+ sh("ip", "link", "set", IFACE, "mtu", c["iface"].get("mtu") or "1420", "up")
+
+ # RVS und Endpunkte immer am Tunnel vorbei
+ for ip in set(rvs_ips + ep_ips):
+ sh("ip", "route", "replace", f"{ip}/32", "via", gw, "dev", "eth0")
+ nets = []
+ for p in c["peers"]:
+ for n in _split(p.get("allowedips", "")):
+ if ":" in n:
+ continue
+ net = ipaddress.ip_network(n, strict=False)
+ if net.prefixlen == 0:
+ for half in ("0.0.0.0/1", "128.0.0.0/1"):
+ sh("ip", "route", "replace", half, "dev", IFACE)
+ else:
+ sh("ip", "route", "replace", str(net), "dev", IFACE)
+ nets.append(str(net))
+ firewall(rvs_ips, rvs_port, ep_ips)
+ log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
+ return {"nets": nets, "endpoints": ep_ips}
+
+
+def scan_nets(c: dict) -> str:
+ """Was der Satellit abklopft: SCAN_NETS (aus der Diagnostic) oder die
+ AllowedIPs ohne 0.0.0.0/0. Zu grosse Netze kuerzt satellite.py selbst."""
+ if os.environ.get("SCAN_NETS", "").strip():
+ return os.environ["SCAN_NETS"].strip()
+ nets = []
+ for p in c["peers"]:
+ for n in _split(p.get("allowedips", "")):
+ if ":" not in n and not n.endswith("/0"):
+ nets.append(n)
+ return ",".join(nets)
+
+
+# ── Beobachten ──────────────────────────────────────────────────
+
+def watch(c: dict) -> None:
+ connected = False
+ since = 0
+ last_resolve = 0.0
+ while not _stop.is_set():
+ try:
+ dump = sh("wg", "show", IFACE, "dump").splitlines()[1:]
+ now = int(time.time())
+ hs = max((int(l.split("\t")[4]) for l in dump if l.count("\t") >= 6), default=0)
+ rx = sum(int(l.split("\t")[5]) for l in dump if l.count("\t") >= 6)
+ tx = sum(int(l.split("\t")[6]) for l in dump if l.count("\t") >= 6)
+ ep = ", ".join(l.split("\t")[2] for l in dump if l.count("\t") >= 6)
+ ok = hs > 0 and now - hs < HANDSHAKE_OK_S
+ if ok and not connected:
+ since = now
+ event("verbunden", endpoint=ep)
+ log.info("verbunden (%s)", ep)
+ elif connected and not ok:
+ event("getrennt", dauer_s=now - since, rx=rx, tx=tx)
+ log.info("getrennt nach %d s", now - since)
+ connected = ok
+ _conn.update(connected=ok, since=since, rx=rx, tx=tx)
+ st = {"state": "verbunden" if ok else "getrennt", "since": since if ok else 0,
+ "handshake": hs, "rx": rx, "tx": tx, "endpoint": ep, "updated": now}
+ tmp = STATUS + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(st, f)
+ os.replace(tmp, STATUS)
+ # DynDNS beim Kunden: ohne Verbindung den Namen neu aufloesen
+ if not ok and time.time() - last_resolve > 120:
+ last_resolve = time.time()
+ for p in c["peers"]:
+ if not p.get("endpoint"):
+ continue
+ h, port = endpoint_host_port(p["endpoint"])
+ for ip in resolve4(h):
+ sh("nft", "add", "element", "inet", "vpnsat", "endpoints", f"{{ {ip} }}", check=False)
+ sh("ip", "route", "replace", f"{ip}/32", "via", default_gw(), "dev", "eth0", check=False)
+ sh("wg", "set", IFACE, "peer", p["publickey"], "endpoint", f"{h}:{port}", check=False)
+ except Exception as e:
+ log.warning("Beobachten: %s", e)
+ _stop.wait(WATCH_S)
+
+
+def main() -> None:
+ def _term(*_):
+ _stop.set()
+ signal.signal(signal.SIGTERM, _term)
+ signal.signal(signal.SIGINT, _term)
+
+ try:
+ c = parse_conf(open(CONF, encoding="utf-8").read())
+ info = setup(c)
+ except Exception as e:
+ log.error("Tunnel nicht aufgebaut: %s", e)
+ event("fehler", meldung=str(e)[:300])
+ with open(STATUS, "w") as f:
+ json.dump({"state": "fehler", "error": str(e)[:300], "updated": int(time.time())}, f)
+ time.sleep(60) # nicht im Sekundentakt neu starten
+ sys.exit(1)
+ event("gestartet", netze=info["nets"])
+
+ env = dict(os.environ)
+ env.update({
+ "SAT_KIND": "vpn",
+ "SATELLITE_ID": env.get("SATELLITE_ID") or f"vpn-{env.get('VPN_ID', 'x')}",
+ "SATELLITE_LOCATION": env.get("VPN_NAME") or env.get("SATELLITE_LOCATION") or "VPN",
+ "SCAN_NETS": scan_nets(c),
+ "LIST_SELF": "false",
+ "CONTROL_ENABLED": env.get("CONTROL_ENABLED", "true"),
+ })
+ threading.Thread(target=watch, args=(c,), daemon=True).start()
+ proc = None
+ while not _stop.is_set():
+ proc = subprocess.Popen([sys.executable, "-u", "/app/satellite.py"], env=env)
+ while proc.poll() is None and not _stop.is_set():
+ time.sleep(1)
+ if not _stop.is_set():
+ log.warning("satellite.py beendet (rc=%s) — starte neu", proc.returncode)
+ _stop.wait(5)
+ if proc and proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(10)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ if _conn["connected"]:
+ event("getrennt", dauer_s=int(time.time()) - _conn["since"], rx=_conn["rx"], tx=_conn["tx"])
+ event("gestoppt")
+ sh("ip", "link", "del", IFACE, check=False)
+
+
+if __name__ == "__main__":
+ main()