#!/usr/bin/env python3 """Akzeptiert die SSH-Host-Keys aller aktiven Geraete (Clients) in einem Rutsch -- macht technisch dasselbe wie "Verbindung testen" im Browser- Terminal (Verbindungsaufbau -> Host-Key abfragen -> "yes" -> trennen), nur automatisiert fuer alle Geraete statt eines einzelnen. Nutzt bewusst dieselbe known_hosts-Datei/dasselbe Format wie die App (SSH_KNOWN_HOSTS_PATH in app.py) -- Geraete, deren Key hier akzeptiert wird, muessen danach in der Weboberflaeche NICHT mehr einzeln bestaetigt werden (SSH-Verbindungstest und Wartungs-Aktionen teilen sich dieselbe Datei). Aufruf auf dem Server: sudo /srv/tesm/venv/bin/python3 accept_all_hostkeys.py """ import socket import sqlite3 from concurrent.futures import ThreadPoolExecutor, as_completed import paramiko DB_PATH = "/srv/tesm/sqlite.db" KNOWN_HOSTS_PATH = "/srv/tesm/known_hosts" DEFAULT_SSH_PORT = 22 CONNECT_TIMEOUT = 4 MAX_WORKERS = 32 conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row devices = conn.execute( "SELECT name, ip, ssh_port FROM devices WHERE deleted_at IS NULL ORDER BY name" ).fetchall() conn.close() def fetch_key(dev): """Nur der Handshake (Verbindungsaufbau + Schluesseltausch), KEIN Login -- exakt der Teil, der im Browser-Terminal vor der Passwortabfrage passiert. Parallel je Gerät, sonst dauert eine Liste mit vielen offline Geraeten (je bis zu CONNECT_TIMEOUT Sekunden) unnoetig lange.""" host = dev["ip"] port = dev["ssh_port"] or DEFAULT_SSH_PORT try: sock = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT) transport = paramiko.Transport(sock) transport.start_client(timeout=CONNECT_TIMEOUT) server_key = transport.get_remote_server_key() transport.close() return dev, server_key, None except (OSError, paramiko.SSHException, EOFError) as e: return dev, None, str(e) hostkeys = paramiko.HostKeys() try: hostkeys.load(KNOWN_HOSTS_PATH) except (OSError, paramiko.SSHException): pass accepted, already_known, changed, unreachable = [], [], [], [] with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = [pool.submit(fetch_key, dev) for dev in devices] for future in as_completed(futures): dev, server_key, err = future.result() host = dev["ip"] port = dev["ssh_port"] or DEFAULT_SSH_PORT lookup_host = host if port == DEFAULT_SSH_PORT else f"[{host}]:{port}" if err is not None: unreachable.append((dev["name"], host, err)) continue existing = hostkeys.lookup(lookup_host) if existing and existing.get(server_key.get_name()): if existing[server_key.get_name()] == server_key: already_known.append((dev["name"], host)) continue else: # Nicht blind ueberschreiben -- das waere genau das MITM- # Szenario, vor dem die App im Terminal auch schuetzt (harte # Ablehnung statt automatischer Uebernahme eines GEAENDERTEN # Keys). changed.append((dev["name"], host)) continue hostkeys.add(lookup_host, server_key.get_name(), server_key) accepted.append((dev["name"], host)) hostkeys.save(KNOWN_HOSTS_PATH) print(f"Neu akzeptiert: {len(accepted)}") for name, host in accepted: print(f" + {name} ({host})") print(f"Bereits bekannt: {len(already_known)}") if changed: print(f"ACHTUNG -- Host-Key geaendert, NICHT automatisch uebernommen ({len(changed)}):") for name, host in changed: print(f" ! {name} ({host}) -- manuell in der App pruefen (moeglicher MITM oder Geraetetausch)") print(f"Nicht erreichbar: {len(unreachable)}") for name, host, err in unreachable: print(f" - {name} ({host}): {err}")