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:
@@ -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
@@ -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 & KI</button>
|
<button class="settings-subnav-btn active" data-sub="models" onclick="switchSettingsTab('models')">Modelle & 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 & Wartung</button>
|
<button class="settings-subnav-btn" data-sub="system" onclick="switchSettingsTab('system')">System & 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
@@ -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");
|
||||||
|
|||||||
@@ -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}×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 };
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user