Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e983791baa | ||
|
|
a0cbea8214 | ||
|
|
7bf200b910 | ||
|
|
2bde89a86e | ||
|
|
8c268be778 | ||
|
|
1b51784748 | ||
|
|
fff69277ab | ||
|
|
e147642117 | ||
|
|
10cc7b1196 | ||
|
|
47e1d3499c | ||
|
|
08add7982a | ||
|
|
cf9f2ac53a |
@@ -1,6 +1,6 @@
|
|||||||
# NetBox VM Import Desktop
|
# NetBox VM Import Desktop
|
||||||
|
|
||||||
Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschinen aus VMware ESXi/vSphere und Proxmox VE nach NetBox.
|
Windows-Desktopanwendung zum Synchronisieren virtueller Maschinen sowie HPE-/Aruba-Switche nach NetBox.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
|
|
||||||
@@ -16,6 +16,17 @@ Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschine
|
|||||||
- 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
|
||||||
|
- Getrennte Haupt-Tabs für Virtualisierung und Switche
|
||||||
|
- Gemeinsame, synchronisierte Kundenprofil- und NetBox-Zielansicht in beiden Tabs
|
||||||
|
- Verschlüsselte SSH-Zugänge pro bestehendem NetBox-Switch
|
||||||
|
- SSH-Erkennung für Aruba CX, ArubaOS-Switch/ProCurve und HPE Comware
|
||||||
|
- Durchsuchbare, nach Mandant filterbare Auswahl vorhandener NetBox-Geräte
|
||||||
|
- Automatische Aktivierung von global deaktiviertem LLDP auf unterstützten Switches
|
||||||
|
- Synchronisierung von Switch-Schnittstellen und optionalen LLDP-/CDP-Verbindungen als NetBox-Kabel
|
||||||
|
- Auslesen und Synchronisieren vorhandener IPv4-/IPv6-Adressen auf Switch-Schnittstellen
|
||||||
|
- Robustes, vollständiges Einlesen großer SSH-Ausgaben von 48-Port-Switches
|
||||||
|
- Spezieller LLDP-Detailparser und ergänzender CDP-Fallback für Aruba-6100-Switches mit AOS-CX 10.13
|
||||||
|
- Skalierbares Switch-Protokoll sowie separates Fenster für Protokoll und SSH-Rohdaten
|
||||||
|
|
||||||
## Verwendung
|
## Verwendung
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pyVim.connect import Disconnect, SmartConnect
|
|||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
from profile_store import ProfileStore
|
from profile_store import ProfileStore
|
||||||
|
from switch_sync import PLATFORMS, discover_switch, sync_snapshot_to_netbox
|
||||||
|
|
||||||
|
|
||||||
class SourceDialog(tk.Toplevel):
|
class SourceDialog(tk.Toplevel):
|
||||||
@@ -134,6 +135,159 @@ class SourceDialog(tk.Toplevel):
|
|||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchSourceDialog(tk.Toplevel):
|
||||||
|
"""Editor for one existing NetBox switch and its SSH connection."""
|
||||||
|
|
||||||
|
def __init__(self, parent, devices, switch=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.title("Switch-Zugang")
|
||||||
|
self.geometry("620x560")
|
||||||
|
self.resizable(False, False)
|
||||||
|
self.transient(parent)
|
||||||
|
self.grab_set()
|
||||||
|
self.result = None
|
||||||
|
switch = switch or {}
|
||||||
|
self.switch_id = switch.get("id", uuid.uuid4().hex)
|
||||||
|
self.enabled = tk.BooleanVar(value=switch.get("enabled", True))
|
||||||
|
self.platform = tk.StringVar(value=switch.get("platform", "aruba_cx"))
|
||||||
|
self.device_map = {}
|
||||||
|
self.device_options = []
|
||||||
|
|
||||||
|
selected_device = ""
|
||||||
|
selected_tenant = "Alle Mandanten"
|
||||||
|
for device in devices:
|
||||||
|
site = getattr(getattr(device, "site", None), "name", "")
|
||||||
|
tenant = getattr(getattr(device, "tenant", None), "name", "") or "Ohne Mandant"
|
||||||
|
parts = [device.name]
|
||||||
|
if site:
|
||||||
|
parts.append(site)
|
||||||
|
parts.append(tenant)
|
||||||
|
display = " · ".join(parts)
|
||||||
|
option = {
|
||||||
|
"display": display,
|
||||||
|
"device_id": device.id,
|
||||||
|
"device_name": device.name,
|
||||||
|
"tenant": tenant,
|
||||||
|
}
|
||||||
|
self.device_options.append(option)
|
||||||
|
self.device_map[display] = (device.id, device.name)
|
||||||
|
if device.id == switch.get("device_id"):
|
||||||
|
selected_device = display
|
||||||
|
selected_tenant = tenant
|
||||||
|
|
||||||
|
body = ttk.Frame(self, padding=22)
|
||||||
|
body.pack(fill="both", expand=True)
|
||||||
|
body.columnconfigure(1, weight=1)
|
||||||
|
tenants = ["Alle Mandanten"] + sorted(
|
||||||
|
{option["tenant"] for option in self.device_options},
|
||||||
|
key=str.casefold,
|
||||||
|
)
|
||||||
|
ttk.Label(body, text="Mandant").grid(row=0, column=0, sticky="w", padx=(0, 12), pady=7)
|
||||||
|
self.tenant_filter = tk.StringVar(value=selected_tenant)
|
||||||
|
tenant_combo = ttk.Combobox(body, textvariable=self.tenant_filter, values=tenants, state="readonly")
|
||||||
|
tenant_combo.grid(row=0, column=1, sticky="ew", pady=7)
|
||||||
|
tenant_combo.bind("<<ComboboxSelected>>", self.on_tenant_changed)
|
||||||
|
|
||||||
|
ttk.Label(body, text="NetBox-Gerät suchen").grid(row=1, column=0, sticky="w", padx=(0, 12), pady=7)
|
||||||
|
self.device_selection = tk.StringVar(value=selected_device)
|
||||||
|
self.device_combo = ttk.Combobox(
|
||||||
|
body,
|
||||||
|
textvariable=self.device_selection,
|
||||||
|
values=[],
|
||||||
|
)
|
||||||
|
self.device_combo.grid(row=1, column=1, sticky="ew", pady=7)
|
||||||
|
self.device_combo.bind("<KeyRelease>", self.filter_devices)
|
||||||
|
|
||||||
|
ttk.Label(body, text="Plattform").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=7)
|
||||||
|
platform_map = {label: key for key, label in PLATFORMS.items()}
|
||||||
|
self.platform_map = platform_map
|
||||||
|
platform_display = tk.StringVar(value=PLATFORMS[self.platform.get()])
|
||||||
|
self.platform_display = platform_display
|
||||||
|
ttk.Combobox(
|
||||||
|
body,
|
||||||
|
textvariable=platform_display,
|
||||||
|
values=list(platform_map),
|
||||||
|
state="readonly",
|
||||||
|
).grid(row=2, column=1, sticky="ew", pady=7)
|
||||||
|
|
||||||
|
self.host_entry = self.add_entry(body, 3, "Host / IP", switch.get("host", ""))
|
||||||
|
self.port_entry = self.add_entry(body, 4, "SSH-Port", str(switch.get("port", 22)))
|
||||||
|
self.username_entry = self.add_entry(body, 5, "Benutzer", switch.get("username", ""))
|
||||||
|
self.password_entry = self.add_entry(body, 6, "Passwort", switch.get("password", ""), secret=True)
|
||||||
|
ttk.Checkbutton(body, text="Switch aktivieren", variable=self.enabled).grid(
|
||||||
|
row=7, column=0, columnspan=2, sticky="w", pady=(12, 4)
|
||||||
|
)
|
||||||
|
ttk.Label(
|
||||||
|
body,
|
||||||
|
text="Das Passwort wird im Kundenprofil mit Windows DPAPI verschlüsselt gespeichert.",
|
||||||
|
foreground="#52606d",
|
||||||
|
wraplength=500,
|
||||||
|
).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=(22, 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_device_values()
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||||
|
self.wait_window(self)
|
||||||
|
|
||||||
|
def matching_device_options(self, query=""):
|
||||||
|
tenant = self.tenant_filter.get()
|
||||||
|
query = query.strip().casefold()
|
||||||
|
return [
|
||||||
|
option for option in self.device_options
|
||||||
|
if (tenant == "Alle Mandanten" or option["tenant"] == tenant)
|
||||||
|
and (not query or query in option["display"].casefold())
|
||||||
|
]
|
||||||
|
|
||||||
|
def update_device_values(self, query=""):
|
||||||
|
self.device_combo["values"] = [option["display"] for option in self.matching_device_options(query)]
|
||||||
|
|
||||||
|
def filter_devices(self, _event=None):
|
||||||
|
self.update_device_values(self.device_selection.get())
|
||||||
|
|
||||||
|
def on_tenant_changed(self, _event=None):
|
||||||
|
current = self.device_selection.get()
|
||||||
|
allowed = {option["display"] for option in self.matching_device_options()}
|
||||||
|
if current not in allowed:
|
||||||
|
self.device_selection.set("")
|
||||||
|
self.update_device_values()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def add_entry(parent, row, label, value, secret=False):
|
||||||
|
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=7)
|
||||||
|
entry = ttk.Entry(parent, show="•" if secret else "")
|
||||||
|
entry.grid(row=row, column=1, sticky="ew", pady=7)
|
||||||
|
entry.insert(0, value)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def accept(self):
|
||||||
|
device = self.device_map.get(self.device_selection.get())
|
||||||
|
host = self.host_entry.get().strip()
|
||||||
|
username = self.username_entry.get().strip()
|
||||||
|
password = self.password_entry.get()
|
||||||
|
try:
|
||||||
|
port = int(self.port_entry.get())
|
||||||
|
except ValueError:
|
||||||
|
port = 0
|
||||||
|
if not device or not host or not username or not password or not 1 <= port <= 65535:
|
||||||
|
messagebox.showwarning("Fehlende Angaben", "Bitte alle Switch-Zugangsdaten korrekt ausfüllen.", parent=self)
|
||||||
|
return
|
||||||
|
device_id, device_name = device
|
||||||
|
self.result = {
|
||||||
|
"id": self.switch_id,
|
||||||
|
"name": device_name,
|
||||||
|
"device_id": device_id,
|
||||||
|
"platform": self.platform_map[self.platform_display.get()],
|
||||||
|
"host": host,
|
||||||
|
"port": port,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"enabled": self.enabled.get(),
|
||||||
|
}
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
class TenantConfirmationDialog(tk.Toplevel):
|
class TenantConfirmationDialog(tk.Toplevel):
|
||||||
"""Require explicit tenant confirmation before changing NetBox VMs."""
|
"""Require explicit tenant confirmation before changing NetBox VMs."""
|
||||||
|
|
||||||
@@ -718,7 +872,7 @@ class NetworkManagementDialog(tk.Toplevel):
|
|||||||
class NetBoxVMImporter:
|
class NetBoxVMImporter:
|
||||||
def __init__(self, root):
|
def __init__(self, root):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.root.title("VMware & Proxmox → NetBox Synchronizer")
|
self.root.title("NetBox Synchronizer")
|
||||||
self.root.geometry("1160x850")
|
self.root.geometry("1160x850")
|
||||||
self.root.minsize(960, 700)
|
self.root.minsize(960, 700)
|
||||||
|
|
||||||
@@ -726,12 +880,17 @@ class NetBoxVMImporter:
|
|||||||
self.vms = []
|
self.vms = []
|
||||||
self.profiles = {}
|
self.profiles = {}
|
||||||
self.current_sources = []
|
self.current_sources = []
|
||||||
|
self.current_switches = []
|
||||||
|
self.switch_snapshots = {}
|
||||||
|
self.netbox_devices = []
|
||||||
self.cluster_map = {}
|
self.cluster_map = {}
|
||||||
self.cluster_tenants = {}
|
self.cluster_tenants = {}
|
||||||
self.tenant_map = {}
|
self.tenant_map = {}
|
||||||
self.vrfs = []
|
self.vrfs = []
|
||||||
self.platforms = []
|
self.platforms = []
|
||||||
self.profile_name = tk.StringVar()
|
self.profile_name = tk.StringVar()
|
||||||
|
self.netbox_url_var = tk.StringVar()
|
||||||
|
self.netbox_token_var = tk.StringVar()
|
||||||
self.netbox_ignore_ssl = tk.BooleanVar(value=False)
|
self.netbox_ignore_ssl = tk.BooleanVar(value=False)
|
||||||
self.status_text = tk.StringVar(value="Bereit")
|
self.status_text = tk.StringVar(value="Bereit")
|
||||||
self.store = ProfileStore()
|
self.store = ProfileStore()
|
||||||
@@ -766,7 +925,14 @@ class NetBoxVMImporter:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def create_gui(self):
|
def create_gui(self):
|
||||||
main = ttk.Frame(self.root, padding=18)
|
self.main_tabs = ttk.Notebook(self.root)
|
||||||
|
self.main_tabs.pack(fill="both", expand=True)
|
||||||
|
virtualization_tab = ttk.Frame(self.main_tabs)
|
||||||
|
switches_tab = ttk.Frame(self.main_tabs)
|
||||||
|
self.main_tabs.add(virtualization_tab, text="Virtualisierung")
|
||||||
|
self.main_tabs.add(switches_tab, text="Switche")
|
||||||
|
|
||||||
|
main = ttk.Frame(virtualization_tab, padding=18)
|
||||||
main.pack(fill="both", expand=True)
|
main.pack(fill="both", expand=True)
|
||||||
main.columnconfigure(0, weight=1)
|
main.columnconfigure(0, weight=1)
|
||||||
main.rowconfigure(4, weight=3)
|
main.rowconfigure(4, weight=3)
|
||||||
@@ -808,8 +974,8 @@ class NetBoxVMImporter:
|
|||||||
ttk.Button(source_buttons, text="Bearbeiten", command=self.edit_source).pack(side="left", padx=5)
|
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")
|
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_url = self.add_field(netbox_card, 0, "URL", variable=self.netbox_url_var)
|
||||||
self.netbox_token = self.add_field(netbox_card, 1, "API-Token", secret=True)
|
self.netbox_token = self.add_field(netbox_card, 1, "API-Token", secret=True, variable=self.netbox_token_var)
|
||||||
ttk.Checkbutton(netbox_card, text="Zertifikatsprüfung deaktivieren", variable=self.netbox_ignore_ssl).grid(
|
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)
|
row=2, column=0, columnspan=2, sticky="w", pady=(10, 0)
|
||||||
)
|
)
|
||||||
@@ -854,11 +1020,123 @@ class NetBoxVMImporter:
|
|||||||
self.log_text = tk.Text(log_card, height=7, borderwidth=0, bg="#f7f9fb", foreground="#243b53", font=("Consolas", 9), state="disabled")
|
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)
|
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))
|
ttk.Label(main, textvariable=self.status_text, style="Status.TLabel", anchor="w").grid(row=7, column=0, sticky="ew", pady=(6, 0))
|
||||||
|
self.create_switch_gui(switches_tab)
|
||||||
|
|
||||||
|
def create_switch_gui(self, parent):
|
||||||
|
main = ttk.Frame(parent, padding=18)
|
||||||
|
main.pack(fill="both", expand=True)
|
||||||
|
main.columnconfigure(0, weight=1)
|
||||||
|
main.rowconfigure(3, weight=1)
|
||||||
|
|
||||||
|
header = ttk.Frame(main)
|
||||||
|
header.grid(row=0, column=0, sticky="ew", pady=(0, 12))
|
||||||
|
ttk.Label(header, text="HPE & Aruba Switche", style="Title.TLabel").pack(anchor="w")
|
||||||
|
ttk.Label(
|
||||||
|
header,
|
||||||
|
text="Bestehende NetBox-Geräte per SSH auslesen und dokumentieren",
|
||||||
|
style="Subtitle.TLabel",
|
||||||
|
).pack(anchor="w")
|
||||||
|
|
||||||
|
settings = ttk.Panedwindow(main, orient="horizontal")
|
||||||
|
settings.grid(row=1, column=0, sticky="ew", pady=(0, 10))
|
||||||
|
profile_card = ttk.LabelFrame(settings, text="Kundenprofil", style="Card.TLabelframe")
|
||||||
|
netbox_card = ttk.LabelFrame(settings, text="NetBox-Ziel", style="Card.TLabelframe")
|
||||||
|
settings.add(profile_card, weight=3)
|
||||||
|
settings.add(netbox_card, weight=2)
|
||||||
|
|
||||||
|
self.switch_profile_combo = ttk.Combobox(profile_card, textvariable=self.profile_name, state="readonly")
|
||||||
|
self.switch_profile_combo.pack(side="left", fill="x", expand=True, padx=(0, 8))
|
||||||
|
self.switch_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)
|
||||||
|
|
||||||
|
self.switch_netbox_url = self.add_field(netbox_card, 0, "URL", variable=self.netbox_url_var)
|
||||||
|
self.switch_netbox_token = self.add_field(
|
||||||
|
netbox_card,
|
||||||
|
1,
|
||||||
|
"API-Token",
|
||||||
|
secret=True,
|
||||||
|
variable=self.netbox_token_var,
|
||||||
|
)
|
||||||
|
ttk.Checkbutton(
|
||||||
|
netbox_card,
|
||||||
|
text="Zertifikatsprüfung deaktivieren",
|
||||||
|
variable=self.netbox_ignore_ssl,
|
||||||
|
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 0))
|
||||||
|
|
||||||
|
source_card = ttk.LabelFrame(main, text="Switch-Zugänge", style="Card.TLabelframe")
|
||||||
|
source_card.grid(row=2, column=0, sticky="nsew", pady=(0, 10))
|
||||||
|
switch_columns = ("device", "platform", "host", "active")
|
||||||
|
self.switch_tree = ttk.Treeview(source_card, columns=switch_columns, show="headings", height=6)
|
||||||
|
for column, title, width in (
|
||||||
|
("device", "NetBox-Gerät", 230),
|
||||||
|
("platform", "Plattform", 210),
|
||||||
|
("host", "SSH-Host", 260),
|
||||||
|
("active", "Aktiv", 60),
|
||||||
|
):
|
||||||
|
self.switch_tree.heading(column, text=title)
|
||||||
|
self.switch_tree.column(column, width=width, anchor="center" if column == "active" else "w")
|
||||||
|
self.switch_tree.pack(fill="both", expand=True)
|
||||||
|
self.switch_tree.bind("<Double-1>", lambda _event: self.edit_switch())
|
||||||
|
source_buttons = ttk.Frame(source_card)
|
||||||
|
source_buttons.pack(fill="x", pady=(8, 0))
|
||||||
|
ttk.Button(source_buttons, text="NetBox-Geräte laden", command=self.load_switch_devices).pack(side="left")
|
||||||
|
ttk.Button(source_buttons, text="Switch hinzufügen", command=self.add_switch).pack(side="left", padx=(8, 4))
|
||||||
|
ttk.Button(source_buttons, text="Bearbeiten", command=self.edit_switch).pack(side="left", padx=4)
|
||||||
|
ttk.Button(source_buttons, text="Entfernen", command=self.remove_switch).pack(side="left", padx=4)
|
||||||
|
ttk.Button(
|
||||||
|
source_buttons,
|
||||||
|
text="Ausgewählte auslesen",
|
||||||
|
command=self.discover_selected_switches,
|
||||||
|
style="Primary.TButton",
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
|
switch_workspace = ttk.Panedwindow(main, orient="vertical")
|
||||||
|
switch_workspace.grid(row=3, column=0, sticky="nsew", pady=(0, 10))
|
||||||
|
result_card = ttk.LabelFrame(switch_workspace, text="Schnittstellen & LLDP/CDP-Nachbarn", style="Card.TLabelframe")
|
||||||
|
result_columns = ("switch", "interface", "state", "speed", "ips", "neighbor", "remote_port")
|
||||||
|
self.switch_result_tree = ttk.Treeview(result_card, columns=result_columns, show="headings")
|
||||||
|
result_headings = {
|
||||||
|
"switch": "Switch", "interface": "Schnittstelle", "state": "Status",
|
||||||
|
"speed": "Geschwindigkeit", "ips": "IP-Adressen",
|
||||||
|
"neighbor": "LLDP/CDP-Nachbar", "remote_port": "Nachbar-Port",
|
||||||
|
}
|
||||||
|
result_widths = {
|
||||||
|
"switch": 150, "interface": 140, "state": 70, "speed": 100,
|
||||||
|
"ips": 210, "neighbor": 170, "remote_port": 140,
|
||||||
|
}
|
||||||
|
for column in result_columns:
|
||||||
|
self.switch_result_tree.heading(column, text=result_headings[column])
|
||||||
|
self.switch_result_tree.column(column, width=result_widths[column])
|
||||||
|
result_scroll = ttk.Scrollbar(result_card, orient="vertical", command=self.switch_result_tree.yview)
|
||||||
|
self.switch_result_tree.configure(yscrollcommand=result_scroll.set)
|
||||||
|
self.switch_result_tree.pack(side="left", fill="both", expand=True)
|
||||||
|
result_scroll.pack(side="right", fill="y")
|
||||||
|
|
||||||
|
log_card = ttk.LabelFrame(switch_workspace, text="Switch-Protokoll", style="Card.TLabelframe")
|
||||||
|
self.switch_log_text = tk.Text(
|
||||||
|
log_card, height=6, borderwidth=0, bg="#f7f9fb", foreground="#243b53", font=("Consolas", 9), state="disabled"
|
||||||
|
)
|
||||||
|
log_scroll = ttk.Scrollbar(log_card, orient="vertical", command=self.switch_log_text.yview)
|
||||||
|
self.switch_log_text.configure(yscrollcommand=log_scroll.set)
|
||||||
|
self.switch_log_text.pack(side="left", fill="both", expand=True)
|
||||||
|
log_scroll.pack(side="right", fill="y")
|
||||||
|
switch_workspace.add(result_card, weight=4)
|
||||||
|
switch_workspace.add(log_card, weight=1)
|
||||||
|
|
||||||
|
actions = ttk.Frame(main)
|
||||||
|
actions.grid(row=4, column=0, sticky="ew", pady=(0, 10))
|
||||||
|
self.create_neighbor_cables = tk.BooleanVar(value=True)
|
||||||
|
ttk.Checkbutton(actions, text="LLDP/CDP-Verbindungen als Kabel anlegen", variable=self.create_neighbor_cables).pack(side="left")
|
||||||
|
ttk.Button(actions, text="Nach NetBox synchronisieren", command=self.sync_switches, style="Primary.TButton").pack(side="right")
|
||||||
|
ttk.Button(actions, text="SSH-Rohdaten", command=self.open_switch_raw_window).pack(side="right", padx=(0, 8))
|
||||||
|
ttk.Button(actions, text="Protokoll öffnen", command=self.open_switch_log_window).pack(side="right", padx=(0, 8))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def add_field(parent, row, label, secret=False):
|
def add_field(parent, row, label, secret=False, variable=None):
|
||||||
ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", padx=(0, 10), pady=5)
|
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 = ttk.Entry(parent, show="•" if secret else "", textvariable=variable)
|
||||||
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
entry.grid(row=row, column=1, sticky="ew", pady=5)
|
||||||
parent.columnconfigure(1, weight=1)
|
parent.columnconfigure(1, weight=1)
|
||||||
return entry
|
return entry
|
||||||
@@ -892,7 +1170,10 @@ class NetBoxVMImporter:
|
|||||||
messagebox.showwarning("Konfiguration", f"Profile konnten nicht geladen werden:\n{error}")
|
messagebox.showwarning("Konfiguration", f"Profile konnten nicht geladen werden:\n{error}")
|
||||||
|
|
||||||
def refresh_profile_list(self):
|
def refresh_profile_list(self):
|
||||||
self.profile_combo["values"] = sorted(self.profiles, key=str.casefold)
|
values = sorted(self.profiles, key=str.casefold)
|
||||||
|
self.profile_combo["values"] = values
|
||||||
|
if hasattr(self, "switch_profile_combo"):
|
||||||
|
self.switch_profile_combo["values"] = values
|
||||||
|
|
||||||
def profile_from_form(self, name):
|
def profile_from_form(self, name):
|
||||||
return {
|
return {
|
||||||
@@ -901,6 +1182,7 @@ class NetBoxVMImporter:
|
|||||||
"netbox_token": self.netbox_token.get(),
|
"netbox_token": self.netbox_token.get(),
|
||||||
"netbox_ignore_ssl": self.netbox_ignore_ssl.get(),
|
"netbox_ignore_ssl": self.netbox_ignore_ssl.get(),
|
||||||
"sources": [dict(source) for source in self.current_sources],
|
"sources": [dict(source) for source in self.current_sources],
|
||||||
|
"switches": [dict(switch) for switch in self.current_switches],
|
||||||
}
|
}
|
||||||
|
|
||||||
def populate_profile(self, name):
|
def populate_profile(self, name):
|
||||||
@@ -909,7 +1191,12 @@ class NetBoxVMImporter:
|
|||||||
self.set_entry(self.netbox_token, profile.get("netbox_token", ""))
|
self.set_entry(self.netbox_token, profile.get("netbox_token", ""))
|
||||||
self.netbox_ignore_ssl.set(bool(profile.get("netbox_ignore_ssl", False)))
|
self.netbox_ignore_ssl.set(bool(profile.get("netbox_ignore_ssl", False)))
|
||||||
self.current_sources = [dict(source) for source in profile.get("sources", [])]
|
self.current_sources = [dict(source) for source in profile.get("sources", [])]
|
||||||
|
self.current_switches = [dict(switch) for switch in profile.get("switches", [])]
|
||||||
|
self.switch_snapshots = {}
|
||||||
self.refresh_source_tree()
|
self.refresh_source_tree()
|
||||||
|
self.refresh_switch_tree()
|
||||||
|
if hasattr(self, "switch_result_tree"):
|
||||||
|
self.refresh_switch_results()
|
||||||
self.clear_loaded_data()
|
self.clear_loaded_data()
|
||||||
self.status_text.set(f"Profil „{name}“ geladen")
|
self.status_text.set(f"Profil „{name}“ geladen")
|
||||||
|
|
||||||
@@ -933,7 +1220,9 @@ class NetBoxVMImporter:
|
|||||||
self.set_entry(self.netbox_token, "")
|
self.set_entry(self.netbox_token, "")
|
||||||
self.netbox_ignore_ssl.set(False)
|
self.netbox_ignore_ssl.set(False)
|
||||||
self.current_sources = []
|
self.current_sources = []
|
||||||
|
self.current_switches = []
|
||||||
self.refresh_source_tree()
|
self.refresh_source_tree()
|
||||||
|
self.refresh_switch_tree()
|
||||||
self.profiles[name] = self.profile_from_form(name)
|
self.profiles[name] = self.profile_from_form(name)
|
||||||
self.refresh_profile_list()
|
self.refresh_profile_list()
|
||||||
|
|
||||||
@@ -971,7 +1260,9 @@ class NetBoxVMImporter:
|
|||||||
self.set_entry(self.netbox_url, "")
|
self.set_entry(self.netbox_url, "")
|
||||||
self.set_entry(self.netbox_token, "")
|
self.set_entry(self.netbox_token, "")
|
||||||
self.current_sources = []
|
self.current_sources = []
|
||||||
|
self.current_switches = []
|
||||||
self.refresh_source_tree()
|
self.refresh_source_tree()
|
||||||
|
self.refresh_switch_tree()
|
||||||
|
|
||||||
def refresh_source_tree(self):
|
def refresh_source_tree(self):
|
||||||
self.source_tree.delete(*self.source_tree.get_children())
|
self.source_tree.delete(*self.source_tree.get_children())
|
||||||
@@ -1012,6 +1303,236 @@ class NetBoxVMImporter:
|
|||||||
self.current_sources.remove(source)
|
self.current_sources.remove(source)
|
||||||
self.refresh_source_tree()
|
self.refresh_source_tree()
|
||||||
|
|
||||||
|
def switch_log(self, text):
|
||||||
|
self.switch_log_text.configure(state="normal")
|
||||||
|
self.switch_log_text.insert(tk.END, f"{text}\n")
|
||||||
|
self.switch_log_text.see(tk.END)
|
||||||
|
self.switch_log_text.configure(state="disabled")
|
||||||
|
self.status_text.set(text)
|
||||||
|
self.root.update()
|
||||||
|
|
||||||
|
def _open_switch_text_window(self, title, content):
|
||||||
|
window = tk.Toplevel(self.root)
|
||||||
|
window.title(title)
|
||||||
|
window.geometry("1000x650")
|
||||||
|
window.minsize(700, 400)
|
||||||
|
icon = self.resource_path("netbox_vm_import.ico")
|
||||||
|
if icon.exists():
|
||||||
|
try:
|
||||||
|
window.iconbitmap(str(icon))
|
||||||
|
except tk.TclError:
|
||||||
|
pass
|
||||||
|
frame = ttk.Frame(window, padding=10)
|
||||||
|
frame.pack(fill="both", expand=True)
|
||||||
|
frame.rowconfigure(0, weight=1)
|
||||||
|
frame.columnconfigure(0, weight=1)
|
||||||
|
text_widget = tk.Text(frame, wrap="none", font=("Consolas", 9), bg="#f7f9fb", foreground="#243b53")
|
||||||
|
vertical = ttk.Scrollbar(frame, orient="vertical", command=text_widget.yview)
|
||||||
|
horizontal = ttk.Scrollbar(frame, orient="horizontal", command=text_widget.xview)
|
||||||
|
text_widget.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set)
|
||||||
|
text_widget.grid(row=0, column=0, sticky="nsew")
|
||||||
|
vertical.grid(row=0, column=1, sticky="ns")
|
||||||
|
horizontal.grid(row=1, column=0, sticky="ew")
|
||||||
|
text_widget.insert("1.0", content)
|
||||||
|
text_widget.configure(state="disabled")
|
||||||
|
return window
|
||||||
|
|
||||||
|
def open_switch_log_window(self):
|
||||||
|
content = self.switch_log_text.get("1.0", "end-1c") or "Noch keine Protokolleinträge vorhanden."
|
||||||
|
self._open_switch_text_window("Switch-Protokoll", content)
|
||||||
|
|
||||||
|
def open_switch_raw_window(self):
|
||||||
|
selected_ids = set(self.switch_tree.selection())
|
||||||
|
snapshots = [
|
||||||
|
(switch, self.switch_snapshots.get(switch["id"]))
|
||||||
|
for switch in self.current_switches
|
||||||
|
if (not selected_ids or switch["id"] in selected_ids) and self.switch_snapshots.get(switch["id"])
|
||||||
|
]
|
||||||
|
if not snapshots:
|
||||||
|
messagebox.showinfo("SSH-Rohdaten", "Bitte zuerst mindestens einen Switch auslesen.", parent=self.root)
|
||||||
|
return
|
||||||
|
sections = []
|
||||||
|
labels = {
|
||||||
|
"interfaces": "SCHNITTSTELLEN",
|
||||||
|
"ipv4_interfaces": "IPV4-SCHNITTSTELLEN",
|
||||||
|
"ipv6_interfaces": "IPV6-SCHNITTSTELLEN",
|
||||||
|
"neighbors": "LLDP-NACHBARN",
|
||||||
|
"cdp_neighbors": "CDP-NACHBARN",
|
||||||
|
}
|
||||||
|
for switch, snapshot in snapshots:
|
||||||
|
sections.append(f"{'=' * 20} {switch['name']} ({switch['host']}) {'=' * 20}")
|
||||||
|
raw = snapshot.get("raw", {})
|
||||||
|
for key, label in labels.items():
|
||||||
|
sections.extend((f"\n--- {label} ---", str(raw.get(key, "") or "(keine Ausgabe)")))
|
||||||
|
self._open_switch_text_window("SSH-Rohdaten", "\n".join(sections))
|
||||||
|
|
||||||
|
def refresh_switch_tree(self):
|
||||||
|
if not hasattr(self, "switch_tree"):
|
||||||
|
return
|
||||||
|
self.switch_tree.delete(*self.switch_tree.get_children())
|
||||||
|
for switch in self.current_switches:
|
||||||
|
self.switch_tree.insert("", "end", iid=switch["id"], values=(
|
||||||
|
switch.get("name", ""),
|
||||||
|
PLATFORMS.get(switch.get("platform"), switch.get("platform", "")),
|
||||||
|
f"{switch.get('host', '')}:{switch.get('port', 22)}",
|
||||||
|
"Ja" if switch.get("enabled", True) else "Nein",
|
||||||
|
))
|
||||||
|
|
||||||
|
def load_switch_devices(self):
|
||||||
|
try:
|
||||||
|
if not self.netbox_url.get().strip() or not self.netbox_token.get():
|
||||||
|
raise ValueError("Bitte NetBox-URL und API-Token im Tab Virtualisierung eintragen.")
|
||||||
|
if not self.save_profile(show_message=False):
|
||||||
|
return False
|
||||||
|
self.connect_netbox()
|
||||||
|
self.switch_log("Lade vorhandene NetBox-Geräte …")
|
||||||
|
devices = list(self.nb.dcim.devices.all())
|
||||||
|
self.netbox_devices = sorted(
|
||||||
|
(device for device in devices if getattr(device, "name", None)),
|
||||||
|
key=lambda item: str(item.name).casefold(),
|
||||||
|
)
|
||||||
|
ignored = len(devices) - len(self.netbox_devices)
|
||||||
|
text = f"{len(self.netbox_devices)} benannte NetBox-Geräte geladen"
|
||||||
|
if ignored:
|
||||||
|
text += f" ({ignored} Gerät(e) ohne Namen übersprungen)"
|
||||||
|
self.switch_log(text)
|
||||||
|
return True
|
||||||
|
except Exception as error:
|
||||||
|
messagebox.showerror("NetBox-Verbindung", str(error), parent=self.root)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add_switch(self):
|
||||||
|
if not self.netbox_devices and not self.load_switch_devices():
|
||||||
|
return
|
||||||
|
dialog = SwitchSourceDialog(self.root, self.netbox_devices)
|
||||||
|
if dialog.result:
|
||||||
|
self.current_switches.append(dialog.result)
|
||||||
|
self.refresh_switch_tree()
|
||||||
|
|
||||||
|
def selected_switches(self):
|
||||||
|
selected_ids = set(self.switch_tree.selection())
|
||||||
|
return [switch for switch in self.current_switches if switch["id"] in selected_ids]
|
||||||
|
|
||||||
|
def edit_switch(self):
|
||||||
|
selected = self.selected_switches()
|
||||||
|
if len(selected) != 1:
|
||||||
|
messagebox.showinfo("Switch", "Bitte genau einen Switch auswählen.", parent=self.root)
|
||||||
|
return
|
||||||
|
if not self.netbox_devices and not self.load_switch_devices():
|
||||||
|
return
|
||||||
|
current = selected[0]
|
||||||
|
dialog = SwitchSourceDialog(self.root, self.netbox_devices, current)
|
||||||
|
if dialog.result:
|
||||||
|
index = self.current_switches.index(current)
|
||||||
|
self.current_switches[index] = dialog.result
|
||||||
|
self.refresh_switch_tree()
|
||||||
|
self.switch_tree.selection_set(dialog.result["id"])
|
||||||
|
|
||||||
|
def remove_switch(self):
|
||||||
|
selected = self.selected_switches()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
if not messagebox.askyesno(
|
||||||
|
"Switch entfernen",
|
||||||
|
f"{len(selected)} Switch-Zugang/Zugänge aus dem Profil entfernen?",
|
||||||
|
parent=self.root,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
selected_ids = {switch["id"] for switch in selected}
|
||||||
|
self.current_switches = [switch for switch in self.current_switches if switch["id"] not in selected_ids]
|
||||||
|
for switch_id in selected_ids:
|
||||||
|
self.switch_snapshots.pop(switch_id, None)
|
||||||
|
self.refresh_switch_tree()
|
||||||
|
self.refresh_switch_results()
|
||||||
|
|
||||||
|
def discover_selected_switches(self):
|
||||||
|
switches = self.selected_switches()
|
||||||
|
if not switches:
|
||||||
|
messagebox.showinfo("Switche", "Bitte mindestens einen Switch auswählen.", parent=self.root)
|
||||||
|
return
|
||||||
|
if not self.save_profile(show_message=False):
|
||||||
|
return
|
||||||
|
failures = []
|
||||||
|
for switch in switches:
|
||||||
|
if not switch.get("enabled", True):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
self.switch_log(f"Lese {switch['name']} ({switch['host']}) aus …")
|
||||||
|
snapshot = discover_switch(switch)
|
||||||
|
self.switch_snapshots[switch["id"]] = snapshot
|
||||||
|
if snapshot.get("lldp_enabled_automatically"):
|
||||||
|
self.switch_log(f"{switch['name']}: LLDP war deaktiviert und wurde automatisch aktiviert")
|
||||||
|
self.switch_log(
|
||||||
|
f"{switch['name']}: {len(snapshot['interfaces'])} Schnittstellen, "
|
||||||
|
f"{len(snapshot.get('ip_addresses', []))} IP-Adressen, "
|
||||||
|
f"{len(snapshot.get('lldp_neighbors', []))} LLDP- und "
|
||||||
|
f"{len(snapshot.get('cdp_neighbors', []))} CDP-Nachbarn"
|
||||||
|
)
|
||||||
|
if not snapshot["interfaces"]:
|
||||||
|
self.switch_log(f"WARNUNG {switch['name']}: Keine Schnittstellen erkannt – bitte SSH-Rohdaten prüfen")
|
||||||
|
if not snapshot["neighbors"]:
|
||||||
|
self.switch_log(f"WARNUNG {switch['name']}: Keine LLDP/CDP-Nachbarn erkannt – bitte SSH-Rohdaten prüfen")
|
||||||
|
except Exception as error:
|
||||||
|
failures.append(f"{switch['name']}: {error}")
|
||||||
|
self.switch_log(f"FEHLER {switch['name']}: {error}")
|
||||||
|
self.refresh_switch_results()
|
||||||
|
if failures:
|
||||||
|
messagebox.showwarning("Teilweise ausgelesen", "\n".join(failures), parent=self.root)
|
||||||
|
|
||||||
|
def refresh_switch_results(self):
|
||||||
|
self.switch_result_tree.delete(*self.switch_result_tree.get_children())
|
||||||
|
switch_map = {switch["id"]: switch for switch in self.current_switches}
|
||||||
|
for switch_id, snapshot in self.switch_snapshots.items():
|
||||||
|
switch = switch_map.get(switch_id)
|
||||||
|
if not switch:
|
||||||
|
continue
|
||||||
|
for interface in snapshot["interfaces"]:
|
||||||
|
neighbor = interface.get("neighbor") or {}
|
||||||
|
speed = interface.get("speed_mbps")
|
||||||
|
speed_text = f"{speed / 1000:g} Gbit/s" if speed and speed >= 1000 else (f"{speed} Mbit/s" if speed else "")
|
||||||
|
self.switch_result_tree.insert("", "end", values=(
|
||||||
|
switch["name"],
|
||||||
|
interface["name"],
|
||||||
|
"Up" if interface.get("connected") else "Down",
|
||||||
|
speed_text,
|
||||||
|
", ".join(interface.get("ip_addresses", [])),
|
||||||
|
neighbor.get("system_name", ""),
|
||||||
|
neighbor.get("remote_port", ""),
|
||||||
|
))
|
||||||
|
|
||||||
|
def sync_switches(self):
|
||||||
|
if not self.switch_snapshots:
|
||||||
|
messagebox.showinfo("Switche", "Bitte zuerst mindestens einen Switch auslesen.", parent=self.root)
|
||||||
|
return
|
||||||
|
if self.nb is None and not self.load_switch_devices():
|
||||||
|
return
|
||||||
|
switch_map = {switch["id"]: switch for switch in self.current_switches}
|
||||||
|
totals = {"created": 0, "updated": 0, "ip_addresses": 0, "cables": 0}
|
||||||
|
errors = []
|
||||||
|
for switch_id, snapshot in self.switch_snapshots.items():
|
||||||
|
switch = switch_map.get(switch_id)
|
||||||
|
if not switch:
|
||||||
|
continue
|
||||||
|
self.switch_log(f"Synchronisiere {switch['name']} nach NetBox …")
|
||||||
|
result = sync_snapshot_to_netbox(
|
||||||
|
self.nb,
|
||||||
|
switch,
|
||||||
|
snapshot,
|
||||||
|
create_cables=self.create_neighbor_cables.get(),
|
||||||
|
)
|
||||||
|
for key in totals:
|
||||||
|
totals[key] += result[key]
|
||||||
|
errors.extend(f"{switch['name']}: {error}" for error in result["errors"])
|
||||||
|
text = (
|
||||||
|
f"{totals['created']} Schnittstellen erstellt, {totals['updated']} aktualisiert, "
|
||||||
|
f"{totals['ip_addresses']} IP-Adressen synchronisiert, {totals['cables']} LLDP-Kabel angelegt."
|
||||||
|
)
|
||||||
|
self.switch_log(text)
|
||||||
|
if errors:
|
||||||
|
messagebox.showwarning("Synchronisierung teilweise abgeschlossen", text + "\n\n" + "\n".join(errors), parent=self.root)
|
||||||
|
else:
|
||||||
|
messagebox.showinfo("Switch-Synchronisierung abgeschlossen", text, parent=self.root)
|
||||||
|
|
||||||
def clear_loaded_data(self):
|
def clear_loaded_data(self):
|
||||||
self.vms = []
|
self.vms = []
|
||||||
if hasattr(self, "vm_tree"):
|
if hasattr(self, "vm_tree"):
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
+18
-1
@@ -83,6 +83,7 @@ class ProfileStore:
|
|||||||
"netbox_token": unprotect(record.get("netbox_token_encrypted", "")),
|
"netbox_token": unprotect(record.get("netbox_token_encrypted", "")),
|
||||||
"netbox_ignore_ssl": bool(record.get("netbox_ignore_ssl", record.get("ignore_ssl_errors", False))),
|
"netbox_ignore_ssl": bool(record.get("netbox_ignore_ssl", record.get("ignore_ssl_errors", False))),
|
||||||
"sources": [],
|
"sources": [],
|
||||||
|
"switches": [],
|
||||||
}
|
}
|
||||||
for source_record in record.get("sources", []):
|
for source_record in record.get("sources", []):
|
||||||
source = dict(source_record)
|
source = dict(source_record)
|
||||||
@@ -91,6 +92,13 @@ class ProfileStore:
|
|||||||
if encrypted_key in source:
|
if encrypted_key in source:
|
||||||
source[key] = unprotect(source.pop(encrypted_key))
|
source[key] = unprotect(source.pop(encrypted_key))
|
||||||
profile["sources"].append(source)
|
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.
|
# Migration from v2: one VMware source embedded in each profile.
|
||||||
if not profile["sources"] and record.get("esxi_host"):
|
if not profile["sources"] and record.get("esxi_host"):
|
||||||
@@ -123,6 +131,7 @@ class ProfileStore:
|
|||||||
"password": data.get("esxi_password", ""),
|
"password": data.get("esxi_password", ""),
|
||||||
"ignore_ssl": bool(data.get("ignore_ssl_errors", False)),
|
"ignore_ssl": bool(data.get("ignore_ssl_errors", False)),
|
||||||
}],
|
}],
|
||||||
|
"switches": [],
|
||||||
}
|
}
|
||||||
return profiles, data.get("active_profile", "")
|
return profiles, data.get("active_profile", "")
|
||||||
|
|
||||||
@@ -137,15 +146,23 @@ class ProfileStore:
|
|||||||
if key in source_data:
|
if key in source_data:
|
||||||
source[f"{key}_encrypted"] = protect(source_data.get(key, ""))
|
source[f"{key}_encrypted"] = protect(source_data.get(key, ""))
|
||||||
sources.append(source)
|
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({
|
records.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"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", "")),
|
||||||
"netbox_ignore_ssl": bool(profile.get("netbox_ignore_ssl", False)),
|
"netbox_ignore_ssl": bool(profile.get("netbox_ignore_ssl", False)),
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
|
"switches": switches,
|
||||||
})
|
})
|
||||||
|
|
||||||
payload = {"version": 3, "active_profile": active_profile, "profiles": records}
|
payload = {"version": 4, "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,4 +1,5 @@
|
|||||||
pyvmomi
|
pyvmomi
|
||||||
pynetbox
|
pynetbox
|
||||||
requests
|
requests
|
||||||
|
paramiko
|
||||||
pyinstaller
|
pyinstaller
|
||||||
|
|||||||
+597
@@ -0,0 +1,597 @@
|
|||||||
|
"""SSH discovery and NetBox synchronization for HPE and Aruba switches."""
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
|
||||||
|
PLATFORMS = {
|
||||||
|
"aruba_cx": "Aruba CX",
|
||||||
|
"aruba_aos": "ArubaOS-Switch / ProCurve",
|
||||||
|
"hpe_comware": "HPE Comware",
|
||||||
|
}
|
||||||
|
|
||||||
|
COMMANDS = {
|
||||||
|
"aruba_cx": ("no page", "show interface brief", "show lldp neighbor-info detail"),
|
||||||
|
"aruba_aos": ("no page", "show interfaces brief", "show lldp info remote-device detail"),
|
||||||
|
"hpe_comware": ("screen-length disable", "display interface brief", "display lldp neighbor-information verbose"),
|
||||||
|
}
|
||||||
|
|
||||||
|
IP_COMMANDS = {
|
||||||
|
"aruba_cx": ("show ip interface brief", "show ipv6 interface brief"),
|
||||||
|
"aruba_aos": ("show ip", "show ipv6"),
|
||||||
|
"hpe_comware": ("display ip interface brief", "display ipv6 interface brief"),
|
||||||
|
}
|
||||||
|
|
||||||
|
LLDP_CONTROL = {
|
||||||
|
"aruba_cx": {
|
||||||
|
"status": "show lldp configuration",
|
||||||
|
"disabled": r"LLDP Enabled\s*:\s*No",
|
||||||
|
"enable": ("configure terminal", "lldp", "end"),
|
||||||
|
},
|
||||||
|
"aruba_aos": {
|
||||||
|
"status": "show lldp config",
|
||||||
|
"disabled": r"LLDP (?:Enabled|Run)\s*:\s*(?:No|False)|LLDP operation.*disabled",
|
||||||
|
"enable": ("configure terminal", "lldp run", "exit"),
|
||||||
|
},
|
||||||
|
"hpe_comware": {
|
||||||
|
"status": "display lldp status",
|
||||||
|
"disabled": r"Global status of LLDP\s*:\s*Disable",
|
||||||
|
"enable": ("system-view", "lldp global enable", "return"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ANSI_ESCAPE = re.compile(
|
||||||
|
r"\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-_]|[=>])"
|
||||||
|
)
|
||||||
|
TERMINAL_CONTROL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
MAC_PATTERN = re.compile(r"(?i)\b(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b|\b[0-9a-f]{4}(?:-[0-9a-f]{4}){2}\b")
|
||||||
|
CLI_PROMPT = re.compile(r"(?:^|\n)[^\r\n]*[>#][ \t]*(?:\n)?\Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_output(value):
|
||||||
|
value = ANSI_ESCAPE.sub("", value).replace("\r", "")
|
||||||
|
value = TERMINAL_CONTROL.sub("", value)
|
||||||
|
value = re.sub(r"--\s*MORE\s*--|Press any key to continue.*", "", value, flags=re.I)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_available(channel, timeout=20, initialize_session=False):
|
||||||
|
chunks = []
|
||||||
|
started = time.monotonic()
|
||||||
|
next_nudge = started + 0.5
|
||||||
|
nudges = 0
|
||||||
|
while time.monotonic() - started < timeout:
|
||||||
|
if channel.recv_ready():
|
||||||
|
chunks.append(channel.recv(65535).decode("utf-8", errors="replace"))
|
||||||
|
# Interactive AOS-CX/AOS-S/Comware commands are complete when the
|
||||||
|
# device prompt returns. This avoids truncating output at a short
|
||||||
|
# pause while also avoiding a fixed delay after every command.
|
||||||
|
current = _clean_output("".join(chunks))
|
||||||
|
if CLI_PROMPT.search(current):
|
||||||
|
break
|
||||||
|
now = time.monotonic()
|
||||||
|
if initialize_session and nudges < 3 and now >= next_nudge:
|
||||||
|
# ArubaOS-S may stop at the login/registration banner until a key
|
||||||
|
# is pressed. Some WC releases need Enter two or three times.
|
||||||
|
channel.send("\r")
|
||||||
|
nudges += 1
|
||||||
|
next_nudge = now + 1.5
|
||||||
|
time.sleep(0.05)
|
||||||
|
output = _clean_output("".join(chunks))
|
||||||
|
if not CLI_PROMPT.search(output):
|
||||||
|
tail = " | ".join(line.strip() for line in output.splitlines()[-3:] if line.strip())
|
||||||
|
detail = f" Letzte Ausgabe: {tail[-300:]}" if tail else " Keine Ausgabe vom Switch empfangen."
|
||||||
|
raise TimeoutError(
|
||||||
|
"Der Switch hat die CLI-Ausgabe nicht mit einem vollständigen Prompt abgeschlossen." + detail
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cli_command(channel, command, timeout=20):
|
||||||
|
channel.send(command + "\n")
|
||||||
|
try:
|
||||||
|
return _read_available(channel, timeout=timeout)
|
||||||
|
except TimeoutError as error:
|
||||||
|
raise TimeoutError(f"CLI-Befehl nicht vollständig abgeschlossen: {command}. {error}") from error
|
||||||
|
|
||||||
|
|
||||||
|
def run_switch_commands(config):
|
||||||
|
platform = config["platform"]
|
||||||
|
if platform not in COMMANDS:
|
||||||
|
raise ValueError(f"Nicht unterstützte Switch-Plattform: {platform}")
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
try:
|
||||||
|
client.connect(
|
||||||
|
hostname=config["host"],
|
||||||
|
port=int(config.get("port", 22)),
|
||||||
|
username=config["username"],
|
||||||
|
password=config["password"],
|
||||||
|
look_for_keys=False,
|
||||||
|
allow_agent=False,
|
||||||
|
timeout=12,
|
||||||
|
auth_timeout=12,
|
||||||
|
banner_timeout=12,
|
||||||
|
)
|
||||||
|
channel = client.invoke_shell(width=240, height=1000)
|
||||||
|
try:
|
||||||
|
_read_available(channel, timeout=45, initialize_session=True)
|
||||||
|
except TimeoutError as error:
|
||||||
|
raise TimeoutError(
|
||||||
|
f"SSH-Anmeldung nicht mit einem vollständigen CLI-Prompt abgeschlossen. {error}"
|
||||||
|
) from error
|
||||||
|
pager_command, interface_command, neighbor_command = COMMANDS[platform]
|
||||||
|
_run_cli_command(channel, pager_command)
|
||||||
|
|
||||||
|
control = LLDP_CONTROL[platform]
|
||||||
|
lldp_status = _run_cli_command(channel, control["status"])
|
||||||
|
lldp_enabled_automatically = bool(re.search(control["disabled"], lldp_status, flags=re.I))
|
||||||
|
if lldp_enabled_automatically:
|
||||||
|
enable_output = []
|
||||||
|
for command in control["enable"]:
|
||||||
|
enable_output.append(_run_cli_command(channel, command))
|
||||||
|
combined = "\n".join(enable_output)
|
||||||
|
if platform == "hpe_comware" and re.search(r"(?i)unrecognized|invalid|wrong parameter", combined):
|
||||||
|
_run_cli_command(channel, "system-view")
|
||||||
|
combined = _run_cli_command(channel, "lldp enable")
|
||||||
|
combined += "\n" + _run_cli_command(channel, "return")
|
||||||
|
if re.search(r"(?i)permission denied|authorization failed|access denied", combined):
|
||||||
|
raise PermissionError("LLDP konnte mangels Konfigurationsrechten nicht aktiviert werden.")
|
||||||
|
if re.search(r"(?i)unrecognized|invalid input|unknown command|wrong parameter", combined):
|
||||||
|
raise RuntimeError("Der LLDP-Aktivierungsbefehl wird von diesem Switch nicht unterstützt.")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
interfaces = _run_cli_command(channel, interface_command, timeout=45)
|
||||||
|
ip_outputs = []
|
||||||
|
for command in IP_COMMANDS[platform]:
|
||||||
|
ip_outputs.append(_run_cli_command(channel, command, timeout=35))
|
||||||
|
neighbors = _run_cli_command(channel, neighbor_command, timeout=60)
|
||||||
|
if platform == "aruba_cx" and re.search(r"(?i)invalid input|unknown command|unrecognized", neighbors):
|
||||||
|
neighbors = _run_cli_command(channel, "show lldp neighbor-info", timeout=60)
|
||||||
|
cdp_neighbors = ""
|
||||||
|
if platform == "aruba_cx":
|
||||||
|
cdp_summary = _run_cli_command(channel, "show cdp neighbor-info", timeout=35)
|
||||||
|
cdp_ports = []
|
||||||
|
for line in cdp_summary.splitlines():
|
||||||
|
match = re.match(r"^\s*(\d+/\d+/\d+)\s+\S+", line)
|
||||||
|
if match and match.group(1) not in cdp_ports:
|
||||||
|
cdp_ports.append(match.group(1))
|
||||||
|
details = []
|
||||||
|
for port in cdp_ports:
|
||||||
|
details.append(_run_cli_command(channel, f"show cdp neighbor-info {port}", timeout=30))
|
||||||
|
cdp_neighbors = "\n".join([cdp_summary, *details])
|
||||||
|
return {
|
||||||
|
"interfaces": interfaces,
|
||||||
|
"ipv4_interfaces": ip_outputs[0],
|
||||||
|
"ipv6_interfaces": ip_outputs[1],
|
||||||
|
"neighbors": neighbors,
|
||||||
|
"cdp_neighbors": cdp_neighbors,
|
||||||
|
"lldp_enabled_automatically": lldp_enabled_automatically,
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_mac(value):
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
compact = re.sub(r"[^0-9a-fA-F]", "", value).upper()
|
||||||
|
if len(compact) != 12:
|
||||||
|
return None
|
||||||
|
return ":".join(compact[index:index + 2] for index in range(0, 12, 2))
|
||||||
|
|
||||||
|
|
||||||
|
def _speed_mbps(line):
|
||||||
|
matches = re.findall(
|
||||||
|
r"(?i)\b(\d+(?:\.\d+)?)\s*(T|G|M)(?:b(?:it)?|bps)?(?:T|X|SR|LR|CR)?\b",
|
||||||
|
line,
|
||||||
|
)
|
||||||
|
if not matches:
|
||||||
|
duplex = re.search(r"(?i)\b(\d+)(?:FDx|HDx)\b", line)
|
||||||
|
if duplex:
|
||||||
|
return int(duplex.group(1))
|
||||||
|
trailing = re.search(r"\b(\d{2,6})\s*$", line)
|
||||||
|
return int(trailing.group(1)) if trailing else None
|
||||||
|
value, unit = matches[-1]
|
||||||
|
factor = {"M": 1, "G": 1000, "T": 1_000_000}[unit.upper()]
|
||||||
|
return int(float(value) * factor)
|
||||||
|
|
||||||
|
|
||||||
|
def _interface_name(token):
|
||||||
|
token = token.strip(" |:")
|
||||||
|
return bool(re.match(
|
||||||
|
r"(?i)^(?:\d+(?:/\d+){0,3}|(?:GE|XGE|HGE|FGE|FortyGigE|Ten-GigabitEthernet|GigabitEthernet)\S+|"
|
||||||
|
r"Bridge-Aggregation\d+|Trk\d+|lag\d+|Vlan-interface\d+|vlan\s*\d+|loopback\s*\d*|lo\d+|mgmt)$",
|
||||||
|
token,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_interfaces(output, platform):
|
||||||
|
interfaces = []
|
||||||
|
seen = set()
|
||||||
|
for raw_line in _clean_output(output).splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or set(line) <= {"-", "=", " "}:
|
||||||
|
continue
|
||||||
|
tokens = re.split(r"\s+", line.replace("|", " | ").strip())
|
||||||
|
name_index = next((index for index, token in enumerate(tokens[:3]) if _interface_name(token)), None)
|
||||||
|
if name_index is None:
|
||||||
|
continue
|
||||||
|
name = tokens[name_index].strip(" |:")
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
lower = line.lower()
|
||||||
|
if not re.search(r"\b(up|down|administratively down|disabled|enabled)\b", lower):
|
||||||
|
continue
|
||||||
|
enabled = not bool(re.search(r"administratively down|admin.*down|disabled|\bno\s+(?:up|down)\b", lower))
|
||||||
|
connected = bool(re.search(r"\bup\b", lower)) and not bool(re.search(r"\bdown\b", lower))
|
||||||
|
mac_match = MAC_PATTERN.search(line)
|
||||||
|
description = ""
|
||||||
|
if platform == "aruba_aos" and "|" in raw_line:
|
||||||
|
columns = [column.strip() for column in raw_line.split("|")]
|
||||||
|
if len(columns) > 1:
|
||||||
|
description = columns[1]
|
||||||
|
interfaces.append({
|
||||||
|
"name": name,
|
||||||
|
"enabled": enabled,
|
||||||
|
"connected": connected,
|
||||||
|
"speed_mbps": _speed_mbps(line),
|
||||||
|
"mac_address": _normalize_mac(mac_match.group(0)) if mac_match else None,
|
||||||
|
"description": description,
|
||||||
|
"neighbor": None,
|
||||||
|
"ip_addresses": [],
|
||||||
|
"virtual": False,
|
||||||
|
})
|
||||||
|
seen.add(name)
|
||||||
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
def _address_with_mask(address, mask=None):
|
||||||
|
try:
|
||||||
|
if "/" in address:
|
||||||
|
return str(ipaddress.ip_interface(address))
|
||||||
|
if mask:
|
||||||
|
return str(ipaddress.ip_interface(f"{address}/{mask}"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_interface_ips(ipv4_output, ipv6_output, platform):
|
||||||
|
entries = []
|
||||||
|
if platform == "aruba_aos":
|
||||||
|
current_interface = ""
|
||||||
|
for line in _clean_output(ipv4_output).splitlines():
|
||||||
|
if "|" not in line:
|
||||||
|
continue
|
||||||
|
interface_column, values = line.split("|", 1)
|
||||||
|
candidate = interface_column.strip()
|
||||||
|
if candidate and not re.search(r"(?i)^VLAN$", candidate) and not set(candidate) <= {"-", "+", " "}:
|
||||||
|
current_interface = candidate
|
||||||
|
addresses = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", values)
|
||||||
|
if current_interface and len(addresses) >= 2:
|
||||||
|
address = _address_with_mask(addresses[0], addresses[1])
|
||||||
|
if address and not address.startswith("0.0.0.0/"):
|
||||||
|
entries.append({"interface": current_interface, "address": address})
|
||||||
|
current_interface = ""
|
||||||
|
for line in _clean_output(ipv6_output).splitlines():
|
||||||
|
vlan_match = re.match(r"^\s*Vlan Name\s*:\s*(.+)$", line, flags=re.I)
|
||||||
|
if vlan_match:
|
||||||
|
current_interface = vlan_match.group(1).strip()
|
||||||
|
address_match = re.search(r"([0-9a-fA-F:]+/\d{1,3})", line)
|
||||||
|
if address_match and current_interface:
|
||||||
|
address = _address_with_mask(address_match.group(1))
|
||||||
|
if address and not address.lower().startswith("fe80:"):
|
||||||
|
entries.append({"interface": current_interface, "address": address})
|
||||||
|
else:
|
||||||
|
for output, version in ((ipv4_output, 4), (ipv6_output, 6)):
|
||||||
|
for line in _clean_output(output).splitlines():
|
||||||
|
tokens = re.split(r"\s+", line.strip())
|
||||||
|
if len(tokens) < 2 or not _interface_name(tokens[0]):
|
||||||
|
continue
|
||||||
|
patterns = (
|
||||||
|
r"\b(\d+(?:\.\d+){3}/\d{1,2})\b"
|
||||||
|
if version == 4 else
|
||||||
|
r"\b([0-9a-fA-F]*:[0-9a-fA-F:]+/\d{1,3})\b"
|
||||||
|
)
|
||||||
|
match = re.search(patterns, line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
address = _address_with_mask(match.group(1))
|
||||||
|
if not address or address.startswith("0.0.0.0/") or address.lower().startswith("fe80:"):
|
||||||
|
continue
|
||||||
|
entries.append({"interface": tokens[0], "address": address})
|
||||||
|
unique = {}
|
||||||
|
for entry in entries:
|
||||||
|
unique[(entry["interface"].casefold(), entry["address"])] = entry
|
||||||
|
return list(unique.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _interface_key(value):
|
||||||
|
value = value.casefold().replace("_", "").replace("-", "").replace(" ", "")
|
||||||
|
value = value.replace("vlaninterface", "vlan")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _field(block, patterns):
|
||||||
|
for pattern in patterns:
|
||||||
|
match = re.search(pattern, block, flags=re.I | re.M)
|
||||||
|
if match:
|
||||||
|
return match.group(1).strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_neighbors(output):
|
||||||
|
clean = _clean_output(output)
|
||||||
|
starts = list(re.finditer(
|
||||||
|
r"(?im)^(?:\s*(?:Port|Local (?:Port|Interface|Intf)|LLDP neighbor-information of port)\s*[: ]\s*.+)$",
|
||||||
|
clean,
|
||||||
|
))
|
||||||
|
if not starts:
|
||||||
|
return _parse_neighbor_table(clean)
|
||||||
|
neighbors = []
|
||||||
|
for index, start in enumerate(starts):
|
||||||
|
end = starts[index + 1].start() if index + 1 < len(starts) else len(clean)
|
||||||
|
block = clean[start.start():end]
|
||||||
|
local_port = _field(block, (
|
||||||
|
r"^\s*Port\s*:\s*(\S+)",
|
||||||
|
r"^\s*Local (?:Port|Interface|Intf)\s*[: ]\s*(\S+)",
|
||||||
|
r"^\s*LLDP neighbor-information of port\s+(?:\d+\[)?([^\]\s:]+)\]?",
|
||||||
|
))
|
||||||
|
system_name = _field(block, (
|
||||||
|
r"^\s*Neighbor Chassis-Name\s*:\s*(.+)$",
|
||||||
|
r"^[ \t]*Neighbor System-Name[ \t]*:[ \t]*(.*)$",
|
||||||
|
r"^[ \t]*(?:System Name|SysName)[ \t]*:[ \t]*(.*)$",
|
||||||
|
r"^\s*Neighbor name\s*[: ]\s*(.+)$",
|
||||||
|
))
|
||||||
|
if not system_name:
|
||||||
|
system_name = _field(block, (
|
||||||
|
r"^[ \t]*Neighbor Chassis-ID[ \t]*:[ \t]*(.+)$",
|
||||||
|
r"^[ \t]*ChassisId[ \t]*:[ \t]*(.+)$",
|
||||||
|
))
|
||||||
|
remote_port = _field(block, (
|
||||||
|
r"^\s*Neighbor Port-ID\s*:\s*(.+)$",
|
||||||
|
r"^\s*Neighbor Port-Desc\s*:\s*(.+)$",
|
||||||
|
r"^[ \t]*(?:Port ID|PortId)[ \t]*:[ \t]*(.*)$",
|
||||||
|
r"^\s*Port ID subtype.*\n\s*Port ID\s*[: ]\s*(.+)$",
|
||||||
|
r"^\s*(?:Port Description|PortDesc)\s*[: ]\s*(.+)$",
|
||||||
|
))
|
||||||
|
management_ip = _field(block, (
|
||||||
|
r"^\s*Neighbor Management-Address\s*:\s*([0-9a-fA-F:.]+)",
|
||||||
|
r"^\s*(?:Management Address|Management address|Management IP)\s*[: ]\s*([0-9a-fA-F:.]+)",
|
||||||
|
r"^[ \t]*Address[ \t]*:[ \t]*([0-9a-fA-F:.]+)[ \t]*$",
|
||||||
|
))
|
||||||
|
if local_port and (system_name or remote_port):
|
||||||
|
neighbors.append({
|
||||||
|
"local_port": local_port,
|
||||||
|
"system_name": system_name,
|
||||||
|
"remote_port": remote_port,
|
||||||
|
"management_ip": management_ip,
|
||||||
|
})
|
||||||
|
return neighbors
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cdp_neighbors(output):
|
||||||
|
"""Parse detailed AOS-CX CDP records into the common neighbor shape."""
|
||||||
|
clean = _clean_output(output)
|
||||||
|
starts = list(re.finditer(r"(?im)^\s*Local Port\s*:\s*\S+", clean))
|
||||||
|
neighbors = []
|
||||||
|
for index, start in enumerate(starts):
|
||||||
|
end = starts[index + 1].start() if index + 1 < len(starts) else len(clean)
|
||||||
|
block = clean[start.start():end]
|
||||||
|
local_port = _field(block, (r"^\s*Local Port\s*:\s*(\S+)",))
|
||||||
|
system_name = _field(block, (
|
||||||
|
r"^\s*Device ID\s*:\s*(.+)$",
|
||||||
|
r"^\s*System Name\s*:\s*(.+)$",
|
||||||
|
))
|
||||||
|
remote_port = _field(block, (
|
||||||
|
r"^\s*Neighbor Port-ID\s*:\s*(.+)$",
|
||||||
|
r"^\s*Port ID\s*:\s*(.+)$",
|
||||||
|
))
|
||||||
|
management_ip = _field(block, (
|
||||||
|
r"^\s*Address\s*:\s*([0-9a-fA-F:.]+)",
|
||||||
|
r"^\s*Management Address\s*:\s*([0-9a-fA-F:.]+)",
|
||||||
|
))
|
||||||
|
if local_port and (system_name or remote_port or management_ip):
|
||||||
|
neighbors.append({
|
||||||
|
"local_port": local_port,
|
||||||
|
"system_name": system_name,
|
||||||
|
"remote_port": remote_port,
|
||||||
|
"management_ip": management_ip,
|
||||||
|
})
|
||||||
|
return neighbors
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_neighbor_table(output):
|
||||||
|
neighbors = []
|
||||||
|
for raw_line in output.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or set(line) <= {"-", "=", " "}:
|
||||||
|
continue
|
||||||
|
tokens = re.split(r"\s+", line.replace("|", " | "))
|
||||||
|
if not tokens or not _interface_name(tokens[0]):
|
||||||
|
continue
|
||||||
|
if "|" in tokens:
|
||||||
|
separator = tokens.index("|")
|
||||||
|
remaining = tokens[separator + 1:]
|
||||||
|
if len(remaining) < 3:
|
||||||
|
continue
|
||||||
|
remote_port = remaining[1]
|
||||||
|
system_name = remaining[-1]
|
||||||
|
else:
|
||||||
|
if len(tokens) < 5:
|
||||||
|
continue
|
||||||
|
remote_port = tokens[2]
|
||||||
|
system_name = tokens[-1]
|
||||||
|
neighbors.append({
|
||||||
|
"local_port": tokens[0],
|
||||||
|
"system_name": system_name,
|
||||||
|
"remote_port": remote_port,
|
||||||
|
"management_ip": "",
|
||||||
|
})
|
||||||
|
return neighbors
|
||||||
|
|
||||||
|
|
||||||
|
def discover_switch(config):
|
||||||
|
outputs = run_switch_commands(config)
|
||||||
|
interfaces = parse_interfaces(outputs["interfaces"], config["platform"])
|
||||||
|
interface_ips = parse_interface_ips(
|
||||||
|
outputs.get("ipv4_interfaces", ""),
|
||||||
|
outputs.get("ipv6_interfaces", ""),
|
||||||
|
config["platform"],
|
||||||
|
)
|
||||||
|
lldp_neighbors = parse_neighbors(outputs["neighbors"])
|
||||||
|
cdp_neighbors = parse_cdp_neighbors(outputs.get("cdp_neighbors", ""))
|
||||||
|
# LLDP normally contains richer data. CDP fills ports for which CX did not
|
||||||
|
# return an LLDP record, without duplicating a physical local interface.
|
||||||
|
neighbors = list(lldp_neighbors)
|
||||||
|
occupied_ports = {_interface_key(item["local_port"]) for item in neighbors}
|
||||||
|
for neighbor in cdp_neighbors:
|
||||||
|
key = _interface_key(neighbor["local_port"])
|
||||||
|
if key not in occupied_ports:
|
||||||
|
neighbors.append(neighbor)
|
||||||
|
occupied_ports.add(key)
|
||||||
|
interface_map = {_interface_key(interface["name"]): interface for interface in interfaces}
|
||||||
|
for item in interface_ips:
|
||||||
|
key = _interface_key(item["interface"])
|
||||||
|
interface = interface_map.get(key)
|
||||||
|
if not interface:
|
||||||
|
interface = {
|
||||||
|
"name": item["interface"],
|
||||||
|
"enabled": True,
|
||||||
|
"connected": True,
|
||||||
|
"speed_mbps": None,
|
||||||
|
"mac_address": None,
|
||||||
|
"description": "Logisches Layer-3-Interface",
|
||||||
|
"neighbor": None,
|
||||||
|
"ip_addresses": [],
|
||||||
|
"virtual": True,
|
||||||
|
}
|
||||||
|
interfaces.append(interface)
|
||||||
|
interface_map[key] = interface
|
||||||
|
interface["ip_addresses"].append(item["address"])
|
||||||
|
for neighbor in neighbors:
|
||||||
|
local = interface_map.get(_interface_key(neighbor["local_port"]))
|
||||||
|
if local:
|
||||||
|
local["neighbor"] = neighbor
|
||||||
|
return {
|
||||||
|
"interfaces": interfaces,
|
||||||
|
"ip_addresses": interface_ips,
|
||||||
|
"neighbors": neighbors,
|
||||||
|
"lldp_neighbors": lldp_neighbors,
|
||||||
|
"cdp_neighbors": cdp_neighbors,
|
||||||
|
"raw": outputs,
|
||||||
|
"lldp_enabled_automatically": outputs.get("lldp_enabled_automatically", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _netbox_interface_type(speed_mbps, virtual=False):
|
||||||
|
if virtual:
|
||||||
|
return "virtual"
|
||||||
|
if not speed_mbps:
|
||||||
|
return "other"
|
||||||
|
if speed_mbps >= 100_000:
|
||||||
|
return "100gbase-x-qsfp28"
|
||||||
|
if speed_mbps >= 40_000:
|
||||||
|
return "40gbase-x-qsfpp"
|
||||||
|
if speed_mbps >= 25_000:
|
||||||
|
return "25gbase-x-sfp28"
|
||||||
|
if speed_mbps >= 10_000:
|
||||||
|
return "10gbase-x-sfpp"
|
||||||
|
if speed_mbps >= 1000:
|
||||||
|
return "1000base-t"
|
||||||
|
return "100base-tx"
|
||||||
|
|
||||||
|
|
||||||
|
def sync_snapshot_to_netbox(nb, config, snapshot, create_cables=True):
|
||||||
|
device_id = int(config["device_id"])
|
||||||
|
interface_records = {}
|
||||||
|
created = updated = cables = ip_addresses = 0
|
||||||
|
errors = []
|
||||||
|
device = nb.dcim.devices.get(id=device_id)
|
||||||
|
tenant_id = getattr(getattr(device, "tenant", None), "id", None) if device else None
|
||||||
|
for interface in snapshot["interfaces"]:
|
||||||
|
payload = {
|
||||||
|
"device": device_id,
|
||||||
|
"name": interface["name"],
|
||||||
|
"type": _netbox_interface_type(interface.get("speed_mbps"), interface.get("virtual", False)),
|
||||||
|
"enabled": interface.get("enabled", True),
|
||||||
|
}
|
||||||
|
if interface.get("description"):
|
||||||
|
payload["description"] = interface["description"][:200]
|
||||||
|
if interface.get("mac_address"):
|
||||||
|
payload["mac_address"] = interface["mac_address"]
|
||||||
|
try:
|
||||||
|
record = nb.dcim.interfaces.get(device_id=device_id, name=interface["name"])
|
||||||
|
if record:
|
||||||
|
record.update(payload)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
record = nb.dcim.interfaces.create(payload)
|
||||||
|
created += 1
|
||||||
|
interface_records[interface["name"].casefold()] = record
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(f"Interface {interface['name']}: {error}")
|
||||||
|
|
||||||
|
for interface in snapshot["interfaces"]:
|
||||||
|
record = interface_records.get(interface["name"].casefold())
|
||||||
|
if not record:
|
||||||
|
continue
|
||||||
|
for address in interface.get("ip_addresses", []):
|
||||||
|
try:
|
||||||
|
host = str(ipaddress.ip_interface(address).ip)
|
||||||
|
candidates = [
|
||||||
|
item for item in nb.ipam.ip_addresses.filter(address=host)
|
||||||
|
if getattr(getattr(item, "vrf", None), "id", None) is None
|
||||||
|
]
|
||||||
|
if len(candidates) > 1:
|
||||||
|
raise ValueError("mehrere globale IP-Adressen mit diesem Host gefunden")
|
||||||
|
payload = {
|
||||||
|
"address": address,
|
||||||
|
"status": "active",
|
||||||
|
"assigned_object_type": "dcim.interface",
|
||||||
|
"assigned_object_id": record.id,
|
||||||
|
}
|
||||||
|
if tenant_id:
|
||||||
|
payload["tenant"] = tenant_id
|
||||||
|
if candidates:
|
||||||
|
candidates[0].update(payload)
|
||||||
|
else:
|
||||||
|
nb.ipam.ip_addresses.create(payload)
|
||||||
|
ip_addresses += 1
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(f"IP-Adresse {address} auf {interface['name']}: {error}")
|
||||||
|
|
||||||
|
if create_cables:
|
||||||
|
for neighbor in snapshot["neighbors"]:
|
||||||
|
try:
|
||||||
|
local = interface_records.get(neighbor["local_port"].casefold())
|
||||||
|
if not local or getattr(local, "cable", None):
|
||||||
|
continue
|
||||||
|
remote_device = nb.dcim.devices.get(name=neighbor["system_name"])
|
||||||
|
if not remote_device:
|
||||||
|
continue
|
||||||
|
remote = nb.dcim.interfaces.get(device_id=remote_device.id, name=neighbor["remote_port"])
|
||||||
|
if not remote or getattr(remote, "cable", None):
|
||||||
|
continue
|
||||||
|
nb.dcim.cables.create({
|
||||||
|
"a_terminations": [{"object_type": "dcim.interface", "object_id": local.id}],
|
||||||
|
"b_terminations": [{"object_type": "dcim.interface", "object_id": remote.id}],
|
||||||
|
"status": "connected",
|
||||||
|
})
|
||||||
|
cables += 1
|
||||||
|
except Exception as error:
|
||||||
|
errors.append(
|
||||||
|
f"Nachbar {neighbor.get('local_port')} → {neighbor.get('system_name')} "
|
||||||
|
f"{neighbor.get('remote_port')}: {error}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"created": created,
|
||||||
|
"updated": updated,
|
||||||
|
"ip_addresses": ip_addresses,
|
||||||
|
"cables": cables,
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user