Files
nmon/internal/api/l2_test.go
Vibe Coder 7173360346 Add the MAC/ARP/FDB table job, the lookup API, BGP peer collection and bgp_peer_down
Owner request: finding where a host is plugged in from a MAC or IP is a must,
and BGP peer monitoring is wanted.

L2 job (internal/l2): a job of its own, off the 60 s poll path (its sessions
are path "l2"), walks BRIDGE-MIB, Q-BRIDGE-MIB, CISCO-VTP-MIB (a per-VLAN
context walk, enabled by the cisco-ios profile flag l2.per_vlan_context, not by
detection) and the IP-MIB neighbour tables (IPv4 ARP and IPv6 ND, IPv4-only
fallback) every l2.interval, with its own concurrency cap and a per-device
deadline. It yields to the poller like the rollup job, and a device that times
out is skipped for the cycle, counted and named on /poller/health. Migration 14
adds fdb_entries, arp_entries, vlans and l2_walks with first/last-seen history,
batched upserts, daily pruning by last_seen (l2.keep) and backup coverage.

API (additive): /l2/search ranks edge ports first (a port is an uplink above
l2.edge_max_macs MACs, with an LLDP neighbour, or as a LAG), plus the device
fdb/arp/l2/l2-ports tables and /interfaces/{id}/macs; vendor and mac_kind are
computed at query time from the optional registry.

BGP: profiles label peer rows by decoding the table index (index_label.layout),
so each peer, IPv4 or IPv6, is one component whatever MIB feeds it; AS, local
AS, router id and description are component attributes, never labels.
CISCO-BGP4 (cisco-ios, cisco-nxos), Juniper BGP4-V2 and Arista BGP4V2 replace
the optional generic BGP4-MIB metrics, which edgeos, fortigate, pfsense and vyos
switch on. /bgp/peers lists sessions down first. The built-in rule
bgp_peer_down (critical, not established for 2 minutes) fires only where a
policy binds it; its notification names the peer's AS and description.

Fixtures are generated (Q-BRIDGE with IPv4/IPv6 neighbours, BRIDGE-MIB only,
Cisco per-VLAN contexts via a new simagent contexts feature) and checked
against real LibreNMS recordings. DEVIATIONS 50-55 record the departures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4PdJUPrakM8fHvN8jYv2x
2026-10-24 01:30:32 +00:00

303 lines
11 KiB
Go

package api_test
import (
"context"
"fmt"
"net/url"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/store"
)
// stubVendors is a registry of two vendors.
type stubVendors struct{}
func (stubVendors) Vendor(mac string) string {
switch {
case strings.HasPrefix(mac, "f0:9f:c2"):
return "Ubiquiti Inc"
case mac[1] == '2' || mac[1] == '6' || mac[1] == 'a' || mac[1] == 'e':
return "Private / randomised"
}
return ""
}
func (stubVendors) Available() bool { return true }
func (stubVendors) Match(sub string) func(string) bool {
return func(mac string) bool { return strings.Contains(strings.ToLower((stubVendors{}).Vendor(mac)), sub) }
}
// seedL2Net seeds three switches that all see the same host: its own access switch, a distribution switch where the
// host's MAC is one of ten behind an uplink, and a core switch whose port toward the distribution layer has an
// LLDP neighbour.
const host = "f0:9f:c2:11:22:33"
func seedL2Net(t *testing.T, e *env) {
t.Helper()
ctx := context.Background()
access := e.device(t, "access1", "yyz", "access")
dist := e.device(t, "dist1", "yyz", "distribution")
core := e.device(t, "core1", "yyz", "core")
aif := e.iface(t, access, 7, "Gi1/0/7", "")
dif := e.iface(t, dist, 48, "Te1/1", "")
cif := e.iface(t, core, 3, "Eth3", "")
now := e.clk.Now()
rec := func(d *store.Device, entries []store.FDBEntry, arp []store.ARPEntry) {
if err := e.st.RecordL2Walk(ctx, d.ID, store.L2WalkResult{At: now, Source: "qbridge", FDB: entries, ARP: arp,
VLANs: []store.VLANName{{VLAN: 20, Name: "cameras"}}}); err != nil {
t.Fatal(err)
}
}
rec(access, []store.FDBEntry{{InterfaceID: aif.ID, VLAN: 20, Mac: host}}, nil)
var many []store.FDBEntry
many = append(many, store.FDBEntry{InterfaceID: dif.ID, VLAN: 20, Mac: host})
for i := 1; i <= 9; i++ {
many = append(many, store.FDBEntry{InterfaceID: dif.ID, VLAN: 20, Mac: fmt.Sprintf("02:00:00:00:00:%02x", i)})
}
rec(dist, many, nil)
rec(core, []store.FDBEntry{{InterfaceID: cif.ID, VLAN: 20, Mac: host}}, []store.ARPEntry{
{IP: "192.0.2.77", Mac: host, InterfaceID: cif.ID}, {IP: "2001:db8::77", Mac: host, InterfaceID: cif.ID},
})
// The core's port toward the distribution layer has an LLDP neighbour (local port number = ifIndex).
if err := e.st.ReplaceComponents(ctx, core.ID, "LLDP-MIB::lldpRemTable", []store.Component{{Idx: "0.3.1", Label: "dist1"}}); err != nil {
t.Fatal(err)
}
}
func searchL2(t *testing.T, e *env, q string) api.L2SearchResult {
t.Helper()
var got api.L2SearchResult
e.getJSON(t, "/api/v1/l2/search?q="+url.QueryEscape(q), &got)
return got
}
func TestAMACSeenOnAnAccessPortAndTwoUplinksResolvesToTheAccessPortFirst(t *testing.T) {
e := newEnvDeps(t, 0, func(d *api.Deps) { d.Vendors = stubVendors{} })
seedL2Net(t, e)
got := searchL2(t, e, host)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("hits = %+v", got)
}
h := got.Items[0]
if h.MAC != host || h.Vendor != "Ubiquiti Inc" || h.MACKind != "global" {
t.Errorf("hit = %+v", h)
}
if len(h.Locations) != 3 {
t.Fatalf("locations = %+v", h.Locations)
}
first := h.Locations[0]
if !first.Edge || first.Device.Slug == "" || first.Iface == nil || first.Iface.IfName != "Gi1/0/7" || first.PortMACCount != 1 ||
first.VLAN != 20 || first.VLANName != "cameras" || !first.Current {
t.Errorf("the access port must come first: %+v", first)
}
reasons := map[string]string{}
for _, l := range h.Locations[1:] {
if l.Edge {
t.Errorf("an uplink was reported as an edge port: %+v", l)
}
reasons[l.Device.Name] = l.UplinkReason
}
if reasons["dist1"] != "macs" || reasons["core1"] != "lldp" {
t.Errorf("uplink reasons = %v", reasons)
}
if len(h.IPs) != 2 {
t.Errorf("ips = %+v", h.IPs)
}
}
func TestSearchAcceptsEveryMACFormatAndPrefixAndRefusesGarbage(t *testing.T) {
e := newEnv(t, 0)
seedL2Net(t, e)
for _, q := range []string{"f09f.c211.2233", "F0-9F-C2-11-22-33", "f09fc2112233", "f0:9f:c2", "f09fc2", "F0:9F:C2:11:2"} {
if got := searchL2(t, e, q); got.Total != 1 || got.Items[0].MAC != host {
t.Errorf("q=%q: %+v", q, got)
}
}
for _, q := range []string{"hello", "f09f", "zz:zz:zz:zz:zz:zz", "1.2.3", "10.0.0.0/99", "vendor:"} {
rec := e.do("GET", "/api/v1/l2/search?q="+url.QueryEscape(q), "")
isProblem(t, rec, 422)
}
}
func TestIPv6AndIPv4AndCIDRSearchFindTheMAC(t *testing.T) {
e := newEnv(t, 0)
seedL2Net(t, e)
for q, kind := range map[string]string{"2001:db8::77": "ip", "192.0.2.77": "ip", "192.0.2.0/24": "cidr", "2001:db8::/32": "cidr"} {
got := searchL2(t, e, q)
if got.Kind != kind || got.Total != 1 || got.Items[0].MAC != host {
t.Errorf("q=%q: %+v", q, got)
}
}
if got := searchL2(t, e, "198.51.100.0/24"); got.Total != 0 || len(got.Items) != 0 {
t.Errorf("a CIDR with no entries matched: %+v", got)
}
}
func TestVendorSearchListsCurrentMACsAndWorksEmptyWithoutARegistry(t *testing.T) {
e := newEnvDeps(t, 0, func(d *api.Deps) { d.Vendors = stubVendors{} })
seedL2Net(t, e)
got := searchL2(t, e, "vendor:ubiquiti")
if got.Kind != "vendor" || !got.VendorsAvailable || got.Total != 1 || got.Items[0].MAC != host {
t.Fatalf("vendor search = %+v", got)
}
var vlan20 api.L2SearchResult
e.getJSON(t, "/api/v1/l2/search?q=vendor:ubiquiti&vlan=30", &vlan20)
if vlan20.Total != 0 {
t.Errorf("a vendor search restricted to another VLAN matched: %+v", vlan20)
}
none := newEnv(t, 0)
seedL2Net(t, none)
got = searchL2(t, none, "vendor:ubiquiti")
if got.VendorsAvailable || got.Total != 0 || len(got.Items) != 0 {
t.Errorf("without a registry the vendor search must find nothing, not fail: %+v", got)
}
// The same lookup by MAC still works, with an empty vendor.
if h := searchL2(t, none, host).Items[0]; h.Vendor != "" || h.MACKind != "global" || len(h.Locations) != 3 {
t.Errorf("hit without a registry = %+v", h)
}
}
func TestLocallyAdministeredMACsAreFlaggedPrivate(t *testing.T) {
e := newEnvDeps(t, 0, func(d *api.Deps) { d.Vendors = stubVendors{} })
seedL2Net(t, e)
h := searchL2(t, e, "02:00:00:00:00:01").Items[0]
if h.MACKind != "local" || h.Vendor != "Private / randomised" {
t.Errorf("hit = %+v", h)
}
}
func TestDeviceTablesFilterByVLANInterfaceAndCurrent(t *testing.T) {
e := newEnv(t, 0)
seedL2Net(t, e)
devs, _, _ := e.st.ListDevices(context.Background(), store.DeviceFilter{}, store.Page{})
var dist, core *store.Device
for i := range devs {
switch devs[i].Name {
case "dist1":
dist = &devs[i]
case "core1":
core = &devs[i]
}
}
var fdb api.L2FDBList
e.getJSON(t, "/api/v1/devices/"+dist.Slug+"/fdb", &fdb)
if fdb.Total != 10 || len(fdb.Items) != 10 || !fdb.Items[0].Current {
t.Fatalf("fdb = %+v", fdb)
}
e.getJSON(t, "/api/v1/devices/"+dist.Slug+"/fdb?vlan=30", &fdb)
if fdb.Total != 0 {
t.Errorf("vlan filter: %+v", fdb)
}
e.getJSON(t, "/api/v1/devices/"+dist.Slug+"/fdb?mac=f09fc2", &fdb)
if fdb.Total != 1 || fdb.Items[0].MAC != host {
t.Errorf("mac prefix filter: %+v", fdb)
}
isProblem(t, e.do("GET", "/api/v1/devices/"+dist.Slug+"/fdb?mac=zz", ""), 422)
var arp api.L2ARPList
e.getJSON(t, "/api/v1/devices/"+core.Slug+"/arp", &arp)
if arp.Total != 2 {
t.Fatalf("arp = %+v", arp)
}
e.getJSON(t, "/api/v1/devices/"+core.Slug+"/arp?ip=2001:db8::/32", &arp)
if arp.Total != 1 || arp.Items[0].IP != "2001:db8::77" {
t.Errorf("arp ip filter: %+v", arp)
}
// History: a later walk that no longer sees the host leaves the row, no longer current.
if err := e.st.RecordL2Walk(context.Background(), core.ID, store.L2WalkResult{At: e.clk.Now().Add(time.Hour)}); err != nil {
t.Fatal(err)
}
e.getJSON(t, "/api/v1/devices/"+core.Slug+"/fdb", &fdb)
if fdb.Total != 0 {
t.Errorf("current=true must hide rows the last walk missed: %+v", fdb)
}
e.getJSON(t, "/api/v1/devices/"+core.Slug+"/fdb?current=false", &fdb)
if fdb.Total != 1 || fdb.Items[0].Current {
t.Errorf("history: %+v", fdb)
}
isProblem(t, e.do("GET", "/api/v1/devices/nope/fdb", ""), 404)
}
func TestInterfaceMACsWithTheirIPsAndPortsSummary(t *testing.T) {
e := newEnv(t, 0)
seedL2Net(t, e)
ifs, _, _ := e.st.SearchInterfaces(context.Background(), "Eth3", false, store.Page{})
var macs api.L2FDBList
e.getJSON(t, "/api/v1/interfaces/"+ifs[0].ID+"/macs", &macs)
if macs.Total != 1 || macs.Items[0].MAC != host || len(macs.Items[0].IPs) != 2 {
t.Fatalf("macs = %+v", macs)
}
isProblem(t, e.do("GET", "/api/v1/interfaces/01ARZ3NDEKTSV4RRFFQ69G5FAV/macs", ""), 404)
devs, _, _ := e.st.ListDevices(context.Background(), store.DeviceFilter{}, store.Page{})
for _, d := range devs {
var sum api.L2Summary
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/l2", &sum)
if !sum.HasData || sum.LastWalk == nil || sum.Source != "qbridge" || len(sum.VLANs) != 1 || sum.EdgeMaxMACs != 8 {
t.Errorf("%s summary = %+v", d.Name, sum)
}
var ports api.L2PortList
e.getJSON(t, "/api/v1/devices/"+d.Slug+"/l2/ports", &ports)
if len(ports.Items) != 1 {
t.Fatalf("%s ports = %+v", d.Name, ports)
}
p := ports.Items[0]
wantUp := map[string]string{"access1": "", "dist1": "macs", "core1": "lldp"}[d.Name]
if p.UplinkReason != wantUp || p.Uplink != (wantUp != "") {
t.Errorf("%s port = %+v", d.Name, p)
}
}
other := e.device(t, "plain", "yyz", "server")
var sum api.L2Summary
e.getJSON(t, "/api/v1/devices/"+other.Slug+"/l2", &sum)
if sum.HasData || sum.LastWalk != nil || sum.VLANs == nil {
t.Errorf("a device the job has not walked: %+v", sum)
}
}
func TestEdgeLimitComesFromTheLiveSetting(t *testing.T) {
limit := 8
e := newEnvDeps(t, 0, func(d *api.Deps) { d.L2EdgeMax = func() int { return limit } })
seedL2Net(t, e)
if h := searchL2(t, e, host).Items[0]; h.Locations[1].UplinkReason == "" || h.Locations[0].Device.Name != "access1" {
t.Fatalf("baseline = %+v", h.Locations)
}
limit = 20 // the distribution port now counts as an edge port: it carries 10 MACs
h := searchL2(t, e, host).Items[0]
edge := 0
for _, l := range h.Locations {
if l.Edge {
edge++
}
}
if edge != 2 || h.Locations[0].Device.Name != "access1" {
t.Errorf("with a limit of 20 the 10-MAC port is an edge port, ranked after the 1-MAC one: %+v", h.Locations)
}
}
func TestPollerHealthCarriesL2StatusAndNotes(t *testing.T) {
e := newEnvDeps(t, 0, func(d *api.Deps) {
d.L2Health = func() *api.L2Health { return &api.L2Health{OK: 3, TimedOut: 1, Skipped: []string{"slow1"}} }
d.HealthNotes = func() []api.HealthNote {
return []api.HealthNote{{Code: "oui_unavailable", Message: "no IEEE vendor registry is loaded"}}
}
})
var h api.PollerHealth
e.getJSON(t, "/api/v1/poller/health", &h)
if h.L2 == nil || h.L2.TimedOut != 1 || len(h.Notes) != 1 || h.Notes[0].Code != "oui_unavailable" {
t.Errorf("health = %+v", h)
}
plain := newEnv(t, 0)
h = api.PollerHealth{}
plain.getJSON(t, "/api/v1/poller/health", &h)
if h.L2 != nil || h.Notes == nil || len(h.Notes) != 0 {
t.Errorf("a server without the L2 job: %+v", h)
}
}