From 4c69be99e06070e6c39b101d99f20f6c32444002 Mon Sep 17 00:00:00 2001 From: Dmitrii Gudov Date: Wed, 25 Mar 2026 15:25:02 +0300 Subject: [PATCH] add bridge and vlan support --- examples/2hostsswitch/table.csv | 4 +- examples/bridge/bridge.yaml | 35 ++++++++++++ examples/bridge/table.csv | 6 ++ examples/vlan/vlan.yaml | 88 ++++++++++++++++++++++++++++++ src/netdiag/domain/models.py | 62 +++++++++++++++------ src/netdiag/output/d2.py | 3 +- src/netdiag/output/file_convert.py | 59 ++++++++++++++++---- src/netdiag/parse/convert_raw.py | 40 ++++++-------- 8 files changed, 244 insertions(+), 53 deletions(-) create mode 100644 examples/bridge/bridge.yaml create mode 100644 examples/bridge/table.csv create mode 100644 examples/vlan/vlan.yaml diff --git a/examples/2hostsswitch/table.csv b/examples/2hostsswitch/table.csv index 4c2ba61..82122b3 100644 --- a/examples/2hostsswitch/table.csv +++ b/examples/2hostsswitch/table.csv @@ -7,4 +7,6 @@ com,Switch,Adapter1,eth1,vlan7,A,7,,,, com,Switch,Adapter2,eth2,vlan9,B,9,,,, com,Switch,,vlan7,bridge,,,,,, com,Switch,,vlan9,bridge,,,,,, -com,Switch,,bridge,,,,,,, \ No newline at end of file +com,Switch,,bridge,,,,,,, + +## REMAKE \ No newline at end of file diff --git a/examples/bridge/bridge.yaml b/examples/bridge/bridge.yaml new file mode 100644 index 0000000..e5b62c3 --- /dev/null +++ b/examples/bridge/bridge.yaml @@ -0,0 +1,35 @@ +meta: + id: "lab-bridge" + name: "L2 Bridge" + description: "Two hosts in separate networks joined by a Linux bridge at L2." + +networks: + - name: net1 + - name: net2 + +nodes: + - name: srv + role: host + interfaces: + eth1: + network: net1 + ip: "10.0.0.1/24" + + - name: client + role: host + interfaces: + eth1: + network: net2 + ip: "10.0.0.2/24" + + - name: bridge + role: switch + interfaces: + eth1: + network: net1 + eth2: + network: net2 + bridges: + - name: br0 + stp: false + members: [eth1, eth2] diff --git a/examples/bridge/table.csv b/examples/bridge/table.csv new file mode 100644 index 0000000..20bb2b7 --- /dev/null +++ b/examples/bridge/table.csv @@ -0,0 +1,6 @@ +Name,Role,Adapter,Interface,Interface Type,Slave Interfaces,Network,VLAN,Network IP,Mask,Device IP,Default Gateway +srv,Host,Adapter1,eth1,,,net1,,10.0.0.0,/24,10.0.0.1, +client,Host,Adapter1,eth1,,,net2,,10.0.0.0,/24,10.0.0.2, +bridge,Switch,Adapter1,eth1,,,net1,,,,, +bridge,Switch,Adapter2,eth2,,,net2,,,,, +bridge,Switch,,br0,bridge,"eth1,eth2",,,,,, diff --git a/examples/vlan/vlan.yaml b/examples/vlan/vlan.yaml new file mode 100644 index 0000000..3381c27 --- /dev/null +++ b/examples/vlan/vlan.yaml @@ -0,0 +1,88 @@ +meta: + id: "lab-vlan" + name: "VLAN over shared trunk" + description: > + Two VLANs (5 and 7) isolated at L2, carried over a shared trunk link + between two bridge nodes (bright and bleft). + +networks: + - name: netA + - name: netB + - name: netC # trunk between bright and bleft + - name: netD + - name: netE + +nodes: + - name: hostA + role: host + interfaces: + eth1: + network: netA + ip: "10.0.0.1/24" + + - name: hostB + role: host + interfaces: + eth1: + network: netB + ip: "10.0.0.2/24" + + - name: hostD + role: host + interfaces: + eth1: + network: netD + ip: "10.0.0.3/24" + + - name: hostE + role: host + interfaces: + eth1: + network: netE + ip: "10.0.0.4/24" + + # Right bridge: connects hostA (VLAN 5) and hostB (VLAN 7) via trunk to bleft + - name: bright + role: switch + interfaces: + eth1: + network: netA # untagged port for VLAN 5 + eth2: + network: netB # untagged port for VLAN 7 + eth3: + network: netC # 802.1Q trunk to bleft + vlans: + - id: 5 + parent: eth3 + name: vlan5 # carries VLAN 5 over trunk + - id: 7 + parent: eth3 + name: vlan7 # carries VLAN 7 over trunk + bridges: + - name: br5 + members: [eth1, vlan5] # VLAN 5 domain + - name: br7 + members: [eth2, vlan7] # VLAN 7 domain + + # Left bridge: connects hostD (VLAN 5) and hostE (VLAN 7) via trunk to bright + - name: bleft + role: switch + interfaces: + eth1: + network: netD # untagged port for VLAN 5 + eth2: + network: netE # untagged port for VLAN 7 + eth3: + network: netC # 802.1Q trunk to bright + vlans: + - id: 5 + parent: eth3 + name: vlan5 + - id: 7 + parent: eth3 + name: vlan7 + bridges: + - name: br5 + members: [eth1, vlan5] + - name: br7 + members: [eth2, vlan7] diff --git a/src/netdiag/domain/models.py b/src/netdiag/domain/models.py index 2a1855b..0691084 100644 --- a/src/netdiag/domain/models.py +++ b/src/netdiag/domain/models.py @@ -5,10 +5,13 @@ from typing import Any, Dict, List, Optional class Interface: name: str + itype: Optional[str] adapter: Optional[str] - master_interface: Optional[str] + slave_interfaces: Optional[List[str]] + parent_interface: Optional[str] ip_address: Optional[str] network: Optional[str] + subnet_mask: Optional[str] default_gateway: Optional[str] # --- device: "Device" # set by Device.add_interface() when the interface is added to a device @@ -16,38 +19,70 @@ class Interface: def __init__( self, name: str, + itype: Optional[str] = None, adapter: Optional[str] = None, - master_interface: Optional[str] = None, + slave_interfaces: Optional[List[str]] = None, + parent_interface: Optional[str] = None, ip_address: Optional[str] = None, network: Optional[str] = None, + subnet_mask: Optional[str] = None, default_gateway: Optional[str] = None, ): if not isinstance(name, str): raise ValueError("Interface 'name' must be a string") + if itype is not None and not isinstance(itype, str): + raise ValueError("Interface 'itype' must be a string or None") if ip_address is not None and not isinstance(ip_address, str): raise ValueError("Interface 'ip_address' must be a string or None") if network is not None and not isinstance(network, str): raise ValueError("Interface 'network' must be a string or None") + if subnet_mask is not None and not isinstance(subnet_mask, str): + raise ValueError("Interface 'subnet_mask' must be a string or None") if default_gateway is not None and not isinstance(default_gateway, str): raise ValueError("Interface 'default_gateway' must be a string or None") if adapter is not None and not isinstance(adapter, str): raise ValueError("Interface 'adapter' must be a string or None") - if master_interface is not None and not isinstance(master_interface, str): - raise ValueError("Interface 'master_interface' must be a string or None") + if slave_interfaces is not None and not isinstance(slave_interfaces, list): + raise ValueError( + "Interface 'slave_interfaces' must be a list of strings or None" + ) + if parent_interface is not None and not isinstance(parent_interface, str): + raise ValueError("Interface 'parent_interface' must be a string or None") + + itype = None if itype == "" else itype + adapter = None if adapter == "" else adapter + + if itype is None and adapter is None: + itype = "virtual" + if adapter is not None: + if itype is not None and itype != "physical": + raise ValueError( + "Interface with an adapter must have 'itype' set to 'physical'" + ) + itype = "physical" + + if itype not in (None, "physical", "virtual", "bridge", "vlan"): + raise ValueError( + "Interface 'itype' must be one of 'physical', 'virtual', 'bridge', 'vlan', or None" + ) + + if itype == "bridge" and not slave_interfaces: + raise ValueError("Bridge interfaces must have 'slave_interfaces' defined") + if itype == "vlan" and not parent_interface: + raise ValueError("VLAN interfaces must have 'parent_interface' defined") self.name = name + self.itype = itype self.ip_address = ip_address self.network = network + self.subnet_mask = subnet_mask self.default_gateway = default_gateway self.adapter = adapter - self.master_interface = master_interface + self.slave_interfaces = slave_interfaces + self.parent_interface = parent_interface def __repr__(self) -> str: - return f"Interface(name={self.name}, ip_address={self.ip_address}, network={self.network}, default_gateway={self.default_gateway})" - - -class VirtualInterface(Interface): - pass + return f"Interface(name={self.name}, itype={self.itype}, ip_address={self.ip_address}, network={self.network}, default_gateway={self.default_gateway}, subnet_mask={self.subnet_mask}, adapter={self.adapter}, slave_interfaces={self.slave_interfaces})" # =========================== @@ -105,14 +140,12 @@ class Network: interfaces: List[Interface] vlan: Optional[str] # need a proper parse network_ip: Optional[str] - subnet_mask: Optional[str] def __init__( self, name: str, vlan: Optional[str] = None, network_ip: Optional[str] = None, - subnet_mask: Optional[str] = None, ): if not isinstance(name, str): raise ValueError("Network 'name' must be a string") @@ -120,14 +153,11 @@ class Network: raise ValueError("Network 'vlan' must be a string or None") if network_ip is not None and not isinstance(network_ip, str): raise ValueError("Network 'network_ip' must be a string or None") - if subnet_mask is not None and not isinstance(subnet_mask, str): - raise ValueError("Network 'subnet_mask' must be a string or None") self.name = name self.interfaces = [] self.vlan = vlan self.network_ip = network_ip - self.subnet_mask = subnet_mask def add_interface(self, interface: Interface): if not isinstance(interface, Interface): @@ -152,7 +182,7 @@ class Network: ) def __repr__(self) -> str: - return f"Network(name={self.name}, interfaces={self.interfaces}), vlan={self.vlan}, network_ip={self.network_ip}, subnet_mask={self.subnet_mask})" + return f"Network(name={self.name}, interfaces={self.interfaces}), vlan={self.vlan}, network_ip={self.network_ip})" class Topology: diff --git a/src/netdiag/output/d2.py b/src/netdiag/output/d2.py index 64a1602..757722c 100644 --- a/src/netdiag/output/d2.py +++ b/src/netdiag/output/d2.py @@ -2,7 +2,6 @@ import shutil import subprocess from ..domain.models import Topology -from ..domain.models import VirtualInterface from pathlib import Path from py_d2 import D2Diagram, D2Shape, D2Connection from py_d2.shape import Shape @@ -53,7 +52,7 @@ def generate_d2_diagram(topology: Topology, output_path: Path) -> None: for device in topology.devices.values(): for interface in device.interfaces.values(): - if isinstance(interface, VirtualInterface): + if interface.itype != "physical": continue # Skip virtual interfaces for now shape = D2Shape( diff --git a/src/netdiag/output/file_convert.py b/src/netdiag/output/file_convert.py index 8964478..63c4971 100644 --- a/src/netdiag/output/file_convert.py +++ b/src/netdiag/output/file_convert.py @@ -21,12 +21,6 @@ def make_yaml(topology: Topology, output_path: Path) -> None: if len(interfaces_with_device) >= 2: data["networks"].append( - # { - # network.name: [ - # f"{iface.device.name}.{iface.name}" - # for iface in interfaces_with_device - # ] - # } { "name": network.name, } @@ -36,24 +30,67 @@ def make_yaml(topology: Topology, output_path: Path) -> None: for _, device in topology.devices.items(): interfaces = dict() + interfaces["bridges"] = [] + interfaces["vlans"] = [] + for interface_name, interface in device.interfaces.items(): ip = interface.ip_address if interface.ip_address else None - mask = ( - topology.networks[interface.network].subnet_mask - if interface.network - else None - ) + mask = interface.subnet_mask if interface.subnet_mask else None + if mask is not None: mask = mask.split("/")[1] if "/" in mask else mask ip = f"{interface.ip_address}/{mask}" if interface.ip_address else None + index = None + # check regex Adapter[0-9]+ + if interface.adapter: + if ( + not interface.adapter.startswith("Adapter") + or not interface.adapter[7:].isdigit() + ): + raise ValueError( + f"Invalid adapter name '{interface.adapter}' for interface '{interface_name}' on device '{device.name}'. Adapter name must be in the format 'AdapterX' where X is a number." + ) + index = int(interface.adapter[7:]) + + if interface.itype == "bridge": + interfaces["bridges"].append( + { + "name": interface.name, + "members": interface.slave_interfaces, + "ip": ip, + "network": interface.network if interface.network else None, + "gateway": ( + interface.default_gateway + if interface.default_gateway + else None + ), + } + ) + continue + if interface.itype == "vlan": + interfaces["vlans"].append( + { + "name": interface.name, + "parent": interface.parent_interface, + "ip": ip, + "network": interface.network if interface.network else None, + "gateway": ( + interface.default_gateway + if interface.default_gateway + else None + ), + } + ) + continue interfaces[interface_name] = { "ip": ip, "network": interface.network if interface.network else None, "gateway": ( interface.default_gateway if interface.default_gateway else None ), + "index": index, } data["nodes"].append( diff --git a/src/netdiag/parse/convert_raw.py b/src/netdiag/parse/convert_raw.py index 215f8d9..94f1cb3 100644 --- a/src/netdiag/parse/convert_raw.py +++ b/src/netdiag/parse/convert_raw.py @@ -8,7 +8,6 @@ from ..domain.models import ( Router, Switch, Topology, - VirtualInterface, ) from . import RawDevices @@ -16,7 +15,10 @@ name_matching = { "DEVICE_TYPE": "Role", "DEVICE_NAME": "Name", "ADAPTER": "Adapter", + "INTERFACE TYPE": "Interface Type", "MASTER": "Master Interface", + "SLAVES": "Slave Interfaces", + "PARENT": "Parent Interface", "INTERFACE_NAME": "Interface", "NETWORK_NAME": "Network", "VLAN": "VLAN", @@ -79,24 +81,20 @@ def add_interfaces(devices: list[Device], raw_devices: list[RawDevices]) -> None f"Device with name '{device_name}' not found for interface parsing" ) - if not raw_device.fields.get(name_matching["ADAPTER"], "").strip(): - interface = VirtualInterface( - name=_get_from_field(raw_device.fields, "INTERFACE_NAME"), - master_interface=_get_from_field(raw_device.fields, "MASTER"), - ip_address=_get_from_field(raw_device.fields, "IP_ADDRESS"), - network=_get_from_field(raw_device.fields, "NETWORK_NAME"), - default_gateway=_get_from_field(raw_device.fields, "DEFAULT_GATEWAY"), - ) - - else: - interface = Interface( - name=_get_from_field(raw_device.fields, "INTERFACE_NAME"), - adapter=_get_from_field(raw_device.fields, "ADAPTER"), - master_interface=_get_from_field(raw_device.fields, "MASTER"), - ip_address=_get_from_field(raw_device.fields, "IP_ADDRESS"), - network=_get_from_field(raw_device.fields, "NETWORK_NAME"), - default_gateway=_get_from_field(raw_device.fields, "DEFAULT_GATEWAY"), - ) + interface = Interface( + name=_get_from_field(raw_device.fields, "INTERFACE_NAME"), + itype=_get_from_field(raw_device.fields, "INTERFACE TYPE"), + adapter=_get_from_field(raw_device.fields, "ADAPTER"), + slave_interfaces=( + _get_from_field(raw_device.fields, "SLAVES").split(",") + if _get_from_field(raw_device.fields, "SLAVES") + else None + ), + ip_address=_get_from_field(raw_device.fields, "IP_ADDRESS"), + network=_get_from_field(raw_device.fields, "NETWORK_NAME"), + default_gateway=_get_from_field(raw_device.fields, "DEFAULT_GATEWAY"), + subnet_mask=raw_device.fields.get(name_matching["SUBNET_MASK"], "").strip(), + ) device.add_interface(interface) return @@ -112,7 +110,6 @@ def parse_networks(raw_devices: list[RawDevices]) -> list[Network]: vlan = (raw_device.fields.get(name_matching["VLAN"], "").strip(),) network_ip = (raw_device.fields.get(name_matching["NETWORK_IP"], "").strip(),) - subnet_mask = (raw_device.fields.get(name_matching["SUBNET_MASK"], "").strip(),) if network_name in [n.name for n in networks]: # update if already exists network = next(n for n in networks if n.name == network_name) @@ -121,15 +118,12 @@ def parse_networks(raw_devices: list[RawDevices]) -> list[Network]: network.vlan = vlan if not network.network_ip and network_ip: network.network_ip = network_ip - if not network.subnet_mask and subnet_mask: - network.subnet_mask = subnet_mask continue network = Network( name=network_name, vlan=raw_device.fields.get(name_matching["VLAN"], "").strip(), network_ip=raw_device.fields.get(name_matching["NETWORK_IP"], "").strip(), - subnet_mask=raw_device.fields.get(name_matching["SUBNET_MASK"], "").strip(), ) networks.append(network)