Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b25187084 | ||
|
|
627b826226 | ||
|
|
ca35f5f1e0 | ||
|
|
68686de9dc |
@@ -3745,6 +3745,8 @@ class ARIABridge:
|
||||
"location": payload.get("location") or sid,
|
||||
"caps": payload.get("caps") or [],
|
||||
"control": bool(payload.get("control")),
|
||||
# "vpn" = VPN-Satellit (Kundennetz hinter WireGuard, laeuft auf ARIA)
|
||||
"kind": payload.get("kind") or "lan",
|
||||
"last_seen": time.time(),
|
||||
}
|
||||
cur = self._satellites[sid]
|
||||
@@ -5229,6 +5231,7 @@ class ARIABridge:
|
||||
"id": s["id"], "location": alias or own,
|
||||
"agentName": own, "alias": alias,
|
||||
"caps": s.get("caps") or [], "control": bool(s.get("control")),
|
||||
"kind": s.get("kind") or "lan",
|
||||
"online": (now - s.get("last_seen", 0)) < 300,
|
||||
})
|
||||
return out
|
||||
|
||||
+565
-5
@@ -688,6 +688,7 @@
|
||||
<div class="settings-subnav">
|
||||
<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="vpn" onclick="switchSettingsTab('vpn')">VPN</button>
|
||||
<button class="settings-subnav-btn" data-sub="system" onclick="switchSettingsTab('system')">System & Wartung</button>
|
||||
</div>
|
||||
|
||||
@@ -952,6 +953,7 @@
|
||||
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
|
||||
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
|
||||
<button class="btn secondary" onclick="openModelBoards()" title="Ranglisten der besten Modelle + was gerade auf HuggingFace im Trend ist (direkt ladbar)" style="padding:4px 10px;font-size:11px;">🏆 Bestenlisten</button>
|
||||
<button class="btn secondary" onclick="openLlmRuntime()" title="Installierte llama.cpp-/llama-swap-Version der LLM-Boxen, nach Updates suchen, aktualisieren" style="padding:4px 10px;font-size:11px;">⚙ llama.cpp</button>
|
||||
</div>
|
||||
<div class="card" style="max-width:1100px;">
|
||||
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
|
||||
@@ -1358,6 +1360,59 @@
|
||||
</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;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 — 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>
|
||||
|
||||
<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 -->
|
||||
@@ -1894,6 +1949,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>
|
||||
@@ -2691,6 +2761,10 @@
|
||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); 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_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) {
|
||||
renderAgentHistory(msg.payload.events || []);
|
||||
@@ -5319,6 +5393,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'));
|
||||
@@ -6240,12 +6315,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';
|
||||
@@ -6316,7 +6401,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>' +
|
||||
@@ -6324,12 +6409,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 ───────────────────────────────
|
||||
@@ -9972,6 +10058,461 @@
|
||||
function closeLlmDownloads() {
|
||||
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() {
|
||||
_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') === 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 || {};
|
||||
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) : '') +
|
||||
(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="' + (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>';
|
||||
}).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;
|
||||
}
|
||||
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>' +
|
||||
'<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 _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;
|
||||
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 <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 <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. ' +
|
||||
'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
|
||||
// die Pruefsumme des Image-Tags in der Registry, nicht die Versionsnummer.
|
||||
const _llmRt = {}; // instanceId -> {status, checked, busy, log, result, error}
|
||||
function openLlmRuntime() {
|
||||
let bg = document.getElementById('llmrt-modal');
|
||||
if (!bg) {
|
||||
bg = document.createElement('div');
|
||||
bg.id = 'llmrt-modal';
|
||||
bg.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;';
|
||||
bg.innerHTML = '<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;color:#E0E0F0;font-size:13px;line-height:1.5;">' +
|
||||
'<div style="display:flex;align-items:center;margin-bottom:10px;"><b style="font-size:15px;">⚙ llama.cpp auf den LLM-Boxen</b>' +
|
||||
'<button class="btn secondary" onclick="closeLlmRuntime()" style="margin-left:auto;padding:2px 10px;font-size:11px;">Schließen</button></div>' +
|
||||
'<div id="llmrt-body"></div></div>';
|
||||
bg.addEventListener('click', e => { if (e.target === bg) closeLlmRuntime(); });
|
||||
document.body.appendChild(bg);
|
||||
}
|
||||
bg.style.display = 'flex';
|
||||
for (const b of onlineLlmBoxes()) {
|
||||
const st = _llmRt[b.instanceId] || (_llmRt[b.instanceId] = { log: [] });
|
||||
if (!st.busy) llmRtSend(b.instanceId, 'status');
|
||||
}
|
||||
renderLlmRuntime();
|
||||
}
|
||||
function closeLlmRuntime() {
|
||||
const bg = document.getElementById('llmrt-modal');
|
||||
if (bg) bg.style.display = 'none';
|
||||
}
|
||||
function llmRtSend(instanceId, op) {
|
||||
send({ action: 'llm_runtime', instanceId, op, requestId: 'rt_' + Date.now() });
|
||||
}
|
||||
function llmRtCheck(id) {
|
||||
const st = _llmRt[id] || (_llmRt[id] = { log: [] });
|
||||
st.checking = true; st.error = '';
|
||||
llmRtSend(id, 'check');
|
||||
renderLlmRuntime();
|
||||
}
|
||||
async function llmRtUpdate(id) {
|
||||
const st = _llmRt[id] || {};
|
||||
const rel = (st.status && st.status.release) || {};
|
||||
if (!await uiConfirm('llama.cpp jetzt aktualisieren?\n\n' +
|
||||
'Die Box zieht das neue llama-swap-Image' + (rel.tag ? ' (' + rel.tag + ')' : '') + ' (mehrere GB) und legt den Container neu an. ' +
|
||||
'Die geladenen Modelle fliegen dabei kurz von den Karten und laden danach neu — laufende Anfragen (z.B. die Schmiede) brechen ab.\n\n' +
|
||||
'Vorher wird die alte Version gesichert. Sieht llama.cpp danach keine Grafikkarte oder lädt das Modell nicht, wird automatisch zurückgesetzt.')) return;
|
||||
Object.assign(st, { busy: 'update', log: [], result: null, error: '' });
|
||||
_llmRt[id] = st;
|
||||
llmRtSend(id, 'update');
|
||||
renderLlmRuntime();
|
||||
}
|
||||
async function llmRtRollback(id) {
|
||||
const st = _llmRt[id] || {};
|
||||
const bk = ((st.status && st.status.backups) || [])[0];
|
||||
if (!bk) return;
|
||||
if (!await uiConfirm('Auf die gesicherte Version zurücksetzen?\n\n' +
|
||||
'Setzt ' + bk.tag + ' wieder ein und legt den Container neu an. Die Modelle laden danach neu, laufende Anfragen brechen ab.')) return;
|
||||
Object.assign(st, { busy: 'rollback', log: [], result: null, error: '' });
|
||||
llmRtSend(id, 'rollback');
|
||||
renderLlmRuntime();
|
||||
}
|
||||
function onLlmRuntimeProgress(p) {
|
||||
const st = _llmRt[p.instanceId] || (_llmRt[p.instanceId] = { log: [] });
|
||||
st.busy = st.busy || p.op;
|
||||
st.log = (st.log || []).concat(p.lines || []).slice(-40);
|
||||
renderLlmRuntime();
|
||||
}
|
||||
function onLlmRuntimeResult(p) {
|
||||
const st = _llmRt[p.instanceId] || (_llmRt[p.instanceId] = { log: [] });
|
||||
if (p.op === 'status' || p.op === 'check') {
|
||||
st.checking = false;
|
||||
if (p.ok === false) { st.error = p.error || 'fehlgeschlagen'; }
|
||||
else {
|
||||
st.status = Object.assign({}, st.status || {}, p);
|
||||
if (p.op === 'check') st.checked = true;
|
||||
st.error = '';
|
||||
if (p.job && p.job.state === 'running' && !st.busy) st.busy = p.job.op;
|
||||
}
|
||||
} else {
|
||||
st.busy = '';
|
||||
st.result = p;
|
||||
llmRtSend(p.instanceId, 'check'); // neuen Stand anzeigen
|
||||
}
|
||||
renderLlmRuntime();
|
||||
}
|
||||
function _rtDate(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s);
|
||||
return isNaN(d) ? String(s) : d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
function renderLlmRuntime() {
|
||||
const body = document.getElementById('llmrt-body');
|
||||
if (!body) return;
|
||||
const boxes = onlineLlmBoxes();
|
||||
if (!boxes.length) { body.innerHTML = '<span style="color:#6a6a88;">Keine LLM-Box online.</span>'; return; }
|
||||
body.innerHTML = boxes.map(b => {
|
||||
const id = b.instanceId, st = _llmRt[id] || {}, s = st.status || {}, inst = s.installed || {};
|
||||
const q = "'" + escapeHtml(id) + "'";
|
||||
let h = '<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:10px;">' +
|
||||
'<div style="font-weight:600;color:#AAB;margin-bottom:6px;">🖥️ ' + escapeHtml(b.node || id) + '</div>';
|
||||
if (st.error) h += '<div style="color:#FF6E6E;margin-bottom:6px;">✗ ' + escapeHtml(st.error) + '</div>';
|
||||
if (!s.image && !st.error) h += '<div style="color:#8888AA;">frage Version ab …</div>';
|
||||
if (s.image) {
|
||||
h += '<div style="font-size:12px;"><b>Installiert:</b> llama-swap ' + escapeHtml(inst.llamaSwap || '?') +
|
||||
' · llama.cpp ' + escapeHtml(inst.llamaCpp || '?') + '</div>' +
|
||||
'<div style="font-size:11px;color:#8888AA;">Image gebaut ' + escapeHtml(_rtDate(inst.created)) +
|
||||
' · <code>' + escapeHtml(s.image) + '</code></div>';
|
||||
if (st.checked) {
|
||||
const rel = s.release || {};
|
||||
const relTxt = rel.tag ? 'llama-swap <b>' + escapeHtml(rel.tag) + '</b> vom ' + escapeHtml(_rtDate(rel.published)) +
|
||||
(rel.url ? ' · <a href="' + escapeHtml(rel.url) + '" target="_blank" style="color:#0A84FF;">Änderungen</a>' : '') : '';
|
||||
if (s.latestError) h += '<div style="color:#FF9500;margin-top:6px;">⚠ ' + escapeHtml(s.latestError) + '</div>';
|
||||
else if (s.updateAvailable) h += '<div style="color:#FFD60A;margin-top:6px;">⬆ Update verfügbar' + (relTxt ? ': ' + relTxt : '') + '</div>';
|
||||
else h += '<div style="color:#34C759;margin-top:6px;">✓ Aktuell' + (relTxt ? ' (neuestes Release: ' + relTxt + ')' : '') + '</div>';
|
||||
}
|
||||
const bk = (s.backups || [])[0];
|
||||
if (bk) h += '<div style="font-size:11px;color:#8888AA;margin-top:4px;">Gesicherte Vorversion: <code>' + escapeHtml(bk.tag) + '</code></div>';
|
||||
if (!st.busy) {
|
||||
h += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;">' +
|
||||
'<button class="btn secondary" onclick="llmRtCheck(' + q + ')" style="padding:3px 10px;font-size:11px;"' + (st.checking ? ' disabled' : '') + '>' +
|
||||
(st.checking ? 'suche …' : 'Nach Updates suchen') + '</button>' +
|
||||
(st.checked && s.updateAvailable ? '<button class="btn" onclick="llmRtUpdate(' + q + ')" style="padding:3px 10px;font-size:11px;">Jetzt aktualisieren</button>' : '') +
|
||||
(bk ? '<button class="btn secondary" onclick="llmRtRollback(' + q + ')" style="padding:3px 10px;font-size:11px;">Zurück auf Vorversion</button>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
}
|
||||
if (st.busy) h += '<div style="color:#FFD60A;margin-top:8px;">⏳ ' + (st.busy === 'update' ? 'Aktualisiere' : 'Setze zurück') +
|
||||
' … das dauert ein paar Minuten (Download + Modell neu laden).</div>';
|
||||
if ((st.log || []).length && (st.busy || st.result)) {
|
||||
h += '<pre style="background:#08080F;color:#9a9ab5;font-size:10.5px;padding:6px 8px;border-radius:4px;max-height:160px;overflow-y:auto;white-space:pre-wrap;margin:6px 0 0;">' +
|
||||
escapeHtml(st.log.join('\n')) + '</pre>';
|
||||
}
|
||||
const r = st.result;
|
||||
if (r && !st.busy) {
|
||||
if (r.ok && r.changed === false) h += '<div style="color:#34C759;margin-top:6px;">✓ ' + escapeHtml(r.message || 'nichts geändert') + '</div>';
|
||||
else if (r.ok) h += '<div style="color:#34C759;margin-top:6px;">✓ Fertig. ' + escapeHtml(r.selftest || '') +
|
||||
(r.before && r.after ? '<br>llama-swap ' + escapeHtml(r.before.llamaSwap || '?') + ' → ' + escapeHtml(r.after.llamaSwap || '?') +
|
||||
' · llama.cpp ' + escapeHtml(r.before.llamaCpp || '?') + ' → ' + escapeHtml(r.after.llamaCpp || '?') : '') + '</div>';
|
||||
else h += '<div style="color:#FF6E6E;margin-top:6px;">✗ ' + escapeHtml(r.error || 'fehlgeschlagen') +
|
||||
(r.rolledBack ? '<br><span style="color:#FF9500;">Automatisch zurückgesetzt: ' + escapeHtml(r.rollbackSelftest || '') + '</span>' : '') + '</div>';
|
||||
}
|
||||
return h + '</div>';
|
||||
}).join('') +
|
||||
'<div style="font-size:11px;color:#6a6a88;">Ob es etwas Neues gibt, zeigt die Prüfsumme des Images in der Registry — der Tag <code>unified-cuda</code> wandert mit. ' +
|
||||
'Das Image enthält llama-swap und llama.cpp zusammen.</div>';
|
||||
}
|
||||
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
|
||||
setInterval(() => {
|
||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||
@@ -10721,5 +11262,24 @@
|
||||
<div id="versions-body" style="overflow-y:auto;padding:4px 12px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nach oben: schwebt unten rechts, sobald man ein Stueck gescrollt hat -->
|
||||
<button id="to-top" type="button" title="Nach oben" aria-label="Nach oben"
|
||||
onclick="window.scrollTo({ top: 0, behavior: 'smooth' })"
|
||||
style="position:fixed;right:20px;bottom:20px;z-index:900;width:42px;height:42px;border-radius:50%;
|
||||
border:1px solid #2A2A3E;background:#1E1E2E;color:#E0E0F0;font-size:20px;line-height:1;
|
||||
cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .2s;">↑</button>
|
||||
<script>
|
||||
(function () {
|
||||
const b = document.getElementById('to-top');
|
||||
const upd = () => {
|
||||
const on = window.scrollY > 400;
|
||||
b.style.opacity = on ? '0.85' : '0';
|
||||
b.style.pointerEvents = on ? 'auto' : 'none';
|
||||
};
|
||||
window.addEventListener('scroll', upd, { passive: true });
|
||||
upd();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+78
-1
@@ -17,6 +17,7 @@ const { WebSocket, WebSocketServer } = require("ws");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const vpn = require("./vpn");
|
||||
|
||||
// ── Konfiguration ───────────────────────────────────────
|
||||
const HTTP_PORT = parseInt(process.env.DIAG_PORT || "3001", 10);
|
||||
@@ -1090,7 +1091,7 @@ function satelliteList() {
|
||||
return Array.from(satellites.values()).map(s => ({
|
||||
id: s.id, location: alias[s.id] || s.location,
|
||||
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,
|
||||
}));
|
||||
}
|
||||
@@ -1098,6 +1099,62 @@ function satelliteList() {
|
||||
// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat
|
||||
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 || "",
|
||||
publicPort: process.env.VPN_PUBLIC_PORT || "443" };
|
||||
}
|
||||
|
||||
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_profile":
|
||||
reply({ ok: true, profile: vpn.profile(String(msg.id || ""), msg.type === "fritz" ? "fritz" : "standard", vpnRvs()) });
|
||||
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() {
|
||||
broadcast({ type: "sat_update", satellites: satelliteList() });
|
||||
}
|
||||
@@ -1644,6 +1701,7 @@ function connectRVS(forcePlain) {
|
||||
satellites.set(p.id, {
|
||||
id: p.id, location: p.location || p.id,
|
||||
caps: p.caps || [], control: !!p.control, net: p.net || null,
|
||||
kind: p.kind || "lan",
|
||||
last_seen: Date.now(),
|
||||
});
|
||||
broadcastSatellites();
|
||||
@@ -1672,6 +1730,8 @@ function connectRVS(forcePlain) {
|
||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_openai_config_result") {
|
||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
||||
} else if (msg.type === "llm_runtime_result" || msg.type === "llm_runtime_progress") {
|
||||
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||
} else if (msg.type === "sat_result") {
|
||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
||||
@@ -3693,6 +3753,23 @@ wss.on("connection", (ws) => {
|
||||
},
|
||||
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") {
|
||||
// llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
|
||||
const op = String(msg.op || "status");
|
||||
if (!["status", "check", "update", "rollback"].includes(op)) return;
|
||||
sendToRVS_raw({
|
||||
type: "llm_runtime",
|
||||
payload: {
|
||||
instanceId: String(msg.instanceId || ""),
|
||||
op,
|
||||
requestId: String(msg.requestId || ""),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else if (msg.action === "agent_history_query") {
|
||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
// ── 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, …)
|
||||
//
|
||||
// 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).
|
||||
// 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-";
|
||||
// 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) {
|
||||
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" },
|
||||
IPAM: { Config: [{ Subnet: SUBNET, IPRange: `${SUBNET_BASE}.0/25`, Gateway: `${SUBNET_BASE}.1` }] },
|
||||
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" } },
|
||||
},
|
||||
...(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)}`);
|
||||
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("");
|
||||
}
|
||||
|
||||
// ── 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) ───────────────────────────
|
||||
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) {
|
||||
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 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);
|
||||
try {
|
||||
if (conn.enabled) await startContainer(conn, rvs);
|
||||
else await removeContainer(id);
|
||||
} finally {
|
||||
writeRelayMap();
|
||||
}
|
||||
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 });
|
||||
writeRelayMap();
|
||||
return { id };
|
||||
}
|
||||
|
||||
function getConf(id) {
|
||||
const conn = loadConns().find((c) => c.id === id);
|
||||
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") };
|
||||
}
|
||||
|
||||
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, profile, writeRelayMap };
|
||||
@@ -164,6 +164,42 @@ services:
|
||||
- RVS_TOKEN=${RVS_TOKEN:-}
|
||||
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"
|
||||
|
||||
# ─── 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:
|
||||
aria-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -135,6 +135,9 @@ const ALLOWED_TYPES = new Set([
|
||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
||||
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||
"llm_openai_config", "llm_openai_config_result",
|
||||
// llama-swap/llama.cpp einer Box pruefen/aktualisieren/zuruecksetzen
|
||||
// (Diagnostic → Modell-Katalog → ⚙ llama.cpp; ausgefuehrt vom llm-updater).
|
||||
"llm_runtime", "llm_runtime_result", "llm_runtime_progress",
|
||||
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
|
||||
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
|
||||
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||
|
||||
@@ -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 vpn_relay.py ./
|
||||
|
||||
CMD ["python", "-u", "vpn_entry.py"]
|
||||
@@ -117,3 +117,77 @@ Der Satellit scannt ein Netz **und** ist über einen Cloud-Relay erreichbar —
|
||||
|
||||
Empfehlung: in vertrauenswürdigen Netzen `CONTROL_ENABLED=true` mit enger Allowlist;
|
||||
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): im Reiter Server
|
||||
„+ Hinzufügen“, Namen, Kundennetz (z.B. `192.168.178.0/24`) und LAN-IP der FritzBox eintragen.
|
||||
ARIA erzeugt beide Schlüsselpaare und einen Pre-Shared-Key; ⬇ liefert die Datei für die
|
||||
Gegenseite:
|
||||
- **FritzBox** (Netzwerk-Kopplung): `Address` = LAN-IP der Box, `AllowedIPs` = ARIAs
|
||||
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
|
||||
```
|
||||
|
||||
+76
-6
@@ -131,6 +131,16 @@ if RVS_TLS and RVS_SNI:
|
||||
SATELLITE_ID = (os.environ.get("SATELLITE_ID") or _default_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_ALLOWLIST = [
|
||||
a.strip() for a in
|
||||
@@ -417,6 +427,9 @@ def _net_context() -> dict:
|
||||
ips.insert(0, primary)
|
||||
p = primary or (ips[0] if ips else "")
|
||||
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):
|
||||
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 "
|
||||
@@ -679,6 +692,58 @@ def _discover_arp() -> list[dict]:
|
||||
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]:
|
||||
"""Fuehrt Geraetelisten zusammen, dedupt per IP (reichere Quelle gewinnt)."""
|
||||
by_ip: dict[str, dict] = {}
|
||||
@@ -2626,12 +2691,16 @@ class Satellite:
|
||||
async def _scan_inner(self) -> None:
|
||||
"""Ein kompletter Scan; _scan begrenzt ihn auf SCAN_MAX_SEC."""
|
||||
loop = asyncio.get_event_loop()
|
||||
self._scan_stage = "mdns"
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "ssdp"
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "arp"
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
if SAT_KIND == "vpn":
|
||||
self._scan_stage = "vpn"
|
||||
mdns, ssdp, arp = [], [], await _discover_routed(SCAN_NETS)
|
||||
else:
|
||||
self._scan_stage = "mdns"
|
||||
mdns = await loop.run_in_executor(None, _discover_mdns, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "ssdp"
|
||||
ssdp = await loop.run_in_executor(None, _discover_ssdp, DISCOVER_TIMEOUT_SEC)
|
||||
self._scan_stage = "arp"
|
||||
arp = await loop.run_in_executor(None, _discover_arp)
|
||||
_self = [_self_device()] if LIST_SELF else []
|
||||
_self = [d for d in _self if d]
|
||||
devices = _apply_names(_merge_devices(mdns, ssdp, arp, _self))
|
||||
@@ -2718,6 +2787,7 @@ class Satellite:
|
||||
"caps": CAPABILITIES,
|
||||
"control": CONTROL_ENABLED,
|
||||
"net": NET,
|
||||
"kind": SAT_KIND,
|
||||
},
|
||||
"timestamp": int(time.time() * 1000),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
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], listen: int = 0) -> 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"
|
||||
# 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
|
||||
# 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
|
||||
{lin}
|
||||
}}
|
||||
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
|
||||
{lout}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
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, 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 "-")
|
||||
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()
|
||||
@@ -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())
|
||||
@@ -207,6 +207,23 @@ services:
|
||||
- HF_TOKEN=${HF_TOKEN:-}
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── LLM-Updater — llama-swap/llama.cpp aktualisieren ─
|
||||
# Der EINZIGE Dienst mit Docker-Socket (= root auf der Box). Darum kann er nur
|
||||
# drei feste Dinge fuer aria-llama-swap: Version pruefen, aktualisieren (altes
|
||||
# Image sichern, ziehen, neu anlegen, Selbsttest, sonst zurueck) und auf die
|
||||
# gesicherte Version zuruecksetzen. Kein Port nach aussen; der llm-adapter
|
||||
# spricht ihn im Compose-Netz mit einem Token an (/models/.updater_token).
|
||||
# Ausgeloest wird das aus der Diagnostic (Modell-Katalog → ⚙ llama.cpp).
|
||||
llm-updater:
|
||||
build: ./llm-updater
|
||||
container_name: aria-llm-updater
|
||||
profiles: ["llm"]
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- .:/stack:ro # Compose-Datei + .env zum Neu-Anlegen
|
||||
- ./models:/models:ro # Token + welche Modelle vorgeladen sind
|
||||
restart: unless-stopped
|
||||
|
||||
# ─── OpenHands (optional, Standalone) ────────────────
|
||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||
|
||||
@@ -342,7 +342,7 @@ async def _reload_llama() -> None:
|
||||
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
|
||||
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
|
||||
# Wiederverbinden aufheben.
|
||||
_RELIABLE_TYPES = {"llm_response"}
|
||||
_RELIABLE_TYPES = {"llm_response", "llm_runtime_result"}
|
||||
_OUTBOX_TTL_S = 300
|
||||
_outbox: list = [] # [(ts, mtype, payload)]
|
||||
_current_ws = None # die aktuell offene RVS-Verbindung
|
||||
@@ -1002,6 +1002,83 @@ async def _do_llm_request(ws, payload: dict) -> None:
|
||||
})
|
||||
|
||||
|
||||
# ── llama-swap/llama.cpp aktualisieren (ueber den llm-updater) ──
|
||||
# Der Adapter hat bewusst KEINEN Docker-Zugriff. Pruefen, Aktualisieren und
|
||||
# Zuruecksetzen macht der llm-updater (eigener Container, nur im Compose-Netz);
|
||||
# der Adapter reicht die Diagnostic-Anfrage weiter und meldet den Fortschritt.
|
||||
UPDATER_URL = os.getenv("UPDATER_URL", "http://llm-updater:8090").rstrip("/")
|
||||
_UPDATER_TOKEN_FILE = "/models/.updater_token"
|
||||
|
||||
|
||||
def _updater_token() -> str:
|
||||
"""Gemeinsamer Token mit dem llm-updater. Liegt in /models (der Updater
|
||||
haengt es nur lesend ein); fehlt er, legt der Adapter ihn an."""
|
||||
try:
|
||||
tok = open(_UPDATER_TOKEN_FILE).read().strip()
|
||||
if tok:
|
||||
return tok
|
||||
except OSError:
|
||||
pass
|
||||
import secrets
|
||||
tok = secrets.token_hex(24)
|
||||
try:
|
||||
fd = os.open(_UPDATER_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
f.write(tok)
|
||||
except OSError as e:
|
||||
logger.warning("Updater-Token nicht schreibbar: %s", e)
|
||||
return tok
|
||||
|
||||
|
||||
async def _updater(method: str, path: str, timeout: float = 60) -> dict:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as c:
|
||||
r = await c.request(method, UPDATER_URL + path,
|
||||
headers={"X-Updater-Token": _updater_token()})
|
||||
return r.json()
|
||||
except httpx.ConnectError:
|
||||
return {"ok": False, "error": "Der Update-Dienst (llm-updater) laeuft auf dieser Box "
|
||||
"nicht. Mit `docker compose up -d llm-updater` starten."}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Update-Dienst: {str(e)[:150]}"}
|
||||
|
||||
|
||||
async def _handle_runtime(ws, payload: dict) -> None:
|
||||
"""op = status | check | update | rollback (aus der Diagnostic)."""
|
||||
if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
|
||||
return
|
||||
op = payload.get("op", "status")
|
||||
base = {"requestId": payload.get("requestId", ""), "instanceId": INSTANCE_ID,
|
||||
"node": NODE_NAME, "op": op}
|
||||
if op in ("status", "check"):
|
||||
res = await _updater("GET", "/" + op, timeout=60)
|
||||
await _send(ws, "llm_runtime_result", {**base, **res})
|
||||
return
|
||||
if op not in ("update", "rollback"):
|
||||
await _send(ws, "llm_runtime_result", {**base, "ok": False, "error": "unbekannte Aktion"})
|
||||
return
|
||||
start = await _updater("POST", "/" + op)
|
||||
if not start.get("ok"):
|
||||
await _send(ws, "llm_runtime_result", {**base, **start})
|
||||
return
|
||||
logger.info("llama-swap %s gestartet (%s)", op, start.get("job"))
|
||||
seen = 0
|
||||
while True:
|
||||
await asyncio.sleep(3)
|
||||
job = await _updater("GET", "/job", timeout=20)
|
||||
if job.get("error") and "state" not in job:
|
||||
continue # Updater kurz nicht erreichbar — weiter warten
|
||||
lines = job.get("log") or []
|
||||
if len(lines) > seen:
|
||||
await _send(ws, "llm_runtime_progress", {**base, "lines": lines[seen:]})
|
||||
seen = len(lines)
|
||||
if job.get("state") == "done":
|
||||
await _send(ws, "llm_runtime_result", {**base, **(job.get("result") or {})})
|
||||
logger.info("llama-swap %s fertig: %s", op, (job.get("result") or {}).get("ok"))
|
||||
await _announce(ws)
|
||||
return
|
||||
|
||||
|
||||
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||
|
||||
@@ -1886,6 +1963,9 @@ async def _run() -> None:
|
||||
if mtype == "llm_openai_config":
|
||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||
continue
|
||||
if mtype == "llm_runtime":
|
||||
asyncio.create_task(_handle_runtime(ws, payload))
|
||||
continue
|
||||
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
|
||||
"llm_unload_model", "llm_models_request"):
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Docker-CLI mit Compose-Plugin + Python (nur Standardbibliothek + PyYAML)
|
||||
FROM docker:29-cli
|
||||
|
||||
RUN apk add --no-cache python3 py3-yaml
|
||||
WORKDIR /app
|
||||
COPY updater.py .
|
||||
|
||||
CMD ["python3", "-u", "updater.py"]
|
||||
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
ARIA LLM-Updater (AI-Box) — aktualisiert das llama-swap-Image (mit llama.cpp).
|
||||
|
||||
Der einzige Dienst mit Docker-Socket. Darum kann er bewusst NUR drei feste
|
||||
Dinge, alle fuer genau einen Container (aria-llama-swap):
|
||||
|
||||
check installierte Version + Image-Pruefsumme, dazu die neueste aus der
|
||||
Registry (Pruefsumme des Tags) und das letzte llama-swap-Release
|
||||
update altes Image sichern (Tag vor-update-<Zeit>), neues ziehen,
|
||||
Container neu anlegen, Selbsttest — scheitert der, automatisch zurueck
|
||||
rollback das gesicherte Image wieder einsetzen, Container neu anlegen, Selbsttest
|
||||
|
||||
Kein freier Befehl, keine anderen Container. Erreichbar nur im Compose-Netz
|
||||
(kein Port nach aussen), jede Anfrage braucht den Token, den der llm-adapter
|
||||
in /models/.updater_token ablegt.
|
||||
|
||||
Neu angelegt wird ueber `docker compose … up --no-deps --force-recreate
|
||||
llama-swap` mit der Compose-Datei des Stacks — so bleibt alles, was die
|
||||
Compose-Datei festlegt (GPU-Runtime, Volumes, LD_LIBRARY_PATH …), gleich.
|
||||
Relative Pfade loest Compose gegen --project-directory auf; das ist der
|
||||
Host-Pfad aus dem Label des laufenden Containers, er muss im Container hier
|
||||
nicht existieren.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.server
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
||||
logger = logging.getLogger("llm-updater")
|
||||
|
||||
CONTAINER = os.getenv("LLAMA_CONTAINER", "aria-llama-swap")
|
||||
SERVICE = os.getenv("LLAMA_SERVICE", "llama-swap")
|
||||
STACK_DIR = os.getenv("STACK_DIR", "/stack")
|
||||
TOKEN_FILE = os.getenv("TOKEN_FILE", "/models/.updater_token")
|
||||
GEN_CONFIG = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
|
||||
LLAMA_URL = os.getenv("LLAMA_URL", "http://llama-swap:8080").rstrip("/")
|
||||
RELEASES_URL = os.getenv("RELEASES_URL", "https://api.github.com/repos/mostlygeek/llama-swap/releases/latest")
|
||||
BACKUP_REPO = "llama-swap" # gesicherte Images: llama-swap:vor-update-<Zeit>
|
||||
KEEP_BACKUPS = 1
|
||||
READY_TIMEOUT_S = int(os.getenv("READY_TIMEOUT_S", "900")) # Modell(e) wieder geladen
|
||||
PORT = int(os.getenv("PORT", "8090"))
|
||||
|
||||
_job = {"id": "", "op": "", "state": "idle", "log": [], "result": None}
|
||||
_job_lock = threading.Lock()
|
||||
|
||||
|
||||
# ── kleine Helfer ───────────────────────────────────────────────
|
||||
|
||||
def _run(args: list[str], timeout: int = 120) -> tuple[int, str]:
|
||||
p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
return p.returncode, (p.stdout + p.stderr).strip()
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
logger.info(msg)
|
||||
with _job_lock:
|
||||
_job["log"].append(f"{time.strftime('%H:%M:%S')} {msg}")
|
||||
_job["log"] = _job["log"][-60:]
|
||||
|
||||
|
||||
def _inspect(ref: str, kind: str = "container") -> dict:
|
||||
rc, out = _run(["docker", kind, "inspect", ref])
|
||||
if rc != 0:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(out)[0]
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _image_ref() -> str:
|
||||
return (_inspect(CONTAINER).get("Config") or {}).get("Image", "")
|
||||
|
||||
|
||||
def _local_digest(ref: str) -> str:
|
||||
"""Pruefsumme, unter der das Image gezogen wurde (Index/Manifest des Tags)."""
|
||||
repo = ref.rsplit(":", 1)[0] if ":" in ref.split("/")[-1] else ref
|
||||
for d in _inspect(ref, "image").get("RepoDigests") or []:
|
||||
if d.startswith(repo + "@"):
|
||||
return d.split("@", 1)[1]
|
||||
return ""
|
||||
|
||||
|
||||
def _versions() -> dict:
|
||||
v = {}
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-swap", "-version"], 30)
|
||||
m = re.search(r"version:\s*(\S+)", out)
|
||||
if rc == 0 and m:
|
||||
v["llamaSwap"] = m.group(1)
|
||||
b = re.search(r"built at\s*(\S+)", out)
|
||||
if b:
|
||||
v["llamaSwapBuilt"] = b.group(1)
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--version"], 30)
|
||||
m = re.search(r"version:\s*(.+)", out)
|
||||
if rc == 0 and m:
|
||||
v["llamaCpp"] = m.group(1).strip()
|
||||
return v
|
||||
|
||||
|
||||
def _backups() -> list[dict]:
|
||||
rc, out = _run(["docker", "images", BACKUP_REPO, "--format", "{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}"])
|
||||
res = []
|
||||
for line in out.splitlines() if rc == 0 else []:
|
||||
tag, iid, created = (line.split("\t") + ["", "", ""])[:3]
|
||||
if tag.startswith("vor-update-"):
|
||||
res.append({"tag": f"{BACKUP_REPO}:{tag}", "id": iid, "created": created})
|
||||
return sorted(res, key=lambda b: b["tag"], reverse=True)
|
||||
|
||||
|
||||
def _http(url: str, method: str = "GET", headers: dict | None = None, timeout: int = 20):
|
||||
req = urllib.request.Request(url, method=method, headers=headers or {})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def _remote_digest(ref: str) -> str:
|
||||
"""Pruefsumme des Tags in der Registry (ohne Docker, anonymer Token)."""
|
||||
name, tag = ref.rsplit(":", 1) if ":" in ref.split("/")[-1] else (ref, "latest")
|
||||
parts = name.split("/")
|
||||
if "." in parts[0] or ":" in parts[0]:
|
||||
host, path = parts[0], "/".join(parts[1:])
|
||||
else:
|
||||
host, path = "registry-1.docker.io", name if "/" in name else f"library/{name}"
|
||||
url = f"https://{host}/v2/{path}/manifests/{tag}"
|
||||
accept = ", ".join([
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
])
|
||||
try:
|
||||
r = _http(url, "HEAD", {"Accept": accept})
|
||||
return r.headers.get("Docker-Content-Digest", "")
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 401:
|
||||
raise
|
||||
chal = e.headers.get("WWW-Authenticate", "")
|
||||
p = dict(re.findall(r'(\w+)="([^"]*)"', chal))
|
||||
tok_url = f"{p['realm']}?service={p.get('service', '')}&scope={p.get('scope', f'repository:{path}:pull')}"
|
||||
tok = json.load(_http(tok_url))
|
||||
token = tok.get("token") or tok.get("access_token") or ""
|
||||
r = _http(url, "HEAD", {"Accept": accept, "Authorization": f"Bearer {token}"})
|
||||
return r.headers.get("Docker-Content-Digest", "")
|
||||
|
||||
|
||||
def _latest_release() -> dict:
|
||||
try:
|
||||
d = json.load(_http(RELEASES_URL, headers={"Accept": "application/vnd.github+json"}))
|
||||
return {"tag": d.get("tag_name", ""), "published": d.get("published_at", ""),
|
||||
"url": d.get("html_url", "")}
|
||||
except Exception as e:
|
||||
return {"error": str(e)[:120]}
|
||||
|
||||
|
||||
def _status(remote: bool) -> dict:
|
||||
ref = _image_ref()
|
||||
if not ref:
|
||||
return {"ok": False, "error": f"Container {CONTAINER} nicht gefunden"}
|
||||
img = _inspect(ref, "image")
|
||||
st = {
|
||||
"ok": True, "image": ref,
|
||||
"installed": {**_versions(), "digest": _local_digest(ref),
|
||||
"created": img.get("Created", "")},
|
||||
"backups": _backups(),
|
||||
"job": {k: _job[k] for k in ("id", "op", "state")},
|
||||
}
|
||||
if remote:
|
||||
try:
|
||||
st["latestDigest"] = _remote_digest(ref)
|
||||
except Exception as e:
|
||||
st["latestError"] = f"Registry nicht erreichbar: {str(e)[:120]}"
|
||||
st["release"] = _latest_release()
|
||||
if st.get("latestDigest") and st["installed"]["digest"]:
|
||||
st["updateAvailable"] = st["latestDigest"] != st["installed"]["digest"]
|
||||
return st
|
||||
|
||||
|
||||
# ── Neu anlegen + Selbsttest ────────────────────────────────────
|
||||
|
||||
def _compose_args() -> list[str]:
|
||||
labels = (_inspect(CONTAINER).get("Config") or {}).get("Labels") or {}
|
||||
proj = labels.get("com.docker.compose.project", "")
|
||||
wdir = labels.get("com.docker.compose.project.working_dir", "")
|
||||
if not proj or not wdir:
|
||||
raise RuntimeError("Container wurde nicht mit Compose angelegt — Labels fehlen")
|
||||
args = ["docker", "compose", "--project-directory", wdir, "-p", proj,
|
||||
"-f", os.path.join(STACK_DIR, "docker-compose.yml"), "--profile", "llm"]
|
||||
env = os.path.join(STACK_DIR, ".env")
|
||||
if os.path.exists(env):
|
||||
args += ["--env-file", env]
|
||||
return args
|
||||
|
||||
|
||||
def _recreate() -> None:
|
||||
rc, out = _run(_compose_args() + ["up", "-d", "--no-deps", "--force-recreate",
|
||||
"--pull", "never", SERVICE], 300)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Container neu anlegen fehlgeschlagen: {out[-400:]}")
|
||||
_log("Container neu angelegt")
|
||||
|
||||
|
||||
def _preloaded() -> list[str]:
|
||||
try:
|
||||
import yaml
|
||||
cfg = yaml.safe_load(open(GEN_CONFIG)) or {}
|
||||
return list(((cfg.get("hooks") or {}).get("on_startup") or {}).get("preload") or [])
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _selftest() -> tuple[bool, str]:
|
||||
"""Karten sichtbar (sonst rechnet llama.cpp still auf der CPU) und die
|
||||
vorher geladenen Modelle wieder bereit."""
|
||||
for _ in range(30):
|
||||
if (_inspect(CONTAINER).get("State") or {}).get("Running"):
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
return False, "Container laeuft nicht"
|
||||
rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--list-devices"], 60)
|
||||
cards = [l.strip() for l in out.splitlines() if re.match(r"\s*CUDA\d+", l)]
|
||||
if not cards:
|
||||
return False, f"llama.cpp sieht keine Grafikkarte (wuerde auf der CPU rechnen): {out[-300:]}"
|
||||
_log(f"Karten sichtbar: {len(cards)} ({'; '.join(c[:60] for c in cards)})")
|
||||
want = _preloaded()
|
||||
if not want:
|
||||
return True, f"{len(cards)} Karte(n) sichtbar, kein Modell vorgeladen"
|
||||
_log(f"warte, bis {', '.join(want)} geladen ist …")
|
||||
deadline = time.time() + READY_TIMEOUT_S
|
||||
last = ""
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
run = json.load(_http(f"{LLAMA_URL}/running", timeout=10)).get("running") or []
|
||||
states = {r.get("model"): r.get("state") for r in run}
|
||||
last = ", ".join(f"{m}={states.get(m, '-')}" for m in want)
|
||||
if all(states.get(m) == "ready" for m in want):
|
||||
return True, f"{len(cards)} Karte(n), Modell(e) bereit: {', '.join(want)}"
|
||||
except Exception as e:
|
||||
last = str(e)[:80]
|
||||
time.sleep(5)
|
||||
return False, f"Modell nach {READY_TIMEOUT_S // 60} min nicht bereit ({last})"
|
||||
|
||||
|
||||
def _drop_untagged(image_id: str) -> None:
|
||||
"""Ein Image, das nach dem Zuruecksetzen keinen Namen mehr hat, belegt nur
|
||||
noch Platz (das llama-swap-Image hat ~14 GB)."""
|
||||
if image_id and not (_inspect(image_id, "image").get("RepoTags") or []):
|
||||
rc, _ = _run(["docker", "rmi", image_id])
|
||||
if rc == 0:
|
||||
_log("verworfene Version geloescht")
|
||||
|
||||
|
||||
def _prune_backups() -> None:
|
||||
for b in _backups()[KEEP_BACKUPS:]:
|
||||
_run(["docker", "rmi", b["tag"]])
|
||||
_log(f"alte Sicherung entfernt: {b['tag']}")
|
||||
|
||||
|
||||
# ── Jobs ────────────────────────────────────────────────────────
|
||||
|
||||
def _do_update() -> dict:
|
||||
ref = _image_ref()
|
||||
before = _status(remote=False)
|
||||
old_id = _inspect(ref, "image").get("Id", "")
|
||||
backup = f"{BACKUP_REPO}:vor-update-{time.strftime('%Y%m%d-%H%M%S')}"
|
||||
rc, out = _run(["docker", "tag", old_id, backup])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Sicherung fehlgeschlagen: {out}")
|
||||
_log(f"altes Image gesichert als {backup}")
|
||||
_log(f"ziehe {ref} … (mehrere GB, dauert)")
|
||||
rc, out = _run(["docker", "pull", ref], 3600)
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Ziehen fehlgeschlagen: {out[-400:]}")
|
||||
new_id = _inspect(ref, "image").get("Id", "")
|
||||
if new_id == old_id:
|
||||
_run(["docker", "rmi", backup])
|
||||
return {"ok": True, "changed": False, "message": "Schon aktuell — nichts geaendert."}
|
||||
_recreate()
|
||||
ok, why = _selftest()
|
||||
if ok:
|
||||
_prune_backups()
|
||||
after = _status(remote=False)
|
||||
return {"ok": True, "changed": True, "before": before.get("installed"),
|
||||
"after": after.get("installed"), "selftest": why, "backup": backup}
|
||||
_log(f"Selbsttest fehlgeschlagen: {why} — setze zurueck")
|
||||
_run(["docker", "tag", backup, ref])
|
||||
_recreate()
|
||||
_drop_untagged(new_id)
|
||||
ok2, why2 = _selftest()
|
||||
return {"ok": False, "rolledBack": True, "error": f"Selbsttest fehlgeschlagen: {why}",
|
||||
"rollbackSelftest": why2 if ok2 else f"AUCH die alte Version laeuft nicht: {why2}"}
|
||||
|
||||
|
||||
def _do_rollback() -> dict:
|
||||
bks = _backups()
|
||||
if not bks:
|
||||
raise RuntimeError("Keine gesicherte Version vorhanden")
|
||||
ref = _image_ref()
|
||||
cur_id = _inspect(ref, "image").get("Id", "")
|
||||
b = bks[0]
|
||||
rc, out = _run(["docker", "tag", b["tag"], ref])
|
||||
if rc != 0:
|
||||
raise RuntimeError(f"Zuruecksetzen fehlgeschlagen: {out}")
|
||||
_log(f"{b['tag']} wieder als {ref} gesetzt")
|
||||
_recreate()
|
||||
_drop_untagged(cur_id)
|
||||
ok, why = _selftest()
|
||||
return {"ok": ok, "selftest": why, "restored": b["tag"],
|
||||
"after": _status(remote=False).get("installed"),
|
||||
**({} if ok else {"error": f"Selbsttest fehlgeschlagen: {why}"})}
|
||||
|
||||
|
||||
def _start_job(op: str) -> dict:
|
||||
with _job_lock:
|
||||
if _job["state"] == "running":
|
||||
return {"ok": False, "error": f"Es laeuft schon: {_job['op']}", "job": _job["id"]}
|
||||
_job.update(id=f"{op}-{int(time.time())}", op=op, state="running", log=[], result=None)
|
||||
jid = _job["id"]
|
||||
|
||||
def work():
|
||||
try:
|
||||
res = _do_update() if op == "update" else _do_rollback()
|
||||
except Exception as e:
|
||||
logger.exception("%s fehlgeschlagen", op)
|
||||
res = {"ok": False, "error": str(e)[:500]}
|
||||
with _job_lock:
|
||||
_job["result"] = res
|
||||
_job["state"] = "done"
|
||||
|
||||
threading.Thread(target=work, daemon=True).start()
|
||||
return {"ok": True, "job": jid}
|
||||
|
||||
|
||||
# ── HTTP ────────────────────────────────────────────────────────
|
||||
|
||||
def _token() -> str:
|
||||
try:
|
||||
return open(TOKEN_FILE).read().strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *a):
|
||||
pass
|
||||
|
||||
def _reply(self, code: int, body: dict) -> None:
|
||||
data = json.dumps(body).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _authorized(self) -> bool:
|
||||
tok = _token()
|
||||
if not tok or self.headers.get("X-Updater-Token", "") != tok:
|
||||
self._reply(403, {"ok": False, "error": "Token fehlt oder falsch"})
|
||||
return False
|
||||
return True
|
||||
|
||||
def do_GET(self):
|
||||
if not self._authorized():
|
||||
return
|
||||
try:
|
||||
if self.path == "/status":
|
||||
self._reply(200, _status(remote=False))
|
||||
elif self.path == "/check":
|
||||
self._reply(200, _status(remote=True))
|
||||
elif self.path == "/job":
|
||||
with _job_lock:
|
||||
self._reply(200, dict(_job))
|
||||
else:
|
||||
self._reply(404, {"ok": False, "error": "unbekannt"})
|
||||
except Exception as e:
|
||||
self._reply(500, {"ok": False, "error": str(e)[:300]})
|
||||
|
||||
def do_POST(self):
|
||||
if not self._authorized():
|
||||
return
|
||||
if self.path in ("/update", "/rollback"):
|
||||
self._reply(200, _start_job(self.path[1:]))
|
||||
else:
|
||||
self._reply(404, {"ok": False, "error": "unbekannt"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("LLM-Updater auf :%d fuer Container %s", PORT, CONTAINER)
|
||||
http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
|
||||
Reference in New Issue
Block a user