From 9458dadc9077379c1ca83662c88514d0420310d1 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 19:18:37 -0500 Subject: [PATCH] feat(18-01): add gosnmp dependency, SNMP types, and client builder - Install gosnmp v1.43.2 as direct dependency - Create snmp package with SNMPConfig, CompiledProfile, PollGroup types - Implement BuildSNMPClient for v1, v2c, and v3 (all security levels) - Map auth protocols (MD5, SHA, SHA224-512) and priv protocols (DES, AES128-256) - MaxRepetitions set to 10 (not gosnmp default 50) for embedded devices - Full test coverage: 9 tests covering all SNMP versions and protocol mappings Co-Authored-By: Claude Opus 4.6 (1M context) --- poller/go.mod | 1 + poller/go.sum | 2 + poller/internal/snmp/client.go | 102 ++++++++++++++ poller/internal/snmp/client_test.go | 200 ++++++++++++++++++++++++++++ poller/internal/snmp/types.go | 67 ++++++++++ 5 files changed, 372 insertions(+) create mode 100644 poller/internal/snmp/client.go create mode 100644 poller/internal/snmp/client_test.go create mode 100644 poller/internal/snmp/types.go diff --git a/poller/go.mod b/poller/go.mod index ddc8adf..9396ffc 100644 --- a/poller/go.mod +++ b/poller/go.mod @@ -7,6 +7,7 @@ require ( github.com/bsm/redislock v0.9.4 github.com/go-routeros/routeros/v3 v3.0.0 github.com/google/uuid v1.6.0 + github.com/gosnmp/gosnmp v1.43.2 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jackc/pgx/v5 v5.7.4 github.com/nats-io/nats-server/v2 v2.12.5 diff --git a/poller/go.sum b/poller/go.sum index 628d351..c565553 100644 --- a/poller/go.sum +++ b/poller/go.sum @@ -67,6 +67,8 @@ github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gosnmp/gosnmp v1.43.2 h1:F9loz6uMCNtIQj0RNO5wz/mZ+FZt2WyNKJYOvw+Zosw= +github.com/gosnmp/gosnmp v1.43.2/go.mod h1:smHIwoaqr1M+HTAEd7+mKkPs8lp3Lf/U+htPUql1Q3c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= diff --git a/poller/internal/snmp/client.go b/poller/internal/snmp/client.go new file mode 100644 index 0000000..aa7d821 --- /dev/null +++ b/poller/internal/snmp/client.go @@ -0,0 +1,102 @@ +package snmp + +import ( + "fmt" + + "github.com/gosnmp/gosnmp" + + "github.com/staack/the-other-dude/poller/internal/store" + "github.com/staack/the-other-dude/poller/internal/vault" +) + +// BuildSNMPClient constructs a gosnmp.GoSNMP struct configured for the given +// device and credential. It does NOT call Connect — the caller is responsible +// for establishing the UDP session and closing it when done. +// +// This supports SNMP v1, v2c, and all three v3 security levels. +func BuildSNMPClient(dev store.Device, cred *vault.SNMPCredential, cfg SNMPConfig) (*gosnmp.GoSNMP, error) { + g := &gosnmp.GoSNMP{ + Target: dev.IPAddress, + Port: uint16(dev.SNMPPort), + Timeout: cfg.Timeout, + Retries: cfg.Retries, + MaxRepetitions: cfg.MaxRepetitions, + } + + switch cred.Version { + case "v1": + g.Version = gosnmp.Version1 + g.Community = cred.Community + + case "v2c": + g.Version = gosnmp.Version2c + g.Community = cred.Community + + case "v3": + g.Version = gosnmp.Version3 + g.SecurityModel = gosnmp.UserSecurityModel + g.MsgFlags = mapSecurityLevel(cred.SecurityLevel) + g.SecurityParameters = &gosnmp.UsmSecurityParameters{ + UserName: cred.Username, + AuthenticationProtocol: mapAuthProto(cred.AuthProtocol), + AuthenticationPassphrase: cred.AuthPass, + PrivacyProtocol: mapPrivProto(cred.PrivProtocol), + PrivacyPassphrase: cred.PrivPass, + } + + default: + return nil, fmt.Errorf("unsupported SNMP version: %q", cred.Version) + } + + return g, nil +} + +// mapSecurityLevel maps credential security level strings to gosnmp v3 message flags. +func mapSecurityLevel(level string) gosnmp.SnmpV3MsgFlags { + switch level { + case "auth_priv": + return gosnmp.AuthPriv + case "auth_no_priv": + return gosnmp.AuthNoPriv + case "no_auth_no_priv": + return gosnmp.NoAuthNoPriv + default: + return gosnmp.NoAuthNoPriv + } +} + +// mapAuthProto maps credential auth protocol strings to gosnmp v3 auth protocol constants. +func mapAuthProto(proto string) gosnmp.SnmpV3AuthProtocol { + switch proto { + case "MD5": + return gosnmp.MD5 + case "SHA": + return gosnmp.SHA + case "SHA224": + return gosnmp.SHA224 + case "SHA256": + return gosnmp.SHA256 + case "SHA384": + return gosnmp.SHA384 + case "SHA512": + return gosnmp.SHA512 + default: + return gosnmp.NoAuth + } +} + +// mapPrivProto maps credential privacy protocol strings to gosnmp v3 privacy protocol constants. +func mapPrivProto(proto string) gosnmp.SnmpV3PrivProtocol { + switch proto { + case "DES": + return gosnmp.DES + case "AES128": + return gosnmp.AES + case "AES192": + return gosnmp.AES192 + case "AES256": + return gosnmp.AES256 + default: + return gosnmp.NoPriv + } +} diff --git a/poller/internal/snmp/client_test.go b/poller/internal/snmp/client_test.go new file mode 100644 index 0000000..1382ea0 --- /dev/null +++ b/poller/internal/snmp/client_test.go @@ -0,0 +1,200 @@ +package snmp + +import ( + "testing" + + "github.com/gosnmp/gosnmp" + "github.com/staack/the-other-dude/poller/internal/store" + "github.com/staack/the-other-dude/poller/internal/vault" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSNMPClient_V2c(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.1", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v2c", + Community: "test-community", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, gosnmp.Version2c, g.Version) + assert.Equal(t, "test-community", g.Community) + assert.Equal(t, "10.0.0.1", g.Target) + assert.Equal(t, uint16(161), g.Port) +} + +func TestBuildSNMPClient_V1(t *testing.T) { + dev := store.Device{ + IPAddress: "192.168.1.1", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v1", + Community: "public", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, gosnmp.Version1, g.Version) + assert.Equal(t, "public", g.Community) +} + +func TestBuildSNMPClient_V3_AuthPriv(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.2", + SNMPPort: 1161, + } + cred := &vault.SNMPCredential{ + Version: "v3", + SecurityLevel: "auth_priv", + Username: "admin", + AuthProtocol: "SHA256", + AuthPass: "authpass123", + PrivProtocol: "AES128", + PrivPass: "privpass456", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, gosnmp.Version3, g.Version) + assert.Equal(t, gosnmp.UserSecurityModel, g.SecurityModel) + assert.Equal(t, gosnmp.AuthPriv, g.MsgFlags) + assert.Equal(t, uint16(1161), g.Port) + + usp, ok := g.SecurityParameters.(*gosnmp.UsmSecurityParameters) + require.True(t, ok, "SecurityParameters should be *UsmSecurityParameters") + assert.Equal(t, "admin", usp.UserName) + assert.Equal(t, gosnmp.SHA256, usp.AuthenticationProtocol) + assert.Equal(t, "authpass123", usp.AuthenticationPassphrase) + assert.Equal(t, gosnmp.AES, usp.PrivacyProtocol) + assert.Equal(t, "privpass456", usp.PrivacyPassphrase) +} + +func TestBuildSNMPClient_V3_AuthNoPriv(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.3", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v3", + SecurityLevel: "auth_no_priv", + Username: "monitor", + AuthProtocol: "SHA", + AuthPass: "authonly", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, gosnmp.Version3, g.Version) + assert.Equal(t, gosnmp.AuthNoPriv, g.MsgFlags) + + usp := g.SecurityParameters.(*gosnmp.UsmSecurityParameters) + assert.Equal(t, "monitor", usp.UserName) + assert.Equal(t, gosnmp.SHA, usp.AuthenticationProtocol) +} + +func TestBuildSNMPClient_V3_NoAuthNoPriv(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.4", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v3", + SecurityLevel: "no_auth_no_priv", + Username: "readonly", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, gosnmp.Version3, g.Version) + assert.Equal(t, gosnmp.NoAuthNoPriv, g.MsgFlags) + + usp := g.SecurityParameters.(*gosnmp.UsmSecurityParameters) + assert.Equal(t, "readonly", usp.UserName) +} + +func TestMapAuthProto(t *testing.T) { + tests := []struct { + input string + expected gosnmp.SnmpV3AuthProtocol + }{ + {"MD5", gosnmp.MD5}, + {"SHA", gosnmp.SHA}, + {"SHA224", gosnmp.SHA224}, + {"SHA256", gosnmp.SHA256}, + {"SHA384", gosnmp.SHA384}, + {"SHA512", gosnmp.SHA512}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, mapAuthProto(tt.input)) + }) + } +} + +func TestMapPrivProto(t *testing.T) { + tests := []struct { + input string + expected gosnmp.SnmpV3PrivProtocol + }{ + {"DES", gosnmp.DES}, + {"AES128", gosnmp.AES}, + {"AES192", gosnmp.AES192}, + {"AES256", gosnmp.AES256}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + assert.Equal(t, tt.expected, mapPrivProto(tt.input)) + }) + } +} + +func TestBuildSNMPClient_MaxRepetitions(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.1", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v2c", + Community: "public", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, uint32(10), g.MaxRepetitions, "MaxRepetitions must be 10, not gosnmp default 50") +} + +func TestBuildSNMPClient_Timeout(t *testing.T) { + dev := store.Device{ + IPAddress: "10.0.0.1", + SNMPPort: 161, + } + cred := &vault.SNMPCredential{ + Version: "v2c", + Community: "public", + } + cfg := DefaultSNMPConfig() + + g, err := BuildSNMPClient(dev, cred, cfg) + require.NoError(t, err) + + assert.Equal(t, cfg.Timeout, g.Timeout, "Timeout should come from config") + assert.Equal(t, cfg.Retries, g.Retries, "Retries should come from config") +} diff --git a/poller/internal/snmp/types.go b/poller/internal/snmp/types.go new file mode 100644 index 0000000..836ebff --- /dev/null +++ b/poller/internal/snmp/types.go @@ -0,0 +1,67 @@ +// Package snmp provides SNMP collection primitives for the poller. +package snmp + +import "time" + +// SNMPConfig holds SNMP client configuration defaults. +type SNMPConfig struct { + Timeout time.Duration + Retries int + MaxRepetitions uint32 + ConnTimeout time.Duration + CmdTimeout time.Duration +} + +// DefaultSNMPConfig returns sensible SNMP defaults. +func DefaultSNMPConfig() SNMPConfig { + return SNMPConfig{ + Timeout: 5 * time.Second, + Retries: 1, + MaxRepetitions: 10, + ConnTimeout: 5 * time.Second, + CmdTimeout: 10 * time.Second, + } +} + +// CompiledProfile is an in-memory representation of an snmp_profiles row +// with its JSONB profile_data parsed into typed Go structs. +type CompiledProfile struct { + ID string + Name string + PollGroups map[string]*PollGroup +} + +// PollGroup is a named collection of OIDs polled at a specific interval multiplier. +type PollGroup struct { + IntervalMultiplier int + Scalars []ScalarOID + Tables []TableOID +} + +// ScalarOID describes a single scalar SNMP object to poll. +type ScalarOID struct { + OID string + Name string + Type string + MapTo string + Transform string + FallbackFor string +} + +// TableOID describes an SNMP table to walk. +type TableOID struct { + OID string + Name string + IndexOID string + Columns []ColumnOID + MapTo string + PreferOver string + Filter map[string][]string +} + +// ColumnOID describes a single column within a table walk. +type ColumnOID struct { + OID string + Name string + Type string +}