feat(satellite): gespeicherte Zugangsdaten anzeigen, Passwort per Auge, FritzBox-Benutzer ermitteln

Das Zugangsdaten-Formular war beim erneuten Oeffnen leer, obwohl der
Satellit alles gespeichert hatte.

Satellit:
- sat_creds_list mit ip liefert je Typ die Felder ohne Geheimnisse
  (Benutzer, Port, SNMP-Version/v3-Stufe) und has_<feld> fuer Passwoerter;
  mit reveal (nur zusammen mit ip) auch die Geheimnisse dieses Geraets.
- sat_creds_set behaelt ein gespeichertes Geheimnis, wenn das Feld nicht
  mitgeschickt wird — Benutzer aendern, ohne das Passwort neu zu tippen.

Diagnostic-Server: Antworten mit Klartext (revealed) gehen nur an den
Browser, der gefragt hat, nicht an alle offenen Tabs.

Formular: Benutzer/Port/Version stehen beim Oeffnen drin, Passwoerter als
"•••••• gespeichert"; 👁 holt sie im Klartext bzw. schaltet um. Leeres
Passwortfeld = gespeichertes behalten. FritzBox: "Benutzer ermitteln"
(nur bei als FritzBox erkannten Geraeten) liest login_sid.lua?version=2 —
die Box nennt ihre Benutzer ohne Anmeldung; eingetragen wird der zuletzt
angemeldete.

Getestet: Satelliten-Logik per Unit-Test, Formular in headless Chromium
(Anzeige, Speichern ohne Passwort, Auge, Benutzer ermitteln).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 16:48:44 +01:00
co-authored by Claude Opus 5.5
parent 3a3ef81835
commit c934866bba
3 changed files with 181 additions and 31 deletions
+46 -6
View File
@@ -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_<feld>=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: