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
228 lines
7.0 KiB
Go
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...) }
|