feat(vpn): VPN-Satelliten — WireGuard-Clients als Satelliten ohne Installation beim Kunden

Einstellungen → VPN → WireGuard → Clients: WireGuard-Datei des Kunden
hochladen/einfuegen (editierbar), je Verbindung Schalter (an = auch nach
Neustart, aus = bleibt aus), bearbeiten, Download, ℹ-Verlauf, loeschen;
Log-Aufbewahrung im Reiter Einstellungen. Server-Reiter mit Anleitung fuer
UDP 443 (NPM-Stream bzw. ohne Nginx), Server selbst folgt.

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 13:30:10 +01:00
co-authored by Claude Opus 5.5
parent ca35f5f1e0
commit 627b826226
9 changed files with 1155 additions and 12 deletions
+3
View File
@@ -3745,6 +3745,8 @@ class ARIABridge:
"location": payload.get("location") or sid, "location": payload.get("location") or sid,
"caps": payload.get("caps") or [], "caps": payload.get("caps") or [],
"control": bool(payload.get("control")), "control": bool(payload.get("control")),
# "vpn" = VPN-Satellit (Kundennetz hinter WireGuard, laeuft auf ARIA)
"kind": payload.get("kind") or "lan",
"last_seen": time.time(), "last_seen": time.time(),
} }
cur = self._satellites[sid] cur = self._satellites[sid]
@@ -5229,6 +5231,7 @@ class ARIABridge:
"id": s["id"], "location": alias or own, "id": s["id"], "location": alias or own,
"agentName": own, "alias": alias, "agentName": own, "alias": alias,
"caps": s.get("caps") or [], "control": bool(s.get("control")), "caps": s.get("caps") or [], "control": bool(s.get("control")),
"kind": s.get("kind") or "lan",
"online": (now - s.get("last_seen", 0)) < 300, "online": (now - s.get("last_seen", 0)) < 300,
}) })
return out return out
+321 -5
View File
@@ -688,6 +688,7 @@
<div class="settings-subnav"> <div class="settings-subnav">
<button class="settings-subnav-btn active" data-sub="models" onclick="switchSettingsTab('models')">Modelle &amp; KI</button> <button class="settings-subnav-btn active" data-sub="models" onclick="switchSettingsTab('models')">Modelle &amp; KI</button>
<button class="settings-subnav-btn" data-sub="integrations" onclick="switchSettingsTab('integrations')">Integrationen</button> <button class="settings-subnav-btn" data-sub="integrations" onclick="switchSettingsTab('integrations')">Integrationen</button>
<button class="settings-subnav-btn" data-sub="vpn" onclick="switchSettingsTab('vpn')">VPN</button>
<button class="settings-subnav-btn" data-sub="system" onclick="switchSettingsTab('system')">System &amp; Wartung</button> <button class="settings-subnav-btn" data-sub="system" onclick="switchSettingsTab('system')">System &amp; Wartung</button>
</div> </div>
@@ -1359,6 +1360,57 @@
</div> </div>
</div> </div>
<div class="settings-subtab" data-sub="vpn">
<div class="settings-section">
<div class="vpn-tabs" style="display:flex;gap:4px;margin-bottom:10px;border-bottom:1px solid #1E1E2E;">
<span style="color:#0096FF;border-bottom:2px solid #0096FF;padding:6px 12px;font-size:12px;">WireGuard</span>
</div>
<div style="display:flex;gap:4px;margin-bottom:10px;">
<button class="btn secondary vpn-sub" data-vsub="clients" onclick="vpnTab('clients')" style="padding:4px 12px;font-size:12px;">Clients</button>
<button class="btn secondary vpn-sub" data-vsub="server" onclick="vpnTab('server')" style="padding:4px 12px;font-size:12px;">Server</button>
<button class="btn secondary vpn-sub" data-vsub="settings" onclick="vpnTab('settings')" style="padding:4px 12px;font-size:12px;">Einstellungen</button>
</div>
<div class="vpn-pane" data-vsub="clients">
<div class="card" style="max-width:900px;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;flex:1;">
WireGuard-Dateien, die Du von Kunden bekommst. Jede Verbindung wird ein <b>VPN-Satellit</b>
(eigener Container auf ARIA): gleiche Funktionen wie ein Satellit, ohne beim Kunden etwas zu installieren.
<b>An</b> = verbindet sich auch nach einem Neustart wieder · <b>Aus</b> = bleibt aus.
</p>
<button class="btn" onclick="vpnEdit(null,'client')" style="padding:5px 12px;font-size:12px;white-space:nowrap;">+ Hinzufügen</button>
</div>
<div id="vpn-client-list" style="font-size:12px;color:#8888AA;">(lade …)</div>
</div>
</div>
<div class="vpn-pane" data-vsub="server" style="display:none;">
<div class="card" style="max-width:900px;">
<div style="display:flex;align-items:center;gap:10px;">
<p style="color:#8888AA;font-size:12px;margin:0;flex:1;">
Hier wählen sich Kunden (z.B. per FritzBox) bei ARIA ein — über <b>UDP 443</b> auf dem RVS-Hostnamen.
Jede Verbindung wird ebenfalls ein VPN-Satellit; aus dem Kundennetz führt keine Verbindung zu ARIA.
<br><span style="color:#FFD60A;">Kommt im nächsten Schritt.</span>
</p>
<button class="btn secondary" onclick="vpnNpmGuide()" style="padding:4px 10px;font-size:11px;white-space:nowrap;">ℹ Einrichtung Proxy/Router</button>
</div>
</div>
</div>
<div class="vpn-pane" data-vsub="settings" style="display:none;">
<div class="card" style="max-width:520px;font-size:12px;">
<label style="color:#AAB;">Verbindungs-Log aufbewahren (Tage):
<input id="vpn-keep-days" type="number" min="1" max="3650" style="width:80px;margin-left:6px;background:#1E1E2E;border:1px solid #333;border-radius:4px;padding:4px 6px;color:#E0E0F0;">
</label>
<button class="btn secondary" onclick="vpnSaveSettings()" style="padding:3px 10px;font-size:11px;margin-left:6px;">Speichern</button>
<div id="vpn-settings-msg" style="margin-top:6px;color:#8888AA;"></div>
<p style="color:#6a6a88;margin:10px 0 0;">Ältere Einträge (verbunden/getrennt) werden stündlich gelöscht.</p>
</div>
</div>
</div>
</div>
<div class="settings-subtab" data-sub="system"> <div class="settings-subtab" data-sub="system">
<!-- Anhang-Limit --> <!-- Anhang-Limit -->
@@ -1895,6 +1947,21 @@
<div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div> <div id="sat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;">
<h2 style="margin:0;">VPN-Satelliten 🔐</h2>
<button class="btn secondary" onclick="switchMainTab('settings');switchSettingsTab('vpn')" style="padding:4px 10px;font-size:11px;">Verbindungen verwalten</button>
</div>
<div class="card" style="margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;">
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.
</p>
</div>
<div class="card">
<div id="vpnsat-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin:16px 0 8px;">
<h2 style="margin:0;">Host-Agenten 💻</h2> <h2 style="margin:0;">Host-Agenten 💻</h2>
<button class="btn secondary" onclick="requestHosts()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button> <button class="btn secondary" onclick="requestHosts()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
@@ -2693,6 +2760,8 @@
if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; } if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; }
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(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 === '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 === 'llm_runtime_progress') { onLlmRuntimeProgress(msg.payload || {}); return; }
if (msg.type === 'agent_history_list') { if (msg.type === 'agent_history_list') {
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) { 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-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)); document.querySelectorAll('.settings-subnav-btn').forEach(b => b.classList.toggle('active', b.dataset.sub === sub));
try { localStorage.setItem('diag_settings_subtab', sub); } catch (e) {} try { localStorage.setItem('diag_settings_subtab', sub); } catch (e) {}
if (sub === 'vpn') vpnOpen();
} }
function switchMainTab(tab) { function switchMainTab(tab) {
document.querySelectorAll('.main-tab').forEach(el => el.classList.remove('visible')); document.querySelectorAll('.main-tab').forEach(el => el.classList.remove('visible'));
@@ -6243,12 +6313,22 @@
function renderSatellites() { function renderSatellites() {
const box = document.getElementById('sat-list'); const box = document.getElementById('sat-list');
if (!box) return; 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('') :
'<span style="color:#8888AA;">Keine VPN-Verbindung aktiv. Anlegen unter Einstellungen → VPN.</span>';
if (!lan.length) {
box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' + box.innerHTML = '<span style="color:#8888AA;">Kein Satellit verbunden. ' +
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>'; 'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
return; 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 online = s.online !== false;
const dot = online ? '#34C759' : '#FF3B30'; const dot = online ? '#34C759' : '#FF3B30';
const caps = (s.caps || []).join(', ') || 'nur beobachten'; const caps = (s.caps || []).join(', ') || 'nur beobachten';
@@ -6319,7 +6399,7 @@
return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' + return '<div style="border:1px solid #1E1E2E;border-radius:8px;padding:10px;margin-bottom:8px;">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' + '<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' + '<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + dot + ';margin-right:6px;"></span>' +
'<strong style="color:#E0E0F0;">🛰️ ' + escapeHtml(s.location || s.id) + '</strong> ' + '<strong style="color:#E0E0F0;">' + (s.kind === 'vpn' ? '🔐 ' : '🛰️ ') + escapeHtml(s.location || s.id) + '</strong> ' +
(s.alias ? '<span style="color:#666;font-size:11px;" title="Der Satellit selbst nennt sich so">(' + escapeHtml(s.agentName || '') + ')</span> ' : '') + (s.alias ? '<span style="color:#666;font-size:11px;" title="Der Satellit selbst nennt sich so">(' + escapeHtml(s.agentName || '') + ')</span> ' : '') +
'<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' + '<span style="color:#666;font-size:11px;">id=' + escapeHtml(s.id) + '</span></div>' +
'<button class="btn secondary" onclick="renameEntity(\'satellite\',\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(s.alias || '') + '\',\'' + escapeHtml(s.agentName || s.id) + '\')" title="Anzeigenamen vergeben — ARIA kann den Satelliten danach so ansprechen" style="padding:3px 8px;font-size:11px;">✎</button>' + '<button class="btn secondary" onclick="renameEntity(\'satellite\',\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(s.alias || '') + '\',\'' + escapeHtml(s.agentName || s.id) + '\')" title="Anzeigenamen vergeben — ARIA kann den Satelliten danach so ansprechen" style="padding:3px 8px;font-size:11px;">✎</button>' +
@@ -6327,12 +6407,13 @@
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' + (scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' + '</div>' +
'<div style="color:#8888AA;font-size:11px;margin-top:4px;">kann: ' + escapeHtml(caps) + ' · ' + ctrl + '<div style="color:#8888AA;font-size:11px;margin-top:4px;">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: <span style="font-family:monospace;">' + escapeHtml(s.net.mac) + '</span>' : '') + '</div>' + (s.net && s.net.mac ? ' · MAC: <span style="font-family:monospace;">' + escapeHtml(s.net.mac) + '</span>' : '') + '</div>' +
(s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') + (s.net && s.net.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
devHtml + devHtml +
'</div>'; '</div>';
}).join(''); }
} }
// ── FritzBox-Sicherungen ─────────────────────────────── // ── FritzBox-Sicherungen ───────────────────────────────
@@ -9976,6 +10057,241 @@
document.getElementById('llm-dl-modal').classList.remove('open'); 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 = '<span style="color:#6a6a88;">Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.</span>'; return; }
box.innerHTML = list.map(c => {
const [col, txt] = _vpnState(c);
const st = c.status || {};
const q = "'" + escapeHtml(c.id) + "'";
return '<div style="display:flex;align-items:center;gap:10px;padding:7px 8px;background:#0D0D1A;border-radius:6px;margin-bottom:5px;">' +
'<span style="width:9px;height:9px;border-radius:50%;background:' + col + ';flex:none;"></span>' +
'<div style="flex:1;min-width:0;"><b style="color:#E0E0F0;">🔐 ' + escapeHtml(c.name) + '</b>' +
'<div style="color:#8888AA;font-size:11px;">' + escapeHtml(txt) +
(c.enabled && st.rx != null ? ' · ↓ ' + _vpnBytes(st.rx) + ' ↑ ' + _vpnBytes(st.tx) : '') +
(c.scanNets ? ' · Netze: ' + escapeHtml(c.scanNets) : '') + '</div></div>' +
'<label title="' + (c.enabled ? 'Ausschalten' : 'Einschalten — verbindet sich dann auch nach einem Neustart wieder') + '" style="display:inline-flex;align-items:center;cursor:pointer;">' +
'<input type="checkbox" ' + (c.enabled ? 'checked' : '') + ' onchange="vpnToggle(' + q + ',this.checked,this)" style="width:16px;height:16px;"></label>' +
'<button class="btn secondary" title="Bearbeiten" onclick="vpnEdit(' + q + ')" style="padding:2px 7px;font-size:12px;">✏️</button>' +
'<button class="btn secondary" title="WireGuard-Datei herunterladen" onclick="vpnDownload(' + q + ')" style="padding:2px 7px;font-size:12px;">⬇</button>' +
'<button class="btn secondary" title="Verbindungs-Log und Statistik" onclick="vpnInfo(' + q + ')" style="padding:2px 7px;font-size:12px;">ℹ</button>' +
'<button class="btn secondary" title="Löschen" onclick="vpnDelete(' + q + ')" style="padding:2px 7px;font-size:12px;">🗑</button>' +
'</div>';
}).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 = '<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:700px;width:100%;max-height:88vh;overflow-y:auto;color:#E0E0F0;font-size:13px;line-height:1.5;">' + html + '</div>';
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(
'<b style="font-size:15px;">' + (id ? 'Verbindung bearbeiten' : 'WireGuard-Verbindung hinzufügen') + '</b>' +
'<div style="margin-top:10px;">Name <span style="color:#8888AA;">(so heißt der VPN-Satellit, ARIA spricht ihn damit an)</span>' +
'<input id="vpn-f-name" type="text" maxlength="60" placeholder="z.B. Kunde Müller Büro" style="' + inp + 'margin-top:3px;"></div>' +
'<div style="margin-top:10px;display:flex;align-items:center;gap:8px;">WireGuard-Datei' +
'<label class="btn secondary" style="padding:2px 10px;font-size:11px;cursor:pointer;">Datei wählen …' +
'<input type="file" accept=".conf,.txt,text/plain" onchange="vpnLoadFile(this)" style="display:none;"></label>' +
'<span style="color:#8888AA;font-size:11px;">oder unten einfügen — bleibt editierbar</span></div>' +
'<textarea id="vpn-f-conf" rows="14" spellcheck="false" oninput="vpnCheckConf()" placeholder="[Interface]\nPrivateKey = …\nAddress = 10.8.0.2/24\n\n[Peer]\nPublicKey = …\nEndpoint = kunde.example.de:51820\nAllowedIPs = 192.168.178.0/24" ' +
'style="' + inp + 'margin-top:4px;font-family:monospace;font-size:11.5px;"></textarea>' +
'<div id="vpn-f-check" style="font-size:11px;min-height:16px;margin-top:3px;"></div>' +
'<div style="margin-top:8px;">Netze zum Scannen <span style="color:#8888AA;">(optional, Komma-getrennt — leer = AllowedIPs der Datei)</span>' +
'<input id="vpn-f-nets" type="text" placeholder="z.B. 192.168.178.0/24" style="' + inp + 'margin-top:3px;"></div>' +
'<div id="vpn-f-msg" style="margin-top:8px;font-size:12px;"></div>' +
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px;">' +
'<button class="btn secondary" onclick="vpnCloseModal()" style="padding:6px 14px;font-size:12px;">Abbrechen</button>' +
'<button class="btn" id="vpn-f-save" onclick="vpnSave(' + (id ? "'" + escapeHtml(id) + "'" : 'null') + ',\'' + escapeHtml(c.kind || 'client') + '\')" style="padding:6px 14px;font-size:12px;">Speichern</button>' +
'</div>');
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 = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(k.error) + '</span>'; return; }
el.innerHTML = '<span style="color:#34C759;">✓ Gegenstelle ' + escapeHtml(k.endpoint || '?') + '</span>' +
(k.nets.length ? ' <span style="color:#8888AA;">· Netze: ' + escapeHtml(k.nets.join(', ')) + '</span>' : '') +
(k.all ? '<br><span style="color:#FF9500;">⚠ 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.</span>' : '');
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 = '<span style="color:#FF6E6E;">Bitte einen Namen vergeben.</span>'; return; }
btn.disabled = true; msg.innerHTML = '<span style="color:#8888AA;">speichere …</span>';
const r = await vpnReq('vpn_save', { id, kind, name, conf, scanNets });
btn.disabled = false;
if (!r.ok) { msg.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(r.error || 'fehlgeschlagen') + '</span>'; return; }
vpnCloseModal();
}
async function vpnInfo(id) {
_vpnModal('<b>lade …</b>');
const r = await vpnReq('vpn_info', { id });
if (!r.ok) { _vpnModal('<b>Fehler</b><div style="color:#FF6E6E;margin-top:8px;">' + escapeHtml(r.error || '') + '</div>' +
'<div style="text-align:right;margin-top:12px;"><button class="btn secondary" onclick="vpnCloseModal()">Schließen</button></div>'); 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 '<tr><td style="color:#8888AA;padding:2px 10px 2px 0;white-space:nowrap;">' + fmt(e.ts) + '</td><td>' + what + '</td></tr>';
}).join('');
_vpnModal(
'<div style="display:flex;align-items:center;"><b style="font-size:15px;">🔐 ' + escapeHtml(i.name) + '</b>' +
'<button class="btn secondary" onclick="vpnCloseModal()" style="margin-left:auto;padding:2px 10px;font-size:11px;">Schließen</button></div>' +
'<div style="margin-top:8px;"><span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:' + col + ';margin-right:6px;"></span>' + escapeHtml(txt) + '</div>' +
'<div style="color:#8888AA;font-size:12px;margin-top:4px;">' +
(st.endpoint ? 'Gegenstelle: ' + escapeHtml(st.endpoint) + ' · ' : '') +
(st.handshake ? 'letzter Handshake: ' + fmt(st.handshake) + ' · ' : '') +
(st.rx != null ? 'empfangen ' + _vpnBytes(st.rx) + ', gesendet ' + _vpnBytes(st.tx) : '') + '</div>' +
'<div style="margin-top:12px;font-weight:600;">Verlauf</div>' +
(ev ? '<table style="font-size:12px;margin-top:4px;border-collapse:collapse;">' + ev + '</table>' : '<div style="color:#8888AA;">Noch keine Einträge.</div>') +
(i.logs ? '<details style="margin-top:12px;"><summary style="cursor:pointer;color:#8888AA;">Container-Log (technisch)</summary>' +
'<pre style="background:#08080F;color:#9a9ab5;font-size:10.5px;padding:6px 8px;border-radius:4px;max-height:220px;overflow:auto;white-space:pre-wrap;">' + escapeHtml(i.logs) + '</pre></details>' : ''));
}
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
? '<span style="color:#34C759;">✓ gespeichert (' + r.settings.keepDays + ' Tage)</span>'
: '<span style="color:#FF6E6E;">✗ ' + escapeHtml(r.error || '') + '</span>';
}
function vpnNpmGuide() {
_vpnModal(
'<div style="display:flex;align-items:center;"><b style="font-size:15px;">ℹ WireGuard-Server erreichbar machen (UDP 443)</b>' +
'<button class="btn secondary" onclick="vpnCloseModal()" style="margin-left:auto;padding:2px 10px;font-size:11px;">Schließen</button></div>' +
'<p style="color:#AAB;">Kunden wählen sich auf <b>denselben Hostnamen wie der RVS</b> ein, nur über <b>UDP</b> statt TCP. ' +
'TCP 443 (RVS, Webseiten) bleibt unverändert, beides stört sich nicht. Den Hostnamen nimmt ARIA automatisch aus den Einstellungen.</p>' +
'<b>Mit Nginx Proxy Manager</b><ol style="color:#AAB;margin-top:4px;">' +
'<li>Hosts → <b>Streams</b> → Add Stream: Incoming Port <code>443</code>, Forward Host = ARIA-VM, Forward Port = Port des WireGuard-Verteilers, ' +
'<b>TCP Forwarding aus</b>, <b>UDP Forwarding an</b> (TCP an würde mit den Proxy-Hosts kollidieren).</li>' +
'<li>Im docker-compose des NPM den UDP-Port freigeben und neu starten:<pre style="background:#08080F;padding:6px 8px;border-radius:4px;">ports:\n - \'443:443\'\n - \'443:443/udp\' # neu</pre></li>' +
'<li>Am Router/an der Firewall <b>UDP 443</b> ebenfalls auf den NPM-Rechner weiterleiten (nicht nur TCP).</li></ol>' +
'<b>Ohne Nginx</b><p style="color:#AAB;margin-top:4px;">Den RVS-Port am Router zusätzlich als <b>UDP</b> weiterleiten — direkt auf die ARIA-VM, Port des WireGuard-Verteilers.</p>' +
'<p style="color:#8888AA;font-size:12px;">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.</p>');
}
// ── llama.cpp/llama-swap der LLM-Boxen (Modell-Katalog → ⚙ llama.cpp) ── // ── llama.cpp/llama-swap der LLM-Boxen (Modell-Katalog → ⚙ llama.cpp) ──
// Pruefen/Aktualisieren/Zuruecksetzen macht auf der Box der llm-updater // Pruefen/Aktualisieren/Zuruecksetzen macht auf der Box der llm-updater
// (einziger Dienst mit Docker-Zugriff). Ob es etwas Neues gibt, entscheidet // (einziger Dienst mit Docker-Zugriff). Ob es etwas Neues gibt, entscheidet
+59 -1
View File
@@ -17,6 +17,7 @@ const { WebSocket, WebSocketServer } = require("ws");
const crypto = require("crypto"); const crypto = require("crypto");
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const vpn = require("./vpn");
// ── Konfiguration ─────────────────────────────────────── // ── Konfiguration ───────────────────────────────────────
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10); const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
@@ -1090,7 +1091,7 @@ function satelliteList() {
return Array.from(satellites.values()).map(s => ({ return Array.from(satellites.values()).map(s => ({
id: s.id, location: alias[s.id] || s.location, id: s.id, location: alias[s.id] || s.location,
agentName: s.location, alias: alias[s.id] || "", 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, online: (now - (s.last_seen || 0)) < 300000,
})); }));
} }
@@ -1098,6 +1099,58 @@ function satelliteList() {
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat // requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
const credsRevealWaiters = new Map(); 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() { function broadcastSatellites() {
broadcast({ type: "sat_update", satellites: satelliteList() }); broadcast({ type: "sat_update", satellites: satelliteList() });
} }
@@ -1644,6 +1697,7 @@ function connectRVS(forcePlain) {
satellites.set(p.id, { satellites.set(p.id, {
id: p.id, location: p.location || p.id, id: p.id, location: p.location || p.id,
caps: p.caps || [], control: !!p.control, net: p.net || null, caps: p.caps || [], control: !!p.control, net: p.net || null,
kind: p.kind || "lan",
last_seen: Date.now(), last_seen: Date.now(),
}); });
broadcastSatellites(); broadcastSatellites();
@@ -3695,6 +3749,10 @@ wss.on("connection", (ws) => {
}, },
timestamp: Date.now(), 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") { } else if (msg.action === "llm_runtime") {
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen. // llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
const op = String(msg.op || "status"); const op = String(msg.op || "status");
+282
View File
@@ -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/<id>/tunnel/wg.conf die WireGuard-Datei (0600)
// /data/vpn/<id>/tunnel/status.json schreibt der Container (ℹ-Fenster)
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
//
// An = Container mit restart unless-stopped (nach Neustart wieder da).
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
const http = require("http");
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const DIR = "/data/vpn";
const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest";
const NETWORK = "aria-vpn";
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
const PREFIX = "aria-vpn-";
function docker(method, apiPath, body) {
return new Promise((resolve, reject) => {
const data = body ? JSON.stringify(body) : "";
const req = http.request({
socketPath: "/var/run/docker.sock", path: apiPath, method,
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) },
}, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
const buf = Buffer.concat(chunks);
let json = null;
try { json = JSON.parse(buf.toString() || "null"); } catch { /* Logs sind kein JSON */ }
resolve({ status: res.statusCode, json, buf });
});
});
req.on("error", reject);
req.setTimeout(30000, () => req.destroy(new Error("Docker antwortet nicht")));
if (data) req.write(data);
req.end();
});
}
// ── Ablage ──────────────────────────────────────────────────────
function readJson(p, fallback) {
try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
}
function writeJson(p, v, mode = 0o600) {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p + ".tmp", JSON.stringify(v, null, 2), { mode });
fs.renameSync(p + ".tmp", p);
}
const connsFile = () => path.join(DIR, "connections.json");
const loadConns = () => readJson(connsFile(), []);
const saveConns = (list) => writeJson(connsFile(), list);
const tunnelDir = (id) => path.join(DIR, id, "tunnel");
function settings() {
return { keepDays: 30, ...readJson(path.join(DIR, "settings.json"), {}) };
}
// Host-Pfad von /data — die VPN-Container brauchen Bind-Mounts mit HOST-Pfaden.
let _hostData = "";
async function hostDataDir() {
if (_hostData) return _hostData;
const r = await docker("GET", `/containers/${SELF}/json`);
const m = ((r.json && r.json.Mounts) || []).find((x) => x.Destination === "/data");
if (!m) throw new Error("Host-Pfad von /data unbekannt (Diagnostic ohne Bind-Mount?)");
_hostData = m.Source;
return _hostData;
}
// ── WireGuard-Datei pruefen ─────────────────────────────────────
function parseConf(text) {
const iface = {}; const peers = []; let cur = null;
for (const raw of String(text || "").split(/\r?\n/)) {
const line = raw.split("#")[0].trim();
if (!line) continue;
const sec = line.match(/^\[(\w+)\]$/);
if (sec) {
const n = sec[1].toLowerCase();
cur = n === "interface" ? iface : n === "peer" ? (peers.push({}), peers[peers.length - 1]) : null;
continue;
}
const i = line.indexOf("=");
if (!cur || i < 0) continue;
const k = line.slice(0, i).trim().toLowerCase(); const v = line.slice(i + 1).trim();
cur[k] = cur[k] && ["address", "allowedips", "dns"].includes(k) ? cur[k] + "," + v : v;
}
return { iface, peers };
}
function checkConf(text) {
const c = parseConf(text);
if (!c.iface.privatekey) return { error: "In [Interface] fehlt PrivateKey." };
if (!c.peers.length || c.peers.some((p) => !p.publickey)) return { error: "In [Peer] fehlt PublicKey." };
if (!c.peers.some((p) => p.endpoint)) return { error: "Kein Endpoint — wohin soll sich der Tunnel verbinden?" };
const nets = [];
for (const p of c.peers) {
for (const n of String(p.allowedips || "").split(",").map((s) => s.trim()).filter(Boolean)) {
if (!n.includes(":") && !n.endsWith("/0")) nets.push(n);
}
}
const all = c.peers.some((p) => /(^|,)\s*0\.0\.0\.0\/0/.test(p.allowedips || ""));
return { ok: true, nets, all, endpoint: c.peers.map((p) => p.endpoint).filter(Boolean).join(", ") };
}
// ── Container ───────────────────────────────────────────────────
async function ensureNetwork() {
const r = await docker("GET", `/networks/${NETWORK}`);
if (r.status === 200) return;
// ICC aus: VPN-Container sehen sich gegenseitig nicht.
const c = await docker("POST", "/networks/create", {
Name: NETWORK, Driver: "bridge",
Options: { "com.docker.network.bridge.enable_icc": "false" },
Labels: { "aria.vpn": "1" },
});
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
}
async function removeContainer(id) {
await docker("DELETE", `/containers/${PREFIX}${id}?force=true`);
}
async function startContainer(conn, rvs) {
const img = await docker("GET", `/images/${encodeURIComponent(IMAGE)}/json`);
if (img.status !== 200) {
throw new Error(`Image ${IMAGE} fehlt. Auf der ARIA-VM: docker compose up -d --build vpn-satellite-image`);
}
await ensureNetwork();
const host = await hostDataDir();
fs.mkdirSync(path.join(DIR, conn.id, "data"), { recursive: true });
const env = {
VPN_ID: conn.id, VPN_NAME: conn.name, SATELLITE_ID: `vpn-${conn.id}`,
SATELLITE_LOCATION: conn.name, SCAN_NETS: conn.scanNets || "",
SCAN_MAX_SEC: "180", CONTROL_ENABLED: "true",
RVS_HOST: rvs.host, RVS_PORT: String(rvs.port), RVS_TLS: String(rvs.tls),
RVS_TLS_FALLBACK: String(rvs.tlsFallback), RVS_TOKEN: rvs.token,
...(rvs.sni ? { RVS_SNI: rvs.sni } : {}),
};
await removeContainer(conn.id);
const c = await docker("POST", `/containers/create?name=${PREFIX}${conn.id}`, {
Image: IMAGE,
Env: Object.entries(env).map(([k, v]) => `${k}=${v}`),
Labels: { "aria.vpn": conn.id, "aria.vpn.name": conn.name },
HostConfig: {
Binds: [`${host}/vpn/${conn.id}/tunnel:/vpn`, `${host}/vpn/${conn.id}/data:/data`],
CapAdd: ["NET_ADMIN"],
RestartPolicy: { Name: "unless-stopped" },
NetworkMode: NETWORK,
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
},
});
if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`);
const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`);
if (s.status >= 300) throw new Error(`Container starten: ${JSON.stringify(s.json).slice(0, 300)}`);
}
async function containerState(id) {
const r = await docker("GET", `/containers/${PREFIX}${id}/json`);
if (r.status !== 200) return { exists: false };
const st = r.json.State || {};
return { exists: true, running: !!st.Running, restarting: !!st.Restarting,
startedAt: st.StartedAt, exitCode: st.ExitCode };
}
async function containerLogs(id, lines = 60) {
const r = await docker("GET", `/containers/${PREFIX}${id}/logs?stdout=true&stderr=true&tail=${lines}&timestamps=true`);
if (r.status !== 200) return "";
// Docker-Multiplex-Format: 8 Byte Kopf je Block
const out = []; let i = 0; const b = r.buf;
while (i + 8 <= b.length) {
const len = b.readUInt32BE(i + 4);
out.push(b.slice(i + 8, i + 8 + len).toString());
i += 8 + len;
}
return out.join("");
}
// ── API (vom ws-Handler in server.js) ───────────────────────────
async function list() {
const conns = loadConns();
const res = [];
for (const c of conns) {
const st = readJson(path.join(tunnelDir(c.id), "status.json"), {});
res.push({ ...c, status: st, container: await containerState(c.id) });
}
return { connections: res, settings: settings() };
}
async function save(msg, rvs) {
const name = String(msg.name || "").trim().slice(0, 60);
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
if (!name) throw new Error("Name fehlt.");
const chk = checkConf(conf);
if (chk.error) throw new Error(chk.error);
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",");
const conns = loadConns();
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
if (!conn) {
conn = { id: crypto.randomBytes(4).toString("hex"), kind: msg.kind || "client",
enabled: false, createdAt: Date.now() };
conns.push(conn);
}
Object.assign(conn, { name, scanNets, updatedAt: Date.now() });
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), conf, { mode: 0o600 });
saveConns(conns);
if (conn.enabled) await startContainer(conn, rvs); // Aenderung greift sofort
return { id: conn.id };
}
async function toggle(id, enabled, rvs) {
const conns = loadConns();
const conn = conns.find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
conn.enabled = !!enabled;
saveConns(conns);
if (conn.enabled) await startContainer(conn, rvs);
else await removeContainer(id);
return { id, enabled: conn.enabled };
}
async function remove(id) {
const conns = loadConns();
if (!conns.some((c) => c.id === id)) throw new Error("Verbindung unbekannt.");
await removeContainer(id);
saveConns(conns.filter((c) => c.id !== id));
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
return { id };
}
function getConf(id) {
const conn = loadConns().find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
}
async function info(id) {
const conn = loadConns().find((c) => c.id === id);
if (!conn) throw new Error("Verbindung unbekannt.");
const dir = tunnelDir(id);
let events = [];
try {
events = fs.readFileSync(path.join(dir, "events.jsonl"), "utf8").trim().split("\n")
.filter(Boolean).slice(-300).map((l) => { try { return JSON.parse(l); } catch { return null; } })
.filter(Boolean);
} catch { /* noch keine */ }
return { ...conn, status: readJson(path.join(dir, "status.json"), {}), events,
container: await containerState(id), logs: await containerLogs(id) };
}
function setSettings(s) {
const keepDays = Math.max(1, Math.min(3650, parseInt(s.keepDays, 10) || 30));
writeJson(path.join(DIR, "settings.json"), { keepDays });
rotate();
return { keepDays };
}
// Log-Rotation: Ereignisse aelter als keepDays fliegen raus (stuendlich).
function rotate() {
const cut = Date.now() / 1000 - settings().keepDays * 86400;
for (const c of loadConns()) {
const p = path.join(tunnelDir(c.id), "events.jsonl");
try {
const lines = fs.readFileSync(p, "utf8").split("\n").filter(Boolean);
const keep = lines.filter((l) => { try { return JSON.parse(l).ts >= cut; } catch { return false; } });
if (keep.length !== lines.length) fs.writeFileSync(p, keep.join("\n") + (keep.length ? "\n" : ""));
} catch { /* keine Datei */ }
}
}
setInterval(rotate, 3600 * 1000).unref();
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf };
+16
View File
@@ -164,6 +164,22 @@ services:
- RVS_TOKEN=${RVS_TOKEN:-} - RVS_TOKEN=${RVS_TOKEN:-}
restart: unless-stopped restart: unless-stopped
# ─── VPN-Satelliten: nur das Image bauen ───────────────
# Die eigentlichen Container (einer pro WireGuard-Verbindung, aria-vpn-<id>)
# 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: networks:
aria-net: aria-net:
driver: bridge driver: bridge
+18
View File
@@ -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"]
+43
View File
@@ -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; Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
sonst `false` und nur beobachten. 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-<id>`, 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/<id>/` (`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.
+70
View File
@@ -131,6 +131,16 @@ if RVS_TLS and RVS_SNI:
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip() SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_id()).strip()
SATELLITE_LOCATION = (os.environ.get("SATELLITE_LOCATION") or SATELLITE_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_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [ CONTROL_ALLOWLIST = [
a.strip() for a in a.strip() for a in
@@ -417,6 +427,9 @@ def _net_context() -> dict:
ips.insert(0, primary) ips.insert(0, primary)
p = primary or (ips[0] if ips else "") p = primary or (ips[0] if ips else "")
warning = "" 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): 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. " 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 " "mDNS/SSDP erreichen die realen Geraete so nicht. Auf Docker Desktop "
@@ -679,6 +692,58 @@ def _discover_arp() -> list[dict]:
return out 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]: def _merge_devices(*lists) -> list[dict]:
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt).""" """Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
by_ip: dict[str, dict] = {} by_ip: dict[str, dict] = {}
@@ -2626,6 +2691,10 @@ class Satellite:
async def _scan_inner(self) -> None: async def _scan_inner(self) -> None:
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC.""" """Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
if SAT_KIND == "vpn":
self._scan_stage = "vpn"
mdns, ssdp, arp = [], [], await _discover_routed(SCAN_NETS)
else:
self._scan_stage = "mdns" self._scan_stage = "mdns"
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC) mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
self._scan_stage = "ssdp" self._scan_stage = "ssdp"
@@ -2718,6 +2787,7 @@ class Satellite:
"caps": CAPABILITIES, "caps": CAPABILITIES,
"control": CONTROL_ENABLED, "control": CONTROL_ENABLED,
"net": NET, "net": NET,
"kind": SAT_KIND,
}, },
"timestamp": int(time.time() * 1000), "timestamp": int(time.time() * 1000),
}) })
+337
View File
@@ -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()