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 "
|
||||
"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
@@ -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 …';
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user