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

533 lines
15 KiB
Go

// Package simagent is an SNMP agent that serves snmpsim-format .snmprec
// recordings over UDP (v1, v2c, v3 USM) with dynamic counters and fault
// injection. Tests and the loadtest use it in place of Python snmpsim.
package simagent
import (
"context"
"errors"
"fmt"
"net"
"net/netip"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/snmprec"
)
// Behaviour injects faults.
type Behaviour struct {
Latency time.Duration // delay before every response, slept on the agent's clock
DropEvery int // silently drop every Nth request (0 = never)
TooBigAbove int // GET/GETNEXT with more varbinds than this returns tooBig (0 = no limit)
MaxResponseSize int // bytes; larger responses are tooBig (GET) or truncated (GETBULK). Default 65000
}
// V3User is a USM user. Auth and priv are required; the agent only serves authPriv.
type V3User struct {
Name string
AuthProto gosnmp.SnmpV3AuthProtocol
AuthPass string
PrivProto gosnmp.SnmpV3PrivProtocol
PrivPass string
}
// Config describes one agent.
type Config struct {
Addr string // UDP listen address, default "[::1]:0"
Recording *snmprec.Recording
Community string // v1/v2c community, default "public"
V3 []V3User
// Contexts serve other recordings under the same agent, the way an IOS switch serves one bridge table
// per VLAN: a v1/v2c request with community "<community>@<name>" or a v3 request with context name
// "<name>" is answered from Contexts[name]; a name not listed gets the default recording.
Contexts map[string]*snmprec.Recording
EngineID string // v3 authoritative engine ID, default derived from the bound address
Behaviour Behaviour
Clock clock.Clock // default clock.Real()
}
// Stats counts requests that passed community or USM checks, by PDU type.
type Stats struct {
Get, GetNext, GetBulk uint64
Set uint64 // rejected, counted for completeness
Discovery uint64 // v3 engine-ID discovery probes
Dropped uint64 // dropped by Behaviour.DropEvery
Invalid uint64 // undecodable, wrong community, unknown user or bad key
TooBig uint64 // tooBig responses sent
NotInTimeWindow uint64 // v3 notInTimeWindow reports sent
}
// Agent is a running simulated device.
type Agent struct {
cfg Config
rec *snmprec.Recording
clk clock.Clock
start time.Time
conn *net.UDPConn
addr netip.AddrPort
community string
engineID string
dec *gosnmp.GoSNMP // decodes requests; holds the USM user table
hasV3 bool
maxSize int
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
once sync.Once
reqN atomic.Uint64
salt atomic.Uint64
st struct{ get, next, bulk, set, disc, dropped, invalid, tooBig, notInTime atomic.Uint64 }
}
const (
engineBoots = 1
usmStatsNotInTime = ".1.3.6.1.6.3.15.1.1.2.0"
usmStatsUnknownEngine = ".1.3.6.1.6.3.15.1.1.4.0"
)
// Start binds cfg.Addr and serves until ctx is cancelled or Close is called.
func Start(ctx context.Context, cfg Config) (*Agent, error) {
if cfg.Recording == nil {
return nil, errors.New("simagent: no recording")
}
if cfg.Addr == "" {
cfg.Addr = "[::1]:0"
}
ap, err := netip.ParseAddrPort(cfg.Addr)
if err != nil {
return nil, fmt.Errorf("simagent: %w", err)
}
conn, err := net.ListenUDP("udp", net.UDPAddrFromAddrPort(ap))
if err != nil {
return nil, fmt.Errorf("simagent: %w", err)
}
a := &Agent{
cfg: cfg, rec: cfg.Recording, clk: cfg.Clock, conn: conn,
addr: conn.LocalAddr().(*net.UDPAddr).AddrPort(),
community: cfg.Community, maxSize: cfg.Behaviour.MaxResponseSize,
hasV3: len(cfg.V3) > 0,
}
if a.clk == nil {
a.clk = clock.Real()
}
if a.community == "" {
a.community = "public"
}
if a.maxSize <= 0 {
a.maxSize = 65000
}
a.start = a.clk.Now()
id := "nmon-" + a.addr.String()
if len(id) > 27 {
id = id[:27]
}
a.engineID = "\x80\x00\x1f\x88\x04" + id
if cfg.EngineID != "" {
a.engineID = cfg.EngineID
}
a.dec = &gosnmp.GoSNMP{Version: gosnmp.Version3, SecurityModel: gosnmp.UserSecurityModel}
if a.hasV3 {
if err := a.initUsers(); err != nil {
_ = conn.Close() // best-effort: the init error is the one to report
return nil, err
}
}
a.ctx, a.cancel = context.WithCancel(ctx)
a.wg.Add(1)
go a.serve()
context.AfterFunc(a.ctx, func() { _ = a.Close() }) // idempotent; the caller sees errors from its own Close
return a, nil
}
// Addr returns the bound address.
func (a *Agent) Addr() netip.AddrPort { return a.addr }
// Close stops the agent and waits for in-flight requests.
func (a *Agent) Close() error {
var err error
a.once.Do(func() {
a.cancel()
err = a.conn.Close()
a.wg.Wait()
})
return err
}
// Stats returns a snapshot of the request counters.
func (a *Agent) Stats() Stats {
return Stats{
Get: a.st.get.Load(), GetNext: a.st.next.Load(), GetBulk: a.st.bulk.Load(), Set: a.st.set.Load(),
Discovery: a.st.disc.Load(), Dropped: a.st.dropped.Load(), Invalid: a.st.invalid.Load(),
TooBig: a.st.tooBig.Load(), NotInTimeWindow: a.st.notInTime.Load(),
}
}
// ResetStats zeroes the counters (and the DropEvery sequence).
func (a *Agent) ResetStats() {
for _, c := range []*atomic.Uint64{&a.st.get, &a.st.next, &a.st.bulk, &a.st.set, &a.st.disc,
&a.st.dropped, &a.st.invalid, &a.st.tooBig, &a.st.notInTime, &a.reqN} {
c.Store(0)
}
}
func (a *Agent) serve() {
defer a.wg.Done()
buf := make([]byte, 8192)
for {
n, from, err := a.conn.ReadFromUDPAddrPort(buf)
if err != nil {
if a.ctx.Err() != nil || errors.Is(err, net.ErrClosed) {
return
}
continue
}
if a.cfg.Behaviour.Latency > 0 {
raw := append([]byte(nil), buf[:n]...)
a.wg.Add(1)
go func() {
defer a.wg.Done()
a.handle(raw, from)
}()
continue
}
a.handle(buf[:n], from)
}
}
func (a *Agent) handle(raw []byte, from netip.AddrPort) {
req, err := a.dec.UnmarshalTrap(raw, false)
if err != nil || !a.authorised(req) {
a.st.invalid.Add(1)
return
}
disc := req.Version == gosnmp.Version3 && req.MsgFlags&gosnmp.AuthNoPriv == 0
if !a.count(req, disc) || a.injectFault() {
return
}
out := a.respond(req, disc, a.recordingFor(req))
if out == nil {
return
}
// UDP is best-effort: a failed write looks like a dropped reply to the client, which is what it is.
_, _ = a.conn.WriteToUDPAddrPort(out, from)
}
// authorised applies the community (v1/v2c) or USM-presence (v3) check.
func (a *Agent) authorised(req *gosnmp.SnmpPacket) bool {
switch req.Version {
case gosnmp.Version1, gosnmp.Version2c:
base, _, _ := strings.Cut(req.Community, "@")
return base == a.community
case gosnmp.Version3:
return a.hasV3 && req.SecurityModel == gosnmp.UserSecurityModel
default:
return false
}
}
// recordingFor picks the recording a request addresses: the context's, or the default one.
func (a *Agent) recordingFor(req *gosnmp.SnmpPacket) *snmprec.Recording {
name := ""
switch req.Version {
case gosnmp.Version3:
name = strings.TrimPrefix(req.ContextName, "vlan-")
default:
_, name, _ = strings.Cut(req.Community, "@")
}
if rec, ok := a.cfg.Contexts[name]; ok && name != "" {
return rec
}
return a.rec
}
// count bumps the per-PDU counter; false means the PDU type is not served.
func (a *Agent) count(req *gosnmp.SnmpPacket, disc bool) bool {
switch {
case disc:
a.st.disc.Add(1)
case req.PDUType == gosnmp.GetRequest:
a.st.get.Add(1)
case req.PDUType == gosnmp.GetNextRequest:
a.st.next.Add(1)
case req.PDUType == gosnmp.GetBulkRequest:
a.st.bulk.Add(1)
case req.PDUType == gosnmp.SetRequest:
a.st.set.Add(1)
default:
return false
}
return true
}
// injectFault applies the configured drop and latency; true means no reply is sent.
func (a *Agent) injectFault() bool {
if n := a.cfg.Behaviour.DropEvery; n > 0 && a.reqN.Add(1)%uint64(n) == 0 {
a.st.dropped.Add(1)
return true
}
if d := a.cfg.Behaviour.Latency; d > 0 && a.clk.Sleep(a.ctx, d) != nil {
return true
}
return false
}
// respond builds the marshalled reply, or nil for none.
func (a *Agent) respond(req *gosnmp.SnmpPacket, disc bool, rec *snmprec.Recording) []byte {
elapsed := a.clk.Now().Sub(a.start)
limit := a.maxSize
if req.Version == gosnmp.Version3 {
if report, ok := a.v3Report(req, disc, elapsed); ok {
return report
}
if m := int(req.MsgMaxSize); m > 0 && m < limit {
limit = m
}
}
resp := a.newResponse(req, elapsed)
if out, done := a.refuse(req, resp); done {
return out
}
a.fill(req, resp, elapsed, rec)
return a.fit(req, resp, limit)
}
// v3Report answers discovery, unknown-engine and out-of-window v3 requests with
// a report PDU; ok is false when the request should be served normally.
func (a *Agent) v3Report(req *gosnmp.SnmpPacket, disc bool, elapsed time.Duration) (out []byte, ok bool) {
if disc {
return a.marshal(a.discoveryReport(req, elapsed)), true
}
if req.SecurityParameters.(*gosnmp.UsmSecurityParameters).AuthoritativeEngineID != a.engineID {
return a.marshal(a.discoveryReport(req, elapsed)), true // RFC 3414 3.2.3: unknown engine ID
}
if !a.inTimeWindow(req, elapsed) {
a.st.notInTime.Add(1)
return a.marshal(a.timeReport(req, elapsed)), true
}
return nil, false
}
// refuse covers requests answered without looking at the data: SET is always
// notWritable, v1 has no GetBulk, and oversized GET/GETNEXT is tooBig.
func (a *Agent) refuse(req, resp *gosnmp.SnmpPacket) (out []byte, done bool) {
switch req.PDUType {
case gosnmp.SetRequest:
resp.Variables = req.Variables
resp.Error, resp.ErrorIndex = gosnmp.NotWritable, 1
if req.Version == gosnmp.Version1 {
resp.Error = gosnmp.NoSuchName
}
return a.marshal(resp), true
case gosnmp.GetRequest, gosnmp.GetNextRequest:
if n := a.cfg.Behaviour.TooBigAbove; n > 0 && len(req.Variables) > n {
return a.tooBig(resp), true
}
case gosnmp.GetBulkRequest:
if req.Version == gosnmp.Version1 {
return nil, true
}
}
return nil, false
}
// fit marshals resp, trimming GetBulk varbinds (or answering tooBig) until it
// fits the size limit.
func (a *Agent) fit(req, resp *gosnmp.SnmpPacket, limit int) []byte {
out, err := resp.MarshalMsg()
if err != nil {
return nil
}
for len(out) > limit {
if req.PDUType != gosnmp.GetBulkRequest || len(resp.Variables) < 2 {
return a.tooBig(resp)
}
keep := len(resp.Variables) * limit / len(out)
if keep >= len(resp.Variables) {
keep = len(resp.Variables) - 1
}
resp.Variables = resp.Variables[:keep]
if out, err = resp.MarshalMsg(); err != nil {
return nil
}
}
return out
}
func (a *Agent) marshal(p *gosnmp.SnmpPacket) []byte {
out, err := p.MarshalMsg()
if err != nil {
return nil
}
return out
}
func (a *Agent) tooBig(resp *gosnmp.SnmpPacket) []byte {
a.st.tooBig.Add(1)
resp.Variables, resp.Error, resp.ErrorIndex = nil, gosnmp.TooBig, 0
return a.marshal(resp)
}
// fill computes the response varbinds and error status for req.
func (a *Agent) fill(req, resp *gosnmp.SnmpPacket, elapsed time.Duration, rec *snmprec.Recording) {
v1 := req.Version == gosnmp.Version1
switch req.PDUType {
case gosnmp.GetRequest:
out := make([]gosnmp.SnmpPDU, 0, len(req.Variables))
for i, vb := range req.Variables {
oid := trimDot(vb.Name)
r := exact(rec, oid, v1)
if r == nil {
if v1 {
resp.Variables, resp.Error, resp.ErrorIndex = req.Variables, gosnmp.NoSuchName, uint8(i+1)
return
}
out = append(out, gosnmp.SnmpPDU{Name: oid, Type: missing(rec, oid)})
continue
}
out = append(out, pdu(r, elapsed))
}
resp.Variables = out
case gosnmp.GetNextRequest:
out := make([]gosnmp.SnmpPDU, 0, len(req.Variables))
for i, vb := range req.Variables {
oid := trimDot(vb.Name)
r := next(rec, oid, v1)
if r == nil {
if v1 {
resp.Variables, resp.Error, resp.ErrorIndex = req.Variables, gosnmp.NoSuchName, uint8(i+1)
return
}
out = append(out, gosnmp.SnmpPDU{Name: oid, Type: gosnmp.EndOfMibView})
continue
}
out = append(out, pdu(r, elapsed))
}
resp.Variables = out
case gosnmp.GetBulkRequest:
resp.Variables = a.bulk(req, elapsed, rec)
}
}
func (a *Agent) bulk(req *gosnmp.SnmpPacket, elapsed time.Duration, rec *snmprec.Recording) []gosnmp.SnmpPDU {
vbs := req.Variables
nr := min(int(req.NonRepeaters), len(vbs))
var out []gosnmp.SnmpPDU
for _, vb := range vbs[:nr] {
oid := trimDot(vb.Name)
if r := next(rec, oid, false); r != nil {
out = append(out, pdu(r, elapsed))
} else {
out = append(out, gosnmp.SnmpPDU{Name: oid, Type: gosnmp.EndOfMibView})
}
}
cur := make([]string, 0, len(vbs)-nr)
for _, vb := range vbs[nr:] {
cur = append(cur, trimDot(vb.Name))
}
for rep := uint32(0); rep < req.MaxRepetitions && len(cur) > 0; rep++ {
if len(out) > a.maxSize/8 {
break
}
ended := 0
for i, oid := range cur {
r := next(rec, oid, false)
if r == nil {
out = append(out, gosnmp.SnmpPDU{Name: oid, Type: gosnmp.EndOfMibView})
ended++
continue
}
cur[i] = r.OID
out = append(out, pdu(r, elapsed))
}
if ended == len(cur) {
break
}
}
return out
}
func trimDot(s string) string {
if len(s) > 0 && s[0] == '.' {
return s[1:]
}
return s
}
func pdu(r *snmprec.Record, elapsed time.Duration) gosnmp.SnmpPDU {
return gosnmp.SnmpPDU{Name: r.OID, Type: r.Type, Value: r.At(elapsed)}
}
// visible reports whether r can be sent on the wire version (v1 has no Counter64).
func visible(r *snmprec.Record, v1 bool) bool { return !v1 || r.Type != gosnmp.Counter64 }
func exact(rec *snmprec.Recording, oid string, v1 bool) *snmprec.Record {
recs := rec.Records()
i := rec.Search(oid)
if i < len(recs) && recs[i].OID == oid && visible(&recs[i], v1) {
return &recs[i]
}
return nil
}
func next(rec *snmprec.Recording, oid string, v1 bool) *snmprec.Record {
recs := rec.Records()
i := rec.Search(oid)
if i < len(recs) && recs[i].OID == oid {
i++
}
for ; i < len(recs); i++ {
if visible(&recs[i], v1) {
return &recs[i]
}
}
return nil
}
// missing classifies an absent OID the way net-snmp does: noSuchInstance when
// the object exists but not this instance (a sibling instance or a direct
// child exists), noSuchObject otherwise.
func missing(rec *snmprec.Recording, oid string) gosnmp.Asn1BER {
recs := rec.Records()
i := rec.Search(oid)
arcs := strings.Count(oid, ".") + 1
parent := oid
if j := strings.LastIndexByte(oid, '.'); j >= 0 {
parent = oid[:j]
}
for _, k := range []int{i - 1, i} {
if k < 0 || k >= len(recs) {
continue
}
r := recs[k].OID
n := strings.Count(r, ".") + 1
if snmprec.HasOIDPrefix(r, parent) && n == arcs || snmprec.HasOIDPrefix(r, oid) && n == arcs+1 {
return gosnmp.NoSuchInstance
}
}
return gosnmp.NoSuchObject
}
func (a *Agent) newResponse(req *gosnmp.SnmpPacket, elapsed time.Duration) *gosnmp.SnmpPacket {
p := &gosnmp.SnmpPacket{
Version: req.Version, Community: req.Community,
PDUType: gosnmp.GetResponse, RequestID: req.RequestID,
}
if req.Version == gosnmp.Version3 {
p.MsgID = req.MsgID
p.MsgFlags = req.MsgFlags & gosnmp.AuthPriv
p.SecurityModel = gosnmp.UserSecurityModel
p.ContextEngineID = a.engineID
p.ContextName = req.ContextName
p.SecurityParameters = a.usmFor(req, elapsed, true)
}
return p
}