feat(satellite): Credential-Store + snmp.ports/info + FritzBox-Reader
Baut SNMP zur generellen Geraete-Auskunft aus und macht Zugangsdaten pro Geraet
hinterlegbar, damit der Satellit tiefer lesen kann.
Satellit:
- Verschluesselter Credential-Store (Fernet) pro IP im Bind-Volume ./data:
SNMP (community/v2c oder v3 user/auth/priv), HTTP-Basic, FritzBox-Login.
Neue Messages sat_creds_set/list/delete + *_result; Secrets werden NIE
zurueckgeliefert (list gibt nur Typen). snmp.*/http/fritzbox nutzen die
Creds automatisch (params haben Vorrang).
- snmp.ports: ifTable -> aktive/freie Ports, Linkspeed ('sind noch Ports frei').
- snmp.info: sysName/Descr + Entity-MIB Modell/Serial/Firmware (installierte
Version; Update-Check ist Hersteller-Sache, kein SNMP).
- fritzbox.info/hosts: TR-064 (SOAP+Digest) — Verbindung/Datenrate/externe IP
bzw. verbundene Geraete. Bewusst als Reader, weil TR-064 fuer on-the-fly
http.post zu fummelig ist.
- CONTROL_ALLOWLIST-Default + .env.example erweitert; data/ ge-gitignore-t.
RVS: sat_creds_* in ALLOWED_TYPES (sonst verworfen).
Diagnostic: pro entdecktem Geraet ein Schluessel-Button -> Modal (SNMP v2c/v3,
HTTP-Basic, FritzBox); Speichern/Loeschen via RVS an den Satelliten; gesetzte
Typen werden pro Geraet angezeigt (🔑 ✓). Server relayed die Cred-Messages.
Brain: satellite_command-Tool um snmp.ports/info + fritzbox.* erweitert; ARIA
muss keine Passwoerter mitgeben (Satellit nutzt den Store).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+10
-2
@@ -1225,9 +1225,17 @@ META_TOOLS = [
|
|||||||
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
"Aktionen: 'wol' (params={'mac':'...'}) zum Aufwecken, "
|
||||||
"'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
|
"'snmp.printer' (params={'ip':'<drucker-ip>'}) — BEVORZUGT fuer Drucker-"
|
||||||
"Tinte/Toner: liest die Fuellstaende zuverlaessig als Prozent aus der "
|
"Tinte/Toner: liest die Fuellstaende zuverlaessig als Prozent aus der "
|
||||||
"Printer-MIB (kein HTML-Scrapen). 'snmp.get'/'snmp.walk' "
|
"Printer-MIB (kein HTML-Scrapen). 'snmp.ports' (params={'ip':'...'}) — "
|
||||||
|
"Switch/Router-Interfaces: aktive/freie Ports + Linkspeed (beantwortet "
|
||||||
|
"'sind noch Ports frei'). 'snmp.info' (params={'ip':'...'}) — Modell, "
|
||||||
|
"Seriennummer, INSTALLIERTE Firmware/Software-Version (ob ein Update "
|
||||||
|
"existiert, weiss SNMP NICHT). 'snmp.get'/'snmp.walk' "
|
||||||
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
||||||
"SNMP-Werte. "
|
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
||||||
|
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
||||||
|
"hinterlegten FritzBox-Login). Fuer Geraete mit hinterlegten Zugangsdaten "
|
||||||
|
"(community/v3/Login) nutzt der Satellit diese automatisch — Du musst keine "
|
||||||
|
"Passwoerter mitgeben. "
|
||||||
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
|
"'http.get'/'http.post' (params={'url':'...'}) fuer lokale Webhooks UND "
|
||||||
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
|
"um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen "
|
||||||
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
|
"Seiten NICHT blind paginieren: setze params['contains'] (String oder "
|
||||||
|
|||||||
+143
-2
@@ -196,6 +196,66 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Geraete-Credentials Modal -->
|
||||||
|
<div id="dev-creds-modal" style="display:none;position:fixed;inset:0;z-index:1002;background:rgba(0,0,0,0.8);align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#0D0D1A;border:1px solid #2A2A3E;border-radius:10px;padding:18px;max-width:520px;width:94%;max-height:92vh;overflow:auto;display:flex;flex-direction:column;gap:12px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<h3 style="margin:0;color:#fff;">🔑 Zugangsdaten: <span id="dev-creds-title">—</span></h3>
|
||||||
|
<button onclick="closeDeviceCreds()" style="background:none;border:none;color:#8888AA;font-size:22px;cursor:pointer;">×</button>
|
||||||
|
</div>
|
||||||
|
<p style="color:#8888AA;font-size:11px;margin:0;">Wird verschluesselt auf dem Satelliten gespeichert. Leere Felder speichern nichts / loeschen den Typ. Der Satellit nutzt sie automatisch bei SNMP / HTTP / FritzBox.</p>
|
||||||
|
|
||||||
|
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
|
||||||
|
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">SNMP</div>
|
||||||
|
<label style="font-size:11px;color:#8888AA;">Version</label>
|
||||||
|
<select id="dc-snmp-version" onchange="dcToggleV3()" style="width:100%;margin-bottom:6px;">
|
||||||
|
<option value="">— nicht setzen —</option>
|
||||||
|
<option value="2c">v2c (Community)</option>
|
||||||
|
<option value="1">v1 (Community)</option>
|
||||||
|
<option value="3">v3 (User/Auth)</option>
|
||||||
|
</select>
|
||||||
|
<div id="dc-snmp-community-row">
|
||||||
|
<label style="font-size:11px;color:#8888AA;">Community</label>
|
||||||
|
<input id="dc-snmp-community" placeholder="public" style="width:100%;margin-bottom:4px;">
|
||||||
|
</div>
|
||||||
|
<div id="dc-snmp-v3" style="display:none;">
|
||||||
|
<input id="dc-v3-user" placeholder="v3 User" style="width:100%;margin-bottom:4px;">
|
||||||
|
<select id="dc-v3-level" style="width:100%;margin-bottom:4px;">
|
||||||
|
<option value="authPriv">authPriv</option>
|
||||||
|
<option value="authNoPriv">authNoPriv</option>
|
||||||
|
<option value="noAuthNoPriv">noAuthNoPriv</option>
|
||||||
|
</select>
|
||||||
|
<div style="display:flex;gap:4px;margin-bottom:4px;">
|
||||||
|
<select id="dc-v3-authproto" style="flex:1;"><option value="SHA">SHA</option><option value="MD5">MD5</option><option value="SHA-256">SHA-256</option></select>
|
||||||
|
<input id="dc-v3-authkey" placeholder="Auth-Passwort" style="flex:2;">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<select id="dc-v3-privproto" style="flex:1;"><option value="AES">AES</option><option value="DES">DES</option><option value="AES-256">AES-256</option></select>
|
||||||
|
<input id="dc-v3-privkey" placeholder="Priv-Passwort" style="flex:2;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
|
||||||
|
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">HTTP-Basic</div>
|
||||||
|
<input id="dc-http-user" placeholder="Benutzer" style="width:100%;margin-bottom:4px;">
|
||||||
|
<input id="dc-http-pass" type="password" placeholder="Passwort" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
|
||||||
|
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">FritzBox (TR-064)</div>
|
||||||
|
<input id="dc-fb-user" placeholder="FritzBox-Benutzer" style="width:100%;margin-bottom:4px;">
|
||||||
|
<input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||||
|
<button class="btn secondary" onclick="deleteDeviceCreds()" style="color:#FF6E6E;">Alle loeschen</button>
|
||||||
|
<button class="btn" onclick="saveDeviceCreds()">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<div id="dc-status" style="font-size:11px;color:#8888AA;min-height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Voice-Preview Modal -->
|
<!-- Voice-Preview Modal -->
|
||||||
<div id="voice-preview-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;">
|
<div id="voice-preview-modal" style="display:none;position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,0.7);align-items:center;justify-content:center;">
|
||||||
<div style="background:#1A1A2E;border:1px solid #2A2A3E;border-radius:10px;padding:20px;max-width:560px;width:90%;display:flex;flex-direction:column;gap:12px;">
|
<div style="background:#1A1A2E;border:1px solid #2A2A3E;border-radius:10px;padding:20px;max-width:560px;width:90%;display:flex;flex-direction:column;gap:12px;">
|
||||||
@@ -2062,7 +2122,29 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'sat_update') { satellites = msg.satellites || []; renderSatellites(); return; }
|
if (msg.type === 'sat_update') {
|
||||||
|
satellites = msg.satellites || [];
|
||||||
|
renderSatellites();
|
||||||
|
satellites.forEach(s => { if (s.online !== false) requestSatCreds(s.id); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'sat_creds_list_result') {
|
||||||
|
const p = msg.payload || msg;
|
||||||
|
const sat = p.satellite || '';
|
||||||
|
const map = {};
|
||||||
|
(p.items || []).forEach(it => { map[it.ip] = it.types || []; });
|
||||||
|
satCreds[sat] = map;
|
||||||
|
renderSatellites();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === 'sat_creds_result') {
|
||||||
|
const p = msg.payload || msg;
|
||||||
|
const el = document.getElementById('dc-status');
|
||||||
|
if (el) el.textContent = p.ok ? '✓ Gespeichert.' : '⚠ Fehlgeschlagen.';
|
||||||
|
if (p.ok && p.satellite) requestSatCreds(p.satellite);
|
||||||
|
if (p.ok) setTimeout(closeDeviceCreds, 700);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
|
if (msg.type === 'worker_update') { workers = msg.workers || []; renderWorkers(); if (typeof refreshLocalLlmModelChoices === 'function') refreshLocalLlmModelChoices(); if (typeof renderLlmCatalog === 'function') renderLlmCatalog(); return; }
|
||||||
if (msg.type === 'llm_response') {
|
if (msg.type === 'llm_response') {
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
@@ -4455,10 +4537,16 @@
|
|||||||
let satellites = []; // [{id, location, caps, control, online}]
|
let satellites = []; // [{id, location, caps, control, online}]
|
||||||
let satDevices = {}; // id → {devices, location, ts}
|
let satDevices = {}; // id → {devices, location, ts}
|
||||||
let satScanning = null; // id des gerade scannenden Satelliten
|
let satScanning = null; // id des gerade scannenden Satelliten
|
||||||
|
let satCreds = {}; // satId → { ip → [typen] } (welche Creds gesetzt sind, OHNE Secrets)
|
||||||
|
let credsModalSat = ''; // aktuell im Modal bearbeiteter Satellit
|
||||||
|
let credsModalIp = '';
|
||||||
|
|
||||||
function requestSatellites() {
|
function requestSatellites() {
|
||||||
send({ action: 'sat_list' });
|
send({ action: 'sat_list' });
|
||||||
}
|
}
|
||||||
|
function requestSatCreds(id) {
|
||||||
|
send({ action: 'sat_creds_list', satellite: id });
|
||||||
|
}
|
||||||
function scanSatellite(id) {
|
function scanSatellite(id) {
|
||||||
satScanning = id;
|
satScanning = id;
|
||||||
renderSatellites();
|
renderSatellites();
|
||||||
@@ -4490,13 +4578,20 @@
|
|||||||
dev.devices.map(d => {
|
dev.devices.map(d => {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
if (d.model) tags.push(escapeHtml(d.model));
|
if (d.model) tags.push(escapeHtml(d.model));
|
||||||
|
if (d.snmp && d.snmp.descr) tags.push(escapeHtml(String(d.snmp.descr).slice(0, 40)));
|
||||||
if (d.dialAppUrl) tags.push('DIAL');
|
if (d.dialAppUrl) tags.push('DIAL');
|
||||||
if (d.mac) tags.push(escapeHtml(d.mac));
|
if (d.mac) tags.push(escapeHtml(d.mac));
|
||||||
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
const tagStr = tags.length ? ' <span style="color:#666;">(' + tags.join(' · ') + ')</span>' : '';
|
||||||
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;">' +
|
const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length;
|
||||||
|
const keyBtn = d.ip ? '<button class="btn secondary" title="Zugangsdaten (SNMP / HTTP / FritzBox)" ' +
|
||||||
|
'onclick="openDeviceCreds(\'' + escapeHtml(s.id) + '\',\'' + escapeHtml(d.ip) + '\',\'' + escapeHtml(d.name || d.ip) + '\')" ' +
|
||||||
|
'style="padding:1px 6px;font-size:11px;margin-left:8px;">' + (hasCreds ? '🔑 ✓' : '🔑') + '</button>' : '';
|
||||||
|
return '<div style="padding:3px 6px;background:#0D0D1A;border-radius:4px;display:flex;align-items:center;">' +
|
||||||
|
'<div style="flex:1;min-width:0;">' +
|
||||||
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
'<span style="color:#E0E0F0;">' + escapeHtml(d.name || d.id || '?') + '</span> ' +
|
||||||
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
'<span style="color:#0096FF;">[' + escapeHtml(d.type || '?') + ']</span> ' +
|
||||||
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
'<span style="color:#8888AA;">' + escapeHtml(d.ip || '') + '</span>' + tagStr +
|
||||||
|
'</div>' + keyBtn +
|
||||||
'</div>';
|
'</div>';
|
||||||
}).join('') + '</div>';
|
}).join('') + '</div>';
|
||||||
}
|
}
|
||||||
@@ -4519,6 +4614,52 @@
|
|||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Geraete-Credentials ────────────────────────────────
|
||||||
|
function dcToggleV3() {
|
||||||
|
const v = document.getElementById('dc-snmp-version').value;
|
||||||
|
document.getElementById('dc-snmp-v3').style.display = (v === '3') ? 'block' : 'none';
|
||||||
|
document.getElementById('dc-snmp-community-row').style.display = (v === '1' || v === '2c') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
function openDeviceCreds(satId, ip, name) {
|
||||||
|
credsModalSat = satId; credsModalIp = ip;
|
||||||
|
document.getElementById('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')';
|
||||||
|
// Felder leeren — Secrets werden NIE zurueckgeliefert, nur neu gesetzt.
|
||||||
|
['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass'].forEach(id => document.getElementById(id).value = '');
|
||||||
|
document.getElementById('dc-snmp-version').value = '';
|
||||||
|
dcToggleV3();
|
||||||
|
const set = ((satCreds[satId] || {})[ip]) || [];
|
||||||
|
document.getElementById('dc-status').textContent = set.length ? ('Bereits hinterlegt: ' + set.join(', ')) : 'Noch keine Zugangsdaten hinterlegt.';
|
||||||
|
document.getElementById('dev-creds-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
function closeDeviceCreds() { document.getElementById('dev-creds-modal').style.display = 'none'; }
|
||||||
|
function saveDeviceCreds() {
|
||||||
|
const v = document.getElementById('dc-snmp-version').value;
|
||||||
|
const creds = {};
|
||||||
|
if (v === '3') {
|
||||||
|
const user = document.getElementById('dc-v3-user').value.trim();
|
||||||
|
if (user) creds.snmp = { version: '3', v3: {
|
||||||
|
user, level: document.getElementById('dc-v3-level').value,
|
||||||
|
authProto: document.getElementById('dc-v3-authproto').value,
|
||||||
|
authKey: document.getElementById('dc-v3-authkey').value,
|
||||||
|
privProto: document.getElementById('dc-v3-privproto').value,
|
||||||
|
privKey: document.getElementById('dc-v3-privkey').value } };
|
||||||
|
} else if (v === '1' || v === '2c') {
|
||||||
|
const c = document.getElementById('dc-snmp-community').value.trim();
|
||||||
|
if (c) creds.snmp = { version: v, community: c };
|
||||||
|
}
|
||||||
|
const hu = document.getElementById('dc-http-user').value.trim();
|
||||||
|
if (hu) creds.http = { user: hu, pass: document.getElementById('dc-http-pass').value };
|
||||||
|
const fu = document.getElementById('dc-fb-user').value.trim();
|
||||||
|
if (fu) creds.fritzbox = { user: fu, pass: document.getElementById('dc-fb-pass').value };
|
||||||
|
if (!Object.keys(creds).length) { document.getElementById('dc-status').textContent = 'Nichts zu speichern — mindestens einen Typ ausfuellen.'; return; }
|
||||||
|
send({ action: 'sat_creds_set', satellite: credsModalSat, ip: credsModalIp, creds });
|
||||||
|
document.getElementById('dc-status').textContent = 'Speichere …';
|
||||||
|
}
|
||||||
|
function deleteDeviceCreds() {
|
||||||
|
send({ action: 'sat_creds_delete', satellite: credsModalSat, ip: credsModalIp });
|
||||||
|
document.getElementById('dc-status').textContent = 'Loesche …';
|
||||||
|
}
|
||||||
|
|
||||||
// ── Triggers-Verwaltung ────────────────────────────────
|
// ── Triggers-Verwaltung ────────────────────────────────
|
||||||
let triggersCache = [];
|
let triggersCache = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1071,6 +1071,9 @@ function connectRVS(forcePlain) {
|
|||||||
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
if (p.satellite && satellites.has(p.satellite)) satellites.get(p.satellite).last_seen = Date.now();
|
||||||
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
broadcast({ type: "sat_devices", satellite: p.satellite || "",
|
||||||
location: p.location || "", devices: p.devices || [] });
|
location: p.location || "", devices: p.devices || [] });
|
||||||
|
} else if (msg.type === "sat_creds_list_result" || msg.type === "sat_creds_result") {
|
||||||
|
// Credential-Store-Antworten eines Satelliten → an den Browser.
|
||||||
|
broadcast({ type: msg.type, payload: msg.payload || {} });
|
||||||
} else if (msg.type === "worker_hello") {
|
} else if (msg.type === "worker_hello") {
|
||||||
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
// Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet.
|
||||||
const p = msg.payload || {};
|
const p = msg.payload || {};
|
||||||
@@ -2759,6 +2762,20 @@ wss.on("connection", (ws) => {
|
|||||||
sendToRVS_raw({ type: "sat_discover",
|
sendToRVS_raw({ type: "sat_discover",
|
||||||
payload: { satellite: msg.satellite || "", force: true },
|
payload: { satellite: msg.satellite || "", force: true },
|
||||||
timestamp: Date.now() });
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_list") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_list",
|
||||||
|
payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_set") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_set",
|
||||||
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
||||||
|
creds: msg.creds || {}, requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
|
} else if (msg.action === "sat_creds_delete") {
|
||||||
|
sendToRVS_raw({ type: "sat_creds_delete",
|
||||||
|
payload: { satellite: msg.satellite || "", ip: msg.ip || "",
|
||||||
|
type: msg.credType || "", requestId: "dc_" + Date.now() },
|
||||||
|
timestamp: Date.now() });
|
||||||
} else if (msg.action === "test_proxy") {
|
} else if (msg.action === "test_proxy") {
|
||||||
testProxy(msg.text);
|
testProxy(msg.text);
|
||||||
} else if (msg.action === "check_proxy_auth") {
|
} else if (msg.action === "check_proxy_auth") {
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
// sat_hello, liefern Geraete-Inventar (sat_devices) auf sat_discover und
|
||||||
// fuehren Aktionen aus (sat_command → sat_result).
|
// fuehren Aktionen aus (sat_command → sat_result).
|
||||||
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
"sat_hello", "sat_discover", "sat_devices", "sat_command", "sat_result",
|
||||||
|
// Satelliten-Credential-Store: Diagnostic legt pro Geraet Zugangsdaten ab
|
||||||
|
// (SNMP/HTTP/FritzBox), der Satellit speichert sie verschluesselt.
|
||||||
|
"sat_creds_set", "sat_creds_delete", "sat_creds_list",
|
||||||
|
"sat_creds_result", "sat_creds_list_result",
|
||||||
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
|
// Compute-Flotte (AI-Boxen): Worker (f5tts/whisper/voxtral/llm-adapter) melden
|
||||||
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
|
// sich per worker_hello an und pingen per worker_ping; der Diagnostic-Server
|
||||||
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
|
// aggregiert das und broadcastet worker_update/worker_list an die Browser-UI.
|
||||||
|
|||||||
+12
-1
@@ -29,7 +29,18 @@ CONTROL_ENABLED=true
|
|||||||
# snmp.get einzelner SNMP-Wert (params: ip, oid)
|
# snmp.get einzelner SNMP-Wert (params: ip, oid)
|
||||||
# snmp.walk SNMP-Teilbaum (params: ip, oid)
|
# snmp.walk SNMP-Teilbaum (params: ip, oid)
|
||||||
# snmp.printer Drucker-Fuellstaende (Tinte/Toner) aus der Printer-MIB (params: ip)
|
# snmp.printer Drucker-Fuellstaende (Tinte/Toner) aus der Printer-MIB (params: ip)
|
||||||
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer
|
# snmp.ports Switch/Router-Interfaces: aktive/freie Ports (params: ip)
|
||||||
|
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||||
|
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||||
|
# fritzbox.hosts FritzBox: verbundene Geraete (TR-064, braucht Login)
|
||||||
|
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts
|
||||||
|
|
||||||
|
# ─── Credential-Store (optional) ───────────────────────────────────
|
||||||
|
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
|
||||||
|
# v3, HTTP-Basic, FritzBox-Login). Der Satellit speichert sie VERSCHLUESSELT im
|
||||||
|
# Bind-Volume ./data. Der Schluessel wird beim ersten Start automatisch erzeugt
|
||||||
|
# (./data/creds.key) — oder hier fest vorgeben (Fernet-Key, base64):
|
||||||
|
# CREDS_KEY=
|
||||||
|
|
||||||
# ─── SNMP (optional) ───────────────────────────────────────────────
|
# ─── SNMP (optional) ───────────────────────────────────────────────
|
||||||
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
|
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
# Verschluesselter Credential-Store + Schluessel (nie einchecken!)
|
||||||
|
data/
|
||||||
@@ -21,3 +21,6 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
env_file: .env
|
env_file: .env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
|
||||||
|
- ./data:/data
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
websockets>=12.0
|
websockets>=12.0
|
||||||
zeroconf>=0.131.0
|
zeroconf>=0.131.0
|
||||||
requests>=2.31.0
|
requests>=2.31.0
|
||||||
|
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
|
||||||
|
|||||||
+389
-12
@@ -114,7 +114,8 @@ CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
|
|||||||
CONTROL_ALLOWLIST = [
|
CONTROL_ALLOWLIST = [
|
||||||
a.strip() for a in
|
a.strip() for a in
|
||||||
os.environ.get("CONTROL_ALLOWLIST",
|
os.environ.get("CONTROL_ALLOWLIST",
|
||||||
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer").split(",")
|
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
|
||||||
|
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts").split(",")
|
||||||
if a.strip()
|
if a.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -156,6 +157,80 @@ SNMP_SYS_OIDS = {
|
|||||||
"location": "1.3.6.1.2.1.1.6.0", # sysLocation
|
"location": "1.3.6.1.2.1.1.6.0", # sysLocation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── Geraete-Credential-Store (verschluesselt, pro IP) ─────────────
|
||||||
|
# Diagnostic legt via sat_creds_set pro Geraet Zugangsdaten ab (SNMP-Community/
|
||||||
|
# v3, HTTP-Basic, FritzBox-Login). Der Satellit nutzt sie automatisch bei snmp.*/
|
||||||
|
# http/fritzbox. Persistiert verschluesselt (Fernet) in einem Bind-Volume.
|
||||||
|
CREDS_PATH = os.environ.get("CREDS_PATH", "/data/credentials.json.enc")
|
||||||
|
CREDS_KEY_PATH = os.environ.get("CREDS_KEY_PATH", "/data/creds.key")
|
||||||
|
_CREDS: dict = {} # {ip: {snmp:{...}, http:{...}, fritzbox:{...}}}
|
||||||
|
_creds_fernet = None # Fernet-Instanz (lazy)
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_cipher():
|
||||||
|
"""Fernet-Instanz; Schluessel aus CREDS_KEY (env) oder Schluesseldatei im
|
||||||
|
Volume (wird beim ersten Start erzeugt, 0600)."""
|
||||||
|
global _creds_fernet
|
||||||
|
if _creds_fernet is not None:
|
||||||
|
return _creds_fernet
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = os.environ.get("CREDS_KEY", "").strip().encode() or None
|
||||||
|
if not key:
|
||||||
|
kp = Path(CREDS_KEY_PATH)
|
||||||
|
if kp.exists():
|
||||||
|
key = kp.read_bytes().strip()
|
||||||
|
else:
|
||||||
|
key = Fernet.generate_key()
|
||||||
|
kp.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
kp.write_bytes(key)
|
||||||
|
try:
|
||||||
|
os.chmod(kp, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
logger.info("[creds] neuer Verschluesselungs-Schluessel erzeugt: %s", CREDS_KEY_PATH)
|
||||||
|
_creds_fernet = Fernet(key)
|
||||||
|
return _creds_fernet
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_load() -> None:
|
||||||
|
global _CREDS
|
||||||
|
p = Path(CREDS_PATH)
|
||||||
|
if not p.exists():
|
||||||
|
_CREDS = {}
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
blob = p.read_bytes()
|
||||||
|
raw = _creds_cipher().decrypt(blob)
|
||||||
|
_CREDS = json.loads(raw.decode("utf-8")) or {}
|
||||||
|
logger.info("[creds] %d Geraete-Eintraege geladen", len(_CREDS))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] laden fehlgeschlagen (%s) — starte leer", exc)
|
||||||
|
_CREDS = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_save() -> None:
|
||||||
|
p = Path(CREDS_PATH)
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
blob = _creds_cipher().encrypt(json.dumps(_CREDS).encode("utf-8"))
|
||||||
|
p.write_bytes(blob)
|
||||||
|
try:
|
||||||
|
os.chmod(p, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_for(ip: str) -> dict:
|
||||||
|
return _CREDS.get((ip or "").strip(), {}) if ip else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _creds_public_summary() -> list:
|
||||||
|
"""Fuer sat_creds_list: welche Geraete welche Cred-Typen haben — OHNE Secrets."""
|
||||||
|
out = []
|
||||||
|
for ip, entry in sorted(_CREDS.items()):
|
||||||
|
types = [t for t in ("snmp", "http", "fritzbox") if entry.get(t)]
|
||||||
|
out.append({"ip": ip, "types": types})
|
||||||
|
return out
|
||||||
|
|
||||||
HEARTBEAT_SEC = 25
|
HEARTBEAT_SEC = 25
|
||||||
|
|
||||||
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
# mDNS-Servicetypen, die fuer ARIA interessant sind.
|
||||||
@@ -459,6 +534,12 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
|||||||
return await loop.run_in_executor(None, _do_snmp, action, params)
|
return await loop.run_in_executor(None, _do_snmp, action, params)
|
||||||
if action == "snmp.printer":
|
if action == "snmp.printer":
|
||||||
return await loop.run_in_executor(None, _do_snmp_printer, params)
|
return await loop.run_in_executor(None, _do_snmp_printer, params)
|
||||||
|
if action == "snmp.ports":
|
||||||
|
return await loop.run_in_executor(None, _do_snmp_ports, params)
|
||||||
|
if action == "snmp.info":
|
||||||
|
return await loop.run_in_executor(None, _do_snmp_info, params)
|
||||||
|
if action in ("fritzbox.info", "fritzbox.hosts"):
|
||||||
|
return await loop.run_in_executor(None, _do_fritzbox, action, params)
|
||||||
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
return {"ok": False, "error": f"Aktion '{action}' nicht implementiert."}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
return {"ok": False, "error": f"{action} fehlgeschlagen: {exc}"}
|
||||||
@@ -529,8 +610,22 @@ def _do_http(action: str, params: dict) -> dict:
|
|||||||
if not url.startswith(("http://", "https://")):
|
if not url.startswith(("http://", "https://")):
|
||||||
return {"ok": False, "error": "url (http/https) erforderlich."}
|
return {"ok": False, "error": "url (http/https) erforderlich."}
|
||||||
method = "GET" if action == "http.get" else "POST"
|
method = "GET" if action == "http.get" else "POST"
|
||||||
|
# HTTP-Basic-Auth: explizite params > gespeicherte http-Creds fuer den Host.
|
||||||
|
auth = None
|
||||||
|
hcreds = {}
|
||||||
|
try:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
host = urlparse(url).hostname or ""
|
||||||
|
hcreds = _creds_for(host).get("http", {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
user = params.get("user") or hcreds.get("user")
|
||||||
|
pw = params.get("pass") or params.get("password") or hcreds.get("pass")
|
||||||
|
if user:
|
||||||
|
auth = (str(user), str(pw or ""))
|
||||||
r = requests.request(method, url, data=params.get("body"),
|
r = requests.request(method, url, data=params.get("body"),
|
||||||
headers=params.get("headers"), timeout=HTTP_TIMEOUT_SEC)
|
headers=params.get("headers"), auth=auth,
|
||||||
|
timeout=HTTP_TIMEOUT_SEC)
|
||||||
text = r.text
|
text = r.text
|
||||||
total = len(text)
|
total = len(text)
|
||||||
|
|
||||||
@@ -581,11 +676,27 @@ def _snmp_run(args: list, timeout: float) -> tuple:
|
|||||||
return True, r.stdout
|
return True, r.stdout
|
||||||
|
|
||||||
|
|
||||||
def _snmp_base_args(params: dict) -> list:
|
def _snmp_base_args(params: dict, ip: str = "") -> list:
|
||||||
community = str(params.get("community") or SNMP_COMMUNITY)
|
"""Version/Community bzw. v3-Auth. Prioritaet: explizite params > gespeicherte
|
||||||
version = str(params.get("version") or SNMP_VERSION)
|
Creds fuer die IP > globale Defaults. OHNE -t/-r (haengt der Aufrufer an)."""
|
||||||
# -t Timeout(s), -r Retries: schnell scheitern statt haengen.
|
creds = _creds_for(ip).get("snmp", {}) if ip else {}
|
||||||
return ["-v", version, "-c", community, "-t", "2", "-r", "1"]
|
version = str(params.get("version") or creds.get("version") or SNMP_VERSION)
|
||||||
|
if version == "3":
|
||||||
|
v3 = creds.get("v3", {}) or {}
|
||||||
|
user = str(params.get("user") or v3.get("user") or "")
|
||||||
|
level = str(params.get("level") or v3.get("level") or "authPriv")
|
||||||
|
args = ["-v", "3", "-u", user, "-l", level]
|
||||||
|
ap = params.get("authProto") or v3.get("authProto")
|
||||||
|
ak = params.get("authKey") or v3.get("authKey")
|
||||||
|
pp = params.get("privProto") or v3.get("privProto")
|
||||||
|
pk = params.get("privKey") or v3.get("privKey")
|
||||||
|
if ap and ak:
|
||||||
|
args += ["-a", str(ap), "-A", str(ak)]
|
||||||
|
if pp and pk:
|
||||||
|
args += ["-x", str(pp), "-X", str(pk)]
|
||||||
|
return args
|
||||||
|
community = str(params.get("community") or creds.get("community") or SNMP_COMMUNITY)
|
||||||
|
return ["-v", version, "-c", community]
|
||||||
|
|
||||||
|
|
||||||
def _snmp_target(params: dict) -> str:
|
def _snmp_target(params: dict) -> str:
|
||||||
@@ -603,7 +714,7 @@ def _do_snmp(action: str, params: dict) -> dict:
|
|||||||
return {"ok": False, "error": "oid erforderlich (z.B. 1.3.6.1.2.1.1.5.0 fuer sysName)."}
|
return {"ok": False, "error": "oid erforderlich (z.B. 1.3.6.1.2.1.1.5.0 fuer sysName)."}
|
||||||
tool = "snmpwalk" if action == "snmp.walk" else "snmpget"
|
tool = "snmpwalk" if action == "snmp.walk" else "snmpget"
|
||||||
# -OQ: OID = Wert, ohne Typannotation; numerische OIDs brauchen keine MIB-Files.
|
# -OQ: OID = Wert, ohne Typannotation; numerische OIDs brauchen keine MIB-Files.
|
||||||
args = [tool, "-OQ", *_snmp_base_args(params), ip, oid]
|
args = [tool, "-OQ", *_snmp_base_args(params, ip), "-t", "2", "-r", "1", ip, oid]
|
||||||
ok, out = _snmp_run(args, SNMP_TIMEOUT_SEC)
|
ok, out = _snmp_run(args, SNMP_TIMEOUT_SEC)
|
||||||
if not ok:
|
if not ok:
|
||||||
return {"ok": False, "error": out}
|
return {"ok": False, "error": out}
|
||||||
@@ -625,7 +736,7 @@ def _do_snmp_printer(params: dict) -> dict:
|
|||||||
ip = _snmp_target(params)
|
ip = _snmp_target(params)
|
||||||
if not ip:
|
if not ip:
|
||||||
return {"ok": False, "error": "ip/host erforderlich."}
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
base = _snmp_base_args(params)
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
descs = _snmp_walk_values(ip, base, SNMP_SUPPLY_DESC)
|
descs = _snmp_walk_values(ip, base, SNMP_SUPPLY_DESC)
|
||||||
if not descs:
|
if not descs:
|
||||||
return {"ok": False, "error":
|
return {"ok": False, "error":
|
||||||
@@ -648,6 +759,209 @@ def _do_snmp_printer(params: dict) -> dict:
|
|||||||
return {"ok": True, "result": {"ip": ip, "supplies": supplies}}
|
return {"ok": True, "result": {"ip": ip, "supplies": supplies}}
|
||||||
|
|
||||||
|
|
||||||
|
# ifTable (RFC 1213) Spalten:
|
||||||
|
_IF_DESCR = "1.3.6.1.2.1.2.2.1.2"
|
||||||
|
_IF_TYPE = "1.3.6.1.2.1.2.2.1.3"
|
||||||
|
_IF_SPEED = "1.3.6.1.2.1.2.2.1.5"
|
||||||
|
_IF_ADMIN = "1.3.6.1.2.1.2.2.1.7" # up(1) down(2)
|
||||||
|
_IF_OPER = "1.3.6.1.2.1.2.2.1.8" # up(1) down(2) ...
|
||||||
|
_IF_ALIAS = "1.3.6.1.2.1.31.1.1.1.18" # ifAlias (ifXTable, optional)
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp_ports(params: dict) -> dict:
|
||||||
|
"""Interface-Uebersicht eines Switches/Routers: welche Ports sind aktiv (Link),
|
||||||
|
welche frei. params: {ip|host, community?/v3?}. ethernetCsmacd(6)=echte Ports;
|
||||||
|
Loopback/VLAN etc. werden als 'other' markiert, nicht als freier Port gezaehlt."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
|
descr = _snmp_walk_values(ip, base, _IF_DESCR)
|
||||||
|
if not descr:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"Keine Interface-Daten (kein SNMP / falsche Credentials / kein Switch)."}
|
||||||
|
types = _snmp_walk_values(ip, base, _IF_TYPE)
|
||||||
|
opers = _snmp_walk_values(ip, base, _IF_OPER)
|
||||||
|
admins = _snmp_walk_values(ip, base, _IF_ADMIN)
|
||||||
|
speeds = _snmp_walk_values(ip, base, _IF_SPEED)
|
||||||
|
aliases = _snmp_walk_values(ip, base, _IF_ALIAS)
|
||||||
|
ports = []
|
||||||
|
up = down_free = disabled = 0
|
||||||
|
for i, name in enumerate(descr):
|
||||||
|
itype = _to_int(types[i]) if i < len(types) else None
|
||||||
|
oper = _to_int(opers[i]) if i < len(opers) else None
|
||||||
|
admin = _to_int(admins[i]) if i < len(admins) else None
|
||||||
|
speed = _to_int(speeds[i]) if i < len(speeds) else None
|
||||||
|
is_eth = (itype == 6) # ethernetCsmacd
|
||||||
|
state = ("up" if oper == 1 else
|
||||||
|
"disabled" if admin == 2 else "down")
|
||||||
|
if is_eth:
|
||||||
|
if state == "up":
|
||||||
|
up += 1
|
||||||
|
elif state == "disabled":
|
||||||
|
disabled += 1
|
||||||
|
else:
|
||||||
|
down_free += 1
|
||||||
|
ports.append({
|
||||||
|
"name": name.strip('"'),
|
||||||
|
"alias": (aliases[i].strip('"') if i < len(aliases) else ""),
|
||||||
|
"physical": is_eth,
|
||||||
|
"state": state,
|
||||||
|
"speedMbps": round(speed / 1_000_000) if speed else None,
|
||||||
|
})
|
||||||
|
return {"ok": True, "result": {
|
||||||
|
"ip": ip,
|
||||||
|
"summary": {"physical_ports": up + down_free + disabled,
|
||||||
|
"up": up, "free": down_free, "disabled": disabled},
|
||||||
|
"ports": ports,
|
||||||
|
}}
|
||||||
|
|
||||||
|
|
||||||
|
# entPhysicalTable (RFC 4133) — Modell/Serie/Firmware:
|
||||||
|
_ENT_MODEL = "1.3.6.1.2.1.47.1.1.1.1.13" # entPhysicalModelName
|
||||||
|
_ENT_SERIAL = "1.3.6.1.2.1.47.1.1.1.1.11" # entPhysicalSerialNum
|
||||||
|
_ENT_SWREV = "1.3.6.1.2.1.47.1.1.1.1.10" # entPhysicalSoftwareRev
|
||||||
|
_ENT_FWREV = "1.3.6.1.2.1.47.1.1.1.1.9" # entPhysicalFirmwareRev
|
||||||
|
|
||||||
|
|
||||||
|
def _do_snmp_info(params: dict) -> dict:
|
||||||
|
"""Geraeteinfo: sysName/sysDescr + (falls vorhanden) Modell, Seriennummer,
|
||||||
|
Firmware-/Software-Version aus der Entity-MIB. Sagt die INSTALLIERTE Version —
|
||||||
|
ob ein Update existiert, weiss SNMP nicht (Hersteller-Sache)."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
sysinfo = _snmp_system(ip, str(params.get("community") or ""), str(params.get("version") or ""))
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "2", "-r", "1"]
|
||||||
|
|
||||||
|
def _first(oid):
|
||||||
|
vals = [v for v in _snmp_walk_values(ip, base, oid)
|
||||||
|
if v and "No Such" not in v]
|
||||||
|
return vals[0] if vals else None
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"ip": ip,
|
||||||
|
"name": (sysinfo or {}).get("name"),
|
||||||
|
"descr": (sysinfo or {}).get("descr"),
|
||||||
|
"location": (sysinfo or {}).get("location"),
|
||||||
|
"uptime": (sysinfo or {}).get("uptime"),
|
||||||
|
"model": _first(_ENT_MODEL),
|
||||||
|
"serial": _first(_ENT_SERIAL),
|
||||||
|
"firmware": _first(_ENT_FWREV) or _first(_ENT_SWREV),
|
||||||
|
}
|
||||||
|
if not any(result[k] for k in ("name", "descr", "model", "firmware")):
|
||||||
|
return {"ok": False, "error": "Kein SNMP / keine verwertbaren Infos."}
|
||||||
|
return {"ok": True, "result": result}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── FritzBox (TR-064) ─────────────────────────────────────────────
|
||||||
|
# TR-064 ist SOAP+Digest-Auth — zu fummelig fuer on-the-fly http.post, daher ein
|
||||||
|
# schlanker Reader. Braucht FritzBox-Login (Credential-Store, Typ 'fritzbox').
|
||||||
|
|
||||||
|
def _tr064(ip: str, user: str, pw: str, service: str, control: str,
|
||||||
|
action: str, args: Optional[dict] = None) -> dict:
|
||||||
|
"""Ein TR-064-SOAP-Call. Gibt {ok, fields|error}. fields = alle <NewX>-Tags."""
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
body = "".join(f"<{k}>{v}</{k}>" for k, v in (args or {}).items())
|
||||||
|
envelope = (
|
||||||
|
'<?xml version="1.0"?>'
|
||||||
|
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" '
|
||||||
|
's:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><s:Body>'
|
||||||
|
f'<u:{action} xmlns:u="{service}">{body}</u:{action}>'
|
||||||
|
'</s:Body></s:Envelope>'
|
||||||
|
)
|
||||||
|
url = f"http://{ip}:49000{control}"
|
||||||
|
try:
|
||||||
|
r = requests.post(url, data=envelope.encode("utf-8"),
|
||||||
|
headers={"Content-Type": 'text/xml; charset="utf-8"',
|
||||||
|
"SOAPAction": f"{service}#{action}"},
|
||||||
|
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"TR-064 nicht erreichbar: {exc}"}
|
||||||
|
if r.status_code == 401:
|
||||||
|
return {"ok": False, "error": "TR-064 Auth fehlgeschlagen (FritzBox-Login pruefen)."}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"ok": False, "error": f"TR-064 HTTP {r.status_code}"}
|
||||||
|
fields = {m.group(1): m.group(2) for m in
|
||||||
|
re.finditer(r"<(New[^>/]+)>(.*?)</\1>", r.text, re.DOTALL)}
|
||||||
|
return {"ok": True, "fields": fields}
|
||||||
|
|
||||||
|
|
||||||
|
def _do_fritzbox(action: str, params: dict) -> dict:
|
||||||
|
"""fritzbox.info -> Modell/Firmware/Verbindung/externe IP/Datenrate.
|
||||||
|
fritzbox.hosts -> Liste der bekannten Geraete (Name/IP/MAC/aktiv)."""
|
||||||
|
ip = _snmp_target(params)
|
||||||
|
if not ip:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
fb = _creds_for(ip).get("fritzbox", {})
|
||||||
|
user = str(params.get("user") or fb.get("user") or "")
|
||||||
|
pw = str(params.get("pass") or params.get("password") or fb.get("pass") or "")
|
||||||
|
if not pw:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"Kein FritzBox-Login hinterlegt. In der Geraeteliste Credentials "
|
||||||
|
"(Typ 'fritzbox') fuer diese IP setzen."}
|
||||||
|
|
||||||
|
if action == "fritzbox.hosts":
|
||||||
|
p = _tr064(ip, user, pw, "urn:dslforum-org:service:Hosts:1",
|
||||||
|
"/upnp/control/hosts", "X_AVM-DE_GetHostListPath")
|
||||||
|
if not p.get("ok"):
|
||||||
|
return p
|
||||||
|
path = p["fields"].get("NewX_AVM-DE_HostListPath", "")
|
||||||
|
if not path:
|
||||||
|
return {"ok": False, "error": "FritzBox lieferte keinen Host-Listen-Pfad."}
|
||||||
|
import requests
|
||||||
|
from requests.auth import HTTPDigestAuth
|
||||||
|
try:
|
||||||
|
r = requests.get(f"http://{ip}:49000{path}",
|
||||||
|
auth=HTTPDigestAuth(user, pw), timeout=HTTP_TIMEOUT_SEC)
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"Host-Liste nicht abrufbar: {exc}"}
|
||||||
|
hosts = []
|
||||||
|
for item in re.finditer(r"<Item>(.*?)</Item>", r.text, re.DOTALL):
|
||||||
|
blk = item.group(1)
|
||||||
|
|
||||||
|
def _t(tag):
|
||||||
|
m = re.search(rf"<{tag}>(.*?)</{tag}>", blk, re.DOTALL)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
hosts.append({"name": _t("HostName"), "ip": _t("IPAddress"),
|
||||||
|
"mac": _t("MACAddress"),
|
||||||
|
"active": _t("Active") in ("1", "true")})
|
||||||
|
return {"ok": True, "result": {"ip": ip, "count": len(hosts), "hosts": hosts}}
|
||||||
|
|
||||||
|
# fritzbox.info (Default): mehrere Services, Teil-Fehler tolerieren.
|
||||||
|
info = {"ip": ip}
|
||||||
|
dev = _tr064(ip, user, pw, "urn:dslforum-org:service:DeviceInfo:1",
|
||||||
|
"/upnp/control/deviceinfo", "GetInfo")
|
||||||
|
if dev.get("ok"):
|
||||||
|
f = dev["fields"]
|
||||||
|
info.update({"model": f.get("NewModelName"), "firmware": f.get("NewSoftwareVersion"),
|
||||||
|
"serial": f.get("NewSerialNumber"), "uptime_s": _to_int(f.get("NewUpTime"))})
|
||||||
|
st = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
|
||||||
|
"/upnp/control/wanipconnection1", "GetStatusInfo")
|
||||||
|
if st.get("ok"):
|
||||||
|
info["connection"] = st["fields"].get("NewConnectionStatus")
|
||||||
|
info["connection_uptime_s"] = _to_int(st["fields"].get("NewUptime"))
|
||||||
|
ext = _tr064(ip, user, pw, "urn:dslforum-org:service:WANIPConnection:1",
|
||||||
|
"/upnp/control/wanipconnection1", "GetExternalIPAddress")
|
||||||
|
if ext.get("ok"):
|
||||||
|
info["external_ip"] = ext["fields"].get("NewExternalIPAddress")
|
||||||
|
link = _tr064(ip, user, pw, "urn:dslforum-org:service:WANCommonInterfaceConfig:1",
|
||||||
|
"/upnp/control/wancommonifconfig1", "GetCommonLinkProperties")
|
||||||
|
if link.get("ok"):
|
||||||
|
f = link["fields"]
|
||||||
|
dn = _to_int(f.get("NewLayer1DownstreamMaxBitRate"))
|
||||||
|
upr = _to_int(f.get("NewLayer1UpstreamMaxBitRate"))
|
||||||
|
info["downstream_mbit"] = round(dn / 1_000_000, 1) if dn else None
|
||||||
|
info["upstream_mbit"] = round(upr / 1_000_000, 1) if upr else None
|
||||||
|
info["physical_link"] = f.get("NewPhysicalLinkStatus")
|
||||||
|
if len(info) == 1:
|
||||||
|
return {"ok": False, "error":
|
||||||
|
"FritzBox antwortet nicht auf TR-064 (Login/Rechte pruefen; TR-064 in "
|
||||||
|
"der FritzBox unter Heimnetz > Netzwerkeinstellungen aktivieren)."}
|
||||||
|
return {"ok": True, "result": info}
|
||||||
|
|
||||||
|
|
||||||
def _to_int(s: str):
|
def _to_int(s: str):
|
||||||
try:
|
try:
|
||||||
return int(str(s).strip())
|
return int(str(s).strip())
|
||||||
@@ -658,9 +972,14 @@ def _to_int(s: str):
|
|||||||
def _snmp_system(ip: str, community: str = "", version: str = "") -> Optional[dict]:
|
def _snmp_system(ip: str, community: str = "", version: str = "") -> Optional[dict]:
|
||||||
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
|
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
|
||||||
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
|
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
|
||||||
Kurzer Timeout, keine Retries -> Nicht-SNMP-Hosts scheitern schnell."""
|
Nutzt gespeicherte Creds fuer die IP; kurzer Timeout, keine Retries ->
|
||||||
base = ["-v", str(version or SNMP_VERSION), "-c", str(community or SNMP_COMMUNITY),
|
Nicht-SNMP-Hosts scheitern schnell."""
|
||||||
"-t", "1", "-r", "0"]
|
params = {}
|
||||||
|
if community:
|
||||||
|
params["community"] = community
|
||||||
|
if version:
|
||||||
|
params["version"] = version
|
||||||
|
base = [*_snmp_base_args(params, ip), "-t", "1", "-r", "0"]
|
||||||
keys = list(SNMP_SYS_OIDS.keys())
|
keys = list(SNMP_SYS_OIDS.keys())
|
||||||
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
|
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
|
||||||
SNMP_DISCOVERY_TIMEOUT)
|
SNMP_DISCOVERY_TIMEOUT)
|
||||||
@@ -850,6 +1169,63 @@ class Satellite:
|
|||||||
"timestamp": int(time.time() * 1000),
|
"timestamp": int(time.time() * 1000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_set":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
ip = (payload.get("ip") or "").strip()
|
||||||
|
creds = payload.get("creds") or {}
|
||||||
|
ok = False
|
||||||
|
if ip and isinstance(creds, dict):
|
||||||
|
entry = _CREDS.setdefault(ip, {})
|
||||||
|
for t in ("snmp", "http", "fritzbox"):
|
||||||
|
if t in creds:
|
||||||
|
if creds[t]: # leeres Objekt = Typ loeschen
|
||||||
|
entry[t] = creds[t]
|
||||||
|
else:
|
||||||
|
entry.pop(t, None)
|
||||||
|
if not entry:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
try:
|
||||||
|
_creds_save()
|
||||||
|
ok = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
|
||||||
|
await self._send({"type": "sat_creds_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID, "ok": ok, "ip": ip},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_delete":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
ip = (payload.get("ip") or "").strip()
|
||||||
|
ctype = (payload.get("type") or "").strip()
|
||||||
|
if ip in _CREDS:
|
||||||
|
if ctype:
|
||||||
|
_CREDS[ip].pop(ctype, None)
|
||||||
|
if not _CREDS[ip]:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
else:
|
||||||
|
_CREDS.pop(ip, None)
|
||||||
|
try:
|
||||||
|
_creds_save()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("[creds] speichern fehlgeschlagen: %s", exc)
|
||||||
|
await self._send({"type": "sat_creds_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID, "ok": True, "ip": ip},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
|
elif mtype == "sat_creds_list":
|
||||||
|
if not self._for_me(payload):
|
||||||
|
return
|
||||||
|
await self._send({"type": "sat_creds_list_result",
|
||||||
|
"payload": {"requestId": payload.get("requestId", ""),
|
||||||
|
"satellite": SATELLITE_ID,
|
||||||
|
"location": SATELLITE_LOCATION,
|
||||||
|
"items": _creds_public_summary()},
|
||||||
|
"timestamp": int(time.time() * 1000)})
|
||||||
|
|
||||||
async def _periodic_scan(self) -> None:
|
async def _periodic_scan(self) -> None:
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
@@ -871,6 +1247,7 @@ class Satellite:
|
|||||||
if not RVS_HOST or not RVS_TOKEN:
|
if not RVS_HOST or not RVS_TOKEN:
|
||||||
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
|
||||||
return
|
return
|
||||||
|
_creds_load()
|
||||||
asyncio.create_task(self._periodic_scan())
|
asyncio.create_task(self._periodic_scan())
|
||||||
backoff = 1
|
backoff = 1
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
Reference in New Issue
Block a user