@cryptotaxi247 / netdata-1 / commits / d373112c4

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