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
117 lines
4.8 KiB
Go
117 lines
4.8 KiB
Go
package api_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/v10networks/nmon/internal/api"
|
|
"github.com/v10networks/nmon/internal/store"
|
|
)
|
|
|
|
// bgpRouter seeds an Arista with an IPv4 and an IPv6 peer (the latter down) and the readings the hot store would hold.
|
|
func bgpRouter(t *testing.T, e *env, name, profileName string) *store.Device {
|
|
t.Helper()
|
|
d := e.device(t, name, "yyz", "router", func(d *store.Device) { d.Profile = profileName })
|
|
table := "ARISTA-BGP4V2-MIB::aristaBgp4V2PeerTable"
|
|
err := e.st.ReplaceComponents(context.Background(), d.ID, table, []store.Component{
|
|
{Idx: "1.1.4.192.0.2.1", Label: "192.0.2.1", Attrs: `{"remote_as":"65001","local_as":"65000","description":"transit-a","router_id":"198.51.100.1"}`},
|
|
{Idx: "1.2.16.32.1.13.184.0.0.0.0.0.0.0.0.0.0.0.2", Label: "2001:db8::2", Attrs: `{"remote_as":"65002","local_as":"65000"}`},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return d
|
|
}
|
|
|
|
func bgpBody(dev string) func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
|
|
return func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
|
|
if strings.Contains(expr, "rate(") {
|
|
return vector(
|
|
vrow{lbl(dev, "bgp_peer_in_updates", "192.0.2.1"), 0.25}, vrow{lbl(dev, "bgp_peer_out_updates", "192.0.2.1"), 0.5},
|
|
), nil
|
|
}
|
|
var rows []vrow
|
|
rows = append(rows, rd(dev, "bgp_peer_state", "192.0.2.1", 6)...)
|
|
rows = append(rows, rd(dev, "bgp_peer_state", "2001:db8::2", 3)...) // active
|
|
rows = append(rows, rd(dev, "bgp_peer_uptime", "192.0.2.1", 86400)...)
|
|
rows = append(rows, rd(dev, "bgp_prefixes_accepted", "192.0.2.1|ipv4-unicast", 540000)...)
|
|
rows = append(rows, rd(dev, "bgp_prefixes_accepted", "192.0.2.1|ipv6-unicast", 12)...)
|
|
return vector(rows...), nil
|
|
}
|
|
}
|
|
|
|
func TestBGPPeersListsSessionsDownFirstWithAttributesFromTheInventory(t *testing.T) {
|
|
e := newEnvDeps(t, 0, withShipped(t))
|
|
d := bgpRouter(t, e, "edge1", "arista-eos")
|
|
e.device(t, "plain-switch", "yyz", "access") // generic: no BGP, never listed
|
|
e.q.fn = bgpBody(d.Slug)
|
|
|
|
var got api.BGPPeerList
|
|
e.getJSON(t, "/api/v1/bgp/peers", &got)
|
|
if got.Total != 2 || len(got.Items) != 2 || got.Counts.Total != 2 || got.Counts.Established != 1 || got.Counts.Down != 1 {
|
|
t.Fatalf("list = %+v", got)
|
|
}
|
|
down, up := got.Items[0], got.Items[1]
|
|
if down.Peer != "2001:db8::2" || down.Family != "ipv6" || down.State != "active" || down.StateClass != "crit" || down.Established {
|
|
t.Errorf("the down IPv6 session must come first: %+v", down)
|
|
}
|
|
if *down.RemoteAS != 65002 || *down.LocalAS != 65000 || down.Description != "" {
|
|
t.Errorf("down attrs = %+v", down)
|
|
}
|
|
if up.Component != "192.0.2.1" {
|
|
t.Errorf("component = %q", up.Component)
|
|
}
|
|
if up.Peer != "192.0.2.1" || up.State != "established" || up.StateClass != "ok" || !up.Established || up.Description != "transit-a" ||
|
|
up.RouterID != "198.51.100.1" || *up.RemoteAS != 65001 || *up.UptimeS != 86400 {
|
|
t.Errorf("up = %+v", up)
|
|
}
|
|
if len(up.Prefixes) != 2 || up.Prefixes[0].Family != "ipv4-unicast" || *up.Prefixes[0].Accepted != 540000 || up.Prefixes[1].Family != "ipv6-unicast" {
|
|
t.Errorf("prefixes = %+v", up.Prefixes)
|
|
}
|
|
if up.InUpdatesPerS == nil || *up.InUpdatesPerS != 0.25 || *up.OutUpdatesPerS != 0.5 || down.InUpdatesPerS != nil {
|
|
t.Errorf("update rates = %v %v / %v", up.InUpdatesPerS, up.OutUpdatesPerS, down.InUpdatesPerS)
|
|
}
|
|
if up.Device.Slug != d.Slug || up.Sampled == nil {
|
|
t.Errorf("device/sampled = %+v", up)
|
|
}
|
|
}
|
|
|
|
func TestBGPPeersFiltersByStateASFamilyDeviceAndText(t *testing.T) {
|
|
e := newEnvDeps(t, 0, withShipped(t))
|
|
d := bgpRouter(t, e, "edge1", "arista-eos")
|
|
e.q.fn = bgpBody(d.Slug)
|
|
for path, want := range map[string]int64{
|
|
"?state=down": 1, "?state=established": 1, "?state=active": 1, "?state=idle": 0, "?as=65001": 1, "?as=1": 0,
|
|
"?family=ipv6": 1, "?family=ipv4": 1, "?device=" + d.Slug: 2, "?device=nope": 0, "?q=transit": 1, "?q=EDGE1": 2, "?q=2001:db8": 1,
|
|
} {
|
|
var got api.BGPPeerList
|
|
e.getJSON(t, "/api/v1/bgp/peers"+path, &got)
|
|
if got.Total != want {
|
|
t.Errorf("%s: total %d, want %d", path, got.Total, want)
|
|
}
|
|
}
|
|
var got api.BGPPeerList
|
|
e.getJSON(t, "/api/v1/bgp/peers?state=down", &got)
|
|
if got.Counts.Total != 2 {
|
|
t.Errorf("counts must ignore the state filter: %+v", got.Counts)
|
|
}
|
|
}
|
|
|
|
func TestBGPPeersWithNoReadingIsUnknownAndCountsAsDown(t *testing.T) {
|
|
e := newEnvDeps(t, 0, withShipped(t))
|
|
bgpRouter(t, e, "edge1", "juniper-junos") // profile with a different table key: no components match
|
|
var got api.BGPPeerList
|
|
e.getJSON(t, "/api/v1/bgp/peers", &got)
|
|
if got.Total != 0 || got.Items == nil {
|
|
t.Errorf("a router whose rows are in another table lists nothing: %+v", got)
|
|
}
|
|
e2 := newEnvDeps(t, 0, withShipped(t))
|
|
bgpRouter(t, e2, "edge2", "arista-eos") // store has no readings at all
|
|
e2.getJSON(t, "/api/v1/bgp/peers", &got)
|
|
if got.Total != 2 || got.Items[0].State != "unknown" || got.Counts.Down != 2 {
|
|
t.Errorf("no readings: %+v", got)
|
|
}
|
|
}
|