Skip to content

Commit

Permalink
SNMP Traps: Add support for v1 and v3 (#10780)
Browse files Browse the repository at this point in the history
* SNMP Traps: Support v1 and v3

* Test multiple hostnames
  • Loading branch information
FlorianVeaux authored Feb 10, 2022
1 parent f05e1c6 commit 363ca0b
Show file tree
Hide file tree
Showing 13 changed files with 535 additions and 99 deletions.
2 changes: 1 addition & 1 deletion cmd/agent/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ func StartAgent() error {
// Start SNMP trap server
if traps.IsEnabled() {
if config.Datadog.GetBool("logs_enabled") {
err = traps.StartServer()
err = traps.StartServer(hostname)
if err != nil {
log.Errorf("Failed to start snmp-traps server: %s", err)
}
Expand Down
26 changes: 24 additions & 2 deletions pkg/config/config_template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2815,7 +2815,7 @@ api_key:
## @param snmp_traps_config - custom object - optional
## This section configures SNMP traps collection. Traps are forwarded as logs to Datadog.
## NOTE: This feature is currently **EXPERIMENTAL**. Both behavior and configuration options may
## change in the future. Only SNMPv2 is supported.
## change in the future.
#
# snmp_traps_config:

Expand All @@ -2825,7 +2825,7 @@ api_key:
# port: 162

## @param community_strings - list of strings - required
## A list of known SNMPv2 community strings that devices can use to send traps to the Agent.
## A list of known SNMP community strings that devices can use to send traps to the Agent.
## Traps with an unknown community string are ignored.
## Enclose the community string with single quote like below (to avoid special characters being interpreted).
## Must be non-empty.
Expand All @@ -2834,6 +2834,28 @@ api_key:
# - '<COMMUNITY_1>'
# - '<COMMUNITY_2>'

## @param users - list of custom objects - optional
## List of SNMPv3 users that can be used to listen for traps.
## NOTE: Currently the Datadog Agent only supports having a
## single user in this list.
## Each user can contain:
## * username - string - The username used by devices when sending Traps to the Agent.
## * authKey - string - (Optional) The passphrase to use with the given user and authProtocol
## * authProtocol - string - (Optional) The authentication protocol to use when listening for traps from this user.
## Available options are: MD5, SHA, SHA224, SHA256, SHA384, SHA512.
## Defaults to MD5 when authKey is set.
## * privKey - string - (Optional) The passphrase to use with the given user privacy protocol.
## * privProtocol - string - (Optional) The privacy protocol to use when listening for traps from this user.
## Available options are: DES, AES (128 bits), AES192, AES192C, AES256, AES256C.
## Defaults to DES when privKey is set.
#
# users:
# - username: <USERNAME>
# authKey: <AUTHENTICATION_KEY>
# authProtocol: <AUTHENTICATION_PROTOCOL>
# privKey: <PRIVACY_KEY>
# privProtocol: <PRIVACY_PROTOCOL>

## @param bind_host - string - optional
## The hostname to listen on for incoming trap packets.
## Defaults to the global `bind_host` config option value.
Expand Down
2 changes: 1 addition & 1 deletion pkg/logs/internal/tailers/traps/tailer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func TestTrapsShouldReceiveMessages(t *testing.T) {
Content: &gosnmp.SnmpPacket{
Version: gosnmp.Version2c,
Community: "public",
Variables: traps.NetSNMPExampleHeartbeatNotificationVariables,
Variables: traps.NetSNMPExampleHeartbeatNotification.Variables,
},
Addr: &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 1620},
}
Expand Down
115 changes: 100 additions & 15 deletions pkg/snmp/traps/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ package traps
import (
"errors"
"fmt"
"hash/fnv"
"strings"

"github.com/DataDog/datadog-agent/pkg/config"
"github.com/gosnmp/gosnmp"
Expand All @@ -18,26 +20,38 @@ func IsEnabled() bool {
return config.Datadog.GetBool("snmp_traps_enabled")
}

// UserV3 contains the definition of one SNMPv3 user with its username and its auth
// parameters.
type UserV3 struct {
Username string `mapstructure:"user" yaml:"user"`
AuthKey string `mapstructure:"authKey" yaml:"authKey"`
AuthProtocol string `mapstructure:"authProtocol" yaml:"authProtocol"`
PrivKey string `mapstructure:"privKey" yaml:"privKey"`
PrivProtocol string `mapstructure:"privProtocol" yaml:"privProtocol"`
}

// Config contains configuration for SNMP trap listeners.
// YAML field tags provided for test marshalling purposes.
type Config struct {
Port uint16 `mapstructure:"port" yaml:"port"`
CommunityStrings []string `mapstructure:"community_strings" yaml:"community_strings"`
BindHost string `mapstructure:"bind_host" yaml:"bind_host"`
StopTimeout int `mapstructure:"stop_timeout" yaml:"stop_timeout"`
Port uint16 `mapstructure:"port" yaml:"port"`
Users []UserV3 `mapstructure:"users" yaml:"users"`
CommunityStrings []string `mapstructure:"community_strings" yaml:"community_strings"`
BindHost string `mapstructure:"bind_host" yaml:"bind_host"`
StopTimeout int `mapstructure:"stop_timeout" yaml:"stop_timeout"`
authoritativeEngineID string `mapstructure:"-" yaml:"-"`
}

// ReadConfig builds and returns configuration from Agent configuration.
func ReadConfig() (*Config, error) {
func ReadConfig(agentHostname string) (*Config, error) {
var c Config
err := config.Datadog.UnmarshalKey("snmp_traps_config", &c)
if err != nil {
return nil, err
}

// Validate required fields.
if c.CommunityStrings == nil || len(c.CommunityStrings) == 0 {
return nil, errors.New("`community_strings` is required and must be non-empty")
// gosnmp only supports one v3 user at the moment.
if len(c.Users) > 1 {
return nil, errors.New("only one user is currently supported in snmp_traps_config")
}

// Set defaults.
Expand All @@ -52,6 +66,19 @@ func ReadConfig() (*Config, error) {
c.StopTimeout = defaultStopTimeout
}

if agentHostname == "" {
// Make sure to have at least some unique bytes for the authoritative engineID.
// Unlikely to happen since the agent cannot start without a hostname
agentHostname = "unknown-datadog-agent"
}
h := fnv.New128()
h.Write([]byte(agentHostname))
// First byte is always 0x80
// Next four bytes are the Private Enterprise Number (set to an invalid value here)
// The next 16 bytes are the hash of the agent hostname
engineID := h.Sum([]byte{0x80, 0xff, 0xff, 0xff, 0xff})
c.authoritativeEngineID = string(engineID)

return &c, nil
}

Expand All @@ -60,12 +87,70 @@ func (c *Config) Addr() string {
return fmt.Sprintf("%s:%d", c.BindHost, c.Port)
}

// BuildV2Params returns a valid GoSNMP SNMPv2 params structure from configuration.
func (c *Config) BuildV2Params() *gosnmp.GoSNMP {
return &gosnmp.GoSNMP{
Port: c.Port,
Transport: "udp",
Version: gosnmp.Version2c,
Logger: gosnmp.NewLogger(&trapLogger{}),
// BuildSNMPParams returns a valid GoSNMP params structure from configuration.
func (c *Config) BuildSNMPParams() (*gosnmp.GoSNMP, error) {
if len(c.Users) == 0 {
return &gosnmp.GoSNMP{
Port: c.Port,
Transport: "udp",
Version: gosnmp.Version2c, // No user configured, let's use Version2 which is enough and doesn't require setting up fake security data.
Logger: gosnmp.NewLogger(&trapLogger{}),
}, nil
}
user := c.Users[0]
var authProtocol gosnmp.SnmpV3AuthProtocol
switch lowerAuthProtocol := strings.ToLower(user.AuthProtocol); lowerAuthProtocol {
case "":
authProtocol = gosnmp.NoAuth
case "md5":
authProtocol = gosnmp.MD5
case "sha":
authProtocol = gosnmp.SHA
default:
return nil, fmt.Errorf("unsupported authentication protocol: %s", user.AuthProtocol)
}

var privProtocol gosnmp.SnmpV3PrivProtocol
switch lowerPrivProtocol := strings.ToLower(user.PrivProtocol); lowerPrivProtocol {
case "":
privProtocol = gosnmp.NoPriv
case "des":
privProtocol = gosnmp.DES
case "aes":
privProtocol = gosnmp.AES
case "aes192":
privProtocol = gosnmp.AES192
case "aes192c":
privProtocol = gosnmp.AES192C
case "aes256":
privProtocol = gosnmp.AES256
case "aes256c":
privProtocol = gosnmp.AES256C
default:
return nil, fmt.Errorf("unsupported privacy protocol: %s", user.PrivProtocol)
}

msgFlags := gosnmp.NoAuthNoPriv
if user.PrivKey != "" {
msgFlags = gosnmp.AuthPriv
} else if user.AuthKey != "" {
msgFlags = gosnmp.AuthNoPriv
}

return &gosnmp.GoSNMP{
Port: c.Port,
Transport: "udp",
Version: gosnmp.Version3, // Always using version3 for traps, only option that works with all SNMP versions simultaneously
SecurityModel: gosnmp.UserSecurityModel,
MsgFlags: msgFlags,
SecurityParameters: &gosnmp.UsmSecurityParameters{
UserName: user.Username,
AuthoritativeEngineID: c.authoritativeEngineID,
AuthenticationProtocol: authProtocol,
AuthenticationPassphrase: user.AuthKey,
PrivacyProtocol: privProtocol,
PrivacyPassphrase: user.PrivKey,
},
Logger: gosnmp.NewLogger(&trapLogger{}),
}, nil
}
111 changes: 74 additions & 37 deletions pkg/snmp/traps/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,105 @@
package traps

import (
"testing"

"github.com/gosnmp/gosnmp"
"github.com/stretchr/testify/assert"
"testing"
)

func TestConfig(t *testing.T) {
const mockedHostname = "VeryLongHostnameThatDoesNotFitIntoTheByteArray"

var expectedEngineID = "\x80\xff\xff\xff\xff\x67\xb2\x0f\xe4\xdf\x73\x7a\xce\x28\x47\x03\x8f\x57\xe6\x5c\x98"

var expectedEngineIDs = map[string]string{
"VeryLongHostnameThatDoesNotFitIntoTheByteArray": "\x80\xff\xff\xff\xff\x67\xb2\x0f\xe4\xdf\x73\x7a\xce\x28\x47\x03\x8f\x57\xe6\x5c\x98",
"VeryLongHostnameThatIsDifferent": "\x80\xff\xff\xff\xff\xe7\x21\xcc\xd7\x0b\xe1\x60\xc5\x18\xd7\xde\x17\x86\xb0\x7d\x36",
}

func TestFullConfig(t *testing.T) {
Configure(t, Config{
Port: 1234,
Port: 1234,
Users: []UserV3{
{
Username: "user",
AuthKey: "password",
AuthProtocol: "MD5",
PrivKey: "password",
PrivProtocol: "AES",
},
},
BindHost: "127.0.0.1",
CommunityStrings: []string{"public"},
StopTimeout: 12,
})
config, err := ReadConfig()
config, err := ReadConfig(mockedHostname)
assert.NoError(t, err)
assert.Equal(t, uint16(1234), config.Port)
assert.Equal(t, defaultStopTimeout, config.StopTimeout)
assert.Equal(t, 12, config.StopTimeout)
assert.Equal(t, []string{"public"}, config.CommunityStrings)
assert.Equal(t, "127.0.0.1", config.BindHost)
assert.Equal(t, []UserV3{
{
Username: "user",
AuthKey: "password",
AuthProtocol: "MD5",
PrivKey: "password",
PrivProtocol: "AES",
},
}, config.Users)

params := config.BuildV2Params()
params, err := config.BuildSNMPParams()
assert.NoError(t, err)
assert.Equal(t, uint16(1234), params.Port)
assert.Equal(t, gosnmp.Version2c, params.Version)
assert.Equal(t, gosnmp.Version3, params.Version)
assert.Equal(t, "udp", params.Transport)
assert.NotNil(t, params.Logger)
assert.Equal(t, gosnmp.UserSecurityModel, params.SecurityModel)
assert.Equal(t, &gosnmp.UsmSecurityParameters{
UserName: "user",
AuthoritativeEngineID: expectedEngineID,
AuthenticationProtocol: gosnmp.MD5,
AuthenticationPassphrase: "password",
PrivacyProtocol: gosnmp.AES,
PrivacyPassphrase: "password",
}, params.SecurityParameters)
}

func TestDefaultPort(t *testing.T) {
Configure(t, Config{
CommunityStrings: []string{"public"},
})
config, err := ReadConfig()
assert.NoError(t, err)
assert.Equal(t, defaultPort, config.Port)
}

func TestCommunityStringsEmpty(t *testing.T) {
Configure(t, Config{
CommunityStrings: []string{},
})
_, err := ReadConfig()
assert.Error(t, err)
}

func TestCommunityStringsMissing(t *testing.T) {
func TestMinimalConfig(t *testing.T) {
Configure(t, Config{})
_, err := ReadConfig()
assert.Error(t, err)
}

func TestDefaultStopTimeout(t *testing.T) {
Configure(t, Config{
CommunityStrings: []string{"public"},
})
config, err := ReadConfig()
config, err := ReadConfig("")
assert.NoError(t, err)

assert.Equal(t, uint16(162), config.Port)
assert.Equal(t, 5, config.StopTimeout)
assert.Equal(t, []string{}, config.CommunityStrings)
assert.Equal(t, "localhost", config.BindHost)
assert.Equal(t, []UserV3{}, config.Users)

params, err := config.BuildSNMPParams()
assert.NoError(t, err)
assert.Equal(t, uint16(162), params.Port)
assert.Equal(t, gosnmp.Version2c, params.Version)
assert.Equal(t, "udp", params.Transport)
assert.NotNil(t, params.Logger)
assert.Equal(t, nil, params.SecurityParameters)
}

func TestStopTimeout(t *testing.T) {
func TestDefaultUsers(t *testing.T) {
Configure(t, Config{
CommunityStrings: []string{"public"},
StopTimeout: 11,
})
config, err := ReadConfig()
config, err := ReadConfig("")
assert.NoError(t, err)

assert.Equal(t, 11, config.StopTimeout)
}

func TestBuildAuthoritativeEngineID(t *testing.T) {
Configure(t, Config{})
for hostname, engineID := range expectedEngineIDs {
config, err := ReadConfig(hostname)
assert.NoError(t, err)
assert.Equal(t, engineID, config.authoritativeEngineID)
}
}
1 change: 1 addition & 0 deletions pkg/snmp/traps/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ const (
defaultPort = uint16(162) // Standard UDP port for traps.
defaultStopTimeout = 5
packetsChanSize = 100
genericTrapOid = "1.3.6.1.6.3.1.1.5"
)
Loading

0 comments on commit 363ca0b

Please sign in to comment.