diff --git a/diagnostic/index.html b/diagnostic/index.html index d3d7375..be24ef1 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -286,7 +286,7 @@

🔑 Zugangsdaten: —

-

Wird verschluesselt auf dem Satelliten gespeichert. Leere Felder speichern nichts / loeschen den Typ. Der Satellit nutzt sie automatisch bei SNMP / HTTP / FritzBox.

+

Wird verschlüsselt auf dem Satelliten gespeichert. Gespeicherte Passwörter stehen als •••••• da — 👁 zeigt sie an. Ein leeres Passwortfeld lässt das gespeicherte Passwort unverändert. Der Satellit nutzt die Daten automatisch bei SNMP / HTTP / FritzBox / SSH.

SNMP
@@ -299,7 +299,7 @@
- +
@@ -322,13 +322,14 @@
HTTP-Basic
- +
FritzBox (TR-064)
- - +
+
+
@@ -337,8 +338,8 @@
- - +
+
@@ -2695,9 +2696,20 @@ if (msg.type === 'sat_creds_list_result') { const p = msg.payload || msg; const sat = p.satellite || ''; - const map = {}; + let map = {}; + if (p.ip) { + // Antwort fuer EIN Geraet (Formular) — Liste der anderen behalten + map = { ...(satCreds[sat] || {}) }; + delete map[p.ip]; + } (p.items || []).forEach(it => { map[it.ip] = it.types || []; }); satCreds[sat] = map; + const modalOpen = _dcEl('dev-creds-modal').style.display === 'flex'; + const it = (p.items || []).find(x => x.ip === credsModalIp); + if (modalOpen && sat === credsModalSat && it && it.detail && (p.revealed || !dcFilled)) { + dcFill(it.detail, !!p.revealed); + _dcEl('dc-status').textContent = 'Hinterlegt: ' + (it.types || []).join(', ') + (p.revealed ? ' — im Klartext angezeigt.' : ''); + } renderSatellites(); return; } @@ -6060,6 +6072,7 @@ requestId: apkRequestId, params: { ip: apkTarget.ip } }); } function onSatResult(p) { + if (p && dcFbReq && p.requestId === dcFbReq) { dcOnFritzUsers(p); return; } if (!apkRequestId || !p || p.requestId !== apkRequestId) return; const status = document.getElementById('apk-status'); const log = document.getElementById('apk-log'); @@ -6252,7 +6265,8 @@ 'style="padding:1px 6px;font-size:11px;margin-left:6px;">✎' : ''; const hasCreds = d.ip && (satCreds[s.id] || {})[d.ip] && (satCreds[s.id][d.ip] || []).length; const keyBtn = d.ip ? '' : ''; // APK ausrollen: bei ADB-faehigen Geraeten (Fire TV & Co.). const canAdb = d.ip && (d.adbCapable || ['firetv', 'android', 'android-adb', 'androidtv', 'dial'].includes(d.type)); @@ -6306,16 +6320,90 @@ 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) { + // Gespeichertes wird beim Oeffnen angezeigt: Benutzer/Port/Version im + // Klartext, Passwoerter nur als "gespeichert". Das Auge holt sie im Klartext + // (geht nur an diesen Browser, nicht an andere offene Diagnostic-Tabs). + const DC_SECRET_FIELDS = { + 'dc-snmp-community': ['snmp', 'community'], 'dc-v3-authkey': ['snmp', 'v3', 'authKey'], + 'dc-v3-privkey': ['snmp', 'v3', 'privKey'], 'dc-http-pass': ['http', 'pass'], + 'dc-fb-pass': ['fritzbox', 'pass'], 'dc-ssh-pass': ['ssh', 'pass'], 'dc-ssh-key': ['ssh', 'key'], + }; + let dcStored = {}, dcRevealed = false, dcFilled = false, dcFbReq = ''; + const _dcEl = id => document.getElementById(id); + function openDeviceCreds(satId, ip, name, isFritz) { 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','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => document.getElementById(id).value = ''); - document.getElementById('dc-snmp-version').value = ''; + dcStored = {}; dcRevealed = false; dcFilled = false; dcFbReq = ''; + _dcEl('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')'; + ['dc-snmp-community','dc-v3-user','dc-v3-authkey','dc-v3-privkey','dc-http-user','dc-http-pass','dc-fb-user','dc-fb-pass','dc-ssh-user','dc-ssh-port','dc-ssh-pass','dc-ssh-key'].forEach(id => { + const el = _dcEl(id); + el.value = ''; + if (el.dataset.ph == null) el.dataset.ph = el.placeholder; + el.placeholder = el.dataset.ph; + if (DC_SECRET_FIELDS[id] && el.tagName === 'INPUT') el.type = 'password'; + }); + _dcEl('dc-snmp-version').value = ''; dcToggleV3(); + _dcEl('dc-fb-detect').style.display = isFritz ? '' : 'none'; 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'; + _dcEl('dc-status').textContent = set.length ? ('Bereits hinterlegt: ' + set.join(', ') + ' — lade …') : 'Noch keine Zugangsdaten hinterlegt.'; + _dcEl('dev-creds-modal').style.display = 'flex'; + if (set.length) send({ action: 'sat_creds_list', satellite: satId, ip }); + } + function dcFill(detail, revealed) { + const g = path => path.reduce((o, k) => (o ? o[k] : undefined), detail || {}); + if (!dcFilled) { + const sn = detail.snmp || {}; + if (sn.version) { _dcEl('dc-snmp-version').value = sn.version; dcToggleV3(); } + const v3 = sn.v3 || {}; + if (v3.user) _dcEl('dc-v3-user').value = v3.user; + if (v3.level) _dcEl('dc-v3-level').value = v3.level; + if (v3.authProto) _dcEl('dc-v3-authproto').value = v3.authProto; + if (v3.privProto) _dcEl('dc-v3-privproto').value = v3.privProto; + _dcEl('dc-http-user').value = (detail.http || {}).user || ''; + _dcEl('dc-fb-user').value = (detail.fritzbox || {}).user || ''; + _dcEl('dc-ssh-user').value = (detail.ssh || {}).user || ''; + _dcEl('dc-ssh-port').value = (detail.ssh || {}).port || ''; + } + for (const [id, path] of Object.entries(DC_SECRET_FIELDS)) { + const has = !!g([...path.slice(0, -1), 'has_' + path[path.length - 1]]); + const el = _dcEl(id); + dcStored[id] = has; + if (!has) continue; + if (revealed) { el.value = g(path) || ''; if (el.tagName === 'INPUT') el.type = 'text'; } + else if (!el.value) el.placeholder = '•••••• gespeichert'; + } + dcFilled = true; + if (revealed) dcRevealed = true; + } + function dcEye(id) { + const el = _dcEl(id); + if (dcStored[id] && !dcRevealed && !el.value) { + _dcEl('dc-status').textContent = 'Hole gespeicherte Zugangsdaten …'; + send({ action: 'sat_creds_list', satellite: credsModalSat, ip: credsModalIp, reveal: true }); + return; + } + if (el.tagName === 'INPUT') el.type = el.type === 'password' ? 'text' : 'password'; + } + // FritzBox: login_sid.lua listet die Benutzer auch ohne Anmeldung. Wer sich + // im Browser nur mit Passwort anmeldet, hat trotzdem einen (fritz1234). + function dcDetectFritzUser() { + dcFbReq = 'fb_' + Date.now(); + _dcEl('dc-status').textContent = 'Frage die FritzBox nach ihren Benutzern …'; + send({ action: 'sat_command', satellite: credsModalSat, cmd: 'http.get', requestId: dcFbReq, + params: { url: 'http://' + credsModalIp + '/login_sid.lua?version=2' } }); + } + function dcOnFritzUsers(p) { + dcFbReq = ''; + const body = String(((p.result || {}).body) || ''); + const users = [...body.matchAll(/]*)>([^<]+)<\/User>/g)].map(m => ({ name: m[2].trim(), last: /last="1"/.test(m[1]) })); + const st = _dcEl('dc-status'); + if (!p.ok) { st.textContent = '⚠ FritzBox nicht erreichbar: ' + (p.error || 'Fehler'); return; } + if (!users.length) { st.textContent = 'Die Box nennt keine Benutzer — dann reicht das Passwort, Benutzer leer lassen.'; return; } + const pick = users.find(u => u.last) || users[0]; + _dcEl('dc-fb-user').value = pick.name; + st.textContent = users.length > 1 + ? `Benutzer auf der Box: ${users.map(u => u.name).join(', ')} — eingetragen: ${pick.name} (zuletzt angemeldet).` + : `Benutzer ermittelt: ${pick.name}`; } function closeDeviceCreds() { document.getElementById('dev-creds-modal').style.display = 'none'; } function saveDeviceCreds() { @@ -6332,21 +6420,26 @@ } else if (v === '1' || v === '2c') { const c = document.getElementById('dc-snmp-community').value.trim(); if (c) creds.snmp = { version: v, community: c }; + else if (dcStored['dc-snmp-community']) creds.snmp = { version: v }; // Community bleibt } + if (creds.snmp && creds.snmp.v3) { // leere Schluesselfelder = gespeicherte behalten + if (!creds.snmp.v3.authKey) delete creds.snmp.v3.authKey; + if (!creds.snmp.v3.privKey) delete creds.snmp.v3.privKey; + } + // Leeres Passwortfeld = gespeichertes Passwort behalten (Satellit fuellt es auf). // HTTP-Basic: Benutzer optional (manche Geraete nutzen Password-only). const hu = document.getElementById('dc-http-user').value.trim(); const hp = document.getElementById('dc-http-pass').value; - if (hu || hp) creds.http = { user: hu, pass: hp }; - // FritzBox: Benutzer optional (Password-only-Boxen haben keinen) — es reicht - // ein Passwort. Gespeichert wird, sobald User ODER Passwort gesetzt ist. + if (hu || hp || dcStored['dc-http-pass']) { creds.http = { user: hu }; if (hp) creds.http.pass = hp; } + // FritzBox: Benutzer optional — es reicht ein Passwort. const fu = document.getElementById('dc-fb-user').value.trim(); const fp = document.getElementById('dc-fb-pass').value; - if (fu || fp) creds.fritzbox = { user: fu, pass: fp }; + if (fu || fp || dcStored['dc-fb-pass']) { creds.fritzbox = { user: fu }; if (fp) creds.fritzbox.pass = fp; } // SSH: Benutzer + (Passwort ODER Key). Port optional. const su = document.getElementById('dc-ssh-user').value.trim(); const sp = document.getElementById('dc-ssh-pass').value; const sk = document.getElementById('dc-ssh-key').value.trim(); - if (su && (sp || sk)) { + if (su && (sp || sk || dcStored['dc-ssh-pass'] || dcStored['dc-ssh-key'])) { creds.ssh = { user: su }; if (sp) creds.ssh.pass = sp; if (sk) creds.ssh.key = sk; diff --git a/diagnostic/server.js b/diagnostic/server.js index 7f25507..04777aa 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -836,6 +836,9 @@ function satelliteList() { })); } +// requestId → Browser-Socket, der Klartext-Zugangsdaten angefordert hat +const credsRevealWaiters = new Map(); + function broadcastSatellites() { broadcast({ type: "sat_update", satellites: satelliteList() }); } @@ -1425,7 +1428,16 @@ function connectRVS(forcePlain) { 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 || {} }); + // Mit Klartext-Passwoertern (Auge im Formular) NUR an den Browser, der + // gefragt hat — nie an alle offenen Diagnostic-Tabs. + const p = msg.payload || {}; + if (p.revealed) { + const asker = credsRevealWaiters.get(p.requestId); + credsRevealWaiters.delete(p.requestId); + if (asker && asker.readyState === WebSocket.OPEN) asker.send(JSON.stringify({ type: msg.type, payload: p })); + } else { + broadcast({ type: msg.type, payload: p }); + } } else if (msg.type === "worker_hello") { // Ein Compute-Worker (GPU-Dienst) meldet sich mit seiner Identitaet. const p = msg.payload || {}; @@ -3411,8 +3423,13 @@ wss.on("connection", (ws) => { requestId: "dn_" + Date.now() }, timestamp: Date.now() }); } else if (msg.action === "sat_creds_list") { + const requestId = "dc_" + Date.now() + "_" + Math.random().toString(36).slice(2, 8); + if (msg.reveal) { + credsRevealWaiters.set(requestId, ws); + setTimeout(() => credsRevealWaiters.delete(requestId), 30000); + } sendToRVS_raw({ type: "sat_creds_list", - payload: { satellite: msg.satellite || "", requestId: "dc_" + Date.now() }, + payload: { satellite: msg.satellite || "", ip: msg.ip || "", reveal: !!msg.reveal, requestId }, timestamp: Date.now() }); } else if (msg.action === "sat_creds_set") { sendToRVS_raw({ type: "sat_creds_set", diff --git a/satellite/satellite.py b/satellite/satellite.py index 19c6b8b..850bfbf 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -290,12 +290,49 @@ 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.""" +_CRED_SECRETS = ("pass", "key", "community", "authKey", "privKey") + + +def _creds_detail(d: dict, reveal: bool) -> dict: + """Ein Cred-Eintrag fuer die Diagnostic: Benutzer, Port, Version … immer; + Geheimes nur als has_=True — ausser reveal (Auge im Formular).""" + out = {} + for k, v in (d or {}).items(): + if isinstance(v, dict): + out[k] = _creds_detail(v, reveal) + elif k in _CRED_SECRETS: + out["has_" + k] = bool(v) + if reveal: + out[k] = v + else: + out[k] = v + return out + + +def _creds_keep_secrets(new: dict, old: dict) -> dict: + """Beim Speichern: fehlt ein Geheimnis im neuen Eintrag (Feld unangetastet), + bleibt das alte. So muss man ein Passwort nicht neu tippen, um nur den + Benutzer zu aendern.""" + out = dict(new) + for k, v in (old or {}).items(): + if isinstance(v, dict) and isinstance(out.get(k), dict): + out[k] = _creds_keep_secrets(out[k], v) + elif k in _CRED_SECRETS and k not in out: + out[k] = v + return out + + +def _creds_public_summary(ip: str = "", reveal: bool = False) -> list: + """Fuer sat_creds_list: welche Geraete welche Cred-Typen haben, dazu die + Felder OHNE Secrets (Benutzer, Port, SNMP-Version). Mit ip + reveal auch + die Secrets dieses einen Geraets.""" out = [] - for ip, entry in sorted(_CREDS.items()): + for dev_ip, entry in sorted(_CREDS.items()): + if ip and dev_ip != ip: + continue types = [t for t in ("snmp", "http", "fritzbox", "ssh") if entry.get(t)] - out.append({"ip": ip, "types": types}) + out.append({"ip": dev_ip, "types": types, + "detail": {t: _creds_detail(entry[t], reveal and bool(ip)) for t in types}}) return out HEARTBEAT_SEC = 25 @@ -2150,7 +2187,7 @@ class Satellite: for t in ("snmp", "http", "fritzbox", "ssh"): if t in creds: if creds[t]: # leeres Objekt = Typ loeschen - entry[t] = creds[t] + entry[t] = _creds_keep_secrets(creds[t], entry.get(t) or {}) else: entry.pop(t, None) if not entry: @@ -2189,11 +2226,14 @@ class Satellite: elif mtype == "sat_creds_list": if not self._for_me(payload): return + ip = (payload.get("ip") or "").strip() + reveal = bool(payload.get("reveal")) and bool(ip) await self._send({"type": "sat_creds_list_result", "payload": {"requestId": payload.get("requestId", ""), "satellite": SATELLITE_ID, "location": SATELLITE_LOCATION, - "items": _creds_public_summary()}, + "ip": ip, "revealed": reveal, + "items": _creds_public_summary(ip, reveal)}, "timestamp": int(time.time() * 1000)}) async def _periodic_scan(self) -> None: