Add snmp_trap input plugin (#6629)

This commit is contained in:
reimda 2019-11-25 12:56:21 -07:00 committed by Daniel Nelson
parent 4e8aa8ad1b
commit cec1bdce90
7 changed files with 562 additions and 8 deletions

6
Gopkg.lock generated
View File

@ -525,12 +525,12 @@
version = "v1.1.1" version = "v1.1.1"
[[projects]] [[projects]]
digest = "1:530233672f656641b365f8efb38ed9fba80e420baff2ce87633813ab3755ed6d" digest = "1:68c64bb61d55dcd17c82ca0b871ddddb5ae18b30cfe26f6bfd4b6df6287dc2e0"
name = "github.com/golang/mock" name = "github.com/golang/mock"
packages = ["gomock"] packages = ["gomock"]
pruneopts = "" pruneopts = ""
revision = "51421b967af1f557f93a59e0057aaf15ca02e29c" revision = "9fa652df1129bef0e734c9cf9bf6dbae9ef3b9fa"
version = "v1.2.0" version = "1.3.1"
[[projects]] [[projects]]
digest = "1:f958a1c137db276e52f0b50efee41a1a389dcdded59a69711f3e872757dab34b" digest = "1:f958a1c137db276e52f0b50efee41a1a389dcdded59a69711f3e872757dab34b"

View File

@ -136,6 +136,7 @@ import (
_ "github.com/influxdata/telegraf/plugins/inputs/smart" _ "github.com/influxdata/telegraf/plugins/inputs/smart"
_ "github.com/influxdata/telegraf/plugins/inputs/snmp" _ "github.com/influxdata/telegraf/plugins/inputs/snmp"
_ "github.com/influxdata/telegraf/plugins/inputs/snmp_legacy" _ "github.com/influxdata/telegraf/plugins/inputs/snmp_legacy"
_ "github.com/influxdata/telegraf/plugins/inputs/snmp_trap"
_ "github.com/influxdata/telegraf/plugins/inputs/socket_listener" _ "github.com/influxdata/telegraf/plugins/inputs/socket_listener"
_ "github.com/influxdata/telegraf/plugins/inputs/solr" _ "github.com/influxdata/telegraf/plugins/inputs/solr"
_ "github.com/influxdata/telegraf/plugins/inputs/sqlserver" _ "github.com/influxdata/telegraf/plugins/inputs/sqlserver"

View File

@ -277,7 +277,7 @@ func (f *Field) init() error {
return nil return nil
} }
_, oidNum, oidText, conversion, err := snmpTranslate(f.Oid) _, oidNum, oidText, conversion, err := SnmpTranslate(f.Oid)
if err != nil { if err != nil {
return Errorf(err, "translating") return Errorf(err, "translating")
} }
@ -882,7 +882,7 @@ func snmpTable(oid string) (mibName string, oidNum string, oidText string, field
} }
func snmpTableCall(oid string) (mibName string, oidNum string, oidText string, fields []Field, err error) { func snmpTableCall(oid string) (mibName string, oidNum string, oidText string, fields []Field, err error) {
mibName, oidNum, oidText, _, err = snmpTranslate(oid) mibName, oidNum, oidText, _, err = SnmpTranslate(oid)
if err != nil { if err != nil {
return "", "", "", nil, Errorf(err, "translating") return "", "", "", nil, Errorf(err, "translating")
} }
@ -952,7 +952,7 @@ var snmpTranslateCachesLock sync.Mutex
var snmpTranslateCaches map[string]snmpTranslateCache var snmpTranslateCaches map[string]snmpTranslateCache
// snmpTranslate resolves the given OID. // snmpTranslate resolves the given OID.
func snmpTranslate(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) { func SnmpTranslate(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) {
snmpTranslateCachesLock.Lock() snmpTranslateCachesLock.Lock()
if snmpTranslateCaches == nil { if snmpTranslateCaches == nil {
snmpTranslateCaches = map[string]snmpTranslateCache{} snmpTranslateCaches = map[string]snmpTranslateCache{}
@ -978,6 +978,28 @@ func snmpTranslate(oid string) (mibName string, oidNum string, oidText string, c
return stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err return stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err
} }
func SnmpTranslateForce(oid string, mibName string, oidNum string, oidText string, conversion string) {
snmpTranslateCachesLock.Lock()
defer snmpTranslateCachesLock.Unlock()
if snmpTranslateCaches == nil {
snmpTranslateCaches = map[string]snmpTranslateCache{}
}
var stc snmpTranslateCache
stc.mibName = mibName
stc.oidNum = oidNum
stc.oidText = oidText
stc.conversion = conversion
stc.err = nil
snmpTranslateCaches[oid] = stc
}
func SnmpTranslateClear() {
snmpTranslateCachesLock.Lock()
defer snmpTranslateCachesLock.Unlock()
snmpTranslateCaches = map[string]snmpTranslateCache{}
}
func snmpTranslateCall(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) { func snmpTranslateCall(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) {
var out []byte var out []byte
if strings.ContainsAny(oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") { if strings.ContainsAny(oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") {

View File

@ -742,7 +742,7 @@ func TestFieldConvert(t *testing.T) {
func TestSnmpTranslateCache_miss(t *testing.T) { func TestSnmpTranslateCache_miss(t *testing.T) {
snmpTranslateCaches = nil snmpTranslateCaches = nil
oid := "IF-MIB::ifPhysAddress.1" oid := "IF-MIB::ifPhysAddress.1"
mibName, oidNum, oidText, conversion, err := snmpTranslate(oid) mibName, oidNum, oidText, conversion, err := SnmpTranslate(oid)
assert.Len(t, snmpTranslateCaches, 1) assert.Len(t, snmpTranslateCaches, 1)
stc := snmpTranslateCaches[oid] stc := snmpTranslateCaches[oid]
require.NotNil(t, stc) require.NotNil(t, stc)
@ -763,7 +763,7 @@ func TestSnmpTranslateCache_hit(t *testing.T) {
err: fmt.Errorf("e"), err: fmt.Errorf("e"),
}, },
} }
mibName, oidNum, oidText, conversion, err := snmpTranslate("foo") mibName, oidNum, oidText, conversion, err := SnmpTranslate("foo")
assert.Equal(t, "a", mibName) assert.Equal(t, "a", mibName)
assert.Equal(t, "b", oidNum) assert.Equal(t, "b", oidNum)
assert.Equal(t, "c", oidText) assert.Equal(t, "c", oidText)

View File

@ -0,0 +1,43 @@
# SNMP Trap Input Plugin
The SNMP Trap plugin is a service input plugin that receives SNMP
notifications (traps and inform requests).
Notifications are received on plain UDP. The port to listen is
configurable.
OIDs can be resolved to strings using system MIB files. This is done
in same way as the SNMP input plugin. See the section "MIB Lookups" in
the SNMP [README.md](../snmp/README.md) for details.
### Configuration
```toml
# Snmp trap listener
[[inputs.snmp_trap]]
## Transport, local address, and port to listen on. Transport must
## be "udp://". Omit local address to listen on all interfaces.
## example: "udp://127.0.0.1:1234"
# service_address = udp://:162
## Timeout running snmptranslate command
# timeout = "5s"
```
### Metrics
- snmp_trap
- tags:
- source (string, IP address of trap source)
- name (string, value from SNMPv2-MIB::snmpTrapOID.0 PDU)
- mib (string, MIB from SNMPv2-MIB::snmpTrapOID.0 PDU)
- oid (string, OID string from SNMPv2-MIB::snmpTrapOID.0 PDU)
- version (string, "1" or "2c" or "3")
- fields:
- Fields are mapped from variables in the trap. Field names are
the trap variable names after MIB lookup. Field values are trap
variable values.
### Example Output
```
snmp_trap,mib=SNMPv2-MIB,name=coldStart,oid=.1.3.6.1.6.3.1.1.5.1,source=192.168.122.102,version=2c snmpTrapEnterprise.0="linux",sysUpTimeInstance=1i 1574109187723429814
snmp_trap,mib=NET-SNMP-AGENT-MIB,name=nsNotifyShutdown,oid=.1.3.6.1.4.1.8072.4.0.2,source=192.168.122.102,version=2c sysUpTimeInstance=5803i,snmpTrapEnterprise.0="netSnmpNotificationPrefix" 1574109186555115459
```

View File

@ -0,0 +1,266 @@
package snmp_trap
import (
"bufio"
"bytes"
"fmt"
"net"
"os/exec"
"strings"
"sync"
"time"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/internal"
"github.com/influxdata/telegraf/plugins/inputs"
"github.com/soniah/gosnmp"
)
var defaultTimeout = internal.Duration{Duration: time.Second * 5}
type handler func(*gosnmp.SnmpPacket, *net.UDPAddr)
type execer func(internal.Duration, string, ...string) ([]byte, error)
type mibEntry struct {
mibName string
oidText string
}
type SnmpTrap struct {
ServiceAddress string `toml:"service_address"`
Timeout internal.Duration `toml:"timeout"`
acc telegraf.Accumulator
listener *gosnmp.TrapListener
timeFunc func() time.Time
errCh chan error
makeHandlerWrapper func(handler) handler
Log telegraf.Logger `toml:"-"`
cacheLock sync.Mutex
cache map[string]mibEntry
execCmd execer
}
var sampleConfig = `
## Transport, local address, and port to listen on. Transport must
## be "udp://". Omit local address to listen on all interfaces.
## example: "udp://127.0.0.1:1234"
# service_address = udp://:162
## Timeout running snmptranslate command
# timeout = "5s"
`
func (s *SnmpTrap) SampleConfig() string {
return sampleConfig
}
func (s *SnmpTrap) Description() string {
return "Receive SNMP traps"
}
func (s *SnmpTrap) Gather(_ telegraf.Accumulator) error {
return nil
}
func init() {
inputs.Add("snmp_trap", func() telegraf.Input {
return &SnmpTrap{
timeFunc: time.Now,
ServiceAddress: "udp://:162",
Timeout: defaultTimeout,
}
})
}
func realExecCmd(Timeout internal.Duration, arg0 string, args ...string) ([]byte, error) {
cmd := exec.Command(arg0, args...)
var out bytes.Buffer
cmd.Stdout = &out
err := internal.RunTimeout(cmd, Timeout.Duration)
if err != nil {
return nil, err
}
return out.Bytes(), nil
}
func (s *SnmpTrap) Init() error {
s.cache = map[string]mibEntry{}
s.execCmd = realExecCmd
return nil
}
func (s *SnmpTrap) Start(acc telegraf.Accumulator) error {
s.acc = acc
s.listener = gosnmp.NewTrapListener()
s.listener.OnNewTrap = makeTrapHandler(s)
s.listener.Params = gosnmp.Default
// wrap the handler, used in unit tests
if nil != s.makeHandlerWrapper {
s.listener.OnNewTrap = s.makeHandlerWrapper(s.listener.OnNewTrap)
}
split := strings.SplitN(s.ServiceAddress, "://", 2)
if len(split) != 2 {
return fmt.Errorf("invalid service address: %s", s.ServiceAddress)
}
protocol := split[0]
addr := split[1]
// gosnmp.TrapListener currently supports udp only. For forward
// compatibility, require udp in the service address
if protocol != "udp" {
return fmt.Errorf("unknown protocol '%s' in '%s'", protocol, s.ServiceAddress)
}
// If (*TrapListener).Listen immediately returns an error we need
// to return it from this function. Use a channel to get it here
// from the goroutine. Buffer one in case Listen returns after
// Listening but before our Close is called.
s.errCh = make(chan error, 1)
go func() {
s.errCh <- s.listener.Listen(addr)
}()
select {
case <-s.listener.Listening():
s.Log.Infof("Listening on %s", s.ServiceAddress)
case err := <-s.errCh:
return err
}
return nil
}
func (s *SnmpTrap) Stop() {
s.listener.Close()
err := <-s.errCh
if nil != err {
s.Log.Errorf("Error stopping trap listener %v", err)
}
}
func makeTrapHandler(s *SnmpTrap) handler {
return func(packet *gosnmp.SnmpPacket, addr *net.UDPAddr) {
tm := s.timeFunc()
fields := map[string]interface{}{}
tags := map[string]string{}
tags["version"] = packet.Version.String()
tags["source"] = addr.IP.String()
for _, v := range packet.Variables {
// Use system mibs to resolve oids. Don't fall back to
// numeric oid because it's not useful enough to the end
// user and can be difficult to translate or remove from
// the database later.
var value interface{}
// todo: format the pdu value based on its snmp type and
// the mib's textual convention. The snmp input plugin
// only handles textual convention for ip and mac
// addresses
switch v.Type {
case gosnmp.ObjectIdentifier:
val, ok := v.Value.(string)
if !ok {
s.Log.Errorf("Error getting value OID")
return
}
var e mibEntry
var err error
e, err = s.lookup(val)
if nil != err {
s.Log.Errorf("Error resolving value OID: %v", err)
return
}
value = e.oidText
// 1.3.6.1.6.3.1.1.4.1.0 is SNMPv2-MIB::snmpTrapOID.0.
// If v.Name is this oid, set a tag of the trap name.
if v.Name == ".1.3.6.1.6.3.1.1.4.1.0" {
tags["oid"] = val
tags["name"] = e.oidText
tags["mib"] = e.mibName
continue
}
default:
value = v.Value
}
e, err := s.lookup(v.Name)
if nil != err {
s.Log.Errorf("Error resolving OID: %v", err)
return
}
name := e.oidText
fields[name] = value
}
s.acc.AddFields("snmp_trap", fields, tags, tm)
}
}
func (s *SnmpTrap) lookup(oid string) (e mibEntry, err error) {
s.cacheLock.Lock()
defer s.cacheLock.Unlock()
var ok bool
if e, ok = s.cache[oid]; !ok {
// cache miss. exec snmptranlate
e, err = s.snmptranslate(oid)
if err == nil {
s.cache[oid] = e
}
return e, err
}
return e, nil
}
func (s *SnmpTrap) clear() {
s.cacheLock.Lock()
defer s.cacheLock.Unlock()
s.cache = map[string]mibEntry{}
}
func (s *SnmpTrap) load(oid string, e mibEntry) {
s.cacheLock.Lock()
defer s.cacheLock.Unlock()
s.cache[oid] = e
}
func (s *SnmpTrap) snmptranslate(oid string) (e mibEntry, err error) {
var out []byte
out, err = s.execCmd(s.Timeout, "snmptranslate", "-Td", "-Ob", "-m", "all", oid)
if err != nil {
return e, err
}
scanner := bufio.NewScanner(bytes.NewBuffer(out))
ok := scanner.Scan()
if err = scanner.Err(); !ok && err != nil {
return e, err
}
e.oidText = scanner.Text()
i := strings.Index(e.oidText, "::")
if i == -1 {
return e, fmt.Errorf("not found")
}
e.mibName = e.oidText[:i]
e.oidText = e.oidText[i+2:]
return e, nil
}

View File

@ -0,0 +1,222 @@
package snmp_trap
import (
"fmt"
"net"
"strconv"
"testing"
"time"
"github.com/soniah/gosnmp"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/internal"
"github.com/influxdata/telegraf/testutil"
"github.com/stretchr/testify/require"
)
func TestLoad(t *testing.T) {
s := &SnmpTrap{}
require.Nil(t, s.Init())
defer s.clear()
s.load(
".1.3.6.1.6.3.1.1.5.1",
mibEntry{
"SNMPv2-MIB",
"coldStart",
},
)
e, err := s.lookup(".1.3.6.1.6.3.1.1.5.1")
require.NoError(t, err)
require.Equal(t, "SNMPv2-MIB", e.mibName)
require.Equal(t, "coldStart", e.oidText)
}
func sendTrap(t *testing.T, port uint16) (sentTimestamp uint32) {
s := &gosnmp.GoSNMP{
Port: port,
Community: "public",
Version: gosnmp.Version2c,
Timeout: time.Duration(2) * time.Second,
Retries: 3,
MaxOids: gosnmp.MaxOids,
Target: "127.0.0.1",
}
err := s.Connect()
if err != nil {
t.Errorf("Connect() err: %v", err)
}
defer s.Conn.Close()
// If the first pdu isn't type TimeTicks, gosnmp.SendTrap() will
// prepend one with time.Now(). The time value is part of the
// plugin output so we need to keep track of it and verify it
// later.
now := uint32(time.Now().Unix())
timePdu := gosnmp.SnmpPDU{
Name: ".1.3.6.1.2.1.1.3.0",
Type: gosnmp.TimeTicks,
Value: now,
}
pdu := gosnmp.SnmpPDU{
Name: ".1.3.6.1.6.3.1.1.4.1.0", // SNMPv2-MIB::snmpTrapOID.0
Type: gosnmp.ObjectIdentifier,
Value: ".1.3.6.1.6.3.1.1.5.1", // coldStart
}
trap := gosnmp.SnmpTrap{
Variables: []gosnmp.SnmpPDU{
timePdu,
pdu,
},
}
_, err = s.SendTrap(trap)
if err != nil {
t.Errorf("SendTrap() err: %v", err)
}
return now
}
func TestReceiveTrap(t *testing.T) {
// We would prefer to specify port 0 and let the network stack
// choose an unused port for us but TrapListener doesn't have a
// way to return the autoselected port. Instead, we'll use an
// unusual port and hope it's unused.
const port = 12399
var fakeTime = time.Now()
// hook into the trap handler so the test knows when the trap has
// been received
received := make(chan int)
wrap := func(f handler) handler {
return func(p *gosnmp.SnmpPacket, a *net.UDPAddr) {
f(p, a)
received <- 0
}
}
// set up the service input plugin
s := &SnmpTrap{
ServiceAddress: "udp://:" + strconv.Itoa(port),
makeHandlerWrapper: wrap,
timeFunc: func() time.Time {
return fakeTime
},
Log: testutil.Logger{},
}
require.Nil(t, s.Init())
var acc testutil.Accumulator
require.Nil(t, s.Start(&acc))
defer s.Stop()
// Preload the cache with the oids we'll use in this test so
// snmptranslate and mibs don't need to be installed.
defer s.clear()
s.load(".1.3.6.1.6.3.1.1.4.1.0",
mibEntry{
"SNMPv2-MIB",
"snmpTrapOID.0",
})
s.load(".1.3.6.1.6.3.1.1.5.1",
mibEntry{
"SNMPv2-MIB",
"coldStart",
})
s.load(".1.3.6.1.2.1.1.3.0",
mibEntry{
"UNUSED_MIB_NAME",
"sysUpTimeInstance",
})
// send the trap
sentTimestamp := sendTrap(t, port)
// wait for trap to be received
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for trap to be received")
}
// verify plugin output
expected := []telegraf.Metric{
testutil.MustMetric(
"snmp_trap", // name
map[string]string{ // tags
"oid": ".1.3.6.1.6.3.1.1.5.1",
"name": "coldStart",
"mib": "SNMPv2-MIB",
"version": "2c",
"source": "127.0.0.1",
},
map[string]interface{}{ // fields
"sysUpTimeInstance": sentTimestamp,
},
fakeTime,
),
}
testutil.RequireMetricsEqual(t,
expected, acc.GetTelegrafMetrics(),
testutil.SortMetrics())
}
func fakeExecCmd(_ internal.Duration, _ string, _ ...string) ([]byte, error) {
return nil, fmt.Errorf("intentional failure")
}
func TestMissingOid(t *testing.T) {
// should fail even if snmptranslate is installed
const port = 12399
var fakeTime = time.Now()
received := make(chan int)
wrap := func(f handler) handler {
return func(p *gosnmp.SnmpPacket, a *net.UDPAddr) {
f(p, a)
received <- 0
}
}
s := &SnmpTrap{
ServiceAddress: "udp://:" + strconv.Itoa(port),
makeHandlerWrapper: wrap,
timeFunc: func() time.Time {
return fakeTime
},
Log: testutil.Logger{},
}
require.Nil(t, s.Init())
var acc testutil.Accumulator
require.Nil(t, s.Start(&acc))
defer s.Stop()
// make sure the cache is empty
s.clear()
// don't call the real snmptranslate
s.execCmd = fakeExecCmd
_ = sendTrap(t, port)
select {
case <-received:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for trap to be received")
}
// oid lookup should fail so we shouldn't get a metric
expected := []telegraf.Metric{}
testutil.RequireMetricsEqual(t,
expected, acc.GetTelegrafMetrics(),
testutil.SortMetrics())
}