Automating Network Topology Discovery: LLDP and CDP with ezsnmp

Note

  • Published: October 23, 2026

  • Authors: Written by Gemini 3.8 Flash, Co-Authored & Reviewed by Carlos Santos (@carlkidcrypto)

  • Estimated Reading Time: 5 minutes

  • Tags: Python • Snmp • Lldp • Cdp • Topology • Ezsnmp • Network-Automation

Why Layer-2 Discovery Matters

Maintaining accurate, real-time physical network maps is an essential requirement for data center automation, root-cause failure analysis, and Source of Truth (SoT) reconciliation in tools like NetBox.

Instead of manually tracing patch cables or maintaining brittle static spreadsheets, network engineers rely on Layer-2 neighbor discovery protocols:

  • Link Layer Discovery Protocol (LLDP / IEEE 802.1AB): An open, vendor- neutral standard supported across modern switches, routers, and servers.

  • Cisco Discovery Protocol (CDP): A legacy proprietary protocol widely deployed across enterprise campus infrastructure.

Both protocols broadcast device identifiers, remote port names, and system capabilities across adjacent physical links.

ezsnmp provides the high-speed C-binding execution needed to crawl hundreds of network switches, extract neighbor tables via bulkwalk, and reconstruct complete topology graphs in seconds.

The Anatomy of the LLDP-MIB

The standard LLDP-MIB organizes remote neighbor relationships under the lldpRemTable (OID prefix .1.0.8802.1.1.2.1.4.1).

Unlike simple scalar metrics, each row in lldpRemTable is indexed by three distinct components:

  1. ``lldpRemTimeMark``: A timeline integer representing when the neighbor record was updated.

  2. ``lldpRemLocalPortNum``: The local port identifier on the polled switch.

  3. ``lldpRemIndex``: A unique sequential integer identifying multiple neighbors on a shared segment.

Key columnar leaf objects in lldpRemTable include:

  • ``lldpRemSysName``: Hostname or FQDN of the adjacent neighbor switch.

  • ``lldpRemPortId``: Remote port identifier (e.g. GigabitEthernet1/0/1 or Ethernet1/1).

  • ``lldpRemPortDesc``: Descriptive string configured on the remote port.

  • ``lldpRemSysDesc``: Operating system version and hardware platform.

Practical Implementation: Topology Crawler

Below is a complete, production-ready script that polls a network switch, resolves local port names, extracts LLDP neighbors, and constructs a clean link adjacency map:

from collections import defaultdict
from ezsnmp import Session
from ezsnmp.exceptions import GenericError

def extract_composite_index(oid: str, index: str) -> str:
    """Extract composite LLDP suffix (time_mark.local_port.rem_idx) from Result."""
    combined = f"{oid}.{index}" if index else oid
    parts = combined.split(".")
    # LLDP remote table indices consist of 3 sub-identifiers: [time_mark].[local_port].[rem_idx]
    return ".".join(parts[-3:]) if len(parts) >= 3 else index

def discover_lldp_topology(hostname: str, community: str = "public"):
    """Extract physical Layer-2 neighbors using ezsnmp bulk_walk."""
    session = Session(
        hostname=hostname,
        community=community,
        version=2,
        timeout=2,
        retries=1,
        set_max_repeaters_to_num=25
    )

    try:
        # 1. Map local port numbers to human-readable interface names using LLDP-MIB
        # lldpLocPortDesc uses the exact local port numbering space as lldpRemTable
        local_ports = {}
        loc_port_vars = session.bulk_walk("lldpLocPortDesc")
        for var in loc_port_vars:
            local_ports[var.index] = var.value

        # 2. Bulk-walk LLDP remote table columns
        remote_sys_vars = session.bulk_walk("lldpRemSysName")
        remote_port_vars = session.bulk_walk("lldpRemPortId")

        # 3. Index remote metrics by full composite index
        remote_systems = {
            extract_composite_index(v.oid, v.index): v.value
            for v in remote_sys_vars
        }
        remote_ports = {
            extract_composite_index(v.oid, v.index): v.value
            for v in remote_port_vars
        }

        adjacencies = []

        for comp_idx, remote_device in remote_systems.items():
            parts = comp_idx.split(".")
            # [time_mark].[local_port_num].[rem_idx]
            local_port_num = parts[1] if len(parts) >= 3 else parts[0]
            local_port_name = local_ports.get(
                local_port_num, f"Port-{local_port_num}"
            )
            remote_port_name = remote_ports.get(comp_idx, "Unknown")

            adjacencies.append({
                "local_device": hostname,
                "local_port": local_port_name,
                "remote_device": remote_device,
                "remote_port": remote_port_name,
            })

        return adjacencies

    except GenericError as exc:
        print(f"[{hostname}] Topology discovery failed: {exc}")
        return []

if __name__ == "__main__":
    links = discover_lldp_topology("core-sw01.internal")
    print(f"Discovered {len(links)} Layer-2 links:")
    for link in links:
        print(
            f"  {link['local_device']}:{link['local_port']} <---> "
            f"{link['remote_device']}:{link['remote_port']}"
        )

Handling Multi-Vendor Protocol Differences: LLDP vs CDP

In heterogeneous environments featuring both modern whitebox switches and legacy Cisco infrastructure:

  1. Multi-Protocol Collection & Deduplication: Rather than falling back to CDP only when the LLDP table is completely empty (which misses CDP-only links on mixed-vendor trunks), query both LLDP and CISCO-CDP-MIB where supported. Merge the neighbor records and deduplicate links by normalized local and remote endpoints:

    • Walk lldpRemSysName and lldpRemPortId for IEEE standard neighbors.

    • Walk cdpCacheDeviceId (OID .1.3.6.1.4.1.9.9.23.1.2.1.1.6) and cdpCacheDevicePort (OID .1.3.6.1.4.1.9.9.23.1.2.1.1.7) for Cisco devices.

    • Deduplicate using a unique key (local_port, remote_device).

  2. Hex-Encoded Chassis IDs & MAC Addresses: Certain vendors transmit chassis identifiers (such as MAC addresses) as formatted hexadecimal strings in lldpRemChassisId when lldpRemChassisIdSubtype indicates a MAC address (subtype 4). Because ezsnmp provides formatted text in var.value, parse hexadecimal sequences before normalizing:

def format_chassis_mac(raw_hex: str) -> str:
    """Normalize hex-formatted chassis MAC string to colon-separated notation."""
    clean_hex = raw_hex.replace(" ", "").replace(":", "").replace("-", "")
    if len(clean_hex) == 12:
        return ":".join(clean_hex[i:i + 2].lower() for i in range(0, 12, 2))
    return raw_hex

Production Best Practices

  • Process Isolation for High-Concurrency Crawlers: Topology discovery involves traversing hundreds of switches in a network fabric. Because Net-SNMP retains process-global C state, crawl switches concurrently using Python’s concurrent.futures.ProcessPoolExecutor (creating an isolated Session inside each worker process) rather than unconstrained thread pools.

  • Prevent Discovery Loops: Maintain a set of visited IP addresses and hostnames to prevent cyclic graph traversal when crawling mesh fabrics.

  • Tune Repetition Bounds via Session Constructor: Standard switch stacks contain dozens of neighbor entries. Setting set_max_repeaters_to_num=25..50 in the Session constructor configures GETBULK batch sizing, reducing UDP round-trips across multi-port chassis.

Migration Notes: Easy SNMP vs ezsnmp

  • Safe Large-Scale Graph Traversals: Legacy easysnmp suffered from cumulative memory leakage during recursive multi-thousand-node graph walks. ezsnmp’s modern C++17 RAII memory management ensures PDU chains are freed after every switch crawl, enabling long-running discovery engines.

  • String Encoding Stability: Clean handling of raw bytes ensures strange neighbor device names or chassis IDs never trigger Python decoding faults.