diff --git a/README.md b/README.md index 79b032b..32ef8c1 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,11 @@ Windows-Desktopanwendung zum Synchronisieren virtueller Maschinen sowie HPE-/Aru - 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-Verbindungen als NetBox-Kabel +- Synchronisierung von Switch-Schnittstellen und optionalen LLDP-/CDP-Verbindungen als NetBox-Kabel - Auslesen und Synchronisieren vorhandener IPv4-/IPv6-Adressen auf Switch-Schnittstellen -- Spezieller LLDP-Detailparser mit Tabellen-Fallback für Aruba-6100-Switches +- 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 diff --git a/app.py b/app.py index 10a55ef..e3aeb8f 100644 --- a/app.py +++ b/app.py @@ -1026,8 +1026,7 @@ class NetBoxVMImporter: main = ttk.Frame(parent, padding=18) main.pack(fill="both", expand=True) main.columnconfigure(0, weight=1) - main.rowconfigure(3, weight=2) - main.rowconfigure(5, weight=2) + main.rowconfigure(3, weight=1) header = ttk.Frame(main) header.grid(row=0, column=0, sticky="ew", pady=(0, 12)) @@ -1093,14 +1092,15 @@ class NetBoxVMImporter: style="Primary.TButton", ).pack(side="right") - result_card = ttk.LabelFrame(main, text="Schnittstellen & LLDP-Nachbarn", style="Card.TLabelframe") - result_card.grid(row=3, column=0, sticky="nsew", pady=(0, 10)) + 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-Nachbar", "remote_port": "Nachbar-Port", + "neighbor": "LLDP/CDP-Nachbar", "remote_port": "Nachbar-Port", } result_widths = { "switch": 150, "interface": 140, "state": 70, "speed": 100, @@ -1114,18 +1114,24 @@ class NetBoxVMImporter: 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-Verbindungen als Kabel anlegen", variable=self.create_neighbor_cables).pack(side="left") + 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") - - log_card = ttk.LabelFrame(main, text="Switch-Protokoll", style="Card.TLabelframe") - log_card.grid(row=5, column=0, sticky="nsew") - self.switch_log_text = tk.Text( - log_card, height=8, borderwidth=0, bg="#f7f9fb", foreground="#243b53", font=("Consolas", 9), state="disabled" - ) - self.switch_log_text.pack(fill="both", expand=True) + 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 def add_field(parent, row, label, secret=False, variable=None): @@ -1305,6 +1311,61 @@ class NetBoxVMImporter: 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 @@ -1404,8 +1465,13 @@ class NetBoxVMImporter: self.switch_log( f"{switch['name']}: {len(snapshot['interfaces'])} Schnittstellen, " f"{len(snapshot.get('ip_addresses', []))} IP-Adressen, " - f"{len(snapshot['neighbors'])} LLDP-Nachbarn" + 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}") diff --git a/dist/NetBox VM Import.exe b/dist/NetBox VM Import.exe index 7bd74dd..e5f882c 100644 Binary files a/dist/NetBox VM Import.exe and b/dist/NetBox VM Import.exe differ diff --git a/switch_sync.py b/switch_sync.py index 4e4d331..b710074 100644 --- a/switch_sync.py +++ b/switch_sync.py @@ -53,7 +53,7 @@ def _clean_output(value): return value -def _read_available(channel, quiet_seconds=0.35, timeout=12): +def _read_available(channel, quiet_seconds=0.8, timeout=20): chunks = [] started = time.monotonic() last_data = started @@ -61,7 +61,14 @@ def _read_available(channel, quiet_seconds=0.35, timeout=12): if channel.recv_ready(): chunks.append(channel.recv(65535).decode("utf-8", errors="replace")) last_data = time.monotonic() - elif time.monotonic() - last_data >= quiet_seconds: + # 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. + if re.search(r"(?m)^\s*[^\r\n]+[>#]\s*$", "".join(chunks)): + break + elif chunks and time.monotonic() - last_data >= quiet_seconds: + break + elif not chunks and time.monotonic() - started >= min(timeout, 5): break else: time.sleep(0.05) @@ -87,7 +94,7 @@ def run_switch_commands(config): banner_timeout=12, ) channel = client.invoke_shell(width=240, height=1000) - _read_available(channel, timeout=3) + _read_available(channel, quiet_seconds=0.5, timeout=5) pager_command, interface_command, neighbor_command = COMMANDS[platform] channel.send(pager_command + "\n") _read_available(channel) @@ -116,21 +123,36 @@ def run_switch_commands(config): time.sleep(2) channel.send(interface_command + "\n") - interfaces = _read_available(channel) + interfaces = _read_available(channel, quiet_seconds=1.2, timeout=30) ip_outputs = [] for command in IP_COMMANDS[platform]: channel.send(command + "\n") - ip_outputs.append(_read_available(channel)) + ip_outputs.append(_read_available(channel, quiet_seconds=1.0, timeout=25)) channel.send(neighbor_command + "\n") - neighbors = _read_available(channel) + neighbors = _read_available(channel, quiet_seconds=1.5, timeout=35) if platform == "aruba_cx" and re.search(r"(?i)invalid input|unknown command|unrecognized", neighbors): channel.send("show lldp neighbor-info\n") - neighbors = _read_available(channel) + neighbors = _read_available(channel, quiet_seconds=1.5, timeout=35) + cdp_neighbors = "" + if platform == "aruba_cx": + channel.send("show cdp neighbor-info\n") + cdp_summary = _read_available(channel, quiet_seconds=1.2, timeout=25) + 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: + channel.send(f"show cdp neighbor-info {port}\n") + details.append(_read_available(channel, quiet_seconds=1.0, timeout=20)) + 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: @@ -322,6 +344,37 @@ def parse_neighbors(output): 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(): @@ -360,7 +413,17 @@ def discover_switch(config): outputs.get("ipv6_interfaces", ""), config["platform"], ) - neighbors = parse_neighbors(outputs["neighbors"]) + 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"]) @@ -388,6 +451,8 @@ def discover_switch(config): "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), }