mirror of
https://github.com/netdata/netdata.git
synced 2025-04-29 23:20:01 +00:00
feat(go.d): add snmp devices discovery (#19720)
* 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:
parent
86b5acbfcf
commit
d373112c44
31 changed files with 1544 additions and 149 deletions
src/go
cmd/godplugin
go.modgo.sumplugin/go.d
agent
collector/snmp
config/go.d/sd
pkg/confopt
|
@ -62,7 +62,7 @@ type config struct {
|
||||||
collectorsDir multipath.MultiPath
|
collectorsDir multipath.MultiPath
|
||||||
collectorsWatchPath []string
|
collectorsWatchPath []string
|
||||||
serviceDiscoveryDir multipath.MultiPath
|
serviceDiscoveryDir multipath.MultiPath
|
||||||
stateFile string
|
varLibDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newConfig(opts *cli.Option, env *envConfig) *config {
|
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.collectorsDir = cfg.initCollectorsDir(opts)
|
||||||
cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env)
|
cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env)
|
||||||
cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir()
|
cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir()
|
||||||
cfg.stateFile = cfg.initStateFile(env)
|
cfg.varLibDir = env.varLibDir
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
@ -156,13 +156,6 @@ func (c *config) initCollectorsWatchPaths(opts *cli.Option, env *envConfig) []st
|
||||||
return append(opts.WatchPath, env.watchPath)
|
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() {
|
func (c *config) mustPluginDir() {
|
||||||
if len(c.pluginDir) == 0 {
|
if len(c.pluginDir) == 0 {
|
||||||
panic("plugin config init: plugin dir is empty")
|
panic("plugin config init: plugin dir is empty")
|
||||||
|
|
|
@ -53,7 +53,7 @@ func main() {
|
||||||
CollectorsConfigDir: cfg.collectorsDir,
|
CollectorsConfigDir: cfg.collectorsDir,
|
||||||
ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir,
|
ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir,
|
||||||
CollectorsConfigWatchPath: cfg.collectorsWatchPath,
|
CollectorsConfigWatchPath: cfg.collectorsWatchPath,
|
||||||
StateFile: cfg.stateFile,
|
VarLibDir: cfg.varLibDir,
|
||||||
RunModule: opts.Module,
|
RunModule: opts.Module,
|
||||||
MinUpdateEvery: opts.UpdateEvery,
|
MinUpdateEvery: opts.UpdateEvery,
|
||||||
})
|
})
|
||||||
|
|
|
@ -44,6 +44,7 @@ require (
|
||||||
github.com/prometheus/prometheus v2.55.1+incompatible
|
github.com/prometheus/prometheus v2.55.1+incompatible
|
||||||
github.com/redis/go-redis/v9 v9.7.1
|
github.com/redis/go-redis/v9 v9.7.1
|
||||||
github.com/sijms/go-ora/v2 v2.8.24
|
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/stretchr/testify v1.10.0
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
github.com/valyala/fastjson v1.6.4
|
github.com/valyala/fastjson v1.6.4
|
||||||
|
@ -144,6 +145,7 @@ require (
|
||||||
go.opentelemetry.io/otel v1.34.0 // indirect
|
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.34.0 // indirect
|
go.opentelemetry.io/otel/metric v1.34.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace 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/crypto v0.33.0 // indirect
|
||||||
golang.org/x/mod v0.22.0 // indirect
|
golang.org/x/mod v0.22.0 // indirect
|
||||||
golang.org/x/oauth2 v0.25.0 // indirect
|
golang.org/x/oauth2 v0.25.0 // indirect
|
||||||
|
|
|
@ -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.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
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 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
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=
|
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.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
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.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/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.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
|
|
@ -34,7 +34,7 @@ type Config struct {
|
||||||
CollectorsConfigDir []string
|
CollectorsConfigDir []string
|
||||||
CollectorsConfigWatchPath []string
|
CollectorsConfigWatchPath []string
|
||||||
ServiceDiscoveryConfigDir []string
|
ServiceDiscoveryConfigDir []string
|
||||||
StateFile string
|
VarLibDir string
|
||||||
ModuleRegistry module.Registry
|
ModuleRegistry module.Registry
|
||||||
RunModule string
|
RunModule string
|
||||||
MinUpdateEvery int
|
MinUpdateEvery int
|
||||||
|
@ -51,7 +51,7 @@ type Agent struct {
|
||||||
CollectorsConfigWatchPath []string
|
CollectorsConfigWatchPath []string
|
||||||
ServiceDiscoveryConfigDir multipath.MultiPath
|
ServiceDiscoveryConfigDir multipath.MultiPath
|
||||||
|
|
||||||
StateFile string
|
VarLibDir string
|
||||||
|
|
||||||
RunModule string
|
RunModule string
|
||||||
MinUpdateEvery int
|
MinUpdateEvery int
|
||||||
|
@ -75,13 +75,13 @@ func New(cfg Config) *Agent {
|
||||||
CollectorsConfDir: cfg.CollectorsConfigDir,
|
CollectorsConfDir: cfg.CollectorsConfigDir,
|
||||||
ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir,
|
ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir,
|
||||||
CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath,
|
CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath,
|
||||||
StateFile: cfg.StateFile,
|
VarLibDir: cfg.VarLibDir,
|
||||||
RunModule: cfg.RunModule,
|
RunModule: cfg.RunModule,
|
||||||
MinUpdateEvery: cfg.MinUpdateEvery,
|
MinUpdateEvery: cfg.MinUpdateEvery,
|
||||||
ModuleRegistry: module.DefaultRegistry,
|
ModuleRegistry: module.DefaultRegistry,
|
||||||
Out: safewriter.Stdout,
|
Out: safewriter.Stdout,
|
||||||
api: netdataapi.New(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 := jobmgr.New()
|
||||||
jobMgr.PluginName = a.Name
|
jobMgr.PluginName = a.Name
|
||||||
jobMgr.Out = a.Out
|
jobMgr.Out = a.Out
|
||||||
jobMgr.StateFile = a.StateFile
|
jobMgr.VarLibDir = a.VarLibDir
|
||||||
jobMgr.Modules = enabledModules
|
jobMgr.Modules = enabledModules
|
||||||
jobMgr.ConfigDefaults = discCfg.Registry
|
jobMgr.ConfigDefaults = discCfg.Registry
|
||||||
jobMgr.FnReg = fnMgr
|
jobMgr.FnReg = fnMgr
|
||||||
|
|
|
@ -119,12 +119,18 @@ func (m *Manager) registerDiscoverers(cfg Config) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) {
|
func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) {
|
||||||
|
done := make(chan struct{})
|
||||||
updates := make(chan []*confgroup.Group)
|
updates := make(chan []*confgroup.Group)
|
||||||
go d.Run(ctx, updates)
|
|
||||||
|
go func() { defer close(done); d.Run(ctx, updates) }()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second * 10):
|
||||||
|
}
|
||||||
return
|
return
|
||||||
case groups, ok := <-updates:
|
case groups, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
|
@ -18,7 +18,7 @@ func (g *targetGroup) Source() string { return g.source }
|
||||||
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
||||||
|
|
||||||
type target struct {
|
type target struct {
|
||||||
model.Base
|
model.Base `hash:"ignore"`
|
||||||
|
|
||||||
hash uint64
|
hash uint64
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ func (g *targetGroup) Source() string { return g.source }
|
||||||
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
||||||
|
|
||||||
type target struct {
|
type target struct {
|
||||||
model.Base
|
model.Base `hash:"ignore"`
|
||||||
|
|
||||||
hash uint64
|
hash uint64
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package entnum
|
package snmpsd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
|
@ -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()
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 }
|
|
@ -51,7 +51,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
a.Info("all discoverers exited")
|
a.Info("all discoverers exited")
|
||||||
case <-time.After(time.Second * 3):
|
case <-time.After(time.Second * 10):
|
||||||
a.Warning("not all discoverers exited")
|
a.Warning("not all discoverers exited")
|
||||||
}
|
}
|
||||||
a.trySend(in)
|
a.trySend(in)
|
||||||
|
@ -83,7 +83,7 @@ func (a *accumulator) runDiscoverer(ctx context.Context, d model.Discoverer, upd
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
select {
|
select {
|
||||||
case <-done:
|
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)
|
a.Warningf("discoverer '%v' didn't exit on ctx done", d)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
@ -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/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/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/netlisteners"
|
||||||
|
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
@ -28,6 +29,7 @@ type DiscoveryConfig struct {
|
||||||
NetListeners netlisteners.Config `yaml:"net_listeners"`
|
NetListeners netlisteners.Config `yaml:"net_listeners"`
|
||||||
Docker dockerd.Config `yaml:"docker"`
|
Docker dockerd.Config `yaml:"docker"`
|
||||||
K8s []kubernetes.Config `yaml:"k8s"`
|
K8s []kubernetes.Config `yaml:"k8s"`
|
||||||
|
SNMP snmpsd.Config `yaml:"snmp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClassifyRuleConfig struct {
|
type ClassifyRuleConfig struct {
|
||||||
|
@ -71,7 +73,7 @@ func validateDiscoveryConfig(config []DiscoveryConfig) error {
|
||||||
}
|
}
|
||||||
for _, cfg := range config {
|
for _, cfg := range config {
|
||||||
switch cfg.Discoverer {
|
switch cfg.Discoverer {
|
||||||
case "net_listeners", "docker", "k8s":
|
case "net_listeners", "docker", "k8s", "snmp":
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
|
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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/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/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/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/discovery/sd/model"
|
||||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/hostinfo"
|
"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)
|
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:
|
default:
|
||||||
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
|
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():
|
case <-ctx.Done():
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case <-time.After(time.Second * 4):
|
case <-time.After(time.Second * 10):
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
case <-done:
|
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 {
|
func (p *Pipeline) processGroups(tggs []model.TargetGroup) []*confgroup.Group {
|
||||||
var groups []*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 {
|
for _, tgg := range tggs {
|
||||||
p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets()))
|
p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets()))
|
||||||
if v := p.processGroup(tgg); v != nil {
|
if v := p.processGroup(tgg); v != nil {
|
||||||
|
|
|
@ -16,14 +16,21 @@ type Data interface {
|
||||||
Updated() <-chan struct{}
|
Updated() <-chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Save(path string, data interface{ Bytes() ([]byte, error) }) {
|
||||||
|
if path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
New(path).flush(data)
|
||||||
|
}
|
||||||
|
|
||||||
func New(path string) *Persister {
|
func New(path string) *Persister {
|
||||||
return &Persister{
|
return &Persister{
|
||||||
Logger: logger.New().With(
|
Logger: logger.New().With(
|
||||||
slog.String("component", "file persister"),
|
slog.String("component", "file persister"),
|
||||||
slog.String("file", path),
|
slog.String("file", path),
|
||||||
),
|
),
|
||||||
|
FlushEvery: time.Minute * 1,
|
||||||
filepath: path,
|
filepath: path,
|
||||||
flushEvery: time.Second * 5,
|
|
||||||
flushCh: make(chan struct{}, 1),
|
flushCh: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -31,10 +38,11 @@ func New(path string) *Persister {
|
||||||
type Persister struct {
|
type Persister struct {
|
||||||
*logger.Logger
|
*logger.Logger
|
||||||
|
|
||||||
data Data
|
FlushEvery time.Duration
|
||||||
filepath string
|
|
||||||
flushEvery time.Duration
|
data Data
|
||||||
flushCh chan struct{}
|
filepath string
|
||||||
|
flushCh chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Persister) Run(ctx context.Context, data Data) {
|
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
|
p.data = data
|
||||||
|
|
||||||
tk := time.NewTicker(p.flushEvery)
|
tk := time.NewTicker(p.FlushEvery)
|
||||||
defer tk.Stop()
|
defer tk.Stop()
|
||||||
defer p.flush()
|
defer p.flush(p.data)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
@ -70,14 +78,14 @@ func (p *Persister) triggerFlush() {
|
||||||
func (p *Persister) tryFlush() {
|
func (p *Persister) tryFlush() {
|
||||||
select {
|
select {
|
||||||
case <-p.flushCh:
|
case <-p.flushCh:
|
||||||
p.flush()
|
p.flush(p.data)
|
||||||
default:
|
default:
|
||||||
// no pending flush
|
// no pending flush
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Persister) flush() {
|
func (p *Persister) flush(data interface{ Bytes() ([]byte, error) }) {
|
||||||
bs, err := p.data.Bytes()
|
bs, err := data.Bytes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.Debugf("failed to marshal data: %v", err)
|
p.Debugf("failed to marshal data: %v", err)
|
||||||
return
|
return
|
||||||
|
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
@ -13,14 +14,18 @@ import (
|
||||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
|
"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() {
|
func (m *Manager) loadFileStatus() {
|
||||||
m.fileStatus = newFileStatus()
|
m.fileStatus = newFileStatus()
|
||||||
|
|
||||||
if isTerminal || m.StateFile == "" {
|
if isTerminal || m.VarLibDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := loadFileStatus(m.StateFile)
|
s, err := loadFileStatus(statusFileName(m.VarLibDir))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
m.Warningf("failed to load state file: %v", err)
|
m.Warningf("failed to load state file: %v", err)
|
||||||
return
|
return
|
||||||
|
@ -29,10 +34,12 @@ func (m *Manager) loadFileStatus() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) runFileStatusPersistence() {
|
func (m *Manager) runFileStatusPersistence() {
|
||||||
if m.StateFile == "" {
|
if m.VarLibDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := filepersister.New(m.StateFile)
|
|
||||||
|
p := filepersister.New(statusFileName(m.VarLibDir))
|
||||||
|
|
||||||
p.Run(m.ctx, m.fileStatus)
|
p.Run(m.ctx, m.fileStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -60,7 +60,7 @@ type Manager struct {
|
||||||
Out io.Writer
|
Out io.Writer
|
||||||
Modules module.Registry
|
Modules module.Registry
|
||||||
ConfigDefaults confgroup.Registry
|
ConfigDefaults confgroup.Registry
|
||||||
StateFile string
|
VarLibDir string
|
||||||
FnReg FunctionRegistry
|
FnReg FunctionRegistry
|
||||||
Vnodes map[string]*vnodes.VirtualNode
|
Vnodes map[string]*vnodes.VirtualNode
|
||||||
|
|
||||||
|
|
|
@ -162,8 +162,8 @@ func (c *Collector) addNetIfaceCharts(iface *netInterface) {
|
||||||
for _, chart := range *charts {
|
for _, chart := range *charts {
|
||||||
chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName))
|
chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName))
|
||||||
chart.Labels = []module.Label{
|
chart.Labels = []module.Label{
|
||||||
{Key: "vendor", Value: c.sysInfo.organization},
|
{Key: "vendor", Value: c.sysInfo.Organization},
|
||||||
{Key: "sysName", Value: c.sysInfo.name},
|
{Key: "sysName", Value: c.sysInfo.Name},
|
||||||
{Key: "ifDescr", Value: iface.ifDescr},
|
{Key: "ifDescr", Value: iface.ifDescr},
|
||||||
{Key: "ifName", Value: iface.ifName},
|
{Key: "ifName", Value: iface.ifName},
|
||||||
{Key: "ifType", Value: ifTypeMapping[iface.ifType]},
|
{Key: "ifType", Value: ifTypeMapping[iface.ifType]},
|
||||||
|
@ -191,8 +191,8 @@ func (c *Collector) removeNetIfaceCharts(iface *netInterface) {
|
||||||
func (c *Collector) addSysUptimeChart() {
|
func (c *Collector) addSysUptimeChart() {
|
||||||
chart := uptimeChart.Copy()
|
chart := uptimeChart.Copy()
|
||||||
chart.Labels = []module.Label{
|
chart.Labels = []module.Label{
|
||||||
{Key: "vendor", Value: c.sysInfo.organization},
|
{Key: "vendor", Value: c.sysInfo.Organization},
|
||||||
{Key: "sysName", Value: c.sysInfo.name},
|
{Key: "sysName", Value: c.sysInfo.Name},
|
||||||
}
|
}
|
||||||
if err := c.Charts().Add(chart); err != nil {
|
if err := c.Charts().Add(chart); err != nil {
|
||||||
c.Warning(err)
|
c.Warning(err)
|
||||||
|
|
|
@ -3,10 +3,12 @@
|
||||||
package snmp
|
package snmp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"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/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
@ -15,7 +17,7 @@ import (
|
||||||
|
|
||||||
func (c *Collector) collect() (map[string]int64, error) {
|
func (c *Collector) collect() (map[string]int64, error) {
|
||||||
if c.sysInfo == nil {
|
if c.sysInfo == nil {
|
||||||
si, err := c.getSysInfo()
|
si, err := snmpsd.GetSysInfo(c.snmpClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -49,6 +51,24 @@ func (c *Collector) collect() (map[string]int64, error) {
|
||||||
return mx, nil
|
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) {
|
func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
|
||||||
if c.snmpClient.Version() == gosnmp.Version1 {
|
if c.snmpClient.Version() == gosnmp.Version1 {
|
||||||
return c.snmpClient.WalkAll(rootOid)
|
return c.snmpClient.WalkAll(rootOid)
|
||||||
|
@ -56,12 +76,12 @@ func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
|
||||||
return c.snmpClient.BulkWalkAll(rootOid)
|
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 == "" {
|
if c.Vnode.GUID == "" {
|
||||||
c.Vnode.GUID = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(c.Hostname)).String()
|
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 != "" })
|
i := slices.IndexFunc(hostnames, func(s string) bool { return s != "" })
|
||||||
|
|
||||||
c.Vnode.Hostname = fmt.Sprintf("%s(%s)", hostnames[i], c.Hostname)
|
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 {
|
for k, v := range c.Vnode.Labels {
|
||||||
labels[k] = v
|
labels[k] = v
|
||||||
}
|
}
|
||||||
if si.descr != "" {
|
if si.Descr != "" {
|
||||||
labels["sysDescr"] = si.descr
|
labels["sysDescr"] = si.Descr
|
||||||
}
|
}
|
||||||
if si.contact != "" {
|
if si.Contact != "" {
|
||||||
labels["sysContact"] = si.contact
|
labels["sysContact"] = si.Contact
|
||||||
}
|
}
|
||||||
if si.location != "" {
|
if si.Location != "" {
|
||||||
labels["sysLocation"] = si.location
|
labels["sysLocation"] = si.Location
|
||||||
}
|
}
|
||||||
// FIXME: vendor should be obtained from sysDescr, org should be used as a fallback
|
// 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{
|
return &vnodes.VirtualNode{
|
||||||
GUID: c.Vnode.GUID,
|
GUID: c.Vnode.GUID,
|
||||||
|
|
|
@ -1,90 +1,3 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
package snmp
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/netdata/netdata/go/plugins/pkg/matcher"
|
"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/module"
|
||||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
|
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
|
||||||
|
|
||||||
|
@ -76,7 +77,7 @@ type Collector struct {
|
||||||
|
|
||||||
netInterfaces map[string]*netInterface
|
netInterfaces map[string]*netInterface
|
||||||
|
|
||||||
sysInfo *sysInfo
|
sysInfo *snmpsd.SysInfo
|
||||||
|
|
||||||
customOids []string
|
customOids []string
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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/netdata/netdata/go/plugins/plugin/go.d/agent/module"
|
||||||
|
|
||||||
"github.com/golang/mock/gomock"
|
"github.com/golang/mock/gomock"
|
||||||
|
@ -581,15 +582,15 @@ func setMockClientInitExpect(m *snmpmock.MockHandler) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setMockClientSysExpect(m *snmpmock.MockHandler) {
|
func setMockClientSysExpect(m *snmpmock.MockHandler) {
|
||||||
m.EXPECT().WalkAll(rootOidMibSystem).Return([]gosnmp.SnmpPDU{
|
m.EXPECT().WalkAll(snmpsd.RootOidMibSystem).Return([]gosnmp.SnmpPDU{
|
||||||
{Name: oidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
|
{Name: snmpsd.OidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
|
||||||
{Name: oidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
|
{Name: snmpsd.OidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
|
||||||
{Name: oidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
|
{Name: snmpsd.OidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
|
||||||
{Name: oidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
|
{Name: snmpsd.OidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
|
||||||
{Name: oidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
|
{Name: snmpsd.OidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
|
||||||
}, nil).MinTimes(1)
|
}, nil).MinTimes(1)
|
||||||
|
|
||||||
m.EXPECT().Get([]string{oidSysUptime}).Return(&gosnmp.SnmpPacket{
|
m.EXPECT().Get([]string{snmpsd.OidSysUptime}).Return(&gosnmp.SnmpPacket{
|
||||||
Variables: []gosnmp.SnmpPDU{
|
Variables: []gosnmp.SnmpPDU{
|
||||||
{Value: uint32(6048), Type: gosnmp.TimeTicks},
|
{Value: uint32(6048), Type: gosnmp.TimeTicks},
|
||||||
},
|
},
|
||||||
|
|
98
src/go/plugin/go.d/config/go.d/sd/snmp.conf
Normal file
98
src/go/plugin/go.d/config/go.d/sd/snmp.conf
Normal 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 }}
|
|
@ -5,10 +5,70 @@ package confopt
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"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
|
type Duration time.Duration
|
||||||
|
|
||||||
func (d Duration) Duration() time.Duration {
|
func (d Duration) Duration() time.Duration {
|
||||||
|
@ -26,7 +86,7 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if v, err := time.ParseDuration(s); err == nil {
|
if v, err := ParseDuration(s); err == nil {
|
||||||
*d = Duration(v)
|
*d = Duration(v)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ package confopt
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
@ -15,6 +16,80 @@ import (
|
||||||
"gopkg.in/yaml.v2"
|
"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) {
|
func TestDuration_MarshalYAML(t *testing.T) {
|
||||||
tests := map[string]struct {
|
tests := map[string]struct {
|
||||||
d Duration
|
d Duration
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue