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 …)
+
+
+ + + + +
+
+
@@ -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. +

+
+
+
(Lade...)
+
+

Host-Agenten 💻

@@ -2693,6 +2760,8 @@ if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; } if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; } if (msg.type === 'llm_runtime_result') { onLlmRuntimeResult(msg.payload || {}); return; } + if (msg.type === 'vpn_list') { _vpnData = msg; renderVpn(); return; } + if (msg.type === 'vpn_result') { const f = _vpnWait[msg.requestId]; if (f) { delete _vpnWait[msg.requestId]; f(msg); } return; } if (msg.type === 'llm_runtime_progress') { onLlmRuntimeProgress(msg.payload || {}); return; } if (msg.type === 'agent_history_list') { if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) { @@ -5322,6 +5391,7 @@ document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub)); document.querySelectorAll('.settings-subnav-btn').forEach(b => b.classList.toggle('active', b.dataset.sub === sub)); try { localStorage.setItem('diag_settings_subtab', sub); } catch (e) {} + if (sub === 'vpn') vpnOpen(); } function switchMainTab(tab) { document.querySelectorAll('.main-tab').forEach(el => el.classList.remove('visible')); @@ -6243,12 +6313,22 @@ function renderSatellites() { const box = document.getElementById('sat-list'); if (!box) return; - if (!satellites.length) { + const lan = satellites.filter(s => s.kind !== 'vpn'); + const vpnSats = satellites.filter(s => s.kind === 'vpn'); + const vbox = document.getElementById('vpnsat-list'); + if (vbox) vbox.innerHTML = vpnSats.length ? vpnSats.map(s => _satCard(s)).join('') : + 'Keine VPN-Verbindung aktiv. Anlegen unter Einstellungen → VPN.'; + if (!lan.length) { box.innerHTML = 'Kein Satellit verbunden. ' + 'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).'; return; } - box.innerHTML = satellites.map(s => { + box.innerHTML = lan.map(s => _satCard(s)).join(''); + } + + // Eine Satelliten-Karte (auch fuer VPN-Satelliten — gleiche Funktionen). + function _satCard(s) { + { const online = s.online !== false; const dot = online ? '#34C759' : '#FF3B30'; const caps = (s.caps || []).join(', ') || 'nur beobachten'; @@ -6319,7 +6399,7 @@ return '
' + '
' + '
' + - '🛰️ ' + escapeHtml(s.location || s.id) + ' ' + + '' + (s.kind === 'vpn' ? '🔐 ' : '🛰️ ') + escapeHtml(s.location || s.id) + ' ' + (s.alias ? '(' + escapeHtml(s.agentName || '') + ') ' : '') + 'id=' + escapeHtml(s.id) + '
' + '' + @@ -6327,12 +6407,13 @@ (scanning ? ' disabled' : '') + '>Geraete scannen' + '
' + '
kann: ' + escapeHtml(caps) + ' · ' + ctrl + - (s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '') + + (s.kind === 'vpn' && s.net && (s.net.vpnNets || []).length ? ' · VPN-Netze: ' + escapeHtml(s.net.vpnNets.join(', ')) : + (s.net && s.net.primary_ip ? ' · Netz: ' + escapeHtml(s.net.primary_ip) : '')) + (s.net && s.net.mac ? ' · MAC: ' + escapeHtml(s.net.mac) + '' : '') + '
' + (s.net && s.net.warning ? '
⚠ ' + escapeHtml(s.net.warning) + '
' : '') + devHtml + '
'; - }).join(''); + } } // ── FritzBox-Sicherungen ─────────────────────────────── @@ -9976,6 +10057,241 @@ document.getElementById('llm-dl-modal').classList.remove('open'); } + // ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ────────── + // Jede Verbindung = eigener Container auf ARIA (diagnostic/vpn.js). + let _vpnData = { connections: [], settings: {} }; + const _vpnWait = {}; + let _vpnTimer = null; + function vpnReq(action, extra = {}, timeoutMs = 60000) { + const requestId = 'vpn_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7); + return new Promise(resolve => { + const t = setTimeout(() => { delete _vpnWait[requestId]; resolve({ ok: false, error: 'Keine Antwort von der Diagnostic.' }); }, timeoutMs); + _vpnWait[requestId] = r => { clearTimeout(t); resolve(r); }; + send({ action, requestId, ...extra }); + }); + } + function vpnOpen() { + send({ action: 'vpn_list' }); + if (!_vpnTimer) _vpnTimer = setInterval(() => { + const pane = document.querySelector('.settings-subtab[data-sub="vpn"]'); + if (pane && pane.classList.contains('visible')) send({ action: 'vpn_list' }); + }, 15000); + vpnTab(window._vpnSub || 'clients'); + } + function vpnTab(t) { + window._vpnSub = t; + document.querySelectorAll('.vpn-pane').forEach(el => el.style.display = el.dataset.vsub === t ? '' : 'none'); + document.querySelectorAll('.vpn-sub').forEach(b => b.style.borderColor = b.dataset.vsub === t ? '#0096FF' : ''); + if (t === 'settings') { + const el = document.getElementById('vpn-keep-days'); + if (el && document.activeElement !== el) el.value = (_vpnData.settings || {}).keepDays || 30; + } + } + function _vpnBytes(n) { + n = +n || 0; + return n > 1e9 ? (n / 1e9).toFixed(1).replace('.', ',') + ' GB' : n > 1e6 ? (n / 1e6).toFixed(1).replace('.', ',') + ' MB' : Math.round(n / 1e3) + ' kB'; + } + function _vpnDur(s) { + s = Math.max(0, Math.round(s)); + const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60); + return d ? d + ' T ' + h + ' h' : h ? h + ' h ' + m + ' min' : m ? m + ' min' : s + ' s'; + } + function _vpnState(c) { + const st = c.status || {}, ct = c.container || {}; + if (!c.enabled) return ['#8888AA', 'aus']; + if (!ct.exists) return ['#FF9500', 'Container fehlt']; + if (st.state === 'fehler') return ['#FF3B30', 'Fehler: ' + (st.error || '')]; + if (ct.restarting || !ct.running) return ['#FF9500', 'startet neu …']; + if (st.state === 'verbunden') return ['#34C759', 'verbunden seit ' + _vpnDur(Date.now() / 1000 - st.since)]; + return ['#FFD60A', 'wartet auf Gegenstelle']; + } + function renderVpn() { + const box = document.getElementById('vpn-client-list'); + if (!box) return; + const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === 'client'); + if (!list.length) { box.innerHTML = 'Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.'; return; } + box.innerHTML = list.map(c => { + const [col, txt] = _vpnState(c); + const st = c.status || {}; + const q = "'" + escapeHtml(c.id) + "'"; + return '
' + + '' + + '
🔐 ' + escapeHtml(c.name) + '' + + '
' + escapeHtml(txt) + + (c.enabled && st.rx != null ? ' · ↓ ' + _vpnBytes(st.rx) + ' ↑ ' + _vpnBytes(st.tx) : '') + + (c.scanNets ? ' · Netze: ' + escapeHtml(c.scanNets) : '') + '
' + + '' + + '' + + '' + + '' + + '' + + '
'; + }).join(''); + } + async function vpnToggle(id, on, el) { + if (el) el.disabled = true; + const r = await vpnReq('vpn_toggle', { id, enabled: on }); + if (!r.ok) uiAlert('Verbindung ' + (on ? 'einschalten' : 'ausschalten') + ' fehlgeschlagen\n\n' + (r.error || '')); + if (el) el.disabled = false; + } + async function vpnDelete(id) { + const c = (_vpnData.connections || []).find(x => x.id === id) || {}; + if (!await uiConfirm('Verbindung „' + (c.name || id) + '“ löschen?\n\nDer Tunnel wird getrennt, WireGuard-Datei, Log und die Zugangsdaten dieses VPN-Satelliten sind danach weg.')) return; + const r = await vpnReq('vpn_delete', { id }); + if (!r.ok) uiAlert('Löschen fehlgeschlagen\n\n' + (r.error || '')); + } + async function vpnDownload(id) { + const r = await vpnReq('vpn_get', { id }); + if (!r.ok) { uiAlert('Download fehlgeschlagen\n\n' + (r.error || '')); return; } + const c = r.connection; + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([c.conf], { type: 'text/plain' })); + a.download = (c.name || 'wireguard').replace(/[^\w.-]+/g, '_') + '.conf'; + document.body.appendChild(a); a.click(); a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 2000); + } + function _vpnModal(html) { + let m = document.getElementById('vpn-modal'); + if (!m) { + m = document.createElement('div'); + m.id = 'vpn-modal'; + m.style.cssText = 'position:fixed;inset:0;z-index:9000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;'; + m.addEventListener('click', e => { if (e.target === m) vpnCloseModal(); }); + document.body.appendChild(m); + } + m.innerHTML = '
' + html + '
'; + m.style.display = 'flex'; + return m; + } + function vpnCloseModal() { const m = document.getElementById('vpn-modal'); if (m) m.style.display = 'none'; } + async function vpnEdit(id, kind) { + let c = { name: '', conf: '', scanNets: '', kind: kind || 'client' }; + if (id) { + const r = await vpnReq('vpn_get', { id }); + if (!r.ok) { uiAlert('Laden fehlgeschlagen\n\n' + (r.error || '')); return; } + c = r.connection; + } + const inp = 'width:100%;box-sizing:border-box;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:6px 8px;color:#E0E0F0;font-size:12px;'; + _vpnModal( + '' + (id ? 'Verbindung bearbeiten' : 'WireGuard-Verbindung hinzufügen') + '' + + '
Name (so heißt der VPN-Satellit, ARIA spricht ihn damit an)' + + '
' + + '
WireGuard-Datei' + + '' + + 'oder unten einfügen — bleibt editierbar
' + + '' + + '
' + + '
Netze zum Scannen (optional, Komma-getrennt — leer = AllowedIPs der Datei)' + + '
' + + '
' + + '
' + + '' + + '' + + '
'); + document.getElementById('vpn-f-name').value = c.name || ''; + document.getElementById('vpn-f-conf').value = c.conf || ''; + document.getElementById('vpn-f-nets').value = c.scanNets || ''; + vpnCheckConf(); + document.getElementById('vpn-f-name').focus(); + } + function vpnLoadFile(input) { + const f = input.files && input.files[0]; + if (!f) return; + if (f.size > 64 * 1024) { uiAlert('Die Datei ist zu groß für eine WireGuard-Konfiguration.'); return; } + const rd = new FileReader(); + rd.onload = () => { + document.getElementById('vpn-f-conf').value = String(rd.result || ''); + const n = document.getElementById('vpn-f-name'); + if (n && !n.value.trim()) n.value = f.name.replace(/\.(conf|txt)$/i, ''); + vpnCheckConf(); + }; + rd.readAsText(f); + } + let _vpnChkT = null; + function vpnCheckConf() { + clearTimeout(_vpnChkT); + _vpnChkT = setTimeout(async () => { + const el = document.getElementById('vpn-f-check'); + const conf = (document.getElementById('vpn-f-conf') || {}).value || ''; + if (!el) return; + if (!conf.trim()) { el.innerHTML = ''; return; } + const r = await vpnReq('vpn_check', { conf }, 10000); + const k = (r && r.check) || {}; + if (k.error) { el.innerHTML = '✗ ' + escapeHtml(k.error) + ''; return; } + el.innerHTML = '✓ Gegenstelle ' + escapeHtml(k.endpoint || '?') + '' + + (k.nets.length ? ' · Netze: ' + escapeHtml(k.nets.join(', ')) + '' : '') + + (k.all ? '
⚠ AllowedIPs = 0.0.0.0/0 (alles durch den Tunnel). Der Weg zu ARIA bleibt trotzdem draußen — trag bei „Netze zum Scannen“ das Kundennetz ein.' : ''); + const nets = document.getElementById('vpn-f-nets'); + if (nets && !nets.value.trim() && k.nets.length) nets.placeholder = k.nets.join(', '); + }, 300); + } + async function vpnSave(id, kind) { + const name = document.getElementById('vpn-f-name').value.trim(); + const conf = document.getElementById('vpn-f-conf').value; + const scanNets = document.getElementById('vpn-f-nets').value.trim(); + const msg = document.getElementById('vpn-f-msg'); + const btn = document.getElementById('vpn-f-save'); + if (!name) { msg.innerHTML = 'Bitte einen Namen vergeben.'; return; } + btn.disabled = true; msg.innerHTML = 'speichere …'; + const r = await vpnReq('vpn_save', { id, kind, name, conf, scanNets }); + btn.disabled = false; + if (!r.ok) { msg.innerHTML = '✗ ' + escapeHtml(r.error || 'fehlgeschlagen') + ''; return; } + vpnCloseModal(); + } + async function vpnInfo(id) { + _vpnModal('lade …'); + const r = await vpnReq('vpn_info', { id }); + if (!r.ok) { _vpnModal('Fehler
' + escapeHtml(r.error || '') + '
' + + '
'); return; } + const i = r.info, st = i.status || {}, [col, txt] = _vpnState(i); + const fmt = ts => new Date(ts * 1000).toLocaleString('de-DE'); + const ev = (i.events || []).slice().reverse().map(e => { + const what = e.event === 'verbunden' ? '🟢 verbunden' + (e.endpoint ? ' (' + escapeHtml(e.endpoint) + ')' : '') + : e.event === 'getrennt' ? '🔴 getrennt nach ' + _vpnDur(e.dauer_s || 0) + (e.rx != null ? ' · ↓ ' + _vpnBytes(e.rx) + ' ↑ ' + _vpnBytes(e.tx) : '') + : e.event === 'fehler' ? '⚠ ' + escapeHtml(e.meldung || 'Fehler') + : e.event === 'gestartet' ? '▶ gestartet' + ((e.netze || []).length ? ' · ' + escapeHtml(e.netze.join(', ')) : '') + : e.event === 'gestoppt' ? '■ gestoppt' : escapeHtml(e.event); + return '' + fmt(e.ts) + '' + what + ''; + }).join(''); + _vpnModal( + '
🔐 ' + escapeHtml(i.name) + '' + + '
' + + '
' + escapeHtml(txt) + '
' + + '
' + + (st.endpoint ? 'Gegenstelle: ' + escapeHtml(st.endpoint) + ' · ' : '') + + (st.handshake ? 'letzter Handshake: ' + fmt(st.handshake) + ' · ' : '') + + (st.rx != null ? 'empfangen ' + _vpnBytes(st.rx) + ', gesendet ' + _vpnBytes(st.tx) : '') + '
' + + '
Verlauf
' + + (ev ? '' + ev + '
' : '
Noch keine Einträge.
') + + (i.logs ? '
Container-Log (technisch)' + + '
' + escapeHtml(i.logs) + '
' : '')); + } + async function vpnSaveSettings() { + const v = document.getElementById('vpn-keep-days').value; + const r = await vpnReq('vpn_settings', { settings: { keepDays: v } }); + document.getElementById('vpn-settings-msg').innerHTML = r.ok + ? '✓ gespeichert (' + r.settings.keepDays + ' Tage)' + : '✗ ' + escapeHtml(r.error || '') + ''; + } + function vpnNpmGuide() { + _vpnModal( + '
ℹ WireGuard-Server erreichbar machen (UDP 443)' + + '
' + + '

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
    ' + + '
  1. 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).
  2. ' + + '
  3. Im docker-compose des NPM den UDP-Port freigeben und neu starten:
    ports:\n  - \'443:443\'\n  - \'443:443/udp\'   # neu
  4. ' + + '
  5. 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()