0
0
Fork 0
mirror of https://github.com/netdata/netdata.git synced 2025-04-26 22:04:46 +00:00

feat(go.d): add snmp devices discovery ()

* init sd snmp

* update config example

* use confopt.Duration for time options

* support more units when parsing duration

* update config comments and skip servers by default

* update default config

* add tests

* cache ttl 12 h
This commit is contained in:
Ilya Mashchenko 2025-03-03 15:07:32 +02:00 committed by GitHub
parent 86b5acbfcf
commit d373112c44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1544 additions and 149 deletions

View file

@ -62,7 +62,7 @@ type config struct {
collectorsDir multipath.MultiPath
collectorsWatchPath []string
serviceDiscoveryDir multipath.MultiPath
stateFile string
varLibDir string
}
func newConfig(opts *cli.Option, env *envConfig) *config {
@ -74,7 +74,7 @@ func newConfig(opts *cli.Option, env *envConfig) *config {
cfg.collectorsDir = cfg.initCollectorsDir(opts)
cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env)
cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir()
cfg.stateFile = cfg.initStateFile(env)
cfg.varLibDir = env.varLibDir
return cfg
}
@ -156,13 +156,6 @@ func (c *config) initCollectorsWatchPaths(opts *cli.Option, env *envConfig) []st
return append(opts.WatchPath, env.watchPath)
}
func (c *config) initStateFile(env *envConfig) string {
if env.varLibDir == "" {
return ""
}
return filepath.Join(env.varLibDir, "god-jobs-statuses.json")
}
func (c *config) mustPluginDir() {
if len(c.pluginDir) == 0 {
panic("plugin config init: plugin dir is empty")

View file

@ -53,7 +53,7 @@ func main() {
CollectorsConfigDir: cfg.collectorsDir,
ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir,
CollectorsConfigWatchPath: cfg.collectorsWatchPath,
StateFile: cfg.stateFile,
VarLibDir: cfg.varLibDir,
RunModule: opts.Module,
MinUpdateEvery: opts.UpdateEvery,
})

View file

@ -44,6 +44,7 @@ require (
github.com/prometheus/prometheus v2.55.1+incompatible
github.com/redis/go-redis/v9 v9.7.1
github.com/sijms/go-ora/v2 v2.8.24
github.com/sourcegraph/conc v0.3.0
github.com/stretchr/testify v1.10.0
github.com/tidwall/gjson v1.18.0
github.com/valyala/fastjson v1.6.4
@ -144,6 +145,7 @@ require (
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect

View file

@ -409,6 +409,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@ -488,6 +490,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=

View file

@ -34,7 +34,7 @@ type Config struct {
CollectorsConfigDir []string
CollectorsConfigWatchPath []string
ServiceDiscoveryConfigDir []string
StateFile string
VarLibDir string
ModuleRegistry module.Registry
RunModule string
MinUpdateEvery int
@ -51,7 +51,7 @@ type Agent struct {
CollectorsConfigWatchPath []string
ServiceDiscoveryConfigDir multipath.MultiPath
StateFile string
VarLibDir string
RunModule string
MinUpdateEvery int
@ -75,13 +75,13 @@ func New(cfg Config) *Agent {
CollectorsConfDir: cfg.CollectorsConfigDir,
ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir,
CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath,
StateFile: cfg.StateFile,
VarLibDir: cfg.VarLibDir,
RunModule: cfg.RunModule,
MinUpdateEvery: cfg.MinUpdateEvery,
ModuleRegistry: module.DefaultRegistry,
Out: safewriter.Stdout,
api: netdataapi.New(safewriter.Stdout),
quitCh: make(chan struct{}),
quitCh: make(chan struct{}, 1),
}
}
@ -194,7 +194,7 @@ func (a *Agent) run(ctx context.Context) {
jobMgr := jobmgr.New()
jobMgr.PluginName = a.Name
jobMgr.Out = a.Out
jobMgr.StateFile = a.StateFile
jobMgr.VarLibDir = a.VarLibDir
jobMgr.Modules = enabledModules
jobMgr.ConfigDefaults = discCfg.Registry
jobMgr.FnReg = fnMgr

View file

@ -119,12 +119,18 @@ func (m *Manager) registerDiscoverers(cfg Config) error {
}
func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) {
done := make(chan struct{})
updates := make(chan []*confgroup.Group)
go d.Run(ctx, updates)
go func() { defer close(done); d.Run(ctx, updates) }()
for {
select {
case <-ctx.Done():
select {
case <-done:
case <-time.After(time.Second * 10):
}
return
case groups, ok := <-updates:
if !ok {

View file

@ -18,7 +18,7 @@ func (g *targetGroup) Source() string { return g.source }
func (g *targetGroup) Targets() []model.Target { return g.targets }
type target struct {
model.Base
model.Base `hash:"ignore"`
hash uint64

View file

@ -20,7 +20,7 @@ func (g *targetGroup) Source() string { return g.source }
func (g *targetGroup) Targets() []model.Target { return g.targets }
type target struct {
model.Base
model.Base `hash:"ignore"`
hash uint64

View file

@ -0,0 +1,210 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"fmt"
"github.com/gosnmp/gosnmp"
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/confopt"
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
)
type (
Config struct {
// RescanInterval defines how often to scan the networks for devices (default: 30m)
RescanInterval confopt.Duration `yaml:"rescan_interval"`
// Timeout defines the maximum time to wait for SNMP device responses (default: 1s)
Timeout confopt.Duration `yaml:"timeout"`
// DeviceCacheTTL defines how long to trust cached discovery results before requiring a new probe (default: 12h)
DeviceCacheTTL confopt.Duration `yaml:"device_cache_ttl"`
// ParallelScansPerNetwork defines how many IPs to scan concurrently within each subnet (default: 32)
ParallelScansPerNetwork int `yaml:"parallel_scans_per_network"`
// Credentials define the SNMP credentials used for authentication
Credentials []CredentialConfig `yaml:"credentials"`
// Networks defines the subnets to scan and which credentials to use
Networks []NetworkConfig `yaml:"networks"`
}
NetworkConfig struct {
// Subnet is the IP range to scan, supporting various formats
// https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
Subnet string `yaml:"subnet"`
// Credential is the name of a credential from the Credentials list
Credential string `yaml:"credential"`
}
CredentialConfig struct {
// Name is the identifier for this credential set, used in Network.Credential
Name string `yaml:"name"`
// Version must be one of: "1", "2c", or "3"
Version string `yaml:"version"`
// Community is the SNMP community string (used in v1 and v2c)
Community string `yaml:"community"`
// UserName is the SNMPv3 username
UserName string `yaml:"username"`
// SecurityLevel must be one of: "noAuthNoPriv", "authNoPriv", or "authPriv" (for SNMPv3)
SecurityLevel string `yaml:"security_level"`
// AuthProtocol must be one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" (for SNMPv3)
AuthProtocol string `yaml:"auth_protocol"`
// AuthPassphrase is the authentication passphrase (for SNMPv3)
AuthPassphrase string `yaml:"auth_passphrase"`
// PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" (for SNMPv3)
PrivacyProtocol string `yaml:"privacy_protocol"`
// PrivacyPassphrase is the privacy passphrase (for SNMPv3)
PrivacyPassphrase string `yaml:"privacy_passphrase"`
}
)
func (c *Config) validateAndParse() ([]subnet, error) {
if len(c.Credentials) == 0 {
return nil, fmt.Errorf("no credentials provided")
}
if len(c.Networks) == 0 {
return nil, fmt.Errorf("no networks provided")
}
credentials := make(map[string]CredentialConfig)
for i, cr := range c.Credentials {
if cr.Name == "" {
return nil, fmt.Errorf("no name provided for credential %d", i)
}
if _, ok := credentials[cr.Name]; ok {
return nil, fmt.Errorf("duplicate credential name: %s", cr.Name)
}
credentials[cr.Name] = c.Credentials[i]
}
networks := make(map[string]bool)
var subnets []subnet
for i, n := range c.Networks {
if n.Subnet == "" {
return nil, fmt.Errorf("no subnet provided for network %d", i)
}
if n.Credential == "" {
return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
}
if _, ok := credentials[n.Credential]; !ok {
return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
}
r, err := iprange.ParseRange(n.Subnet)
if err != nil {
return nil, fmt.Errorf("invalid subnet range '%s': %v", n.Subnet, err)
}
// Limit subnet size to /23 or smaller (512 IPs max per subnet)
// This prevents accidental scanning of excessively large networks.
if s := r.Size().Int64(); s > 512 {
return nil, fmt.Errorf("subnet '%s' exceeds maximum size of /23 (512 IPs, got %d IPs)", n.Subnet, s)
}
sub := subnet{
str: n.Subnet,
ips: r,
credential: credentials[n.Credential],
}
if networks[subKey(sub)] {
return nil, fmt.Errorf("duplicate subnet '%s'", subKey(sub))
}
networks[subKey(sub)] = true
subnets = append(subnets, sub)
}
return subnets, nil
}
func setCredential(client gosnmp.Handler, cred CredentialConfig) {
switch parseSNMPVersion(cred) {
case gosnmp.Version1:
client.SetVersion(gosnmp.Version1)
client.SetCommunity(cred.Community)
case gosnmp.Version2c:
client.SetVersion(gosnmp.Version2c)
client.SetCommunity(cred.Community)
case gosnmp.Version3:
client.SetVersion(gosnmp.Version3)
client.SetSecurityModel(gosnmp.UserSecurityModel)
client.SetMsgFlags(parseSNMPv3SecurityLevel(cred))
client.SetSecurityParameters(&gosnmp.UsmSecurityParameters{
UserName: cred.UserName,
AuthenticationProtocol: parseSNMPv3AuthProtocol(cred),
AuthenticationPassphrase: cred.AuthPassphrase,
PrivacyProtocol: parseSNMPv3PrivProtocol(cred),
PrivacyPassphrase: cred.PrivacyPassphrase,
})
}
}
func parseSNMPVersion(cred CredentialConfig) gosnmp.SnmpVersion {
switch cred.Version {
case "0", "1":
return gosnmp.Version1
case "2", "2c", "":
return gosnmp.Version2c
case "3":
return gosnmp.Version3
default:
return gosnmp.Version2c
}
}
func parseSNMPv3SecurityLevel(cred CredentialConfig) gosnmp.SnmpV3MsgFlags {
switch cred.SecurityLevel {
case "1", "none", "noAuthNoPriv", "":
return gosnmp.NoAuthNoPriv
case "2", "authNoPriv":
return gosnmp.AuthNoPriv
case "3", "authPriv":
return gosnmp.AuthPriv
default:
return gosnmp.NoAuthNoPriv
}
}
func parseSNMPv3AuthProtocol(cred CredentialConfig) gosnmp.SnmpV3AuthProtocol {
switch cred.AuthProtocol {
case "1", "none", "noAuth", "":
return gosnmp.NoAuth
case "2", "md5", "MD5":
return gosnmp.MD5
case "3", "sha", "SHA":
return gosnmp.SHA
case "4", "sha224", "SHA224":
return gosnmp.SHA224
case "5", "sha256", "SHA256":
return gosnmp.SHA256
case "6", "sha384", "SHA384":
return gosnmp.SHA384
case "7", "sha512", "SHA512":
return gosnmp.SHA512
default:
return gosnmp.NoAuth
}
}
func parseSNMPv3PrivProtocol(cred CredentialConfig) gosnmp.SnmpV3PrivProtocol {
switch cred.PrivacyProtocol {
case "1", "none", "noPriv", "":
return gosnmp.NoPriv
case "2", "des", "DES":
return gosnmp.DES
case "3", "aes", "AES":
return gosnmp.AES
case "4", "aes192", "AES192":
return gosnmp.AES192
case "5", "aes256", "AES256":
return gosnmp.AES256
case "6", "aes192c", "AES192C":
return gosnmp.AES192C
case "7", "aes256c", "AES256C":
return gosnmp.AES256C
default:
return gosnmp.NoPriv
}
}

View file

@ -0,0 +1,266 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"context"
"fmt"
"log/slog"
"sync/atomic"
"time"
"github.com/gohugoio/hashstructure"
"github.com/gosnmp/gosnmp"
"github.com/sourcegraph/conc/pool"
"github.com/netdata/netdata/go/plugins/logger"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
)
const (
defaultRescanInterval = time.Minute * 30
defaultTimeout = time.Second * 1
defaultParallelScansPerNetwork = 32
defaultDeviceCacheTTL = time.Hour * 12
)
func NewDiscoverer(cfg Config) (*Discoverer, error) {
subnets, err := cfg.validateAndParse()
if err != nil {
return nil, err
}
cfgHash, _ := hashstructure.Hash(cfg, nil)
d := &Discoverer{
Logger: logger.New().With(
slog.String("component", "service discovery"),
slog.String("discoverer", "snmp"),
),
started: make(chan struct{}),
cfgHash: cfgHash,
subnets: subnets,
newSnmpClient: func() (gosnmp.Handler, func()) {
return gosnmp.NewHandler(), func() {}
},
rescanInterval: defaultRescanInterval,
timeout: defaultTimeout,
parallelScansPerNetwork: defaultParallelScansPerNetwork,
deviceCacheTTL: defaultDeviceCacheTTL,
firstDiscovery: true,
status: newDiscoveryStatus(),
}
if cfg.RescanInterval > 0 {
d.rescanInterval = cfg.RescanInterval.Duration()
}
if cfg.Timeout > 0 {
d.timeout = cfg.Timeout.Duration()
}
if cfg.ParallelScansPerNetwork > 0 {
d.parallelScansPerNetwork = cfg.ParallelScansPerNetwork
}
if cfg.DeviceCacheTTL > 0 {
d.deviceCacheTTL = cfg.DeviceCacheTTL.Duration()
}
return d, nil
}
type (
Discoverer struct {
*logger.Logger
model.Base
started chan struct{}
cfgHash uint64
subnets []subnet
newSnmpClient func() (gosnmp.Handler, func())
parallelScansPerNetwork int
rescanInterval time.Duration
timeout time.Duration
deviceCacheTTL time.Duration
firstDiscovery bool
status *discoveryStatus
statusUpdated atomic.Bool
}
subnet struct {
str string
ips iprange.Range
credential CredentialConfig
}
)
func (d *Discoverer) String() string {
return "sd:snmp"
}
func (d *Discoverer) Discover(ctx context.Context, in chan<- []model.TargetGroup) {
d.Info("instance is started")
defer func() { d.Info("instance is stopped") }()
close(d.started)
d.loadFileStatus()
d.discoverNetworks(ctx, in)
if d.rescanInterval <= 0 {
filepersister.Save(statusFileName(), d.status)
return
}
tk := time.NewTicker(d.rescanInterval)
defer tk.Stop()
for {
select {
case <-ctx.Done():
return
case <-tk.C:
d.discoverNetworks(ctx, in)
}
}
}
func (d *Discoverer) discoverNetworks(ctx context.Context, in chan<- []model.TargetGroup) {
now := time.Now()
doProbing := !d.firstDiscovery ||
d.status.ConfigHash != d.cfgHash ||
now.After(d.status.LastDiscoveryTime.Add(d.rescanInterval))
defer func() {
if isDone(ctx) {
return
}
d.firstDiscovery = false
if doProbing {
d.status.LastDiscoveryTime = now
}
if d.statusUpdated.Swap(false) || d.status.ConfigHash != d.cfgHash {
d.status.ConfigHash = d.cfgHash
filepersister.Save(statusFileName(), d.status)
}
}()
d.Infof("discovery mode: %s", map[bool]string{true: "active probing", false: "using cache"}[doProbing])
p := pool.New()
for _, sub := range d.subnets {
sub := sub
p.Go(func() { d.discoverNetwork(ctx, in, sub, doProbing) })
}
p.Wait()
}
func (d *Discoverer) discoverNetwork(ctx context.Context, in chan<- []model.TargetGroup, sub subnet, doProbing bool) {
tgg := newTargetGroup(sub)
p := pool.New().WithMaxGoroutines(d.parallelScansPerNetwork)
for ip := range sub.ips.Iterate() {
ipAddr := ip.String()
if doProbing {
p.Go(func() { d.probeIPAddress(ctx, sub, ipAddr, tgg) })
} else {
d.useCacheIPAddress(sub, ipAddr, tgg)
}
}
p.Wait()
send(ctx, in, tgg)
}
func (d *Discoverer) useCacheIPAddress(sub subnet, ip string, tgg *targetGroup) {
if dev := d.status.get(sub, ip); dev != nil {
tg := newTarget(ip, sub.credential, dev.SysInfo)
tgg.addTarget(tg)
}
}
func (d *Discoverer) probeIPAddress(ctx context.Context, sub subnet, ip string, tgg *targetGroup) {
if isDone(ctx) {
return
}
now := time.Now()
dev := d.status.get(sub, ip)
// Use the cached device if available and not expired
if dev != nil && now.Before(dev.DiscoverTime.Add(d.deviceCacheTTL)) {
if d.firstDiscovery {
untilProbe := dev.DiscoverTime.Add(d.deviceCacheTTL).Sub(now).Round(time.Second)
d.Infof("device '%s': found in cache (sysName: '%s', network: '%s', next probe in %s)",
ip, dev.SysInfo.Name, subKey(sub), untilProbe)
}
tg := newTarget(ip, sub.credential, dev.SysInfo)
tgg.addTarget(tg)
return
}
si, err := d.getSnmpSysInfo(sub, ip)
if err != nil {
if dev == nil {
// First-time discovery failure - log at debug level as this is expected for many IPs
d.Debugf("device '%s': probe failed (network: '%s'): %v", ip, subKey(sub), err)
} else {
// Previously discovered device is now unreachable
d.Warningf("lost connection to previously discovered SNMP device '%s' (sysName: '%s', network: '%s'): %v",
ip, dev.SysInfo.Name, subKey(sub), err)
}
d.status.del(sub, ip)
d.statusUpdated.Store(dev != nil)
return
}
d.Infof("device '%s': successfully discovered (sysName: '%s', network: '%s')", ip, si.Name, subKey(sub))
d.status.put(sub, ip, &discoveredDevice{DiscoverTime: now, SysInfo: *si})
d.statusUpdated.Store(true)
tg := newTarget(ip, sub.credential, *si)
tgg.addTarget(tg)
}
func (d *Discoverer) getSnmpSysInfo(sub subnet, ip string) (*SysInfo, error) {
client, cleanup := d.newSnmpClient()
defer cleanup()
client.SetTarget(ip)
client.SetTimeout(d.timeout)
client.SetRetries(0)
setCredential(client, sub.credential)
if err := client.Connect(); err != nil {
return nil, fmt.Errorf("failed to connect: %v", err)
}
defer func() { _ = client.Close() }()
return GetSysInfo(client)
}
func send(ctx context.Context, in chan<- []model.TargetGroup, tgg model.TargetGroup) {
select {
case <-ctx.Done():
case in <- []model.TargetGroup{tgg}:
}
}
func isDone(ctx context.Context) bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}

View file

@ -0,0 +1,279 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
)
func TestNewDiscoverer(t *testing.T) {
tests := map[string]struct {
cfg Config
wantFail bool
}{
"succeeds with valid SNMPv1 config": {
wantFail: false,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "v1cred", Version: "1", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "v1cred"},
},
},
},
"succeeds with valid SNMPv2c config": {
wantFail: false,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "v2cred", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "v2cred"},
},
},
},
"succeeds with valid SNMPv3 config": {
wantFail: false,
cfg: Config{
Credentials: []CredentialConfig{
{
Name: "v3cred",
Version: "3",
UserName: "user",
SecurityLevel: "authPriv",
AuthProtocol: "sha",
AuthPassphrase: "authpass",
PrivacyProtocol: "aes",
PrivacyPassphrase: "privpass",
},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "v3cred"},
},
},
},
"succeeds with multiple valid credentials and networks": {
cfg: Config{
Credentials: []CredentialConfig{
{Name: "v1cred", Version: "1", Community: "public"},
{Name: "v2cred", Version: "2c", Community: "private"},
{
Name: "v3cred",
Version: "3",
UserName: "user",
SecurityLevel: "authPriv",
AuthProtocol: "sha",
AuthPassphrase: "authpass",
PrivacyProtocol: "aes",
PrivacyPassphrase: "privpass",
},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "v1cred"},
{Subnet: "10.0.0.0/24", Credential: "v2cred"},
{Subnet: "172.16.0.0/24", Credential: "v3cred"},
},
},
wantFail: false,
},
"fails on empty config": {
wantFail: true,
},
"fails with credentials but no networks": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
},
},
"fails with networks but no credentials": {
wantFail: true,
cfg: Config{
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "test"},
},
},
},
"fails with credential without name": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "test"},
},
},
},
"fails with duplicate credential names": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
{Name: "test", Version: "2c", Community: "private"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "test"},
},
},
},
"fails with network without subnet": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Credential: "test"},
},
},
},
"fails with network without credential": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24"},
},
},
},
"fails with network with nonexistent credential": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "nonexistent"},
},
},
},
"fails with invalid subnet format": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "invalid-subnet", Credential: "test"},
},
},
},
"fails with subnet too large (> 512 IPs)": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/22", Credential: "test"}, // 1024 IPs
},
},
},
"fails with duplicate subnet": {
wantFail: true,
cfg: Config{
Credentials: []CredentialConfig{
{Name: "test", Version: "2c", Community: "public"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/24", Credential: "test"},
{Subnet: "192.0.2.0/24", Credential: "test"},
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
d, err := NewDiscoverer(test.cfg)
if test.wantFail {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.NotNil(t, d)
}
})
}
}
func TestDiscoverer_Run(t *testing.T) {
tests := map[string]struct {
prepareSim func(t *testing.T) *discoverySim
}{
"simple discovery": {
prepareSim: func(t *testing.T) *discoverySim {
cfg := Config{
Credentials: []CredentialConfig{
{Name: "public-v2", Version: "2", Community: "public-v2"},
},
Networks: []NetworkConfig{
{Subnet: "192.0.2.0/29", Credential: "public-v2"},
},
}
subnets, err := cfg.validateAndParse()
sub := subnets[0]
require.NoError(t, err)
sim := discoverySim{
cfg: cfg,
updateSnmpHandler: func(m *mockSnmpHandler) {
m.skipOnConnect = func(ip string) bool {
// Skip if the last octet is odd
i := strings.LastIndexByte(ip, '.')
if i == -1 {
return false
}
lastOctet, err := strconv.Atoi(ip[i+1:])
return err == nil && lastOctet%2 != 0
}
},
wantGroups: []model.TargetGroup{
prepareNewTargetGroup(sub, "192.0.2.2", "192.0.2.4", "192.0.2.6"),
},
}
return &sim
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
sim := test.prepareSim(t)
sim.run(t)
})
}
}
func prepareNewTargetGroup(sub subnet, ips ...string) *targetGroup {
tgg := newTargetGroup(sub)
for _, ip := range ips {
tg := prepareNewTarget(sub, ip)
tgg.addTarget(tg)
}
return tgg
}
func prepareNewTarget(sub subnet, ip string) *target {
return newTarget(ip, sub.credential, SysInfo{
Descr: mockSysDescr,
Contact: mockSysContact,
Name: mockSysName,
Location: mockSysLocation,
Organization: "net-snmp",
})
}

View file

@ -1,6 +1,6 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package entnum
package snmpsd
import (
"bufio"

View file

@ -0,0 +1,183 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"context"
"errors"
"sort"
"sync"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/gosnmp/gosnmp"
snmpmock "github.com/gosnmp/gosnmp/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
)
type discoverySim struct {
cfg Config
updateSnmpHandler func(m *mockSnmpHandler)
wantGroups []model.TargetGroup
}
func (sim *discoverySim) run(t *testing.T) {
d, err := NewDiscoverer(sim.cfg)
require.NoError(t, err)
d.newSnmpClient = func() (gosnmp.Handler, func()) {
h, cleanup := prepareMockSnmpHandler(t)
h.setExpectInit()
h.setExpectSysInfo()
if sim.updateSnmpHandler != nil {
sim.updateSnmpHandler(h)
}
return h, cleanup
}
seen := make(map[string]model.TargetGroup)
ctx, cancel := context.WithCancel(context.Background())
in := make(chan []model.TargetGroup)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
d.Discover(ctx, in)
}()
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case tggs := <-in:
for _, tgg := range tggs {
seen[tgg.Source()] = tgg
}
}
}
}()
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
select {
case <-d.started:
case <-time.After(time.Second * 5):
require.Fail(t, "discovery failed to start")
}
time.Sleep(time.Second * 2)
cancel()
select {
case <-done:
case <-time.After(time.Second * 5):
require.Fail(t, "discovery hasn't finished after cancel")
}
var tggs []model.TargetGroup
for _, tgg := range seen {
tggs = append(tggs, tgg)
}
sortTargetGroups(tggs)
sortTargetGroups(sim.wantGroups)
wantLen, gotLen := calcTargets(sim.wantGroups), calcTargets(tggs)
assert.Equalf(t, wantLen, gotLen, "different len (want %d got %d)", wantLen, gotLen)
assert.Equal(t, sim.wantGroups, tggs)
}
func calcTargets(tggs []model.TargetGroup) int {
var n int
for _, tgg := range tggs {
n += len(tgg.Targets())
}
return n
}
func sortTargetGroups(tggs []model.TargetGroup) {
if len(tggs) == 0 {
return
}
sort.Slice(tggs, func(i, j int) bool { return tggs[i].Source() < tggs[j].Source() })
for idx := range tggs {
tgts := tggs[idx].Targets()
sort.Slice(tgts, func(i, j int) bool { return tgts[i].Hash() < tgts[j].Hash() })
}
}
type mockSnmpHandler struct {
mu sync.Mutex
*snmpmock.MockHandler
skipOnConnect func(ip string) bool
}
func (m *mockSnmpHandler) Connect() error {
if m.skipOnConnect != nil && m.skipOnConnect(m.MockHandler.Target()) {
return errors.New("mock handler skip connect")
}
return m.MockHandler.Connect()
}
func prepareMockSnmpHandler(t *testing.T) (*mockSnmpHandler, func()) {
mockCtl := gomock.NewController(t)
cleanup := func() { mockCtl.Finish() }
mockSNMP := snmpmock.NewMockHandler(mockCtl)
m := &mockSnmpHandler{MockHandler: mockSNMP}
return m, cleanup
}
func (m *mockSnmpHandler) setExpectInit() {
var ip string
m.EXPECT().Target().DoAndReturn(func() string { return ip }).AnyTimes()
m.EXPECT().SetTarget(gomock.Any()).Do(func(target string) { ip = target }).AnyTimes()
m.EXPECT().Port().AnyTimes()
m.EXPECT().Version().AnyTimes()
m.EXPECT().Community().AnyTimes()
m.EXPECT().SetPort(gomock.Any()).AnyTimes()
m.EXPECT().SetRetries(gomock.Any()).AnyTimes()
m.EXPECT().SetMaxRepetitions(gomock.Any()).AnyTimes()
m.EXPECT().SetMaxOids(gomock.Any()).AnyTimes()
m.EXPECT().SetLogger(gomock.Any()).AnyTimes()
m.EXPECT().SetTimeout(gomock.Any()).AnyTimes()
m.EXPECT().SetCommunity(gomock.Any()).AnyTimes()
m.EXPECT().SetVersion(gomock.Any()).AnyTimes()
m.EXPECT().SetSecurityModel(gomock.Any()).AnyTimes()
m.EXPECT().SetMsgFlags(gomock.Any()).AnyTimes()
m.EXPECT().SetSecurityParameters(gomock.Any()).AnyTimes()
m.EXPECT().Connect().Return(nil).AnyTimes()
m.EXPECT().Close().Return(nil).AnyTimes()
}
const (
mockSysDescr = "mock sysDescr"
mockSysObject = ".1.3.6.1.4.1.8072.3.2.10"
mockSysContact = "mock sysContact"
mockSysName = "mock sysName"
mockSysLocation = "mock sysLocation"
)
func (m *mockSnmpHandler) setExpectSysInfo() {
m.EXPECT().WalkAll(RootOidMibSystem).Return([]gosnmp.SnmpPDU{
{Name: OidSysDescr, Value: []uint8(mockSysDescr), Type: gosnmp.OctetString},
{Name: OidSysObject, Value: mockSysObject, Type: gosnmp.ObjectIdentifier},
{Name: OidSysContact, Value: []uint8(mockSysContact), Type: gosnmp.OctetString},
{Name: OidSysName, Value: []uint8(mockSysName), Type: gosnmp.OctetString},
{Name: OidSysLocation, Value: []uint8(mockSysLocation), Type: gosnmp.OctetString},
}, nil).AnyTimes()
}

View file

@ -0,0 +1,105 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
func (d *Discoverer) loadFileStatus() {
d.status = newDiscoveryStatus()
filename := statusFileName()
if filename == "" {
return
}
f, err := os.Open(filename)
if err != nil {
d.Warningf("failed to open status file %s: %v", filename, err)
return
}
defer func() { _ = f.Close() }()
if err := json.NewDecoder(f).Decode(d.status); err != nil {
d.Warningf("failed to parse status file %s: %v", filename, err)
return
}
d.Infof("loaded status file: last discovery=%s", d.status.LastDiscoveryTime)
}
func statusFileName() string {
v := os.Getenv("NETDATA_LIB_DIR")
if v == "" {
return ""
}
return filepath.Join(v, "god-sd-snmp-status.json")
}
func newDiscoveryStatus() *discoveryStatus {
return &discoveryStatus{
Networks: make(map[string]map[string]*discoveredDevice),
}
}
type (
discoveryStatus struct {
mux sync.RWMutex
Networks map[string]map[string]*discoveredDevice `json:"networks"`
LastDiscoveryTime time.Time `json:"last_discovery_time"`
ConfigHash uint64 `json:"config_hash"`
}
discoveredDevice struct {
DiscoverTime time.Time `json:"discover_time"`
SysInfo SysInfo `json:"sysinfo"`
}
)
func (s *discoveryStatus) Bytes() ([]byte, error) {
s.mux.RLock()
defer s.mux.RUnlock()
return json.MarshalIndent(s, "", " ")
}
func (s *discoveryStatus) get(sub subnet, ip string) *discoveredDevice {
s.mux.RLock()
defer s.mux.RUnlock()
devices, ok := s.Networks[subKey(sub)]
if !ok {
return nil
}
return devices[ip]
}
func (s *discoveryStatus) put(sub subnet, ip string, dev *discoveredDevice) {
s.mux.Lock()
defer s.mux.Unlock()
devices, ok := s.Networks[subKey(sub)]
if !ok {
devices = make(map[string]*discoveredDevice)
s.Networks[subKey(sub)] = devices
}
devices[ip] = dev
}
func (s *discoveryStatus) del(sub subnet, ip string) {
s.mux.Lock()
defer s.mux.Unlock()
if devices, ok := s.Networks[subKey(sub)]; ok {
delete(devices, ip)
}
}
func subKey(s subnet) string {
return fmt.Sprintf("%s:%s", s.str, s.credential.Name)
}

View file

@ -0,0 +1,91 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"fmt"
"strings"
"github.com/gosnmp/gosnmp"
)
const (
RootOidMibSystem = "1.3.6.1.2.1.1"
OidSysDescr = "1.3.6.1.2.1.1.1.0"
OidSysObject = "1.3.6.1.2.1.1.2.0"
OidSysUptime = "1.3.6.1.2.1.1.3.0"
OidSysContact = "1.3.6.1.2.1.1.4.0"
OidSysName = "1.3.6.1.2.1.1.5.0"
OidSysLocation = "1.3.6.1.2.1.1.6.0"
)
type SysInfo struct {
Descr string `json:"description"`
Contact string `json:"contact"`
Name string `json:"name"`
Location string `json:"location"`
Organization string `json:"organization"`
}
func GetSysInfo(client gosnmp.Handler) (*SysInfo, error) {
pdus, err := client.WalkAll(RootOidMibSystem)
if err != nil {
return nil, err
}
si := &SysInfo{
Name: "unknown",
Organization: "Unknown",
}
r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
for _, pdu := range pdus {
oid := strings.TrimPrefix(pdu.Name, ".")
switch oid {
case OidSysDescr:
if si.Descr, err = PduToString(pdu); err == nil {
si.Descr = r.Replace(si.Descr)
}
case OidSysObject:
var sysObj string
if sysObj, err = PduToString(pdu); err == nil {
si.Organization = LookupBySysObject(sysObj)
}
case OidSysContact:
si.Contact, err = PduToString(pdu)
case OidSysName:
si.Name, err = PduToString(pdu)
case OidSysLocation:
si.Location, err = PduToString(pdu)
}
if err != nil {
return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
}
}
return si, nil
}
func PduToString(pdu gosnmp.SnmpPDU) (string, error) {
switch pdu.Type {
case gosnmp.OctetString:
// TODO: this isn't reliable (e.g. physAddress we need hex.EncodeToString())
bs, ok := pdu.Value.([]byte)
if !ok {
return "", fmt.Errorf("OctetString is not a []byte but %T", pdu.Value)
}
return strings.ToValidUTF8(string(bs), "<22>"), nil
case gosnmp.Counter32, gosnmp.Counter64, gosnmp.Integer, gosnmp.Gauge32:
return gosnmp.ToBigInt(pdu.Value).String(), nil
case gosnmp.ObjectIdentifier:
v, ok := pdu.Value.(string)
if !ok {
return "", fmt.Errorf("ObjectIdentifier is not a string but %T", pdu.Value)
}
return strings.TrimPrefix(v, "."), nil
default:
return "", fmt.Errorf("unussported type: '%v'", pdu.Type)
}
}

View file

@ -0,0 +1,64 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmpsd
import (
"fmt"
"sync"
"github.com/gohugoio/hashstructure"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
)
func targetSource(sub subnet) string { return fmt.Sprintf("discoverer=snmp,network=%s", subKey(sub)) }
func newTargetGroup(sub subnet) *targetGroup {
return &targetGroup{
provider: "sd:snmpdiscoverer",
source: targetSource(sub),
}
}
type targetGroup struct {
provider string
source string
mux sync.Mutex
targets []model.Target
}
func (g *targetGroup) Provider() string { return g.provider }
func (g *targetGroup) Source() string { return g.source }
func (g *targetGroup) Targets() []model.Target { return g.targets }
func (g *targetGroup) addTarget(tg model.Target) {
g.mux.Lock()
defer g.mux.Unlock()
g.targets = append(g.targets, tg)
}
func newTarget(ip string, cred CredentialConfig, si SysInfo) *target {
tg := &target{
IPAddress: ip,
Credential: cred,
SysInfo: si,
}
tg.hash, _ = hashstructure.Hash(tg, nil)
return tg
}
type (
target struct {
model.Base `hash:"ignore"`
hash uint64
IPAddress string
Credential CredentialConfig `hash:"ignore"`
SysInfo SysInfo `hash:"ignore"`
}
)
func (t *target) TUID() string { return fmt.Sprintf("snmp_%s_%d", t.IPAddress, t.hash) }
func (t *target) Hash() uint64 { return t.hash }

View file

@ -51,7 +51,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) {
select {
case <-done:
a.Info("all discoverers exited")
case <-time.After(time.Second * 3):
case <-time.After(time.Second * 10):
a.Warning("not all discoverers exited")
}
a.trySend(in)
@ -83,7 +83,7 @@ func (a *accumulator) runDiscoverer(ctx context.Context, d model.Discoverer, upd
case <-ctx.Done():
select {
case <-done:
case <-time.After(time.Second * 2):
case <-time.After(time.Second * 10):
a.Warningf("discoverer '%v' didn't exit on ctx done", d)
}
return

View file

@ -10,6 +10,7 @@ import (
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
)
type Config struct {
@ -28,6 +29,7 @@ type DiscoveryConfig struct {
NetListeners netlisteners.Config `yaml:"net_listeners"`
Docker dockerd.Config `yaml:"docker"`
K8s []kubernetes.Config `yaml:"k8s"`
SNMP snmpsd.Config `yaml:"snmp"`
}
type ClassifyRuleConfig struct {
@ -71,7 +73,7 @@ func validateDiscoveryConfig(config []DiscoveryConfig) error {
}
for _, cfg := range config {
switch cfg.Discoverer {
case "net_listeners", "docker", "k8s":
case "net_listeners", "docker", "k8s", "snmp":
default:
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
}

View file

@ -14,6 +14,7 @@ import (
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/hostinfo"
)
@ -102,6 +103,12 @@ func (p *Pipeline) registerDiscoverers(conf Config) error {
}
p.discoverers = append(p.discoverers, td)
}
case "snmp":
td, err := snmpsd.NewDiscoverer(cfg.SNMP)
if err != nil {
return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
}
p.discoverers = append(p.discoverers, td)
default:
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
}
@ -130,7 +137,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
case <-ctx.Done():
select {
case <-done:
case <-time.After(time.Second * 4):
case <-time.After(time.Second * 10):
}
return
case <-done:
@ -149,7 +156,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
func (p *Pipeline) processGroups(tggs []model.TargetGroup) []*confgroup.Group {
var groups []*confgroup.Group
// updates come from the accumulator, this ensures that all groups have different sources
// updates come from the accumulator; this ensures that all groups have different sources
for _, tgg := range tggs {
p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets()))
if v := p.processGroup(tgg); v != nil {

View file

@ -16,14 +16,21 @@ type Data interface {
Updated() <-chan struct{}
}
func Save(path string, data interface{ Bytes() ([]byte, error) }) {
if path == "" {
return
}
New(path).flush(data)
}
func New(path string) *Persister {
return &Persister{
Logger: logger.New().With(
slog.String("component", "file persister"),
slog.String("file", path),
),
FlushEvery: time.Minute * 1,
filepath: path,
flushEvery: time.Second * 5,
flushCh: make(chan struct{}, 1),
}
}
@ -31,10 +38,11 @@ func New(path string) *Persister {
type Persister struct {
*logger.Logger
data Data
filepath string
flushEvery time.Duration
flushCh chan struct{}
FlushEvery time.Duration
data Data
filepath string
flushCh chan struct{}
}
func (p *Persister) Run(ctx context.Context, data Data) {
@ -43,9 +51,9 @@ func (p *Persister) Run(ctx context.Context, data Data) {
p.data = data
tk := time.NewTicker(p.flushEvery)
tk := time.NewTicker(p.FlushEvery)
defer tk.Stop()
defer p.flush()
defer p.flush(p.data)
for {
select {
@ -70,14 +78,14 @@ func (p *Persister) triggerFlush() {
func (p *Persister) tryFlush() {
select {
case <-p.flushCh:
p.flush()
p.flush(p.data)
default:
// no pending flush
}
}
func (p *Persister) flush() {
bs, err := p.data.Bytes()
func (p *Persister) flush(data interface{ Bytes() ([]byte, error) }) {
bs, err := data.Bytes()
if err != nil {
p.Debugf("failed to marshal data: %v", err)
return

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"sync"
@ -13,14 +14,18 @@ import (
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
)
func statusFileName(dir string) string {
return filepath.Join(dir, "god-jobs-statuses.json")
}
func (m *Manager) loadFileStatus() {
m.fileStatus = newFileStatus()
if isTerminal || m.StateFile == "" {
if isTerminal || m.VarLibDir == "" {
return
}
s, err := loadFileStatus(m.StateFile)
s, err := loadFileStatus(statusFileName(m.VarLibDir))
if err != nil {
m.Warningf("failed to load state file: %v", err)
return
@ -29,10 +34,12 @@ func (m *Manager) loadFileStatus() {
}
func (m *Manager) runFileStatusPersistence() {
if m.StateFile == "" {
if m.VarLibDir == "" {
return
}
p := filepersister.New(m.StateFile)
p := filepersister.New(statusFileName(m.VarLibDir))
p.Run(m.ctx, m.fileStatus)
}

View file

@ -60,7 +60,7 @@ type Manager struct {
Out io.Writer
Modules module.Registry
ConfigDefaults confgroup.Registry
StateFile string
VarLibDir string
FnReg FunctionRegistry
Vnodes map[string]*vnodes.VirtualNode

View file

@ -162,8 +162,8 @@ func (c *Collector) addNetIfaceCharts(iface *netInterface) {
for _, chart := range *charts {
chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName))
chart.Labels = []module.Label{
{Key: "vendor", Value: c.sysInfo.organization},
{Key: "sysName", Value: c.sysInfo.name},
{Key: "vendor", Value: c.sysInfo.Organization},
{Key: "sysName", Value: c.sysInfo.Name},
{Key: "ifDescr", Value: iface.ifDescr},
{Key: "ifName", Value: iface.ifName},
{Key: "ifType", Value: ifTypeMapping[iface.ifType]},
@ -191,8 +191,8 @@ func (c *Collector) removeNetIfaceCharts(iface *netInterface) {
func (c *Collector) addSysUptimeChart() {
chart := uptimeChart.Copy()
chart.Labels = []module.Label{
{Key: "vendor", Value: c.sysInfo.organization},
{Key: "sysName", Value: c.sysInfo.name},
{Key: "vendor", Value: c.sysInfo.Organization},
{Key: "sysName", Value: c.sysInfo.Name},
}
if err := c.Charts().Add(chart); err != nil {
c.Warning(err)

View file

@ -3,10 +3,12 @@
package snmp
import (
"errors"
"fmt"
"slices"
"strings"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
"github.com/google/uuid"
@ -15,7 +17,7 @@ import (
func (c *Collector) collect() (map[string]int64, error) {
if c.sysInfo == nil {
si, err := c.getSysInfo()
si, err := snmpsd.GetSysInfo(c.snmpClient)
if err != nil {
return nil, err
}
@ -49,6 +51,24 @@ func (c *Collector) collect() (map[string]int64, error) {
return mx, nil
}
func (c *Collector) collectSysUptime(mx map[string]int64) error {
resp, err := c.snmpClient.Get([]string{snmpsd.OidSysUptime})
if err != nil {
return err
}
if len(resp.Variables) == 0 {
return errors.New("no system uptime")
}
v, err := pduToInt(resp.Variables[0])
if err != nil {
return err
}
mx["uptime"] = v / 100 // the time is in hundredths of a second
return nil
}
func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
if c.snmpClient.Version() == gosnmp.Version1 {
return c.snmpClient.WalkAll(rootOid)
@ -56,12 +76,12 @@ func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
return c.snmpClient.BulkWalkAll(rootOid)
}
func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
func (c *Collector) setupVnode(si *snmpsd.SysInfo) *vnodes.VirtualNode {
if c.Vnode.GUID == "" {
c.Vnode.GUID = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(c.Hostname)).String()
}
hostnames := []string{c.Vnode.Hostname, si.name, "snmp-device"}
hostnames := []string{c.Vnode.Hostname, si.Name, "snmp-device"}
i := slices.IndexFunc(hostnames, func(s string) bool { return s != "" })
c.Vnode.Hostname = fmt.Sprintf("%s(%s)", hostnames[i], c.Hostname)
@ -71,17 +91,17 @@ func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
for k, v := range c.Vnode.Labels {
labels[k] = v
}
if si.descr != "" {
labels["sysDescr"] = si.descr
if si.Descr != "" {
labels["sysDescr"] = si.Descr
}
if si.contact != "" {
labels["sysContact"] = si.contact
if si.Contact != "" {
labels["sysContact"] = si.Contact
}
if si.location != "" {
labels["sysLocation"] = si.location
if si.Location != "" {
labels["sysLocation"] = si.Location
}
// FIXME: vendor should be obtained from sysDescr, org should be used as a fallback
labels["vendor"] = si.organization
labels["vendor"] = si.Organization
return &vnodes.VirtualNode{
GUID: c.Vnode.GUID,

View file

@ -1,90 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package snmp
import (
"errors"
"fmt"
"strings"
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/entnum"
)
const (
rootOidMibSystem = "1.3.6.1.2.1.1"
oidSysDescr = "1.3.6.1.2.1.1.1.0"
oidSysObject = "1.3.6.1.2.1.1.2.0"
oidSysUptime = "1.3.6.1.2.1.1.3.0"
oidSysContact = "1.3.6.1.2.1.1.4.0"
oidSysName = "1.3.6.1.2.1.1.5.0"
oidSysLocation = "1.3.6.1.2.1.1.6.0"
)
type sysInfo struct {
descr string
contact string
name string
location string
organization string
}
func (c *Collector) getSysInfo() (*sysInfo, error) {
pdus, err := c.snmpClient.WalkAll(rootOidMibSystem)
if err != nil {
return nil, err
}
si := &sysInfo{
name: "unknown",
organization: "Unknown",
}
r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
for _, pdu := range pdus {
oid := strings.TrimPrefix(pdu.Name, ".")
switch oid {
case oidSysDescr:
if si.descr, err = pduToString(pdu); err == nil {
si.descr = r.Replace(si.descr)
}
case oidSysObject:
var sysObj string
if sysObj, err = pduToString(pdu); err == nil {
si.organization = entnum.LookupBySysObject(sysObj)
c.Debugf("device sysObject '%s', organization '%s'", sysObj, si.organization)
}
case oidSysContact:
si.contact, err = pduToString(pdu)
case oidSysName:
si.name, err = pduToString(pdu)
case oidSysLocation:
si.location, err = pduToString(pdu)
}
if err != nil {
return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
}
}
return si, nil
}
func (c *Collector) collectSysUptime(mx map[string]int64) error {
resp, err := c.snmpClient.Get([]string{oidSysUptime})
if err != nil {
return err
}
if len(resp.Variables) == 0 {
return errors.New("no system uptime")
}
v, err := pduToInt(resp.Variables[0])
if err != nil {
return err
}
mx["uptime"] = v / 100 // the time is in hundredths of a second
return nil
}

View file

@ -9,6 +9,7 @@ import (
"fmt"
"github.com/netdata/netdata/go/plugins/pkg/matcher"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
@ -76,7 +77,7 @@ type Collector struct {
netInterfaces map[string]*netInterface
sysInfo *sysInfo
sysInfo *snmpsd.SysInfo
customOids []string
}

View file

@ -11,6 +11,7 @@ import (
"strings"
"testing"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
"github.com/golang/mock/gomock"
@ -581,15 +582,15 @@ func setMockClientInitExpect(m *snmpmock.MockHandler) {
}
func setMockClientSysExpect(m *snmpmock.MockHandler) {
m.EXPECT().WalkAll(rootOidMibSystem).Return([]gosnmp.SnmpPDU{
{Name: oidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
{Name: oidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
{Name: oidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
{Name: oidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
{Name: oidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
m.EXPECT().WalkAll(snmpsd.RootOidMibSystem).Return([]gosnmp.SnmpPDU{
{Name: snmpsd.OidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
{Name: snmpsd.OidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
{Name: snmpsd.OidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
{Name: snmpsd.OidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
{Name: snmpsd.OidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
}, nil).MinTimes(1)
m.EXPECT().Get([]string{oidSysUptime}).Return(&gosnmp.SnmpPacket{
m.EXPECT().Get([]string{snmpsd.OidSysUptime}).Return(&gosnmp.SnmpPacket{
Variables: []gosnmp.SnmpPDU{
{Value: uint32(6048), Type: gosnmp.TimeTicks},
},

View file

@ -0,0 +1,98 @@
# ===================================================================
# WARNING: SNMP DISCOVERY IS DISABLED BY DEFAULT
# To enable, change "disabled: yes" to "disabled: no" below
# AND configure proper credentials and networks
# ===================================================================
disabled: yes
name: 'snmp'
discover:
- discoverer: snmp
snmp:
# how often to scan the networks for devices (default: 30m)
rescan_interval: "30m"
# the maximum time to wait for SNMP device responses (default: 1s)
timeout: "1s"
# How long to trust cached discovery results before requiring a new probe (default: 12h)
device_cache_ttl: "6h"
# how many IPs to scan concurrently within each subnet (default: 32)
parallel_scans_per_network: 32
# ==========================================================
# IMPORTANT: YOU MUST CONFIGURE YOUR OWN CREDENTIALS BELOW
# The example credentials will not work in most environments
# ==========================================================
credentials:
- name: "public-v2c"
version: "2"
community: "public"
- name: "secure-v3"
version: "3"
# one of: "noAuthNoPriv", "authNoPriv", or "authPriv"
security_level: "authPriv"
username: "admin"
# one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512"
auth_protocol: "sha"
auth_password: "secret123"
# one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C"
priv_protocol: "aes"
priv_password: "encrypt123"
# ========================================================
# IMPORTANT: YOU MUST CONFIGURE YOUR OWN NETWORKS BELOW
# By default, no networks will be scanned until configured
# Maximum size is limited to 512 IPs per subnet (/23 CIDR)
# ========================================================
networks:
# Subnet is the IP range to scan, supporting various formats:
# https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
- subnet: "192.168.1.0/24"
# Credential is the name of a credential from the Credentials list
credential: "public-v2c"
classify:
- name: "Servers"
selector: "*"
tags: "skip"
match:
- tags: "skip"
expr: '{{ match "sp" .SysInfo.Descr "Linux* FreeBSD* OpenBSD* NetBSD*" }}'
- name: "SNMP Devices"
selector: "!skip *"
tags: "snmp"
match:
- tags: "snmp"
expr: '{{ true }}'
compose:
- name: "SNMP Devices"
selector: "snmp"
config:
- selector: "snmp"
template: |
module: snmp
update_every: 5
{{- if .SysInfo.Name }}
name: {{ .SysInfo.Name }}-ip-{{ .IPAddress }}
{{- else }}
name: ip-{{ .IPAddress }}
{{- end }}
hostname: {{ .IPAddress }}
options:
version: {{ .Credential.Version }}
{{- if eq .Credential.Version "1" "2" }}
community: {{ .Credential.Community }}
{{- else }}
user:
name: {{ .Credential.UserName }}
level: {{ .Credential.SecurityLevel }}
auth_proto: {{ .Credential.AuthProtocol }}
auth_key: {{ .Credential.AuthPassphrase }}
priv_proto: {{ .Credential.PrivacyProtocol }}
priv_key: {{ .Credential.PrivacyPassphrase }}
{{- end }}

View file

@ -5,10 +5,70 @@ package confopt
import (
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`)
// ParseDuration parses a duration string with units.
func ParseDuration(s string) (time.Duration, error) {
orig := s
if s = strings.ReplaceAll(s, " ", ""); s == "" {
return 0, fmt.Errorf("empty duration string")
}
neg := s[0] == '-'
if neg {
s = s[1:]
}
unitMap := map[string]time.Duration{
"d": 24 * time.Hour,
"w": 7 * 24 * time.Hour,
"wk": 7 * 24 * time.Hour,
"mo": 30 * 24 * time.Hour,
"M": 30 * 24 * time.Hour,
"y": 365 * 24 * time.Hour,
}
matches := reDuration.FindAllStringSubmatch(s, -1)
if len(matches) == 0 {
return 0, fmt.Errorf("invalid duration format: '%s'", orig)
}
var total time.Duration
for _, m := range matches {
value, unit := m[1], m[2]
val, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, fmt.Errorf("invalid number: %s", value)
}
if multiplier, ok := unitMap[unit]; ok {
total += time.Duration(val * float64(multiplier))
} else {
dur, err := time.ParseDuration(value + unit)
if err != nil {
return 0, fmt.Errorf("invalid duration unit: %s", value+unit)
}
total += dur
}
}
if neg {
total = -total
}
return total, nil
}
type Duration time.Duration
func (d Duration) Duration() time.Duration {
@ -26,7 +86,7 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
return err
}
if v, err := time.ParseDuration(s); err == nil {
if v, err := ParseDuration(s); err == nil {
*d = Duration(v)
return nil
}

View file

@ -5,6 +5,7 @@ package confopt
import (
"encoding/json"
"fmt"
"math"
"strings"
"testing"
"time"
@ -15,6 +16,80 @@ import (
"gopkg.in/yaml.v2"
)
func TestParseDuration(t *testing.T) {
tests := map[string]struct {
input string
wantDuration time.Duration
wantErr bool
}{
"nanoseconds": {input: "10ns", wantDuration: 10 * time.Nanosecond},
"microseconds": {input: "10us", wantDuration: 10 * time.Microsecond},
"milliseconds": {input: "10ms", wantDuration: 10 * time.Millisecond},
"seconds": {input: "10s", wantDuration: 10 * time.Second},
"minutes": {input: "10m", wantDuration: 10 * time.Minute},
"hours": {input: "10h", wantDuration: 10 * time.Hour},
"days": {input: "10d", wantDuration: 10 * 24 * time.Hour},
"weeks (w)": {input: "10w", wantDuration: 10 * 7 * 24 * time.Hour},
"weeks (wk)": {input: "10wk", wantDuration: 10 * 7 * 24 * time.Hour},
"months (mo)": {input: "10mo", wantDuration: 10 * 30 * 24 * time.Hour},
"months (M)": {input: "10M", wantDuration: 10 * 30 * 24 * time.Hour},
"years": {input: "10y", wantDuration: 10 * 365 * 24 * time.Hour},
"negative units": {input: "-10d", wantDuration: -10 * 24 * time.Hour},
"mixed units": {
input: "1y2M3w4d5h6m7s8ms9us10ns",
wantDuration: (1 * 365 * 24 * time.Hour) +
(2 * 30 * 24 * time.Hour) +
(3 * 7 * 24 * time.Hour) +
(4 * 24 * time.Hour) +
(5 * time.Hour) +
(6 * time.Minute) +
(7 * time.Second) +
(8 * time.Millisecond) +
(9 * time.Microsecond) +
(10 * time.Nanosecond),
},
"mixed units with spaces": {
input: "1y 2M 3w 4d 5h 6m 7s 8ms 9us 10ns",
wantDuration: (1 * 365 * 24 * time.Hour) +
(2 * 30 * 24 * time.Hour) +
(3 * 7 * 24 * time.Hour) +
(4 * 24 * time.Hour) +
(5 * time.Hour) +
(6 * time.Minute) +
(7 * time.Second) +
(8 * time.Millisecond) +
(9 * time.Microsecond) +
(10 * time.Nanosecond),
},
"mixed units with decimals": {
input: "1.5y2.25M3.75w4.5d5.5h6.5m7.5s8.5ms9.5us10.5ns",
wantDuration: time.Duration(math.Floor(1.5*365*24*float64(time.Hour))) +
time.Duration(math.Floor(2.25*30*24*float64(time.Hour))) +
time.Duration(math.Floor(3.75*7*24*float64(time.Hour))) +
time.Duration(math.Floor(4.5*24*float64(time.Hour))) +
time.Duration(math.Floor(5.5*float64(time.Hour))) +
time.Duration(math.Floor(6.5*float64(time.Minute))) +
time.Duration(math.Floor(7.5*float64(time.Second))) +
time.Duration(math.Floor(8.5*float64(time.Millisecond))) +
time.Duration(math.Floor(9.5*float64(time.Microsecond))) +
time.Duration(math.Floor(10.5*float64(time.Nanosecond))),
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
dur, err := ParseDuration(test.input)
if test.wantErr {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, test.wantDuration, dur)
}
})
}
}
func TestDuration_MarshalYAML(t *testing.T) {
tests := map[string]struct {
d Duration