From 84f405d264f36b2ac43773c8cdb5c591240f0226 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 24 Sep 2026 16:00:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(satellite):=20ssh.exec=20=E2=80=94=20ARIA?= =?UTF-8?q?=20fuehrt=20Kommandos=20per=20SSH=20auf=20Geraeten=20aus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- aria-brain/agent.py | 10 ++- diagnostic/index.html | 25 ++++++- satellite/.env.example | 3 +- satellite/requirements.txt | 1 + satellite/satellite.py | 139 +++++++++++++++++++++++++++---------- 5 files changed, 135 insertions(+), 43 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 4cc1804..d9142f2 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -1233,9 +1233,13 @@ META_TOOLS = [ "(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige " "SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':''}) " "— Internetverbindung/Datenrate/externe IP bzw. verbundene Geraete (braucht " - "hinterlegten FritzBox-Login). Fuer Geraete mit hinterlegten Zugangsdaten " - "(community/v3/Login) nutzt der Satellit diese automatisch — Du musst keine " - "Passwoerter mitgeben. " + "hinterlegten FritzBox-Login). 'ssh.exec' (params={'ip':'...','cmd':'...'}) " + "— fuehrt EIN Shell-Kommando per SSH auf einem Geraet aus (Server, Pi, NAS) " + "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 " "um Geraete-Statusseiten zu lesen (Fallback fuer Tinte, NAS ...). Bei grossen " "Seiten NICHT blind paginieren: setze params['contains'] (String oder " diff --git a/diagnostic/index.html b/diagnostic/index.html index db3dc0f..fc773a5 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -248,6 +248,16 @@ +
+
SSH
+
+ + +
+ + +
+
@@ -4624,7 +4634,7 @@ 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'].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 = ''; dcToggleV3(); const set = ((satCreds[satId] || {})[ip]) || []; @@ -4656,7 +4666,18 @@ 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 (!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 }); document.getElementById('dc-status').textContent = 'Speichere …'; } diff --git a/satellite/.env.example b/satellite/.env.example index 16f2ca4..3699a2a 100644 --- a/satellite/.env.example +++ b/satellite/.env.example @@ -33,7 +33,8 @@ CONTROL_ENABLED=true # 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 +# 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) ─────────────────────────────────── # Pro Geraet koennen im Diagnostic Zugangsdaten hinterlegt werden (SNMP-Community/ diff --git a/satellite/requirements.txt b/satellite/requirements.txt index d60e39c..3d71c05 100644 --- a/satellite/requirements.txt +++ b/satellite/requirements.txt @@ -2,3 +2,4 @@ websockets>=12.0 zeroconf>=0.131.0 requests>=2.31.0 cryptography>=42.0 # Verschluesselung des Geraete-Credential-Stores (Fernet) +paramiko>=3.4 # SSH-Client fuer ssh.exec (Passwort ODER Key) diff --git a/satellite/satellite.py b/satellite/satellite.py index dd38ba1..5765527 100644 --- a/satellite/satellite.py +++ b/satellite/satellite.py @@ -116,7 +116,7 @@ CONTROL_ALLOWLIST = [ a.strip() for a in os.environ.get("CONTROL_ALLOWLIST", "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() ] @@ -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_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 # i.d.R. auf community 'public', v2c. 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.""" out = [] 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}) 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) if action in ("fritzbox.info", "fritzbox.hosts"): 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."} except Exception as 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."} +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: """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"), headers=params.get("headers"), auth=auth, timeout=HTTP_TIMEOUT_SEC) - text = r.text - 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? - }} + return {"ok": True, "result": {"status": r.status_code, **_window_text(r.text, params)}} 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} +# ─── 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): try: return int(str(s).strip()) @@ -1178,7 +1243,7 @@ class Satellite: ok = False if ip and isinstance(creds, dict): entry = _CREDS.setdefault(ip, {}) - for t in ("snmp", "http", "fritzbox"): + for t in ("snmp", "http", "fritzbox", "ssh"): if t in creds: if creds[t]: # leeres Objekt = Typ loeschen entry[t] = creds[t]