diff --git a/README.md b/README.md index dee7a15..6d30ac9 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Migriert ein komplettes Proxmox-Cluster (inkl. Ceph) von einem Netzwerk in ein a - Ceph-Support (Public Network, Cluster Network, MON-Adressen) - Funktioniert auch bei **gebrochenem Quorum** (z.B. wenn ein Node bereits manuell geändert wurde) - **Rescue-Netzwerk** — temporäres Emergency-Netz wenn sich Nodes nicht mehr erreichen können -- **Passwort-Auth via `.env`** — SSH-Passwort aus `.env`-Datei, funktioniert auch nach pve-cluster Stop +- **Passwort-Auth via `.env`** — unabhängig von `/etc/pve`, funktioniert immer - Automatische Backups aller Konfigurationen vor der Migration - Dry-Run-Modus zum gefahrlosen Testen - Verifikation nach der Migration @@ -21,8 +21,8 @@ Migriert ein komplettes Proxmox-Cluster (inkl. Ceph) von einem Netzwerk in ein a - Python 3.9+ (auf Proxmox standardmäßig vorhanden) - Root-Zugriff auf dem Node, auf dem das Tool läuft -- SSH-Zugriff zu allen anderen Cluster-Nodes (Key-basiert oder Passwort via `.env`) -- `sshpass` (nur bei Passwort-Auth): `apt install sshpass` +- `sshpass` — wird beim ersten Start automatisch installiert falls nicht vorhanden +- Root-Passwort der Proxmox-Nodes (alle Nodes müssen das gleiche Passwort haben) - Keine externen Python-Pakete nötig (nur stdlib) ## Installation @@ -34,48 +34,24 @@ scp -r proxmox-cluster-network-changer/ root@pve1:/root/ # Oder direkt klonen cd /root git clone proxmox-cluster-network-changer + +# .env erstellen +cd proxmox-cluster-network-changer +cp .env.example .env +nano .env # SSH_PASSWORD=dein-root-passwort eintragen ``` ## SSH-Authentifizierung -### Empfohlen: Passwort via `.env` (sshpass) - -Die einfachste und robusteste Methode. Funktioniert immer — auch nachdem `pve-cluster` gestoppt wird und `/etc/pve/priv/authorized_keys` verschwindet. +Das Tool nutzt `sshpass` mit dem Root-Passwort aus der `.env`-Datei. Key-basierte Auth funktioniert bei Proxmox nicht zuverlässig, weil `/etc/pve/priv/authorized_keys` verschwindet wenn `pve-cluster` gestoppt wird. ```bash # .env-Datei erstellen -cp .env.example .env - -# Passwort eintragen -nano .env -``` - -`.env`-Datei: -``` -SSH_PASSWORD=dein-root-passwort -``` - -Voraussetzung: -```bash -apt install sshpass +echo 'SSH_PASSWORD=dein-root-passwort' > .env ``` > **Hinweis:** Alle Nodes müssen das gleiche Root-Passwort haben (bei Proxmox-Clustern üblich). -### Alternative: Key-basiert - -Ohne `.env`-Datei wird automatisch Key-basierte Authentifizierung verwendet. Bei dieser Methode werden vor dem pve-cluster-Stop die SSH-Keys gesichert und die sshd-Konfiguration temporär angepasst. - -```bash -# Ohne .env -> Key-basiert -python3 main.py - -# Oder mit explizitem Key -python3 main.py --ssh-key /root/.ssh/id_rsa -``` - -> **Achtung:** Key-basierte Auth kann Probleme verursachen wenn `/etc/pve` unmounted wird und der sshd nur dort nach Keys sucht. - ## Verwendung ### Aktuelle Konfiguration anzeigen (Discovery) @@ -112,7 +88,7 @@ Das Tool führt interaktiv durch den Prozess: Proxmox Cluster Network Changer ============================================================ - [SSH] Passwort-Authentifizierung aktiv (via .env) + [SSH] Passwort-Authentifizierung aktiv (via sshpass) === Phase 1: Discovery === @@ -161,19 +137,16 @@ Wenn Ceph Public/Cluster auf separaten NICs liegen, werden die IPs einzeln pro N | `--discover` | Nur aktuelle Config anzeigen | | `--rescue` | Rescue-Modus: Emergency-Netzwerk einrichten | | `--rescue-commands SUBNET` | Nur Rescue-Befehle ausgeben (z.B. `10.99.99.0/24`) | -| `--ssh-key PFAD` | Pfad zum SSH-Key (Standard: Default-Key) | | `--ssh-port PORT` | SSH-Port (Standard: 22) | | `--env-file PFAD` | Pfad zur .env-Datei (Standard: `.env`) | -### Umgebungsvariablen / `.env` +### `.env`-Datei | Variable | Beschreibung | |---|---| -| `SSH_PASSWORD` | Root-Passwort für SSH (aktiviert sshpass-Modus) | +| `SSH_PASSWORD` | **Pflicht.** Root-Passwort für SSH (alle Nodes gleich) | | `SSH_USER` | SSH-Benutzer (Standard: `root`) | -Variablen können in der `.env`-Datei oder als Umgebungsvariablen gesetzt werden. - ## Was wird geändert? | Datei | Wo | Was | @@ -185,28 +158,18 @@ Variablen können in der `.env`-Datei oder als Umgebungsvariablen gesetzt werden ## Migrationsablauf (Phase 4) -### Mit Passwort-Auth (empfohlen, 7 Schritte) - 1. Neue Konfigurationen werden auf alle Nodes verteilt (Staging) 2. Corosync wird auf allen Nodes gestoppt 3. pve-cluster (pmxcfs) wird gestoppt → `/etc/pve` unmounted 4. Corosync-Config wird direkt geschrieben (`/etc/corosync/corosync.conf`) 5. `/etc/hosts` wird aktualisiert -6. `/etc/network/interfaces` wird aktualisiert + `ifreload -a` (alle Bridges) -7. Services starten, Quorum abwarten, Ceph aktualisieren +6. `/etc/network/interfaces` wird aktualisiert + Netzwerk-Reload: + - Remote-Nodes zuerst (fire-and-forget via `nohup`) + - Lokaler Node zuletzt + - Verifikation der neuen IPs mit Retry +7. Services starten (pve-cluster, corosync), Quorum abwarten, Ceph aktualisieren -> SSH funktioniert durchgehend, weil Passwort-Auth unabhängig von `/etc/pve` ist. - -### Mit Key-Auth (Fallback, 8 Schritte) - -1. Neue Konfigurationen werden auf alle Nodes verteilt (Staging) -2. SSH-Keys sichern (`/etc/pve/priv/authorized_keys` → `~/.ssh/authorized_keys`) + sshd anpassen -3. Corosync wird auf allen Nodes gestoppt -4. pve-cluster (pmxcfs) wird gestoppt → `/etc/pve` unmounted -5. Corosync-Config wird direkt geschrieben (`/etc/corosync/corosync.conf`) -6. `/etc/hosts` wird aktualisiert -7. `/etc/network/interfaces` wird aktualisiert + `ifreload -a` (alle Bridges) -8. Services starten, Quorum abwarten, Ceph aktualisieren, SSH-Keys wiederherstellen +> SSH funktioniert durchgehend via `sshpass` — unabhängig von `/etc/pve`. ## Rescue-Netzwerk (Emergency Mode) @@ -268,7 +231,7 @@ Ablauf: 4. Du führst die Befehle auf den anderen Nodes per Konsole aus 5. Das Tool testet die Verbindung und liest die Configs 6. Danach läuft die normale Migration -7. Am Ende werden die Emergency-IPs automatisch entfernt +7. Rescue-IPs werden durch `ifreload -a` automatisch entfernt ### Wann brauche ich das? @@ -325,26 +288,26 @@ systemctl restart corosync ## Hinweise - Das Tool muss als **root** ausgeführt werden -- Bei Passwort-Auth müssen alle Nodes das gleiche Root-Passwort haben -- Bei Key-Auth müssen SSH-Keys vorher eingerichtet sein (bei Proxmox-Clustern standardmäßig der Fall) +- Alle Nodes müssen das gleiche Root-Passwort haben - VMs/CTs werden **nicht** automatisch migriert oder gestoppt — das Netzwerk wird im laufenden Betrieb geändert - Nach der Migration sollten VM-Netzwerke (Bridges in VM-Configs) geprüft werden, falls diese sich auf spezifische IPs beziehen -- Die Emergency-IPs (`ip addr add`) sind temporär und überleben keinen Reboot — sie werden nur zur SSH-Kommunikation während der Migration genutzt -- Bridges werden automatisch erkannt — keine manuelle Angabe nötig. Alle betroffenen Bridges (Management, Ceph Public, Ceph Cluster) werden per `ifreload -a` aktualisiert +- Die Emergency-IPs (`ip addr add`) sind temporär und werden durch `ifreload -a` automatisch entfernt +- Bridges werden automatisch erkannt — keine manuelle Angabe nötig +- Beim Netzwerk-Reload werden Remote-Nodes zuerst umgestellt (fire-and-forget), der lokale Node zuletzt — so schneidet sich das Tool nicht selbst die Verbindung ab - Getestet mit Proxmox VE 7.x und 8.x ## Projektstruktur ``` proxmox-cluster-network-changer/ -├── main.py # Entry-Point, CLI mit allen Optionen +├── main.py # Entry-Point, CLI, .env-Loader ├── discovery.py # Phase 1: Cluster-Config lesen & parsen ├── planner.py # Phase 2: IP-Mapping, neue Configs generieren ├── backup.py # Phase 3: Backup aller Configs -├── migrator.py # Phase 4: Migration durchführen +├── migrator.py # Phase 4: Migration durchführen (7 Schritte) ├── verifier.py # Phase 5: Post-Migration Checks ├── rescue.py # Rescue-Netzwerk (Emergency Mode) -├── ssh_manager.py # SSH-Verbindungen (Key + Passwort via sshpass) +├── ssh_manager.py # SSH via sshpass (Passwort-Auth) ├── config_parser.py # Parser für Corosync/Ceph/Network Configs ├── models.py # Dataclasses (NodeInfo, CorosyncConfig, etc.) ├── .env.example # Vorlage für SSH-Credentials @@ -356,9 +319,9 @@ proxmox-cluster-network-changer/ ### Szenario 1: Normaler Umzug (alles funktioniert noch) ```bash -cp .env.example .env && nano .env # Passwort eintragen -python3 main.py --dry-run # Erst testen -python3 main.py # Dann ausführen +echo 'SSH_PASSWORD=dein-passwort' > .env +python3 main.py --dry-run # Erst testen +python3 main.py # Dann ausführen ``` ### Szenario 2: Ein Node wurde bereits manuell geändert diff --git a/main.py b/main.py index 9edbea8..ebffdce 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ Kann auch mit gebrochenem Quorum umgehen (z.B. wenn ein Node bereits manuell geändert wurde). Muss als root auf einem Proxmox-Node ausgeführt werden. +Benötigt SSH_PASSWORD in .env und sshpass. Verwendung: python3 main.py # Interaktiver Modus @@ -68,6 +69,28 @@ def check_prerequisites(): sys.exit(0) +def ensure_sshpass(): + """Ensure sshpass is installed, offer to install if missing.""" + if shutil.which("sshpass"): + return True + + print("\n [SSH] sshpass nicht gefunden — wird für SSH-Authentifizierung benötigt.") + answer = input(" sshpass jetzt installieren? (apt install sshpass) [J/n]: ").strip().lower() + if answer not in ('n', 'nein', 'no'): + rc = subprocess.run( + ["apt", "install", "-y", "sshpass"], + capture_output=True, text=True, + ).returncode + if rc == 0: + print(" sshpass installiert.") + return True + else: + print(" FEHLER: Installation fehlgeschlagen!") + print(" Bitte manuell installieren: apt install sshpass") + return False + return False + + def main(): parser = argparse.ArgumentParser( description="Proxmox Cluster Network Changer - " @@ -81,10 +104,6 @@ def main(): "--discover", action="store_true", help="Nur Discovery durchführen, keine Migration" ) - parser.add_argument( - "--ssh-key", type=str, default=None, - help="Pfad zum SSH-Key (Standard: Default SSH-Key)" - ) parser.add_argument( "--ssh-port", type=int, default=22, help="SSH-Port (Standard: 22)" @@ -116,37 +135,28 @@ def main(): ssh_password = env.get('SSH_PASSWORD') or os.environ.get('SSH_PASSWORD') ssh_user = env.get('SSH_USER') or os.environ.get('SSH_USER') or 'root' - # Ensure sshpass is available when password auth is requested - if ssh_password and not shutil.which("sshpass"): - print("\n [SSH] sshpass nicht gefunden — wird für Passwort-Auth benötigt.") - answer = input(" sshpass jetzt installieren? (apt install sshpass) [J/n]: ").strip().lower() - if answer not in ('n', 'nein', 'no'): - rc = subprocess.run( - ["apt", "install", "-y", "sshpass"], - capture_output=True, text=True, - ).returncode - if rc == 0: - print(" sshpass installiert.") - else: - print(" FEHLER: Installation fehlgeschlagen!") - print(" Bitte manuell installieren: apt install sshpass") - sys.exit(1) - else: - print(" Ohne sshpass ist Passwort-Auth nicht möglich.") - ssh_password = None + if not ssh_password: + print("\n FEHLER: SSH_PASSWORD nicht gesetzt!") + print(" Erstelle eine .env-Datei mit dem Root-Passwort:") + print(" cp .env.example .env") + print(" echo 'SSH_PASSWORD=dein-passwort' > .env") + print() + print(" Oder als Umgebungsvariable:") + print(" SSH_PASSWORD=dein-passwort python3 main.py") + sys.exit(1) + + # Ensure sshpass is installed + if not ensure_sshpass(): + sys.exit(1) # Initialize SSH manager ssh = SSHManager( ssh_user=ssh_user, - ssh_key=args.ssh_key, ssh_port=args.ssh_port, ssh_password=ssh_password, ) - if ssh.uses_password: - print(f"\n [SSH] Passwort-Authentifizierung aktiv (via .env)") - else: - print(f"\n [SSH] Key-basierte Authentifizierung") + print(f"\n [SSH] Passwort-Authentifizierung aktiv (via sshpass)") rescue = RescueNetwork(ssh) # Quick mode: just print rescue commands and exit diff --git a/migrator.py b/migrator.py index 2dafb43..afcb016 100644 --- a/migrator.py +++ b/migrator.py @@ -31,58 +31,38 @@ class Migrator: print(" FEHLER: Keine Nodes erreichbar!") return False - uses_password = self.ssh.uses_password - step = 1 - total = 7 if uses_password else 8 - # Step 1: Write new configs to all nodes (but don't activate yet) - print(f"[{step}/{total}] Neue Konfigurationen verteilen...") + print("[1/7] Neue Konfigurationen verteilen...") if not self._distribute_configs(plan, configs, dry_run): return False - step += 1 - # Step 2: Preserve SSH keys (only needed for key-based auth) - if not uses_password: - print(f"\n[{step}/{total}] SSH-Keys sichern (werden nach pve-cluster stop benötigt)...") - if not self._preserve_ssh_keys(reachable_nodes, dry_run): - return False - step += 1 - else: - if not dry_run: - print(f"\n SSH-Key-Sicherung übersprungen (Passwort-Auth aktiv)") - - # Stop Corosync on all nodes - print(f"\n[{step}/{total}] Corosync stoppen auf allen Nodes...") + # Step 2: Stop Corosync on all nodes + print("\n[2/7] Corosync stoppen auf allen Nodes...") if not self._stop_corosync(reachable_nodes, dry_run): return False - step += 1 - # Stop pve-cluster (pmxcfs) to release corosync.conf - print(f"\n[{step}/{total}] pve-cluster stoppen...") + # Step 3: Stop pve-cluster (pmxcfs) to release corosync.conf + print("\n[3/7] pve-cluster stoppen...") if not self._stop_pve_cluster(reachable_nodes, dry_run): return False - step += 1 - # Write corosync config directly - print(f"\n[{step}/{total}] Corosync-Konfiguration aktualisieren...") + # Step 4: Write corosync config directly + print("\n[4/7] Corosync-Konfiguration aktualisieren...") if not self._update_corosync(reachable_nodes, configs, dry_run): return False - step += 1 - # Update /etc/hosts on all nodes - print(f"\n[{step}/{total}] /etc/hosts aktualisieren...") + # Step 5: Update /etc/hosts on all nodes + print("\n[5/7] /etc/hosts aktualisieren...") if not self._update_hosts(plan, configs, dry_run): return False - step += 1 - # Update network interfaces and restart networking - print(f"\n[{step}/{total}] Netzwerk-Interfaces aktualisieren und Netzwerk neu starten...") + # Step 6: Update network interfaces and restart networking + print("\n[6/7] Netzwerk-Interfaces aktualisieren und Netzwerk neu starten...") if not self._update_network(plan, configs, dry_run): return False - step += 1 - # Start services back up - print(f"\n[{step}/{total}] Services starten...") + # Step 7: Start services back up + print("\n[7/7] Services starten...") if not self._start_services(plan, configs, dry_run): return False @@ -177,118 +157,6 @@ class Migrator: return True - def _preserve_ssh_keys(self, nodes: list, dry_run: bool) -> bool: - """Ensure SSH keeps working after pve-cluster stop. - - When pve-cluster (pmxcfs) is stopped, /etc/pve gets unmounted and - the cluster SSH keys in /etc/pve/priv/authorized_keys disappear. - This breaks SSH between nodes. - - Fix: Copy PVE keys to ~/.ssh/authorized_keys AND ensure sshd is - configured to actually check that file (Proxmox may only check /etc/pve/). - """ - for node in nodes: - if dry_run: - print(f" [{node.name}] Würde SSH-Keys sichern") - continue - - # Step 1: Copy PVE keys to ~/.ssh/authorized_keys - copy_cmd = ( - "mkdir -p /root/.ssh && " - "cp /root/.ssh/authorized_keys /root/.ssh/authorized_keys.pre_migration 2>/dev/null; " - "if [ -f /etc/pve/priv/authorized_keys ]; then " - " cat /etc/pve/priv/authorized_keys >> /root/.ssh/authorized_keys && " - " sort -u /root/.ssh/authorized_keys > /root/.ssh/authorized_keys.tmp && " - " mv /root/.ssh/authorized_keys.tmp /root/.ssh/authorized_keys && " - " chmod 600 /root/.ssh/authorized_keys && " - " echo keys_copied; " - "else " - " echo no_pve_keys; " - "fi" - ) - rc, stdout, err = self.ssh.run_on_node( - node.ssh_host, copy_cmd, node.is_local - ) - if "keys_copied" in stdout: - print(f" [{node.name}] PVE-Keys nach ~/.ssh/authorized_keys kopiert") - elif "no_pve_keys" in stdout: - print(f" [{node.name}] Keine PVE-Keys gefunden (übersprungen)") - else: - print(f" [{node.name}] WARNUNG Key-Kopie: rc={rc} {err}") - - # Step 2: Ensure sshd checks ~/.ssh/authorized_keys - # Proxmox sshd_config may only list /etc/pve/priv/authorized_keys, - # or use AuthorizedKeysCommand pointing to /etc/pve/priv/. - # We need to ensure .ssh/authorized_keys is checked as fallback. - sshd_cmd = ( - "cp /etc/ssh/sshd_config /etc/ssh/sshd_config.pre_migration && " - "NEED_RELOAD=0 && " - # Handle AuthorizedKeysFile - "if grep -q '^AuthorizedKeysFile' /etc/ssh/sshd_config; then " - " if ! grep '^AuthorizedKeysFile' /etc/ssh/sshd_config | grep -q '.ssh/authorized_keys'; then " - " sed -i '/^AuthorizedKeysFile/s|$| .ssh/authorized_keys|' /etc/ssh/sshd_config && " - " NEED_RELOAD=1; " - " fi; " - "else " - # No AuthorizedKeysFile line = uses default (.ssh/authorized_keys), which is fine. - # But if AuthorizedKeysCommand is active, it might override. Add explicit line. - " if grep -q '^AuthorizedKeysCommand ' /etc/ssh/sshd_config; then " - " echo 'AuthorizedKeysFile .ssh/authorized_keys' >> /etc/ssh/sshd_config && " - " NEED_RELOAD=1; " - " fi; " - "fi && " - # Temporarily disable AuthorizedKeysCommand if it points to /etc/pve - "if grep '^AuthorizedKeysCommand ' /etc/ssh/sshd_config | grep -q '/etc/pve'; then " - " sed -i 's|^AuthorizedKeysCommand |#AuthorizedKeysCommand_DISABLED |' /etc/ssh/sshd_config && " - " NEED_RELOAD=1; " - "fi && " - "if [ $NEED_RELOAD -eq 1 ]; then " - " systemctl reload sshd && echo sshd_modified; " - "else " - " echo sshd_already_ok; " - "fi" - ) - rc2, stdout2, err2 = self.ssh.run_on_node( - node.ssh_host, sshd_cmd, node.is_local - ) - if "sshd_modified" in stdout2: - print(f" [{node.name}] sshd_config angepasst (.ssh/authorized_keys hinzugefügt)") - elif "sshd_already_ok" in stdout2: - print(f" [{node.name}] sshd_config OK") - else: - print(f" [{node.name}] WARNUNG sshd: {err2}") - - # Step 3: Verify SSH will still work after pve-cluster stop - # Test that ~/.ssh/authorized_keys is readable on all remote nodes - print(" [Verifikation] Prüfe ob SSH-Keys korrekt gesichert sind...") - for node in nodes: - if node.is_local: - continue - rc, stdout, _ = self.ssh.run_on_node( - node.ssh_host, - "wc -l /root/.ssh/authorized_keys 2>/dev/null || echo 0", - False, - ) - key_count = stdout.strip().split()[0] if stdout.strip() else "0" - print(f" [{node.name}] authorized_keys: {key_count} Zeilen") - - return True - - def _restore_ssh_keys(self, nodes: list): - """Restore original ~/.ssh/authorized_keys and sshd_config after migration.""" - for node in nodes: - new_host = node.new_ip if not node.is_local else node.ssh_host - cmd = ( - "if [ -f /root/.ssh/authorized_keys.pre_migration ]; then " - " mv /root/.ssh/authorized_keys.pre_migration /root/.ssh/authorized_keys; " - "fi; " - "if [ -f /etc/ssh/sshd_config.pre_migration ]; then " - " mv /etc/ssh/sshd_config.pre_migration /etc/ssh/sshd_config && " - " systemctl reload sshd 2>/dev/null; " - "fi" - ) - self.ssh.run_on_node(new_host, cmd, node.is_local) - def _stop_corosync(self, nodes: list, dry_run: bool) -> bool: """Stop corosync on all nodes.""" for node in nodes: @@ -519,11 +387,6 @@ class Migrator: if configs.get('ceph'): self._update_ceph(plan, configs) - # Restore original SSH keys (only needed for key-based auth) - if not self.ssh.uses_password: - print("\n SSH-Keys wiederherstellen...") - self._restore_ssh_keys(plan.nodes) - # Cleanup staging directories print("\n Staging-Verzeichnisse aufräumen...") for node in plan.nodes: diff --git a/ssh_manager.py b/ssh_manager.py index e11ff9d..c06a0d7 100644 --- a/ssh_manager.py +++ b/ssh_manager.py @@ -1,79 +1,40 @@ -"""SSH connection manager for remote Proxmox nodes.""" +"""SSH connection manager for remote Proxmox nodes. + +Uses sshpass + password from .env for authentication. +This is required because Proxmox stores SSH keys in /etc/pve/priv/authorized_keys, +which disappears when pve-cluster is stopped during migration. +""" import subprocess from typing import Optional class SSHManager: - """Manages SSH connections to Proxmox nodes using system ssh. + """Manages SSH connections to Proxmox nodes using sshpass + system ssh.""" - Supports two authentication modes: - - Key-based (default): Uses ssh keys (may break when pve-cluster stops) - - Password-based: Uses sshpass + password from .env (always works) - """ - - def __init__(self, ssh_user: str = "root", ssh_key: Optional[str] = None, - ssh_port: int = 22, ssh_password: Optional[str] = None): + def __init__(self, ssh_user: str = "root", ssh_port: int = 22, + ssh_password: Optional[str] = None): self.ssh_user = ssh_user - self.ssh_key = ssh_key self.ssh_port = ssh_port self.ssh_password = ssh_password def _build_ssh_cmd(self, host: str, command: str) -> list[str]: """Build the ssh command list.""" - cmd = [] - - if self.ssh_password: - cmd.extend(["sshpass", "-p", self.ssh_password]) + cmd = ["sshpass", "-p", self.ssh_password] cmd.extend([ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", + "-o", "BatchMode=no", + "-o", "PubkeyAuthentication=no", "-p", str(self.ssh_port), ]) - if self.ssh_password: - # Disable BatchMode for password auth, enable keyboard-interactive - cmd.extend(["-o", "BatchMode=no"]) - cmd.extend(["-o", "PubkeyAuthentication=no"]) - else: - cmd.extend(["-o", "BatchMode=yes"]) - - if self.ssh_key: - cmd.extend(["-i", self.ssh_key]) - cmd.append(f"{self.ssh_user}@{host}") cmd.append(command) return cmd - def _build_scp_cmd(self, host: str, local_path: str, - remote_path: str) -> list[str]: - """Build an scp command list.""" - cmd = [] - - if self.ssh_password: - cmd.extend(["sshpass", "-p", self.ssh_password]) - - cmd.extend([ - "scp", - "-o", "StrictHostKeyChecking=no", - "-o", "ConnectTimeout=10", - "-P", str(self.ssh_port), - ]) - - if self.ssh_password: - cmd.extend(["-o", "BatchMode=no"]) - cmd.extend(["-o", "PubkeyAuthentication=no"]) - else: - cmd.extend(["-o", "BatchMode=yes"]) - - if self.ssh_key: - cmd.extend(["-i", self.ssh_key]) - - cmd.extend([local_path, f"{self.ssh_user}@{host}:{remote_path}"]) - return cmd - def execute(self, host: str, command: str, timeout: int = 30) -> tuple[int, str, str]: """Execute a command on a remote host via SSH. @@ -108,7 +69,6 @@ class SSHManager: Returns: (success, message) """ - # Use heredoc via ssh to write file cmd = self._build_ssh_cmd( host, f"cat > {path} << 'PROXMOX_NET_EOF'\n{content}\nPROXMOX_NET_EOF" ) @@ -185,8 +145,3 @@ class SSHManager: if is_local: return self.write_local_file(path, content) return self.write_file(host, path, content) - - @property - def uses_password(self) -> bool: - """Whether password-based auth is active.""" - return self.ssh_password is not None