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:
+87
-11
@@ -1387,14 +1387,16 @@
|
|||||||
|
|
||||||
<div class="vpn-pane" data-vsub="server" style="display:none;">
|
<div class="vpn-pane" data-vsub="server" style="display:none;">
|
||||||
<div class="card" style="max-width:900px;">
|
<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;">
|
<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.
|
Hier wählen sich Kunden (z.B. per FritzBox) bei ARIA ein — alle ü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.
|
ARIA erzeugt die Schlüssel; ⬇ liefert die Datei für die Gegenseite (FritzBox oder Standard-WireGuard).
|
||||||
<br><span style="color:#FFD60A;">Kommt im nächsten Schritt.</span>
|
Jede Verbindung wird ein VPN-Satellit; aus dem Kundennetz führt keine Verbindung zu ARIA.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn secondary" onclick="vpnNpmGuide()" style="padding:4px 10px;font-size:11px;white-space:nowrap;">ℹ Einrichtung Proxy/Router</button>
|
<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>
|
||||||
|
<div id="vpn-server-list" style="font-size:12px;color:#8888AA;">(lade …)</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -10106,10 +10108,14 @@
|
|||||||
return ['#FFD60A', 'wartet auf Gegenstelle'];
|
return ['#FFD60A', 'wartet auf Gegenstelle'];
|
||||||
}
|
}
|
||||||
function renderVpn() {
|
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;
|
if (!box) return;
|
||||||
const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === 'client');
|
const list = (_vpnData.connections || []).filter(c => (c.kind || 'client') === kind);
|
||||||
if (!list.length) { box.innerHTML = '<span style="color:#6a6a88;">Noch keine Verbindung. „+ Hinzufügen“ und die WireGuard-Datei des Kunden einfügen.</span>'; return; }
|
if (!list.length) { box.innerHTML = '<span style="color:#6a6a88;">' + empty + '</span>'; return; }
|
||||||
box.innerHTML = list.map(c => {
|
box.innerHTML = list.map(c => {
|
||||||
const [col, txt] = _vpnState(c);
|
const [col, txt] = _vpnState(c);
|
||||||
const st = c.status || {};
|
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="flex:1;min-width:0;"><b style="color:#E0E0F0;">🔐 ' + escapeHtml(c.name) + '</b>' +
|
||||||
'<div style="color:#8888AA;font-size:11px;">' + escapeHtml(txt) +
|
'<div style="color:#8888AA;font-size:11px;">' + escapeHtml(txt) +
|
||||||
(c.enabled && st.rx != null ? ' · ↓ ' + _vpnBytes(st.rx) + ' ↑ ' + _vpnBytes(st.tx) : '') +
|
(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;">' +
|
'<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>' +
|
'<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="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="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>' +
|
'<button class="btn secondary" title="Löschen" onclick="vpnDelete(' + q + ')" style="padding:2px 7px;font-size:12px;">🗑</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
@@ -10172,6 +10178,7 @@
|
|||||||
if (!r.ok) { uiAlert('Laden fehlgeschlagen\n\n' + (r.error || '')); return; }
|
if (!r.ok) { uiAlert('Laden fehlgeschlagen\n\n' + (r.error || '')); return; }
|
||||||
c = r.connection;
|
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;';
|
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(
|
_vpnModal(
|
||||||
'<b style="font-size:15px;">' + (id ? 'Verbindung bearbeiten' : 'WireGuard-Verbindung hinzufügen') + '</b>' +
|
'<b style="font-size:15px;">' + (id ? 'Verbindung bearbeiten' : 'WireGuard-Verbindung hinzufügen') + '</b>' +
|
||||||
@@ -10197,6 +10204,71 @@
|
|||||||
vpnCheckConf();
|
vpnCheckConf();
|
||||||
document.getElementById('vpn-f-name').focus();
|
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) {
|
function vpnLoadFile(input) {
|
||||||
const f = input.files && input.files[0];
|
const f = input.files && input.files[0];
|
||||||
if (!f) return;
|
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. ' +
|
'<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>' +
|
'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;">' +
|
'<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>' +
|
'<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>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>' +
|
'<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 <IP-des-NPM> 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. ' +
|
'<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>');
|
'Die erzeugten Profile senden alle 25 s ein Lebenszeichen, damit nginx die Verbindung nicht vergisst.</p>');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1101,7 +1101,8 @@ const credsRevealWaiters = new Map();
|
|||||||
|
|
||||||
function vpnRvs() {
|
function vpnRvs() {
|
||||||
return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK,
|
return { host: RVS_HOST, port: RVS_PORT, tls: RVS_TLS, tlsFallback: RVS_TLS_FALLBACK,
|
||||||
token: RVS_TOKEN, sni: process.env.RVS_SNI || "" };
|
token: RVS_TOKEN, sni: process.env.RVS_SNI || "",
|
||||||
|
publicPort: process.env.VPN_PUBLIC_PORT || "443" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function broadcastVpn() {
|
async function broadcastVpn() {
|
||||||
@@ -1122,6 +1123,9 @@ async function handleVpn(ws, msg) {
|
|||||||
case "vpn_get":
|
case "vpn_get":
|
||||||
reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
|
reply({ ok: true, connection: vpn.getConf(String(msg.id || "")) });
|
||||||
return;
|
return;
|
||||||
|
case "vpn_profile":
|
||||||
|
reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) });
|
||||||
|
return;
|
||||||
case "vpn_info":
|
case "vpn_info":
|
||||||
reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
|
reply({ ok: true, info: await vpn.info(String(msg.id || "")) });
|
||||||
return;
|
return;
|
||||||
|
|||||||
+128
-3
@@ -14,6 +14,11 @@
|
|||||||
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
|
// /data/vpn/<id>/tunnel/events.jsonl verbunden/getrennt/… (Log, rotiert hier)
|
||||||
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
|
// /data/vpn/<id>/data/ Satelliten-Daten (Zugangsdaten, Namen, …)
|
||||||
//
|
//
|
||||||
|
// Server-Verbindungen (Kunde waehlt sich ein, z.B. FritzBox): ARIA erzeugt
|
||||||
|
// beide Schluesselpaare + PSK, der Container lauscht auf 51820 mit fester IP
|
||||||
|
// im Netz aria-vpn; der Verteiler (satellite/vpn_relay.py, UDP-Port fuer
|
||||||
|
// alle) ordnet die Pakete per mac1 zu und liest dafuer /data/vpn/relay.json.
|
||||||
|
//
|
||||||
// An = Container mit restart unless-stopped (nach Neustart wieder da).
|
// An = Container mit restart unless-stopped (nach Neustart wieder da).
|
||||||
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
|
// Aus = Container weg, Dateien bleiben — Kundennetze nur bei Bedarf.
|
||||||
|
|
||||||
@@ -27,6 +32,15 @@ const IMAGE = process.env.VPN_IMAGE || "aria-vpn-satellite:latest";
|
|||||||
const NETWORK = "aria-vpn";
|
const NETWORK = "aria-vpn";
|
||||||
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
|
const SELF = process.env.DIAG_CONTAINER || "aria-diagnostic";
|
||||||
const PREFIX = "aria-vpn-";
|
const PREFIX = "aria-vpn-";
|
||||||
|
// Netz der VPN-Container: dynamische IPs (Clients) aus der unteren Haelfte,
|
||||||
|
// Server-Verbindungen bekommen feste IPs aus der oberen (.128 + n), damit der
|
||||||
|
// Verteiler sie auch nach einem Neustart findet.
|
||||||
|
// Bewusst ausserhalb von Dockers eigenen Pools (172.17–31.x, 192.168.x),
|
||||||
|
// sonst kollidiert es mit automatisch angelegten Compose-Netzen.
|
||||||
|
const SUBNET = process.env.VPN_SUBNET || "10.213.213.0/24";
|
||||||
|
const SUBNET_BASE = SUBNET.split("/")[0].split(".").slice(0, 3).join(".");
|
||||||
|
const SERVER_PORT = 51820; // WireGuard im Server-Container
|
||||||
|
const TUNNEL_NET = (n) => `10.222.${n}`; // je Server-Verbindung ein /24
|
||||||
|
|
||||||
function docker(method, apiPath, body) {
|
function docker(method, apiPath, body) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -121,6 +135,7 @@ async function ensureNetwork() {
|
|||||||
const c = await docker("POST", "/networks/create", {
|
const c = await docker("POST", "/networks/create", {
|
||||||
Name: NETWORK, Driver: "bridge",
|
Name: NETWORK, Driver: "bridge",
|
||||||
Options: { "com.docker.network.bridge.enable_icc": "false" },
|
Options: { "com.docker.network.bridge.enable_icc": "false" },
|
||||||
|
IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] },
|
||||||
Labels: { "aria.vpn": "1" },
|
Labels: { "aria.vpn": "1" },
|
||||||
});
|
});
|
||||||
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
|
if (c.status >= 300) throw new Error(`Netz ${NETWORK} anlegen: ${JSON.stringify(c.json).slice(0, 200)}`);
|
||||||
@@ -158,6 +173,8 @@ async function startContainer(conn, rvs) {
|
|||||||
NetworkMode: NETWORK,
|
NetworkMode: NETWORK,
|
||||||
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
|
LogConfig: { Type: "json-file", Config: { "max-size": "5m", "max-file": "2" } },
|
||||||
},
|
},
|
||||||
|
...(conn.kind === "server" ? { NetworkingConfig: { EndpointsConfig: {
|
||||||
|
[NETWORK]: { IPAMConfig: { IPv4Address: serverIp(conn) } } } } } : {}),
|
||||||
});
|
});
|
||||||
if (c.status >= 300) throw new Error(`Container anlegen: ${JSON.stringify(c.json).slice(0, 300)}`);
|
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`);
|
const s = await docker("POST", `/containers/${PREFIX}${conn.id}/start`);
|
||||||
@@ -185,6 +202,105 @@ async function containerLogs(id, lines = 60) {
|
|||||||
return out.join("");
|
return out.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Server-Verbindungen ─────────────────────────────────────────
|
||||||
|
function wgKeypair() {
|
||||||
|
const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
|
||||||
|
return {
|
||||||
|
priv: privateKey.export({ format: "der", type: "pkcs8" }).slice(-32).toString("base64"),
|
||||||
|
pub: publicKey.export({ format: "der", type: "spki" }).slice(-32).toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const serverIp = (conn) => `${SUBNET_BASE}.${128 + conn.n}`;
|
||||||
|
const keysFile = (id) => path.join(tunnelDir(id), "keys.json");
|
||||||
|
|
||||||
|
function parseLan(v) {
|
||||||
|
const nets = String(v || "").split(/[\s,]+/).filter(Boolean);
|
||||||
|
if (!nets.length) throw new Error("Kundennetz fehlt (z.B. 192.168.178.0/24).");
|
||||||
|
for (const n of nets) {
|
||||||
|
const m = n.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/);
|
||||||
|
if (!m || m.slice(1, 5).some((x) => +x > 255) || +m[5] < 8 || +m[5] > 32) {
|
||||||
|
throw new Error(`Kundennetz „${n}" ist kein Netz wie 192.168.178.0/24.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nets;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-seitige wg.conf (im Container): lauscht, Gegenseite = Kunde
|
||||||
|
function serverConf(conn, k) {
|
||||||
|
const t = TUNNEL_NET(conn.n);
|
||||||
|
return ["[Interface]", `PrivateKey = ${k.srvPriv}`, `ListenPort = ${SERVER_PORT}`, `Address = ${t}.1/24`, "",
|
||||||
|
"[Peer]", `PublicKey = ${k.peerPub}`, `PresharedKey = ${k.psk}`,
|
||||||
|
`AllowedIPs = ${[`${t}.2/32`, ...conn.lan].join(", ")}`, ""].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei fuer die Gegenseite. FritzBox (Netzwerk-Kopplung): Address = eigene
|
||||||
|
// LAN-Adresse der Box, AllowedIPs = unser Netz (kein 0.0.0.0/0 — lehnt die
|
||||||
|
// Box ab). Standard: eigene Tunnel-Adresse, die Gegenseite muss ihr LAN dann
|
||||||
|
// selbst routen/maskieren.
|
||||||
|
function peerProfile(conn, k, type, rvs) {
|
||||||
|
const t = TUNNEL_NET(conn.n);
|
||||||
|
const lan0 = conn.lan[0];
|
||||||
|
const prefix = lan0.split("/")[1];
|
||||||
|
const addr = type === "fritz" ? `${conn.routerIp}/${prefix}` : `${t}.2/24`;
|
||||||
|
return [`# ARIA VPN-Satellit „${conn.name}" — ${type === "fritz" ? "für FRITZ!Box (Netzwerk-Kopplung)" : "Standard-WireGuard"}`,
|
||||||
|
"[Interface]", `PrivateKey = ${k.peerPriv}`, `Address = ${addr}`, "",
|
||||||
|
"[Peer]", `PublicKey = ${k.srvPub}`, `PresharedKey = ${k.psk}`,
|
||||||
|
`AllowedIPs = ${t}.0/24`, `Endpoint = ${rvs.host}:${rvs.publicPort || 443}`,
|
||||||
|
"PersistentKeepalive = 25", ""].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRelayMap() {
|
||||||
|
const rows = [];
|
||||||
|
for (const c of loadConns()) {
|
||||||
|
if (c.kind !== "server" || !c.enabled) continue;
|
||||||
|
const k = readJson(keysFile(c.id), null);
|
||||||
|
if (k) rows.push({ id: c.id, ip: serverIp(c), port: SERVER_PORT, pub: k.srvPub });
|
||||||
|
}
|
||||||
|
writeJson(path.join(DIR, "relay.json"), rows, 0o644);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer(msg, rvs) {
|
||||||
|
const name = String(msg.name || "").trim().slice(0, 60);
|
||||||
|
if (!name) throw new Error("Name fehlt.");
|
||||||
|
const lan = parseLan(msg.lan);
|
||||||
|
const routerIp = String(msg.routerIp || "").trim() ||
|
||||||
|
lan[0].split("/")[0].replace(/\.\d+$/, ".1");
|
||||||
|
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(routerIp)) throw new Error("Router-IP ist keine IP-Adresse.");
|
||||||
|
const conns = loadConns();
|
||||||
|
let conn = msg.id ? conns.find((c) => c.id === msg.id) : null;
|
||||||
|
if (!conn) {
|
||||||
|
const used = new Set(conns.filter((c) => c.kind === "server").map((c) => c.n));
|
||||||
|
let n = 1;
|
||||||
|
while (used.has(n)) n++;
|
||||||
|
if (n > 126) throw new Error("Höchstens 126 Server-Verbindungen.");
|
||||||
|
conn = { id: crypto.randomBytes(4).toString("hex"), kind: "server", n, enabled: false, createdAt: Date.now() };
|
||||||
|
conns.push(conn);
|
||||||
|
}
|
||||||
|
const scanNets = String(msg.scanNets || "").split(/[\s,]+/).filter(Boolean).join(",") || lan.join(",");
|
||||||
|
Object.assign(conn, { name, lan, routerIp, scanNets, updatedAt: Date.now() });
|
||||||
|
fs.mkdirSync(tunnelDir(conn.id), { recursive: true });
|
||||||
|
let k = readJson(keysFile(conn.id), null);
|
||||||
|
if (!k) {
|
||||||
|
const s = wgKeypair(); const p = wgKeypair();
|
||||||
|
k = { srvPriv: s.priv, srvPub: s.pub, peerPriv: p.priv, peerPub: p.pub,
|
||||||
|
psk: crypto.randomBytes(32).toString("base64") };
|
||||||
|
writeJson(keysFile(conn.id), k);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join(tunnelDir(conn.id), "wg.conf"), serverConf(conn, k), { mode: 0o600 });
|
||||||
|
saveConns(conns);
|
||||||
|
if (conn.enabled) await startContainer(conn, rvs);
|
||||||
|
writeRelayMap();
|
||||||
|
return { id: conn.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
function profile(id, type, rvs) {
|
||||||
|
const conn = loadConns().find((c) => c.id === id);
|
||||||
|
if (!conn || conn.kind !== "server") throw new Error("Server-Verbindung unbekannt.");
|
||||||
|
const k = readJson(keysFile(id), null);
|
||||||
|
if (!k) throw new Error("Schlüssel fehlen.");
|
||||||
|
return { name: conn.name, type, conf: peerProfile(conn, k, type, rvs) };
|
||||||
|
}
|
||||||
|
|
||||||
// ── API (vom ws-Handler in server.js) ───────────────────────────
|
// ── API (vom ws-Handler in server.js) ───────────────────────────
|
||||||
async function list() {
|
async function list() {
|
||||||
const conns = loadConns();
|
const conns = loadConns();
|
||||||
@@ -197,6 +313,9 @@ async function list() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function save(msg, rvs) {
|
async function save(msg, rvs) {
|
||||||
|
if ((msg.kind || "") === "server" || (msg.id && (loadConns().find((c) => c.id === msg.id) || {}).kind === "server")) {
|
||||||
|
return saveServer(msg, rvs);
|
||||||
|
}
|
||||||
const name = String(msg.name || "").trim().slice(0, 60);
|
const name = String(msg.name || "").trim().slice(0, 60);
|
||||||
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
|
const conf = String(msg.conf || "").replace(/\r\n/g, "\n").trim() + "\n";
|
||||||
if (!name) throw new Error("Name fehlt.");
|
if (!name) throw new Error("Name fehlt.");
|
||||||
@@ -224,8 +343,12 @@ async function toggle(id, enabled, rvs) {
|
|||||||
if (!conn) throw new Error("Verbindung unbekannt.");
|
if (!conn) throw new Error("Verbindung unbekannt.");
|
||||||
conn.enabled = !!enabled;
|
conn.enabled = !!enabled;
|
||||||
saveConns(conns);
|
saveConns(conns);
|
||||||
if (conn.enabled) await startContainer(conn, rvs);
|
try {
|
||||||
else await removeContainer(id);
|
if (conn.enabled) await startContainer(conn, rvs);
|
||||||
|
else await removeContainer(id);
|
||||||
|
} finally {
|
||||||
|
writeRelayMap();
|
||||||
|
}
|
||||||
return { id, enabled: conn.enabled };
|
return { id, enabled: conn.enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,12 +358,14 @@ async function remove(id) {
|
|||||||
await removeContainer(id);
|
await removeContainer(id);
|
||||||
saveConns(conns.filter((c) => c.id !== id));
|
saveConns(conns.filter((c) => c.id !== id));
|
||||||
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
|
fs.rmSync(path.join(DIR, id), { recursive: true, force: true });
|
||||||
|
writeRelayMap();
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConf(id) {
|
function getConf(id) {
|
||||||
const conn = loadConns().find((c) => c.id === id);
|
const conn = loadConns().find((c) => c.id === id);
|
||||||
if (!conn) throw new Error("Verbindung unbekannt.");
|
if (!conn) throw new Error("Verbindung unbekannt.");
|
||||||
|
if (conn.kind === "server") return { ...conn }; // Schluessel bleiben auf ARIA
|
||||||
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
|
return { ...conn, conf: fs.readFileSync(path.join(tunnelDir(id), "wg.conf"), "utf8") };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,4 +404,4 @@ function rotate() {
|
|||||||
}
|
}
|
||||||
setInterval(rotate, 3600 * 1000).unref();
|
setInterval(rotate, 3600 * 1000).unref();
|
||||||
|
|
||||||
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf };
|
module.exports = { list, save, toggle, remove, getConf, info, setSettings, checkConf, profile, writeRelayMap };
|
||||||
|
|||||||
@@ -180,6 +180,26 @@ services:
|
|||||||
network_mode: none
|
network_mode: none
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|
||||||
|
# ─── VPN-Verteiler: EIN UDP-Port fuer alle WireGuard-Server-Verbindungen ─
|
||||||
|
# Kunden waehlen sich auf UDP 443 am RVS-Hostnamen ein; der NPM-Stream (oder
|
||||||
|
# der Router) leitet das hierher (VPN_RELAY_PORT, Standard 51820). Der
|
||||||
|
# Verteiler ordnet jedes Paket per WireGuard-mac1 der richtigen Verbindung
|
||||||
|
# (eigener Container) zu, ohne etwas zu entschluesseln; Unbekanntes wird
|
||||||
|
# still verworfen. Host-Netz, weil er die Container-IPs im Netz aria-vpn
|
||||||
|
# erreichen muss (dort ist Container-zu-Container gesperrt).
|
||||||
|
vpn-relay:
|
||||||
|
image: aria-vpn-satellite:latest
|
||||||
|
container_name: aria-vpn-relay
|
||||||
|
depends_on:
|
||||||
|
- vpn-satellite-image
|
||||||
|
network_mode: host
|
||||||
|
command: ["python", "-u", "vpn_relay.py"]
|
||||||
|
environment:
|
||||||
|
- VPN_RELAY_PORT=${VPN_RELAY_PORT:-51820}
|
||||||
|
volumes:
|
||||||
|
- ./aria-data/config/diag-state/vpn:/vpn:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
aria-net:
|
aria-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ RUN apt-get update \
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY satellite.py vpn_entry.py ./
|
COPY satellite.py vpn_entry.py vpn_relay.py ./
|
||||||
|
|
||||||
CMD ["python", "-u", "vpn_entry.py"]
|
CMD ["python", "-u", "vpn_entry.py"]
|
||||||
|
|||||||
+37
-6
@@ -154,9 +154,40 @@ oder einfügen, Namen vergeben, einschalten.
|
|||||||
- Image bauen: `docker compose up -d --build vpn-satellite-image` (baut nur das Image,
|
- Image bauen: `docker compose up -d --build vpn-satellite-image` (baut nur das Image,
|
||||||
der Container beendet sich sofort wieder — das ist richtig so).
|
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.
|
**Server** (Kunden wählen sich bei ARIA ein, z.B. per FritzBox): im Reiter Server
|
||||||
Erreichbar über **UDP 443** auf dem RVS-Hostnamen (TCP 443 bleibt beim RVS):
|
„+ Hinzufügen“, Namen, Kundennetz (z.B. `192.168.178.0/24`) und LAN-IP der FritzBox eintragen.
|
||||||
- *Mit Nginx Proxy Manager:* Hosts → Streams → Incoming Port 443, Forward Host = ARIA-VM,
|
ARIA erzeugt beide Schlüsselpaare und einen Pre-Shared-Key; ⬇ liefert die Datei für die
|
||||||
**nur UDP** (TCP würde mit den Proxy-Hosts kollidieren); im NPM-Compose `'443:443/udp'`
|
Gegenseite:
|
||||||
ergänzen; am Router UDP 443 ebenfalls auf den NPM-Rechner.
|
- **FritzBox** (Netzwerk-Kopplung): `Address` = LAN-IP der Box, `AllowedIPs` = ARIAs
|
||||||
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** direkt auf die ARIA-VM weiterleiten.
|
Tunnelnetz `10.222.<n>.0/24` (kein `0.0.0.0/0` — das lehnt die FritzBox ab). Import:
|
||||||
|
Internet → Freigaben → VPN (WireGuard) → Verbindung hinzufügen → Netzwerke koppeln →
|
||||||
|
Einstellungen aus Datei übernehmen. *Noch nicht an einer echten FritzBox getestet.*
|
||||||
|
- **Standard-WireGuard** (Linux, andere Router): eigene Tunnel-Adresse `10.222.<n>.2`; das
|
||||||
|
Kundennetz muss die Gegenseite dann selbst weiterleiten bzw. maskieren.
|
||||||
|
|
||||||
|
Alle Einwahlen kommen über **einen** Port: Der **Verteiler** (`vpn_relay.py`, Container
|
||||||
|
`aria-vpn-relay`, Host-Netz, `VPN_RELAY_PORT`, Standard 51820) ordnet jedes Paket anhand der
|
||||||
|
WireGuard-Prüfsumme `mac1` der richtigen Verbindung zu — jede Einwahl hat ihr eigenes
|
||||||
|
Server-Schlüsselpaar und ihren eigenen Container mit fester IP im Netz `aria-vpn`
|
||||||
|
(`10.213.213.0/24`, änderbar mit `VPN_SUBNET`). Entschlüsselt wird dabei nichts; fremde
|
||||||
|
Schlüssel und alles andere verwirft er still. Auch zwei Kunden mit demselben Netz
|
||||||
|
(`192.168.178.0/24`) funktionieren gleichzeitig.
|
||||||
|
|
||||||
|
**Erreichbar machen — UDP 443 auf dem RVS-Hostnamen** (TCP 443 bleibt beim RVS):
|
||||||
|
- *Mit Nginx Proxy Manager:* Hosts → **Streams** → Add Stream: Incoming Port `443`,
|
||||||
|
Forward Host = ARIA-VM, Forward Port `51820`, **TCP Forwarding aus, UDP Forwarding an**
|
||||||
|
(TCP würde mit den Proxy-Hosts kollidieren). Im docker-compose des NPM `- '443:443/udp'`
|
||||||
|
ergänzen und neu starten. Am Router UDP 443 ebenfalls auf den NPM-Rechner leiten.
|
||||||
|
Hinweis: ARIA sieht dann als Absender den NPM statt der Kunden-IP; die erzeugten Dateien
|
||||||
|
senden alle 25 s ein Lebenszeichen, damit nginx die Zuordnung behält.
|
||||||
|
- *Ohne Nginx:* den RVS-Port am Router zusätzlich als **UDP** weiterleiten — direkt auf die
|
||||||
|
ARIA-VM, Port `51820`.
|
||||||
|
- *Mit ufw als Firewall:*
|
||||||
|
```bash
|
||||||
|
# auf dem NPM-Rechner: UDP 443 aus dem Internet erlauben
|
||||||
|
ufw allow 443/udp comment 'WireGuard -> ARIA'
|
||||||
|
# auf der ARIA-VM: Verteiler-Port NUR vom NPM erlauben
|
||||||
|
ufw allow proto udp from <IP-des-NPM> to any port 51820 comment 'VPN-Verteiler nur vom NPM'
|
||||||
|
# ohne Nginx statt dessen (der Router leitet direkt auf die VM):
|
||||||
|
ufw allow 51820/udp
|
||||||
|
```
|
||||||
|
|||||||
@@ -149,9 +149,13 @@ def default_gw() -> str:
|
|||||||
return m.group(1)
|
return m.group(1)
|
||||||
|
|
||||||
|
|
||||||
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str]) -> None:
|
def firewall(rvs_ips: list[str], rvs_port: int, ep_ips: list[str], listen: int = 0) -> None:
|
||||||
rvs = ", ".join(rvs_ips) if rvs_ips else "127.0.0.2"
|
rvs = ", ".join(rvs_ips) if rvs_ips else "127.0.0.2"
|
||||||
eps = ", ".join(ep_ips) if ep_ips else "127.0.0.2"
|
eps = ", ".join(ep_ips) if ep_ips else "127.0.0.2"
|
||||||
|
# Server-Verbindung: der Verteiler (vpn_relay.py) reicht die Pakete der
|
||||||
|
# Gegenseite an diesen Port weiter — nur WireGuard, nur ueber eth0.
|
||||||
|
lin = f'iifname "eth0" udp dport {listen} accept' if listen else ""
|
||||||
|
lout = f'oifname "eth0" udp sport {listen} accept' if listen else ""
|
||||||
# Nur die eigene Tabelle — "flush ruleset" wuerde Dockers NAT-Regeln fuer
|
# Nur die eigene Tabelle — "flush ruleset" wuerde Dockers NAT-Regeln fuer
|
||||||
# den eingebauten DNS (127.0.0.11) im Container mit loeschen.
|
# den eingebauten DNS (127.0.0.11) im Container mit loeschen.
|
||||||
sh("nft", "delete", "table", "inet", "vpnsat", check=False)
|
sh("nft", "delete", "table", "inet", "vpnsat", check=False)
|
||||||
@@ -163,6 +167,7 @@ table inet vpnsat {{
|
|||||||
iif lo accept
|
iif lo accept
|
||||||
ct state established,related accept
|
ct state established,related accept
|
||||||
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
|
iifname "eth0" ip saddr @endpoints meta l4proto udp accept
|
||||||
|
{lin}
|
||||||
}}
|
}}
|
||||||
chain forward {{
|
chain forward {{
|
||||||
type filter hook forward priority 0; policy drop;
|
type filter hook forward priority 0; policy drop;
|
||||||
@@ -174,6 +179,7 @@ table inet vpnsat {{
|
|||||||
oifname "{IFACE}" accept
|
oifname "{IFACE}" accept
|
||||||
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
|
oifname "eth0" ip daddr {{ {rvs} }} tcp dport {rvs_port} accept
|
||||||
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
|
oifname "eth0" ip daddr @endpoints meta l4proto udp accept
|
||||||
|
{lout}
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
@@ -221,7 +227,7 @@ def setup(c: dict) -> dict:
|
|||||||
else:
|
else:
|
||||||
sh("ip", "route", "replace", str(net), "dev", IFACE)
|
sh("ip", "route", "replace", str(net), "dev", IFACE)
|
||||||
nets.append(str(net))
|
nets.append(str(net))
|
||||||
firewall(rvs_ips, rvs_port, ep_ips)
|
firewall(rvs_ips, rvs_port, ep_ips, int(c["iface"].get("listenport") or 0))
|
||||||
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
|
log.info("Tunnel %s oben: Netze %s, Endpunkt(e) %s", IFACE, ", ".join(nets) or "-", ", ".join(ep_ips) or "-")
|
||||||
return {"nets": nets, "endpoints": ep_ips}
|
return {"nets": nets, "endpoints": ep_ips}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
ARIA VPN-Verteiler — EIN UDP-Port fuer alle WireGuard-Server-Verbindungen.
|
||||||
|
|
||||||
|
Kunden waehlen sich alle auf denselben Port ein (UDP 443 am RVS-Hostnamen,
|
||||||
|
per NPM-Stream bzw. Router hierher weitergeleitet). Jede Server-Verbindung
|
||||||
|
laeuft aber in ihrem EIGENEN Container (eigener Netz-Namensraum, wegen
|
||||||
|
ueberlappender Kundennetze). Der Verteiler ordnet die Pakete zu, ohne etwas
|
||||||
|
zu entschluesseln:
|
||||||
|
|
||||||
|
Typ 1 (Handshake-Anfang): mac1 = BLAKE2s(Schluessel aus dem oeffentlichen
|
||||||
|
Schluessel des EMPFAENGERS, Nachricht). Jede Verbindung hat ein eigenes
|
||||||
|
Server-Schluesselpaar → der Verteiler rechnet mac1 fuer jede bekannte
|
||||||
|
Verbindung nach und weiss, wohin das Paket gehoert.
|
||||||
|
Typ 2 (Antwort des Containers): merkt sich dessen Sitzungsnummer.
|
||||||
|
Typ 3/4 (Cookie/Daten): nach Absender-Adresse, sonst nach Sitzungsnummer
|
||||||
|
(Kunde hat die Adresse gewechselt).
|
||||||
|
|
||||||
|
Jeder Absender bekommt einen eigenen Upstream-Socket zum Container, so finden
|
||||||
|
die Antworten zurueck. Unbekanntes wird still verworfen — wie WireGuard selbst
|
||||||
|
(ein Portscan sieht nichts).
|
||||||
|
|
||||||
|
Laeuft mit network_mode: host (erreicht die Container-IPs im Netz aria-vpn,
|
||||||
|
dort ist Container-zu-Container gesperrt). Liest /vpn/relay.json, das die
|
||||||
|
Diagnostic schreibt: [{id, ip, port, pub}].
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [vpn-relay] %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger("vpn-relay")
|
||||||
|
|
||||||
|
LISTEN_PORT = int(os.environ.get("VPN_RELAY_PORT", "51820") or 51820)
|
||||||
|
MAP_FILE = os.environ.get("VPN_RELAY_MAP", "/vpn/relay.json")
|
||||||
|
IDLE_S = 600
|
||||||
|
MAX_CLIENTS = 2048
|
||||||
|
|
||||||
|
_targets: list[dict] = [] # [{id, addr:(ip,port), mac1key}]
|
||||||
|
_map_mtime = 0.0
|
||||||
|
_by_index: dict[int, str] = {} # Sitzungsnummer des Containers -> id
|
||||||
|
_clients: dict = {} # Kunden-Adresse -> Upstream
|
||||||
|
|
||||||
|
|
||||||
|
def _mac1_key(pub_b64: str) -> bytes:
|
||||||
|
pub = base64.b64decode(pub_b64)
|
||||||
|
return hashlib.blake2s(b"mac1----" + pub).digest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_map() -> None:
|
||||||
|
global _targets, _map_mtime
|
||||||
|
try:
|
||||||
|
m = os.path.getmtime(MAP_FILE)
|
||||||
|
except OSError:
|
||||||
|
if _targets:
|
||||||
|
log.info("keine Zuordnung mehr (%s fehlt)", MAP_FILE)
|
||||||
|
_targets, _map_mtime = [], 0.0
|
||||||
|
return
|
||||||
|
if m == _map_mtime:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
rows = json.load(open(MAP_FILE))
|
||||||
|
_targets = [{"id": r["id"], "addr": (r["ip"], int(r.get("port", 51820))),
|
||||||
|
"mac1key": _mac1_key(r["pub"])} for r in rows if r.get("ip") and r.get("pub")]
|
||||||
|
_map_mtime = m
|
||||||
|
log.info("%d Server-Verbindung(en): %s", len(_targets),
|
||||||
|
", ".join(f"{t['id']}→{t['addr'][0]}" for t in _targets) or "-")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("relay.json unlesbar: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
def target_for_init(data: bytes) -> dict | None:
|
||||||
|
if len(data) != 148:
|
||||||
|
return None
|
||||||
|
for t in _targets:
|
||||||
|
mac = hashlib.blake2s(data[:116], key=t["mac1key"], digest_size=16).digest()
|
||||||
|
if mac == data[116:132]:
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class Upstream(asyncio.DatagramProtocol):
|
||||||
|
"""Ein Socket je Kunde → Container; leitet Antworten zurueck."""
|
||||||
|
|
||||||
|
def __init__(self, front, client_addr, target):
|
||||||
|
self.front, self.client, self.target = front, client_addr, target
|
||||||
|
self.transport = None
|
||||||
|
self.last = time.time()
|
||||||
|
|
||||||
|
def connection_made(self, transport):
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
self.last = time.time()
|
||||||
|
if len(data) >= 8 and data[0] == 2: # Antwort: Sitzungsnummer merken
|
||||||
|
_by_index[struct.unpack_from("<I", data, 4)[0]] = self.target["id"]
|
||||||
|
self.front.sendto(data, self.client)
|
||||||
|
|
||||||
|
def error_received(self, exc):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Front(asyncio.DatagramProtocol):
|
||||||
|
def connection_made(self, transport):
|
||||||
|
self.transport = transport
|
||||||
|
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
asyncio.ensure_future(self.handle(data, addr))
|
||||||
|
|
||||||
|
async def handle(self, data: bytes, addr) -> None:
|
||||||
|
if len(data) < 4:
|
||||||
|
return
|
||||||
|
load_map()
|
||||||
|
kind = data[0]
|
||||||
|
up = _clients.get(addr)
|
||||||
|
target = None
|
||||||
|
if kind == 1:
|
||||||
|
target = target_for_init(data)
|
||||||
|
if not target:
|
||||||
|
return # fremder Schluessel → still verwerfen
|
||||||
|
elif up is None and kind == 4 and len(data) >= 8:
|
||||||
|
tid = _by_index.get(struct.unpack_from("<I", data, 4)[0])
|
||||||
|
target = next((t for t in _targets if t["id"] == tid), None)
|
||||||
|
if not target:
|
||||||
|
return
|
||||||
|
elif up is None:
|
||||||
|
return
|
||||||
|
if up and target and up.target["id"] != target["id"]:
|
||||||
|
up.transport.close()
|
||||||
|
up = None
|
||||||
|
if up is None or up.target["addr"] != (target or up.target)["addr"]:
|
||||||
|
if up:
|
||||||
|
up.transport.close()
|
||||||
|
if len(_clients) >= MAX_CLIENTS:
|
||||||
|
return
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
t = target or up.target
|
||||||
|
_, up = await loop.create_datagram_endpoint(
|
||||||
|
lambda: Upstream(self.transport, addr, t), remote_addr=t["addr"])
|
||||||
|
_clients[addr] = up
|
||||||
|
up.last = time.time()
|
||||||
|
up.transport.sendto(data)
|
||||||
|
|
||||||
|
|
||||||
|
async def janitor() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
now = time.time()
|
||||||
|
for a, up in list(_clients.items()):
|
||||||
|
if now - up.last > IDLE_S:
|
||||||
|
up.transport.close()
|
||||||
|
del _clients[a]
|
||||||
|
if len(_by_index) > 8192:
|
||||||
|
_by_index.clear()
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
load_map()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
await loop.create_datagram_endpoint(Front, local_addr=("0.0.0.0", LISTEN_PORT))
|
||||||
|
log.info("lausche auf UDP %d", LISTEN_PORT)
|
||||||
|
await janitor()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in New Issue
Block a user