feat: add Proxmox multi-source support
Add encrypted VMware and Proxmox sources per customer profile, import QEMU and LXC guests, improve Proxmox permission diagnostics, and speed up VMware loading with bulk property collection.
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
# NetBox VM Import Desktop
|
# 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
|
## Funktionen
|
||||||
|
|
||||||
- Mehrere kundenbezogene VMware- und NetBox-Profile
|
- Mehrere VMware- und Proxmox-Quellen pro Kundenprofil
|
||||||
- Mit Windows DPAPI verschlüsselte Passwörter und API-Token
|
- 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
|
- Optionale Unterstützung selbstsignierter TLS-Zertifikate
|
||||||
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
|
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
|
||||||
- Übernahme primärer IPv4- und IPv6-Adressen
|
- Übernahme primärer IPv4- und IPv6-Adressen
|
||||||
@@ -30,4 +31,3 @@ python app.py
|
|||||||
```powershell
|
```powershell
|
||||||
pyinstaller "NetBox VM Import.spec"
|
pyinstaller "NetBox VM Import.spec"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+57
-21
@@ -8,6 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
APP_NAME = "NetBox VM Import"
|
APP_NAME = "NetBox VM Import"
|
||||||
|
SECRET_KEYS = ("password", "token_secret")
|
||||||
|
|
||||||
|
|
||||||
def config_path():
|
def config_path():
|
||||||
@@ -28,7 +29,7 @@ def _blob(data):
|
|||||||
|
|
||||||
|
|
||||||
def protect(value):
|
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:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
@@ -40,18 +41,16 @@ def protect(value):
|
|||||||
):
|
):
|
||||||
raise ctypes.WinError()
|
raise ctypes.WinError()
|
||||||
try:
|
try:
|
||||||
encrypted = ctypes.string_at(result.pbData, result.cbData)
|
return base64.b64encode(ctypes.string_at(result.pbData, result.cbData)).decode("ascii")
|
||||||
return base64.b64encode(encrypted).decode("ascii")
|
|
||||||
finally:
|
finally:
|
||||||
ctypes.windll.kernel32.LocalFree(result.pbData)
|
ctypes.windll.kernel32.LocalFree(result.pbData)
|
||||||
|
|
||||||
|
|
||||||
def unprotect(value):
|
def unprotect(value):
|
||||||
"""Decrypt a DPAPI string for the current Windows user."""
|
"""Decrypt a DPAPI value for the current Windows user."""
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
encrypted = base64.b64decode(value)
|
source, source_buffer = _blob(base64.b64decode(value))
|
||||||
source, source_buffer = _blob(encrypted)
|
|
||||||
result = _DataBlob()
|
result = _DataBlob()
|
||||||
if not ctypes.windll.crypt32.CryptUnprotectData(
|
if not ctypes.windll.crypt32.CryptUnprotectData(
|
||||||
ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)
|
ctypes.byref(source), None, None, None, None, 0, ctypes.byref(result)
|
||||||
@@ -74,25 +73,56 @@ class ProfileStore:
|
|||||||
data = json.load(stream)
|
data = json.load(stream)
|
||||||
|
|
||||||
profiles = {}
|
profiles = {}
|
||||||
for item in data.get("profiles", []):
|
for record in data.get("profiles", []):
|
||||||
name = item.get("name", "").strip()
|
name = record.get("name", "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
continue
|
continue
|
||||||
profile = dict(item)
|
profile = {
|
||||||
profile["esxi_password"] = unprotect(item.get("esxi_password_encrypted", ""))
|
"name": name,
|
||||||
profile["netbox_token"] = unprotect(item.get("netbox_token_encrypted", ""))
|
"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
|
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")):
|
if not profiles and any(key in data for key in ("esxi_host", "netbox_url")):
|
||||||
profiles["Standard"] = {
|
profiles["Standard"] = {
|
||||||
"name": "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_url": data.get("netbox_url", ""),
|
||||||
"netbox_token": data.get("netbox_token", ""),
|
"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", "")
|
return profiles, data.get("active_profile", "")
|
||||||
|
|
||||||
@@ -100,16 +130,22 @@ class ProfileStore:
|
|||||||
records = []
|
records = []
|
||||||
for name in sorted(profiles, key=str.casefold):
|
for name in sorted(profiles, key=str.casefold):
|
||||||
profile = profiles[name]
|
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({
|
records.append({
|
||||||
"name": name,
|
"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_url": profile.get("netbox_url", ""),
|
||||||
"netbox_token_encrypted": protect(profile.get("netbox_token", "")),
|
"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)
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
temp_path = self.path.with_suffix(".tmp")
|
temp_path = self.path.with_suffix(".tmp")
|
||||||
with temp_path.open("w", encoding="utf-8") as stream:
|
with temp_path.open("w", encoding="utf-8") as stream:
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
pyvmomi
|
pyvmomi
|
||||||
pynetbox
|
pynetbox
|
||||||
|
requests
|
||||||
pyinstaller
|
pyinstaller
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user