Skip to content
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 19 additions & 11 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 15 additions & 2 deletions internal/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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})
Expand All @@ -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)))
}
}
}
Expand Down
81 changes: 77 additions & 4 deletions internal/lldp/walker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down