diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3633266 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# SSH-Zugangsdaten für die Cluster-Nodes +# Kopiere diese Datei nach .env und passe die Werte an: +# cp .env.example .env + +# Root-Passwort der Proxmox-Nodes (alle Nodes müssen das gleiche Passwort haben) +# Wird mit sshpass verwendet — funktioniert auch nach pve-cluster Stop +SSH_PASSWORD=dein-root-passwort + +# SSH-Benutzer (Standard: root) +#SSH_USER=root diff --git a/README.md b/README.md index 960a9f8..dee7a15 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 -- SSH-Key-Sicherung vor pve-cluster-Stop (verhindert SSH-Abbrüche) +- **Passwort-Auth via `.env`** — SSH-Passwort aus `.env`-Datei, funktioniert auch nach pve-cluster Stop - Automatische Backups aller Konfigurationen vor der Migration - Dry-Run-Modus zum gefahrlosen Testen - Verifikation nach der Migration @@ -21,7 +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 (Key-basiert) zu allen anderen Cluster-Nodes (oder Konsolenzugriff für Rescue-Modus) +- SSH-Zugriff zu allen anderen Cluster-Nodes (Key-basiert oder Passwort via `.env`) +- `sshpass` (nur bei Passwort-Auth): `apt install sshpass` - Keine externen Python-Pakete nötig (nur stdlib) ## Installation @@ -35,6 +36,46 @@ cd /root git clone proxmox-cluster-network-changer ``` +## 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. + +```bash +# .env-Datei erstellen +cp .env.example .env + +# Passwort eintragen +nano .env +``` + +`.env`-Datei: +``` +SSH_PASSWORD=dein-root-passwort +``` + +Voraussetzung: +```bash +apt install sshpass +``` + +> **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) @@ -67,6 +108,12 @@ python3 main.py Das Tool führt interaktiv durch den Prozess: ``` +============================================================ + Proxmox Cluster Network Changer +============================================================ + + [SSH] Passwort-Authentifizierung aktiv (via .env) + === Phase 1: Discovery === [Corosync] @@ -116,6 +163,16 @@ Wenn Ceph Public/Cluster auf separaten NICs liegen, werden die IPs einzeln pro N | `--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` + +| Variable | Beschreibung | +|---|---| +| `SSH_PASSWORD` | Root-Passwort für SSH (aktiviert sshpass-Modus) | +| `SSH_USER` | SSH-Benutzer (Standard: `root`) | + +Variablen können in der `.env`-Datei oder als Umgebungsvariablen gesetzt werden. ## Was wird geändert? @@ -128,14 +185,28 @@ Wenn Ceph Public/Cluster auf separaten NICs liegen, werden die IPs einzeln pro N ## Migrationsablauf (Phase 4) +### Mit Passwort-Auth (empfohlen, 7 Schritte) + 1. Neue Konfigurationen werden auf alle Nodes verteilt (Staging) -2. SSH-Keys sichern (`/etc/pve/priv/authorized_keys` → `~/.ssh/authorized_keys`) +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 + +> 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 aufräumen +8. Services starten, Quorum abwarten, Ceph aktualisieren, SSH-Keys wiederherstellen ## Rescue-Netzwerk (Emergency Mode) @@ -254,12 +325,12 @@ systemctl restart corosync ## Hinweise - Das Tool muss als **root** ausgeführt werden -- SSH-Keys müssen **vorher** zwischen den Nodes eingerichtet sein (bei Proxmox-Clustern standardmäßig der Fall) +- 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) - 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 -- SSH-Keys werden vor dem pve-cluster-Stop gesichert und danach wiederhergestellt, damit SSH während der Migration nicht abbricht - Getestet mit Proxmox VE 7.x und 8.x ## Projektstruktur @@ -270,12 +341,13 @@ proxmox-cluster-network-changer/ ├── 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 (8 Schritte) +├── migrator.py # Phase 4: Migration durchführen ├── verifier.py # Phase 5: Post-Migration Checks ├── rescue.py # Rescue-Netzwerk (Emergency Mode) -├── ssh_manager.py # SSH-Verbindungen (lokal + remote) +├── ssh_manager.py # SSH-Verbindungen (Key + Passwort via sshpass) ├── config_parser.py # Parser für Corosync/Ceph/Network Configs ├── models.py # Dataclasses (NodeInfo, CorosyncConfig, etc.) +├── .env.example # Vorlage für SSH-Credentials └── requirements.txt # Keine externen Dependencies ``` @@ -284,8 +356,9 @@ proxmox-cluster-network-changer/ ### Szenario 1: Normaler Umzug (alles funktioniert noch) ```bash -python3 main.py --dry-run # Erst testen -python3 main.py # Dann ausführen +cp .env.example .env && nano .env # Passwort eintragen +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 39cec39..9edbea8 100644 --- a/main.py +++ b/main.py @@ -18,6 +18,8 @@ Verwendung: import argparse import os +import shutil +import subprocess import sys from ssh_manager import SSHManager @@ -29,6 +31,28 @@ from verifier import Verifier from rescue import RescueNetwork +def load_env(env_file: str = ".env") -> dict[str, str]: + """Load variables from a .env file (simple key=value parser).""" + env = {} + if not os.path.exists(env_file): + return env + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' not in line: + continue + key, _, value = line.partition('=') + key = key.strip() + # Strip surrounding quotes + value = value.strip() + if len(value) >= 2 and value[0] in ('"', "'") and value[-1] == value[0]: + value = value[1:-1] + env[key] = value + return env + + def check_prerequisites(): """Check that we're running as root on a Proxmox node.""" if os.geteuid() != 0: @@ -75,6 +99,10 @@ def main(): help="Nur Rescue-Befehle ausgeben ohne Migration " "(z.B. --rescue-commands 10.99.99.0/24)" ) + parser.add_argument( + "--env-file", type=str, default=".env", + help="Pfad zur .env-Datei mit SSH_PASSWORD (Standard: .env)" + ) args = parser.parse_args() print("=" * 60) @@ -83,8 +111,42 @@ def main(): check_prerequisites() + # Load .env file + env = load_env(args.env_file) + 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 + # Initialize SSH manager - ssh = SSHManager(ssh_key=args.ssh_key, ssh_port=args.ssh_port) + 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") rescue = RescueNetwork(ssh) # Quick mode: just print rescue commands and exit diff --git a/migrator.py b/migrator.py index 7031f41..6977894 100644 --- a/migrator.py +++ b/migrator.py @@ -31,44 +31,58 @@ 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("[1/8] Neue Konfigurationen verteilen...") + print(f"[{step}/{total}] Neue Konfigurationen verteilen...") if not self._distribute_configs(plan, configs, dry_run): return False + step += 1 - # Step 2: Preserve SSH keys before stopping pve-cluster - # /etc/pve/priv/authorized_keys gets unmounted when pve-cluster stops! - print("\n[2/8] SSH-Keys sichern (werden nach pve-cluster stop benötigt)...") - if not self._preserve_ssh_keys(reachable_nodes, dry_run): - return False + # 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)") - # Step 3: Stop Corosync on all nodes - print("\n[3/8] Corosync stoppen auf allen Nodes...") + # Stop Corosync on all nodes + print(f"\n[{step}/{total}] Corosync stoppen auf allen Nodes...") if not self._stop_corosync(reachable_nodes, dry_run): return False + step += 1 - # Step 4: Stop pve-cluster (pmxcfs) to release corosync.conf - print("\n[4/8] pve-cluster stoppen...") + # Stop pve-cluster (pmxcfs) to release corosync.conf + print(f"\n[{step}/{total}] pve-cluster stoppen...") if not self._stop_pve_cluster(reachable_nodes, dry_run): return False + step += 1 - # Step 5: Write corosync config directly - print("\n[5/8] Corosync-Konfiguration aktualisieren...") + # Write corosync config directly + print(f"\n[{step}/{total}] Corosync-Konfiguration aktualisieren...") if not self._update_corosync(reachable_nodes, configs, dry_run): return False + step += 1 - # Step 6: Update /etc/hosts on all nodes - print("\n[6/8] /etc/hosts aktualisieren...") + # Update /etc/hosts on all nodes + print(f"\n[{step}/{total}] /etc/hosts aktualisieren...") if not self._update_hosts(plan, configs, dry_run): return False + step += 1 - # Step 7: Update network interfaces and restart networking - print("\n[7/8] Netzwerk-Interfaces aktualisieren und Netzwerk neu starten...") + # Update network interfaces and restart networking + print(f"\n[{step}/{total}] Netzwerk-Interfaces aktualisieren und Netzwerk neu starten...") if not self._update_network(plan, configs, dry_run): return False + step += 1 - # Step 8: Start services back up - print("\n[8/8] Services starten...") + # Start services back up + print(f"\n[{step}/{total}] Services starten...") if not self._start_services(plan, configs, dry_run): return False @@ -456,9 +470,10 @@ class Migrator: if configs.get('ceph'): self._update_ceph(plan, configs) - # Restore original SSH keys (pve-cluster manages them again now) - print("\n SSH-Keys wiederherstellen...") - self._restore_ssh_keys(plan.nodes) + # 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...") diff --git a/ssh_manager.py b/ssh_manager.py index 130f0cf..e11ff9d 100644 --- a/ssh_manager.py +++ b/ssh_manager.py @@ -5,29 +5,75 @@ from typing import Optional class SSHManager: - """Manages SSH connections to Proxmox nodes using system ssh.""" + """Manages SSH connections to Proxmox nodes using 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_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 = [ + cmd = [] + + if self.ssh_password: + cmd.extend(["sshpass", "-p", self.ssh_password]) + + cmd.extend([ "ssh", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=10", - "-o", "BatchMode=yes", "-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. @@ -63,8 +109,9 @@ class SSHManager: Returns: (success, message) """ # Use heredoc via ssh to write file - escaped = content.replace("'", "'\\''") - cmd = self._build_ssh_cmd(host, f"cat > {path} << 'PROXMOX_NET_EOF'\n{content}\nPROXMOX_NET_EOF") + cmd = self._build_ssh_cmd( + host, f"cat > {path} << 'PROXMOX_NET_EOF'\n{content}\nPROXMOX_NET_EOF" + ) try: result = subprocess.run( cmd, @@ -138,3 +185,8 @@ 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