mirror of
https://github.com/netdata/netdata.git
synced 2025-04-26 22:04:46 +00:00
feat(go.d): add snmp devices discovery (#19720)
* init sd snmp * update config example * use confopt.Duration for time options * support more units when parsing duration * update config comments and skip servers by default * update default config * add tests * cache ttl 12 h
This commit is contained in:
parent
86b5acbfcf
commit
d373112c44
31 changed files with 1544 additions and 149 deletions
src/go
cmd/godplugin
go.modgo.sumplugin/go.d
agent
collector/snmp
config/go.d/sd
pkg/confopt
|
@ -62,7 +62,7 @@ type config struct {
|
|||
collectorsDir multipath.MultiPath
|
||||
collectorsWatchPath []string
|
||||
serviceDiscoveryDir multipath.MultiPath
|
||||
stateFile string
|
||||
varLibDir string
|
||||
}
|
||||
|
||||
func newConfig(opts *cli.Option, env *envConfig) *config {
|
||||
|
@ -74,7 +74,7 @@ func newConfig(opts *cli.Option, env *envConfig) *config {
|
|||
cfg.collectorsDir = cfg.initCollectorsDir(opts)
|
||||
cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env)
|
||||
cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir()
|
||||
cfg.stateFile = cfg.initStateFile(env)
|
||||
cfg.varLibDir = env.varLibDir
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
@ -156,13 +156,6 @@ func (c *config) initCollectorsWatchPaths(opts *cli.Option, env *envConfig) []st
|
|||
return append(opts.WatchPath, env.watchPath)
|
||||
}
|
||||
|
||||
func (c *config) initStateFile(env *envConfig) string {
|
||||
if env.varLibDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(env.varLibDir, "god-jobs-statuses.json")
|
||||
}
|
||||
|
||||
func (c *config) mustPluginDir() {
|
||||
if len(c.pluginDir) == 0 {
|
||||
panic("plugin config init: plugin dir is empty")
|
||||
|
|
|
@ -53,7 +53,7 @@ func main() {
|
|||
CollectorsConfigDir: cfg.collectorsDir,
|
||||
ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir,
|
||||
CollectorsConfigWatchPath: cfg.collectorsWatchPath,
|
||||
StateFile: cfg.stateFile,
|
||||
VarLibDir: cfg.varLibDir,
|
||||
RunModule: opts.Module,
|
||||
MinUpdateEvery: opts.UpdateEvery,
|
||||
})
|
||||
|
|
|
@ -44,6 +44,7 @@ require (
|
|||
github.com/prometheus/prometheus v2.55.1+incompatible
|
||||
github.com/redis/go-redis/v9 v9.7.1
|
||||
github.com/sijms/go-ora/v2 v2.8.24
|
||||
github.com/sourcegraph/conc v0.3.0
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/valyala/fastjson v1.6.4
|
||||
|
@ -144,6 +145,7 @@ require (
|
|||
go.opentelemetry.io/otel v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.34.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.34.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/mod v0.22.0 // indirect
|
||||
golang.org/x/oauth2 v0.25.0 // indirect
|
||||
|
|
|
@ -409,6 +409,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
|
|||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
|
@ -488,6 +490,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
|
|
|
@ -34,7 +34,7 @@ type Config struct {
|
|||
CollectorsConfigDir []string
|
||||
CollectorsConfigWatchPath []string
|
||||
ServiceDiscoveryConfigDir []string
|
||||
StateFile string
|
||||
VarLibDir string
|
||||
ModuleRegistry module.Registry
|
||||
RunModule string
|
||||
MinUpdateEvery int
|
||||
|
@ -51,7 +51,7 @@ type Agent struct {
|
|||
CollectorsConfigWatchPath []string
|
||||
ServiceDiscoveryConfigDir multipath.MultiPath
|
||||
|
||||
StateFile string
|
||||
VarLibDir string
|
||||
|
||||
RunModule string
|
||||
MinUpdateEvery int
|
||||
|
@ -75,13 +75,13 @@ func New(cfg Config) *Agent {
|
|||
CollectorsConfDir: cfg.CollectorsConfigDir,
|
||||
ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir,
|
||||
CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath,
|
||||
StateFile: cfg.StateFile,
|
||||
VarLibDir: cfg.VarLibDir,
|
||||
RunModule: cfg.RunModule,
|
||||
MinUpdateEvery: cfg.MinUpdateEvery,
|
||||
ModuleRegistry: module.DefaultRegistry,
|
||||
Out: safewriter.Stdout,
|
||||
api: netdataapi.New(safewriter.Stdout),
|
||||
quitCh: make(chan struct{}),
|
||||
quitCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -194,7 +194,7 @@ func (a *Agent) run(ctx context.Context) {
|
|||
jobMgr := jobmgr.New()
|
||||
jobMgr.PluginName = a.Name
|
||||
jobMgr.Out = a.Out
|
||||
jobMgr.StateFile = a.StateFile
|
||||
jobMgr.VarLibDir = a.VarLibDir
|
||||
jobMgr.Modules = enabledModules
|
||||
jobMgr.ConfigDefaults = discCfg.Registry
|
||||
jobMgr.FnReg = fnMgr
|
||||
|
|
|
@ -119,12 +119,18 @@ func (m *Manager) registerDiscoverers(cfg Config) error {
|
|||
}
|
||||
|
||||
func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) {
|
||||
done := make(chan struct{})
|
||||
updates := make(chan []*confgroup.Group)
|
||||
go d.Run(ctx, updates)
|
||||
|
||||
go func() { defer close(done); d.Run(ctx, updates) }()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second * 10):
|
||||
}
|
||||
return
|
||||
case groups, ok := <-updates:
|
||||
if !ok {
|
||||
|
|
|
@ -18,7 +18,7 @@ func (g *targetGroup) Source() string { return g.source }
|
|||
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
||||
|
||||
type target struct {
|
||||
model.Base
|
||||
model.Base `hash:"ignore"`
|
||||
|
||||
hash uint64
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ func (g *targetGroup) Source() string { return g.source }
|
|||
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
||||
|
||||
type target struct {
|
||||
model.Base
|
||||
model.Base `hash:"ignore"`
|
||||
|
||||
hash uint64
|
||||
|
||||
|
|
|
@ -0,0 +1,210 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/confopt"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
|
||||
)
|
||||
|
||||
type (
|
||||
Config struct {
|
||||
// RescanInterval defines how often to scan the networks for devices (default: 30m)
|
||||
RescanInterval confopt.Duration `yaml:"rescan_interval"`
|
||||
// Timeout defines the maximum time to wait for SNMP device responses (default: 1s)
|
||||
Timeout confopt.Duration `yaml:"timeout"`
|
||||
// DeviceCacheTTL defines how long to trust cached discovery results before requiring a new probe (default: 12h)
|
||||
DeviceCacheTTL confopt.Duration `yaml:"device_cache_ttl"`
|
||||
// ParallelScansPerNetwork defines how many IPs to scan concurrently within each subnet (default: 32)
|
||||
ParallelScansPerNetwork int `yaml:"parallel_scans_per_network"`
|
||||
// Credentials define the SNMP credentials used for authentication
|
||||
Credentials []CredentialConfig `yaml:"credentials"`
|
||||
// Networks defines the subnets to scan and which credentials to use
|
||||
Networks []NetworkConfig `yaml:"networks"`
|
||||
}
|
||||
|
||||
NetworkConfig struct {
|
||||
// Subnet is the IP range to scan, supporting various formats
|
||||
// https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
|
||||
Subnet string `yaml:"subnet"`
|
||||
// Credential is the name of a credential from the Credentials list
|
||||
Credential string `yaml:"credential"`
|
||||
}
|
||||
CredentialConfig struct {
|
||||
// Name is the identifier for this credential set, used in Network.Credential
|
||||
Name string `yaml:"name"`
|
||||
// Version must be one of: "1", "2c", or "3"
|
||||
Version string `yaml:"version"`
|
||||
// Community is the SNMP community string (used in v1 and v2c)
|
||||
Community string `yaml:"community"`
|
||||
// UserName is the SNMPv3 username
|
||||
UserName string `yaml:"username"`
|
||||
// SecurityLevel must be one of: "noAuthNoPriv", "authNoPriv", or "authPriv" (for SNMPv3)
|
||||
SecurityLevel string `yaml:"security_level"`
|
||||
// AuthProtocol must be one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" (for SNMPv3)
|
||||
AuthProtocol string `yaml:"auth_protocol"`
|
||||
// AuthPassphrase is the authentication passphrase (for SNMPv3)
|
||||
AuthPassphrase string `yaml:"auth_passphrase"`
|
||||
// PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" (for SNMPv3)
|
||||
PrivacyProtocol string `yaml:"privacy_protocol"`
|
||||
// PrivacyPassphrase is the privacy passphrase (for SNMPv3)
|
||||
PrivacyPassphrase string `yaml:"privacy_passphrase"`
|
||||
}
|
||||
)
|
||||
|
||||
func (c *Config) validateAndParse() ([]subnet, error) {
|
||||
if len(c.Credentials) == 0 {
|
||||
return nil, fmt.Errorf("no credentials provided")
|
||||
}
|
||||
if len(c.Networks) == 0 {
|
||||
return nil, fmt.Errorf("no networks provided")
|
||||
}
|
||||
|
||||
credentials := make(map[string]CredentialConfig)
|
||||
|
||||
for i, cr := range c.Credentials {
|
||||
if cr.Name == "" {
|
||||
return nil, fmt.Errorf("no name provided for credential %d", i)
|
||||
}
|
||||
if _, ok := credentials[cr.Name]; ok {
|
||||
return nil, fmt.Errorf("duplicate credential name: %s", cr.Name)
|
||||
}
|
||||
credentials[cr.Name] = c.Credentials[i]
|
||||
}
|
||||
|
||||
networks := make(map[string]bool)
|
||||
|
||||
var subnets []subnet
|
||||
|
||||
for i, n := range c.Networks {
|
||||
if n.Subnet == "" {
|
||||
return nil, fmt.Errorf("no subnet provided for network %d", i)
|
||||
}
|
||||
if n.Credential == "" {
|
||||
return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
|
||||
}
|
||||
if _, ok := credentials[n.Credential]; !ok {
|
||||
return nil, fmt.Errorf("no credential provided for network %s", n.Subnet)
|
||||
}
|
||||
|
||||
r, err := iprange.ParseRange(n.Subnet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid subnet range '%s': %v", n.Subnet, err)
|
||||
}
|
||||
|
||||
// Limit subnet size to /23 or smaller (512 IPs max per subnet)
|
||||
// This prevents accidental scanning of excessively large networks.
|
||||
if s := r.Size().Int64(); s > 512 {
|
||||
return nil, fmt.Errorf("subnet '%s' exceeds maximum size of /23 (512 IPs, got %d IPs)", n.Subnet, s)
|
||||
}
|
||||
|
||||
sub := subnet{
|
||||
str: n.Subnet,
|
||||
ips: r,
|
||||
credential: credentials[n.Credential],
|
||||
}
|
||||
|
||||
if networks[subKey(sub)] {
|
||||
return nil, fmt.Errorf("duplicate subnet '%s'", subKey(sub))
|
||||
}
|
||||
networks[subKey(sub)] = true
|
||||
|
||||
subnets = append(subnets, sub)
|
||||
}
|
||||
|
||||
return subnets, nil
|
||||
}
|
||||
|
||||
func setCredential(client gosnmp.Handler, cred CredentialConfig) {
|
||||
switch parseSNMPVersion(cred) {
|
||||
case gosnmp.Version1:
|
||||
client.SetVersion(gosnmp.Version1)
|
||||
client.SetCommunity(cred.Community)
|
||||
case gosnmp.Version2c:
|
||||
client.SetVersion(gosnmp.Version2c)
|
||||
client.SetCommunity(cred.Community)
|
||||
case gosnmp.Version3:
|
||||
client.SetVersion(gosnmp.Version3)
|
||||
client.SetSecurityModel(gosnmp.UserSecurityModel)
|
||||
client.SetMsgFlags(parseSNMPv3SecurityLevel(cred))
|
||||
client.SetSecurityParameters(&gosnmp.UsmSecurityParameters{
|
||||
UserName: cred.UserName,
|
||||
AuthenticationProtocol: parseSNMPv3AuthProtocol(cred),
|
||||
AuthenticationPassphrase: cred.AuthPassphrase,
|
||||
PrivacyProtocol: parseSNMPv3PrivProtocol(cred),
|
||||
PrivacyPassphrase: cred.PrivacyPassphrase,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseSNMPVersion(cred CredentialConfig) gosnmp.SnmpVersion {
|
||||
switch cred.Version {
|
||||
case "0", "1":
|
||||
return gosnmp.Version1
|
||||
case "2", "2c", "":
|
||||
return gosnmp.Version2c
|
||||
case "3":
|
||||
return gosnmp.Version3
|
||||
default:
|
||||
return gosnmp.Version2c
|
||||
}
|
||||
}
|
||||
|
||||
func parseSNMPv3SecurityLevel(cred CredentialConfig) gosnmp.SnmpV3MsgFlags {
|
||||
switch cred.SecurityLevel {
|
||||
case "1", "none", "noAuthNoPriv", "":
|
||||
return gosnmp.NoAuthNoPriv
|
||||
case "2", "authNoPriv":
|
||||
return gosnmp.AuthNoPriv
|
||||
case "3", "authPriv":
|
||||
return gosnmp.AuthPriv
|
||||
default:
|
||||
return gosnmp.NoAuthNoPriv
|
||||
}
|
||||
}
|
||||
|
||||
func parseSNMPv3AuthProtocol(cred CredentialConfig) gosnmp.SnmpV3AuthProtocol {
|
||||
switch cred.AuthProtocol {
|
||||
case "1", "none", "noAuth", "":
|
||||
return gosnmp.NoAuth
|
||||
case "2", "md5", "MD5":
|
||||
return gosnmp.MD5
|
||||
case "3", "sha", "SHA":
|
||||
return gosnmp.SHA
|
||||
case "4", "sha224", "SHA224":
|
||||
return gosnmp.SHA224
|
||||
case "5", "sha256", "SHA256":
|
||||
return gosnmp.SHA256
|
||||
case "6", "sha384", "SHA384":
|
||||
return gosnmp.SHA384
|
||||
case "7", "sha512", "SHA512":
|
||||
return gosnmp.SHA512
|
||||
default:
|
||||
return gosnmp.NoAuth
|
||||
}
|
||||
}
|
||||
|
||||
func parseSNMPv3PrivProtocol(cred CredentialConfig) gosnmp.SnmpV3PrivProtocol {
|
||||
switch cred.PrivacyProtocol {
|
||||
case "1", "none", "noPriv", "":
|
||||
return gosnmp.NoPriv
|
||||
case "2", "des", "DES":
|
||||
return gosnmp.DES
|
||||
case "3", "aes", "AES":
|
||||
return gosnmp.AES
|
||||
case "4", "aes192", "AES192":
|
||||
return gosnmp.AES192
|
||||
case "5", "aes256", "AES256":
|
||||
return gosnmp.AES256
|
||||
case "6", "aes192c", "AES192C":
|
||||
return gosnmp.AES192C
|
||||
case "7", "aes256c", "AES256C":
|
||||
return gosnmp.AES256C
|
||||
default:
|
||||
return gosnmp.NoPriv
|
||||
}
|
||||
}
|
|
@ -0,0 +1,266 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/gosnmp/gosnmp"
|
||||
"github.com/sourcegraph/conc/pool"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/logger"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRescanInterval = time.Minute * 30
|
||||
defaultTimeout = time.Second * 1
|
||||
defaultParallelScansPerNetwork = 32
|
||||
defaultDeviceCacheTTL = time.Hour * 12
|
||||
)
|
||||
|
||||
func NewDiscoverer(cfg Config) (*Discoverer, error) {
|
||||
subnets, err := cfg.validateAndParse()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfgHash, _ := hashstructure.Hash(cfg, nil)
|
||||
|
||||
d := &Discoverer{
|
||||
Logger: logger.New().With(
|
||||
slog.String("component", "service discovery"),
|
||||
slog.String("discoverer", "snmp"),
|
||||
),
|
||||
started: make(chan struct{}),
|
||||
cfgHash: cfgHash,
|
||||
subnets: subnets,
|
||||
newSnmpClient: func() (gosnmp.Handler, func()) {
|
||||
return gosnmp.NewHandler(), func() {}
|
||||
},
|
||||
|
||||
rescanInterval: defaultRescanInterval,
|
||||
timeout: defaultTimeout,
|
||||
parallelScansPerNetwork: defaultParallelScansPerNetwork,
|
||||
deviceCacheTTL: defaultDeviceCacheTTL,
|
||||
|
||||
firstDiscovery: true,
|
||||
status: newDiscoveryStatus(),
|
||||
}
|
||||
|
||||
if cfg.RescanInterval > 0 {
|
||||
d.rescanInterval = cfg.RescanInterval.Duration()
|
||||
}
|
||||
if cfg.Timeout > 0 {
|
||||
d.timeout = cfg.Timeout.Duration()
|
||||
}
|
||||
if cfg.ParallelScansPerNetwork > 0 {
|
||||
d.parallelScansPerNetwork = cfg.ParallelScansPerNetwork
|
||||
}
|
||||
if cfg.DeviceCacheTTL > 0 {
|
||||
d.deviceCacheTTL = cfg.DeviceCacheTTL.Duration()
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
type (
|
||||
Discoverer struct {
|
||||
*logger.Logger
|
||||
model.Base
|
||||
|
||||
started chan struct{}
|
||||
cfgHash uint64
|
||||
|
||||
subnets []subnet
|
||||
|
||||
newSnmpClient func() (gosnmp.Handler, func())
|
||||
|
||||
parallelScansPerNetwork int
|
||||
rescanInterval time.Duration
|
||||
timeout time.Duration
|
||||
deviceCacheTTL time.Duration
|
||||
|
||||
firstDiscovery bool
|
||||
status *discoveryStatus
|
||||
statusUpdated atomic.Bool
|
||||
}
|
||||
subnet struct {
|
||||
str string
|
||||
ips iprange.Range
|
||||
credential CredentialConfig
|
||||
}
|
||||
)
|
||||
|
||||
func (d *Discoverer) String() string {
|
||||
return "sd:snmp"
|
||||
}
|
||||
|
||||
func (d *Discoverer) Discover(ctx context.Context, in chan<- []model.TargetGroup) {
|
||||
d.Info("instance is started")
|
||||
defer func() { d.Info("instance is stopped") }()
|
||||
|
||||
close(d.started)
|
||||
|
||||
d.loadFileStatus()
|
||||
|
||||
d.discoverNetworks(ctx, in)
|
||||
|
||||
if d.rescanInterval <= 0 {
|
||||
filepersister.Save(statusFileName(), d.status)
|
||||
return
|
||||
}
|
||||
|
||||
tk := time.NewTicker(d.rescanInterval)
|
||||
defer tk.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tk.C:
|
||||
d.discoverNetworks(ctx, in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Discoverer) discoverNetworks(ctx context.Context, in chan<- []model.TargetGroup) {
|
||||
now := time.Now()
|
||||
|
||||
doProbing := !d.firstDiscovery ||
|
||||
d.status.ConfigHash != d.cfgHash ||
|
||||
now.After(d.status.LastDiscoveryTime.Add(d.rescanInterval))
|
||||
|
||||
defer func() {
|
||||
if isDone(ctx) {
|
||||
return
|
||||
}
|
||||
d.firstDiscovery = false
|
||||
|
||||
if doProbing {
|
||||
d.status.LastDiscoveryTime = now
|
||||
}
|
||||
|
||||
if d.statusUpdated.Swap(false) || d.status.ConfigHash != d.cfgHash {
|
||||
d.status.ConfigHash = d.cfgHash
|
||||
filepersister.Save(statusFileName(), d.status)
|
||||
}
|
||||
}()
|
||||
|
||||
d.Infof("discovery mode: %s", map[bool]string{true: "active probing", false: "using cache"}[doProbing])
|
||||
|
||||
p := pool.New()
|
||||
for _, sub := range d.subnets {
|
||||
sub := sub
|
||||
p.Go(func() { d.discoverNetwork(ctx, in, sub, doProbing) })
|
||||
}
|
||||
p.Wait()
|
||||
}
|
||||
|
||||
func (d *Discoverer) discoverNetwork(ctx context.Context, in chan<- []model.TargetGroup, sub subnet, doProbing bool) {
|
||||
tgg := newTargetGroup(sub)
|
||||
p := pool.New().WithMaxGoroutines(d.parallelScansPerNetwork)
|
||||
|
||||
for ip := range sub.ips.Iterate() {
|
||||
ipAddr := ip.String()
|
||||
|
||||
if doProbing {
|
||||
p.Go(func() { d.probeIPAddress(ctx, sub, ipAddr, tgg) })
|
||||
} else {
|
||||
d.useCacheIPAddress(sub, ipAddr, tgg)
|
||||
}
|
||||
}
|
||||
p.Wait()
|
||||
|
||||
send(ctx, in, tgg)
|
||||
}
|
||||
|
||||
func (d *Discoverer) useCacheIPAddress(sub subnet, ip string, tgg *targetGroup) {
|
||||
if dev := d.status.get(sub, ip); dev != nil {
|
||||
tg := newTarget(ip, sub.credential, dev.SysInfo)
|
||||
tgg.addTarget(tg)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Discoverer) probeIPAddress(ctx context.Context, sub subnet, ip string, tgg *targetGroup) {
|
||||
if isDone(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
dev := d.status.get(sub, ip)
|
||||
|
||||
// Use the cached device if available and not expired
|
||||
if dev != nil && now.Before(dev.DiscoverTime.Add(d.deviceCacheTTL)) {
|
||||
if d.firstDiscovery {
|
||||
untilProbe := dev.DiscoverTime.Add(d.deviceCacheTTL).Sub(now).Round(time.Second)
|
||||
d.Infof("device '%s': found in cache (sysName: '%s', network: '%s', next probe in %s)",
|
||||
ip, dev.SysInfo.Name, subKey(sub), untilProbe)
|
||||
}
|
||||
tg := newTarget(ip, sub.credential, dev.SysInfo)
|
||||
tgg.addTarget(tg)
|
||||
return
|
||||
}
|
||||
|
||||
si, err := d.getSnmpSysInfo(sub, ip)
|
||||
if err != nil {
|
||||
if dev == nil {
|
||||
// First-time discovery failure - log at debug level as this is expected for many IPs
|
||||
d.Debugf("device '%s': probe failed (network: '%s'): %v", ip, subKey(sub), err)
|
||||
} else {
|
||||
// Previously discovered device is now unreachable
|
||||
d.Warningf("lost connection to previously discovered SNMP device '%s' (sysName: '%s', network: '%s'): %v",
|
||||
ip, dev.SysInfo.Name, subKey(sub), err)
|
||||
}
|
||||
d.status.del(sub, ip)
|
||||
d.statusUpdated.Store(dev != nil)
|
||||
return
|
||||
}
|
||||
|
||||
d.Infof("device '%s': successfully discovered (sysName: '%s', network: '%s')", ip, si.Name, subKey(sub))
|
||||
d.status.put(sub, ip, &discoveredDevice{DiscoverTime: now, SysInfo: *si})
|
||||
d.statusUpdated.Store(true)
|
||||
tg := newTarget(ip, sub.credential, *si)
|
||||
tgg.addTarget(tg)
|
||||
}
|
||||
|
||||
func (d *Discoverer) getSnmpSysInfo(sub subnet, ip string) (*SysInfo, error) {
|
||||
client, cleanup := d.newSnmpClient()
|
||||
defer cleanup()
|
||||
|
||||
client.SetTarget(ip)
|
||||
client.SetTimeout(d.timeout)
|
||||
client.SetRetries(0)
|
||||
setCredential(client, sub.credential)
|
||||
|
||||
if err := client.Connect(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
return GetSysInfo(client)
|
||||
}
|
||||
|
||||
func send(ctx context.Context, in chan<- []model.TargetGroup, tgg model.TargetGroup) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case in <- []model.TargetGroup{tgg}:
|
||||
}
|
||||
}
|
||||
func isDone(ctx context.Context) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,279 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
|
||||
)
|
||||
|
||||
func TestNewDiscoverer(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
cfg Config
|
||||
wantFail bool
|
||||
}{
|
||||
"succeeds with valid SNMPv1 config": {
|
||||
wantFail: false,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "v1cred", Version: "1", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "v1cred"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"succeeds with valid SNMPv2c config": {
|
||||
wantFail: false,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "v2cred", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "v2cred"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"succeeds with valid SNMPv3 config": {
|
||||
wantFail: false,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{
|
||||
Name: "v3cred",
|
||||
Version: "3",
|
||||
UserName: "user",
|
||||
SecurityLevel: "authPriv",
|
||||
AuthProtocol: "sha",
|
||||
AuthPassphrase: "authpass",
|
||||
PrivacyProtocol: "aes",
|
||||
PrivacyPassphrase: "privpass",
|
||||
},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "v3cred"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"succeeds with multiple valid credentials and networks": {
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "v1cred", Version: "1", Community: "public"},
|
||||
{Name: "v2cred", Version: "2c", Community: "private"},
|
||||
{
|
||||
Name: "v3cred",
|
||||
Version: "3",
|
||||
UserName: "user",
|
||||
SecurityLevel: "authPriv",
|
||||
AuthProtocol: "sha",
|
||||
AuthPassphrase: "authpass",
|
||||
PrivacyProtocol: "aes",
|
||||
PrivacyPassphrase: "privpass",
|
||||
},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "v1cred"},
|
||||
{Subnet: "10.0.0.0/24", Credential: "v2cred"},
|
||||
{Subnet: "172.16.0.0/24", Credential: "v3cred"},
|
||||
},
|
||||
},
|
||||
wantFail: false,
|
||||
},
|
||||
"fails on empty config": {
|
||||
wantFail: true,
|
||||
},
|
||||
"fails with credentials but no networks": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with networks but no credentials": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with credential without name": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with duplicate credential names": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
{Name: "test", Version: "2c", Community: "private"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with network without subnet": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with network without credential": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with network with nonexistent credential": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "nonexistent"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with invalid subnet format": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "invalid-subnet", Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with subnet too large (> 512 IPs)": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/22", Credential: "test"}, // 1024 IPs
|
||||
},
|
||||
},
|
||||
},
|
||||
"fails with duplicate subnet": {
|
||||
wantFail: true,
|
||||
cfg: Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "test", Version: "2c", Community: "public"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/24", Credential: "test"},
|
||||
{Subnet: "192.0.2.0/24", Credential: "test"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
d, err := NewDiscoverer(test.cfg)
|
||||
|
||||
if test.wantFail {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, d)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverer_Run(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
prepareSim func(t *testing.T) *discoverySim
|
||||
}{
|
||||
"simple discovery": {
|
||||
prepareSim: func(t *testing.T) *discoverySim {
|
||||
cfg := Config{
|
||||
Credentials: []CredentialConfig{
|
||||
{Name: "public-v2", Version: "2", Community: "public-v2"},
|
||||
},
|
||||
Networks: []NetworkConfig{
|
||||
{Subnet: "192.0.2.0/29", Credential: "public-v2"},
|
||||
},
|
||||
}
|
||||
|
||||
subnets, err := cfg.validateAndParse()
|
||||
sub := subnets[0]
|
||||
require.NoError(t, err)
|
||||
|
||||
sim := discoverySim{
|
||||
cfg: cfg,
|
||||
updateSnmpHandler: func(m *mockSnmpHandler) {
|
||||
m.skipOnConnect = func(ip string) bool {
|
||||
// Skip if the last octet is odd
|
||||
i := strings.LastIndexByte(ip, '.')
|
||||
if i == -1 {
|
||||
return false
|
||||
}
|
||||
lastOctet, err := strconv.Atoi(ip[i+1:])
|
||||
return err == nil && lastOctet%2 != 0
|
||||
}
|
||||
},
|
||||
wantGroups: []model.TargetGroup{
|
||||
prepareNewTargetGroup(sub, "192.0.2.2", "192.0.2.4", "192.0.2.6"),
|
||||
},
|
||||
}
|
||||
|
||||
return &sim
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
sim := test.prepareSim(t)
|
||||
sim.run(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func prepareNewTargetGroup(sub subnet, ips ...string) *targetGroup {
|
||||
tgg := newTargetGroup(sub)
|
||||
for _, ip := range ips {
|
||||
tg := prepareNewTarget(sub, ip)
|
||||
tgg.addTarget(tg)
|
||||
}
|
||||
return tgg
|
||||
}
|
||||
|
||||
func prepareNewTarget(sub subnet, ip string) *target {
|
||||
return newTarget(ip, sub.credential, SysInfo{
|
||||
Descr: mockSysDescr,
|
||||
Contact: mockSysContact,
|
||||
Name: mockSysName,
|
||||
Location: mockSysLocation,
|
||||
Organization: "net-snmp",
|
||||
})
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package entnum
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"bufio"
|
|
@ -0,0 +1,183 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/gosnmp/gosnmp"
|
||||
snmpmock "github.com/gosnmp/gosnmp/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
|
||||
)
|
||||
|
||||
type discoverySim struct {
|
||||
cfg Config
|
||||
updateSnmpHandler func(m *mockSnmpHandler)
|
||||
wantGroups []model.TargetGroup
|
||||
}
|
||||
|
||||
func (sim *discoverySim) run(t *testing.T) {
|
||||
d, err := NewDiscoverer(sim.cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
d.newSnmpClient = func() (gosnmp.Handler, func()) {
|
||||
h, cleanup := prepareMockSnmpHandler(t)
|
||||
h.setExpectInit()
|
||||
h.setExpectSysInfo()
|
||||
if sim.updateSnmpHandler != nil {
|
||||
sim.updateSnmpHandler(h)
|
||||
}
|
||||
return h, cleanup
|
||||
}
|
||||
|
||||
seen := make(map[string]model.TargetGroup)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
in := make(chan []model.TargetGroup)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
d.Discover(ctx, in)
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case tggs := <-in:
|
||||
for _, tgg := range tggs {
|
||||
seen[tgg.Source()] = tgg
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-d.started:
|
||||
case <-time.After(time.Second * 5):
|
||||
require.Fail(t, "discovery failed to start")
|
||||
}
|
||||
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second * 5):
|
||||
require.Fail(t, "discovery hasn't finished after cancel")
|
||||
}
|
||||
|
||||
var tggs []model.TargetGroup
|
||||
for _, tgg := range seen {
|
||||
tggs = append(tggs, tgg)
|
||||
}
|
||||
|
||||
sortTargetGroups(tggs)
|
||||
sortTargetGroups(sim.wantGroups)
|
||||
|
||||
wantLen, gotLen := calcTargets(sim.wantGroups), calcTargets(tggs)
|
||||
assert.Equalf(t, wantLen, gotLen, "different len (want %d got %d)", wantLen, gotLen)
|
||||
assert.Equal(t, sim.wantGroups, tggs)
|
||||
}
|
||||
|
||||
func calcTargets(tggs []model.TargetGroup) int {
|
||||
var n int
|
||||
for _, tgg := range tggs {
|
||||
n += len(tgg.Targets())
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sortTargetGroups(tggs []model.TargetGroup) {
|
||||
if len(tggs) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(tggs, func(i, j int) bool { return tggs[i].Source() < tggs[j].Source() })
|
||||
|
||||
for idx := range tggs {
|
||||
tgts := tggs[idx].Targets()
|
||||
sort.Slice(tgts, func(i, j int) bool { return tgts[i].Hash() < tgts[j].Hash() })
|
||||
}
|
||||
}
|
||||
|
||||
type mockSnmpHandler struct {
|
||||
mu sync.Mutex
|
||||
*snmpmock.MockHandler
|
||||
skipOnConnect func(ip string) bool
|
||||
}
|
||||
|
||||
func (m *mockSnmpHandler) Connect() error {
|
||||
if m.skipOnConnect != nil && m.skipOnConnect(m.MockHandler.Target()) {
|
||||
return errors.New("mock handler skip connect")
|
||||
}
|
||||
return m.MockHandler.Connect()
|
||||
}
|
||||
|
||||
func prepareMockSnmpHandler(t *testing.T) (*mockSnmpHandler, func()) {
|
||||
mockCtl := gomock.NewController(t)
|
||||
cleanup := func() { mockCtl.Finish() }
|
||||
mockSNMP := snmpmock.NewMockHandler(mockCtl)
|
||||
m := &mockSnmpHandler{MockHandler: mockSNMP}
|
||||
|
||||
return m, cleanup
|
||||
}
|
||||
|
||||
func (m *mockSnmpHandler) setExpectInit() {
|
||||
var ip string
|
||||
m.EXPECT().Target().DoAndReturn(func() string { return ip }).AnyTimes()
|
||||
m.EXPECT().SetTarget(gomock.Any()).Do(func(target string) { ip = target }).AnyTimes()
|
||||
m.EXPECT().Port().AnyTimes()
|
||||
m.EXPECT().Version().AnyTimes()
|
||||
m.EXPECT().Community().AnyTimes()
|
||||
m.EXPECT().SetPort(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetRetries(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetMaxRepetitions(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetMaxOids(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetLogger(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetTimeout(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetCommunity(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetVersion(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetSecurityModel(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetMsgFlags(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().SetSecurityParameters(gomock.Any()).AnyTimes()
|
||||
m.EXPECT().Connect().Return(nil).AnyTimes()
|
||||
m.EXPECT().Close().Return(nil).AnyTimes()
|
||||
}
|
||||
|
||||
const (
|
||||
mockSysDescr = "mock sysDescr"
|
||||
mockSysObject = ".1.3.6.1.4.1.8072.3.2.10"
|
||||
mockSysContact = "mock sysContact"
|
||||
mockSysName = "mock sysName"
|
||||
mockSysLocation = "mock sysLocation"
|
||||
)
|
||||
|
||||
func (m *mockSnmpHandler) setExpectSysInfo() {
|
||||
m.EXPECT().WalkAll(RootOidMibSystem).Return([]gosnmp.SnmpPDU{
|
||||
{Name: OidSysDescr, Value: []uint8(mockSysDescr), Type: gosnmp.OctetString},
|
||||
{Name: OidSysObject, Value: mockSysObject, Type: gosnmp.ObjectIdentifier},
|
||||
{Name: OidSysContact, Value: []uint8(mockSysContact), Type: gosnmp.OctetString},
|
||||
{Name: OidSysName, Value: []uint8(mockSysName), Type: gosnmp.OctetString},
|
||||
{Name: OidSysLocation, Value: []uint8(mockSysLocation), Type: gosnmp.OctetString},
|
||||
}, nil).AnyTimes()
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Discoverer) loadFileStatus() {
|
||||
d.status = newDiscoveryStatus()
|
||||
|
||||
filename := statusFileName()
|
||||
if filename == "" {
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
d.Warningf("failed to open status file %s: %v", filename, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
if err := json.NewDecoder(f).Decode(d.status); err != nil {
|
||||
d.Warningf("failed to parse status file %s: %v", filename, err)
|
||||
return
|
||||
}
|
||||
|
||||
d.Infof("loaded status file: last discovery=%s", d.status.LastDiscoveryTime)
|
||||
}
|
||||
|
||||
func statusFileName() string {
|
||||
v := os.Getenv("NETDATA_LIB_DIR")
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(v, "god-sd-snmp-status.json")
|
||||
}
|
||||
|
||||
func newDiscoveryStatus() *discoveryStatus {
|
||||
return &discoveryStatus{
|
||||
Networks: make(map[string]map[string]*discoveredDevice),
|
||||
}
|
||||
}
|
||||
|
||||
type (
|
||||
discoveryStatus struct {
|
||||
mux sync.RWMutex
|
||||
Networks map[string]map[string]*discoveredDevice `json:"networks"`
|
||||
LastDiscoveryTime time.Time `json:"last_discovery_time"`
|
||||
ConfigHash uint64 `json:"config_hash"`
|
||||
}
|
||||
discoveredDevice struct {
|
||||
DiscoverTime time.Time `json:"discover_time"`
|
||||
SysInfo SysInfo `json:"sysinfo"`
|
||||
}
|
||||
)
|
||||
|
||||
func (s *discoveryStatus) Bytes() ([]byte, error) {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
return json.MarshalIndent(s, "", " ")
|
||||
}
|
||||
|
||||
func (s *discoveryStatus) get(sub subnet, ip string) *discoveredDevice {
|
||||
s.mux.RLock()
|
||||
defer s.mux.RUnlock()
|
||||
|
||||
devices, ok := s.Networks[subKey(sub)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return devices[ip]
|
||||
}
|
||||
|
||||
func (s *discoveryStatus) put(sub subnet, ip string, dev *discoveredDevice) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
devices, ok := s.Networks[subKey(sub)]
|
||||
if !ok {
|
||||
devices = make(map[string]*discoveredDevice)
|
||||
s.Networks[subKey(sub)] = devices
|
||||
}
|
||||
devices[ip] = dev
|
||||
}
|
||||
|
||||
func (s *discoveryStatus) del(sub subnet, ip string) {
|
||||
s.mux.Lock()
|
||||
defer s.mux.Unlock()
|
||||
|
||||
if devices, ok := s.Networks[subKey(sub)]; ok {
|
||||
delete(devices, ip)
|
||||
}
|
||||
}
|
||||
|
||||
func subKey(s subnet) string {
|
||||
return fmt.Sprintf("%s:%s", s.str, s.credential.Name)
|
||||
}
|
|
@ -0,0 +1,91 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
)
|
||||
|
||||
const (
|
||||
RootOidMibSystem = "1.3.6.1.2.1.1"
|
||||
OidSysDescr = "1.3.6.1.2.1.1.1.0"
|
||||
OidSysObject = "1.3.6.1.2.1.1.2.0"
|
||||
OidSysUptime = "1.3.6.1.2.1.1.3.0"
|
||||
OidSysContact = "1.3.6.1.2.1.1.4.0"
|
||||
OidSysName = "1.3.6.1.2.1.1.5.0"
|
||||
OidSysLocation = "1.3.6.1.2.1.1.6.0"
|
||||
)
|
||||
|
||||
type SysInfo struct {
|
||||
Descr string `json:"description"`
|
||||
Contact string `json:"contact"`
|
||||
Name string `json:"name"`
|
||||
Location string `json:"location"`
|
||||
Organization string `json:"organization"`
|
||||
}
|
||||
|
||||
func GetSysInfo(client gosnmp.Handler) (*SysInfo, error) {
|
||||
pdus, err := client.WalkAll(RootOidMibSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
si := &SysInfo{
|
||||
Name: "unknown",
|
||||
Organization: "Unknown",
|
||||
}
|
||||
|
||||
r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
|
||||
|
||||
for _, pdu := range pdus {
|
||||
oid := strings.TrimPrefix(pdu.Name, ".")
|
||||
|
||||
switch oid {
|
||||
case OidSysDescr:
|
||||
if si.Descr, err = PduToString(pdu); err == nil {
|
||||
si.Descr = r.Replace(si.Descr)
|
||||
}
|
||||
case OidSysObject:
|
||||
var sysObj string
|
||||
if sysObj, err = PduToString(pdu); err == nil {
|
||||
si.Organization = LookupBySysObject(sysObj)
|
||||
}
|
||||
case OidSysContact:
|
||||
si.Contact, err = PduToString(pdu)
|
||||
case OidSysName:
|
||||
si.Name, err = PduToString(pdu)
|
||||
case OidSysLocation:
|
||||
si.Location, err = PduToString(pdu)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return si, nil
|
||||
}
|
||||
|
||||
func PduToString(pdu gosnmp.SnmpPDU) (string, error) {
|
||||
switch pdu.Type {
|
||||
case gosnmp.OctetString:
|
||||
// TODO: this isn't reliable (e.g. physAddress we need hex.EncodeToString())
|
||||
bs, ok := pdu.Value.([]byte)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("OctetString is not a []byte but %T", pdu.Value)
|
||||
}
|
||||
return strings.ToValidUTF8(string(bs), "<22>"), nil
|
||||
case gosnmp.Counter32, gosnmp.Counter64, gosnmp.Integer, gosnmp.Gauge32:
|
||||
return gosnmp.ToBigInt(pdu.Value).String(), nil
|
||||
case gosnmp.ObjectIdentifier:
|
||||
v, ok := pdu.Value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("ObjectIdentifier is not a string but %T", pdu.Value)
|
||||
}
|
||||
return strings.TrimPrefix(v, "."), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unussported type: '%v'", pdu.Type)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmpsd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
|
||||
)
|
||||
|
||||
func targetSource(sub subnet) string { return fmt.Sprintf("discoverer=snmp,network=%s", subKey(sub)) }
|
||||
|
||||
func newTargetGroup(sub subnet) *targetGroup {
|
||||
return &targetGroup{
|
||||
provider: "sd:snmpdiscoverer",
|
||||
source: targetSource(sub),
|
||||
}
|
||||
}
|
||||
|
||||
type targetGroup struct {
|
||||
provider string
|
||||
source string
|
||||
mux sync.Mutex
|
||||
targets []model.Target
|
||||
}
|
||||
|
||||
func (g *targetGroup) Provider() string { return g.provider }
|
||||
func (g *targetGroup) Source() string { return g.source }
|
||||
func (g *targetGroup) Targets() []model.Target { return g.targets }
|
||||
|
||||
func (g *targetGroup) addTarget(tg model.Target) {
|
||||
g.mux.Lock()
|
||||
defer g.mux.Unlock()
|
||||
g.targets = append(g.targets, tg)
|
||||
}
|
||||
|
||||
func newTarget(ip string, cred CredentialConfig, si SysInfo) *target {
|
||||
tg := &target{
|
||||
IPAddress: ip,
|
||||
Credential: cred,
|
||||
SysInfo: si,
|
||||
}
|
||||
|
||||
tg.hash, _ = hashstructure.Hash(tg, nil)
|
||||
|
||||
return tg
|
||||
}
|
||||
|
||||
type (
|
||||
target struct {
|
||||
model.Base `hash:"ignore"`
|
||||
hash uint64
|
||||
|
||||
IPAddress string
|
||||
Credential CredentialConfig `hash:"ignore"`
|
||||
SysInfo SysInfo `hash:"ignore"`
|
||||
}
|
||||
)
|
||||
|
||||
func (t *target) TUID() string { return fmt.Sprintf("snmp_%s_%d", t.IPAddress, t.hash) }
|
||||
func (t *target) Hash() uint64 { return t.hash }
|
|
@ -51,7 +51,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) {
|
|||
select {
|
||||
case <-done:
|
||||
a.Info("all discoverers exited")
|
||||
case <-time.After(time.Second * 3):
|
||||
case <-time.After(time.Second * 10):
|
||||
a.Warning("not all discoverers exited")
|
||||
}
|
||||
a.trySend(in)
|
||||
|
@ -83,7 +83,7 @@ func (a *accumulator) runDiscoverer(ctx context.Context, d model.Discoverer, upd
|
|||
case <-ctx.Done():
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second * 2):
|
||||
case <-time.After(time.Second * 10):
|
||||
a.Warningf("discoverer '%v' didn't exit on ctx done", d)
|
||||
}
|
||||
return
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
@ -28,6 +29,7 @@ type DiscoveryConfig struct {
|
|||
NetListeners netlisteners.Config `yaml:"net_listeners"`
|
||||
Docker dockerd.Config `yaml:"docker"`
|
||||
K8s []kubernetes.Config `yaml:"k8s"`
|
||||
SNMP snmpsd.Config `yaml:"snmp"`
|
||||
}
|
||||
|
||||
type ClassifyRuleConfig struct {
|
||||
|
@ -71,7 +73,7 @@ func validateDiscoveryConfig(config []DiscoveryConfig) error {
|
|||
}
|
||||
for _, cfg := range config {
|
||||
switch cfg.Discoverer {
|
||||
case "net_listeners", "docker", "k8s":
|
||||
case "net_listeners", "docker", "k8s", "snmp":
|
||||
default:
|
||||
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/hostinfo"
|
||||
)
|
||||
|
@ -102,6 +103,12 @@ func (p *Pipeline) registerDiscoverers(conf Config) error {
|
|||
}
|
||||
p.discoverers = append(p.discoverers, td)
|
||||
}
|
||||
case "snmp":
|
||||
td, err := snmpsd.NewDiscoverer(cfg.SNMP)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err)
|
||||
}
|
||||
p.discoverers = append(p.discoverers, td)
|
||||
default:
|
||||
return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer)
|
||||
}
|
||||
|
@ -130,7 +137,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
|
|||
case <-ctx.Done():
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second * 4):
|
||||
case <-time.After(time.Second * 10):
|
||||
}
|
||||
return
|
||||
case <-done:
|
||||
|
@ -149,7 +156,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) {
|
|||
|
||||
func (p *Pipeline) processGroups(tggs []model.TargetGroup) []*confgroup.Group {
|
||||
var groups []*confgroup.Group
|
||||
// updates come from the accumulator, this ensures that all groups have different sources
|
||||
// updates come from the accumulator; this ensures that all groups have different sources
|
||||
for _, tgg := range tggs {
|
||||
p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets()))
|
||||
if v := p.processGroup(tgg); v != nil {
|
||||
|
|
|
@ -16,14 +16,21 @@ type Data interface {
|
|||
Updated() <-chan struct{}
|
||||
}
|
||||
|
||||
func Save(path string, data interface{ Bytes() ([]byte, error) }) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
New(path).flush(data)
|
||||
}
|
||||
|
||||
func New(path string) *Persister {
|
||||
return &Persister{
|
||||
Logger: logger.New().With(
|
||||
slog.String("component", "file persister"),
|
||||
slog.String("file", path),
|
||||
),
|
||||
FlushEvery: time.Minute * 1,
|
||||
filepath: path,
|
||||
flushEvery: time.Second * 5,
|
||||
flushCh: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
@ -31,10 +38,11 @@ func New(path string) *Persister {
|
|||
type Persister struct {
|
||||
*logger.Logger
|
||||
|
||||
data Data
|
||||
filepath string
|
||||
flushEvery time.Duration
|
||||
flushCh chan struct{}
|
||||
FlushEvery time.Duration
|
||||
|
||||
data Data
|
||||
filepath string
|
||||
flushCh chan struct{}
|
||||
}
|
||||
|
||||
func (p *Persister) Run(ctx context.Context, data Data) {
|
||||
|
@ -43,9 +51,9 @@ func (p *Persister) Run(ctx context.Context, data Data) {
|
|||
|
||||
p.data = data
|
||||
|
||||
tk := time.NewTicker(p.flushEvery)
|
||||
tk := time.NewTicker(p.FlushEvery)
|
||||
defer tk.Stop()
|
||||
defer p.flush()
|
||||
defer p.flush(p.data)
|
||||
|
||||
for {
|
||||
select {
|
||||
|
@ -70,14 +78,14 @@ func (p *Persister) triggerFlush() {
|
|||
func (p *Persister) tryFlush() {
|
||||
select {
|
||||
case <-p.flushCh:
|
||||
p.flush()
|
||||
p.flush(p.data)
|
||||
default:
|
||||
// no pending flush
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Persister) flush() {
|
||||
bs, err := p.data.Bytes()
|
||||
func (p *Persister) flush(data interface{ Bytes() ([]byte, error) }) {
|
||||
bs, err := data.Bytes()
|
||||
if err != nil {
|
||||
p.Debugf("failed to marshal data: %v", err)
|
||||
return
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
|
@ -13,14 +14,18 @@ import (
|
|||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister"
|
||||
)
|
||||
|
||||
func statusFileName(dir string) string {
|
||||
return filepath.Join(dir, "god-jobs-statuses.json")
|
||||
}
|
||||
|
||||
func (m *Manager) loadFileStatus() {
|
||||
m.fileStatus = newFileStatus()
|
||||
|
||||
if isTerminal || m.StateFile == "" {
|
||||
if isTerminal || m.VarLibDir == "" {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := loadFileStatus(m.StateFile)
|
||||
s, err := loadFileStatus(statusFileName(m.VarLibDir))
|
||||
if err != nil {
|
||||
m.Warningf("failed to load state file: %v", err)
|
||||
return
|
||||
|
@ -29,10 +34,12 @@ func (m *Manager) loadFileStatus() {
|
|||
}
|
||||
|
||||
func (m *Manager) runFileStatusPersistence() {
|
||||
if m.StateFile == "" {
|
||||
if m.VarLibDir == "" {
|
||||
return
|
||||
}
|
||||
p := filepersister.New(m.StateFile)
|
||||
|
||||
p := filepersister.New(statusFileName(m.VarLibDir))
|
||||
|
||||
p.Run(m.ctx, m.fileStatus)
|
||||
}
|
||||
|
||||
|
|
|
@ -60,7 +60,7 @@ type Manager struct {
|
|||
Out io.Writer
|
||||
Modules module.Registry
|
||||
ConfigDefaults confgroup.Registry
|
||||
StateFile string
|
||||
VarLibDir string
|
||||
FnReg FunctionRegistry
|
||||
Vnodes map[string]*vnodes.VirtualNode
|
||||
|
||||
|
|
|
@ -162,8 +162,8 @@ func (c *Collector) addNetIfaceCharts(iface *netInterface) {
|
|||
for _, chart := range *charts {
|
||||
chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName))
|
||||
chart.Labels = []module.Label{
|
||||
{Key: "vendor", Value: c.sysInfo.organization},
|
||||
{Key: "sysName", Value: c.sysInfo.name},
|
||||
{Key: "vendor", Value: c.sysInfo.Organization},
|
||||
{Key: "sysName", Value: c.sysInfo.Name},
|
||||
{Key: "ifDescr", Value: iface.ifDescr},
|
||||
{Key: "ifName", Value: iface.ifName},
|
||||
{Key: "ifType", Value: ifTypeMapping[iface.ifType]},
|
||||
|
@ -191,8 +191,8 @@ func (c *Collector) removeNetIfaceCharts(iface *netInterface) {
|
|||
func (c *Collector) addSysUptimeChart() {
|
||||
chart := uptimeChart.Copy()
|
||||
chart.Labels = []module.Label{
|
||||
{Key: "vendor", Value: c.sysInfo.organization},
|
||||
{Key: "sysName", Value: c.sysInfo.name},
|
||||
{Key: "vendor", Value: c.sysInfo.Organization},
|
||||
{Key: "sysName", Value: c.sysInfo.Name},
|
||||
}
|
||||
if err := c.Charts().Add(chart); err != nil {
|
||||
c.Warning(err)
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
package snmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
@ -15,7 +17,7 @@ import (
|
|||
|
||||
func (c *Collector) collect() (map[string]int64, error) {
|
||||
if c.sysInfo == nil {
|
||||
si, err := c.getSysInfo()
|
||||
si, err := snmpsd.GetSysInfo(c.snmpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -49,6 +51,24 @@ func (c *Collector) collect() (map[string]int64, error) {
|
|||
return mx, nil
|
||||
}
|
||||
|
||||
func (c *Collector) collectSysUptime(mx map[string]int64) error {
|
||||
resp, err := c.snmpClient.Get([]string{snmpsd.OidSysUptime})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resp.Variables) == 0 {
|
||||
return errors.New("no system uptime")
|
||||
}
|
||||
v, err := pduToInt(resp.Variables[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mx["uptime"] = v / 100 // the time is in hundredths of a second
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
|
||||
if c.snmpClient.Version() == gosnmp.Version1 {
|
||||
return c.snmpClient.WalkAll(rootOid)
|
||||
|
@ -56,12 +76,12 @@ func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) {
|
|||
return c.snmpClient.BulkWalkAll(rootOid)
|
||||
}
|
||||
|
||||
func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
|
||||
func (c *Collector) setupVnode(si *snmpsd.SysInfo) *vnodes.VirtualNode {
|
||||
if c.Vnode.GUID == "" {
|
||||
c.Vnode.GUID = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(c.Hostname)).String()
|
||||
}
|
||||
|
||||
hostnames := []string{c.Vnode.Hostname, si.name, "snmp-device"}
|
||||
hostnames := []string{c.Vnode.Hostname, si.Name, "snmp-device"}
|
||||
i := slices.IndexFunc(hostnames, func(s string) bool { return s != "" })
|
||||
|
||||
c.Vnode.Hostname = fmt.Sprintf("%s(%s)", hostnames[i], c.Hostname)
|
||||
|
@ -71,17 +91,17 @@ func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode {
|
|||
for k, v := range c.Vnode.Labels {
|
||||
labels[k] = v
|
||||
}
|
||||
if si.descr != "" {
|
||||
labels["sysDescr"] = si.descr
|
||||
if si.Descr != "" {
|
||||
labels["sysDescr"] = si.Descr
|
||||
}
|
||||
if si.contact != "" {
|
||||
labels["sysContact"] = si.contact
|
||||
if si.Contact != "" {
|
||||
labels["sysContact"] = si.Contact
|
||||
}
|
||||
if si.location != "" {
|
||||
labels["sysLocation"] = si.location
|
||||
if si.Location != "" {
|
||||
labels["sysLocation"] = si.Location
|
||||
}
|
||||
// FIXME: vendor should be obtained from sysDescr, org should be used as a fallback
|
||||
labels["vendor"] = si.organization
|
||||
labels["vendor"] = si.Organization
|
||||
|
||||
return &vnodes.VirtualNode{
|
||||
GUID: c.Vnode.GUID,
|
||||
|
|
|
@ -1,90 +1,3 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package snmp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/entnum"
|
||||
)
|
||||
|
||||
const (
|
||||
rootOidMibSystem = "1.3.6.1.2.1.1"
|
||||
oidSysDescr = "1.3.6.1.2.1.1.1.0"
|
||||
oidSysObject = "1.3.6.1.2.1.1.2.0"
|
||||
oidSysUptime = "1.3.6.1.2.1.1.3.0"
|
||||
oidSysContact = "1.3.6.1.2.1.1.4.0"
|
||||
oidSysName = "1.3.6.1.2.1.1.5.0"
|
||||
oidSysLocation = "1.3.6.1.2.1.1.6.0"
|
||||
)
|
||||
|
||||
type sysInfo struct {
|
||||
descr string
|
||||
contact string
|
||||
name string
|
||||
location string
|
||||
|
||||
organization string
|
||||
}
|
||||
|
||||
func (c *Collector) getSysInfo() (*sysInfo, error) {
|
||||
pdus, err := c.snmpClient.WalkAll(rootOidMibSystem)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
si := &sysInfo{
|
||||
name: "unknown",
|
||||
organization: "Unknown",
|
||||
}
|
||||
|
||||
r := strings.NewReplacer("\n", "\\n", "\r", "\\r")
|
||||
|
||||
for _, pdu := range pdus {
|
||||
oid := strings.TrimPrefix(pdu.Name, ".")
|
||||
|
||||
switch oid {
|
||||
case oidSysDescr:
|
||||
if si.descr, err = pduToString(pdu); err == nil {
|
||||
si.descr = r.Replace(si.descr)
|
||||
}
|
||||
case oidSysObject:
|
||||
var sysObj string
|
||||
if sysObj, err = pduToString(pdu); err == nil {
|
||||
si.organization = entnum.LookupBySysObject(sysObj)
|
||||
c.Debugf("device sysObject '%s', organization '%s'", sysObj, si.organization)
|
||||
}
|
||||
case oidSysContact:
|
||||
si.contact, err = pduToString(pdu)
|
||||
case oidSysName:
|
||||
si.name, err = pduToString(pdu)
|
||||
case oidSysLocation:
|
||||
si.location, err = pduToString(pdu)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return si, nil
|
||||
}
|
||||
|
||||
func (c *Collector) collectSysUptime(mx map[string]int64) error {
|
||||
resp, err := c.snmpClient.Get([]string{oidSysUptime})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(resp.Variables) == 0 {
|
||||
return errors.New("no system uptime")
|
||||
}
|
||||
v, err := pduToInt(resp.Variables[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mx["uptime"] = v / 100 // the time is in hundredths of a second
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/pkg/matcher"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes"
|
||||
|
||||
|
@ -76,7 +77,7 @@ type Collector struct {
|
|||
|
||||
netInterfaces map[string]*netInterface
|
||||
|
||||
sysInfo *sysInfo
|
||||
sysInfo *snmpsd.SysInfo
|
||||
|
||||
customOids []string
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd"
|
||||
"github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
|
@ -581,15 +582,15 @@ func setMockClientInitExpect(m *snmpmock.MockHandler) {
|
|||
}
|
||||
|
||||
func setMockClientSysExpect(m *snmpmock.MockHandler) {
|
||||
m.EXPECT().WalkAll(rootOidMibSystem).Return([]gosnmp.SnmpPDU{
|
||||
{Name: oidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
|
||||
{Name: oidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
|
||||
{Name: oidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
|
||||
{Name: oidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
|
||||
{Name: oidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
|
||||
m.EXPECT().WalkAll(snmpsd.RootOidMibSystem).Return([]gosnmp.SnmpPDU{
|
||||
{Name: snmpsd.OidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString},
|
||||
{Name: snmpsd.OidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier},
|
||||
{Name: snmpsd.OidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString},
|
||||
{Name: snmpsd.OidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString},
|
||||
{Name: snmpsd.OidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString},
|
||||
}, nil).MinTimes(1)
|
||||
|
||||
m.EXPECT().Get([]string{oidSysUptime}).Return(&gosnmp.SnmpPacket{
|
||||
m.EXPECT().Get([]string{snmpsd.OidSysUptime}).Return(&gosnmp.SnmpPacket{
|
||||
Variables: []gosnmp.SnmpPDU{
|
||||
{Value: uint32(6048), Type: gosnmp.TimeTicks},
|
||||
},
|
||||
|
|
98
src/go/plugin/go.d/config/go.d/sd/snmp.conf
Normal file
98
src/go/plugin/go.d/config/go.d/sd/snmp.conf
Normal file
|
@ -0,0 +1,98 @@
|
|||
# ===================================================================
|
||||
# WARNING: SNMP DISCOVERY IS DISABLED BY DEFAULT
|
||||
# To enable, change "disabled: yes" to "disabled: no" below
|
||||
# AND configure proper credentials and networks
|
||||
# ===================================================================
|
||||
|
||||
disabled: yes
|
||||
|
||||
name: 'snmp'
|
||||
|
||||
discover:
|
||||
- discoverer: snmp
|
||||
snmp:
|
||||
# how often to scan the networks for devices (default: 30m)
|
||||
rescan_interval: "30m"
|
||||
|
||||
# the maximum time to wait for SNMP device responses (default: 1s)
|
||||
timeout: "1s"
|
||||
|
||||
# How long to trust cached discovery results before requiring a new probe (default: 12h)
|
||||
device_cache_ttl: "6h"
|
||||
|
||||
# how many IPs to scan concurrently within each subnet (default: 32)
|
||||
parallel_scans_per_network: 32
|
||||
|
||||
# ==========================================================
|
||||
# IMPORTANT: YOU MUST CONFIGURE YOUR OWN CREDENTIALS BELOW
|
||||
# The example credentials will not work in most environments
|
||||
# ==========================================================
|
||||
credentials:
|
||||
- name: "public-v2c"
|
||||
version: "2"
|
||||
community: "public"
|
||||
|
||||
- name: "secure-v3"
|
||||
version: "3"
|
||||
# one of: "noAuthNoPriv", "authNoPriv", or "authPriv"
|
||||
security_level: "authPriv"
|
||||
username: "admin"
|
||||
# one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512"
|
||||
auth_protocol: "sha"
|
||||
auth_password: "secret123"
|
||||
# one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C"
|
||||
priv_protocol: "aes"
|
||||
priv_password: "encrypt123"
|
||||
|
||||
# ========================================================
|
||||
# IMPORTANT: YOU MUST CONFIGURE YOUR OWN NETWORKS BELOW
|
||||
# By default, no networks will be scanned until configured
|
||||
# Maximum size is limited to 512 IPs per subnet (/23 CIDR)
|
||||
# ========================================================
|
||||
networks:
|
||||
# Subnet is the IP range to scan, supporting various formats:
|
||||
# https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats
|
||||
- subnet: "192.168.1.0/24"
|
||||
# Credential is the name of a credential from the Credentials list
|
||||
credential: "public-v2c"
|
||||
|
||||
classify:
|
||||
- name: "Servers"
|
||||
selector: "*"
|
||||
tags: "skip"
|
||||
match:
|
||||
- tags: "skip"
|
||||
expr: '{{ match "sp" .SysInfo.Descr "Linux* FreeBSD* OpenBSD* NetBSD*" }}'
|
||||
- name: "SNMP Devices"
|
||||
selector: "!skip *"
|
||||
tags: "snmp"
|
||||
match:
|
||||
- tags: "snmp"
|
||||
expr: '{{ true }}'
|
||||
compose:
|
||||
- name: "SNMP Devices"
|
||||
selector: "snmp"
|
||||
config:
|
||||
- selector: "snmp"
|
||||
template: |
|
||||
module: snmp
|
||||
update_every: 5
|
||||
{{- if .SysInfo.Name }}
|
||||
name: {{ .SysInfo.Name }}-ip-{{ .IPAddress }}
|
||||
{{- else }}
|
||||
name: ip-{{ .IPAddress }}
|
||||
{{- end }}
|
||||
hostname: {{ .IPAddress }}
|
||||
options:
|
||||
version: {{ .Credential.Version }}
|
||||
{{- if eq .Credential.Version "1" "2" }}
|
||||
community: {{ .Credential.Community }}
|
||||
{{- else }}
|
||||
user:
|
||||
name: {{ .Credential.UserName }}
|
||||
level: {{ .Credential.SecurityLevel }}
|
||||
auth_proto: {{ .Credential.AuthProtocol }}
|
||||
auth_key: {{ .Credential.AuthPassphrase }}
|
||||
priv_proto: {{ .Credential.PrivacyProtocol }}
|
||||
priv_key: {{ .Credential.PrivacyPassphrase }}
|
||||
{{- end }}
|
|
@ -5,10 +5,70 @@ package confopt
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`)
|
||||
|
||||
// ParseDuration parses a duration string with units.
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
orig := s
|
||||
|
||||
if s = strings.ReplaceAll(s, " ", ""); s == "" {
|
||||
return 0, fmt.Errorf("empty duration string")
|
||||
}
|
||||
|
||||
neg := s[0] == '-'
|
||||
if neg {
|
||||
s = s[1:]
|
||||
}
|
||||
|
||||
unitMap := map[string]time.Duration{
|
||||
"d": 24 * time.Hour,
|
||||
"w": 7 * 24 * time.Hour,
|
||||
"wk": 7 * 24 * time.Hour,
|
||||
"mo": 30 * 24 * time.Hour,
|
||||
"M": 30 * 24 * time.Hour,
|
||||
"y": 365 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
matches := reDuration.FindAllStringSubmatch(s, -1)
|
||||
|
||||
if len(matches) == 0 {
|
||||
return 0, fmt.Errorf("invalid duration format: '%s'", orig)
|
||||
}
|
||||
|
||||
var total time.Duration
|
||||
|
||||
for _, m := range matches {
|
||||
value, unit := m[1], m[2]
|
||||
|
||||
val, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number: %s", value)
|
||||
}
|
||||
|
||||
if multiplier, ok := unitMap[unit]; ok {
|
||||
total += time.Duration(val * float64(multiplier))
|
||||
} else {
|
||||
dur, err := time.ParseDuration(value + unit)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid duration unit: %s", value+unit)
|
||||
}
|
||||
total += dur
|
||||
}
|
||||
}
|
||||
|
||||
if neg {
|
||||
total = -total
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
type Duration time.Duration
|
||||
|
||||
func (d Duration) Duration() time.Duration {
|
||||
|
@ -26,7 +86,7 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if v, err := time.ParseDuration(s); err == nil {
|
||||
if v, err := ParseDuration(s); err == nil {
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ package confopt
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -15,6 +16,80 @@ import (
|
|||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func TestParseDuration(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
input string
|
||||
wantDuration time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
"nanoseconds": {input: "10ns", wantDuration: 10 * time.Nanosecond},
|
||||
"microseconds": {input: "10us", wantDuration: 10 * time.Microsecond},
|
||||
"milliseconds": {input: "10ms", wantDuration: 10 * time.Millisecond},
|
||||
"seconds": {input: "10s", wantDuration: 10 * time.Second},
|
||||
"minutes": {input: "10m", wantDuration: 10 * time.Minute},
|
||||
"hours": {input: "10h", wantDuration: 10 * time.Hour},
|
||||
"days": {input: "10d", wantDuration: 10 * 24 * time.Hour},
|
||||
"weeks (w)": {input: "10w", wantDuration: 10 * 7 * 24 * time.Hour},
|
||||
"weeks (wk)": {input: "10wk", wantDuration: 10 * 7 * 24 * time.Hour},
|
||||
"months (mo)": {input: "10mo", wantDuration: 10 * 30 * 24 * time.Hour},
|
||||
"months (M)": {input: "10M", wantDuration: 10 * 30 * 24 * time.Hour},
|
||||
"years": {input: "10y", wantDuration: 10 * 365 * 24 * time.Hour},
|
||||
"negative units": {input: "-10d", wantDuration: -10 * 24 * time.Hour},
|
||||
"mixed units": {
|
||||
input: "1y2M3w4d5h6m7s8ms9us10ns",
|
||||
wantDuration: (1 * 365 * 24 * time.Hour) +
|
||||
(2 * 30 * 24 * time.Hour) +
|
||||
(3 * 7 * 24 * time.Hour) +
|
||||
(4 * 24 * time.Hour) +
|
||||
(5 * time.Hour) +
|
||||
(6 * time.Minute) +
|
||||
(7 * time.Second) +
|
||||
(8 * time.Millisecond) +
|
||||
(9 * time.Microsecond) +
|
||||
(10 * time.Nanosecond),
|
||||
},
|
||||
"mixed units with spaces": {
|
||||
input: "1y 2M 3w 4d 5h 6m 7s 8ms 9us 10ns",
|
||||
wantDuration: (1 * 365 * 24 * time.Hour) +
|
||||
(2 * 30 * 24 * time.Hour) +
|
||||
(3 * 7 * 24 * time.Hour) +
|
||||
(4 * 24 * time.Hour) +
|
||||
(5 * time.Hour) +
|
||||
(6 * time.Minute) +
|
||||
(7 * time.Second) +
|
||||
(8 * time.Millisecond) +
|
||||
(9 * time.Microsecond) +
|
||||
(10 * time.Nanosecond),
|
||||
},
|
||||
"mixed units with decimals": {
|
||||
input: "1.5y2.25M3.75w4.5d5.5h6.5m7.5s8.5ms9.5us10.5ns",
|
||||
wantDuration: time.Duration(math.Floor(1.5*365*24*float64(time.Hour))) +
|
||||
time.Duration(math.Floor(2.25*30*24*float64(time.Hour))) +
|
||||
time.Duration(math.Floor(3.75*7*24*float64(time.Hour))) +
|
||||
time.Duration(math.Floor(4.5*24*float64(time.Hour))) +
|
||||
time.Duration(math.Floor(5.5*float64(time.Hour))) +
|
||||
time.Duration(math.Floor(6.5*float64(time.Minute))) +
|
||||
time.Duration(math.Floor(7.5*float64(time.Second))) +
|
||||
time.Duration(math.Floor(8.5*float64(time.Millisecond))) +
|
||||
time.Duration(math.Floor(9.5*float64(time.Microsecond))) +
|
||||
time.Duration(math.Floor(10.5*float64(time.Nanosecond))),
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dur, err := ParseDuration(test.input)
|
||||
|
||||
if test.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.wantDuration, dur)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDuration_MarshalYAML(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
d Duration
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue