import base64 import ctypes import json import os import sys from ctypes import wintypes from pathlib import Path APP_NAME = "NetBox VM Import" SECRET_KEYS = ("password", "token_secret") 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 value for the current Windows user with 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: 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 value for the current Windows user.""" if not value: return "" 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) ): 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 record in data.get("profiles", []): name = record.get("name", "").strip() if not name: continue 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": [], "switches": [], } 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) for switch_record in record.get("switches", []): switch = dict(switch_record) for key in SECRET_KEYS: encrypted_key = f"{key}_encrypted" if encrypted_key in switch: switch[key] = unprotect(switch.pop(encrypted_key)) profile["switches"].append(switch) # 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 first plaintext single-profile format. if not profiles and any(key in data for key in ("esxi_host", "netbox_url")): profiles["Standard"] = { "name": "Standard", "netbox_url": data.get("netbox_url", ""), "netbox_token": data.get("netbox_token", ""), "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)), }], "switches": [], } return profiles, data.get("active_profile", "") def save(self, profiles, active_profile): 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) switches = [] for switch_data in profile.get("switches", []): switch = {key: value for key, value in switch_data.items() if key not in SECRET_KEYS} for key in SECRET_KEYS: if key in switch_data: switch[f"{key}_encrypted"] = protect(switch_data.get(key, "")) switches.append(switch) records.append({ "name": name, "netbox_url": profile.get("netbox_url", ""), "netbox_token_encrypted": protect(profile.get("netbox_token", "")), "netbox_ignore_ssl": bool(profile.get("netbox_ignore_ssl", False)), "sources": sources, "switches": switches, }) payload = {"version": 4, "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)