# This file is part of the sos project: https://github.com/sosreport/sos # # This copyrighted material is made available to anyone wishing to use, # modify, copy, or redistribute it subject to the terms and conditions of # version 2 of the GNU General Public License. # # See the LICENSE file in the source distribution for further information. # # Copyright (C) 2026 Harshith Hari import os import re from sos.report.plugins import Plugin, IndependentPlugin from sos.utilities import is_executable class NetDiagram(Plugin, IndependentPlugin): """ This plugin generates a visual network topology diagram (PNG) using Graphviz and generates ASCII. It maps relationships between physical NICs, Bonds, Bridges, Team devices, Tunnels, and Containers to help support engineers visualize complex network hierarchies. """ plugin_name = 'net_diagram' profiles = ('network',) packages = ('graphviz', 'teamd', 'perl-Graph-Easy') def collect(self): # Header with tighter spacing for optimal layout dot_lines = [ "digraph network {", " rankdir=LR; overlap=false; splines=true;", " nodesep=0.2; ranksep=0.5;", " node [shape=box, style=filled, fontname=\"Arial\", " "fontsize=10];", " edge [penwidth=1.5, color=\"#444444\"];" ] # 1. Map ALL IP Addresses ips = {} res_ips = self.exec_cmd("ip -br addr") if res_ips['status'] == 0: for line in res_ips['output'].splitlines(): parts = line.split(None, 2) if len(parts) >= 3: ifname = parts[0].split('@')[0] ips[ifname] = "\\n".join(parts[2:].pop().split()) # 2. Identify ALL Routes across all tables res_routes = self.exec_cmd("ip route show table all") if res_routes['status'] == 0: seen_nodes = set() for line in res_routes['output'].splitlines(): parts = line.split() if 'dev' not in parts: continue dest = parts[0] if dest in ('local', 'broadcast', 'anycast', 'multicast'): continue if dest.startswith(('ff00', '224.', '239.', '127.')): continue iface = parts[parts.index('dev') + 1] tbl = parts[parts.index('table') + 1] if 'table' in parts \ else 'main' if 'via' in parts: gw_ip = parts[parts.index('via') + 1] gw_id = f"gw_{gw_ip.replace('.', '_')}" if gw_id not in seen_nodes: dot_lines.append( f' "{gw_id}" [label="Gateway\\n{gw_ip}", ' 'shape=doubleoctagon, fillcolor="#eeeeee"];' ) seen_nodes.add(gw_id) r_lbl = f"to {dest}" if tbl != 'main': r_lbl += f"\\n(table: {tbl})" r_col = "#e67e22" if tbl != 'main' else \ ("#27ae60" if dest == "default" else "#2980b9") dot_lines.append( f' "{iface}" -> "{gw_id}" [label="{r_lbl}", ' f'color="{r_col}", style=dashed];' ) elif dest not in ('default', 'all'): net_id = f"net_{dest.replace('.', '_').replace('/', '_')}" if net_id not in seen_nodes: dot_lines.append( f' "{net_id}" [label="Network\\n{dest}", ' 'shape=ellipse, style=dotted, ' 'fillcolor="#f9f9f9"];' ) seen_nodes.add(net_id) dot_lines.append(f' "{iface}" -> "{net_id}" ' f'[color="#7f8c8d", style=dotted];') # 3. Build Topology from Sysfs net_path = self.path_join("/sys", "class", "net") if self.path_exists(net_path): for iface in self.listdir(net_path): if iface in ('lo', 'bonding_masters'): continue path = self.path_join(net_path, iface) if iface not in ips and not \ self.path_exists(self.path_join(path, "master")): if not any(iface in line for line in dot_lines): continue fill = "white" if any(x in iface for x in ("podman", "docker", "veth")): fill = "#C3E6CB" elif self.path_exists(self.path_join(path, "bridge")): fill = "#ADD8E6" elif self.path_exists(self.path_join(path, "bonding")): fill = "#FFFFE0" elif self.path_exists(self.path_join(path, "device")): fill = "#90EE90" elif self.path_exists(self.path_join(path, "tun_flags")): fill = "#E1BEE7" label = f"<{iface}>" if iface in ips: label += f"\\n{ips[iface]}" dot_lines.append(f' "{iface}" [label="{label}", ' f'fillcolor="{fill}"];') m_link = self.path_join(path, "master") if os.path.islink(m_link): m_name = os.path.basename(os.readlink(m_link)) dot_lines.append(f' "{iface}" -> "{m_name}" ' f'[label="slave-of"];') dot_lines.append("}") with self.collection_file("network_topology.dot") as dotf: dotf.write("\n".join(dot_lines)) dot_path = dotf.name png_path = dot_path.replace(".dot", ".png") self.exec_cmd(f"dot -Tpng {dot_path} -o {png_path}") # ASCII generation via native tools - No bash pipes or redirections if is_executable("graph-easy"): txt_path = dot_path.replace(".dot", ".txt") cleaned_dot_content = re.sub( r'shape=[a-z]*', 'shape=box', "\n".join(dot_lines) ) stage_dot_path = dot_path.replace(".dot", "_ascii_stage.dot") with open(stage_dot_path, "w", encoding="utf-8") as stage_f: stage_f.write(cleaned_dot_content) ascii_cmd = ( f"graph-easy --ascii --output={txt_path} " f"{stage_dot_path}" ) self.exec_cmd(ascii_cmd) try: os.remove(stage_dot_path) except OSError: # Silently ignore if the staging file was already removed pass else: self._log_info("graph-easy tool not found; skipping ASCII output.")