Files
nmon/internal/profile/layout.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

228 lines
7.0 KiB
Go

package profile
import (
"errors"
"fmt"
"net/netip"
"strconv"
"strings"
)
// A Layout describes how a table row index encodes a peer, so a component can be labelled by the peer's
// address without a label column (BGP peer tables are indexed by an address, not by a number). It is the
// index_label.layout string: space-separated tokens read left to right over the index sub-identifiers.
//
// skip one sub-identifier, ignored (a routing-instance number)
// skipinet an InetAddressType.length.octets address, ignored
// inet an InetAddressType.length.octets address: the peer, used as the label
// skipaddr an InetAddressType.octets address with no length (the length follows from the type), ignored
// addr the same encoding, used as the label (Junos indexes its peers this way)
// ipv4 four sub-identifiers, an IpAddress index (BGP4-MIB), used as the label
// peerindex one sub-identifier naming the peer through another table (index_label.via), used as the label
// afisafi two sub-identifiers, address family and subsequent family; the label becomes "<peer>|<afi>-<safi>"
//
// A row whose index does not fit the layout has no label and is not discovered.
type Layout struct {
tokens []string
peer bool // has inet, addr or ipv4
byIdx bool // has peerindex
afi bool
}
var layoutTokens = map[string]bool{
"skip": true, "skipinet": true, "inet": true, "skipaddr": true, "addr": true, "ipv4": true, "peerindex": true, "afisafi": true,
}
// ParseLayout validates and compiles an index_label.layout string.
func ParseLayout(s string) (Layout, error) {
var l Layout
for _, t := range strings.Fields(s) {
if !layoutTokens[t] {
return Layout{}, fmt.Errorf("unknown layout token %q (want one of skip, skipinet, inet, skipaddr, addr, ipv4, peerindex, afisafi)", t)
}
l.tokens = append(l.tokens, t)
switch t {
case "inet", "addr", "ipv4":
if l.peer {
return Layout{}, errors.New("layout names the peer twice")
}
l.peer = true
case "peerindex":
l.byIdx = true
case "afisafi":
l.afi = true
}
}
switch {
case l.peer == l.byIdx:
return Layout{}, errors.New("layout needs exactly one of inet, addr, ipv4 and peerindex")
case l.afi && l.tokens[len(l.tokens)-1] != "afisafi":
return Layout{}, errors.New("afisafi must be the last token")
}
return l, nil
}
// MustParseLayout is ParseLayout for a layout written in code; it panics on a malformed one.
func MustParseLayout(s string) Layout {
l, err := ParseLayout(s)
if err != nil {
panic("profile: " + err.Error())
}
return l
}
// UsesPeerIndex reports whether labels come from another table (index_label.via).
func (l Layout) UsesPeerIndex() bool { return l.byIdx }
// PeerIndexAttr is the component attribute of the via table that holds the peer index a peerindex layout
// looks up.
const PeerIndexAttr = "peer_index"
// Label turns a row index into the component label. lookup maps a peer index to the peer's label for a
// peerindex layout and is unused otherwise.
func (l Layout) Label(idx string, lookup func(peerIndex string) (string, bool)) (string, bool) {
parts := strings.Split(idx, ".")
pos, peer := 0, ""
for _, t := range l.tokens {
var ok bool
switch t {
case "skip":
pos, ok = pos+1, pos < len(parts)
case "skipinet":
_, pos, ok = readInet(parts, pos)
case "inet":
peer, pos, ok = readInet(parts, pos)
case "skipaddr":
_, pos, ok = readAddr(parts, pos)
case "addr":
peer, pos, ok = readAddr(parts, pos)
case "ipv4":
peer, pos, ok = readIPv4(parts, pos)
case "peerindex":
if pos >= len(parts) {
return "", false
}
peer, ok = lookup(parts[pos])
pos++
case "afisafi":
if len(parts)-pos != 2 {
return "", false
}
peer += "|" + afiName(parts[pos]) + "-" + safiName(parts[pos+1])
pos, ok = len(parts), true
}
if !ok {
return "", false
}
}
return peer, pos == len(parts) && peer != ""
}
// readInet decodes InetAddressType.length.octets at parts[pos:] and returns the address and the next position.
func readInet(parts []string, pos int) (string, int, bool) {
if pos+2 > len(parts) {
return "", pos, false
}
typ, err1 := strconv.Atoi(parts[pos])
n, err2 := strconv.Atoi(parts[pos+1])
if err1 != nil || err2 != nil || n < 0 || pos+2+n > len(parts) {
return "", pos, false
}
b, ok := octets(parts[pos+2 : pos+2+n])
if !ok {
return "", pos, false
}
a, ok := inetAddr(typ, b)
return a, pos + 2 + n, ok
}
// addrLen is the octet count of each InetAddressType that has a fixed one: ipv4, ipv6, ipv4z, ipv6z.
var addrLen = map[int]int{1: 4, 2: 16, 3: 8, 4: 20}
// readAddr decodes InetAddressType.octets with no length at parts[pos:]: the type fixes the length.
func readAddr(parts []string, pos int) (string, int, bool) {
if pos >= len(parts) {
return "", pos, false
}
typ, err := strconv.Atoi(parts[pos])
n := addrLen[typ]
if err != nil || n == 0 || pos+1+n > len(parts) {
return "", pos, false
}
b, ok := octets(parts[pos+1 : pos+1+n])
if !ok {
return "", pos, false
}
a, ok := inetAddr(typ, b)
return a, pos + 1 + n, ok
}
// readIPv4 decodes four sub-identifiers as an IPv4 address.
func readIPv4(parts []string, pos int) (string, int, bool) {
if pos+4 > len(parts) {
return "", pos, false
}
b, ok := octets(parts[pos : pos+4])
if !ok {
return "", pos, false
}
return netip.AddrFrom4([4]byte(b)).String(), pos + 4, true
}
// octets converts decimal sub-identifiers to bytes.
func octets(parts []string) ([]byte, bool) {
b := make([]byte, len(parts))
for i, p := range parts {
v, err := strconv.Atoi(p)
if err != nil || v < 0 || v > 255 {
return nil, false
}
b[i] = byte(v)
}
return b, true
}
// inetAddr renders the octets of an InetAddress of the given InetAddressType (RFC 4001): ipv4(1), ipv6(2) and
// their zoned forms ipv4z(3) and ipv6z(4), whose last four octets are the zone and are dropped.
func inetAddr(typ int, b []byte) (string, bool) {
switch {
case (typ == 1 || typ == 3) && len(b) >= 4 && (typ == 1) == (len(b) == 4):
return netip.AddrFrom4([4]byte(b[:4])).String(), true
case (typ == 2 || typ == 4) && len(b) >= 16 && (typ == 2) == (len(b) == 16):
return netip.AddrFrom16([16]byte(b[:16])).String(), true
}
return "", false
}
// IPFromOctets renders a 4- or 16-octet value (an InetAddress or a BGP identifier) as an address; ok is false
// for any other length.
func IPFromOctets(b []byte) (string, bool) {
switch len(b) {
case 4:
return netip.AddrFrom4([4]byte(b)).String(), true
case 16:
return netip.AddrFrom16([16]byte(b)).String(), true
}
return "", false
}
var afiNames = map[string]string{"1": "ipv4", "2": "ipv6", "25": "l2vpn"}
var safiNames = map[string]string{"1": "unicast", "2": "multicast", "4": "labeled", "65": "vpls", "70": "evpn", "128": "vpn"}
func afiName(n string) string {
if s, ok := afiNames[n]; ok {
return s
}
return "afi" + n
}
func safiName(n string) string {
if s, ok := safiNames[n]; ok {
return s
}
return "safi" + n
}
// Tokens returns the layout's tokens in order, for fixture generators that build indexes the layout can read.
func (l Layout) Tokens() []string { return append([]string(nil), l.tokens...) }