diff --git a/src/go/cmd/godplugin/config.go b/src/go/cmd/godplugin/config.go
index 800d079f76..8d23cc4251 100644
--- a/src/go/cmd/godplugin/config.go
+++ b/src/go/cmd/godplugin/config.go
@@ -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")
diff --git a/src/go/cmd/godplugin/main.go b/src/go/cmd/godplugin/main.go
index 191a7ce0fe..ab0ca87a86 100644
--- a/src/go/cmd/godplugin/main.go
+++ b/src/go/cmd/godplugin/main.go
@@ -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,
 	})
diff --git a/src/go/go.mod b/src/go/go.mod
index 8a66914797..f426c4388c 100644
--- a/src/go/go.mod
+++ b/src/go/go.mod
@@ -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
diff --git a/src/go/go.sum b/src/go/go.sum
index 3d9599ac2d..1883b9195c 100644
--- a/src/go/go.sum
+++ b/src/go/go.sum
@@ -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=
diff --git a/src/go/plugin/go.d/agent/agent.go b/src/go/plugin/go.d/agent/agent.go
index 967edb6be3..79d0b1577d 100644
--- a/src/go/plugin/go.d/agent/agent.go
+++ b/src/go/plugin/go.d/agent/agent.go
@@ -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
diff --git a/src/go/plugin/go.d/agent/discovery/manager.go b/src/go/plugin/go.d/agent/discovery/manager.go
index 6466160236..b9b928f3fb 100644
--- a/src/go/plugin/go.d/agent/discovery/manager.go
+++ b/src/go/plugin/go.d/agent/discovery/manager.go
@@ -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 {
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go
index 2cf0575b57..58c0812382 100644
--- a/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go
@@ -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
 
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go
index 9d57d3cc70..c950fdb28e 100644
--- a/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go
@@ -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
 
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go
new file mode 100644
index 0000000000..7e5c009467
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go
@@ -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
+	}
+}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go
new file mode 100644
index 0000000000..6853675f4b
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go
@@ -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
+	}
+}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go
new file mode 100644
index 0000000000..14c42ed863
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go
@@ -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",
+	})
+}
diff --git a/src/go/plugin/go.d/collector/snmp/entnum/enterprise-numbers.txt b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/enterprise-numbers.txt
similarity index 100%
rename from src/go/plugin/go.d/collector/snmp/entnum/enterprise-numbers.txt
rename to src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/enterprise-numbers.txt
diff --git a/src/go/plugin/go.d/collector/snmp/entnum/lookup.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go
similarity index 99%
rename from src/go/plugin/go.d/collector/snmp/entnum/lookup.go
rename to src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go
index bcbfa9d7fa..336f98d413 100644
--- a/src/go/plugin/go.d/collector/snmp/entnum/lookup.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go
@@ -1,6 +1,6 @@
 // SPDX-License-Identifier: GPL-3.0-or-later
 
-package entnum
+package snmpsd
 
 import (
 	"bufio"
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go
new file mode 100644
index 0000000000..5a4ec34b2e
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go
@@ -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()
+}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go
new file mode 100644
index 0000000000..bda4a413d2
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go
@@ -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)
+}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go
new file mode 100644
index 0000000000..0917e72991
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go
@@ -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), "�"), 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)
+	}
+}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go
new file mode 100644
index 0000000000..b095b2244f
--- /dev/null
+++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go
@@ -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 }
diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go
index 60c9014929..ab59fb8544 100644
--- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go
@@ -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
diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go
index 9df7ec59d4..9265a4d793 100644
--- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go
@@ -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)
 		}
diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go
index 4d391d41ef..e7cde64028 100644
--- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go
+++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go
@@ -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 {
diff --git a/src/go/plugin/go.d/agent/filepersister/persister.go b/src/go/plugin/go.d/agent/filepersister/persister.go
index 2f49cdd1cd..251a3b3a1e 100644
--- a/src/go/plugin/go.d/agent/filepersister/persister.go
+++ b/src/go/plugin/go.d/agent/filepersister/persister.go
@@ -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
diff --git a/src/go/plugin/go.d/agent/jobmgr/filestatus.go b/src/go/plugin/go.d/agent/jobmgr/filestatus.go
index d560852cd0..4b79f1d4c8 100644
--- a/src/go/plugin/go.d/agent/jobmgr/filestatus.go
+++ b/src/go/plugin/go.d/agent/jobmgr/filestatus.go
@@ -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)
 }
 
diff --git a/src/go/plugin/go.d/agent/jobmgr/manager.go b/src/go/plugin/go.d/agent/jobmgr/manager.go
index b36cd9f5de..beb8e6b63f 100644
--- a/src/go/plugin/go.d/agent/jobmgr/manager.go
+++ b/src/go/plugin/go.d/agent/jobmgr/manager.go
@@ -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
 
diff --git a/src/go/plugin/go.d/collector/snmp/charts.go b/src/go/plugin/go.d/collector/snmp/charts.go
index 159372fbb6..624489e1c8 100644
--- a/src/go/plugin/go.d/collector/snmp/charts.go
+++ b/src/go/plugin/go.d/collector/snmp/charts.go
@@ -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)
diff --git a/src/go/plugin/go.d/collector/snmp/collect.go b/src/go/plugin/go.d/collector/snmp/collect.go
index 0d2e75419e..944061817c 100644
--- a/src/go/plugin/go.d/collector/snmp/collect.go
+++ b/src/go/plugin/go.d/collector/snmp/collect.go
@@ -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,
diff --git a/src/go/plugin/go.d/collector/snmp/collect_sys_info.go b/src/go/plugin/go.d/collector/snmp/collect_sys_info.go
index ff0e899cec..36b66f2787 100644
--- a/src/go/plugin/go.d/collector/snmp/collect_sys_info.go
+++ b/src/go/plugin/go.d/collector/snmp/collect_sys_info.go
@@ -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
-}
diff --git a/src/go/plugin/go.d/collector/snmp/collector.go b/src/go/plugin/go.d/collector/snmp/collector.go
index 7c32438215..75c778e54a 100644
--- a/src/go/plugin/go.d/collector/snmp/collector.go
+++ b/src/go/plugin/go.d/collector/snmp/collector.go
@@ -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
 }
diff --git a/src/go/plugin/go.d/collector/snmp/collector_test.go b/src/go/plugin/go.d/collector/snmp/collector_test.go
index 418f50dce1..81c8f231a5 100644
--- a/src/go/plugin/go.d/collector/snmp/collector_test.go
+++ b/src/go/plugin/go.d/collector/snmp/collector_test.go
@@ -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},
 		},
diff --git a/src/go/plugin/go.d/config/go.d/sd/snmp.conf b/src/go/plugin/go.d/config/go.d/sd/snmp.conf
new file mode 100644
index 0000000000..493be558e5
--- /dev/null
+++ b/src/go/plugin/go.d/config/go.d/sd/snmp.conf
@@ -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 }}
diff --git a/src/go/plugin/go.d/pkg/confopt/duration.go b/src/go/plugin/go.d/pkg/confopt/duration.go
index 7aebe062a2..dd2afa0584 100644
--- a/src/go/plugin/go.d/pkg/confopt/duration.go
+++ b/src/go/plugin/go.d/pkg/confopt/duration.go
@@ -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
 	}
diff --git a/src/go/plugin/go.d/pkg/confopt/duration_test.go b/src/go/plugin/go.d/pkg/confopt/duration_test.go
index fe907bf530..eb9cea82d0 100644
--- a/src/go/plugin/go.d/pkg/confopt/duration_test.go
+++ b/src/go/plugin/go.d/pkg/confopt/duration_test.go
@@ -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