Files
nmon/internal/simagent/agentfile.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

150 lines
4.4 KiB
Go

package simagent
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmprec"
)
// AgentFile is the sidecar `<name>.agent.yaml` that says how a fixture's
// recording is served.
//
// recording: renumber.a # file stem in the same directory; default: the sidecar's own stem
// community: public
// version: v2c # v1, v2c or v3
// v3: {user: nmon, auth_proto: SHA256, auth_pass: ..., priv_proto: AES, priv_pass: ...}
// latency: 3s
// drop_every: 3
// toobig_above: 20
// contexts: {"10": l2.vlan10} # community public@10 / v3 context vlan-10 serve recording l2.vlan10
type AgentFile struct {
Recording string `yaml:"recording,omitempty"`
Community string `yaml:"community,omitempty"`
Version string `yaml:"version,omitempty"`
V3 *V3Config `yaml:"v3,omitempty"`
Latency string `yaml:"latency,omitempty"`
DropEvery int `yaml:"drop_every,omitempty"`
TooBigAbove int `yaml:"toobig_above,omitempty"`
// Contexts maps a context name (a VLAN) to the stem of the recording served under it.
Contexts map[string]string `yaml:"contexts,omitempty"`
// Dir and Stem say where the sidecar was read from.
Dir string `yaml:"-"`
Stem string `yaml:"-"`
}
// V3Config is a USM user in a sidecar.
type V3Config struct {
User string `yaml:"user,omitempty"`
AuthProto string `yaml:"auth_proto,omitempty"`
AuthPass string `yaml:"auth_pass,omitempty"`
PrivProto string `yaml:"priv_proto,omitempty"`
PrivPass string `yaml:"priv_pass,omitempty"`
}
// LoadAgentFile reads path. A missing file yields the defaults for the stem
// (v2c, public, no faults), with ok false.
func LoadAgentFile(path string) (af *AgentFile, ok bool, err error) {
af = &AgentFile{Dir: filepath.Dir(path)}
af.Stem = strings.TrimSuffix(filepath.Base(path), ".agent.yaml")
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return af.defaults(), false, nil
}
if err != nil {
return nil, false, err
}
dec := yaml.NewDecoder(strings.NewReader(string(b)))
dec.KnownFields(true)
if err := dec.Decode(af); err != nil {
return nil, false, fmt.Errorf("%s: %w", path, err)
}
return af.defaults(), true, nil
}
func (a *AgentFile) defaults() *AgentFile {
if a.Recording == "" {
a.Recording = a.Stem
}
if a.Community == "" {
a.Community = "public"
}
if a.Version == "" {
a.Version = "v2c"
if a.V3 != nil {
a.Version = "v3"
}
}
return a
}
// RecordingPath is the .snmprec the sidecar serves.
func (a *AgentFile) RecordingPath() string { return filepath.Join(a.Dir, a.Recording+".snmprec") }
// Config builds the agent configuration; Addr is left for the caller.
func (a *AgentFile) Config() (Config, error) {
rec, err := snmprec.Load(a.RecordingPath())
if err != nil {
return Config{}, err
}
return a.ConfigFor(rec)
}
// ConfigFor is Config with an explicit recording.
func (a *AgentFile) ConfigFor(rec *snmprec.Recording) (Config, error) {
cfg := Config{Recording: rec, Community: a.Community, Behaviour: Behaviour{DropEvery: a.DropEvery, TooBigAbove: a.TooBigAbove}}
for name, stem := range a.Contexts {
crec, err := snmprec.Load(filepath.Join(a.Dir, stem+".snmprec"))
if err != nil {
return Config{}, fmt.Errorf("context %s: %w", name, err)
}
if cfg.Contexts == nil {
cfg.Contexts = map[string]*snmprec.Recording{}
}
cfg.Contexts[name] = crec
}
if a.Latency != "" {
d, err := time.ParseDuration(a.Latency)
if err != nil {
return Config{}, fmt.Errorf("latency: %w", err)
}
cfg.Behaviour.Latency = d
}
if a.V3 != nil {
ap, err := snmp.ParseAuthProto(orStr(a.V3.AuthProto, "SHA256"))
if err != nil {
return Config{}, err
}
pp, err := snmp.ParsePrivProto(orStr(a.V3.PrivProto, "AES"))
if err != nil {
return Config{}, err
}
cfg.V3 = []V3User{{Name: a.V3.User, AuthProto: ap, AuthPass: a.V3.AuthPass, PrivProto: pp, PrivPass: a.V3.PrivPass}}
}
return cfg, nil
}
// Credential is what a client needs to talk to the agent.
func (a *AgentFile) Credential() snmp.Credential {
c := snmp.Credential{Version: strings.TrimPrefix(a.Version, "v"), Community: a.Community}
if a.V3 != nil {
c.V3User, c.V3AuthProto, c.V3AuthPass = a.V3.User, orStr(a.V3.AuthProto, "SHA256"), a.V3.AuthPass
c.V3PrivProto, c.V3PrivPass = orStr(a.V3.PrivProto, "AES"), a.V3.PrivPass
}
return c
}
func orStr(s, d string) string {
if s == "" {
return d
}
return s
}