From 842155a0e03dacd263be4ecd7c8c4287fa053b63 Mon Sep 17 00:00:00 2001 From: Nick Buraglio Date: Fri, 6 Mar 2026 15:49:57 -0600 Subject: [PATCH 1/3] Add flags for ignoring addresses and prefixes --- cmd/root.go | 36 ++++- internal/filter/addr.go | 88 +++++++++++ internal/filter/addr_test.go | 299 +++++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 6 deletions(-) create mode 100644 internal/filter/addr.go create mode 100644 internal/filter/addr_test.go diff --git a/cmd/root.go b/cmd/root.go index 38c39d8..9192da6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,9 +2,11 @@ package cmd import ( "fmt" + "net" "os" "time" + "github.com/buraglio/lldp2map/internal/filter" "github.com/buraglio/lldp2map/internal/graph" "github.com/buraglio/lldp2map/internal/lldp" "github.com/buraglio/lldp2map/internal/render" @@ -29,10 +31,12 @@ var ( snmpPort uint16 snmpTimeout int snmpRetries int - maxHops int - showAddrs bool - outputFile string - outputFormat string + maxHops int + showAddrs bool + addrFamily string + ignorePrefixStrs []string + outputFile string + outputFormat string ) var rootCmd = &cobra.Command{ @@ -95,6 +99,8 @@ func init() { // Discovery f.IntVar(&maxHops, "max-hops", 10, "Maximum BFS depth for recursive discovery") f.BoolVar(&showAddrs, "show-addrs", false, "Include interface IPv4/IPv6 addresses in node labels (walks IP-MIB on each device)") + f.StringVar(&addrFamily, "addr-family", "both", "Address family to display with --show-addrs: ipv4, ipv6, or both") + f.StringArrayVar(&ignorePrefixStrs, "ignore-prefix", nil, "CIDR prefix to exclude from labels and discovery (repeatable: --ignore-prefix 127.0.0.0/8 --ignore-prefix fd68:1::/48)") // Output f.StringVarP(&outputFile, "output", "o", "network-map.png", "Output file path") @@ -104,6 +110,23 @@ func init() { func run(_ *cobra.Command, args []string) error { seedHost := args[0] + // Validate --addr-family + switch addrFamily { + case "ipv4", "ipv6", "both": + default: + return fmt.Errorf("invalid --addr-family %q: must be ipv4, ipv6, or both", addrFamily) + } + + // Parse --ignore-prefix values once so we can reuse them throughout the run. + var ignorePrefixes []*net.IPNet + if len(ignorePrefixStrs) > 0 { + var err error + ignorePrefixes, err = filter.ParseIgnorePrefixes(ignorePrefixStrs) + if err != nil { + return err + } + } + validFormats := map[string]string{ "png": ".png", "pdf": ".pdf", @@ -164,7 +187,8 @@ func run(_ *cobra.Command, args []string) error { info, err := lldp.Walk(client) var ifAddrs []string if err == nil && showAddrs { - ifAddrs, _ = lldp.WalkIPAddresses(client) + raw, _ := lldp.WalkIPAddresses(client) + ifAddrs = filter.Addrs(raw, addrFamily, ignorePrefixes) } client.Close() if err != nil { @@ -204,7 +228,7 @@ func run(_ *cobra.Command, args []string) error { continue } - for _, ip := range neighbor.MgmtAddrs { + for _, ip := range filter.IPs(neighbor.MgmtAddrs, addrFamily, ignorePrefixes) { ipStr := ip.String() if !visited[ipStr] && !inQueue(queue, ipStr) { queue = append(queue, queueItem{host: ipStr, depth: item.depth + 1}) diff --git a/internal/filter/addr.go b/internal/filter/addr.go new file mode 100644 index 0000000..a8b075d --- /dev/null +++ b/internal/filter/addr.go @@ -0,0 +1,88 @@ +package filter + +import ( + "fmt" + "net" +) + +// ParseIgnorePrefixes parses a slice of CIDR strings into []*net.IPNet. +// Returns an error if any string is not a valid CIDR prefix. +func ParseIgnorePrefixes(prefixes []string) ([]*net.IPNet, error) { + nets := make([]*net.IPNet, 0, len(prefixes)) + for _, p := range prefixes { + _, ipNet, err := net.ParseCIDR(p) + if err != nil { + return nil, fmt.Errorf("invalid prefix %q: %w", p, err) + } + nets = append(nets, ipNet) + } + return nets, nil +} + +// isIPv4 reports whether ip is an IPv4 address. +func isIPv4(ip net.IP) bool { + return ip.To4() != nil +} + +// matchesFamily reports whether ip matches the requested address family. +// family "ipv4" accepts only IPv4, "ipv6" accepts only IPv6, anything else +// (including "" and "both") accepts all addresses. +func matchesFamily(ip net.IP, family string) bool { + switch family { + case "ipv4": + return isIPv4(ip) + case "ipv6": + return !isIPv4(ip) + default: + return true + } +} + +// inIgnored reports whether ip is covered by any of the ignore prefixes. +func inIgnored(ip net.IP, ignore []*net.IPNet) bool { + for _, n := range ignore { + if n.Contains(ip) { + return true + } + } + return false +} + +// Addrs filters a slice of IP address strings by address family and ignore +// prefixes. Addresses that do not parse, do not match the requested family, +// or are covered by an ignore prefix are dropped. +// family: "ipv4", "ipv6", or "" / "both" (no family filtering). +func Addrs(addrs []string, family string, ignore []*net.IPNet) []string { + if len(addrs) == 0 { + return addrs + } + out := make([]string, 0, len(addrs)) + for _, s := range addrs { + ip := net.ParseIP(s) + if ip == nil { + continue + } + if !matchesFamily(ip, family) || inIgnored(ip, ignore) { + continue + } + out = append(out, s) + } + return out +} + +// IPs filters a slice of net.IP by address family and ignore prefixes. +// IPs that do not match the requested family or are covered by an ignore +// prefix are dropped. +func IPs(ips []net.IP, family string, ignore []*net.IPNet) []net.IP { + if len(ips) == 0 { + return ips + } + out := make([]net.IP, 0, len(ips)) + for _, ip := range ips { + if !matchesFamily(ip, family) || inIgnored(ip, ignore) { + continue + } + out = append(out, ip) + } + return out +} diff --git a/internal/filter/addr_test.go b/internal/filter/addr_test.go new file mode 100644 index 0000000..f80acc6 --- /dev/null +++ b/internal/filter/addr_test.go @@ -0,0 +1,299 @@ +package filter + +import ( + "net" + "testing" +) + +// helper: parse a CIDR and return only the *net.IPNet, panicking on error. +func mustParseCIDR(s string) *net.IPNet { + _, n, err := net.ParseCIDR(s) + if err != nil { + panic(err) + } + return n +} + +// helper: parse an IP, panicking on failure. +func mustParseIP(s string) net.IP { + ip := net.ParseIP(s) + if ip == nil { + panic("invalid IP: " + s) + } + return ip +} + +// ── ParseIgnorePrefixes ────────────────────────────────────────────────────── + +func TestParseIgnorePrefixes_Valid(t *testing.T) { + inputs := []string{"10.0.0.0/8", "172.16.0.0/12", "fd00::/8"} + nets, err := ParseIgnorePrefixes(inputs) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nets) != len(inputs) { + t.Fatalf("got %d nets, want %d", len(nets), len(inputs)) + } +} + +func TestParseIgnorePrefixes_Empty(t *testing.T) { + nets, err := ParseIgnorePrefixes(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(nets) != 0 { + t.Fatalf("expected empty result, got %d nets", len(nets)) + } +} + +func TestParseIgnorePrefixes_Invalid(t *testing.T) { + _, err := ParseIgnorePrefixes([]string{"10.0.0.0/8", "not-a-cidr"}) + if err == nil { + t.Fatal("expected error for invalid CIDR, got nil") + } +} + +func TestParseIgnorePrefixes_HostBitsSet(t *testing.T) { + // net.ParseCIDR accepts host-bits-set notation and masks them; should not error. + _, err := ParseIgnorePrefixes([]string{"192.168.1.1/24"}) + if err != nil { + t.Fatalf("unexpected error for host-bits-set CIDR: %v", err) + } +} + +// ── Addrs ──────────────────────────────────────────────────────────────────── + +func TestAddrs_Empty(t *testing.T) { + got := Addrs(nil, "both", nil) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +func TestAddrs_FamilyBoth(t *testing.T) { + in := []string{"10.0.0.1", "2001:db8::1"} + got := Addrs(in, "both", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } +} + +func TestAddrs_FamilyDefault(t *testing.T) { + // Empty string should behave the same as "both". + in := []string{"10.0.0.1", "2001:db8::1"} + got := Addrs(in, "", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } +} + +func TestAddrs_FamilyIPv4Only(t *testing.T) { + in := []string{"10.0.0.1", "2001:db8::1", "192.168.1.1"} + got := Addrs(in, "ipv4", nil) + if len(got) != 2 { + t.Fatalf("expected 2 IPv4 addresses, got %v", got) + } + for _, s := range got { + if net.ParseIP(s).To4() == nil { + t.Errorf("non-IPv4 address leaked through: %s", s) + } + } +} + +func TestAddrs_FamilyIPv6Only(t *testing.T) { + in := []string{"10.0.0.1", "2001:db8::1", "fd00::1"} + got := Addrs(in, "ipv6", nil) + if len(got) != 2 { + t.Fatalf("expected 2 IPv6 addresses, got %v", got) + } + for _, s := range got { + if net.ParseIP(s).To4() != nil { + t.Errorf("IPv4 address leaked through: %s", s) + } + } +} + +func TestAddrs_IgnorePrefix(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("10.0.0.0/8")} + in := []string{"10.0.0.1", "10.255.255.255", "192.168.1.1"} + got := Addrs(in, "both", ignore) + if len(got) != 1 || got[0] != "192.168.1.1" { + t.Fatalf("expected [192.168.1.1], got %v", got) + } +} + +func TestAddrs_IgnorePrefixIPv6(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("fd00::/8")} + in := []string{"fd00::1", "fd68:1::1", "2001:db8::1"} + got := Addrs(in, "both", ignore) + if len(got) != 1 || got[0] != "2001:db8::1" { + t.Fatalf("expected [2001:db8::1], got %v", got) + } +} + +func TestAddrs_MultipleIgnorePrefixes(t *testing.T) { + ignore := []*net.IPNet{ + mustParseCIDR("127.0.0.0/8"), + mustParseCIDR("192.168.0.0/16"), + mustParseCIDR("fd00::/8"), + } + in := []string{"127.0.0.1", "192.168.1.1", "fd68:1::1", "10.0.0.1", "2001:db8::1"} + got := Addrs(in, "both", ignore) + if len(got) != 2 { + t.Fatalf("expected 2 addresses, got %v", got) + } +} + +func TestAddrs_FamilyAndIgnoreCombined(t *testing.T) { + // IPv4 only, ignoring 10.0.0.0/8 + ignore := []*net.IPNet{mustParseCIDR("10.0.0.0/8")} + in := []string{"10.0.0.1", "192.168.1.1", "2001:db8::1"} + got := Addrs(in, "ipv4", ignore) + if len(got) != 1 || got[0] != "192.168.1.1" { + t.Fatalf("expected [192.168.1.1], got %v", got) + } +} + +func TestAddrs_InvalidStringDropped(t *testing.T) { + in := []string{"not-an-ip", "10.0.0.1"} + got := Addrs(in, "both", nil) + if len(got) != 1 || got[0] != "10.0.0.1" { + t.Fatalf("expected [10.0.0.1], got %v", got) + } +} + +func TestAddrs_AllFiltered(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("0.0.0.0/0")} + in := []string{"10.0.0.1", "192.168.1.1"} + got := Addrs(in, "both", ignore) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +// ── IPs ────────────────────────────────────────────────────────────────────── + +func TestIPs_Empty(t *testing.T) { + got := IPs(nil, "both", nil) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +func TestIPs_FamilyBoth(t *testing.T) { + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1")} + got := IPs(in, "both", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } +} + +func TestIPs_FamilyDefault(t *testing.T) { + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1")} + got := IPs(in, "", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } +} + +func TestIPs_FamilyIPv4Only(t *testing.T) { + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1"), mustParseIP("172.16.0.1")} + got := IPs(in, "ipv4", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } + for _, ip := range got { + if ip.To4() == nil { + t.Errorf("non-IPv4 leaked through: %v", ip) + } + } +} + +func TestIPs_FamilyIPv6Only(t *testing.T) { + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1"), mustParseIP("fd00::1")} + got := IPs(in, "ipv6", nil) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } + for _, ip := range got { + if ip.To4() != nil { + t.Errorf("IPv4 leaked through: %v", ip) + } + } +} + +func TestIPs_IgnorePrefix(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("192.168.0.0/16")} + in := []net.IP{mustParseIP("192.168.1.1"), mustParseIP("10.0.0.1")} + got := IPs(in, "both", ignore) + if len(got) != 1 || got[0].String() != "10.0.0.1" { + t.Fatalf("expected [10.0.0.1], got %v", got) + } +} + +func TestIPs_IgnorePrefixIPv6(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("fd68:1::/48")} + in := []net.IP{mustParseIP("fd68:1::1"), mustParseIP("2001:db8::1")} + got := IPs(in, "both", ignore) + if len(got) != 1 || got[0].String() != "2001:db8::1" { + t.Fatalf("expected [2001:db8::1], got %v", got) + } +} + +func TestIPs_MultipleIgnorePrefixes(t *testing.T) { + ignore := []*net.IPNet{ + mustParseCIDR("10.0.0.0/8"), + mustParseCIDR("fd00::/8"), + } + in := []net.IP{ + mustParseIP("10.0.0.1"), + mustParseIP("fd68:1::1"), + mustParseIP("192.168.1.1"), + mustParseIP("2001:db8::1"), + } + got := IPs(in, "both", ignore) + if len(got) != 2 { + t.Fatalf("expected 2, got %v", got) + } +} + +func TestIPs_FamilyAndIgnoreCombined(t *testing.T) { + // IPv6 only, ignoring fd00::/8 + ignore := []*net.IPNet{mustParseCIDR("fd00::/8")} + in := []net.IP{ + mustParseIP("10.0.0.1"), + mustParseIP("fd00::1"), + mustParseIP("2001:db8::1"), + } + got := IPs(in, "ipv6", ignore) + if len(got) != 1 || got[0].String() != "2001:db8::1" { + t.Fatalf("expected [2001:db8::1], got %v", got) + } +} + +func TestIPs_AllFiltered(t *testing.T) { + ignore := []*net.IPNet{mustParseCIDR("0.0.0.0/0"), mustParseCIDR("::/0")} + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1")} + got := IPs(in, "both", ignore) + if len(got) != 0 { + t.Fatalf("expected empty, got %v", got) + } +} + +func TestIPs_InputNotMutated(t *testing.T) { + // Verify that the original slice is not modified by filtering. + in := []net.IP{mustParseIP("10.0.0.1"), mustParseIP("2001:db8::1")} + orig := make([]net.IP, len(in)) + copy(orig, in) + + IPs(in, "ipv4", nil) + + if len(in) != len(orig) { + t.Fatalf("original slice length changed: got %d, want %d", len(in), len(orig)) + } + for i := range in { + if !in[i].Equal(orig[i]) { + t.Errorf("original slice[%d] mutated: got %v, want %v", i, in[i], orig[i]) + } + } +} From f841a21317be83468c314904265677d83cee2226 Mon Sep 17 00:00:00 2001 From: Nick Buraglio Date: Sun, 8 Mar 2026 20:57:01 -0500 Subject: [PATCH 2/3] fix discovery for hosts not returning neighbors, add walkIPNetToPhysical for NDP table. Fix discardrd partial results. --- internal/discover/discover.go | 17 +++++++- internal/lldp/walker.go | 81 +++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 6 deletions(-) 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 From 17df3fdaf6ac8c9ab54ef49d6acfa342e0e0e487 Mon Sep 17 00:00:00 2001 From: Nick Buraglio Date: Mon, 9 Mar 2026 09:16:23 -0500 Subject: [PATCH 3/3] update README to reflect linux lldp --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index b322b73..7b94086 100644 --- a/README.md +++ b/README.md @@ -223,9 +223,44 @@ 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 entry for their MAC) are added to the map but not recursed into. The discovery log explicitly reports this for each such neighbor. +- Linux hosts running `lldpd` without SNMP/AgentX enabled will appear in the topology (discovered as neighbors of other devices) but cannot be recursed into — see [Linux lldpd and SNMP](#linux-lldpd-and-snmp) above. - Duplicate edges (A→B and B→A) are automatically deduplicated. - If `lldpLocSysName` is not available, the device IP is used as the node label. - `--show-addrs` adds one extra SNMP walk per visited device. On large networks this increases discovery time.