diff --git a/README.md b/README.md index 3c63919..03af3b1 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,40 @@ lldp2map/ - [github.com/gosnmp/gosnmp](https://github.com/gosnmp/gosnmp) — SNMP v2c/v3 - [github.com/spf13/cobra](https://github.com/spf13/cobra) — CLI framework +## Linux lldpd and SNMP + +Linux hosts running [`lldpd`](https://lldpd.github.io) do **not** expose LLDP neighbor data via SNMP by default. `lldpd` must be configured to operate as an AgentX sub-agent alongside `snmpd`. Without this, lldp2map can still discover a Linux host as a *neighbor* (seen from an adjacent device's LLDP table), but it cannot recurse into that host to find *its* neighbors. + +This is particularly useful on operating systems like proxmox which also house LXCs and VMs that may also be running lldpd. I don't know if this works on VMWare or HyperV, or if lldp is exposed under windows, althugh it [can be enabled](https://learn.microsoft.com/en-us/powershell/module/netlldpagent/enable-netlldpagent?view=windowsserver2025-ps). + +To enable SNMP on a Linux host running `lldpd`: + +1. Install and configure `snmpd`, then add AgentX master support to `/etc/snmp/snmpd.conf`: + +```text +master agentx +``` + +1. Start `lldpd` with the `-x` flag to connect as an AgentX sub-agent: + +```bash +lldpd -x +``` + +Or, in `/etc/default/lldpd` (Debian/Ubuntu) or `/etc/sysconfig/lldpd` (RHEL/Fedora): + +```text +DAEMON_ARGS="-x" +``` + +1. Restart both services: + +```bash +systemctl restart snmpd lldpd +``` + +Once configured, lldp2map can walk the LLDP-MIB on the Linux host just like any other device. + ## Caveats - Neighbors with no resolvable IP (no management address, no chassis networkAddress, and no ARP/NDP entry for their MAC) are added to the map but not recursed into. The discovery log explicitly reports this for each such neighbor. diff --git a/cmd/root.go b/cmd/root.go index bd789ba..cc30361 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,17 +10,17 @@ import ( ) var ( - community string - snmpVersion string - username string - authProto string - authPass string - privProto string - privPass string - secLevel string - snmpPort uint16 - snmpTimeout int - snmpRetries int + community string + snmpVersion string + username string + authProto string + authPass string + privProto string + privPass string + secLevel string + snmpPort uint16 + snmpTimeout int + snmpRetries int maxHops int showAddrs bool addrFamily string @@ -100,6 +100,14 @@ func init() { func run(_ *cobra.Command, args []string) error { seedHost := args[0] + // Validate --addr-family early so the user gets a clear error before + // any SNMP connections are attempted. + switch addrFamily { + case "ipv4", "ipv6", "both": + default: + return fmt.Errorf("invalid --addr-family %q: must be ipv4, ipv6, or both", addrFamily) + } + validFormats := map[string]string{ "png": ".png", "pdf": ".pdf", diff --git a/internal/discover/discover.go b/internal/discover/discover.go index 7bceabc..3a38254 100644 --- a/internal/discover/discover.go +++ b/internal/discover/discover.go @@ -116,6 +116,13 @@ func Run(ctx context.Context, cfg Config, log func(string)) (*graph.Topology, er if err != nil { log(fmt.Sprintf("%s Warning: LLDP walk failed on %s: %v", indent, item.host, err)) + // Preserve the node even on a partial walk failure so that the + // device appears in the topology (it was reachable via SNMP). + nodeName := item.host + if info != nil && info.SysName != "" { + nodeName = info.SysName + } + topo.AddNode(nodeName, item.host) continue } @@ -157,8 +164,14 @@ func Run(ctx context.Context, cfg Config, log func(string)) (*graph.Topology, er continue } + filteredAddrs := filter.IPs(neighbor.MgmtAddrs, cfg.AddrFamily, ignorePrefixes) + if len(filteredAddrs) == 0 && len(neighbor.MgmtAddrs) > 0 { + log(fmt.Sprintf("%s (all %d management address(es) excluded by addr-family/ignore-prefix filter)", indent, len(neighbor.MgmtAddrs))) + continue + } + queued := 0 - for _, ip := range filter.IPs(neighbor.MgmtAddrs, cfg.AddrFamily, ignorePrefixes) { + for _, ip := range filteredAddrs { ipStr := ip.String() if !visited[ipStr] && !inQueue(queue, ipStr) { queue = append(queue, queueItem{host: ipStr, depth: item.depth + 1}) @@ -168,7 +181,7 @@ func Run(ctx context.Context, cfg Config, log func(string)) (*graph.Topology, er } } if queued == 0 { - log(fmt.Sprintf("%s (all %d address(es) already visited/queued)", indent, len(neighbor.MgmtAddrs))) + log(fmt.Sprintf("%s (all %d management address(es) already visited or queued)", indent, len(filteredAddrs))) } } } diff --git a/internal/lldp/walker.go b/internal/lldp/walker.go index c421487..e01c4f8 100644 --- a/internal/lldp/walker.go +++ b/internal/lldp/walker.go @@ -42,6 +42,11 @@ const ( // ARP table — IPv4 MAC→IP mapping on the queried device. // Index: ifIndex.a.b.c.d Value: MAC (6 bytes) oidARPPhysAddr = "1.3.6.1.2.1.4.22.1.2" + + // ipNetToPhysicalTable (RFC 4293) — unified IPv4+IPv6 neighbor table. + // Index: ifIndex.addrType.addrLen.addr[bytes] Value: MAC (6 bytes) + // addrType 1=IPv4 (4 bytes), 2=IPv6 (16 bytes) + oidIPNetToPhysicalPhysAddr = "1.3.6.1.2.1.4.35.1.4" ) // Neighbor represents a single LLDP-discovered neighbor. @@ -85,7 +90,9 @@ func Walk(client *snmpclient.Client) (*LocalInfo, error) { sysNames := map[remKey]string{} pdus, err := client.Walk(oidRemSysName) if err != nil { - return nil, fmt.Errorf("walk lldpRemSysName: %w", err) + // Return whatever partial info we have so the caller can still register + // this node in the topology even if the full LLDP walk fails. + return info, fmt.Errorf("walk lldpRemSysName: %w", err) } for _, pdu := range pdus { if k, ok := parseRemKey(pdu.Name, oidRemSysName); ok { @@ -165,12 +172,17 @@ func Walk(client *snmpclient.Client) (*LocalInfo, error) { } } - // For MAC-identified chassis IDs, look up the matching IP in the device's ARP table. + // For MAC-identified chassis IDs, resolve to an IP address. + // Prefer the RFC 4293 unified IPv4+IPv6 neighbor table; fall back to the + // legacy IPv4-only ARP table for devices that don't implement RFC 4293. if len(chassisMACs) > 0 { - arpMap := walkARP(client) + neighborMap := walkIPNetToPhysical(client) + if len(neighborMap) == 0 { + neighborMap = walkARP(client) + } for k, mac := range chassisMACs { if _, already := chassisIPs[k]; !already { - if ip, found := arpMap[mac]; found { + if ip, found := neighborMap[mac]; found { chassisIPs[k] = ip } } @@ -335,6 +347,67 @@ func walkARP(client *snmpclient.Client) map[[6]byte]net.IP { return result } +// walkIPNetToPhysical walks the RFC 4293 ipNetToPhysicalTable, which contains +// both IPv4 (ARP) and IPv6 (NDP) neighbor entries. Returns a MAC→IP map +// preferring a global-unicast IPv6 address over IPv4 when both are present +// for the same MAC. Loopback and link-local addresses are excluded. +// Index format: ifIndex.addrType.addrLen.addr[bytes] Value: MAC (6 bytes) +func walkIPNetToPhysical(client *snmpclient.Client) map[[6]byte]net.IP { + result := map[[6]byte]net.IP{} + pdus, err := client.Walk(oidIPNetToPhysicalPhysAddr) + if err != nil { + return result + } + for _, pdu := range pdus { + suffix := suffixAfter(pdu.Name, oidIPNetToPhysicalPhysAddr) + if suffix == "" { + continue + } + parts := strings.Split(suffix, ".") + // Minimum: ifIndex(1) addrType(1) addrLen(1) addr(>=4) = 7 parts for IPv4. + if len(parts) < 7 { + continue + } + addrType := parts[1] // 1=IPv4, 2=IPv6 + + var ip net.IP + switch addrType { + case "1": // IPv4: addrLen always 4, addr starts at parts[3] + if len(parts) >= 7 { + ip = net.ParseIP(strings.Join(parts[3:7], ".")) + } + case "2": // IPv6: addrLen always 16, addr starts at parts[3] + if len(parts) >= 19 { + b := make([]byte, 16) + for i := 0; i < 16; i++ { + var v int + fmt.Sscanf(parts[3+i], "%d", &v) + b[i] = byte(v) + } + ip = net.IP(b) + } + } + + if ip == nil || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + + mac, ok := pdu.Value.([]byte) + if !ok || len(mac) != 6 { + continue + } + var key [6]byte + copy(key[:], mac) + + // Prefer global-unicast IPv6 over IPv4 when multiple entries share a MAC. + existing, has := result[key] + if !has || (existing.To4() != nil && ip.To4() == nil) { + result[key] = ip + } + } + return result +} + // WalkIPAddresses returns all non-loopback, non-link-local unicast interface // addresses on the device. It queries the modern ipAddressTable (RFC 4293) // which covers both IPv4 and IPv6, falling back to the legacy IPv4-only