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:
+12
-1
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.env
|
||||
# Verschluesselter Credential-Store + Schluessel (nie einchecken!)
|
||||
data/
|
||||
@@ -21,3 +21,6 @@ services:
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Persistenter Credential-Store (verschluesselt) + Schluesseldatei.
|
||||
- ./data:/data
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user