diff --git a/README.md b/README.md index 1875221..abc9d18 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # NetBox VM Import Desktop -Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschinen aus VMware ESXi/vSphere nach NetBox. +Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschinen aus VMware ESXi/vSphere und Proxmox VE nach NetBox. ## Funktionen -- Mehrere kundenbezogene VMware- und NetBox-Profile -- Mit Windows DPAPI verschlüsselte Passwörter und API-Token +- Mehrere VMware- und Proxmox-Quellen pro Kundenprofil +- QEMU/KVM-VMs und optional LXC-Container aus Proxmox VE +- Mit Windows DPAPI verschlüsselte Passwörter, API-Token und Token-Secrets - Optionale Unterstützung selbstsignierter TLS-Zertifikate - Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen - Übernahme primärer IPv4- und IPv6-Adressen @@ -30,4 +31,3 @@ python app.py ```powershell pyinstaller "NetBox VM Import.spec" ``` - diff --git a/app.py b/app.py index de65fd1..710e5ba 100644 --- a/app.py +++ b/app.py @@ -1,316 +1,163 @@ -import ssl -import atexit import ipaddress -import json -import os +import re +import ssl import sys +import uuid from pathlib import Path import tkinter as tk -from tkinter import ttk, messagebox, simpledialog +from tkinter import messagebox, simpledialog, ttk -from pyVim.connect import SmartConnect, Disconnect -from pyVmomi import vim import pynetbox +import requests +from pyVim.connect import Disconnect, SmartConnect +from pyVmomi import vim + from profile_store import ProfileStore -APP_NAME = "NetBox VM Import" +class SourceDialog(tk.Toplevel): + """Editor for one VMware or Proxmox connection.""" + + def __init__(self, parent, source=None): + super().__init__(parent) + self.title("Virtualisierungsquelle") + self.geometry("520x440") + self.resizable(False, False) + self.transient(parent) + self.grab_set() + self.result = None + source = source or {} + + self.source_id = source.get("id", uuid.uuid4().hex) + self.source_type = tk.StringVar(value=source.get("type", "vmware")) + self.enabled = tk.BooleanVar(value=source.get("enabled", True)) + self.ignore_ssl = tk.BooleanVar(value=source.get("ignore_ssl", False)) + self.include_lxc = tk.BooleanVar(value=source.get("include_lxc", True)) + + body = ttk.Frame(self, padding=20) + body.pack(fill="both", expand=True) + body.columnconfigure(1, weight=1) + + ttk.Label(body, text="Quellentyp").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=7) + type_combo = ttk.Combobox( + body, textvariable=self.source_type, state="readonly", + values=("vmware", "proxmox"), + ) + type_combo.grid(row=0, column=1, sticky="ew", pady=7) + type_combo.bind("<>", self.update_labels) + + self.name_entry = self.add_entry(body, 1, "Anzeigename", source.get("name", "")) + self.endpoint_label = ttk.Label(body, text="Host / vCenter") + self.endpoint_label.grid(row=2, column=0, sticky="w", padx=(0, 12), pady=7) + self.endpoint_entry = ttk.Entry(body) + self.endpoint_entry.grid(row=2, column=1, sticky="ew", pady=7) + self.endpoint_entry.insert(0, source.get("host", source.get("url", ""))) + + self.identity_label = ttk.Label(body, text="Benutzer") + self.identity_label.grid(row=3, column=0, sticky="w", padx=(0, 12), pady=7) + self.identity_entry = ttk.Entry(body) + self.identity_entry.grid(row=3, column=1, sticky="ew", pady=7) + self.identity_entry.insert(0, source.get("username", source.get("token_id", ""))) + + self.secret_label = ttk.Label(body, text="Passwort") + self.secret_label.grid(row=4, column=0, sticky="w", padx=(0, 12), pady=7) + self.secret_entry = ttk.Entry(body, show="•") + self.secret_entry.grid(row=4, column=1, sticky="ew", pady=7) + self.secret_entry.insert(0, source.get("password", source.get("token_secret", ""))) + + ttk.Checkbutton(body, text="Quelle aktivieren", variable=self.enabled).grid( + row=5, column=0, columnspan=2, sticky="w", pady=(12, 4) + ) + ttk.Checkbutton(body, text="TLS-Zertifikatsprüfung deaktivieren", variable=self.ignore_ssl).grid( + row=6, column=0, columnspan=2, sticky="w", pady=4 + ) + self.lxc_check = ttk.Checkbutton(body, text="LXC-Container mit einlesen", variable=self.include_lxc) + self.lxc_check.grid(row=7, column=0, columnspan=2, sticky="w", pady=4) + self.help_text = ttk.Label(body, foreground="#52606d", wraplength=470) + self.help_text.grid(row=8, column=0, columnspan=2, sticky="w", pady=(12, 0)) + + buttons = ttk.Frame(body) + buttons.grid(row=9, column=0, columnspan=2, sticky="e", pady=(20, 0)) + ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left", padx=4) + ttk.Button(buttons, text="Übernehmen", command=self.accept, style="Primary.TButton").pack(side="left", padx=4) + self.update_labels() + self.protocol("WM_DELETE_WINDOW", self.destroy) + self.wait_window(self) + + @staticmethod + def add_entry(parent, row, label, value): + ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=7) + entry = ttk.Entry(parent) + entry.grid(row=row, column=1, sticky="ew", pady=7) + entry.insert(0, value) + return entry + + def update_labels(self, _event=None): + proxmox = self.source_type.get() == "proxmox" + self.endpoint_label.config(text="Proxmox-URL" if proxmox else "Host / vCenter") + self.identity_label.config(text="API-Token-ID" if proxmox else "Benutzer") + self.secret_label.config(text="API-Token-Secret" if proxmox else "Passwort") + self.help_text.config(text=( + "Beispiel: https://pve.example.de:8006 · Token-ID: user@pve!netbox" + if proxmox else "Beispiel: vcenter.example.de oder 192.0.2.10" + )) + if proxmox: + self.lxc_check.state(["!disabled"]) + else: + self.lxc_check.state(["disabled"]) + + def accept(self): + name = self.name_entry.get().strip() + endpoint = self.endpoint_entry.get().strip() + identity = self.identity_entry.get().strip() + secret = self.secret_entry.get() + if not all((name, endpoint, identity, secret)): + messagebox.showwarning("Fehlende Angaben", "Bitte alle Verbindungsfelder ausfüllen.", parent=self) + return + source_type = self.source_type.get() + self.result = { + "id": self.source_id, + "type": source_type, + "name": name, + "enabled": self.enabled.get(), + "ignore_ssl": self.ignore_ssl.get(), + } + if source_type == "vmware": + self.result.update({"host": endpoint, "username": identity, "password": secret}) + else: + if not endpoint.startswith(("http://", "https://")): + endpoint = f"https://{endpoint}" + self.result.update({ + "url": endpoint.rstrip("/"), "token_id": identity, + "token_secret": secret, "include_lxc": self.include_lxc.get(), + }) + self.destroy() -def get_config_path(): - """Return a per-user config path that also works in a packaged EXE.""" - if sys.platform == "win32": - base_dir = Path(os.environ.get("APPDATA", Path.home())) - else: - base_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - return base_dir / APP_NAME / "config.json" - - -class ESXiNetBoxImporter: - +class NetBoxVMImporter: def __init__(self, root): self.root = root - self.root.title("VMware → NetBox Synchronizer") - self.root.geometry("1080x820") - self.root.minsize(900, 680) + self.root.title("VMware & Proxmox → NetBox Synchronizer") + self.root.geometry("1160x850") + self.root.minsize(960, 700) - self.si = None self.nb = None self.vms = [] - - self.site_map = {} + self.profiles = {} + self.current_sources = [] self.cluster_map = {} self.tenant_map = {} self.platforms = [] - - self.save_credentials = tk.BooleanVar(value=False) - self.ignore_ssl_errors = tk.BooleanVar(value=False) - self.config_path = get_config_path() self.profile_name = tk.StringVar() + self.netbox_ignore_ssl = tk.BooleanVar(value=False) self.status_text = tk.StringVar(value="Bereit") self.store = ProfileStore() - self.profiles = {} self.configure_style() self.create_gui() self.load_profiles() self.set_window_icon() - # ========================================================= - # GUI - # ========================================================= - - def create_gui(self): - - main = ttk.Frame(self.root, padding=10) - main.pack(fill="both", expand=True) - - # ============================================ - # ESXi / vCenter - # ============================================ - - ttk.Label(main, text="ESXi / vCenter Host").grid( - row=0, - column=0, - sticky="w" - ) - - self.esxi_host = ttk.Entry(main, width=45) - self.esxi_host.grid(row=0, column=1, sticky="ew") - - ttk.Label(main, text="Username").grid( - row=1, - column=0, - sticky="w" - ) - - self.esxi_user = ttk.Entry(main, width=45) - self.esxi_user.grid(row=1, column=1, sticky="ew") - - ttk.Label(main, text="Password").grid( - row=2, - column=0, - sticky="w" - ) - - self.esxi_pass = ttk.Entry(main, width=45, show="*") - self.esxi_pass.grid(row=2, column=1, sticky="ew") - - # ============================================ - # NetBox - # ============================================ - - ttk.Label(main, text="NetBox URL").grid( - row=3, - column=0, - sticky="w" - ) - - self.nb_url = ttk.Entry(main, width=45) - self.nb_url.grid(row=3, column=1, sticky="ew") - - ttk.Label(main, text="NetBox Token").grid( - row=4, - column=0, - sticky="w" - ) - - self.nb_token = ttk.Entry(main, width=45, show="*") - self.nb_token.grid(row=4, column=1, sticky="ew") - - options = ttk.Frame(main) - options.grid(row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) - - ttk.Checkbutton( - options, - text="Verbindungsdaten speichern (inkl. Passwort und Token)", - variable=self.save_credentials - ).pack(anchor="w") - - ttk.Checkbutton( - options, - text="TLS-Zertifikatsprüfung deaktivieren (z. B. selbstsignierte Zertifikate)", - variable=self.ignore_ssl_errors - ).pack(anchor="w") - - ttk.Button( - main, - text="Verbinden & Daten laden", - command=self.connect_all - ).grid( - row=6, - column=1, - sticky="e", - pady=10 - ) - - # ============================================ - # Zielauswahl - # ============================================ - - ttk.Label(main, text="Site").grid( - row=7, - column=0, - sticky="w" - ) - - self.site_combo = ttk.Combobox(main, width=42) - self.site_combo.grid(row=7, column=1, sticky="ew") - - ttk.Label(main, text="Cluster").grid( - row=8, - column=0, - sticky="w" - ) - - self.cluster_combo = ttk.Combobox(main, width=42) - self.cluster_combo.grid(row=8, column=1, sticky="ew") - - ttk.Label(main, text="Tenant").grid( - row=9, - column=0, - sticky="w" - ) - - self.tenant_combo = ttk.Combobox(main, width=42) - self.tenant_combo.grid(row=9, column=1, sticky="ew") - - # ============================================ - # VM Liste - # ============================================ - - ttk.Label(main, text="Gefundene VMs").grid( - row=10, - column=0, - sticky="w", - pady=(15, 5) - ) - - self.vm_list = tk.Listbox( - main, - selectmode=tk.MULTIPLE, - width=100, - height=25 - ) - - self.vm_list.grid( - row=11, - column=0, - columnspan=2, - sticky="nsew" - ) - - scrollbar = ttk.Scrollbar( - main, - orient="vertical", - command=self.vm_list.yview - ) - - scrollbar.grid(row=11, column=2, sticky="ns") - - self.vm_list.config(yscrollcommand=scrollbar.set) - - # ============================================ - # Buttons - # ============================================ - - btn_frame = ttk.Frame(main) - - btn_frame.grid( - row=12, - column=0, - columnspan=2, - pady=10, - sticky="e" - ) - - ttk.Button( - btn_frame, - text="Alle auswählen", - command=self.select_all - ).pack(side="left", padx=5) - - ttk.Button( - btn_frame, - text="Import / Sync starten", - command=self.import_vms - ).pack(side="left", padx=5) - - # ============================================ - # Logfeld - # ============================================ - - ttk.Label(main, text="Log").grid( - row=13, - column=0, - sticky="w" - ) - - self.log_text = tk.Text(main, height=10) - - self.log_text.grid( - row=14, - column=0, - columnspan=2, - sticky="nsew" - ) - - main.columnconfigure(1, weight=1) - main.rowconfigure(11, weight=1) - - # ========================================================= - # Einstellungen - # ========================================================= - - def load_settings(self): - if not self.config_path.exists(): - return - - try: - with self.config_path.open("r", encoding="utf-8") as config_file: - settings = json.load(config_file) - - fields = ( - (self.esxi_host, "esxi_host"), - (self.esxi_user, "esxi_user"), - (self.esxi_pass, "esxi_password"), - (self.nb_url, "netbox_url"), - (self.nb_token, "netbox_token"), - ) - for widget, key in fields: - widget.insert(0, settings.get(key, "")) - - self.save_credentials.set(True) - self.ignore_ssl_errors.set( - bool(settings.get("ignore_ssl_errors", False)) - ) - except (OSError, ValueError) as error: - messagebox.showwarning( - "Konfiguration", - f"Gespeicherte Einstellungen konnten nicht geladen werden:\n{error}" - ) - - def save_settings(self): - if not self.save_credentials.get(): - if self.config_path.exists(): - self.config_path.unlink() - return - - settings = { - "esxi_host": self.esxi_host.get().strip(), - "esxi_user": self.esxi_user.get().strip(), - "esxi_password": self.esxi_pass.get(), - "netbox_url": self.nb_url.get().strip(), - "netbox_token": self.nb_token.get(), - "ignore_ssl_errors": self.ignore_ssl_errors.get(), - } - self.config_path.parent.mkdir(parents=True, exist_ok=True) - with self.config_path.open("w", encoding="utf-8") as config_file: - json.dump(settings, config_file, indent=2) - - # The following GUI/profile methods intentionally replace the original - # single-connection UI while keeping the import logic below unchanged. - def configure_style(self): style = ttk.Style(self.root) if "vista" in style.theme_names(): @@ -321,10 +168,11 @@ class ESXiNetBoxImporter: style.configure("Card.TLabelframe.Label", font=("Segoe UI Semibold", 11)) style.configure("Primary.TButton", font=("Segoe UI Semibold", 10), padding=(14, 8)) style.configure("Status.TLabel", padding=(10, 6), foreground="#334e68") + style.configure("Treeview", rowheight=27, font=("Segoe UI", 9)) + style.configure("Treeview.Heading", font=("Segoe UI Semibold", 9)) def resource_path(self, filename): - base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) - return base / filename + return Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent)) / filename def set_window_icon(self): icon = self.resource_path("netbox_vm_import.ico") @@ -337,55 +185,79 @@ class ESXiNetBoxImporter: def create_gui(self): main = ttk.Frame(self.root, padding=18) main.pack(fill="both", expand=True) + main.columnconfigure(0, weight=1) + main.rowconfigure(4, weight=3) + main.rowconfigure(6, weight=1) header = ttk.Frame(main) - header.grid(row=0, column=0, sticky="ew", pady=(0, 14)) - ttk.Label(header, text="VMware → NetBox", style="Title.TLabel").pack(anchor="w") - ttk.Label(header, text="Virtuelle Maschinen kundenbezogen synchronisieren", style="Subtitle.TLabel").pack(anchor="w") + header.grid(row=0, column=0, sticky="ew", pady=(0, 12)) + ttk.Label(header, text="VMware & Proxmox → NetBox", style="Title.TLabel").pack(anchor="w") + ttk.Label(header, text="Mehrere Virtualisierungsquellen kundenbezogen synchronisieren", style="Subtitle.TLabel").pack(anchor="w") profile_card = ttk.LabelFrame(main, text="Kundenprofil", style="Card.TLabelframe") - profile_card.grid(row=1, column=0, sticky="ew", pady=(0, 12)) - self.profile_combo = ttk.Combobox(profile_card, textvariable=self.profile_name, state="readonly", width=34) + profile_card.grid(row=1, column=0, sticky="ew", pady=(0, 10)) + self.profile_combo = ttk.Combobox(profile_card, textvariable=self.profile_name, state="readonly") self.profile_combo.pack(side="left", fill="x", expand=True, padx=(0, 8)) self.profile_combo.bind("<>", self.on_profile_selected) ttk.Button(profile_card, text="Neu", command=self.new_profile).pack(side="left", padx=3) ttk.Button(profile_card, text="Speichern", command=self.save_profile).pack(side="left", padx=3) - ttk.Button(profile_card, text="Löschen", command=self.delete_profile).pack(side="left", padx=(3, 0)) + ttk.Button(profile_card, text="Löschen", command=self.delete_profile).pack(side="left", padx=3) - connections = ttk.Frame(main) - connections.grid(row=2, column=0, sticky="ew", pady=(0, 12)) - connections.columnconfigure(0, weight=1, uniform="connections") - connections.columnconfigure(1, weight=1, uniform="connections") - vmware = ttk.LabelFrame(connections, text="VMware vSphere / ESXi", style="Card.TLabelframe") - netbox = ttk.LabelFrame(connections, text="NetBox", style="Card.TLabelframe") - vmware.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) - netbox.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) - self.esxi_host = self.add_field(vmware, 0, "Host / vCenter") - self.esxi_user = self.add_field(vmware, 1, "Benutzer") - self.esxi_pass = self.add_field(vmware, 2, "Passwort", secret=True) - self.nb_url = self.add_field(netbox, 0, "URL") - self.nb_token = self.add_field(netbox, 1, "API-Token", secret=True) - ttk.Checkbutton(netbox, text="Zertifikatsprüfung deaktivieren", variable=self.ignore_ssl_errors).grid(row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)) + connections = ttk.Panedwindow(main, orient="horizontal") + connections.grid(row=2, column=0, sticky="nsew", pady=(0, 10)) + source_card = ttk.LabelFrame(connections, text="Virtualisierungsquellen", style="Card.TLabelframe") + netbox_card = ttk.LabelFrame(connections, text="NetBox-Ziel", style="Card.TLabelframe") + connections.add(source_card, weight=3) + connections.add(netbox_card, weight=2) + + self.source_tree = ttk.Treeview(source_card, columns=("type", "endpoint", "active"), show="headings", height=5) + self.source_tree.heading("type", text="Typ") + self.source_tree.heading("endpoint", text="Quelle") + self.source_tree.heading("active", text="Aktiv") + self.source_tree.column("type", width=90, stretch=False) + self.source_tree.column("endpoint", width=320) + self.source_tree.column("active", width=60, stretch=False, anchor="center") + self.source_tree.pack(fill="both", expand=True) + self.source_tree.bind("", lambda _event: self.edit_source()) + source_buttons = ttk.Frame(source_card) + source_buttons.pack(fill="x", pady=(8, 0)) + ttk.Button(source_buttons, text="Quelle hinzufügen", command=self.add_source).pack(side="left") + ttk.Button(source_buttons, text="Bearbeiten", command=self.edit_source).pack(side="left", padx=5) + ttk.Button(source_buttons, text="Entfernen", command=self.remove_source).pack(side="left") + + self.netbox_url = self.add_field(netbox_card, 0, "URL") + self.netbox_token = self.add_field(netbox_card, 1, "API-Token", secret=True) + ttk.Checkbutton(netbox_card, text="Zertifikatsprüfung deaktivieren", variable=self.netbox_ignore_ssl).grid( + row=2, column=0, columnspan=2, sticky="w", pady=(10, 0) + ) + ttk.Label(netbox_card, text="Secrets werden mit Windows DPAPI verschlüsselt.", style="Subtitle.TLabel").grid( + row=3, column=0, columnspan=2, sticky="w", pady=(12, 0) + ) action_row = ttk.Frame(main) - action_row.grid(row=3, column=0, sticky="ew", pady=(0, 12)) - ttk.Label(action_row, text="Passwort und Token werden mit Windows DPAPI verschlüsselt.", style="Subtitle.TLabel").pack(side="left") - ttk.Button(action_row, text="Verbinden & Daten laden", command=self.connect_all, style="Primary.TButton").pack(side="right") + action_row.grid(row=3, column=0, sticky="ew", pady=(0, 10)) + ttk.Button(action_row, text="Verbinden & VMs laden", command=self.connect_all, style="Primary.TButton").pack(side="right") workspace = ttk.Panedwindow(main, orient="horizontal") workspace.grid(row=4, column=0, sticky="nsew") - targets = ttk.LabelFrame(workspace, text="NetBox-Ziel", style="Card.TLabelframe") + target_card = ttk.LabelFrame(workspace, text="Importziel", style="Card.TLabelframe") vm_card = ttk.LabelFrame(workspace, text="Gefundene virtuelle Maschinen", style="Card.TLabelframe") - workspace.add(targets, weight=1) - workspace.add(vm_card, weight=3) - self.site_combo = self.add_combo_field(targets, 0, "Site") - self.cluster_combo = self.add_combo_field(targets, 1, "Cluster") - self.tenant_combo = self.add_combo_field(targets, 2, "Tenant") - targets.columnconfigure(0, weight=1) - self.vm_list = tk.Listbox(vm_card, selectmode=tk.MULTIPLE, height=14, borderwidth=0, highlightthickness=1, highlightcolor="#2f80ed", font=("Segoe UI", 10)) - vm_scroll = ttk.Scrollbar(vm_card, orient="vertical", command=self.vm_list.yview) - self.vm_list.configure(yscrollcommand=vm_scroll.set) - self.vm_list.pack(side="left", fill="both", expand=True) + workspace.add(target_card, weight=1) + workspace.add(vm_card, weight=4) + self.cluster_combo = self.add_combo_field(target_card, 0, "Cluster") + self.tenant_combo = self.add_combo_field(target_card, 1, "Tenant") + target_card.columnconfigure(0, weight=1) + + vm_columns = ("name", "source", "kind", "status", "cpu", "memory") + self.vm_tree = ttk.Treeview(vm_card, columns=vm_columns, show="headings", selectmode="extended") + headings = {"name": "Name", "source": "Quelle", "kind": "Typ", "status": "Status", "cpu": "vCPU", "memory": "RAM (MB)"} + widths = {"name": 210, "source": 160, "kind": 80, "status": 80, "cpu": 55, "memory": 80} + for column in vm_columns: + self.vm_tree.heading(column, text=headings[column]) + self.vm_tree.column(column, width=widths[column], anchor="center" if column in ("kind", "status", "cpu", "memory") else "w") + vm_scroll = ttk.Scrollbar(vm_card, orient="vertical", command=self.vm_tree.yview) + self.vm_tree.configure(yscrollcommand=vm_scroll.set) + self.vm_tree.pack(side="left", fill="both", expand=True) vm_scroll.pack(side="right", fill="y") buttons = ttk.Frame(main) @@ -398,23 +270,32 @@ class ESXiNetBoxImporter: self.log_text = tk.Text(log_card, height=7, borderwidth=0, bg="#f7f9fb", foreground="#243b53", font=("Consolas", 9), state="disabled") self.log_text.pack(fill="both", expand=True) ttk.Label(main, textvariable=self.status_text, style="Status.TLabel", anchor="w").grid(row=7, column=0, sticky="ew", pady=(6, 0)) - main.columnconfigure(0, weight=1) - main.rowconfigure(4, weight=3) - main.rowconfigure(6, weight=1) - def add_field(self, parent, row, label, secret=False): + @staticmethod + def add_field(parent, row, label, secret=False): ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=5) entry = ttk.Entry(parent, show="•" if secret else "") entry.grid(row=row, column=1, sticky="ew", pady=5) parent.columnconfigure(1, weight=1) return entry - def add_combo_field(self, parent, row, label): + @staticmethod + def add_combo_field(parent, row, label): ttk.Label(parent, text=label).grid(row=row * 2, column=0, sticky="w", pady=(5, 2)) combo = ttk.Combobox(parent, state="readonly") combo.grid(row=row * 2 + 1, column=0, sticky="ew", pady=(0, 8)) return combo + def log(self, text): + self.log_text.config(state="normal") + self.log_text.insert(tk.END, f"{text}\n") + self.log_text.see(tk.END) + self.log_text.config(state="disabled") + self.status_text.set(text) + self.root.update() + + # Profiles and sources + def load_profiles(self): try: self.profiles, active = self.store.load() @@ -431,20 +312,28 @@ class ESXiNetBoxImporter: def profile_from_form(self, name): return { - "name": name, "esxi_host": self.esxi_host.get().strip(), - "esxi_user": self.esxi_user.get().strip(), "esxi_password": self.esxi_pass.get(), - "netbox_url": self.nb_url.get().strip(), "netbox_token": self.nb_token.get(), - "ignore_ssl_errors": self.ignore_ssl_errors.get(), + "name": name, + "netbox_url": self.netbox_url.get().strip(), + "netbox_token": self.netbox_token.get(), + "netbox_ignore_ssl": self.netbox_ignore_ssl.get(), + "sources": [dict(source) for source in self.current_sources], } def populate_profile(self, name): profile = self.profiles.get(name, {}) - for widget, key in ((self.esxi_host, "esxi_host"), (self.esxi_user, "esxi_user"), (self.esxi_pass, "esxi_password"), (self.nb_url, "netbox_url"), (self.nb_token, "netbox_token")): - widget.delete(0, tk.END) - widget.insert(0, profile.get(key, "")) - self.ignore_ssl_errors.set(bool(profile.get("ignore_ssl_errors", False))) + self.set_entry(self.netbox_url, profile.get("netbox_url", "")) + self.set_entry(self.netbox_token, profile.get("netbox_token", "")) + self.netbox_ignore_ssl.set(bool(profile.get("netbox_ignore_ssl", False))) + self.current_sources = [dict(source) for source in profile.get("sources", [])] + self.refresh_source_tree() + self.clear_loaded_data() self.status_text.set(f"Profil „{name}“ geladen") + @staticmethod + def set_entry(widget, value): + widget.delete(0, tk.END) + widget.insert(0, value) + def on_profile_selected(self, _event=None): self.populate_profile(self.profile_name.get()) @@ -455,16 +344,14 @@ class ESXiNetBoxImporter: name = name.strip() if name in self.profiles and not messagebox.askyesno("Profil vorhanden", "Dieses Profil überschreiben?"): return - self.clear_profile_form() self.profile_name.set(name) + self.set_entry(self.netbox_url, "") + self.set_entry(self.netbox_token, "") + self.netbox_ignore_ssl.set(False) + self.current_sources = [] + self.refresh_source_tree() self.profiles[name] = self.profile_from_form(name) self.refresh_profile_list() - self.status_text.set(f"Neues Profil „{name}“ – Daten eintragen und speichern") - - def clear_profile_form(self): - for widget in (self.esxi_host, self.esxi_user, self.esxi_pass, self.nb_url, self.nb_token): - widget.delete(0, tk.END) - self.ignore_ssl_errors.set(False) def save_profile(self, show_message=True): name = self.profile_name.get().strip() @@ -497,469 +384,459 @@ class ESXiNetBoxImporter: if next_name: self.populate_profile(next_name) else: - self.clear_profile_form() - self.status_text.set("Profil gelöscht") + self.set_entry(self.netbox_url, "") + self.set_entry(self.netbox_token, "") + self.current_sources = [] + self.refresh_source_tree() - # ========================================================= - # Logging - # ========================================================= + def refresh_source_tree(self): + self.source_tree.delete(*self.source_tree.get_children()) + for source in self.current_sources: + endpoint = source.get("host", source.get("url", "")) + self.source_tree.insert("", "end", iid=source["id"], values=( + source.get("type", "").upper(), f"{source.get('name', '')} · {endpoint}", + "Ja" if source.get("enabled", True) else "Nein", + )) - def log(self, text): - self.log_text.config(state="normal") - self.log_text.insert(tk.END, f"{text}\n") - self.log_text.see(tk.END) - self.log_text.config(state="disabled") - self.status_text.set(text) - self.root.update() + def add_source(self): + dialog = SourceDialog(self.root) + if dialog.result: + self.current_sources.append(dialog.result) + self.refresh_source_tree() - # ========================================================= - # Select All - # ========================================================= + def selected_source(self): + selected = self.source_tree.selection() + if not selected: + return None + return next((source for source in self.current_sources if source["id"] == selected[0]), None) - def select_all(self): + def edit_source(self): + source = self.selected_source() + if not source: + messagebox.showinfo("Quelle", "Bitte eine Quelle auswählen.") + return + dialog = SourceDialog(self.root, source) + if dialog.result: + index = self.current_sources.index(source) + self.current_sources[index] = dialog.result + self.refresh_source_tree() + self.source_tree.selection_set(dialog.result["id"]) - self.vm_list.select_set(0, tk.END) + def remove_source(self): + source = self.selected_source() + if source and messagebox.askyesno("Quelle entfernen", f"Quelle „{source['name']}“ entfernen?"): + self.current_sources.remove(source) + self.refresh_source_tree() - # ========================================================= - # Verbindungen - # ========================================================= + def clear_loaded_data(self): + self.vms = [] + if hasattr(self, "vm_tree"): + self.vm_tree.delete(*self.vm_tree.get_children()) + for combo in (getattr(self, "cluster_combo", None), getattr(self, "tenant_combo", None)): + if combo: + combo.set("") + combo["values"] = () + + # Connections and loading def connect_all(self): - try: - if not all((self.esxi_host.get().strip(), self.esxi_user.get().strip(), self.esxi_pass.get(), self.nb_url.get().strip(), self.nb_token.get())): - raise ValueError("Bitte alle VMware- und NetBox-Verbindungsdaten ausfüllen.") + if not self.netbox_url.get().strip() or not self.netbox_token.get(): + raise ValueError("Bitte NetBox-URL und API-Token ausfüllen.") + active_sources = [source for source in self.current_sources if source.get("enabled", True)] + if not active_sources: + raise ValueError("Bitte mindestens eine aktive Virtualisierungsquelle anlegen.") if not self.save_profile(show_message=False): return - self.connect_esxi() self.connect_netbox() - self.load_netbox_data() - self.load_vms() - - messagebox.showinfo( - "Erfolg", - "Verbindung erfolgreich hergestellt" - ) - - except Exception as e: - - messagebox.showerror( - "Fehler", - str(e) - ) - - def connect_esxi(self): - - self.log("Verbinde zu ESXi/vCenter...") - - context = None - if self.ignore_ssl_errors.get(): - context = ssl._create_unverified_context() - - self.si = SmartConnect( - host=self.esxi_host.get(), - user=self.esxi_user.get(), - pwd=self.esxi_pass.get(), - sslContext=context - ) - - atexit.register(Disconnect, self.si) - - self.log("ESXi Verbindung erfolgreich") + self.vms = [] + failed = [] + for source in active_sources: + try: + if source["type"] == "vmware": + loaded = self.load_vmware_source(source) + else: + loaded = self.load_proxmox_source(source) + self.vms.extend(loaded) + self.log(f"{source['name']}: {len(loaded)} Systeme geladen") + except Exception as error: + failed.append(f"{source['name']}: {error}") + self.log(f"FEHLER bei {source['name']}: {error}") + self.refresh_vm_tree() + if not self.vms: + raise RuntimeError("Keine VMs geladen. " + (" | ".join(failed) if failed else "")) + text = f"{len(self.vms)} Systeme aus {len(active_sources) - len(failed)} Quellen geladen." + if failed: + text += "\n\nNicht erreichbar:\n" + "\n".join(failed) + messagebox.showwarning("Teilweise geladen", text) + else: + messagebox.showinfo("Erfolg", text) + except Exception as error: + messagebox.showerror("Verbindungsfehler", str(error)) def connect_netbox(self): - - self.log("Verbinde zu NetBox...") - - self.nb = pynetbox.api( - self.nb_url.get(), - token=self.nb_token.get() - ) - - self.nb.http_session.verify = not self.ignore_ssl_errors.get() - - self.log("NetBox Verbindung erfolgreich") - - # ========================================================= - # NetBox Daten - # ========================================================= + self.log("Verbinde zu NetBox …") + self.nb = pynetbox.api(self.netbox_url.get().strip(), token=self.netbox_token.get()) + self.nb.http_session.verify = not self.netbox_ignore_ssl.get() def load_netbox_data(self): - - self.log("Lade NetBox Daten...") - - # Sites - sites = list(self.nb.dcim.sites.all()) - self.site_map = {x.name: x.id for x in sites} - self.site_combo["values"] = list(self.site_map.keys()) - - # Cluster + self.log("Lade NetBox-Zieldaten …") clusters = list(self.nb.virtualization.clusters.all()) - self.cluster_map = {x.name: x.id for x in clusters} - self.cluster_combo["values"] = list(self.cluster_map.keys()) - - # Tenant tenants = list(self.nb.tenancy.tenants.all()) - self.tenant_map = {x.name: x.id for x in tenants} - self.tenant_combo["values"] = list(self.tenant_map.keys()) - - # Plattformen + self.cluster_map = {item.name: item.id for item in clusters} + self.tenant_map = {item.name: item.id for item in tenants} + self.cluster_combo["values"] = sorted(self.cluster_map, key=str.casefold) + self.tenant_combo["values"] = sorted(self.tenant_map, key=str.casefold) self.platforms = list(self.nb.dcim.platforms.all()) - self.log("NetBox Daten geladen") - - # ========================================================= - # VMware VMs laden - # ========================================================= - - def load_vms(self): - - self.log("Lade VMs aus VMware...") - - content = self.si.RetrieveContent() - - container = content.rootFolder - - view_type = [vim.VirtualMachine] - - recursive = True - - container_view = content.viewManager.CreateContainerView( - container, - view_type, - recursive + def load_vmware_source(self, source): + self.log(f"Verbinde zu VMware: {source['name']} …") + context = ssl._create_unverified_context() if source.get("ignore_ssl") else None + service_instance = SmartConnect( + host=source["host"], user=source["username"], pwd=source["password"], sslContext=context ) + try: + content = service_instance.RetrieveContent() + view = content.viewManager.CreateContainerView(content.rootFolder, [vim.VirtualMachine], True) + try: + # Fetch every required VM property in one PropertyCollector call. + # Accessing properties on each ManagedObject separately is extremely + # slow on vCenter because every access may cause another SOAP request. + traversal = vim.PropertyCollector.TraversalSpec( + name="vmViewTraversal", type=vim.view.ContainerView, + path="view", skip=False, + ) + object_spec = vim.PropertyCollector.ObjectSpec( + obj=view, skip=True, selectSet=[traversal] + ) + property_spec = vim.PropertyCollector.PropertySpec( + type=vim.VirtualMachine, all=False, pathSet=[ + "name", "config.hardware.memoryMB", "config.hardware.numCPU", + "config.hardware.device", "config.guestFullName", + "runtime.powerState", "guest.net", + ], + ) + filter_spec = vim.PropertyCollector.FilterSpec( + objectSet=[object_spec], propSet=[property_spec] + ) + options = vim.PropertyCollector.RetrieveOptions() + result = content.propertyCollector.RetrievePropertiesEx([filter_spec], options) + objects = list(result.objects or []) + while result.token: + result = content.propertyCollector.ContinueRetrievePropertiesEx(result.token) + objects.extend(result.objects or []) + normalized = [] + for item in objects: + properties = {prop.name: prop.val for prop in (item.propSet or [])} + if properties.get("name"): + normalized.append(self.normalize_vmware_vm(properties, source)) + return normalized + finally: + view.Destroy() + finally: + Disconnect(service_instance) - self.vms = container_view.view + def normalize_vmware_vm(self, properties, source): + disks = sum( + round(device.capacityInKB / 1024) + for device in properties.get("config.hardware.device", []) + if isinstance(device, vim.vm.device.VirtualDisk) + ) + interfaces = [] + for index, net in enumerate(properties.get("guest.net", []) or []): + mac = getattr(net, "macAddress", None) + if not mac: + continue + ips = [ip for ip in (getattr(net, "ipAddress", None) or []) if self.valid_guest_ip(ip)] + interfaces.append({"name": getattr(net, "device", None) or f"NIC-{index}", "mac": mac, "ips": ips}) + return { + "name": properties["name"], + "source": source["name"], + "source_type": "VMware", + "kind": "VM", + "status": "active" if "poweredOn" in str(properties.get("runtime.powerState", "")) else "offline", + "vcpus": int(properties.get("config.hardware.numCPU", 0)), + "memory": int(properties.get("config.hardware.memoryMB", 0)), + "disk": disks, + "guest_os": properties.get("config.guestFullName", "") or "", + "interfaces": interfaces, + } - self.vm_list.delete(0, tk.END) + def proxmox_session(self, source): + session = requests.Session() + session.verify = not source.get("ignore_ssl") + session.headers["Authorization"] = f"PVEAPIToken={source['token_id']}={source['token_secret']}" + return session - for vm in self.vms: - self.vm_list.insert(tk.END, vm.name) + @staticmethod + def proxmox_get(session, base_url, path): + response = session.get(f"{base_url}/api2/json{path}", timeout=30) + response.raise_for_status() + return response.json().get("data") - self.log(f"{len(self.vms)} VMs gefunden") + def load_proxmox_source(self, source): + self.log(f"Verbinde zu Proxmox: {source['name']} …") + session = self.proxmox_session(source) + resources = self.proxmox_get(session, source["url"], "/cluster/resources?type=vm") or [] + self.log(f"{source['name']}: Cluster-API meldet {len(resources)} Ressourcen") - # ========================================================= - # Plattform Mapping - # ========================================================= + # Some restricted API tokens may not see the cluster-wide resource + # endpoint. Try the per-node endpoints as a compatible fallback. + if not resources: + resources = self.load_proxmox_resources_by_node(session, source) + self.log(f"{source['name']}: Node-Abfrage meldet {len(resources)} Ressourcen") + + result = [] + for resource in resources: + kind = resource.get("type") + if kind not in ("qemu", "lxc") or self.proxmox_is_template(resource.get("template")): + continue + if kind == "lxc" and not source.get("include_lxc", True): + continue + try: + result.append(self.normalize_proxmox_guest(session, source, resource)) + except Exception as error: + self.log(f"WARNUNG {source['name']} / VMID {resource.get('vmid')}: {error}") + if not result: + raise RuntimeError( + "API erreichbar, aber keine sichtbaren VMs/LXCs gefunden. " + "Dem Benutzer und dem API-Token mindestens PVEAuditor auf /vms zuweisen." + ) + return result + + def load_proxmox_resources_by_node(self, session, source): + resources = [] + nodes = self.proxmox_get(session, source["url"], "/nodes") or [] + kinds = ["qemu"] + if source.get("include_lxc", True): + kinds.append("lxc") + for node_data in nodes: + node = node_data.get("node") + if not node: + continue + for kind in kinds: + guests = self.proxmox_get(session, source["url"], f"/nodes/{node}/{kind}") or [] + for guest in guests: + resource = dict(guest) + resource["node"] = node + resource["type"] = kind + resources.append(resource) + return resources + + @staticmethod + def proxmox_is_template(value): + return str(value).strip().lower() in ("1", "true", "yes") + + def normalize_proxmox_guest(self, session, source, resource): + kind = resource["type"] + node = resource["node"] + vmid = resource["vmid"] + config = self.proxmox_get(session, source["url"], f"/nodes/{node}/{kind}/{vmid}/config") or {} + interfaces = self.proxmox_interfaces(config, kind) + if kind == "qemu": + self.add_qemu_agent_ips(session, source, node, vmid, interfaces) + guest_os = self.proxmox_os_name(config.get("ostype", ""), kind) + return { + "name": resource.get("name") or f"{kind}-{vmid}", + "source": source["name"], + "source_type": "Proxmox", + "kind": "LXC" if kind == "lxc" else "VM", + "status": "active" if resource.get("status") == "running" else "offline", + "vcpus": int(resource.get("maxcpu") or config.get("cores") or 0), + "memory": round(int(resource.get("maxmem") or 0) / 1024 / 1024) or int(config.get("memory") or 0), + "disk": round(int(resource.get("maxdisk") or 0) / 1024 / 1024), + "guest_os": guest_os, + "interfaces": interfaces, + } + + def proxmox_interfaces(self, config, kind): + interfaces = [] + for key in sorted(config): + if not re.fullmatch(r"net\d+", key): + continue + values = self.parse_proxmox_options(str(config[key])) + if kind == "qemu": + mac = next((value for name, value in values.items() if name in ("virtio", "e1000", "e1000e", "vmxnet3", "rtl8139")), None) + name = key + ips = [] + else: + mac = values.get("hwaddr") + name = values.get("name", key) + ip_value = values.get("ip", "") + ips = [ip_value] if ip_value and ip_value not in ("dhcp", "manual") else [] + if mac: + interfaces.append({"name": name, "mac": mac, "ips": ips}) + return interfaces + + @staticmethod + def parse_proxmox_options(value): + result = {} + for part in value.split(","): + if "=" in part: + key, item = part.split("=", 1) + result[key.strip().lower()] = item.strip() + return result + + def add_qemu_agent_ips(self, session, source, node, vmid, interfaces): + try: + payload = self.proxmox_get(session, source["url"], f"/nodes/{node}/qemu/{vmid}/agent/network-get-interfaces") or {} + agent_interfaces = payload.get("result", payload if isinstance(payload, list) else []) + by_mac = {interface["mac"].lower(): interface for interface in interfaces} + for agent_interface in agent_interfaces: + mac = (agent_interface.get("hardware-address") or "").lower() + target = by_mac.get(mac) + if not target: + continue + for address in agent_interface.get("ip-addresses", []): + ip = address.get("ip-address") + prefix = address.get("prefix") + if ip and self.valid_guest_ip(ip): + target["ips"].append(f"{ip}/{prefix}" if prefix is not None else ip) + except (requests.RequestException, ValueError, KeyError): + # The guest agent is optional; configured MAC addresses are still useful. + return + + @staticmethod + def proxmox_os_name(ostype, kind): + names = { + "win11": "Microsoft Windows 11", "win10": "Microsoft Windows 10/2016/2019", + "win8": "Microsoft Windows 8/2012", "win7": "Microsoft Windows 7/2008 R2", + "winxp": "Microsoft Windows XP", "l26": "Linux 2.6+", "l24": "Linux 2.4", + "solaris": "Solaris", "other": "Other", + } + return names.get(ostype, "Linux Container" if kind == "lxc" else ostype) + + def refresh_vm_tree(self): + self.vm_tree.delete(*self.vm_tree.get_children()) + for index, vm_data in enumerate(self.vms): + self.vm_tree.insert("", "end", iid=str(index), values=( + vm_data["name"], vm_data["source"], vm_data["kind"], vm_data["status"], + vm_data["vcpus"], vm_data["memory"], + )) + + def select_all(self): + self.vm_tree.selection_set(self.vm_tree.get_children()) + + # NetBox import + + @staticmethod + def valid_guest_ip(value): + try: + address = ipaddress.ip_interface(value).ip if "/" in value else ipaddress.ip_address(value) + return not (address.is_loopback or address.is_link_local or address.is_unspecified) + except ValueError: + return False + + @staticmethod + def ip_with_prefix(value): + try: + if "/" in value: + return str(ipaddress.ip_interface(value)) + address = ipaddress.ip_address(value) + return f"{address}/24" if address.version == 4 else f"{address}/64" + except ValueError: + return None def get_platform_id(self, guest_os): - + guest_os = (guest_os or "").lower() if not guest_os: return None - - guest_os = guest_os.lower() - for platform in self.platforms: - - if platform.name.lower() in guest_os: + if platform.name.lower() in guest_os or guest_os in platform.name.lower(): return platform.id - return None - # ========================================================= - # Prefix automatisch ergänzen - # ========================================================= - - def get_ip_with_prefix(self, ip): - - try: - - addr = ipaddress.ip_address(ip) - - if addr.version == 4: - return f"{ip}/24" - - return f"{ip}/64" - - except: - return None - - # ========================================================= - # VM Import / Sync - # ========================================================= - def import_vms(self): - - selected = self.vm_list.curselection() - + selected = self.vm_tree.selection() if not selected: - - messagebox.showwarning( - "Hinweis", - "Keine VMs ausgewählt" - ) - + messagebox.showwarning("Hinweis", "Keine VMs ausgewählt.") return - - cluster_name = self.cluster_combo.get() - tenant_name = self.tenant_combo.get() - - cluster_id = self.cluster_map.get(cluster_name) - tenant_id = self.tenant_map.get(tenant_name) - + cluster_id = self.cluster_map.get(self.cluster_combo.get()) + tenant_id = self.tenant_map.get(self.tenant_combo.get()) if not cluster_id: - - messagebox.showerror( - "Fehler", - "Bitte Cluster auswählen" - ) - + messagebox.showerror("Fehler", "Bitte einen NetBox-Cluster auswählen.") return - - for idx in selected: - - vm = self.vms[idx] - - self.log(f"Synchronisiere VM: {vm.name}") - - # ============================================ - # Hardwaredaten - # ============================================ - - memory = int(vm.config.hardware.memoryMB) - - vcpus = int(vm.config.hardware.numCPU) - - # ============================================ - # Disk in MB (NetBox >= 4.x) - # ============================================ - - disk_size = 0 - - for dev in vm.config.hardware.device: - - if isinstance(dev, vim.vm.device.VirtualDisk): - - # VMware liefert KB - # NetBox erwartet MB - size_mb = dev.capacityInKB / 1024 - - disk_size += round(size_mb) - - # ============================================ - # Betriebssystem - # ============================================ - - guest_os = "" - + failures = [] + for item_id in selected: + vm_data = self.vms[int(item_id)] try: - guest_os = vm.config.guestFullName - except: - pass - - platform_id = self.get_platform_id(guest_os) - - # ============================================ - # Status - # ============================================ - - power_state = str(vm.runtime.powerState) - - if "poweredOn" in power_state: - status = "active" - else: - status = "offline" - - # ============================================ - # VM Daten - # ============================================ - - vm_data = { - "name": vm.name, - "cluster": cluster_id, - "status": status, - "vcpus": vcpus, - "memory": memory, - "disk": disk_size, - "tenant": tenant_id, - "platform": platform_id - } - - # ============================================ - # VM erstellen / aktualisieren - # ============================================ - - nb_vm = self.nb.virtualization.virtual_machines.get( - name=vm.name - ) - - if nb_vm: - - nb_vm.update(vm_data) - - self.log(f"VM aktualisiert: {vm.name}") - - else: - - nb_vm = self.nb.virtualization.virtual_machines.create( - vm_data - ) - - self.log(f"VM erstellt: {vm.name}") - - # ============================================ - # Interfaces + IPs - # ============================================ - - primary_ipv4 = None - primary_ipv6 = None - - if vm.guest and hasattr(vm.guest, "net"): - - for net in vm.guest.net: - - mac = getattr(net, "macAddress", None) - - if not mac: - continue - - interface_name = getattr(net, "device", None) - - if not interface_name: - interface_name = f"NIC-{mac[-5:]}" - - iface_data = { - "virtual_machine": nb_vm.id, - "name": interface_name, - "mac_address": mac, - "enabled": True - } - - # Interface zuerst über Namen suchen - existing_iface = self.nb.virtualization.interfaces.get( - virtual_machine_id=nb_vm.id, - name=interface_name - ) - - if existing_iface: - - # Falls MAC geändert wurde -> aktualisieren - existing_iface.update(iface_data) - - vm_iface = existing_iface - - self.log( - f"Interface aktualisiert: {interface_name}" - ) - - else: - - vm_iface = self.nb.virtualization.interfaces.create( - iface_data - ) - - self.log( - f"Interface erstellt: {interface_name}" - ) - - self.log(f"Interface synchronisiert: {mac}") - - # ============================================ - # IPs - # ============================================ - - if hasattr(net, "ipAddress"): - - for ip in net.ipAddress: - - if ip.startswith("127."): - continue - - if ip == "::1": - continue - - address = self.get_ip_with_prefix(ip) - - if not address: - continue - - existing_ip = self.nb.ipam.ip_addresses.get( - address=address - ) - - ip_data = { - "address": address, - "status": "active", - "assigned_object_type": "virtualization.vminterface", - "assigned_object_id": vm_iface.id - } - - if existing_ip: - - existing_ip.update(ip_data) - - ip_obj = existing_ip - - self.log(f"IP aktualisiert: {address}") - - else: - - ip_obj = self.nb.ipam.ip_addresses.create( - ip_data - ) - - self.log(f"IP erstellt: {address}") - - # ============================================ - # Primary IP setzen - # ============================================ - - try: - - parsed_ip = ipaddress.ip_address(ip) - - if parsed_ip.version == 4: - - if not primary_ipv4: - primary_ipv4 = ip_obj.id - - else: - - if not primary_ipv6: - primary_ipv6 = ip_obj.id - - except: - pass - - # ============================================ - # Primäre IPs setzen - # ============================================ - - update_data = {} - - if primary_ipv4: - update_data["primary_ip4"] = primary_ipv4 - - if primary_ipv6: - update_data["primary_ip6"] = primary_ipv6 - - if update_data: - - nb_vm.update(update_data) - - self.log( - f"Primary IPs gesetzt für {vm.name}" - ) - - messagebox.showinfo( - "Fertig", - "VM Import / Synchronisierung abgeschlossen" - ) - + self.sync_vm(vm_data, cluster_id, tenant_id) + except Exception as error: + failures.append(f"{vm_data['name']}: {error}") + self.log(f"FEHLER {vm_data['name']}: {error}") + if failures: + messagebox.showwarning("Import abgeschlossen", "Einige Systeme konnten nicht importiert werden:\n\n" + "\n".join(failures)) + else: + messagebox.showinfo("Fertig", "VM-Import und Synchronisierung abgeschlossen.") self.log("Synchronisierung abgeschlossen") + def sync_vm(self, source_vm, cluster_id, tenant_id): + self.log(f"Synchronisiere {source_vm['name']} ({source_vm['source']}) …") + payload = { + "name": source_vm["name"], "cluster": cluster_id, "status": source_vm["status"], + "vcpus": source_vm["vcpus"], "memory": source_vm["memory"], "disk": source_vm["disk"], + } + if tenant_id: + payload["tenant"] = tenant_id + platform_id = self.get_platform_id(source_vm.get("guest_os")) + if platform_id: + payload["platform"] = platform_id + + netbox_vm = self.nb.virtualization.virtual_machines.get(name=source_vm["name"]) + if netbox_vm: + netbox_vm.update(payload) + self.log(f"VM aktualisiert: {source_vm['name']}") + else: + netbox_vm = self.nb.virtualization.virtual_machines.create(payload) + self.log(f"VM erstellt: {source_vm['name']}") + + primary_ipv4 = None + primary_ipv6 = None + for interface in source_vm.get("interfaces", []): + interface_payload = { + "virtual_machine": netbox_vm.id, "name": interface["name"], + "mac_address": interface["mac"], "enabled": True, + } + netbox_interface = self.nb.virtualization.interfaces.get( + virtual_machine_id=netbox_vm.id, name=interface["name"] + ) + if netbox_interface: + netbox_interface.update(interface_payload) + else: + netbox_interface = self.nb.virtualization.interfaces.create(interface_payload) + + for ip_value in dict.fromkeys(interface.get("ips", [])): + address = self.ip_with_prefix(ip_value) + if not address: + continue + ip_payload = { + "address": address, "status": "active", + "assigned_object_type": "virtualization.vminterface", + "assigned_object_id": netbox_interface.id, + } + ip_object = self.nb.ipam.ip_addresses.get(address=address) + if ip_object: + ip_object.update(ip_payload) + else: + ip_object = self.nb.ipam.ip_addresses.create(ip_payload) + version = ipaddress.ip_interface(address).version + if version == 4 and not primary_ipv4: + primary_ipv4 = ip_object.id + elif version == 6 and not primary_ipv6: + primary_ipv6 = ip_object.id + + primary = {} + if primary_ipv4: + primary["primary_ip4"] = primary_ipv4 + if primary_ipv6: + primary["primary_ip6"] = primary_ipv6 + if primary: + netbox_vm.update(primary) -# ========================================================= -# Main -# ========================================================= if __name__ == "__main__": - root = tk.Tk() - - app = ESXiNetBoxImporter(root) - + application = NetBoxVMImporter(root) root.mainloop() diff --git a/dist/NetBox VM Import.exe b/dist/NetBox VM Import.exe index 6085f1e..052ae05 100644 Binary files a/dist/NetBox VM Import.exe and b/dist/NetBox VM Import.exe differ diff --git a/profile_store.py b/profile_store.py index 96e339e..2756cd2 100644 --- a/profile_store.py +++ b/profile_store.py @@ -8,6 +8,7 @@ from pathlib import Path APP_NAME = "NetBox VM Import" +SECRET_KEYS = ("password", "token_secret") def config_path(): @@ -28,7 +29,7 @@ def _blob(data): def protect(value): - """Encrypt a string for the current Windows user using DPAPI.""" + """Encrypt a value for the current Windows user with DPAPI.""" if not value: return "" if sys.platform != "win32": @@ -40,18 +41,16 @@ def protect(value): ): raise ctypes.WinError() try: - encrypted = ctypes.string_at(result.pbData, result.cbData) - return base64.b64encode(encrypted).decode("ascii") + return base64.b64encode(ctypes.string_at(result.pbData, result.cbData)).decode("ascii") finally: ctypes.windll.kernel32.LocalFree(result.pbData) def unprotect(value): - """Decrypt a DPAPI string for the current Windows user.""" + """Decrypt a DPAPI value for the current Windows user.""" if not value: return "" - encrypted = base64.b64decode(value) - source, source_buffer = _blob(encrypted) + source, source_buffer = _blob(base64.b64decode(value)) result = _DataBlob() if not ctypes.windll.crypt32.CryptUnprotectData( ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result) @@ -74,25 +73,56 @@ class ProfileStore: data = json.load(stream) profiles = {} - for item in data.get("profiles", []): - name = item.get("name", "").strip() + for record in data.get("profiles", []): + name = record.get("name", "").strip() if not name: continue - profile = dict(item) - profile["esxi_password"] = unprotect(item.get("esxi_password_encrypted", "")) - profile["netbox_token"] = unprotect(item.get("netbox_token_encrypted", "")) + profile = { + "name": name, + "netbox_url": record.get("netbox_url", ""), + "netbox_token": unprotect(record.get("netbox_token_encrypted", "")), + "netbox_ignore_ssl": bool(record.get("netbox_ignore_ssl", record.get("ignore_ssl_errors", False))), + "sources": [], + } + for source_record in record.get("sources", []): + source = dict(source_record) + for key in SECRET_KEYS: + encrypted_key = f"{key}_encrypted" + if encrypted_key in source: + source[key] = unprotect(source.pop(encrypted_key)) + profile["sources"].append(source) + + # Migration from v2: one VMware source embedded in each profile. + if not profile["sources"] and record.get("esxi_host"): + profile["sources"].append({ + "id": "vmware-legacy", + "type": "vmware", + "name": "VMware", + "enabled": True, + "host": record.get("esxi_host", ""), + "username": record.get("esxi_user", ""), + "password": unprotect(record.get("esxi_password_encrypted", "")), + "ignore_ssl": bool(record.get("ignore_ssl_errors", False)), + }) profiles[name] = profile - # Migration from the original single-profile, plaintext format. + # Migration from the first plaintext single-profile format. if not profiles and any(key in data for key in ("esxi_host", "netbox_url")): profiles["Standard"] = { "name": "Standard", - "esxi_host": data.get("esxi_host", ""), - "esxi_user": data.get("esxi_user", ""), - "esxi_password": data.get("esxi_password", ""), "netbox_url": data.get("netbox_url", ""), "netbox_token": data.get("netbox_token", ""), - "ignore_ssl_errors": bool(data.get("ignore_ssl_errors", False)), + "netbox_ignore_ssl": bool(data.get("ignore_ssl_errors", False)), + "sources": [{ + "id": "vmware-legacy", + "type": "vmware", + "name": "VMware", + "enabled": True, + "host": data.get("esxi_host", ""), + "username": data.get("esxi_user", ""), + "password": data.get("esxi_password", ""), + "ignore_ssl": bool(data.get("ignore_ssl_errors", False)), + }], } return profiles, data.get("active_profile", "") @@ -100,16 +130,22 @@ class ProfileStore: records = [] for name in sorted(profiles, key=str.casefold): profile = profiles[name] + sources = [] + for source_data in profile.get("sources", []): + source = {key: value for key, value in source_data.items() if key not in SECRET_KEYS} + for key in SECRET_KEYS: + if key in source_data: + source[f"{key}_encrypted"] = protect(source_data.get(key, "")) + sources.append(source) records.append({ "name": name, - "esxi_host": profile.get("esxi_host", ""), - "esxi_user": profile.get("esxi_user", ""), - "esxi_password_encrypted": protect(profile.get("esxi_password", "")), "netbox_url": profile.get("netbox_url", ""), "netbox_token_encrypted": protect(profile.get("netbox_token", "")), - "ignore_ssl_errors": bool(profile.get("ignore_ssl_errors", False)), + "netbox_ignore_ssl": bool(profile.get("netbox_ignore_ssl", False)), + "sources": sources, }) - payload = {"version": 2, "active_profile": active_profile, "profiles": records} + + payload = {"version": 3, "active_profile": active_profile, "profiles": records} self.path.parent.mkdir(parents=True, exist_ok=True) temp_path = self.path.with_suffix(".tmp") with temp_path.open("w", encoding="utf-8") as stream: diff --git a/requirements.txt b/requirements.txt index db9f4d6..f26374d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ pyvmomi pynetbox +requests pyinstaller -