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
Ilya Mashchenko committed
Mar 3, 2025 at 15:07 UTC
d373112c44e8806738a49f90570ba7df545b2a3c
31 files changed
+1544
-149
src/go/cmd/godplugin/config.go
+2
-9
@@ -62,7 +62,7 @@ type config struct {
62
collectorsDir multipath.MultiPath
63
collectorsWatchPath []string
64
serviceDiscoveryDir multipath.MultiPath
65
- stateFile string
65
+ varLibDir string
66
}
67
68
func newConfig(opts *cli.Option, env *envConfig) *config {
@@ -74,7 +74,7 @@ func newConfig(opts *cli.Option, env *envConfig) *config {
74
cfg.collectorsDir = cfg.initCollectorsDir(opts)
75
cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env)
76
cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir()
77
- cfg.stateFile = cfg.initStateFile(env)
77
+ cfg.varLibDir = env.varLibDir
78
79
return cfg
80
}
@@ -156,13 +156,6 @@ func (c *config) initCollectorsWatchPaths(opts *cli.Option, env *envConfig) []st
156
return append(opts.WatchPath, env.watchPath)
157
}
158
159
-func (c *config) initStateFile(env *envConfig) string {
160
- if env.varLibDir == "" {
161
- return ""
162
- }
163
- return filepath.Join(env.varLibDir, "god-jobs-statuses.json")
164
-}
165
-
159
func (c *config) mustPluginDir() {
160
if len(c.pluginDir) == 0 {
161
panic("plugin config init: plugin dir is empty")
src/go/cmd/godplugin/main.go
+1
-1
@@ -53,7 +53,7 @@ func main() {
53
CollectorsConfigDir: cfg.collectorsDir,
54
ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir,
55
CollectorsConfigWatchPath: cfg.collectorsWatchPath,
56
- StateFile: cfg.stateFile,
56
+ VarLibDir: cfg.varLibDir,
57
RunModule: opts.Module,
58
MinUpdateEvery: opts.UpdateEvery,
59
})
src/go/go.mod
+2
@@ -44,6 +44,7 @@ require (
44
github.com/prometheus/prometheus v2.55.1+incompatible
45
github.com/redis/go-redis/v9 v9.7.1
46
github.com/sijms/go-ora/v2 v2.8.24
47
+ github.com/sourcegraph/conc v0.3.0
48
github.com/stretchr/testify v1.10.0
49
github.com/tidwall/gjson v1.18.0
50
github.com/valyala/fastjson v1.6.4
@@ -144,6 +145,7 @@ require (
145
go.opentelemetry.io/otel v1.34.0 // indirect
146
go.opentelemetry.io/otel/metric v1.34.0 // indirect
147
go.opentelemetry.io/otel/trace v1.34.0 // indirect
148
+ go.uber.org/multierr v1.11.0 // indirect
149
golang.org/x/crypto v0.33.0 // indirect
150
golang.org/x/mod v0.22.0 // indirect
151
golang.org/x/oauth2 v0.25.0 // indirect
src/go/go.sum
+4
@@ -409,6 +409,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
409
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
410
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
411
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
412
+github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
413
+github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
414
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
415
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
416
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=
490
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
491
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
492
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
493
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
494
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
495
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
496
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
497
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
src/go/plugin/go.d/agent/agent.go
+5
-5
@@ -34,7 +34,7 @@ type Config struct {
34
CollectorsConfigDir []string
35
CollectorsConfigWatchPath []string
36
ServiceDiscoveryConfigDir []string
37
- StateFile string
37
+ VarLibDir string
38
ModuleRegistry module.Registry
39
RunModule string
40
MinUpdateEvery int
@@ -51,7 +51,7 @@ type Agent struct {
51
CollectorsConfigWatchPath []string
52
ServiceDiscoveryConfigDir multipath.MultiPath
53
54
- StateFile string
54
+ VarLibDir string
55
56
RunModule string
57
MinUpdateEvery int
@@ -75,13 +75,13 @@ func New(cfg Config) *Agent {
75
CollectorsConfDir: cfg.CollectorsConfigDir,
76
ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir,
77
CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath,
78
- StateFile: cfg.StateFile,
78
+ VarLibDir: cfg.VarLibDir,
79
RunModule: cfg.RunModule,
80
MinUpdateEvery: cfg.MinUpdateEvery,
81
ModuleRegistry: module.DefaultRegistry,
82
Out: safewriter.Stdout,
83
api: netdataapi.New(safewriter.Stdout),
84
- quitCh: make(chan struct{}),
84
+ quitCh: make(chan struct{}, 1),
85
}
86
}
87
@@ -194,7 +194,7 @@ func (a *Agent) run(ctx context.Context) {
194
jobMgr := jobmgr.New()
195
jobMgr.PluginName = a.Name
196
jobMgr.Out = a.Out
197
- jobMgr.StateFile = a.StateFile
197
+ jobMgr.VarLibDir = a.VarLibDir
198
jobMgr.Modules = enabledModules
199
jobMgr.ConfigDefaults = discCfg.Registry
200
jobMgr.FnReg = fnMgr
src/go/plugin/go.d/agent/discovery/manager.go
+7
-1
@@ -119,12 +119,18 @@ func (m *Manager) registerDiscoverers(cfg Config) error {
119
}
120
121
func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) {
122
+ done := make(chan struct{})
123
updates := make(chan []*confgroup.Group)
123
- go d.Run(ctx, updates)
124
+
125
+ go func() { defer close(done); d.Run(ctx, updates) }()
126
127
for {
128
select {
129
case <-ctx.Done():
130
+ select {
131
+ case <-done:
132
+ case <-time.After(time.Second * 10):
133
+ }
134
return
135
case groups, ok := <-updates:
136
if !ok {
src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go
+1
-1
@@ -18,7 +18,7 @@ func (g *targetGroup) Source() string { return g.source }
18
func (g *targetGroup) Targets() []model.Target { return g.targets }
19
20
type target struct {
21
- model.Base
21
+ model.Base `hash:"ignore"`
22
23
hash uint64
24
src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go
+1
-1
@@ -20,7 +20,7 @@ func (g *targetGroup) Source() string { return g.source }
20
func (g *targetGroup) Targets() []model.Target { return g.targets }
21
22
type target struct {
23
- model.Base
23
+ model.Base `hash:"ignore"`
24
25
hash uint64
26
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go
new
+210
@@ -0,0 +1,210 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "fmt"
7
+
8
+ "github.com/gosnmp/gosnmp"
9
+
10
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/confopt"
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
12
+)
13
+
14
+type (
15
+ Config struct {
16
+ // RescanInterval defines how often to scan the networks for devices (default: 30m)
17
+ RescanInterval confopt.Duration `yaml:"rescan_interval"`
18
+ // Timeout defines the maximum time to wait for SNMP device responses (default: 1s)
19
+ Timeout confopt.Duration `yaml:"timeout"`
20
+ // DeviceCacheTTL defines how long to trust cached discovery results before requiring a new probe (default: 12h)
21
+ DeviceCacheTTL confopt.Duration `yaml:"device_cache_ttl"`
22
+ // ParallelScansPerNetwork defines how many IPs to scan concurrently within each subnet (default: 32)
23
+ ParallelScansPerNetwork int `yaml:"parallel_scans_per_network"`
24
+ // Credentials define the SNMP credentials used for authentication
25
+ Credentials []CredentialConfig `yaml:"credentials"`
26
+ // Networks defines the subnets to scan and which credentials to use
27
+ Networks []NetworkConfig `yaml:"networks"`
28
+ }
29
+
30
+ NetworkConfig struct {
31
+ // Subnet is the IP range to scan, supporting various formats
32
+ // https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
33
+ Subnet string `yaml:"subnet"`
34
+ // Credential is the name of a credential from the Credentials list
35
+ Credential string `yaml:"credential"`
36
+ }
37
+ CredentialConfig struct {
38
+ // Name is the identifier for this credential set, used in Network.Credential
39
+ Name string `yaml:"name"`
40
+ // Version must be one of: "1", "2c", or "3"
41
+ Version string `yaml:"version"`
42
+ // Community is the SNMP community string (used in v1 and v2c)
43
+ Community string `yaml:"community"`
44
+ // UserName is the SNMPv3 username
45
+ UserName string `yaml:"username"`
46
+ // SecurityLevel must be one of: "noAuthNoPriv", "authNoPriv", or "authPriv" (for SNMPv3)
47
+ SecurityLevel string `yaml:"security_level"`
48
+ // AuthProtocol must be one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" (for SNMPv3)
49
+ AuthProtocol string `yaml:"auth_protocol"`
50
+ // AuthPassphrase is the authentication passphrase (for SNMPv3)
51
+ AuthPassphrase string `yaml:"auth_passphrase"`
52
+ // PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" (for SNMPv3)
53
+ PrivacyProtocol string `yaml:"privacy_protocol"`
54
+ // PrivacyPassphrase is the privacy passphrase (for SNMPv3)
55
+ PrivacyPassphrase string `yaml:"privacy_passphrase"`
56
+ }
57
+)
58
+
59
+func (c *Config) validateAndParse() ([]subnet, error) {
60
+ if len(c.Credentials) == 0 {
61
+ return nil, fmt.Errorf("no credentials provided")
62
+ }
63
+ if len(c.Networks) == 0 {
64
+ return nil, fmt.Errorf("no networks provided")
65
+ }
66
+
67
+ credentials := make(map[string]CredentialConfig)
68
+
69
+ for i, cr := range c.Credentials {
70
+ if cr.Name == "" {
71
+ return nil, fmt.Errorf("no name provided for credential %d", i)
72
+ }
73
+ if _, ok := credentials[cr.Name]; ok {
74
+ return nil, fmt.Errorf("duplicate credential name: %s", cr.Name)
75
+ }
76
+ credentials[cr.Name] = c.Credentials[i]
77
+ }
78
+
79
+ networks := make(map[string]bool)
80
+
81
+ var subnets []subnet
82
+
83
+ for i, n := range c.Networks {
84
+ if n.Subnet == "" {
85
+ return nil, fmt.Errorf("no subnet provided for network %d", i)
86
+ }
87
+ if n.Credential == "" {
88
+ return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
89
+ }
90
+ if _, ok := credentials[n.Credential]; !ok {
91
+ return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
92
+ }
93
+
94
+ r, err := iprange.ParseRange(n.Subnet)
95
+ if err != nil {
96
+ return nil, fmt.Errorf("invalid subnet range '%s': %v", n.Subnet, err)
97
+ }
98
+
99
+ // Limit subnet size to /23 or smaller (512 IPs max per subnet)
100
+ // This prevents accidental scanning of excessively large networks.
101
+ if s := r.Size().Int64(); s > 512 {
102
+ return nil, fmt.Errorf("subnet '%s' exceeds maximum size of /23 (512 IPs, got %d IPs)", n.Subnet, s)
103
+ }
104
+
105
+ sub := subnet{
106
+ str: n.Subnet,
107
+ ips: r,
108
+ credential: credentials[n.Credential],
109
+ }
110
+
111
+ if networks[subKey(sub)] {
112
+ return nil, fmt.Errorf("duplicate subnet '%s'", subKey(sub))
113
+ }
114
+ networks[subKey(sub)] = true
115
+
116
+ subnets = append(subnets, sub)
117
+ }
118
+
119
+ return subnets, nil
120
+}
121
+
122
+func setCredential(client gosnmp.Handler, cred CredentialConfig) {
123
+ switch parseSNMPVersion(cred) {
124
+ case gosnmp.Version1:
125
+ client.SetVersion(gosnmp.Version1)
126
+ client.SetCommunity(cred.Community)
127
+ case gosnmp.Version2c:
128
+ client.SetVersion(gosnmp.Version2c)
129
+ client.SetCommunity(cred.Community)
130
+ case gosnmp.Version3:
131
+ client.SetVersion(gosnmp.Version3)
132
+ client.SetSecurityModel(gosnmp.UserSecurityModel)
133
+ client.SetMsgFlags(parseSNMPv3SecurityLevel(cred))
134
+ client.SetSecurityParameters(&gosnmp.UsmSecurityParameters{
135
+ UserName: cred.UserName,
136
+ AuthenticationProtocol: parseSNMPv3AuthProtocol(cred),
137
+ AuthenticationPassphrase: cred.AuthPassphrase,
138
+ PrivacyProtocol: parseSNMPv3PrivProtocol(cred),
139
+ PrivacyPassphrase: cred.PrivacyPassphrase,
140
+ })
141
+ }
142
+}
143
+
144
+func parseSNMPVersion(cred CredentialConfig) gosnmp.SnmpVersion {
145
+ switch cred.Version {
146
+ case "0", "1":
147
+ return gosnmp.Version1
148
+ case "2", "2c", "":
149
+ return gosnmp.Version2c
150
+ case "3":
151
+ return gosnmp.Version3
152
+ default:
153
+ return gosnmp.Version2c
154
+ }
155
+}
156
+
157
+func parseSNMPv3SecurityLevel(cred CredentialConfig) gosnmp.SnmpV3MsgFlags {
158
+ switch cred.SecurityLevel {
159
+ case "1", "none", "noAuthNoPriv", "":
160
+ return gosnmp.NoAuthNoPriv
161
+ case "2", "authNoPriv":
162
+ return gosnmp.AuthNoPriv
163
+ case "3", "authPriv":
164
+ return gosnmp.AuthPriv
165
+ default:
166
+ return gosnmp.NoAuthNoPriv
167
+ }
168
+}
169
+
170
+func parseSNMPv3AuthProtocol(cred CredentialConfig) gosnmp.SnmpV3AuthProtocol {
171
+ switch cred.AuthProtocol {
172
+ case "1", "none", "noAuth", "":
173
+ return gosnmp.NoAuth
174
+ case "2", "md5", "MD5":
175
+ return gosnmp.MD5
176
+ case "3", "sha", "SHA":
177
+ return gosnmp.SHA
178
+ case "4", "sha224", "SHA224":
179
+ return gosnmp.SHA224
180
+ case "5", "sha256", "SHA256":
181
+ return gosnmp.SHA256
182
+ case "6", "sha384", "SHA384":
183
+ return gosnmp.SHA384
184
+ case "7", "sha512", "SHA512":
185
+ return gosnmp.SHA512
186
+ default:
187
+ return gosnmp.NoAuth
188
+ }
189
+}
190
+
191
+func parseSNMPv3PrivProtocol(cred CredentialConfig) gosnmp.SnmpV3PrivProtocol {
192
+ switch cred.PrivacyProtocol {
193
+ case "1", "none", "noPriv", "":
194
+ return gosnmp.NoPriv
195
+ case "2", "des", "DES":
196
+ return gosnmp.DES
197
+ case "3", "aes", "AES":
198
+ return gosnmp.AES
199
+ case "4", "aes192", "AES192":
200
+ return gosnmp.AES192
201
+ case "5", "aes256", "AES256":
202
+ return gosnmp.AES256
203
+ case "6", "aes192c", "AES192C":
204
+ return gosnmp.AES192C
205
+ case "7", "aes256c", "AES256C":
206
+ return gosnmp.AES256C
207
+ default:
208
+ return gosnmp.NoPriv
209
+ }
210
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go
new
+266
@@ -0,0 +1,266 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "context"
7
+ "fmt"
8
+ "log/slog"
9
+ "sync/atomic"
10
+ "time"
11
+
12
+ "github.com/gohugoio/hashstructure"
13
+ "github.com/gosnmp/gosnmp"
14
+ "github.com/sourcegraph/conc/pool"
15
+
16
+ "github.com/netdata/netdata/go/plugins/logger"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
18
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
19
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
20
+)
21
+
22
+const (
23
+ defaultRescanInterval = time.Minute * 30
24
+ defaultTimeout = time.Second * 1
25
+ defaultParallelScansPerNetwork = 32
26
+ defaultDeviceCacheTTL = time.Hour * 12
27
+)
28
+
29
+func NewDiscoverer(cfg Config) (*Discoverer, error) {
30
+ subnets, err := cfg.validateAndParse()
31
+ if err != nil {
32
+ return nil, err
33
+ }
34
+
35
+ cfgHash, _ := hashstructure.Hash(cfg, nil)
36
+
37
+ d := &Discoverer{
38
+ Logger: logger.New().With(
39
+ slog.String("component", "service discovery"),
40
+ slog.String("discoverer", "snmp"),
41
+ ),
42
+ started: make(chan struct{}),
43
+ cfgHash: cfgHash,
44
+ subnets: subnets,
45
+ newSnmpClient: func() (gosnmp.Handler, func()) {
46
+ return gosnmp.NewHandler(), func() {}
47
+ },
48
+
49
+ rescanInterval: defaultRescanInterval,
50
+ timeout: defaultTimeout,
51
+ parallelScansPerNetwork: defaultParallelScansPerNetwork,
52
+ deviceCacheTTL: defaultDeviceCacheTTL,
53
+
54
+ firstDiscovery: true,
55
+ status: newDiscoveryStatus(),
56
+ }
57
+
58
+ if cfg.RescanInterval > 0 {
59
+ d.rescanInterval = cfg.RescanInterval.Duration()
60
+ }
61
+ if cfg.Timeout > 0 {
62
+ d.timeout = cfg.Timeout.Duration()
63
+ }
64
+ if cfg.ParallelScansPerNetwork > 0 {
65
+ d.parallelScansPerNetwork = cfg.ParallelScansPerNetwork
66
+ }
67
+ if cfg.DeviceCacheTTL > 0 {
68
+ d.deviceCacheTTL = cfg.DeviceCacheTTL.Duration()
69
+ }
70
+
71
+ return d, nil
72
+}
73
+
74
+type (
75
+ Discoverer struct {
76
+ *logger.Logger
77
+ model.Base
78
+
79
+ started chan struct{}
80
+ cfgHash uint64
81
+
82
+ subnets []subnet
83
+
84
+ newSnmpClient func() (gosnmp.Handler, func())
85
+
86
+ parallelScansPerNetwork int
87
+ rescanInterval time.Duration
88
+ timeout time.Duration
89
+ deviceCacheTTL time.Duration
90
+
91
+ firstDiscovery bool
92
+ status *discoveryStatus
93
+ statusUpdated atomic.Bool
94
+ }
95
+ subnet struct {
96
+ str string
97
+ ips iprange.Range
98
+ credential CredentialConfig
99
+ }
100
+)
101
+
102
+func (d *Discoverer) String() string {
103
+ return "sd:snmp"
104
+}
105
+
106
+func (d *Discoverer) Discover(ctx context.Context, in chan<- []model.TargetGroup) {
107
+ d.Info("instance is started")
108
+ defer func() { d.Info("instance is stopped") }()
109
+
110
+ close(d.started)
111
+
112
+ d.loadFileStatus()
113
+
114
+ d.discoverNetworks(ctx, in)
115
+
116
+ if d.rescanInterval <= 0 {
117
+ filepersister.Save(statusFileName(), d.status)
118
+ return
119
+ }
120
+
121
+ tk := time.NewTicker(d.rescanInterval)
122
+ defer tk.Stop()
123
+
124
+ for {
125
+ select {
126
+ case <-ctx.Done():
127
+ return
128
+ case <-tk.C:
129
+ d.discoverNetworks(ctx, in)
130
+ }
131
+ }
132
+}
133
+
134
+func (d *Discoverer) discoverNetworks(ctx context.Context, in chan<- []model.TargetGroup) {
135
+ now := time.Now()
136
+
137
+ doProbing := !d.firstDiscovery ||
138
+ d.status.ConfigHash != d.cfgHash ||
139
+ now.After(d.status.LastDiscoveryTime.Add(d.rescanInterval))
140
+
141
+ defer func() {
142
+ if isDone(ctx) {
143
+ return
144
+ }
145
+ d.firstDiscovery = false
146
+
147
+ if doProbing {
148
+ d.status.LastDiscoveryTime = now
149
+ }
150
+
151
+ if d.statusUpdated.Swap(false) || d.status.ConfigHash != d.cfgHash {
152
+ d.status.ConfigHash = d.cfgHash
153
+ filepersister.Save(statusFileName(), d.status)
154
+ }
155
+ }()
156
+
157
+ d.Infof("discovery mode: %s", map[bool]string{true: "active probing", false: "using cache"}[doProbing])
158
+
159
+ p := pool.New()
160
+ for _, sub := range d.subnets {
161
+ sub := sub
162
+ p.Go(func() { d.discoverNetwork(ctx, in, sub, doProbing) })
163
+ }
164
+ p.Wait()
165
+}
166
+
167
+func (d *Discoverer) discoverNetwork(ctx context.Context, in chan<- []model.TargetGroup, sub subnet, doProbing bool) {
168
+ tgg := newTargetGroup(sub)
169
+ p := pool.New().WithMaxGoroutines(d.parallelScansPerNetwork)
170
+
171
+ for ip := range sub.ips.Iterate() {
172
+ ipAddr := ip.String()
173
+
174
+ if doProbing {
175
+ p.Go(func() { d.probeIPAddress(ctx, sub, ipAddr, tgg) })
176
+ } else {
177
+ d.useCacheIPAddress(sub, ipAddr, tgg)
178
+ }
179
+ }
180
+ p.Wait()
181
+
182
+ send(ctx, in, tgg)
183
+}
184
+
185
+func (d *Discoverer) useCacheIPAddress(sub subnet, ip string, tgg *targetGroup) {
186
+ if dev := d.status.get(sub, ip); dev != nil {
187
+ tg := newTarget(ip, sub.credential, dev.SysInfo)
188
+ tgg.addTarget(tg)
189
+ }
190
+}
191
+
192
+func (d *Discoverer) probeIPAddress(ctx context.Context, sub subnet, ip string, tgg *targetGroup) {
193
+ if isDone(ctx) {
194
+ return
195
+ }
196
+
197
+ now := time.Now()
198
+
199
+ dev := d.status.get(sub, ip)
200
+
201
+ // Use the cached device if available and not expired
202
+ if dev != nil && now.Before(dev.DiscoverTime.Add(d.deviceCacheTTL)) {
203
+ if d.firstDiscovery {
204
+ untilProbe := dev.DiscoverTime.Add(d.deviceCacheTTL).Sub(now).Round(time.Second)
205
+ d.Infof("device '%s': found in cache (sysName: '%s', network: '%s', next probe in %s)",
206
+ ip, dev.SysInfo.Name, subKey(sub), untilProbe)
207
+ }
208
+ tg := newTarget(ip, sub.credential, dev.SysInfo)
209
+ tgg.addTarget(tg)
210
+ return
211
+ }
212
+
213
+ si, err := d.getSnmpSysInfo(sub, ip)
214
+ if err != nil {
215
+ if dev == nil {
216
+ // First-time discovery failure - log at debug level as this is expected for many IPs
217
+ d.Debugf("device '%s': probe failed (network: '%s'): %v", ip, subKey(sub), err)
218
+ } else {
219
+ // Previously discovered device is now unreachable
220
+ d.Warningf("lost connection to previously discovered SNMP device '%s' (sysName: '%s', network: '%s'): %v",
221
+ ip, dev.SysInfo.Name, subKey(sub), err)
222
+ }
223
+ d.status.del(sub, ip)
224
+ d.statusUpdated.Store(dev != nil)
225
+ return
226
+ }
227
+
228
+ d.Infof("device '%s': successfully discovered (sysName: '%s', network: '%s')", ip, si.Name, subKey(sub))
229
+ d.status.put(sub, ip, &discoveredDevice{DiscoverTime: now, SysInfo: *si})
230
+ d.statusUpdated.Store(true)
231
+ tg := newTarget(ip, sub.credential, *si)
232
+ tgg.addTarget(tg)
233
+}
234
+
235
+func (d *Discoverer) getSnmpSysInfo(sub subnet, ip string) (*SysInfo, error) {
236
+ client, cleanup := d.newSnmpClient()
237
+ defer cleanup()
238
+
239
+ client.SetTarget(ip)
240
+ client.SetTimeout(d.timeout)
241
+ client.SetRetries(0)
242
+ setCredential(client, sub.credential)
243
+
244
+ if err := client.Connect(); err != nil {
245
+ return nil, fmt.Errorf("failed to connect: %v", err)
246
+ }
247
+
248
+ defer func() { _ = client.Close() }()
249
+
250
+ return GetSysInfo(client)
251
+}
252
+
253
+func send(ctx context.Context, in chan<- []model.TargetGroup, tgg model.TargetGroup) {
254
+ select {
255
+ case <-ctx.Done():
256
+ case in <- []model.TargetGroup{tgg}:
257
+ }
258
+}
259
+func isDone(ctx context.Context) bool {
260
+ select {
261
+ case <-ctx.Done():
262
+ return true
263
+ default:
264
+ return false
265
+ }
266
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go
new
+279
@@ -0,0 +1,279 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "strconv"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/stretchr/testify/assert"
11
+ "github.com/stretchr/testify/require"
12
+
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
14
+)
15
+
16
+func TestNewDiscoverer(t *testing.T) {
17
+ tests := map[string]struct {
18
+ cfg Config
19
+ wantFail bool
20
+ }{
21
+ "succeeds with valid SNMPv1 config": {
22
+ wantFail: false,
23
+ cfg: Config{
24
+ Credentials: []CredentialConfig{
25
+ {Name: "v1cred", Version: "1", Community: "public"},
26
+ },
27
+ Networks: []NetworkConfig{
28
+ {Subnet: "192.0.2.0/24", Credential: "v1cred"},
29
+ },
30
+ },
31
+ },
32
+ "succeeds with valid SNMPv2c config": {
33
+ wantFail: false,
34
+ cfg: Config{
35
+ Credentials: []CredentialConfig{
36
+ {Name: "v2cred", Version: "2c", Community: "public"},
37
+ },
38
+ Networks: []NetworkConfig{
39
+ {Subnet: "192.0.2.0/24", Credential: "v2cred"},
40
+ },
41
+ },
42
+ },
43
+ "succeeds with valid SNMPv3 config": {
44
+ wantFail: false,
45
+ cfg: Config{
46
+ Credentials: []CredentialConfig{
47
+ {
48
+ Name: "v3cred",
49
+ Version: "3",
50
+ UserName: "user",
51
+ SecurityLevel: "authPriv",
52
+ AuthProtocol: "sha",
53
+ AuthPassphrase: "authpass",
54
+ PrivacyProtocol: "aes",
55
+ PrivacyPassphrase: "privpass",
56
+ },
57
+ },
58
+ Networks: []NetworkConfig{
59
+ {Subnet: "192.0.2.0/24", Credential: "v3cred"},
60
+ },
61
+ },
62
+ },
63
+ "succeeds with multiple valid credentials and networks": {
64
+ cfg: Config{
65
+ Credentials: []CredentialConfig{
66
+ {Name: "v1cred", Version: "1", Community: "public"},
67
+ {Name: "v2cred", Version: "2c", Community: "private"},
68
+ {
69
+ Name: "v3cred",
70
+ Version: "3",
71
+ UserName: "user",
72
+ SecurityLevel: "authPriv",
73
+ AuthProtocol: "sha",
74
+ AuthPassphrase: "authpass",
75
+ PrivacyProtocol: "aes",
76
+ PrivacyPassphrase: "privpass",
77
+ },
78
+ },
79
+ Networks: []NetworkConfig{
80
+ {Subnet: "192.0.2.0/24", Credential: "v1cred"},
81
+ {Subnet: "10.0.0.0/24", Credential: "v2cred"},
82
+ {Subnet: "172.16.0.0/24", Credential: "v3cred"},
83
+ },
84
+ },
85
+ wantFail: false,
86
+ },
87
+ "fails on empty config": {
88
+ wantFail: true,
89
+ },
90
+ "fails with credentials but no networks": {
91
+ wantFail: true,
92
+ cfg: Config{
93
+ Credentials: []CredentialConfig{
94
+ {Name: "test", Version: "2c", Community: "public"},
95
+ },
96
+ },
97
+ },
98
+ "fails with networks but no credentials": {
99
+ wantFail: true,
100
+ cfg: Config{
101
+ Networks: []NetworkConfig{
102
+ {Subnet: "192.0.2.0/24", Credential: "test"},
103
+ },
104
+ },
105
+ },
106
+ "fails with credential without name": {
107
+ wantFail: true,
108
+ cfg: Config{
109
+ Credentials: []CredentialConfig{
110
+ {Version: "2c", Community: "public"},
111
+ },
112
+ Networks: []NetworkConfig{
113
+ {Subnet: "192.0.2.0/24", Credential: "test"},
114
+ },
115
+ },
116
+ },
117
+ "fails with duplicate credential names": {
118
+ wantFail: true,
119
+ cfg: Config{
120
+ Credentials: []CredentialConfig{
121
+ {Name: "test", Version: "2c", Community: "public"},
122
+ {Name: "test", Version: "2c", Community: "private"},
123
+ },
124
+ Networks: []NetworkConfig{
125
+ {Subnet: "192.0.2.0/24", Credential: "test"},
126
+ },
127
+ },
128
+ },
129
+ "fails with network without subnet": {
130
+ wantFail: true,
131
+ cfg: Config{
132
+ Credentials: []CredentialConfig{
133
+ {Name: "test", Version: "2c", Community: "public"},
134
+ },
135
+ Networks: []NetworkConfig{
136
+ {Credential: "test"},
137
+ },
138
+ },
139
+ },
140
+ "fails with network without credential": {
141
+ wantFail: true,
142
+ cfg: Config{
143
+ Credentials: []CredentialConfig{
144
+ {Name: "test", Version: "2c", Community: "public"},
145
+ },
146
+ Networks: []NetworkConfig{
147
+ {Subnet: "192.0.2.0/24"},
148
+ },
149
+ },
150
+ },
151
+ "fails with network with nonexistent credential": {
152
+ wantFail: true,
153
+ cfg: Config{
154
+ Credentials: []CredentialConfig{
155
+ {Name: "test", Version: "2c", Community: "public"},
156
+ },
157
+ Networks: []NetworkConfig{
158
+ {Subnet: "192.0.2.0/24", Credential: "nonexistent"},
159
+ },
160
+ },
161
+ },
162
+ "fails with invalid subnet format": {
163
+ wantFail: true,
164
+ cfg: Config{
165
+ Credentials: []CredentialConfig{
166
+ {Name: "test", Version: "2c", Community: "public"},
167
+ },
168
+ Networks: []NetworkConfig{
169
+ {Subnet: "invalid-subnet", Credential: "test"},
170
+ },
171
+ },
172
+ },
173
+ "fails with subnet too large (> 512 IPs)": {
174
+ wantFail: true,
175
+ cfg: Config{
176
+ Credentials: []CredentialConfig{
177
+ {Name: "test", Version: "2c", Community: "public"},
178
+ },
179
+ Networks: []NetworkConfig{
180
+ {Subnet: "192.0.2.0/22", Credential: "test"}, // 1024 IPs
181
+ },
182
+ },
183
+ },
184
+ "fails with duplicate subnet": {
185
+ wantFail: true,
186
+ cfg: Config{
187
+ Credentials: []CredentialConfig{
188
+ {Name: "test", Version: "2c", Community: "public"},
189
+ },
190
+ Networks: []NetworkConfig{
191
+ {Subnet: "192.0.2.0/24", Credential: "test"},
192
+ {Subnet: "192.0.2.0/24", Credential: "test"},
193
+ },
194
+ },
195
+ },
196
+ }
197
+
198
+ for name, test := range tests {
199
+ t.Run(name, func(t *testing.T) {
200
+ d, err := NewDiscoverer(test.cfg)
201
+
202
+ if test.wantFail {
203
+ assert.Error(t, err)
204
+ } else {
205
+ assert.NoError(t, err)
206
+ assert.NotNil(t, d)
207
+ }
208
+ })
209
+ }
210
+}
211
+
212
+func TestDiscoverer_Run(t *testing.T) {
213
+ tests := map[string]struct {
214
+ prepareSim func(t *testing.T) *discoverySim
215
+ }{
216
+ "simple discovery": {
217
+ prepareSim: func(t *testing.T) *discoverySim {
218
+ cfg := Config{
219
+ Credentials: []CredentialConfig{
220
+ {Name: "public-v2", Version: "2", Community: "public-v2"},
221
+ },
222
+ Networks: []NetworkConfig{
223
+ {Subnet: "192.0.2.0/29", Credential: "public-v2"},
224
+ },
225
+ }
226
+
227
+ subnets, err := cfg.validateAndParse()
228
+ sub := subnets[0]
229
+ require.NoError(t, err)
230
+
231
+ sim := discoverySim{
232
+ cfg: cfg,
233
+ updateSnmpHandler: func(m *mockSnmpHandler) {
234
+ m.skipOnConnect = func(ip string) bool {
235
+ // Skip if the last octet is odd
236
+ i := strings.LastIndexByte(ip, '.')
237
+ if i == -1 {
238
+ return false
239
+ }
240
+ lastOctet, err := strconv.Atoi(ip[i+1:])
241
+ return err == nil && lastOctet%2 != 0
242
+ }
243
+ },
244
+ wantGroups: []model.TargetGroup{
245
+ prepareNewTargetGroup(sub, "192.0.2.2", "192.0.2.4", "192.0.2.6"),
246
+ },
247
+ }
248
+
249
+ return &sim
250
+ },
251
+ },
252
+ }
253
+
254
+ for name, test := range tests {
255
+ t.Run(name, func(t *testing.T) {
256
+ sim := test.prepareSim(t)
257
+ sim.run(t)
258
+ })
259
+ }
260
+}
261
+
262
+func prepareNewTargetGroup(sub subnet, ips ...string) *targetGroup {
263
+ tgg := newTargetGroup(sub)
264
+ for _, ip := range ips {
265
+ tg := prepareNewTarget(sub, ip)
266
+ tgg.addTarget(tg)
267
+ }
268
+ return tgg
269
+}
270
+
271
+func prepareNewTarget(sub subnet, ip string) *target {
272
+ return newTarget(ip, sub.credential, SysInfo{
273
+ Descr: mockSysDescr,
274
+ Contact: mockSysContact,
275
+ Name: mockSysName,
276
+ Location: mockSysLocation,
277
+ Organization: "net-snmp",
278
+ })
279
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/enterprise-numbers.txt
renamed
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go
renamed
+1
-1
@@ -1,6 +1,6 @@
1
// SPDX-License-Identifier: GPL-3.0-or-later
2
3
-package entnum
3
+package snmpsd
4
5
import (
6
"bufio"
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go
new
+183
@@ -0,0 +1,183 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "context"
7
+ "errors"
8
+ "sort"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+
13
+ "github.com/golang/mock/gomock"
14
+ "github.com/gosnmp/gosnmp"
15
+ snmpmock "github.com/gosnmp/gosnmp/mocks"
16
+ "github.com/stretchr/testify/assert"
17
+ "github.com/stretchr/testify/require"
18
+
19
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
20
+)
21
+
22
+type discoverySim struct {
23
+ cfg Config
24
+ updateSnmpHandler func(m *mockSnmpHandler)
25
+ wantGroups []model.TargetGroup
26
+}
27
+
28
+func (sim *discoverySim) run(t *testing.T) {
29
+ d, err := NewDiscoverer(sim.cfg)
30
+ require.NoError(t, err)
31
+
32
+ d.newSnmpClient = func() (gosnmp.Handler, func()) {
33
+ h, cleanup := prepareMockSnmpHandler(t)
34
+ h.setExpectInit()
35
+ h.setExpectSysInfo()
36
+ if sim.updateSnmpHandler != nil {
37
+ sim.updateSnmpHandler(h)
38
+ }
39
+ return h, cleanup
40
+ }
41
+
42
+ seen := make(map[string]model.TargetGroup)
43
+ ctx, cancel := context.WithCancel(context.Background())
44
+ in := make(chan []model.TargetGroup)
45
+ var wg sync.WaitGroup
46
+
47
+ wg.Add(1)
48
+ go func() {
49
+ defer wg.Done()
50
+ d.Discover(ctx, in)
51
+ }()
52
+
53
+ wg.Add(1)
54
+ go func() {
55
+ defer wg.Done()
56
+ for {
57
+ select {
58
+ case <-ctx.Done():
59
+ return
60
+ case tggs := <-in:
61
+ for _, tgg := range tggs {
62
+ seen[tgg.Source()] = tgg
63
+ }
64
+ }
65
+ }
66
+ }()
67
+
68
+ done := make(chan struct{})
69
+ go func() {
70
+ defer close(done)
71
+ wg.Wait()
72
+ }()
73
+
74
+ select {
75
+ case <-d.started:
76
+ case <-time.After(time.Second * 5):
77
+ require.Fail(t, "discovery failed to start")
78
+ }
79
+
80
+ time.Sleep(time.Second * 2)
81
+
82
+ cancel()
83
+
84
+ select {
85
+ case <-done:
86
+ case <-time.After(time.Second * 5):
87
+ require.Fail(t, "discovery hasn't finished after cancel")
88
+ }
89
+
90
+ var tggs []model.TargetGroup
91
+ for _, tgg := range seen {
92
+ tggs = append(tggs, tgg)
93
+ }
94
+
95
+ sortTargetGroups(tggs)
96
+ sortTargetGroups(sim.wantGroups)
97
+
98
+ wantLen, gotLen := calcTargets(sim.wantGroups), calcTargets(tggs)
99
+ assert.Equalf(t, wantLen, gotLen, "different len (want %d got %d)", wantLen, gotLen)
100
+ assert.Equal(t, sim.wantGroups, tggs)
101
+}
102
+
103
+func calcTargets(tggs []model.TargetGroup) int {
104
+ var n int
105
+ for _, tgg := range tggs {
106
+ n += len(tgg.Targets())
107
+ }
108
+ return n
109
+}
110
+
111
+func sortTargetGroups(tggs []model.TargetGroup) {
112
+ if len(tggs) == 0 {
113
+ return
114
+ }
115
+ sort.Slice(tggs, func(i, j int) bool { return tggs[i].Source() < tggs[j].Source() })
116
+
117
+ for idx := range tggs {
118
+ tgts := tggs[idx].Targets()
119
+ sort.Slice(tgts, func(i, j int) bool { return tgts[i].Hash() < tgts[j].Hash() })
120
+ }
121
+}
122
+
123
+type mockSnmpHandler struct {
124
+ mu sync.Mutex
125
+ *snmpmock.MockHandler
126
+ skipOnConnect func(ip string) bool
127
+}
128
+
129
+func (m *mockSnmpHandler) Connect() error {
130
+ if m.skipOnConnect != nil && m.skipOnConnect(m.MockHandler.Target()) {
131
+ return errors.New("mock handler skip connect")
132
+ }
133
+ return m.MockHandler.Connect()
134
+}
135
+
136
+func prepareMockSnmpHandler(t *testing.T) (*mockSnmpHandler, func()) {
137
+ mockCtl := gomock.NewController(t)
138
+ cleanup := func() { mockCtl.Finish() }
139
+ mockSNMP := snmpmock.NewMockHandler(mockCtl)
140
+ m := &mockSnmpHandler{MockHandler: mockSNMP}
141
+
142
+ return m, cleanup
143
+}
144
+
145
+func (m *mockSnmpHandler) setExpectInit() {
146
+ var ip string
147
+ m.EXPECT().Target().DoAndReturn(func() string { return ip }).AnyTimes()
148
+ m.EXPECT().SetTarget(gomock.Any()).Do(func(target string) { ip = target }).AnyTimes()
149
+ m.EXPECT().Port().AnyTimes()
150
+ m.EXPECT().Version().AnyTimes()
151
+ m.EXPECT().Community().AnyTimes()
152
+ m.EXPECT().SetPort(gomock.Any()).AnyTimes()
153
+ m.EXPECT().SetRetries(gomock.Any()).AnyTimes()
154
+ m.EXPECT().SetMaxRepetitions(gomock.Any()).AnyTimes()
155
+ m.EXPECT().SetMaxOids(gomock.Any()).AnyTimes()
156
+ m.EXPECT().SetLogger(gomock.Any()).AnyTimes()
157
+ m.EXPECT().SetTimeout(gomock.Any()).AnyTimes()
158
+ m.EXPECT().SetCommunity(gomock.Any()).AnyTimes()
159
+ m.EXPECT().SetVersion(gomock.Any()).AnyTimes()
160
+ m.EXPECT().SetSecurityModel(gomock.Any()).AnyTimes()
161
+ m.EXPECT().SetMsgFlags(gomock.Any()).AnyTimes()
162
+ m.EXPECT().SetSecurityParameters(gomock.Any()).AnyTimes()
163
+ m.EXPECT().Connect().Return(nil).AnyTimes()
164
+ m.EXPECT().Close().Return(nil).AnyTimes()
165
+}
166
+
167
+const (
168
+ mockSysDescr = "mock sysDescr"
169
+ mockSysObject = ".1.3.6.1.4.1.8072.3.2.10"
170
+ mockSysContact = "mock sysContact"
171
+ mockSysName = "mock sysName"
172
+ mockSysLocation = "mock sysLocation"
173
+)
174
+
175
+func (m *mockSnmpHandler) setExpectSysInfo() {
176
+ m.EXPECT().WalkAll(RootOidMibSystem).Return([]gosnmp.SnmpPDU{
177
+ {Name: OidSysDescr, Value: []uint8(mockSysDescr), Type: gosnmp.OctetString},
178
+ {Name: OidSysObject, Value: mockSysObject, Type: gosnmp.ObjectIdentifier},
179
+ {Name: OidSysContact, Value: []uint8(mockSysContact), Type: gosnmp.OctetString},
180
+ {Name: OidSysName, Value: []uint8(mockSysName), Type: gosnmp.OctetString},
181
+ {Name: OidSysLocation, Value: []uint8(mockSysLocation), Type: gosnmp.OctetString},
182
+ }, nil).AnyTimes()
183
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go
new
+105
@@ -0,0 +1,105 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "encoding/json"
7
+ "fmt"
8
+ "os"
9
+ "path/filepath"
10
+ "sync"
11
+ "time"
12
+)
13
+
14
+func (d *Discoverer) loadFileStatus() {
15
+ d.status = newDiscoveryStatus()
16
+
17
+ filename := statusFileName()
18
+ if filename == "" {
19
+ return
20
+ }
21
+
22
+ f, err := os.Open(filename)
23
+ if err != nil {
24
+ d.Warningf("failed to open status file %s: %v", filename, err)
25
+ return
26
+ }
27
+ defer func() { _ = f.Close() }()
28
+
29
+ if err := json.NewDecoder(f).Decode(d.status); err != nil {
30
+ d.Warningf("failed to parse status file %s: %v", filename, err)
31
+ return
32
+ }
33
+
34
+ d.Infof("loaded status file: last discovery=%s", d.status.LastDiscoveryTime)
35
+}
36
+
37
+func statusFileName() string {
38
+ v := os.Getenv("NETDATA_LIB_DIR")
39
+ if v == "" {
40
+ return ""
41
+ }
42
+ return filepath.Join(v, "god-sd-snmp-status.json")
43
+}
44
+
45
+func newDiscoveryStatus() *discoveryStatus {
46
+ return &discoveryStatus{
47
+ Networks: make(map[string]map[string]*discoveredDevice),
48
+ }
49
+}
50
+
51
+type (
52
+ discoveryStatus struct {
53
+ mux sync.RWMutex
54
+ Networks map[string]map[string]*discoveredDevice `json:"networks"`
55
+ LastDiscoveryTime time.Time `json:"last_discovery_time"`
56
+ ConfigHash uint64 `json:"config_hash"`
57
+ }
58
+ discoveredDevice struct {
59
+ DiscoverTime time.Time `json:"discover_time"`
60
+ SysInfo SysInfo `json:"sysinfo"`
61
+ }
62
+)
63
+
64
+func (s *discoveryStatus) Bytes() ([]byte, error) {
65
+ s.mux.RLock()
66
+ defer s.mux.RUnlock()
67
+
68
+ return json.MarshalIndent(s, "", " ")
69
+}
70
+
71
+func (s *discoveryStatus) get(sub subnet, ip string) *discoveredDevice {
72
+ s.mux.RLock()
73
+ defer s.mux.RUnlock()
74
+
75
+ devices, ok := s.Networks[subKey(sub)]
76
+ if !ok {
77
+ return nil
78
+ }
79
+ return devices[ip]
80
+}
81
+
82
+func (s *discoveryStatus) put(sub subnet, ip string, dev *discoveredDevice) {
83
+ s.mux.Lock()
84
+ defer s.mux.Unlock()
85
+
86
+ devices, ok := s.Networks[subKey(sub)]
87
+ if !ok {
88
+ devices = make(map[string]*discoveredDevice)
89
+ s.Networks[subKey(sub)] = devices
90
+ }
91
+ devices[ip] = dev
92
+}
93
+
94
+func (s *discoveryStatus) del(sub subnet, ip string) {
95
+ s.mux.Lock()
96
+ defer s.mux.Unlock()
97
+
98
+ if devices, ok := s.Networks[subKey(sub)]; ok {
99
+ delete(devices, ip)
100
+ }
101
+}
102
+
103
+func subKey(s subnet) string {
104
+ return fmt.Sprintf("%s:%s", s.str, s.credential.Name)
105
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go
new
+91
@@ -0,0 +1,91 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "fmt"
7
+ "strings"
8
+
9
+ "github.com/gosnmp/gosnmp"
10
+)
11
+
12
+const (
13
+ RootOidMibSystem = "1.3.6.1.2.1.1"
14
+ OidSysDescr = "1.3.6.1.2.1.1.1.0"
15
+ OidSysObject = "1.3.6.1.2.1.1.2.0"
16
+ OidSysUptime = "1.3.6.1.2.1.1.3.0"
17
+ OidSysContact = "1.3.6.1.2.1.1.4.0"
18
+ OidSysName = "1.3.6.1.2.1.1.5.0"
19
+ OidSysLocation = "1.3.6.1.2.1.1.6.0"
20
+)
21
+
22
+type SysInfo struct {
23
+ Descr string `json:"description"`
24
+ Contact string `json:"contact"`
25
+ Name string `json:"name"`
26
+ Location string `json:"location"`
27
+ Organization string `json:"organization"`
28
+}
29
+
30
+func GetSysInfo(client gosnmp.Handler) (*SysInfo, error) {
31
+ pdus, err := client.WalkAll(RootOidMibSystem)
32
+ if err != nil {
33
+ return nil, err
34
+ }
35
+
36
+ si := &SysInfo{
37
+ Name: "unknown",
38
+ Organization: "Unknown",
39
+ }
40
+
41
+ r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
42
+
43
+ for _, pdu := range pdus {
44
+ oid := strings.TrimPrefix(pdu.Name, ".")
45
+
46
+ switch oid {
47
+ case OidSysDescr:
48
+ if si.Descr, err = PduToString(pdu); err == nil {
49
+ si.Descr = r.Replace(si.Descr)
50
+ }
51
+ case OidSysObject:
52
+ var sysObj string
53
+ if sysObj, err = PduToString(pdu); err == nil {
54
+ si.Organization = LookupBySysObject(sysObj)
55
+ }
56
+ case OidSysContact:
57
+ si.Contact, err = PduToString(pdu)
58
+ case OidSysName:
59
+ si.Name, err = PduToString(pdu)
60
+ case OidSysLocation:
61
+ si.Location, err = PduToString(pdu)
62
+ }
63
+ if err != nil {
64
+ return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
65
+ }
66
+ }
67
+
68
+ return si, nil
69
+}
70
+
71
+func PduToString(pdu gosnmp.SnmpPDU) (string, error) {
72
+ switch pdu.Type {
73
+ case gosnmp.OctetString:
74
+ // TODO: this isn't reliable (e.g. physAddress we need hex.EncodeToString())
75
+ bs, ok := pdu.Value.([]byte)
76
+ if !ok {
77
+ return "", fmt.Errorf("OctetString is not a []byte but %T", pdu.Value)
78
+ }
79
+ return strings.ToValidUTF8(string(bs), "�"), nil
80
+ case gosnmp.Counter32, gosnmp.Counter64, gosnmp.Integer, gosnmp.Gauge32:
81
+ return gosnmp.ToBigInt(pdu.Value).String(), nil
82
+ case gosnmp.ObjectIdentifier:
83
+ v, ok := pdu.Value.(string)
84
+ if !ok {
85
+ return "", fmt.Errorf("ObjectIdentifier is not a string but %T", pdu.Value)
86
+ }
87
+ return strings.TrimPrefix(v, "."), nil
88
+ default:
89
+ return "", fmt.Errorf("unussported type: '%v'", pdu.Type)
90
+ }
91
+}
src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go
new
+64
@@ -0,0 +1,64 @@
1
+// SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+package snmpsd
4
+
5
+import (
6
+ "fmt"
7
+ "sync"
8
+
9
+ "github.com/gohugoio/hashstructure"
10
+
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
12
+)
13
+
14
+func targetSource(sub subnet) string { return fmt.Sprintf("discoverer=snmp,network=%s", subKey(sub)) }
15
+
16
+func newTargetGroup(sub subnet) *targetGroup {
17
+ return &targetGroup{
18
+ provider: "sd:snmpdiscoverer",
19
+ source: targetSource(sub),
20
+ }
21
+}
22
+
23
+type targetGroup struct {
24
+ provider string
25
+ source string
26
+ mux sync.Mutex
27
+ targets []model.Target
28
+}
29
+
30
+func (g *targetGroup) Provider() string { return g.provider }
31
+func (g *targetGroup) Source() string { return g.source }
32
+func (g *targetGroup) Targets() []model.Target { return g.targets }
33
+
34
+func (g *targetGroup) addTarget(tg model.Target) {
35
+ g.mux.Lock()
36
+ defer g.mux.Unlock()
37
+ g.targets = append(g.targets, tg)
38
+}
39
+
40
+func newTarget(ip string, cred CredentialConfig, si SysInfo) *target {
41
+ tg := &target{
42
+ IPAddress: ip,
43
+ Credential: cred,
44
+ SysInfo: si,
45
+ }
46
+
47
+ tg.hash, _ = hashstructure.Hash(tg, nil)
48
+
49
+ return tg
50
+}
51
+
52
+type (
53
+ target struct {
54
+ model.Base `hash:"ignore"`
55
+ hash uint64
56
+
57
+ IPAddress string
58
+ Credential CredentialConfig `hash:"ignore"`
59
+ SysInfo SysInfo `hash:"ignore"`
60
+ }
61
+)
62
+
63
+func (t *target) TUID() string { return fmt.Sprintf("snmp_%s_%d", t.IPAddress, t.hash) }
64
+func (t *target) Hash() uint64 { return t.hash }
src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go
+2
-2
@@ -51,7 +51,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) {
51
select {
52
case <-done:
53
a.Info("all discoverers exited")
54
- case <-time.After(time.Second * 3):
54
+ case <-time.After(time.Second * 10):
55
a.Warning("not all discoverers exited")
56
}
57
a.trySend(in)
@@ -83,7 +83,7 @@ func (a *accumulator) runDiscoverer(ctx context.Context, d model.Discoverer, upd
83
case <-ctx.Done():
84
select {
85
case <-done:
86
- case <-time.After(time.Second * 2):
86
+ case <-time.After(time.Second * 10):
87
a.Warningf("discoverer '%v' didn't exit on ctx done", d)
88
}
89
return
src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go
+3
-1
@@ -10,6 +10,7 @@ import (
10
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
11
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
12
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
13
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
14
)
15
16
type Config struct {
@@ -28,6 +29,7 @@ type DiscoveryConfig struct {
29
NetListeners netlisteners.Config `yaml:"net_listeners"`
30
Docker dockerd.Config `yaml:"docker"`
31
K8s []kubernetes.Config `yaml:"k8s"`
32
+ SNMP snmpsd.Config `yaml:"snmp"`
33
}
34
35
type ClassifyRuleConfig struct {
@@ -71,7 +73,7 @@ func validateDiscoveryConfig(config []DiscoveryConfig) error {
73
}
74
for _, cfg := range config {
75
switch cfg.Discoverer {
74
- case "net_listeners", "docker", "k8s":
76
+ case "net_listeners", "docker", "k8s", "snmp":
77
default:
78
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
79
}
src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go
+9
-2
@@ -14,6 +14,7 @@ import (
14
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
15
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
16
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
17
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
18
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
19
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/hostinfo"
20
)
@@ -102,6 +103,12 @@ func (p *Pipeline) registerDiscoverers(conf Config) error {
103
}
104
p.discoverers = append(p.discoverers, td)
105
}
106
+ case "snmp":
107
+ td, err := snmpsd.NewDiscoverer(cfg.SNMP)
108
+ if err != nil {
109
+ return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
110
+ }
111
+ p.discoverers = append(p.discoverers, td)
112
default:
113
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
114
}
@@ -130,7 +137,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
137
case <-ctx.Done():
138
select {
139
case <-done:
133
- case <-time.After(time.Second * 4):
140
+ case <-time.After(time.Second * 10):
141
}
142
return
143
case <-done:
@@ -149,7 +156,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
156
157
func (p *Pipeline) processGroups(tggs []model.TargetGroup) []*confgroup.Group {
158
var groups []*confgroup.Group
152
- // updates come from the accumulator, this ensures that all groups have different sources
159
+ // updates come from the accumulator; this ensures that all groups have different sources
160
for _, tgg := range tggs {
161
p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets()))
162
if v := p.processGroup(tgg); v != nil {
src/go/plugin/go.d/agent/filepersister/persister.go
+18
-10
@@ -16,14 +16,21 @@ type Data interface {
16
Updated() <-chan struct{}
17
}
18
19
+func Save(path string, data interface{ Bytes() ([]byte, error) }) {
20
+ if path == "" {
21
+ return
22
+ }
23
+ New(path).flush(data)
24
+}
25
+
26
func New(path string) *Persister {
27
return &Persister{
28
Logger: logger.New().With(
29
slog.String("component", "file persister"),
30
slog.String("file", path),
31
),
32
+ FlushEvery: time.Minute * 1,
33
filepath: path,
26
- flushEvery: time.Second * 5,
34
flushCh: make(chan struct{}, 1),
35
}
36
}
@@ -31,10 +38,11 @@ func New(path string) *Persister {
38
type Persister struct {
39
*logger.Logger
40
34
- data Data
35
- filepath string
36
- flushEvery time.Duration
37
- flushCh chan struct{}
41
+ FlushEvery time.Duration
42
+
43
+ data Data
44
+ filepath string
45
+ flushCh chan struct{}
46
}
47
48
func (p *Persister) Run(ctx context.Context, data Data) {
@@ -43,9 +51,9 @@ func (p *Persister) Run(ctx context.Context, data Data) {
51
52
p.data = data
53
46
- tk := time.NewTicker(p.flushEvery)
54
+ tk := time.NewTicker(p.FlushEvery)
55
defer tk.Stop()
48
- defer p.flush()
56
+ defer p.flush(p.data)
57
58
for {
59
select {
@@ -70,14 +78,14 @@ func (p *Persister) triggerFlush() {
78
func (p *Persister) tryFlush() {
79
select {
80
case <-p.flushCh:
73
- p.flush()
81
+ p.flush(p.data)
82
default:
83
// no pending flush
84
}
85
}
86
79
-func (p *Persister) flush() {
80
- bs, err := p.data.Bytes()
87
+func (p *Persister) flush(data interface{ Bytes() ([]byte, error) }) {
88
+ bs, err := data.Bytes()
89
if err != nil {
90
p.Debugf("failed to marshal data: %v", err)
91
return
src/go/plugin/go.d/agent/jobmgr/filestatus.go
+11
-4
@@ -6,6 +6,7 @@ import (
6
"encoding/json"
7
"fmt"
8
"os"
9
+ "path/filepath"
10
"slices"
11
"sync"
12
@@ -13,14 +14,18 @@ import (
14
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
15
)
16
17
+func statusFileName(dir string) string {
18
+ return filepath.Join(dir, "god-jobs-statuses.json")
19
+}
20
+
21
func (m *Manager) loadFileStatus() {
22
m.fileStatus = newFileStatus()
23
19
- if isTerminal || m.StateFile == "" {
24
+ if isTerminal || m.VarLibDir == "" {
25
return
26
}
27
23
- s, err := loadFileStatus(m.StateFile)
28
+ s, err := loadFileStatus(statusFileName(m.VarLibDir))
29
if err != nil {
30
m.Warningf("failed to load state file: %v", err)
31
return
@@ -29,10 +34,12 @@ func (m *Manager) loadFileStatus() {
34
}
35
36
func (m *Manager) runFileStatusPersistence() {
32
- if m.StateFile == "" {
37
+ if m.VarLibDir == "" {
38
return
39
}
35
- p := filepersister.New(m.StateFile)
40
+
41
+ p := filepersister.New(statusFileName(m.VarLibDir))
42
+
43
p.Run(m.ctx, m.fileStatus)
44
}
45
src/go/plugin/go.d/agent/jobmgr/manager.go
+1
-1
@@ -60,7 +60,7 @@ type Manager struct {
60
Out io.Writer
61
Modules module.Registry
62
ConfigDefaults confgroup.Registry
63
- StateFile string
63
+ VarLibDir string
64
FnReg FunctionRegistry
65
Vnodes map[string]*vnodes.VirtualNode
66
src/go/plugin/go.d/collector/snmp/charts.go
+4
-4
@@ -162,8 +162,8 @@ func (c *Collector) addNetIfaceCharts(iface *netInterface) {
162
for _, chart := range *charts {
163
chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName))
164
chart.Labels = []module.Label{
165
- {Key: "vendor", Value: c.sysInfo.organization},
166
- {Key: "sysName", Value: c.sysInfo.name},
165
+ {Key: "vendor", Value: c.sysInfo.Organization},
166
+ {Key: "sysName", Value: c.sysInfo.Name},
167
{Key: "ifDescr", Value: iface.ifDescr},
168
{Key: "ifName", Value: iface.ifName},
169
{Key: "ifType", Value: ifTypeMapping[iface.ifType]},
@@ -191,8 +191,8 @@ func (c *Collector) removeNetIfaceCharts(iface *netInterface) {
191
func (c *Collector) addSysUptimeChart() {
192
chart := uptimeChart.Copy()
193
chart.Labels = []module.Label{
194
- {Key: "vendor", Value: c.sysInfo.organization},
195
- {Key: "sysName", Value: c.sysInfo.name},
194
+ {Key: "vendor", Value: c.sysInfo.Organization},
195
+ {Key: "sysName", Value: c.sysInfo.Name},
196
}
197
if err := c.Charts().Add(chart); err != nil {
198
c.Warning(err)
src/go/plugin/go.d/collector/snmp/collect.go
+30
-10
@@ -3,10 +3,12 @@
3
package snmp
4
5
import (
6
+ "errors"
7
"fmt"
8
"slices"
9
"strings"
10
11
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
12
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
13
14
"github.com/google/uuid"
@@ -15,7 +17,7 @@ import (
17
18
func (c *Collector) collect() (map[string]int64, error) {
19
if c.sysInfo == nil {
18
- si, err := c.getSysInfo()
20
+ si, err := snmpsd.GetSysInfo(c.snmpClient)
21
if err != nil {
22
return nil, err
23
}
@@ -49,6 +51,24 @@ func (c *Collector) collect() (map[string]int64, error) {
51
return mx, nil
52
}
53
54
+func (c *Collector) collectSysUptime(mx map[string]int64) error {
55
+ resp, err := c.snmpClient.Get([]string{snmpsd.OidSysUptime})
56
+ if err != nil {
57
+ return err
58
+ }
59
+ if len(resp.Variables) == 0 {
60
+ return errors.New("no system uptime")
61
+ }
62
+ v, err := pduToInt(resp.Variables[0])
63
+ if err != nil {
64
+ return err
65
+ }
66
+
67
+ mx["uptime"] = v / 100 // the time is in hundredths of a second
68
+
69
+ return nil
70
+}
71
+
72
func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
73
if c.snmpClient.Version() == gosnmp.Version1 {
74
return c.snmpClient.WalkAll(rootOid)
@@ -56,12 +76,12 @@ func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
76
return c.snmpClient.BulkWalkAll(rootOid)
77
}
78
59
-func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
79
+func (c *Collector) setupVnode(si *snmpsd.SysInfo) *vnodes.VirtualNode {
80
if c.Vnode.GUID == "" {
81
c.Vnode.GUID = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(c.Hostname)).String()
82
}
83
64
- hostnames := []string{c.Vnode.Hostname, si.name, "snmp-device"}
84
+ hostnames := []string{c.Vnode.Hostname, si.Name, "snmp-device"}
85
i := slices.IndexFunc(hostnames, func(s string) bool { return s != "" })
86
87
c.Vnode.Hostname = fmt.Sprintf("%s(%s)", hostnames[i], c.Hostname)
@@ -71,17 +91,17 @@ func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
91
for k, v := range c.Vnode.Labels {
92
labels[k] = v
93
}
74
- if si.descr != "" {
75
- labels["sysDescr"] = si.descr
94
+ if si.Descr != "" {
95
+ labels["sysDescr"] = si.Descr
96
}
77
- if si.contact != "" {
78
- labels["sysContact"] = si.contact
97
+ if si.Contact != "" {
98
+ labels["sysContact"] = si.Contact
99
}
80
- if si.location != "" {
81
- labels["sysLocation"] = si.location
100
+ if si.Location != "" {
101
+ labels["sysLocation"] = si.Location
102
}
103
// FIXME: vendor should be obtained from sysDescr, org should be used as a fallback
84
- labels["vendor"] = si.organization
104
+ labels["vendor"] = si.Organization
105
106
return &vnodes.VirtualNode{
107
GUID: c.Vnode.GUID,
src/go/plugin/go.d/collector/snmp/collect_sys_info.go
-87
@@ -1,90 +1,3 @@
1
// SPDX-License-Identifier: GPL-3.0-or-later
2
3
package snmp
4
-
5
-import (
6
- "errors"
7
- "fmt"
8
- "strings"
9
-
10
- "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/entnum"
11
-)
12
-
13
-const (
14
- rootOidMibSystem = "1.3.6.1.2.1.1"
15
- oidSysDescr = "1.3.6.1.2.1.1.1.0"
16
- oidSysObject = "1.3.6.1.2.1.1.2.0"
17
- oidSysUptime = "1.3.6.1.2.1.1.3.0"
18
- oidSysContact = "1.3.6.1.2.1.1.4.0"
19
- oidSysName = "1.3.6.1.2.1.1.5.0"
20
- oidSysLocation = "1.3.6.1.2.1.1.6.0"
21
-)
22
-
23
-type sysInfo struct {
24
- descr string
25
- contact string
26
- name string
27
- location string
28
-
29
- organization string
30
-}
31
-
32
-func (c *Collector) getSysInfo() (*sysInfo, error) {
33
- pdus, err := c.snmpClient.WalkAll(rootOidMibSystem)
34
- if err != nil {
35
- return nil, err
36
- }
37
-
38
- si := &sysInfo{
39
- name: "unknown",
40
- organization: "Unknown",
41
- }
42
-
43
- r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
44
-
45
- for _, pdu := range pdus {
46
- oid := strings.TrimPrefix(pdu.Name, ".")
47
-
48
- switch oid {
49
- case oidSysDescr:
50
- if si.descr, err = pduToString(pdu); err == nil {
51
- si.descr = r.Replace(si.descr)
52
- }
53
- case oidSysObject:
54
- var sysObj string
55
- if sysObj, err = pduToString(pdu); err == nil {
56
- si.organization = entnum.LookupBySysObject(sysObj)
57
- c.Debugf("device sysObject '%s', organization '%s'", sysObj, si.organization)
58
- }
59
- case oidSysContact:
60
- si.contact, err = pduToString(pdu)
61
- case oidSysName:
62
- si.name, err = pduToString(pdu)
63
- case oidSysLocation:
64
- si.location, err = pduToString(pdu)
65
- }
66
- if err != nil {
67
- return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
68
- }
69
- }
70
-
71
- return si, nil
72
-}
73
-
74
-func (c *Collector) collectSysUptime(mx map[string]int64) error {
75
- resp, err := c.snmpClient.Get([]string{oidSysUptime})
76
- if err != nil {
77
- return err
78
- }
79
- if len(resp.Variables) == 0 {
80
- return errors.New("no system uptime")
81
- }
82
- v, err := pduToInt(resp.Variables[0])
83
- if err != nil {
84
- return err
85
- }
86
-
87
- mx["uptime"] = v / 100 // the time is in hundredths of a second
88
-
89
- return nil
90
-}
src/go/plugin/go.d/collector/snmp/collector.go
+2
-1
@@ -9,6 +9,7 @@ import (
9
"fmt"
10
11
"github.com/netdata/netdata/go/plugins/pkg/matcher"
12
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
13
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
14
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
15
@@ -76,7 +77,7 @@ type Collector struct {
77
78
netInterfaces map[string]*netInterface
79
79
- sysInfo *sysInfo
80
+ sysInfo *snmpsd.SysInfo
81
82
customOids []string
83
}
src/go/plugin/go.d/collector/snmp/collector_test.go
+8
-7
@@ -11,6 +11,7 @@ import (
11
"strings"
12
"testing"
13
14
+ "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
15
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
16
17
"github.com/golang/mock/gomock"
@@ -581,15 +582,15 @@ func setMockClientInitExpect(m *snmpmock.MockHandler) {
582
}
583
584
func setMockClientSysExpect(m *snmpmock.MockHandler) {
584
- m.EXPECT().WalkAll(rootOidMibSystem).Return([]gosnmp.SnmpPDU{
585
- {Name: oidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
586
- {Name: oidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
587
- {Name: oidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
588
- {Name: oidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
589
- {Name: oidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
585
+ m.EXPECT().WalkAll(snmpsd.RootOidMibSystem).Return([]gosnmp.SnmpPDU{
586
+ {Name: snmpsd.OidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
587
+ {Name: snmpsd.OidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
588
+ {Name: snmpsd.OidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
589
+ {Name: snmpsd.OidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
590
+ {Name: snmpsd.OidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
591
}, nil).MinTimes(1)
592
592
- m.EXPECT().Get([]string{oidSysUptime}).Return(&gosnmp.SnmpPacket{
593
+ m.EXPECT().Get([]string{snmpsd.OidSysUptime}).Return(&gosnmp.SnmpPacket{
594
Variables: []gosnmp.SnmpPDU{
595
{Value: uint32(6048), Type: gosnmp.TimeTicks},
596
},
src/go/plugin/go.d/config/go.d/sd/snmp.conf
new
+98
@@ -0,0 +1,98 @@
1
+# ===================================================================
2
+# WARNING: SNMP DISCOVERY IS DISABLED BY DEFAULT
3
+# To enable, change "disabled: yes" to "disabled: no" below
4
+# AND configure proper credentials and networks
5
+# ===================================================================
6
+
7
+disabled: yes
8
+
9
+name: 'snmp'
10
+
11
+discover:
12
+ - discoverer: snmp
13
+ snmp:
14
+ # how often to scan the networks for devices (default: 30m)
15
+ rescan_interval: "30m"
16
+
17
+ # the maximum time to wait for SNMP device responses (default: 1s)
18
+ timeout: "1s"
19
+
20
+ # How long to trust cached discovery results before requiring a new probe (default: 12h)
21
+ device_cache_ttl: "6h"
22
+
23
+ # how many IPs to scan concurrently within each subnet (default: 32)
24
+ parallel_scans_per_network: 32
25
+
26
+ # ==========================================================
27
+ # IMPORTANT: YOU MUST CONFIGURE YOUR OWN CREDENTIALS BELOW
28
+ # The example credentials will not work in most environments
29
+ # ==========================================================
30
+ credentials:
31
+ - name: "public-v2c"
32
+ version: "2"
33
+ community: "public"
34
+
35
+ - name: "secure-v3"
36
+ version: "3"
37
+ # one of: "noAuthNoPriv", "authNoPriv", or "authPriv"
38
+ security_level: "authPriv"
39
+ username: "admin"
40
+ # one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512"
41
+ auth_protocol: "sha"
42
+ auth_password: "secret123"
43
+ # one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C"
44
+ priv_protocol: "aes"
45
+ priv_password: "encrypt123"
46
+
47
+ # ========================================================
48
+ # IMPORTANT: YOU MUST CONFIGURE YOUR OWN NETWORKS BELOW
49
+ # By default, no networks will be scanned until configured
50
+ # Maximum size is limited to 512 IPs per subnet (/23 CIDR)
51
+ # ========================================================
52
+ networks:
53
+ # Subnet is the IP range to scan, supporting various formats:
54
+ # https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
55
+ - subnet: "192.168.1.0/24"
56
+ # Credential is the name of a credential from the Credentials list
57
+ credential: "public-v2c"
58
+
59
+classify:
60
+ - name: "Servers"
61
+ selector: "*"
62
+ tags: "skip"
63
+ match:
64
+ - tags: "skip"
65
+ expr: '{{ match "sp" .SysInfo.Descr "Linux* FreeBSD* OpenBSD* NetBSD*" }}'
66
+ - name: "SNMP Devices"
67
+ selector: "!skip *"
68
+ tags: "snmp"
69
+ match:
70
+ - tags: "snmp"
71
+ expr: '{{ true }}'
72
+compose:
73
+ - name: "SNMP Devices"
74
+ selector: "snmp"
75
+ config:
76
+ - selector: "snmp"
77
+ template: |
78
+ module: snmp
79
+ update_every: 5
80
+ {{- if .SysInfo.Name }}
81
+ name: {{ .SysInfo.Name }}-ip-{{ .IPAddress }}
82
+ {{- else }}
83
+ name: ip-{{ .IPAddress }}
84
+ {{- end }}
85
+ hostname: {{ .IPAddress }}
86
+ options:
87
+ version: {{ .Credential.Version }}
88
+ {{- if eq .Credential.Version "1" "2" }}
89
+ community: {{ .Credential.Community }}
90
+ {{- else }}
91
+ user:
92
+ name: {{ .Credential.UserName }}
93
+ level: {{ .Credential.SecurityLevel }}
94
+ auth_proto: {{ .Credential.AuthProtocol }}
95
+ auth_key: {{ .Credential.AuthPassphrase }}
96
+ priv_proto: {{ .Credential.PrivacyProtocol }}
97
+ priv_key: {{ .Credential.PrivacyPassphrase }}
98
+ {{- end }}
src/go/plugin/go.d/pkg/confopt/duration.go
+61
-1
@@ -5,10 +5,70 @@ package confopt
5
import (
6
"encoding/json"
7
"fmt"
8
+ "regexp"
9
"strconv"
10
+ "strings"
11
"time"
12
)
13
14
+var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`)
15
+
16
+// ParseDuration parses a duration string with units.
17
+func ParseDuration(s string) (time.Duration, error) {
18
+ orig := s
19
+
20
+ if s = strings.ReplaceAll(s, " ", ""); s == "" {
21
+ return 0, fmt.Errorf("empty duration string")
22
+ }
23
+
24
+ neg := s[0] == '-'
25
+ if neg {
26
+ s = s[1:]
27
+ }
28
+
29
+ unitMap := map[string]time.Duration{
30
+ "d": 24 * time.Hour,
31
+ "w": 7 * 24 * time.Hour,
32
+ "wk": 7 * 24 * time.Hour,
33
+ "mo": 30 * 24 * time.Hour,
34
+ "M": 30 * 24 * time.Hour,
35
+ "y": 365 * 24 * time.Hour,
36
+ }
37
+
38
+ matches := reDuration.FindAllStringSubmatch(s, -1)
39
+
40
+ if len(matches) == 0 {
41
+ return 0, fmt.Errorf("invalid duration format: '%s'", orig)
42
+ }
43
+
44
+ var total time.Duration
45
+
46
+ for _, m := range matches {
47
+ value, unit := m[1], m[2]
48
+
49
+ val, err := strconv.ParseFloat(value, 64)
50
+ if err != nil {
51
+ return 0, fmt.Errorf("invalid number: %s", value)
52
+ }
53
+
54
+ if multiplier, ok := unitMap[unit]; ok {
55
+ total += time.Duration(val * float64(multiplier))
56
+ } else {
57
+ dur, err := time.ParseDuration(value + unit)
58
+ if err != nil {
59
+ return 0, fmt.Errorf("invalid duration unit: %s", value+unit)
60
+ }
61
+ total += dur
62
+ }
63
+ }
64
+
65
+ if neg {
66
+ total = -total
67
+ }
68
+
69
+ return total, nil
70
+}
71
+
72
type Duration time.Duration
73
74
func (d Duration) Duration() time.Duration {
@@ -26,7 +86,7 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
86
return err
87
}
88
29
- if v, err := time.ParseDuration(s); err == nil {
89
+ if v, err := ParseDuration(s); err == nil {
90
*d = Duration(v)
91
return nil
92
}
src/go/plugin/go.d/pkg/confopt/duration_test.go
+75
@@ -5,6 +5,7 @@ package confopt
5
import (
6
"encoding/json"
7
"fmt"
8
+ "math"
9
"strings"
10
"testing"
11
"time"
@@ -15,6 +16,80 @@ import (
16
"gopkg.in/yaml.v2"
17
)
18
19
+func TestParseDuration(t *testing.T) {
20
+ tests := map[string]struct {
21
+ input string
22
+ wantDuration time.Duration
23
+ wantErr bool
24
+ }{
25
+ "nanoseconds": {input: "10ns", wantDuration: 10 * time.Nanosecond},
26
+ "microseconds": {input: "10us", wantDuration: 10 * time.Microsecond},
27
+ "milliseconds": {input: "10ms", wantDuration: 10 * time.Millisecond},
28
+ "seconds": {input: "10s", wantDuration: 10 * time.Second},
29
+ "minutes": {input: "10m", wantDuration: 10 * time.Minute},
30
+ "hours": {input: "10h", wantDuration: 10 * time.Hour},
31
+ "days": {input: "10d", wantDuration: 10 * 24 * time.Hour},
32
+ "weeks (w)": {input: "10w", wantDuration: 10 * 7 * 24 * time.Hour},
33
+ "weeks (wk)": {input: "10wk", wantDuration: 10 * 7 * 24 * time.Hour},
34
+ "months (mo)": {input: "10mo", wantDuration: 10 * 30 * 24 * time.Hour},
35
+ "months (M)": {input: "10M", wantDuration: 10 * 30 * 24 * time.Hour},
36
+ "years": {input: "10y", wantDuration: 10 * 365 * 24 * time.Hour},
37
+ "negative units": {input: "-10d", wantDuration: -10 * 24 * time.Hour},
38
+ "mixed units": {
39
+ input: "1y2M3w4d5h6m7s8ms9us10ns",
40
+ wantDuration: (1 * 365 * 24 * time.Hour) +
41
+ (2 * 30 * 24 * time.Hour) +
42
+ (3 * 7 * 24 * time.Hour) +
43
+ (4 * 24 * time.Hour) +
44
+ (5 * time.Hour) +
45
+ (6 * time.Minute) +
46
+ (7 * time.Second) +
47
+ (8 * time.Millisecond) +
48
+ (9 * time.Microsecond) +
49
+ (10 * time.Nanosecond),
50
+ },
51
+ "mixed units with spaces": {
52
+ input: "1y 2M 3w 4d 5h 6m 7s 8ms 9us 10ns",
53
+ wantDuration: (1 * 365 * 24 * time.Hour) +
54
+ (2 * 30 * 24 * time.Hour) +
55
+ (3 * 7 * 24 * time.Hour) +
56
+ (4 * 24 * time.Hour) +
57
+ (5 * time.Hour) +
58
+ (6 * time.Minute) +
59
+ (7 * time.Second) +
60
+ (8 * time.Millisecond) +
61
+ (9 * time.Microsecond) +
62
+ (10 * time.Nanosecond),
63
+ },
64
+ "mixed units with decimals": {
65
+ input: "1.5y2.25M3.75w4.5d5.5h6.5m7.5s8.5ms9.5us10.5ns",
66
+ wantDuration: time.Duration(math.Floor(1.5*365*24*float64(time.Hour))) +
67
+ time.Duration(math.Floor(2.25*30*24*float64(time.Hour))) +
68
+ time.Duration(math.Floor(3.75*7*24*float64(time.Hour))) +
69
+ time.Duration(math.Floor(4.5*24*float64(time.Hour))) +
70
+ time.Duration(math.Floor(5.5*float64(time.Hour))) +
71
+ time.Duration(math.Floor(6.5*float64(time.Minute))) +
72
+ time.Duration(math.Floor(7.5*float64(time.Second))) +
73
+ time.Duration(math.Floor(8.5*float64(time.Millisecond))) +
74
+ time.Duration(math.Floor(9.5*float64(time.Microsecond))) +
75
+ time.Duration(math.Floor(10.5*float64(time.Nanosecond))),
76
+ },
77
+ }
78
+
79
+ for name, test := range tests {
80
+ t.Run(name, func(t *testing.T) {
81
+ dur, err := ParseDuration(test.input)
82
+
83
+ if test.wantErr {
84
+ assert.Error(t, err)
85
+ } else {
86
+ require.NoError(t, err)
87
+ assert.Equal(t, test.wantDuration, dur)
88
+ }
89
+ })
90
+ }
91
+}
92
+
93
func TestDuration_MarshalYAML(t *testing.T) {
94
tests := map[string]struct {
95
d Duration