inlet/snmp: add proper support for SNMPv3

This commit is contained in:
Vincent Bernat
2022-08-14 01:38:53 +02:00
parent 0a4275d87d
commit 574ec7e79e
9 changed files with 343 additions and 162 deletions

View File

@@ -25,7 +25,7 @@ type SubnetMap[V any] struct {
// Lookup will search for the most specific subnet matching the
// provided IP address and return the value associated with it.
func (sm *SubnetMap[V]) Lookup(ip net.IP) (V, bool) {
if sm.tree == nil {
if sm == nil || sm.tree == nil {
var value V
return value, false
}

View File

@@ -44,7 +44,7 @@ func (v Version) String() string {
return sarama.KafkaVersion(v).String()
}
// MarshalText turns a Kafka version intro a string
// MarshalText turns a Kafka version into a string
func (v Version) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
}

View File

@@ -276,10 +276,20 @@ continuously the exporters. The following keys are accepted:
about to expire or need an update
- `cache-persist-file` tells where to store cached data on shutdown and
read them back on startup
- `communities` is a map from a subnets to the community to use for
exporters in the provided subnet. Use `::/0` to set the default
- `communities` is a map from a subnets to the SNMPv2 community to use
for exporters in the provided subnet. Use `::/0` to set the default
value. Alternatively, it also accepts a string to use for all
exporters.
- `security-parameters` is a map from subnets to the SNMPv3 USM
security parameters. Like for `communities`, `::/0` can be used to
the set the default value. The security paramaters accepts the
following keys: `user-name`, `authentication-protocol` (can be
omitted, otherwise `MD5`, `SHA`, `SHA224`, `SHA256`, `SHA384`, and
`SHA512` are accepted), `authentication-passphrase` (if the previous
value was set), `privacy-protocol` (can be omitted, otherwise `DES`,
`AES`, `AES192`, `AES256`, `AES192C`, and `AES256C` are accepted,
the later being Cisco-variant), `privacy-passphrase` (if the
previous value was set), and `context-name`.
- `poller-retries` is the number of retries on unsuccessful SNMP requests.
- `poller-timeout` tells how much time should the poller wait for an answer.
- `workers` tell how many workers to spawn to handle SNMP polling.
@@ -288,6 +298,10 @@ As flows missing interface information are discarded, persisting the
cache is useful to quickly be able to handle incoming flows. By
default, no persistent cache is configured.
*Akvorado* will use SNMPv3 if there is a match for the
`security-parameters` configuration option. Otherwise, it will use
SNMPv2.
### HTTP
The builtin HTTP server serves various pages. Its configuration

View File

@@ -13,6 +13,7 @@ identified with a specific icon:
## Unreleased
-*inlet*: add support for SNMPv3 protocol
- 🌱 *inlet*: `inlet.snmp.default-community` is now deprecated
## 1.5.5 - 2022-08-09

View File

@@ -6,6 +6,7 @@ package snmp
import (
"errors"
"reflect"
"strings"
"time"
"github.com/gosnmp/gosnmp"
@@ -35,8 +36,18 @@ type Configuration struct {
// Communities is a mapping from exporter IPs to SNMPv2 communities
Communities *helpers.SubnetMap[string]
// Versions is a mapping from exporter IPs to SNMP versions
Versions *helpers.SubnetMap[Version]
// SecurityParameters is a mapping from exporter IPs to SNMPv3 security parameters
SecurityParameters *helpers.SubnetMap[SecurityParameters]
}
// SecurityParameters describes SNMPv3 USM security parameters.
type SecurityParameters struct {
UserName string `validate:"required"`
AuthenticationProtocol AuthProtocol `validate:"required_with=PrivProtocol"`
AuthenticationPassphrase string `validate:"required_with=AuthenticationProtocol"`
PrivacyProtocol PrivProtocol
PrivacyPassphrase string `validate:"required_with=PrivacyProtocol"`
ContextName string
}
// DefaultConfiguration represents the default configuration for the SNMP client.
@@ -54,9 +65,7 @@ func DefaultConfiguration() Configuration {
Communities: helpers.MustNewSubnetMap(map[string]string{
"::/0": "public",
}),
Versions: helpers.MustNewSubnetMap(map[string]Version{
"::/0": Version(gosnmp.Version2c),
}),
SecurityParameters: helpers.MustNewSubnetMap(map[string]SecurityParameters{}),
}
}
@@ -106,36 +115,80 @@ func ConfigurationUnmarshallerHook() mapstructure.DecodeHookFunc {
}
}
// Version represents an SNMP version
type Version gosnmp.SnmpVersion
// AuthProtocol represents a SNMPv3 authentication protocol
type AuthProtocol gosnmp.SnmpV3AuthProtocol
// UnmarshalText parses a SNMP version
func (v *Version) UnmarshalText(text []byte) error {
switch string(text) {
case "1":
*v = Version(gosnmp.Version1)
case "2", "2c":
*v = Version(gosnmp.Version2c)
case "3":
*v = Version(gosnmp.Version3)
// UnmarshalText parses a SNMPv3 authentication protocol
func (ap *AuthProtocol) UnmarshalText(text []byte) error {
switch strings.ToUpper(string(text)) {
case "":
*ap = AuthProtocol(gosnmp.NoAuth)
case "MD5":
*ap = AuthProtocol(gosnmp.MD5)
case "SHA":
*ap = AuthProtocol(gosnmp.SHA)
case "SHA224":
*ap = AuthProtocol(gosnmp.SHA224)
case "SHA256":
*ap = AuthProtocol(gosnmp.SHA256)
case "SHA384":
*ap = AuthProtocol(gosnmp.SHA384)
case "SHA512":
*ap = AuthProtocol(gosnmp.SHA512)
default:
return errors.New("unsupported SNMP version")
return errors.New("unknown auth protocol")
}
return nil
}
// String turns a SNMP version into a string
func (v Version) String() string {
return gosnmp.SnmpVersion(v).String()
// String turns a SNMPv3 authentication protocol to a string
func (ap AuthProtocol) String() string {
return gosnmp.SnmpV3AuthProtocol(ap).String()
}
// MarshalText turns a Kafka version intro a string
func (v Version) MarshalText() ([]byte, error) {
return []byte(v.String()), nil
// MarshalText turns a SNMPv3 authentication protocol to a string
func (ap AuthProtocol) MarshalText() ([]byte, error) {
return []byte(ap.String()), nil
}
// PrivProtocol represents a SNMPv3 privacy protocol
type PrivProtocol gosnmp.SnmpV3PrivProtocol
// UnmarshalText parses a SNMPv3 privacy protocol
func (ap *PrivProtocol) UnmarshalText(text []byte) error {
switch strings.ToUpper(string(text)) {
case "":
*ap = PrivProtocol(gosnmp.NoPriv)
case "DES":
*ap = PrivProtocol(gosnmp.DES)
case "AES":
*ap = PrivProtocol(gosnmp.AES)
case "AES192":
*ap = PrivProtocol(gosnmp.AES192)
case "AES256":
*ap = PrivProtocol(gosnmp.AES256)
case "AES192C":
*ap = PrivProtocol(gosnmp.AES192C)
case "AES256C":
*ap = PrivProtocol(gosnmp.AES256C)
default:
return errors.New("unknown priv protocol")
}
return nil
}
// String turns a SNMPv3 privacy protocol to a string
func (ap PrivProtocol) String() string {
return gosnmp.SnmpV3PrivProtocol(ap).String()
}
// MarshalText turns a SNMPv3 privacy protocol to a string
func (ap PrivProtocol) MarshalText() ([]byte, error) {
return []byte(ap.String()), nil
}
func init() {
helpers.AddMapstructureUnmarshallerHook(ConfigurationUnmarshallerHook())
helpers.AddMapstructureUnmarshallerHook(helpers.SubnetMapUnmarshallerHook[string]())
helpers.AddMapstructureUnmarshallerHook(helpers.SubnetMapUnmarshallerHook[Version]())
helpers.AddMapstructureUnmarshallerHook(helpers.SubnetMapUnmarshallerHook[SecurityParameters]())
}

View File

@@ -108,16 +108,28 @@ func TestConfigurationUnmarshallerHook(t *testing.T) {
}),
},
}, {
Description: "SNMP version",
Description: "SNMP security parameters",
Input: gin.H{
"versions": "2c",
"security-parameters": gin.H{
"user-name": "alfred",
"authentication-protocol": "sha",
"authentication-passphrase": "hello",
"privacy-protocol": "aes",
"privacy-passphrase": "bye",
},
},
Output: Configuration{
Communities: helpers.MustNewSubnetMap(map[string]string{
"::/0": "public",
}),
Versions: helpers.MustNewSubnetMap(map[string]Version{
"::/0": Version(gosnmp.Version2c),
SecurityParameters: helpers.MustNewSubnetMap(map[string]SecurityParameters{
"::/0": SecurityParameters{
UserName: "alfred",
AuthenticationProtocol: AuthProtocol(gosnmp.SHA),
AuthenticationPassphrase: "hello",
PrivacyProtocol: PrivProtocol(gosnmp.AES),
PrivacyPassphrase: "bye",
},
}),
},
},

View File

@@ -46,7 +46,7 @@ type pollerConfig struct {
Retries int
Timeout time.Duration
Communities *helpers.SubnetMap[string]
Versions *helpers.SubnetMap[Version]
SecurityParameters *helpers.SubnetMap[SecurityParameters]
}
// newPoller creates a new SNMP poller.
@@ -125,9 +125,6 @@ func (p *realPoller) Poll(ctx context.Context, exporter string, port uint16, ifI
Context: ctx,
Target: exporter,
Port: port,
Community: p.config.Communities.LookupOrDefault(exporterIP, "public"),
Version: gosnmp.SnmpVersion(
p.config.Versions.LookupOrDefault(exporterIP, Version(gosnmp.Version2c))),
Retries: p.config.Retries,
Timeout: p.config.Timeout,
UseUnconnectedUDPSocket: true,
@@ -136,6 +133,38 @@ func (p *realPoller) Poll(ctx context.Context, exporter string, port uint16, ifI
p.metrics.retries.WithLabelValues(exporter).Inc()
},
}
if securityParameters, ok := p.config.SecurityParameters.Lookup(exporterIP); ok {
g.Version = gosnmp.Version3
g.SecurityModel = gosnmp.UserSecurityModel
usmSecurityParameters := gosnmp.UsmSecurityParameters{
UserName: securityParameters.UserName,
AuthenticationProtocol: gosnmp.SnmpV3AuthProtocol(securityParameters.AuthenticationProtocol),
AuthenticationPassphrase: securityParameters.AuthenticationPassphrase,
PrivacyProtocol: gosnmp.SnmpV3PrivProtocol(securityParameters.PrivacyProtocol),
PrivacyPassphrase: securityParameters.PrivacyPassphrase,
}
g.SecurityParameters = &usmSecurityParameters
if usmSecurityParameters.AuthenticationProtocol == gosnmp.NoAuth {
if usmSecurityParameters.PrivacyProtocol == gosnmp.NoPriv {
g.MsgFlags = gosnmp.NoAuthNoPriv
} else {
// Not possible
g.MsgFlags = gosnmp.NoAuthNoPriv
}
} else {
if usmSecurityParameters.PrivacyProtocol == gosnmp.NoPriv {
g.MsgFlags = gosnmp.AuthNoPriv
} else {
g.MsgFlags = gosnmp.AuthPriv
}
}
g.ContextName = securityParameters.ContextName
fmt.Printf("WWWWAA %+v\n", g)
} else {
g.Version = gosnmp.Version2c
g.Community = p.config.Communities.LookupOrDefault(exporterIP, "public")
}
if err := g.Connect(); err != nil {
p.metrics.failures.WithLabelValues(exporter, "connect").Inc()
p.errLogger.Err(err).Str("exporter", exporter).Msg("unable to connect")

View File

@@ -20,15 +20,69 @@ import (
)
func TestPoller(t *testing.T) {
cases := []struct {
Description string
Skip string
Config pollerConfig
}{
{
Description: "SNMPv2",
Config: pollerConfig{
Retries: 2,
Timeout: 100 * time.Millisecond,
Communities: helpers.MustNewSubnetMap(map[string]string{
"::/0": "private",
}),
},
}, {
Description: "SNMPv3",
Config: pollerConfig{
Retries: 2,
Timeout: 100 * time.Millisecond,
Communities: helpers.MustNewSubnetMap(map[string]string{
"::/0": "public",
}),
SecurityParameters: helpers.MustNewSubnetMap(map[string]SecurityParameters{
"::/0": {
UserName: "alfred",
AuthenticationProtocol: AuthProtocol(gosnmp.MD5),
AuthenticationPassphrase: "hello",
PrivacyProtocol: PrivProtocol(gosnmp.AES),
PrivacyPassphrase: "bye",
ContextName: "private",
},
}),
},
}, {
Description: "SNMPv3 no priv",
Skip: "GoSNMPServer is broken with this configuration",
Config: pollerConfig{
Retries: 2,
Timeout: 100 * time.Millisecond,
Communities: helpers.MustNewSubnetMap(map[string]string{
"::/0": "public",
}),
SecurityParameters: helpers.MustNewSubnetMap(map[string]SecurityParameters{
"::/0": {
UserName: "alfred-nopriv",
AuthenticationProtocol: AuthProtocol(gosnmp.MD5),
AuthenticationPassphrase: "hello",
PrivacyProtocol: PrivProtocol(gosnmp.NoPriv),
ContextName: "private",
},
}),
},
},
}
for _, tc := range cases {
t.Run(tc.Description, func(t *testing.T) {
if tc.Skip != "" {
t.Skip(tc.Skip)
}
got := []string{}
r := reporter.NewMock(t)
clock := clock.NewMock()
config := pollerConfig{
Retries: 2,
Timeout: 100 * time.Millisecond,
Versions: DefaultConfiguration().Versions,
Communities: DefaultConfiguration().Communities,
}
config := tc.Config
p := newPoller(r, config, clock, func(exporterIP, exporterName string, ifIndex uint, iface Interface) {
got = append(got, fmt.Sprintf("%s %s %d %s %s %d", exporterIP, exporterName,
ifIndex, iface.Name, iface.Description, iface.Speed))
@@ -36,9 +90,26 @@ func TestPoller(t *testing.T) {
// Start a new SNMP server
master := GoSNMPServer.MasterAgent{
SecurityConfig: GoSNMPServer.SecurityConfig{
AuthoritativeEngineBoots: 10,
Users: []gosnmp.UsmSecurityParameters{
{
UserName: "alfred",
AuthenticationProtocol: gosnmp.MD5,
AuthenticationPassphrase: "hello",
PrivacyProtocol: gosnmp.AES,
PrivacyPassphrase: "bye",
}, {
UserName: "alfred-nopriv",
AuthenticationProtocol: gosnmp.MD5,
AuthenticationPassphrase: "hello",
PrivacyProtocol: gosnmp.NoPriv,
},
},
},
SubAgents: []*GoSNMPServer.SubAgent{
{
CommunityIDs: []string{"public"},
CommunityIDs: []string{"private"},
OIDs: []*GoSNMPServer.PDUValueControlItem{
{
OID: "1.3.6.1.2.1.1.5.0",
@@ -142,5 +213,6 @@ func TestPoller(t *testing.T) {
if diff := helpers.Diff(gotMetrics, expectedMetrics); diff != "" {
t.Fatalf("Metrics (-got, +want):\n%s", diff)
}
})
}
}

View File

@@ -80,8 +80,8 @@ func New(r *reporter.Reporter, configuration Configuration, dependencies Depende
poller: newPoller(r, pollerConfig{
Retries: configuration.PollerRetries,
Timeout: configuration.PollerTimeout,
Versions: configuration.Versions,
Communities: configuration.Communities,
SecurityParameters: configuration.SecurityParameters,
}, dependencies.Clock, sc.Put),
}
c.d.Daemon.Track(&c.t, "inlet/snmp")