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:
2026-09-24 16:00:54 +02:00
co-authored by Claude Opus 4.8
parent e81fb5b45c
commit 84f405d264
5 changed files with 135 additions and 43 deletions
+7 -3
View File
@@ -1233,9 +1233,13 @@ META_TOOLS = [
"(params={'ip':'...','oid':'...','community':'public'}) fuer beliebige "
"SNMP-Werte. 'fritzbox.info' / 'fritzbox.hosts' (params={'ip':'<fritzbox>'}) "
"— 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 "
+23 -2
View File
@@ -248,6 +248,16 @@
<input id="dc-fb-pass" type="password" placeholder="FritzBox-Passwort" style="width:100%;">
</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;">
<button class="btn secondary" onclick="deleteDeviceCreds()" style="color:#FF6E6E;">Alle loeschen</button>
<button class="btn" onclick="saveDeviceCreds()">Speichern</button>
@@ -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 …';
}
+2 -1
View File
@@ -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/
+1
View File
@@ -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)
+102 -37
View File
@@ -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]