Initial release of NetBox VM Import Desktop

This commit is contained in:
2026-07-24 10:33:01 +02:00
commit 16726b5fab
9 changed files with 1169 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.venv/
build/
__pycache__/
*.py[cod]
*.zip
output/
# Lokale Konfigurationen und Zugangsdaten
config.json
*.tmp
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['app.py'],
pathex=[],
binaries=[],
datas=[('netbox_vm_import.ico', '.')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='NetBox VM Import',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['netbox_vm_import.ico'],
)
+33
View File
@@ -0,0 +1,33 @@
# NetBox VM Import Desktop
Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschinen aus VMware ESXi/vSphere nach NetBox.
## Funktionen
- Mehrere kundenbezogene VMware- und NetBox-Profile
- Mit Windows DPAPI verschlüsselte Passwörter und API-Token
- Optionale Unterstützung selbstsignierter TLS-Zertifikate
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
- Übernahme primärer IPv4- und IPv6-Adressen
## Verwendung
Die fertige Windows-Anwendung befindet sich unter `dist/NetBox VM Import.exe`.
Profile werden im Benutzerprofil unter `%APPDATA%\NetBox VM Import\config.json` gespeichert. Passwort und Token sind an das jeweilige Windows-Benutzerkonto und den Rechner gebunden verschlüsselt.
## Entwicklung
```powershell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python app.py
```
## EXE erstellen
```powershell
pyinstaller "NetBox VM Import.spec"
```
+965
View File
@@ -0,0 +1,965 @@
import ssl
import atexit
import ipaddress
import json
import os
import sys
from pathlib import Path
import tkinter as tk
from tkinter import ttk, messagebox, simpledialog
from pyVim.connect import SmartConnect, Disconnect
from pyVmomi import vim
import pynetbox
from profile_store import ProfileStore
APP_NAME = "NetBox VM Import"
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:
def __init__(self, root):
self.root = root
self.root.title("VMware → NetBox Synchronizer")
self.root.geometry("1080x820")
self.root.minsize(900, 680)
self.si = None
self.nb = None
self.vms = []
self.site_map = {}
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.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():
style.theme_use("vista")
style.configure("Title.TLabel", font=("Segoe UI Semibold", 20))
style.configure("Subtitle.TLabel", foreground="#52606d", font=("Segoe UI", 10))
style.configure("Card.TLabelframe", padding=14)
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")
def resource_path(self, filename):
base = Path(getattr(sys, "_MEIPASS", Path(__file__).resolve().parent))
return base / filename
def set_window_icon(self):
icon = self.resource_path("netbox_vm_import.ico")
if icon.exists():
try:
self.root.iconbitmap(str(icon))
except tk.TclError:
pass
def create_gui(self):
main = ttk.Frame(self.root, padding=18)
main.pack(fill="both", expand=True)
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")
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)
self.profile_combo.pack(side="left", fill="x", expand=True, padx=(0, 8))
self.profile_combo.bind("<<ComboboxSelected>>", 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))
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))
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")
workspace = ttk.Panedwindow(main, orient="horizontal")
workspace.grid(row=4, column=0, sticky="nsew")
targets = ttk.LabelFrame(workspace, text="NetBox-Ziel", 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)
vm_scroll.pack(side="right", fill="y")
buttons = ttk.Frame(main)
buttons.grid(row=5, column=0, sticky="ew", pady=10)
ttk.Button(buttons, text="Alle auswählen", command=self.select_all).pack(side="left")
ttk.Button(buttons, text="Import / Sync starten", command=self.import_vms, style="Primary.TButton").pack(side="right")
log_card = ttk.LabelFrame(main, text="Aktivitätsprotokoll", style="Card.TLabelframe")
log_card.grid(row=6, column=0, sticky="nsew")
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):
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):
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 load_profiles(self):
try:
self.profiles, active = self.store.load()
self.refresh_profile_list()
if self.profiles:
selected = active if active in self.profiles else next(iter(self.profiles))
self.profile_name.set(selected)
self.populate_profile(selected)
except Exception as error:
messagebox.showwarning("Konfiguration", f"Profile konnten nicht geladen werden:\n{error}")
def refresh_profile_list(self):
self.profile_combo["values"] = sorted(self.profiles, key=str.casefold)
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(),
}
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.status_text.set(f"Profil „{name}“ geladen")
def on_profile_selected(self, _event=None):
self.populate_profile(self.profile_name.get())
def new_profile(self):
name = simpledialog.askstring("Neues Kundenprofil", "Kunden- oder Profilname:", parent=self.root)
if not name or not name.strip():
return
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.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()
if not name:
messagebox.showwarning("Profil", "Bitte zuerst ein Kundenprofil anlegen.")
return False
self.profiles[name] = self.profile_from_form(name)
try:
self.store.save(self.profiles, name)
self.refresh_profile_list()
self.status_text.set(f"Profil „{name}“ verschlüsselt gespeichert")
if show_message:
messagebox.showinfo("Gespeichert", "Das Kundenprofil wurde verschlüsselt gespeichert.")
return True
except Exception as error:
messagebox.showerror("Speichern fehlgeschlagen", str(error))
return False
def delete_profile(self):
name = self.profile_name.get()
if not name or name not in self.profiles:
return
if not messagebox.askyesno("Profil löschen", f"Kundenprofil „{name}“ wirklich löschen?"):
return
del self.profiles[name]
next_name = next(iter(sorted(self.profiles, key=str.casefold)), "")
self.store.save(self.profiles, next_name)
self.refresh_profile_list()
self.profile_name.set(next_name)
if next_name:
self.populate_profile(next_name)
else:
self.clear_profile_form()
self.status_text.set("Profil gelöscht")
# =========================================================
# Logging
# =========================================================
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()
# =========================================================
# Select All
# =========================================================
def select_all(self):
self.vm_list.select_set(0, tk.END)
# =========================================================
# Verbindungen
# =========================================================
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.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")
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
# =========================================================
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
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.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
)
self.vms = container_view.view
self.vm_list.delete(0, tk.END)
for vm in self.vms:
self.vm_list.insert(tk.END, vm.name)
self.log(f"{len(self.vms)} VMs gefunden")
# =========================================================
# Plattform Mapping
# =========================================================
def get_platform_id(self, guest_os):
if not guest_os:
return None
guest_os = guest_os.lower()
for platform in self.platforms:
if platform.name.lower() in guest_os:
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()
if not selected:
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)
if not cluster_id:
messagebox.showerror(
"Fehler",
"Bitte 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 = ""
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.log("Synchronisierung abgeschlossen")
# =========================================================
# Main
# =========================================================
if __name__ == "__main__":
root = tk.Tk()
app = ESXiNetBoxImporter(root)
root.mainloop()
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 KiB

+117
View File
@@ -0,0 +1,117 @@
import base64
import ctypes
import json
import os
import sys
from ctypes import wintypes
from pathlib import Path
APP_NAME = "NetBox VM Import"
def config_path():
if sys.platform == "win32":
base = Path(os.environ.get("APPDATA", Path.home()))
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / APP_NAME / "config.json"
class _DataBlob(ctypes.Structure):
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_byte))]
def _blob(data):
buffer = ctypes.create_string_buffer(data)
return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_byte))), buffer
def protect(value):
"""Encrypt a string for the current Windows user using DPAPI."""
if not value:
return ""
if sys.platform != "win32":
raise RuntimeError("Verschlüsselte Profile werden derzeit nur unter Windows unterstützt.")
source, source_buffer = _blob(value.encode("utf-8"))
result = _DataBlob()
if not ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(source), APP_NAME, None, None, None, 0, ctypes.byref(result)
):
raise ctypes.WinError()
try:
encrypted = ctypes.string_at(result.pbData, result.cbData)
return base64.b64encode(encrypted).decode("ascii")
finally:
ctypes.windll.kernel32.LocalFree(result.pbData)
def unprotect(value):
"""Decrypt a DPAPI string for the current Windows user."""
if not value:
return ""
encrypted = base64.b64decode(value)
source, source_buffer = _blob(encrypted)
result = _DataBlob()
if not ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)
):
raise ctypes.WinError()
try:
return ctypes.string_at(result.pbData, result.cbData).decode("utf-8")
finally:
ctypes.windll.kernel32.LocalFree(result.pbData)
class ProfileStore:
def __init__(self, path=None):
self.path = Path(path) if path else config_path()
def load(self):
if not self.path.exists():
return {}, ""
with self.path.open("r", encoding="utf-8") as stream:
data = json.load(stream)
profiles = {}
for item in data.get("profiles", []):
name = item.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", ""))
profiles[name] = profile
# Migration from the original single-profile, plaintext 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)),
}
return profiles, data.get("active_profile", "")
def save(self, profiles, active_profile):
records = []
for name in sorted(profiles, key=str.casefold):
profile = profiles[name]
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)),
})
payload = {"version": 2, "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:
json.dump(payload, stream, indent=2, ensure_ascii=False)
temp_path.replace(self.path)
+4
View File
@@ -0,0 +1,4 @@
pyvmomi
pynetbox
pyinstaller