├── requirements.txt ├── LICENSE ├── .gitignore ├── mac_lookup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | genie==20.12.2 2 | genie.libs.clean==20.12.1 3 | genie.libs.conf==20.12 4 | genie.libs.filetransferutils==20.12 5 | genie.libs.health==20.12 6 | genie.libs.ops==20.12 7 | genie.libs.parser==20.12 8 | genie.libs.robot==20.12 9 | genie.libs.sdk==20.12 10 | genie.telemetry==20.12 11 | genie.trafficgen==20.12 12 | pyats==20.12 13 | pyats.aereport==20.12 14 | pyats.aetest==20.12 15 | pyats.async==20.12 16 | pyats.connections==20.12 17 | pyats.contrib==20.12 18 | pyats.datastructures==20.12 19 | pyats.easypy==20.12 20 | pyats.kleenex==20.12 21 | pyats.log==20.12.1 22 | pyats.reporter==20.12 23 | pyats.results==20.12 24 | pyats.robot==20.12 25 | pyats.tcl==20.12 26 | pyats.topology==20.12 27 | pyats.utils==20.12 28 | unicon==20.12 29 | unicon.plugins==20.12 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Cisco Systems, Inc. and/or its affiliates 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore testbed and output files for this project 2 | results.json 3 | testbed.yaml 4 | 5 | scratch* 6 | *.code-workspace 7 | yang.settings 8 | src_env 9 | *_env 10 | prod/ 11 | .vscode/ 12 | 13 | # NSO 14 | logs/ 15 | ncs-cdb/ 16 | packages/ 17 | scripts/ 18 | state/ 19 | test/ 20 | ncs.conf 21 | README.ncs 22 | cisco-asa* 23 | cisco-nx* 24 | cisco-ios* 25 | cisco-ucs* 26 | storedstate 27 | 28 | netsim/ 29 | README.netsim 30 | 31 | .DS_Store 32 | ~* 33 | 34 | secret_vars.tfvars 35 | 36 | 37 | # Byte-compiled / optimized / DLL files 38 | __pycache__/ 39 | *.py[cod] 40 | *$py.class 41 | 42 | # C extensions 43 | *.so 44 | 45 | # Distribution / packaging 46 | .Python 47 | build/ 48 | develop-eggs/ 49 | dist/ 50 | downloads/ 51 | eggs/ 52 | .eggs/ 53 | lib/ 54 | lib64/ 55 | parts/ 56 | sdist/ 57 | var/ 58 | wheels/ 59 | pip-wheel-metadata/ 60 | share/python-wheels/ 61 | *.egg-info/ 62 | .installed.cfg 63 | *.egg 64 | MANIFEST 65 | 66 | # PyInstaller 67 | # Usually these files are written by a python script from a template 68 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 69 | *.manifest 70 | *.spec 71 | 72 | # Installer logs 73 | pip-log.txt 74 | pip-delete-this-directory.txt 75 | 76 | # Unit test / coverage reports 77 | htmlcov/ 78 | .tox/ 79 | .nox/ 80 | .coverage 81 | .coverage.* 82 | .cache 83 | nosetests.xml 84 | coverage.xml 85 | *.cover 86 | *.py,cover 87 | .hypothesis/ 88 | .pytest_cache/ 89 | 90 | # Translations 91 | *.mo 92 | *.pot 93 | 94 | # Django stuff: 95 | *.log 96 | local_settings.py 97 | db.sqlite3 98 | db.sqlite3-journal 99 | 100 | # Flask stuff: 101 | instance/ 102 | .webassets-cache 103 | 104 | # Scrapy stuff: 105 | .scrapy 106 | 107 | # Sphinx documentation 108 | docs/_build/ 109 | 110 | # PyBuilder 111 | target/ 112 | 113 | # Jupyter Notebook 114 | .ipynb_checkpoints 115 | 116 | # IPython 117 | profile_default/ 118 | ipython_config.py 119 | 120 | # pyenv 121 | .python-version 122 | 123 | # pipenv 124 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 125 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 126 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 127 | # install all needed dependencies. 128 | #Pipfile.lock 129 | 130 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 131 | __pypackages__/ 132 | 133 | # Celery stuff 134 | celerybeat-schedule 135 | celerybeat.pid 136 | 137 | # SageMath parsed files 138 | *.sage.py 139 | 140 | # Environments 141 | .env 142 | .venv 143 | env/ 144 | venv/ 145 | ENV/ 146 | env.bak/ 147 | venv.bak/ 148 | 149 | # Spyder project settings 150 | .spyderproject 151 | .spyproject 152 | 153 | # Rope project settings 154 | .ropeproject 155 | 156 | # mkdocs documentation 157 | /site 158 | 159 | # mypy 160 | .mypy_cache/ 161 | .dmypy.json 162 | dmypy.json 163 | 164 | # Pyre type checker 165 | .pyre/ 166 | -------------------------------------------------------------------------------- /mac_lookup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | """ 3 | This is an example script that generates a report of which network interfaces a MAC addres is found. 4 | 5 | The list of MAC adddresses to lookup is determined by checking the ARP table on a provide list of Layer 3 Devices. 6 | """ 7 | 8 | from genie.conf import Genie 9 | from unicon.core.errors import TimeoutError, StateMachineError, ConnectionError 10 | from genie.metaparser.util.exceptions import SchemaEmptyParserError 11 | import json 12 | import sys 13 | 14 | 15 | def disconnect(testbed): 16 | """disconnect 17 | 18 | Helper function to disconnect from all devices in a testbed 19 | """ 20 | 21 | for device_name, device in testbed.devices.items(): 22 | print(f"Disconnecting from {device.name}") 23 | device.disconnect() 24 | 25 | 26 | def load_testbed(testbed_file): 27 | """load_testbed 28 | 29 | Attempt to create a Genie Testbed Object from provided testbed file and connect to all devices. 30 | """ 31 | 32 | try: 33 | testbed = Genie.init(testbed_file) 34 | try: 35 | testbed.connect( 36 | learn_hostname=True, 37 | log_stdout=False, 38 | init_exec_commands=[], 39 | init_config_commands=[], 40 | connection_timeout=20, 41 | ) 42 | except ConnectionError as e: 43 | print(e) 44 | # See what devices aren't connected 45 | for device in testbed.devices.values(): 46 | if not device.connected: 47 | print(f"{device} is NOT connected.") 48 | return testbed 49 | except Exception as e: 50 | print("Error: loading testbed file") 51 | print(e) 52 | sys.exit(1) 53 | 54 | 55 | def find_layer3_devices(testbed, layer3_devices_list): 56 | """find_layer3_devices 57 | 58 | Given a testbed object, and list of device names, return a list of testbed devices of the names 59 | """ 60 | layer3_devices = [] 61 | 62 | for name in layer3_devices_list: 63 | if name in testbed.devices: 64 | layer3_devices.append(testbed.devices[name]) 65 | else: 66 | print(f"Error: Device with name {name} not found in testbed.") 67 | 68 | return layer3_devices 69 | 70 | 71 | def discover_macs(layer3_devices): 72 | """discover_macs 73 | 74 | Given a list of Layer 3 devices, return their ARP tables and return a dictionary of macs. 75 | 76 | Example return format: 77 | { 78 | "0050.56bf.6f29": { 79 | "ip": "10.10.20.49", 80 | "interfaces": [] 81 | }, 82 | "5254.0006.91c9": { 83 | "ip": "10.10.20.172", 84 | "interfaces": [] 85 | }, 86 | } 87 | 88 | """ 89 | 90 | # Dictionary that will be returned 91 | macs = {} 92 | 93 | print("Looking up Layer 3 IP -> MAC Mappings.") 94 | for device in layer3_devices: 95 | print(f"Checking L3 device {device.name}") 96 | 97 | # What command to lookup arp info from devices 98 | arp_lookup_command = { 99 | "nxos": "show ip arp vrf all", 100 | "iosxr": "show arp detail", 101 | "iosxe": "show ip arp", 102 | "ios": "show ip arp", 103 | } 104 | 105 | try: 106 | arp_info = device.parse(arp_lookup_command[device.os]) 107 | except Exception as e: 108 | print(f"Problem looking up ARP table on device {device.name}") 109 | 110 | # Example output of data from command 111 | # { 112 | # "interfaces": { 113 | # "Ethernet1/3": { 114 | # "ipv4": { 115 | # "neighbors": { 116 | # "172.16.252.2": { 117 | # "ip": "172.16.252.2", 118 | # "link_layer_address": "5254.0016.18d2", 119 | # "physical_interface": "Ethernet1/3", 120 | # "origin": "dynamic", 121 | # "age": "00:10:51" 122 | # } 123 | # } 124 | # } 125 | # } 126 | # }, 127 | # "statistics": { 128 | # "entries_total": 8 129 | # } 130 | # } 131 | 132 | # From returned ARP data, review the details and extract the details for returned mac dictionary 133 | for interface, details in arp_info["interfaces"].items(): 134 | # print(f" Interface {interface}") 135 | 136 | # For each neighbor device on an interface, add new key to macs dictionary. 137 | # Value for each mac will be a dictionary with keys for IP address and empty list of interfaces 138 | # The interfaces list will be filled in by MAC Address Table lookups 139 | for neighbor in details["ipv4"]["neighbors"].values(): 140 | # print(f'{neighbor["ip"]}, {neighbor["link_layer_address"]}') 141 | if neighbor["link_layer_address"] != "INCOMPLETE": 142 | macs[neighbor["link_layer_address"]] = { 143 | "ip": neighbor["ip"], 144 | "interfaces": [], 145 | } 146 | 147 | return macs 148 | 149 | 150 | def lookup_interfaces(macs, testbed, skip_interfaces=[]): 151 | """lookup_interfaces 152 | 153 | Given a dictionary of macs and a testbed, update the list of interfaces for each MAC Address 154 | with interfaces in the testbed where the MAC address is connected. 155 | 156 | Optional parameter: 157 | skip_interfaces : A list of interface names to NOT include in interface lists for macs 158 | 159 | Example returned value: 160 | { 161 | "0050.56bf.6f29": { 162 | "ip": "10.10.20.49", 163 | "interfaces": [ 164 | { 165 | "device": "sw01-1", 166 | "interface": "Ethernet1/11", 167 | "mac_type": "dynamic", 168 | "vlan": "30" 169 | } 170 | ] 171 | }, 172 | "5254.0006.91c9": { 173 | "ip": "10.10.20.172", 174 | "interfaces": [ 175 | { 176 | "device": "sw01-1", 177 | "interface": "Ethernet1/12", 178 | "mac_type": "dynamic", 179 | "vlan": "30" 180 | } 181 | ] 182 | }, 183 | } 184 | 185 | """ 186 | 187 | # Common Interface names to ignore for recording into table 188 | ignored_interface_names = ["CPU", "Sup-eth1(R)", "vPC Peer-Link(R)"] 189 | 190 | # Combine common list with specified skip_interfaces 191 | ignored_interface_names += skip_interfaces 192 | 193 | for device in testbed.devices.values(): 194 | try: 195 | mac_address_table = device.parse("show mac address-table") 196 | except Exception as e: 197 | print( 198 | f"Unable to retrieve MACs from device {device.name}. Likely missing parser for 'show mac address-table' or trying to lookup on router and not switch" 199 | ) 200 | continue 201 | 202 | # Example data returned from show mac address-table command 203 | # { 204 | # "mac_table": { 205 | # "vlans": { 206 | # "999": { 207 | # "vlan": 999, 208 | # "mac_addresses": { 209 | # "5254.0000.c816": { 210 | # "mac_address": "5254.0000.c816", 211 | # "interfaces": { 212 | # "GigabitEthernet0/3": { 213 | # "interface": "GigabitEthernet0/3", 214 | # "entry_type": "dynamic" 215 | # } 216 | # } 217 | # } 218 | # } 219 | # } 220 | # } 221 | # }, 222 | # "total_mac_addresses": 3 223 | # } 224 | 225 | for vlan_id, vlan in mac_address_table["mac_table"]["vlans"].items(): 226 | for mac_address, mac_details in vlan["mac_addresses"].items(): 227 | if mac_address in macs.keys(): 228 | for interface in mac_details["interfaces"].values(): 229 | if interface["interface"] not in ignored_interface_names: 230 | if "mac_type" in interface.keys(): 231 | mac_type = interface["mac_type"] 232 | elif "entry_type" in interface.keys(): 233 | mac_type = interface["entry_type"] 234 | else: 235 | mac_type = "N/A" 236 | 237 | macs[mac_address]["interfaces"].append( 238 | { 239 | "device": device.name, 240 | "interface": interface["interface"], 241 | "mac_type": mac_type, 242 | "vlan": vlan_id, 243 | } 244 | ) 245 | else: 246 | print(f"No ARP for MAC Address {mac_address} found.") 247 | 248 | return macs 249 | 250 | 251 | if __name__ == "__main__": 252 | # for stand-alone execution 253 | import argparse 254 | from pyats import topology 255 | 256 | parser = argparse.ArgumentParser(description="Demonstration Script") 257 | parser.add_argument( 258 | "--testbed", 259 | dest="testbed", 260 | help="testbed YAML file", 261 | type=str, 262 | default=None, 263 | ) 264 | parser.add_argument( 265 | "--l3device", 266 | dest="layer3_devices", 267 | help="Layer 3 Devices whose ARP tables will be gathered.", 268 | type=str, 269 | nargs="+", 270 | ) 271 | parser.add_argument( 272 | "--skipinterface", 273 | dest="skip_interface", 274 | help="Interface names to skip learning MACs on. Most commonly used for known trunks.", 275 | type=str, 276 | nargs="*", 277 | default=[], 278 | ) 279 | parser.add_argument( 280 | "--outputfile", 281 | dest="output_file", 282 | help="File to save the collected data to in JSON format.", 283 | type=str, 284 | default="results.json", 285 | ) 286 | 287 | # do the parsing 288 | args = parser.parse_args() 289 | 290 | # Create object from provided file 291 | print(f"Attempting to load and connect to devices in testbed named {args.testbed}.") 292 | testbed = load_testbed(args.testbed) 293 | 294 | # Create list of tesbed devices to use as Layer 3 ARP sources 295 | layer3_devices = find_layer3_devices(testbed, args.layer3_devices) 296 | 297 | # Get dictionary of MAC Addresses from ARP tables in layer3 devices 298 | print( 299 | f"Building MAC Address list from ARP information on devices {', '.join(args.layer3_devices)}" 300 | ) 301 | macs = discover_macs(layer3_devices) 302 | 303 | # Update the macs dictionary by finding the Layer 2 Interfaces where the MAC addresses are located across the testbed 304 | print( 305 | f"Looking up interfaces where MAC addresses are found on the testbed. The following interfaces will be ignored: {', '.join(args.skip_interface) }" 306 | ) 307 | macs = lookup_interfaces(macs, testbed, skip_interfaces=args.skip_interface) 308 | 309 | print(f"Saving results to file '{args.output_file}'.") 310 | with open(args.output_file, "w") as f: 311 | f.write(json.dumps(macs, indent=2)) 312 | 313 | # Disconnect 314 | print("Disconnecting from all devices.") 315 | disconnect(testbed) 316 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Demonstration Script 2 | 3 | [![published](https://static.production.devnetcloud.com/codeexchange/assets/images/devnet-published.svg)](https://developer.cisco.com/codeexchange/github/repo/hpreston/demo_mac_to_interface_tool) 4 | 5 | This was a fun little project to tackle here at the end of 2020. It started with a question that I got via email. The gist of the question was: 6 | 7 | > We've a need to grab the IP table off our core, and associate it with MAC addresses and interfaces on edge switches. We could do it manually with Excel, but is there an easier way? 8 | 9 | As with a lot of things in tech, once I dove in there were all sorts of interesting things that I learned, and questions about the workflow came up. 10 | 11 | ## Approach and Assumptions 12 | Here’s the general approach and assumptions I took as I put together this example script. 13 | 14 | 1. I wanted to create a tool that could be used across different environments, and wasn’t strongly linked to a specific network or topology. 15 | 2. The ask started with the ARP table from network devices, and then generated info for what switch ports the MACs known by ARP were located. I followed this same approach in my script, so if a MAC address was found in a MAC table, but no corresponding ARP entry was found the MAC address is ignored. 16 | * In most cases this is probably a limited number of MACs, however in my network where I was testing, we have a large number of L2 only networks so it was an interesting side affect of my script. 17 | 3. I assumed there was a specific set of devices in the network that would be the Layer 3 function for where we’d check ARP tables. This list of Layer 3 devices would be an argument to the script. 18 | 4. Our interest is in access ports where MAC Addresses show up, not inter-switch trunks. This meant being able to ignore or rule out cases where MAC addresses show up on trunks automatically would be handy. I have found most network engineers use common switch interfaces (often port-channel names) as inter-switch links. The script takes as an input a list of interface names to skip/ignore when reporting interfaces where a MAC address is found. 19 | * The script also is configured to ignore interface names of "CPU", "Sup-eth1(R)", "vPC Peer-Link(R)". For this use case these “interfaces” wouldn’t be relevant and just generate noise in the report. 20 | 5. The resulting data from the script is a JSON document. I went with this as it’s a great format to allow lots of other later manipulation of the data. 21 | 22 | As I always like to build things to be able to share, which is why I'm posting it here. You are welcome to use it as it is, or build from it for your own needs. But keep in mind this all important caveat: 23 | 24 | > ***This script is provided as an example only, and does not come with any warranty or liability for damage. Before running this script against your network, you should thoroughly test it, and understand the impacts it will have.*** 25 | 26 | ## How to use the script 27 | 28 | Suppose you want to leverage this script and test it in your lab. This script is built using [pyATS](https://developer.cisco.com/pyats), an open source Python network automation framework from Cisco. If you are new to pyATS, I’d encourage you to checkout the [Getting Started Guide](https://developer.cisco.com/docs/pyats-getting-started/) on DevNet. 29 | 30 | Start out by installing pyATS in your Python virtual environment. I've included a requirements file that has the version of pyATS I used for the project, but any newer version should work. 31 | 32 | ``` 33 | python3.7 -m venv venv 34 | source venv/bin/activate 35 | pip install -r requirements.txt 36 | ``` 37 | 38 | First, you’ll need to generate a Testbed for your network to get started. A Testbed is like an inventory file from Ansible or another automation tool. The testbed file is formatted in YAML, but can be created from an Excel/CSV file or other methods. For full details on Testbed creation, check out [Creating Testbed YAML File](https://pubhub.devnetcloud.com/media/pyats-getting-started/docs/quickstart/manageconnections.html#creating-testbed-yaml-file) 39 | In the documentation. 40 | 41 | Once you have the testbed file, you’d run the script with a command like this: 42 | 43 | ```python 44 | python mac_lookup.py --testbed testbed.yaml \ 45 | --l3device leaf01-1 oob01 \ 46 | --skipinterface "Port-channel2" 47 | ``` 48 | 49 | The parameter `--l3device` takes a list of device names from the testbed that have the ARP information the results will be built from. And `--skipinterface` would be the list of interswitch link interface names to ignore in the results. 50 | 51 | > Note: You can run `python mac_lookup.py --help` for details on the parameters. 52 | 53 | The script would run and provide output like this: 54 | 55 | ``` 56 | Building MAC Address list from ARP information on devices leaf01-1, oob01 57 | Looking up Layer 3 IP -> MAC Mappings. 58 | Checking L3 device leaf01-1 59 | Checking L3 device oob01 60 | Looking up interfaces where MAC addresses are found on the testbed. The following interfaces will be ignored: Port-channel2 61 | No ARP for MAC Address bcf1.f2dc.29a5 found. 62 | No ARP for MAC Address 0050.5661.c275 found. 63 | Saving results to file 'results.json'. 64 | Disconnecting from all devices. 65 | Disconnecting from leaf01-1 66 | Disconnecting from oob01 67 | Disconnecting from spine01-1 68 | ``` 69 | 70 | The resulting “results.json” file would have data that looks similar to this: 71 | 72 | ```json 73 | { 74 | "0050.568c.7aa1": { 75 | "ip": "172.19.248.55", 76 | "interfaces": [ 77 | { 78 | "device": "spine01-1", 79 | "interface": "Ethernet1/7", 80 | "mac_type": "dynamic", 81 | "vlan": "41" 82 | } 83 | ] 84 | }, 85 | "0050.5661.4bba": { 86 | "ip": "172.19.6.11", 87 | "interfaces": [ 88 | { 89 | "device": "spine01-1", 90 | "interface": "Ethernet1/6", 91 | "mac_type": "dynamic", 92 | "vlan": "30" 93 | } 94 | ] 95 | }, 96 | "000c.29aa.086b": { 97 | "ip": "172.19.6.12", 98 | "interfaces": [ 99 | { 100 | "device": "spine01-1", 101 | "interface": "Ethernet1/6", 102 | "mac_type": "dynamic", 103 | "vlan": "30" 104 | } 105 | ] 106 | } 107 | } 108 | ``` 109 | 110 | ## How it works, a peak under the hood 111 | 112 | I highly encourage you to read through the full script to truly understand how it works. I did my best to provide comments and examples within to help describe the flow and what is going on. This was just as much for me as for anyone else who could be interested in it. 113 | 114 | But there are few parts of the logic and function that I think are worth discussing directly here. 115 | 116 | ### Python argparse 117 | I wanted to build this as a tool that anyone could use, and this typically means a CLI type utility. I opted to leverage the standard argparse utility from Python, though there are other libraries available as well. Click is another one I’ve used many times before for more robust tools. 118 | 119 | The key part of argparse is allowing users to provide inputs to the script at run time. This is seen in this part of the code: 120 | 121 | ```python 122 | parser.add_argument( 123 | "--testbed", 124 | dest="testbed", 125 | help="testbed YAML file", 126 | type=str, 127 | default=None, 128 | ) 129 | parser.add_argument( 130 | "--l3device", 131 | dest="layer3_devices", 132 | help="Layer 3 Devices whose ARP tables will be gathered.", 133 | type=str, 134 | nargs="+", 135 | ) 136 | parser.add_argument( 137 | "--skipinterface", 138 | dest="skip_interface", 139 | help="Interface names to skip learning MACs on. Most commonly used for known trunks.", 140 | type=str, 141 | nargs="*", 142 | default=[], 143 | ) 144 | parser.add_argument( 145 | "--outputfile", 146 | dest="output_file", 147 | help="File to save the collected data to in JSON format.", 148 | type=str, 149 | default="results.json", 150 | ) 151 | ``` 152 | 153 | 154 | ### Project flow and functions 155 | There are six steps to this script. 156 | 157 | 1. Connect to all devices in the testbed file for the network 158 | 2. Identify which testbed devices are the “layer 3 devices” where we’ll lookup ARP information 159 | 3. Generate the initial MAC list (technically a Python dictionary) from the ARP tables on the Layer 3 Devices 160 | 4. Add in interface details for each discovered MAC address 161 | 5. Create the results.json file 162 | 6. Disconnect from all devices (we don’t want to leave open VTY line connections) 163 | 164 | With the exception of writing out the results file, I created Python functions for each of these steps. This allowed for modular testing of the code during development, and possibilities for future reusability. 165 | 166 | #### `load_testbed()` 167 | This is a very basic function that first attempts to initialize a new Genie testbed object using the provided testbed filename. As long as the testbed file is formatted correctly, this should succeed, but if there is an error the script will exit. 168 | 169 | > Tip: You can verify your testbed file with “pyats validate testbed testbed.yaml” 170 | 171 | It then attempts to connect to all devices in the testbed. Should a `ConnectionError` be raised due to a device connection failing a message is written to the screen to notify the user. 172 | 173 | #### `discover_macs()` 174 | This function takes the list of layer3_devices provided as input, and runs the appropriate `arp_lookup_command` for the platform using the command parsing ability in pyATS. 175 | 176 | I created a dictionary for the likely platforms and the appropriate command: 177 | 178 | ```python 179 | arp_lookup_command = { 180 | "nxos": "show ip arp vrf all", 181 | "iosxr": "show arp detail", 182 | "iosxe": "show ip arp", 183 | "ios": "show ip arp", 184 | } 185 | ``` 186 | 187 | And then we run the appropriate command for the device using the parse method. 188 | 189 | ```python 190 | arp_info = device.parse(arp_lookup_command[device.os]) 191 | ``` 192 | 193 | One of the main advantages of pyATS is that the parser will return not the clear text output, but rather a nice Python object we can work with. Here is an example of what the returned data would look like 194 | 195 | ```python 196 | { 197 | "interfaces": { 198 | "Ethernet1/3": { 199 | "ipv4": { 200 | "neighbors": { 201 | "172.16.252.2": { 202 | "ip": "172.16.252.2", 203 | "link_layer_address": "5254.0016.18d2", 204 | "physical_interface": "Ethernet1/3", 205 | "origin": "dynamic", 206 | "age": "00:10:51" 207 | } 208 | } 209 | } 210 | } 211 | }, 212 | "statistics": { 213 | "entries_total": 8 214 | } 215 | } 216 | ``` 217 | 218 | With this data from all Layer 3 devices, a straightforward use of Python loops allow the creation and return of a dictionary of MAC Addresses ready to have interfaces filled in. 219 | 220 | ```python 221 | { 222 | "0050.56bf.6f29": { 223 | "ip": "10.10.20.49", 224 | "interfaces": [] 225 | }, 226 | "5254.0006.91c9": { 227 | "ip": "10.10.20.172", 228 | "interfaces": [] 229 | } 230 | } 231 | ``` 232 | 233 | #### `lookup_interfaces()` 234 | This function is where we find the true goal of our script, the interfaces where these MAC addresses are located. This is done using the command parsing capabilities of pyATS with the command “show mac address-table”. This will generate a nice Python object that looks like this: 235 | 236 | ```python 237 | { 238 | "mac_table": { 239 | "vlans": { 240 | "999": { 241 | "vlan": 999, 242 | "mac_addresses": { 243 | "5254.0000.c816": { 244 | "mac_address": "5254.0000.c816", 245 | "interfaces": { 246 | "GigabitEthernet0/3": { 247 | "interface": "GigabitEthernet0/3", 248 | "entry_type": "dynamic" 249 | } 250 | } 251 | } 252 | } 253 | } 254 | } 255 | }, 256 | "total_mac_addresses": 3 257 | } 258 | ``` 259 | 260 | A straightforward, but multi-level, set of Python loops and conditionals are used to process this data for each device in the testbed. It looks like this. 261 | 262 | ```python 263 | for vlan_id, vlan in mac_address_table["mac_table"]["vlans"].items(): 264 | for mac_address, mac_details in vlan["mac_addresses"].items(): 265 | if mac_address in macs.keys(): 266 | for interface in mac_details["interfaces"].values(): 267 | if interface["interface"] not in ignored_interface_names: 268 | if "mac_type" in interface.keys(): 269 | mac_type = interface["mac_type"] 270 | elif "entry_type" in interface.keys(): 271 | mac_type = interface["entry_type"] 272 | else: 273 | mac_type = "N/A" 274 | 275 | macs[mac_address]["interfaces"].append( 276 | { 277 | "device": device.name, 278 | "interface": interface["interface"], 279 | "mac_type": mac_type, 280 | "vlan": vlan_id, 281 | } 282 | ) 283 | else: 284 | print(f"No ARP for MAC Address {mac_address} found.") 285 | ``` 286 | 287 | Working our way through it: 288 | 1. We need to loop over each VLAN returned from the table. Each MAC table entry is tied to a particular VLAN as the MAC Address Table is tied to a Layer 2 domain. 289 | 2. Next we’ll loop over each MAC address listed within the VLAN 290 | 3. The conditional `if mac_address in macs.keys():` is where we only process MAC addresses that had a corresponding ARP entry found previously. 291 | 4. The third loop in loops over each interface entry for the MAC address in the table. Typically there would only be one interface listed, but the object from Genie supports cases where there could be more than one. 292 | 5. Next up is where we consider the list of interface names that we don’t want to consider. These are the CPU, Supervisor, or Interswitch Links that were provided as script inputs. 293 | 6. Once we’ve gotten through that, the interfaces list for each MAC address in our dictionary is updated to include the device, interface, MAC type, and VLAN ID where it was found. 294 | 295 | ## And done! 296 | I think that about covers the basics of the example. Depending on your experience with Python, this script may seem overly simple, or possible super duper complicated. The Python topics (loops, conditionals, functions, etc) are all straightforward. The complexity comes from automating the process and workflow that would be done in a manual fashion. That’s why the most important part of any project like this is starting out with a clear understanding of the scope of the goal, as well as how you might do it manually. 297 | --------------------------------------------------------------------------------