Initial release of NetBox VM Import Desktop
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.zip
|
||||||
|
output/
|
||||||
|
|
||||||
|
# Lokale Konfigurationen und Zugangsdaten
|
||||||
|
config.json
|
||||||
|
*.tmp
|
||||||
|
|
||||||
@@ -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'],
|
||||||
|
)
|
||||||
@@ -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"
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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()
|
||||||
Vendored
BIN
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 735 KiB |
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
pyvmomi
|
||||||
|
pynetbox
|
||||||
|
pyinstaller
|
||||||
|
|
||||||
Reference in New Issue
Block a user