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:
2026-09-19 17:16:06 +02:00
co-authored by Claude Opus 4.8
parent 22f5f5c954
commit 9f741df10e
9 changed files with 582 additions and 17 deletions
+12 -1
View File
@@ -29,7 +29,18 @@ CONTROL_ENABLED=true
# snmp.get einzelner SNMP-Wert (params: ip, oid)
# snmp.walk SNMP-Teilbaum (params: ip, oid)
# 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) ───────────────────────────────────────────────
# Defaults fuer die snmp.*-Aktionen; pro Request per params ueberschreibbar.
+3
View File
@@ -0,0 +1,3 @@
.env
# Verschluesselter Credential-Store + Schluessel (nie einchecken!)
data/
+3
View File
@@ -21,3 +21,6 @@ services:
network_mode: host
env_file: .env
restart: unless-stopped
volumes:
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
- ./data:/data
+1
View File
@@ -1,3 +1,4 @@
websockets>=12.0
zeroconf>=0.131.0
requests>=2.31.0
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
+389 -12
View File
@@ -114,7 +114,8 @@ CONTROL_ENABLED = _env_bool("CONTROL_ENABLED", False)
CONTROL_ALLOWLIST = [
a.strip() for a in
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()
]
@@ -156,6 +157,80 @@ SNMP_SYS_OIDS = {
"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
# 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)
if action == "snmp.printer":
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."}
except Exception as 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://")):
return {"ok": False, "error": "url (http/https) erforderlich."}
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"),
headers=params.get("headers"), timeout=HTTP_TIMEOUT_SEC)
headers=params.get("headers"), auth=auth,
timeout=HTTP_TIMEOUT_SEC)
text = r.text
total = len(text)
@@ -581,11 +676,27 @@ def _snmp_run(args: list, timeout: float) -> tuple:
return True, r.stdout
def _snmp_base_args(params: dict) -> list:
community = str(params.get("community") or SNMP_COMMUNITY)
version = str(params.get("version") or SNMP_VERSION)
# -t Timeout(s), -r Retries: schnell scheitern statt haengen.
return ["-v", version, "-c", community, "-t", "2", "-r", "1"]
def _snmp_base_args(params: dict, ip: str = "") -> list:
"""Version/Community bzw. v3-Auth. Prioritaet: explizite params > gespeicherte
Creds fuer die IP > globale Defaults. OHNE -t/-r (haengt der Aufrufer an)."""
creds = _creds_for(ip).get("snmp", {}) if ip else {}
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:
@@ -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)."}
tool = "snmpwalk" if action == "snmp.walk" else "snmpget"
# -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)
if not ok:
return {"ok": False, "error": out}
@@ -625,7 +736,7 @@ def _do_snmp_printer(params: dict) -> dict:
ip = _snmp_target(params)
if not ip:
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)
if not descs:
return {"ok": False, "error":
@@ -648,6 +759,209 @@ def _do_snmp_printer(params: dict) -> dict:
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):
try:
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]:
"""Fragt die SNMP-System-Group eines Hosts ab (ein snmpget, alle 6 OIDs).
Gibt {descr,name,contact,location,uptime,objectid} oder None (kein SNMP).
Kurzer Timeout, keine Retries -> Nicht-SNMP-Hosts scheitern schnell."""
base = ["-v", str(version or SNMP_VERSION), "-c", str(community or SNMP_COMMUNITY),
"-t", "1", "-r", "0"]
Nutzt gespeicherte Creds fuer die IP; kurzer Timeout, keine Retries ->
Nicht-SNMP-Hosts scheitern schnell."""
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())
ok, out = _snmp_run(["snmpget", "-Oqv", *base, ip, *SNMP_SYS_OIDS.values()],
SNMP_DISCOVERY_TIMEOUT)
@@ -850,6 +1169,63 @@ class Satellite:
"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:
while True:
try:
@@ -871,6 +1247,7 @@ class Satellite:
if not RVS_HOST or not RVS_TOKEN:
logger.error("RVS_HOST und RVS_TOKEN sind Pflicht (siehe .env.example).")
return
_creds_load()
asyncio.create_task(self._periodic_scan())
backoff = 1
while True: