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) <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/bsm/redislock v0.9.4
|
github.com/bsm/redislock v0.9.4
|
||||||
github.com/go-routeros/routeros/v3 v3.0.0
|
github.com/go-routeros/routeros/v3 v3.0.0
|
||||||
github.com/google/uuid v1.6.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/hashicorp/golang-lru/v2 v2.0.7
|
||||||
github.com/jackc/pgx/v5 v5.7.4
|
github.com/jackc/pgx/v5 v5.7.4
|
||||||
github.com/nats-io/nats-server/v2 v2.12.5
|
github.com/nats-io/nats-server/v2 v2.12.5
|
||||||
|
|||||||
@@ -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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
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=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
|||||||
102
poller/internal/snmp/client.go
Normal file
102
poller/internal/snmp/client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
200
poller/internal/snmp/client_test.go
Normal file
200
poller/internal/snmp/client_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
67
poller/internal/snmp/types.go
Normal file
67
poller/internal/snmp/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user