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
+321 -5
View File
@@ -688,6 +688,7 @@
<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" 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>
</div>
@@ -1359,6 +1360,57 @@
</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">
<!-- Anhang-Limit -->
@@ -1895,6 +1947,21 @@
<div id="sat-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;">
<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;">
<h2 style="margin:0;">Host-Agenten 💻</h2>
<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 === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; }
if (msg.type === 'llm_runtime_result') { onLlmRuntimeResult(msg.payload || {}); return; }
if (msg.type === 'vpn_list') { _vpnData = msg; renderVpn(); return; }
if (msg.type === 'vpn_result') { const f = _vpnWait[msg.requestId]; if (f) { delete _vpnWait[msg.requestId]; f(msg); } return; }
if (msg.type === 'llm_runtime_progress') { onLlmRuntimeProgress(msg.payload || {}); return; }
if (msg.type === 'agent_history_list') {
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
@@ -5322,6 +5391,7 @@
document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub));
document.querySelectorAll('.settings-subnav-btn').forEach(b => b.classList.toggle('active', b.dataset.sub === sub));
try { localStorage.setItem('diag_settings_subtab', sub); } catch (e) {}
if (sub === 'vpn') vpnOpen();
}
function switchMainTab(tab) {
document.querySelectorAll('.main-tab').forEach(el => el.classList.remove('visible'));
@@ -6243,12 +6313,22 @@
function renderSatellites() {
const box = document.getElementById('sat-list');
if (!box) return;
if (!satellites.length) {
const lan = satellites.filter(s => s.kind !== 'vpn');
const vpnSats = satellites.filter(s => s.kind === 'vpn');
const vbox = document.getElementById('vpnsat-list');
if (vbox) vbox.innerHTML = vpnSats.length ? vpnSats.map(s => _satCard(s)).join('') :
'<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. ' +
'Deploy einen satellite/-Container in einem Netz (siehe satellite/README).</span>';
return;
}
box.innerHTML = satellites.map(s => {
box.innerHTML = lan.map(s => _satCard(s)).join('');
}
// Eine Satelliten-Karte (auch fuer VPN-Satelliten — gleiche Funktionen).
function _satCard(s) {
{
const online = s.online !== false;
const dot = online ? '#34C759' : '#FF3B30';
const caps = (s.caps || []).join(', ') || 'nur beobachten';
@@ -6319,7 +6399,7 @@
return '<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><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> ' : '') +
'<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>' +
@@ -6327,12 +6407,13 @@
(scanning ? ' disabled' : '') + '>Geraete scannen</button>' +
'</div>' +
'<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.warning ? '<div style="color:#FF6E6E;font-size:11px;margin-top:6px;padding:6px;background:#3A1F1F;border-radius:4px;">⚠ ' + escapeHtml(s.net.warning) + '</div>' : '') +
devHtml +
'</div>';
}).join('');
}
}
// ── FritzBox-Sicherungen ───────────────────────────────
@@ -9976,6 +10057,241 @@
document.getElementById('llm-dl-modal').classList.remove('open');
}
// ── VPN-Satelliten (Einstellungen → VPN → WireGuard) ──────────
// Jede Verbindung = eigener Container auf ARIA (diagnostic/vpn.js).
let _vpnData = { connections: [], settings: {} };
const _vpnWait = {};
let _vpnTimer = null;
function vpnReq(action, extra = {}, timeoutMs = 60000) {
const requestId = 'vpn_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7);
return new Promise(resolve => {
const t = setTimeout(() => { delete _vpnWait[requestId]; resolve({ ok: false, error: 'Keine Antwort von der Diagnostic.' }); }, timeoutMs);
_vpnWait[requestId] = r => { clearTimeout(t); resolve(r); };
send({ action, requestId, ...extra });
});
}
function vpnOpen() {
send({ action: 'vpn_list' });
if (!_vpnTimer) _vpnTimer = setInterval(() => {
const pane = document.querySelector('.settings-subtab[data-sub="vpn"]');
if (pane && pane.classList.contains('visible')) send({ action: 'vpn_list' });
}, 15000);
vpnTab(window._vpnSub || 'clients');
}
function vpnTab(t) {
window._vpnSub = t;
document.querySelectorAll('.vpn-pane').forEach(el => el.style.display = el.dataset.vsub === t ? '' : 'none');
document.querySelectorAll('.vpn-sub').forEach(b => b.style.borderColor = b.dataset.vsub === t ? '#0096FF' : '');
if (t === 'settings') {
const el = document.getElementById('vpn-keep-days');
if (el && document.activeElement !== el) el.value = (_vpnData.settings || {}).keepDays || 30;
}
}
function _vpnBytes(n) {
n = +n || 0;
return n > 1e9 ? (n / 1e9).toFixed(1).replace('.', ',') + ' GB' : n > 1e6 ? (n / 1e6).toFixed(1).replace('.', ',') + ' MB' : Math.round(n / 1e3) + ' kB';
}
function _vpnDur(s) {
s = Math.max(0, Math.round(s));
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
return d ? d + ' T ' + h + ' h' : h ? h + ' h ' + m + ' min' : m ? m + ' min' : s + ' s';
}
function _vpnState(c) {
const st = c.status || {}, ct = c.container || {};
if (!c.enabled) return ['#8888AA', 'aus'];
if (!ct.exists) return ['#FF9500', 'Container fehlt'];
if (st.state === 'fehler') return ['#FF3B30', 'Fehler: ' + (st.error || '')];
if (ct.restarting || !ct.running) return ['#FF9500', 'startet neu …'];
if (st.state === 'verbunden') return ['#34C759', 'verbunden seit ' + _vpnDur(Date.now() / 1000 - st.since)];
return ['#FFD60A', 'wartet auf Gegenstelle'];
}
function renderVpn() {
const box = document.getElementById('vpn-client-list');
if (!box) return;
const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === 'client');
if (!list.length) { box.innerHTML = '<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) ──
// Pruefen/Aktualisieren/Zuruecksetzen macht auf der Box der llm-updater
// (einziger Dienst mit Docker-Zugriff). Ob es etwas Neues gibt, entscheidet