feat(vpn): WireGuard-Server — Kunden waehlen sich per UDP 443 ein, ein Verteiler fuer alle

Einstellungen → VPN → WireGuard → Server: Einwahl anlegen (Name,
Kundennetz, LAN-IP der FritzBox); ARIA erzeugt beide Schluesselpaare + PSK,
⬇ liefert die Datei fuer die Gegenseite (FritzBox-Netzwerk-Kopplung oder
Standard-WireGuard). Jede Einwahl = eigener VPN-Satellit-Container mit
fester IP im Netz aria-vpn (10.213.213.0/24, ausserhalb von Dockers Pools).

Neuer Verteiler (satellite/vpn_relay.py, aria-vpn-relay, Host-Netz,
VPN_RELAY_PORT 51820): ordnet jedes Paket per WireGuard-mac1 bzw.
Sitzungsnummer dem richtigen Container zu, ohne zu entschluesseln; Fremdes
wird still verworfen. Anleitung NPM-Stream / ohne Nginx / ufw im ℹ des
Server-Reiters und im satellite/README.

Lokal Ende-zu-Ende getestet: zwei Kunden mit demselben Netz 192.168.178.0/24
gleichzeitig ueber einen Port, jeder erreicht nur sein eigenes LAN; kein Weg
Kunde → ARIA, Kunde 1 → Einwahl 2, Container → Internet; fremder Schluessel
und Muell am Verteiler ohne Antwort.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 14:14:34 +01:00
co-authored by Claude Opus 5.5
parent 627b826226
commit 9b25187084
8 changed files with 459 additions and 24 deletions
+87 -11
View File
@@ -1387,14 +1387,16 @@
<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;">
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<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>
Hier wählen sich Kunden (z.B. per FritzBox) bei ARIA ein — alle über <b>UDP 443</b> auf dem RVS-Hostnamen.
ARIA erzeugt die Schlüssel; ⬇ liefert die Datei für die Gegenseite (FritzBox oder Standard-WireGuard).
Jede Verbindung wird ein VPN-Satellit; aus dem Kundennetz führt keine Verbindung zu ARIA.
</p>
<button class="btn secondary" onclick="vpnNpmGuide()" style="padding:4px 10px;font-size:11px;white-space:nowrap;">ℹ Einrichtung Proxy/Router</button>
<button class="btn" onclick="vpnEdit(null,'server')" style="padding:5px 12px;font-size:12px;white-space:nowrap;">+ Hinzufügen</button>
</div>
<div id="vpn-server-list" style="font-size:12px;color:#8888AA;">(lade …)</div>
</div>
</div>
@@ -10106,10 +10108,14 @@
return ['#FFD60A', 'wartet auf Gegenstelle'];
}
function renderVpn() {
const box = document.getElementById('vpn-client-list');
_renderVpnList('vpn-client-list', 'client', 'Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.');
_renderVpnList('vpn-server-list', 'server', 'Noch keine Einwahl. „+ Hinzufügen“, Kundennetz eintragen, dann die Datei für die FritzBox herunterladen.');
}
function _renderVpnList(boxId, kind, empty) {
const box = document.getElementById(boxId);
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; }
const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === kind);
if (!list.length) { box.innerHTML = '<span style="color:#6a6a88;">' + empty + '</span>'; return; }
box.innerHTML = list.map(c => {
const [col, txt] = _vpnState(c);
const st = c.status || {};
@@ -10119,11 +10125,11 @@
'<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>' +
(kind === 'server' && c.lan ? ' · Kundennetz: ' + escapeHtml(c.lan.join(', ')) : (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="' + (kind === 'server' ? 'Datei für die Gegenseite (FritzBox / Standard)' : 'WireGuard-Datei herunterladen') + '" onclick="' + (kind === 'server' ? 'vpnServerDownload' : '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>';
@@ -10172,6 +10178,7 @@
if (!r.ok) { uiAlert('Laden fehlgeschlagen\n\n' + (r.error || '')); return; }
c = r.connection;
}
if (c.kind === 'server') return _vpnServerForm(id, c);
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>' +
@@ -10197,6 +10204,71 @@
vpnCheckConf();
document.getElementById('vpn-f-name').focus();
}
function _vpnServerForm(id, c) {
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 ? 'Einwahl bearbeiten' : 'Einwahl für einen Kunden anlegen') + '</b>' +
'<p style="color:#8888AA;font-size:12px;margin:6px 0 0;">ARIA erzeugt die Schlüssel. Danach mit ⬇ die Datei für die Gegenseite holen und dort importieren ' +
'(FritzBox: Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln → Datei übernehmen).</p>' +
'<div style="margin-top:10px;">Name <span style="color:#8888AA;">(so heißt der VPN-Satellit)</span>' +
'<input id="vpn-s-name" type="text" maxlength="60" placeholder="z.B. Kunde Müller" style="' + inp + 'margin-top:3px;"></div>' +
'<div style="margin-top:10px;">Kundennetz <span style="color:#8888AA;">(LAN hinter der FritzBox, mehrere mit Komma)</span>' +
'<input id="vpn-s-lan" type="text" placeholder="192.168.178.0/24" oninput="vpnServerHint()" style="' + inp + 'margin-top:3px;"></div>' +
'<div style="margin-top:10px;">LAN-IP der FritzBox / des Routers <span style="color:#8888AA;">(leer = .1 im Kundennetz)</span>' +
'<input id="vpn-s-router" type="text" placeholder="192.168.178.1" style="' + inp + 'margin-top:3px;"></div>' +
'<div style="margin-top:10px;">Netze zum Scannen <span style="color:#8888AA;">(optional — leer = Kundennetz)</span>' +
'<input id="vpn-s-nets" type="text" style="' + inp + 'margin-top:3px;"></div>' +
'<div id="vpn-s-hint" style="font-size:11px;color:#8888AA;margin-top:6px;"></div>' +
'<div id="vpn-s-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-s-save" onclick="vpnServerSave(' + (id ? "'" + escapeHtml(id) + "'" : 'null') + ')" style="padding:6px 14px;font-size:12px;">Speichern</button>' +
'</div>');
document.getElementById('vpn-s-name').value = c.name || '';
document.getElementById('vpn-s-lan').value = (c.lan || []).join(', ');
document.getElementById('vpn-s-router').value = c.routerIp || '';
document.getElementById('vpn-s-nets').value = (c.scanNets && (c.lan || []).join(',') !== c.scanNets) ? c.scanNets : '';
vpnServerHint();
document.getElementById('vpn-s-name').focus();
}
function vpnServerHint() {
const el = document.getElementById('vpn-s-hint');
const lan = (document.getElementById('vpn-s-lan') || {}).value || '';
if (!el) return;
el.innerHTML = /^192\.168\.178\./.test(lan.trim())
? 'Hinweis: 192.168.178.0/24 ist das FritzBox-Standardnetz. Das ist hier kein Problem — jede Einwahl hat ihren eigenen Container.'
: '';
}
async function vpnServerSave(id) {
const name = document.getElementById('vpn-s-name').value.trim();
const lan = document.getElementById('vpn-s-lan').value.trim();
const routerIp = document.getElementById('vpn-s-router').value.trim();
const scanNets = document.getElementById('vpn-s-nets').value.trim();
const msg = document.getElementById('vpn-s-msg');
const btn = document.getElementById('vpn-s-save');
btn.disabled = true; msg.innerHTML = '<span style="color:#8888AA;">speichere …</span>';
const r = await vpnReq('vpn_save', { id, kind: 'server', name, lan, routerIp, scanNets });
btn.disabled = false;
if (!r.ok) { msg.innerHTML = '<span style="color:#FF6E6E;">✗ ' + escapeHtml(r.error || 'fehlgeschlagen') + '</span>'; return; }
vpnCloseModal();
if (!id) vpnServerDownload(r.id);
}
async function vpnServerDownload(id) {
const type = await _uiDialog({ message: 'Datei für die Gegenseite\n\n' +
'FritzBox: Netzwerk-Kopplung (Address = LAN-IP der Box). Standard: eigene Tunnel-Adresse — ' +
'Router/Linux muss das Kundennetz dann selbst weiterleiten.\n\nDie Datei enthält den privaten Schlüssel der Gegenseite — nur dort importieren.',
buttons: [{ label: 'Abbrechen', value: null, cancel: true },
{ label: 'Standard-WireGuard', value: 'standard' },
{ label: 'FritzBox', value: 'fritz', primary: true }] });
if (!type) return;
const r = await vpnReq('vpn_profile', { id, type });
if (!r.ok) { uiAlert('Download fehlgeschlagen\n\n' + (r.error || '')); return; }
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([r.profile.conf], { type: 'text/plain' }));
a.download = (r.profile.name || 'aria').replace(/[^\w.-]+/g, '_') + (type === 'fritz' ? '_fritzbox' : '') + '.conf';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
function vpnLoadFile(input) {
const f = input.files && input.files[0];
if (!f) return;
@@ -10283,11 +10355,15 @@
'<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, ' +
'<li>Hosts → <b>Streams</b> → Add Stream: Incoming Port <code>443</code>, Forward Host = ARIA-VM, Forward Port <code>51820</code> (Verteiler, <code>VPN_RELAY_PORT</code>), ' +
'<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>' +
'<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 <code>51820</code>.</p>' +
'<b>Mit ufw als Firewall</b><ul style="color:#AAB;margin-top:4px;">' +
'<li>Auf dem <b>NPM-Rechner</b> UDP 443 aus dem Internet erlauben: <pre style="background:#08080F;padding:6px 8px;border-radius:4px;">ufw allow 443/udp comment \'WireGuard -> ARIA\'</pre></li>' +
'<li>Auf der <b>ARIA-VM</b> den Verteiler-Port <b>nur vom NPM</b> erlauben (sonst niemand):<pre style="background:#08080F;padding:6px 8px;border-radius:4px;">ufw allow proto udp from &lt;IP-des-NPM&gt; to any port 51820 comment \'VPN-Verteiler nur vom NPM\'</pre>' +
'Ohne Nginx statt dessen: <code>ufw allow 51820/udp</code> (der Router leitet direkt hierher).</li></ul>' +
'<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>');
}