feat(satellite): ssh.exec — ARIA fuehrt Kommandos per SSH auf Geraeten aus
Neuer Credential-Typ 'ssh' (Benutzer + Passwort ODER privater Key, Port) und Aktion ssh.exec (params: ip, cmd). paramiko-Client; grosse Ausgaben werden wie bei http.get gefenstert (contains/offset/max_chars gemeinsam via _window_text), Antwort mit exit_code + stdout + stderr. Auth kommt aus dem Credential-Store, ARIA muss keine Passwoerter mitgeben. - satellite: _do_ssh + _ssh_load_key (RSA/Ed25519/ECDSA/DSS aus String), ssh.exec in _control + Allowlist, 'ssh' in beide Cred-Typ-Listen; paramiko in requirements; .env.example ergaenzt. - diagnostic: SSH-Sektion im Credentials-Modal (User/Port/Passwort/Key) + Save-Logik (User + Passwort|Key). - brain: satellite_command-Tool um ssh.exec erweitert. Laeuft ueber den bestehenden sat_command/sat_result-Pfad — keine RVS-Aenderung. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+7
-3
@@ -1233,9 +1233,13 @@ META_TOOLS = [
|
|||||||
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
|
||||||
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
|
||||||
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
"— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht "
|
||||||
"hinterlegten FritzBox-Login). Fuer Geraete mit hinterlegten Zugangsdaten "
|
"hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) "
|
||||||
"(community/v3/Login) nutzt der Satellit diese automatisch — Du musst keine "
|
"— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) "
|
||||||
"Passwoerter mitgeben. "
|
"und gibt exit_code + stdout (gefenstert: contains/offset/max_chars) + "
|
||||||
|
"stderr zurueck; braucht hinterlegte SSH-Credentials (Benutzer + Passwort "
|
||||||
|
"oder Key). Fuer Geraete mit hinterlegten Zugangsdaten "
|
||||||
|
"(community/v3/Login/SSH) 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 "
|
||||||
|
|||||||
+23
-2
@@ -248,6 +248,16 @@
|
|||||||
<input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="width:100%;">
|
<input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="width:100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="border:1px solid #1E1E2E;border-radius:6px;padding:10px;">
|
||||||
|
<div style="color:#E0E0F0;font-weight:600;margin-bottom:6px;">SSH</div>
|
||||||
|
<div style="display:flex;gap:4px;margin-bottom:4px;">
|
||||||
|
<input id="dc-ssh-user" placeholder="Benutzer" style="flex:2;">
|
||||||
|
<input id="dc-ssh-port" placeholder="Port 22" style="flex:1;">
|
||||||
|
</div>
|
||||||
|
<input id="dc-ssh-pass" type="password" placeholder="Passwort (oder Key unten)" style="width:100%;margin-bottom:4px;">
|
||||||
|
<textarea id="dc-ssh-key" placeholder="Privater SSH-Key (optional, PEM/OpenSSH)" style="width:100%;height:60px;font-family:monospace;font-size:10px;"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
<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 secondary" onclick="deleteDeviceCreds()" style="color:#FF6E6E;">Alle loeschen</button>
|
||||||
<button class="btn" onclick="saveDeviceCreds()">Speichern</button>
|
<button class="btn" onclick="saveDeviceCreds()">Speichern</button>
|
||||||
@@ -4624,7 +4634,7 @@
|
|||||||
credsModalSat = satId; credsModalIp = ip;
|
credsModalSat = satId; credsModalIp = ip;
|
||||||
document.getElementById('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')';
|
document.getElementById('dev-creds-title').textContent = (name || ip) + ' (' + ip + ')';
|
||||||
// Felder leeren — Secrets werden NIE zurueckgeliefert, nur neu gesetzt.
|
// 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 = '');
|
['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 = '';
|
document.getElementById('dc-snmp-version').value = '';
|
||||||
dcToggleV3();
|
dcToggleV3();
|
||||||
const set = ((satCreds[satId] || {})[ip]) || [];
|
const set = ((satCreds[satId] || {})[ip]) || [];
|
||||||
@@ -4656,7 +4666,18 @@
|
|||||||
const fu = document.getElementById('dc-fb-user').value.trim();
|
const fu = document.getElementById('dc-fb-user').value.trim();
|
||||||
const fp = document.getElementById('dc-fb-pass').value;
|
const fp = document.getElementById('dc-fb-pass').value;
|
||||||
if (fu || fp) creds.fritzbox = { user: fu, pass: fp };
|
if (fu || fp) creds.fritzbox = { user: fu, pass: fp };
|
||||||
if (!Object.keys(creds).length) { document.getElementById('dc-status').textContent = 'Nichts zu speichern — mindestens einen Typ ausfuellen.'; return; }
|
// 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)) {
|
||||||
|
creds.ssh = { user: su };
|
||||||
|
if (sp) creds.ssh.pass = sp;
|
||||||
|
if (sk) creds.ssh.key = sk;
|
||||||
|
const sport = document.getElementById('dc-ssh-port').value.trim();
|
||||||
|
if (sport) creds.ssh.port = sport;
|
||||||
|
}
|
||||||
|
if (!Object.keys(creds).length) { document.getElementById('dc-status').textContent = 'Nichts zu speichern — mindestens einen Typ ausfuellen (SSH braucht Benutzer + Passwort/Key).'; return; }
|
||||||
send({ action: 'sat_creds_set', satellite: credsModalSat, ip: credsModalIp, creds });
|
send({ action: 'sat_creds_set', satellite: credsModalSat, ip: credsModalIp, creds });
|
||||||
document.getElementById('dc-status').textContent = 'Speichere …';
|
document.getElementById('dc-status').textContent = 'Speichere …';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ CONTROL_ENABLED=true
|
|||||||
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
# snmp.info Modell/Seriennummer/Firmware-Version (params: ip)
|
||||||
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
# fritzbox.info FritzBox: Verbindung/Datenrate/externe IP (TR-064, braucht Login)
|
||||||
# fritzbox.hosts FritzBox: verbundene Geraete (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
|
# ssh.exec Kommando per SSH ausfuehren (params: ip, cmd; Auth aus Creds 'ssh')
|
||||||
|
CONTROL_ALLOWLIST=dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec
|
||||||
|
|
||||||
# ─── Credential-Store (optional) ───────────────────────────────────
|
# ─── Credential-Store (optional) ───────────────────────────────────
|
||||||
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
|
# Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ 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)
|
cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet)
|
||||||
|
paramiko>=3.4 # SSH-Client fuer ssh.exec (Passwort ODER Key)
|
||||||
|
|||||||
+102
-37
@@ -116,7 +116,7 @@ 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,"
|
"dial.launch,wol,http.get,snmp.get,snmp.walk,snmp.printer,"
|
||||||
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts").split(",")
|
"snmp.ports,snmp.info,fritzbox.info,fritzbox.hosts,ssh.exec").split(",")
|
||||||
if a.strip()
|
if a.strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -131,6 +131,10 @@ HTTP_TIMEOUT_SEC = float(os.environ.get("HTTP_TIMEOUT_SEC", "10") or "10")
|
|||||||
HTTP_MAX_CHARS = int(os.environ.get("HTTP_MAX_CHARS", "20000") or "20000")
|
HTTP_MAX_CHARS = int(os.environ.get("HTTP_MAX_CHARS", "20000") or "20000")
|
||||||
HTTP_MAX_CHARS_HARD = int(os.environ.get("HTTP_MAX_CHARS_HARD", "200000") or "200000")
|
HTTP_MAX_CHARS_HARD = int(os.environ.get("HTTP_MAX_CHARS_HARD", "200000") or "200000")
|
||||||
|
|
||||||
|
# SSH (ssh.exec): Verbindungs-/Kommando-Timeout. Auth aus dem Credential-Store
|
||||||
|
# (Typ 'ssh': user + pass ODER key). Default-Port 22, pro Request/Cred ueberschreibbar.
|
||||||
|
SSH_TIMEOUT_SEC = float(os.environ.get("SSH_TIMEOUT_SEC", "20") or "20")
|
||||||
|
|
||||||
# SNMP (net-snmp-CLI): Default-Community/Version + Timeout. Drucker antworten
|
# SNMP (net-snmp-CLI): Default-Community/Version + Timeout. Drucker antworten
|
||||||
# i.d.R. auf community 'public', v2c.
|
# i.d.R. auf community 'public', v2c.
|
||||||
SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") or "public"
|
SNMP_COMMUNITY = os.environ.get("SNMP_COMMUNITY", "public") or "public"
|
||||||
@@ -228,7 +232,7 @@ def _creds_public_summary() -> list:
|
|||||||
"""Fuer sat_creds_list: welche Geraete welche Cred-Typen haben — OHNE Secrets."""
|
"""Fuer sat_creds_list: welche Geraete welche Cred-Typen haben — OHNE Secrets."""
|
||||||
out = []
|
out = []
|
||||||
for ip, entry in sorted(_CREDS.items()):
|
for ip, entry in sorted(_CREDS.items()):
|
||||||
types = [t for t in ("snmp", "http", "fritzbox") if entry.get(t)]
|
types = [t for t in ("snmp", "http", "fritzbox", "ssh") if entry.get(t)]
|
||||||
out.append({"ip": ip, "types": types})
|
out.append({"ip": ip, "types": types})
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -541,6 +545,8 @@ async def _control(action: str, params: dict, devices: list[dict]) -> dict:
|
|||||||
return await loop.run_in_executor(None, _do_snmp_info, params)
|
return await loop.run_in_executor(None, _do_snmp_info, params)
|
||||||
if action in ("fritzbox.info", "fritzbox.hosts"):
|
if action in ("fritzbox.info", "fritzbox.hosts"):
|
||||||
return await loop.run_in_executor(None, _do_fritzbox, action, params)
|
return await loop.run_in_executor(None, _do_fritzbox, action, params)
|
||||||
|
if action == "ssh.exec":
|
||||||
|
return await loop.run_in_executor(None, _do_ssh, 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}"}
|
||||||
@@ -592,6 +598,32 @@ def _do_wol(params: dict) -> dict:
|
|||||||
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
return {"ok": True, "result": f"Wake-on-LAN an {mac} gesendet."}
|
||||||
|
|
||||||
|
|
||||||
|
def _window_text(text: str, params: dict) -> dict:
|
||||||
|
"""Schneidet grosse Text-Ausgaben zu: optionaler contains-Zeilenfilter, dann
|
||||||
|
offset/max_chars-Fenster. Gibt body + Metadaten (total/returned/truncated)."""
|
||||||
|
total = len(text)
|
||||||
|
contains = params.get("contains")
|
||||||
|
if contains:
|
||||||
|
terms = [contains] if isinstance(contains, str) else list(contains)
|
||||||
|
terms = [str(t).lower() for t in terms if str(t).strip()]
|
||||||
|
if terms:
|
||||||
|
text = "\n".join(ln for ln in text.splitlines()
|
||||||
|
if any(t in ln.lower() for t in terms))
|
||||||
|
try:
|
||||||
|
offset = max(0, int(params.get("offset", 0)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
offset = 0
|
||||||
|
try:
|
||||||
|
max_chars = int(params.get("max_chars", HTTP_MAX_CHARS))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_chars = HTTP_MAX_CHARS
|
||||||
|
max_chars = max(1, min(max_chars, HTTP_MAX_CHARS_HARD))
|
||||||
|
body = text[offset:offset + max_chars]
|
||||||
|
return {"body": body, "total_chars": total, "filtered": bool(contains),
|
||||||
|
"offset": offset, "returned_chars": len(body),
|
||||||
|
"truncated": offset + len(body) < len(text)}
|
||||||
|
|
||||||
|
|
||||||
def _do_http(action: str, params: dict) -> dict:
|
def _do_http(action: str, params: dict) -> dict:
|
||||||
"""HTTP-GET/POST vom Satelliten aus (lokale Webhooks, Geraete-Statusseiten …).
|
"""HTTP-GET/POST vom Satelliten aus (lokale Webhooks, Geraete-Statusseiten …).
|
||||||
|
|
||||||
@@ -627,40 +659,7 @@ def _do_http(action: str, params: dict) -> dict:
|
|||||||
r = requests.request(method, url, data=params.get("body"),
|
r = requests.request(method, url, data=params.get("body"),
|
||||||
headers=params.get("headers"), auth=auth,
|
headers=params.get("headers"), auth=auth,
|
||||||
timeout=HTTP_TIMEOUT_SEC)
|
timeout=HTTP_TIMEOUT_SEC)
|
||||||
text = r.text
|
return {"ok": True, "result": {"status": r.status_code, **_window_text(r.text, params)}}
|
||||||
total = len(text)
|
|
||||||
|
|
||||||
contains = params.get("contains")
|
|
||||||
if contains:
|
|
||||||
terms = [contains] if isinstance(contains, str) else list(contains)
|
|
||||||
terms = [str(t).lower() for t in terms if str(t).strip()]
|
|
||||||
if terms:
|
|
||||||
lines = [ln for ln in text.splitlines()
|
|
||||||
if any(t in ln.lower() for t in terms)]
|
|
||||||
text = "\n".join(lines)
|
|
||||||
|
|
||||||
try:
|
|
||||||
offset = max(0, int(params.get("offset", 0)))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
offset = 0
|
|
||||||
try:
|
|
||||||
max_chars = int(params.get("max_chars", HTTP_MAX_CHARS))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
max_chars = HTTP_MAX_CHARS
|
|
||||||
max_chars = max(1, min(max_chars, HTTP_MAX_CHARS_HARD))
|
|
||||||
|
|
||||||
body = text[offset:offset + max_chars]
|
|
||||||
returned_end = offset + len(body)
|
|
||||||
truncated = returned_end < len(text)
|
|
||||||
return {"ok": True, "result": {
|
|
||||||
"status": r.status_code,
|
|
||||||
"body": body,
|
|
||||||
"total_chars": total, # Groesse der Roh-Antwort
|
|
||||||
"filtered": bool(contains), # contains-Filter aktiv?
|
|
||||||
"offset": offset,
|
|
||||||
"returned_chars": len(body),
|
|
||||||
"truncated": truncated, # noch mehr Text nach diesem Ausschnitt?
|
|
||||||
}}
|
|
||||||
|
|
||||||
|
|
||||||
def _snmp_run(args: list, timeout: float) -> tuple:
|
def _snmp_run(args: list, timeout: float) -> tuple:
|
||||||
@@ -963,6 +962,72 @@ def _do_fritzbox(action: str, params: dict) -> dict:
|
|||||||
return {"ok": True, "result": info}
|
return {"ok": True, "result": info}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── SSH ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _ssh_load_key(key_str: str):
|
||||||
|
"""Laedt einen privaten Schluessel aus einem String (RSA/Ed25519/ECDSA/DSS)."""
|
||||||
|
import io
|
||||||
|
import paramiko
|
||||||
|
for cls in (paramiko.Ed25519Key, paramiko.RSAKey, paramiko.ECDSAKey, paramiko.DSSKey):
|
||||||
|
try:
|
||||||
|
return cls.from_private_key(io.StringIO(key_str))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _do_ssh(params: dict) -> dict:
|
||||||
|
"""Fuehrt EIN Kommando per SSH auf einem Geraet aus. params: {ip|host, cmd,
|
||||||
|
user?, pass?, key?, port?}. Auth bevorzugt aus dem Credential-Store (Typ 'ssh').
|
||||||
|
Grosse Ausgaben werden wie bei http.get gefenstert (contains/offset/max_chars)."""
|
||||||
|
import paramiko
|
||||||
|
host = _snmp_target(params)
|
||||||
|
if not host:
|
||||||
|
return {"ok": False, "error": "ip/host erforderlich."}
|
||||||
|
cmd = params.get("cmd") or params.get("command") or ""
|
||||||
|
if not cmd:
|
||||||
|
return {"ok": False, "error": "cmd (Kommando) erforderlich."}
|
||||||
|
creds = _creds_for(host).get("ssh", {})
|
||||||
|
user = str(params.get("user") or creds.get("user") or "")
|
||||||
|
if not user:
|
||||||
|
return {"ok": False, "error": "SSH-Benutzer fehlt (Credentials fuer diese IP setzen)."}
|
||||||
|
port = _to_int(params.get("port") or creds.get("port")) or 22
|
||||||
|
password = params.get("pass") or params.get("password") or creds.get("pass")
|
||||||
|
key_str = params.get("key") or creds.get("key")
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
kwargs = {"hostname": host, "port": port, "username": user,
|
||||||
|
"timeout": SSH_TIMEOUT_SEC, "banner_timeout": SSH_TIMEOUT_SEC,
|
||||||
|
"auth_timeout": SSH_TIMEOUT_SEC, "allow_agent": False,
|
||||||
|
"look_for_keys": False}
|
||||||
|
if key_str:
|
||||||
|
pkey = _ssh_load_key(str(key_str))
|
||||||
|
if pkey is None:
|
||||||
|
return {"ok": False, "error": "Privater SSH-Key nicht lesbar (Format?)."}
|
||||||
|
kwargs["pkey"] = pkey
|
||||||
|
if password:
|
||||||
|
kwargs["password"] = str(password)
|
||||||
|
client.connect(**kwargs)
|
||||||
|
stdin, stdout, stderr = client.exec_command(cmd, timeout=SSH_TIMEOUT_SEC)
|
||||||
|
exit_code = stdout.channel.recv_exit_status()
|
||||||
|
out = stdout.read().decode("utf-8", "ignore")
|
||||||
|
err = stderr.read().decode("utf-8", "ignore")
|
||||||
|
except paramiko.AuthenticationException:
|
||||||
|
return {"ok": False, "error": "SSH-Auth fehlgeschlagen (User/Passwort/Key pruefen)."}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": f"SSH fehlgeschlagen: {exc}"}
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
win = _window_text(out, params)
|
||||||
|
return {"ok": True, "result": {"host": host, "exit_code": exit_code,
|
||||||
|
"stderr": err[:4000], **win}}
|
||||||
|
|
||||||
|
|
||||||
def _to_int(s: str):
|
def _to_int(s: str):
|
||||||
try:
|
try:
|
||||||
return int(str(s).strip())
|
return int(str(s).strip())
|
||||||
@@ -1178,7 +1243,7 @@ class Satellite:
|
|||||||
ok = False
|
ok = False
|
||||||
if ip and isinstance(creds, dict):
|
if ip and isinstance(creds, dict):
|
||||||
entry = _CREDS.setdefault(ip, {})
|
entry = _CREDS.setdefault(ip, {})
|
||||||
for t in ("snmp", "http", "fritzbox"):
|
for t in ("snmp", "http", "fritzbox", "ssh"):
|
||||||
if t in creds:
|
if t in creds:
|
||||||
if creds[t]: # leeres Objekt = Typ loeschen
|
if creds[t]: # leeres Objekt = Typ loeschen
|
||||||
entry[t] = creds[t]
|
entry[t] = creds[t]
|
||||||
|
|||||||
Reference in New Issue
Block a user