diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index 781e04e60928b..39cd35ed91b3c 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -166,6 +166,7 @@ import ( _ "github.com/influxdata/telegraf/plugins/inputs/sflow" _ "github.com/influxdata/telegraf/plugins/inputs/smart" _ "github.com/influxdata/telegraf/plugins/inputs/snmp" + _ "github.com/influxdata/telegraf/plugins/inputs/snmp_heartbeat" _ "github.com/influxdata/telegraf/plugins/inputs/snmp_legacy" _ "github.com/influxdata/telegraf/plugins/inputs/snmp_trap" _ "github.com/influxdata/telegraf/plugins/inputs/socket_listener" diff --git a/plugins/inputs/snmp/README.md b/plugins/inputs/snmp/README.md index 0d52881a72f04..9099fd63d8392 100644 --- a/plugins/inputs/snmp/README.md +++ b/plugins/inputs/snmp/README.md @@ -49,6 +49,9 @@ information. ## The GETBULK max-repetitions parameter. # max_repetitions = 10 + ##Include period & timestamp in each document + #include_time_info = true + ## SNMPv3 authentication and encryption options. ## ## Security Name. diff --git a/plugins/inputs/snmp/snmp.go b/plugins/inputs/snmp/snmp.go index 7f2df6b689eac..0b7ad96240ca5 100644 --- a/plugins/inputs/snmp/snmp.go +++ b/plugins/inputs/snmp/snmp.go @@ -10,6 +10,7 @@ import ( "math" "net" "os/exec" + "regexp" "strconv" "strings" "sync" @@ -74,6 +75,28 @@ const sampleConfig = ` ## example collects the system uptime and interface variables. Reference the ## full plugin documentation for configuration details. ` +const ( + SYSTEM_NAME_OID = ".1.3.6.1.2.1.1.5" + IP_ADDRESS_OID = ".1.3.6.1.2.1.4.20.1.2" + SYSTEM_CONTACT_OID = ".1.3.6.1.2.1.1.4" + SYSTEM_LOCATION_OID = ".1.3.6.1.2.1.1.6" + IF_ADMIN_STATUS = ".1.3.6.1.2.1.2.2.1.7" + INTERFACE_STATUS = ".1.3.6.1.2.1.2.2.1.8" + IP_ADDRESS_REGEX = "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$" + VERSION_REGEX = `(Cisco IOS Software),\s+(.*)\s+Software\s+(.*)` + HUAWEI_VERSION_REGEX = `Version ([0-9.]+)[^\r\n]*(V[0-9A-Z]+)` + EMPTY_STRING = "" + SYSTEM_DESCRIPTION = ".1.3.6.1.2.1.1.1" +) +const ( + STATUS_UP = "UP" + STATUS_TEST = "TEST" + STATUS_DOWN = "DOWN" +) +const ( + STATUS_UP_NUM = 1 + STATUS_TEST_NUM = 3 +) // execCommand is so tests can mock out exec.Command usage. var execCommand = exec.Command @@ -110,8 +133,10 @@ type Snmp struct { snmp.ClientConfig - Tables []Table `toml:"table"` - + Tables []Table `toml:"table"` + Include_time_info bool `toml:"include_time_info"` + Period string `toml:"interval"` + Packets int64 `toml:"packets"` // Name & Fields are the elements of a Table. // Telegraf chokes if we try to embed a Table. So instead we have to embed the // fields of a Table, and construct a Table during runtime. @@ -130,6 +155,7 @@ func (s *Snmp) init() error { s.connectionCache = make([]snmpConnection, len(s.Agents)) for i := range s.Tables { + if err := s.Tables[i].Init(); err != nil { return fmt.Errorf("initializing table %s: %w", s.Tables[i].Name, err) } @@ -189,11 +215,12 @@ func (t *Table) Init() error { // initialize all the nested fields for i := range t.Fields { + if err := t.Fields[i].init(); err != nil { + return fmt.Errorf("initializing field %s: %w", t.Fields[i].Name, err) } } - t.initialized = true return nil } @@ -205,8 +232,8 @@ func (t *Table) initBuild() error { if t.Oid == "" { return nil } - _, _, oidText, fields, err := snmpTable(t.Oid) + if err != nil { return err } @@ -216,6 +243,7 @@ func (t *Table) initBuild() error { } knownOIDs := map[string]bool{} + for _, f := range t.Fields { knownOIDs[f.Oid] = true } @@ -223,8 +251,8 @@ func (t *Table) initBuild() error { if !knownOIDs[f.Oid] { t.Fields = append(t.Fields, f) } - } + } return nil } @@ -258,26 +286,29 @@ type Field struct { // init() converts OID names to numbers, and sets the .Name attribute if unset. func (f *Field) init() error { + if f.initialized { return nil } // check if oid needs translation or name is not set if strings.ContainsAny(f.Oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") || f.Name == "" { + _, oidNum, oidText, conversion, err := SnmpTranslate(f.Oid) if err != nil { return fmt.Errorf("translating: %w", err) } + f.Oid = oidNum if f.Name == "" { f.Name = oidText } + if f.Conversion == "" { f.Conversion = conversion } //TODO use textual convention conversion from the MIB } - f.initialized = true return nil } @@ -363,9 +394,39 @@ func (s *Snmp) Gather(acc telegraf.Accumulator) error { Name: s.Name, Fields: s.Fields, } + topTags := map[string]string{} if err := s.gatherTable(acc, gs, t, topTags, false); err != nil { - acc.AddError(fmt.Errorf("agent %s: %w", agent, err)) + if s.Include_time_info { + rt := RTable{ + Name: t.Name, + Time: time.Now(), //TODO record time at start + Rows: make([]RTableRow, 0, 1), + } + rtr := RTableRow{} + rtr.Tags = map[string]string{} + rtr.Fields = map[string]interface{}{} + if _, ok := rtr.Tags[s.AgentHostTag]; !ok { + rtr.Tags[s.AgentHostTag] = gs.Host() + } + rtr.Fields["@timestamp"] = 0 + if strings.Contains(s.Period, "m") { + period_str := strings.Trim(s.Period, "m") + period, err := strconv.Atoi(period_str) + if err == nil { + rtr.Fields["period"] = period * 60 + } + } else { + period_str := strings.Trim(s.Period, "s") + rtr.Fields["period"], err = strconv.Atoi(period_str) + } + + rt.Rows = append(rt.Rows, rtr) + + acc.AddFields(rt.Name, rtr.Fields, rtr.Tags, rt.Time) + } else { + acc.AddError(fmt.Errorf("agent %s: %w", agent, err)) + } } // Now is the real tables. @@ -377,11 +438,11 @@ func (s *Snmp) Gather(acc telegraf.Accumulator) error { }(i, agent) } wg.Wait() - return nil } func (s *Snmp) gatherTable(acc telegraf.Accumulator, gs snmpConnection, t Table, topTags map[string]string, walk bool) error { + rt, err := t.Build(gs, walk) if err != nil { return err @@ -393,26 +454,41 @@ func (s *Snmp) gatherTable(acc telegraf.Accumulator, gs snmpConnection, t Table, for k, v := range tr.Tags { topTags[k] = v } + } else { // real table. Inherit any specified tags. for _, k := range t.InheritTags { + if v, ok := topTags[k]; ok { tr.Tags[k] = v } } } + if s.Include_time_info { + t := time.Now().UTC() + tr.Fields["@timestamp"] = t.Format("2006-01-02T15:04:05.000Z") + if strings.Contains(s.Period, "m") { + period, err := strconv.Atoi(strings.Trim(s.Period, "m")) + if err == nil { + tr.Fields["period"] = period * 60 + } + } else { + tr.Fields["period"], err = strconv.Atoi(strings.Trim(s.Period, "s")) + } + } if _, ok := tr.Tags[s.AgentHostTag]; !ok { tr.Tags[s.AgentHostTag] = gs.Host() } + acc.AddFields(rt.Name, tr.Fields, tr.Tags, rt.Time) } - return nil } // Build retrieves all the fields specified in the table and constructs the RTable. func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { rows := map[string]RTableRow{} + var index_arr []string tagCount := 0 for _, f := range t.Fields { @@ -430,7 +506,6 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { // make sure OID has "." because the BulkWalkAll results do, and the prefix needs to match oid = "." + f.Oid } - // ifv contains a mapping of table OID index to field value ifv := map[string]interface{}{} @@ -468,11 +543,13 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { idx := ent.Name[len(oid):] if f.OidIndexSuffix != "" { + if !strings.HasSuffix(idx, f.OidIndexSuffix) { // this entry doesn't match our OidIndexSuffix. skip it return nil } - idx = idx[:len(idx)-len(f.OidIndexSuffix)] + //idx = idx[:len(idx)-len(f.OidIndexSuffix)] + idx = f.OidIndexSuffix } if f.OidIndexLength != 0 { i := f.OidIndexLength + 1 // leading separator @@ -486,7 +563,9 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { return r }, idx) } - + //log.Printf("Value of IDX is %s",idx) + //index_arr := []string{} + index_arr = append(index_arr, idx) // snmptranslate table field value here if f.Translate { if entOid, ok := ent.Value.(string); ok { @@ -505,7 +584,23 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { err: err, } } - ifv[idx] = fv + + if f.Oid == SYSTEM_NAME_OID && idx == ".0" { + //log.Printf("Array_index is : %+v length is: %d",index_arr,len(index_arr)) + addSystemName(index_arr, ifv, fv) + } else if f.Oid == IP_ADDRESS_OID { + addIPAddr(ifv, fv, idx) + } else if f.Oid == SYSTEM_CONTACT_OID || f.Oid == SYSTEM_LOCATION_OID { + replaceEmptyString(ifv, fv, idx) + } else if f.Name == "software_version" && f.Oid == SYSTEM_DESCRIPTION { + extractSoftwareVersion(ifv, fv, t.Name) + } else if f.Name == "hardware_version" && t.Name == "cisco_hardware_software_data" && f.Oid == SYSTEM_DESCRIPTION { + extractHardwareVersion(ifv, fv) + } else if f.Oid == IF_ADMIN_STATUS || f.Oid == INTERFACE_STATUS { + statusConversion(ifv, fv, idx) + } else { + ifv[idx] = fv + } return nil }) if err != nil { @@ -517,9 +612,10 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { } } } - for idx, v := range ifv { + rtr, ok := rows[idx] + if !ok { rtr = RTableRow{} rtr.Tags = map[string]string{} @@ -552,11 +648,81 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { Time: time.Now(), //TODO record time at start Rows: make([]RTableRow, 0, len(rows)), } + for _, r := range rows { rt.Rows = append(rt.Rows, r) } return &rt, nil } +func addSystemName(index_arr []string, ifv map[string]interface{}, fv interface{}) { + for i := 0; i <= len(index_arr)-2; i++ { + ifv[index_arr[i]] = fv + } +} +func addIPAddr(ifv map[string]interface{}, fv interface{}, idx string) { + match, _ := regexp.MatchString(IP_ADDRESS_REGEX, idx[1:]) + if match { + ifv[fmt.Sprint(".", strconv.Itoa(fv.(int)))] = idx[1:] + } +} + +func extractSoftwareVersion(ifv map[string]interface{}, fv interface{}, tableName string) { + if tableName == "cisco_hardware_software_data" { + str := fv.(string) + //r := regexp.MustCompile(`(Cisco IOS Software),\s+(.*)\s+Software\s+(.*)`) + r := regexp.MustCompile(VERSION_REGEX) + groups := r.FindAllStringSubmatch(str, -1) + if len(groups) == 0 { + ifv[".0"] = fmt.Sprint(".") + } else { + str_1 := groups[0][1] + str_2 := groups[0][2] + str_3 := groups[0][3] + ifv[".0"] = fmt.Sprint(str_1, " ", str_2, " ", str_3) + } + } else if tableName == "huawei_hardware_software_data" { + str := fv.(string) + //r := regexp.MustCompile(`Version ([0-9.]+)[^\r\n]*(V[0-9A-Z]+)`) + r := regexp.MustCompile(HUAWEI_VERSION_REGEX) + groups := r.FindAllStringSubmatch(str, -1) + if len(groups) == 0 { + ifv[".0"] = fmt.Sprint(".") + } else { + str_1 := groups[0][0] + ifv[".0"] = fmt.Sprint(str_1) + } + } + +} + +func extractHardwareVersion(ifv map[string]interface{}, fv interface{}) { + str := fv.(string) + //r := regexp.MustCompile(`(Cisco IOS Software),\s+(.*)\s+Software\s+(.*)`) + r := regexp.MustCompile(VERSION_REGEX) + groups := r.FindAllStringSubmatch(str, -1) + if len(groups) == 0 { + ifv[".0"] = fmt.Sprint(".") + } else { + str_2 := groups[0][2] + ifv[".0"] = fmt.Sprint(str_2) + } +} +func statusConversion(ifv map[string]interface{}, fv interface{}, idx string) { + if fv == STATUS_UP_NUM { + ifv[idx] = STATUS_UP + } else if fv == STATUS_TEST_NUM { + ifv[idx] = STATUS_TEST + } else { + ifv[idx] = STATUS_DOWN + } +} +func replaceEmptyString(ifv map[string]interface{}, fv interface{}, idx string) { + if fv == EMPTY_STRING { + ifv[idx] = "-" + } else { + ifv[idx] = fv + } +} // snmpConnection is an interface which wraps a *gosnmp.GoSNMP object. // We interact through an interface so we can mock it out in tests. @@ -575,9 +741,7 @@ func (s *Snmp) getConnection(idx int) (snmpConnection, error) { if gs := s.connectionCache[idx]; gs != nil { return gs, nil } - agent := s.Agents[idx] - var err error var gs snmp.GosnmpWrapper gs, err = snmp.NewWrapper(s.ClientConfig) diff --git a/plugins/inputs/snmp_heartbeat/README.md b/plugins/inputs/snmp_heartbeat/README.md new file mode 100644 index 0000000000000..bab960aad05b8 --- /dev/null +++ b/plugins/inputs/snmp_heartbeat/README.md @@ -0,0 +1,240 @@ +# SNMP_HEARTBEAT Input Plugin + +The `snmp_healthbeat` input plugin uses polling to gather metrics to check the state +of the snmp agent. +Support for gathering individual OIDs as well as complete SNMP tables is +included. + +### Prerequisites + +This plugin uses the `snmptable` and `snmptranslate` programs from the +[net-snmp][] project. These tools will need to be installed into the `PATH` in +order to be located. Other utilities from the net-snmp project may be useful +for troubleshooting, but are not directly used by the plugin. + +These programs will load available MIBs on the system. Typically the default +directory for MIBs is `/usr/share/snmp/mibs`, but if your MIBs are in a +different location you may need to make the paths known to net-snmp. The +location of these files can be configured in the `snmp.conf` or via the +`MIBDIRS` environment variable. See [`man 1 snmpcmd`][man snmpcmd] for more +information. + +### Configuration +```toml +[[inputs.snmp_heartbeat]] + ## Agent addresses to retrieve values from. + ## format: agents = [":"] + ## scheme: optional, either udp, udp4, udp6, tcp, tcp4, tcp6. + ## default is udp + ## port: optional + ## example: agents = ["udp://127.0.0.1:161"] + ## agents = ["tcp://127.0.0.1:161"] + ## agents = ["udp4://v4only-snmp-agent"] + agents = ["udp://127.0.0.1:161"] + + ## Timeout for each request. + # timeout = "5s" + + ## SNMP version; can be 1, 2, or 3. + # version = 2 + + ## The number of request made before gathering details. + packets = 3 + + ## Whether this should operate on probe mode. In probe mode state + ## field will be present in output based on whether response is + ## recieved or not. + probe = true + + ## SNMP community string. + # community = "public" + + ## Agent host tag + # agent_host_tag = "agent_host" + + ## Number of retries to attempt. + # retries = 0 + + ## The GETBULK max-repetitions parameter. + # max_repetitions = 10 + + ## SNMPv3 authentication and encryption options. + ## + ## Security Name. + # sec_name = "myuser" + ## Authentication protocol; one of "MD5", "SHA", "SHA224", "SHA256", "SHA384", "SHA512" or "". + # auth_protocol = "MD5" + ## Authentication password. + # auth_password = "pass" + ## Security Level; one of "noAuthNoPriv", "authNoPriv", or "authPriv". + # sec_level = "authNoPriv" + ## Context Name. + # context_name = "" + ## Privacy protocol used for encrypted messages; one of "DES", "AES", "AES192", "AES192C", "AES256", "AES256C", or "". + ### Protocols "AES192", "AES192", "AES256", and "AES256C" require the underlying net-snmp tools + ### to be compiled with --enable-blumenthal-aes (http://www.net-snmp.org/docs/INSTALL.html) + # priv_protocol = "" + ## Privacy password used for encrypted messages. + # priv_password = "" + + ## Add fields and tables defining the variables you wish to collect. This + ## example collects the system uptime and interface variables. Reference the + ## full plugin documentation for configuration details. + [[inputs.snmp_heartbeat.field]] + oid = "RFC1213-MIB::sysName.0" + name = "sysName" + # Plugin tags. + [inputs.snmp_heartbeat.tags] + type="snmp-heartbeat" + # Additional fields + field1="20" +``` + +#### Configure SNMP Requests + +This plugin provides two methods for configuring the SNMP requests: `fields` +and `tables`. Use the `field` option to gather single ad-hoc variables. +To collect SNMP tables, use the `table` option. + +##### Field + +Use a `field` to collect a variable by OID. Requests specified with this +option operate similar to the `snmpget` utility. + +```toml +[[inputs.snmp_heartbeat]] + # ... snip ... + + [[inputs.snmp_heartbeat.field]] + ## Object identifier of the variable as a numeric or textual OID. + oid = "RFC1213-MIB::sysName.0" + + ## Name of the field or tag to create. If not specified, it defaults to + ## the value of 'oid'. If 'oid' is numeric, an attempt to translate the + ## numeric OID into a textual OID will be made. + # name = "" + + ## If true the variable will be added as a tag, otherwise a field will be + ## created. + # is_tag = false + + ## Apply one of the following conversions to the variable value: + ## float(X): Convert the input value into a float and divides by the + ## Xth power of 10. Effectively just moves the decimal left + ## X places. For example a value of `123` with `float(2)` + ## will result in `1.23`. + ## float: Convert the value into a float with no adjustment. Same + ## as `float(0)`. + ## int: Convert the value into an integer. + ## hwaddr: Convert the value to a MAC address. + ## ipaddr: Convert the value to an IP address. + ## hextoint:X:Y Convert a hex string value to integer. Where X is the Endian + ## and Y the bit size. For example: hextoint:LittleEndian:uint64 + ## or hextoint:BigEndian:uint32. Valid options for the Endian are: + ## BigEndian and LittleEndian. For the bit size: uint16, uint32 + ## and uint64. + ## + # conversion = "" +``` + +##### Table + +Use a `table` to configure the collection of a SNMP table. SNMP requests +formed with this option operate similarly way to the `snmptable` command. + +Control the handling of specific table columns using a nested `field`. These +nested fields are specified similarly to a top-level `field`. + +By default all columns of the SNMP table will be collected - it is not required +to add a nested field for each column, only those which you wish to modify. To +*only* collect certain columns, omit the `oid` from the `table` section and only +include `oid` settings in `field` sections. For more complex include/exclude +cases for columns use [metric filtering][]. + +One [metric][] is created for each row of the SNMP table. + +```toml +[[inputs.snmp_heartbeat]] + # ... snip ... + + [[inputs.snmp_heartbeat.table]] + ## Object identifier of the SNMP table as a numeric or textual OID. + oid = "IF-MIB::ifTable" + + ## Name of the field or tag to create. If not specified, it defaults to + ## the value of 'oid'. If 'oid' is numeric an attempt to translate the + ## numeric OID into a textual OID will be made. + # name = "" + + ## Which tags to inherit from the top-level config and to use in the output + ## of this table's measurement. + ## example: inherit_tags = ["source"] + # inherit_tags = [] + + ## Add an 'index' tag with the table row number. Use this if the table has + ## no indexes or if you are excluding them. This option is normally not + ## required as any index columns are automatically added as tags. + # index_as_tag = false + + [[inputs.snmp_heartbeat.table.field]] + ## OID to get. May be a numeric or textual module-qualified OID. + oid = "IF-MIB::ifDescr" + + ## Name of the field or tag to create. If not specified, it defaults to + ## the value of 'oid'. If 'oid' is numeric an attempt to translate the + ## numeric OID into a textual OID will be made. + # name = "" + + ## Output this field as a tag. + # is_tag = false + + ## The OID sub-identifier to strip off so that the index can be matched + ## against other fields in the table. + # oid_index_suffix = "" + + ## Specifies the length of the index after the supplied table OID (in OID + ## path segments). Truncates the index after this point to remove non-fixed + ## value or length index suffixes. + # oid_index_length = 0 + + ## Specifies if the value of given field should be snmptranslated + ## by default no field values are translated + # translate = true +``` + +### Troubleshooting + +Check that a numeric field can be translated to a textual field: +``` +$ snmptranslate .1.3.6.1.2.1.1.3.0 +DISMAN-EVENT-MIB::sysUpTimeInstance +``` + +Request a top-level field: +``` +$ snmpget -v2c -c public 127.0.0.1 sysUpTime.0 +``` + +Request a table: +``` +$ snmptable -v2c -c public 127.0.0.1 ifTable +``` + +To collect a packet capture, run this command in the background while running +Telegraf or one of the above commands. Adjust the interface, host and port as +needed: +``` +$ sudo tcpdump -s 0 -i eth0 -w telegraf-snmp.pcap host 127.0.0.1 and port 161 +``` + +### Example Output + +``` +{"fields":{"average_rtt":37,"jitter":54,"maximum_rtt":110,"minimum_rtt":1,"period":0,"pkt_loss_pct":0,"sysName":"vostro","target_state":"Up"},"name":"snmp_heartbeat","tags":{"agent_host":"127.0.0.1","field1":"20","host":"vostro","type":"snmp-heartbeat"},"timestamp":163153132200} + +``` + +[net-snmp]: http://www.net-snmp.org/ +[man snmpcmd]: http://net-snmp.sourceforge.net/docs/man/snmpcmd.html#lbAK +[metric filtering]: /docs/CONFIGURATION.md#metric-filtering +[metric]: /docs/METRICS.md \ No newline at end of file diff --git a/plugins/inputs/snmp_heartbeat/snmp_heartbeat.go b/plugins/inputs/snmp_heartbeat/snmp_heartbeat.go new file mode 100644 index 0000000000000..b8cabe5815c3c --- /dev/null +++ b/plugins/inputs/snmp_heartbeat/snmp_heartbeat.go @@ -0,0 +1,1072 @@ +package snmp_heartbeat + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "log" + "math" + "net" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/gosnmp/gosnmp" + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/internal/snmp" + "github.com/influxdata/telegraf/plugins/inputs" + "github.com/influxdata/wlog" +) + +const description = `Retrieves SNMP values from remote agents` +const sampleConfig = ` + ## Agent addresses to retrieve values from. + ## format: agents = [":"] + ## scheme: optional, either udp, udp4, udp6, tcp, tcp4, tcp6. + ## default is udp + ## port: optional + ## example: agents = ["udp://127.0.0.1:161"] + ## agents = ["tcp://127.0.0.1:161"] + ## agents = ["udp4://v4only-snmp-agent"] + agents = ["udp://127.0.0.1:161"] + + ## Timeout for each request. + # timeout = "5s" + + ## SNMP version; can be 1, 2, or 3. + # version = 2 + + ## Agent host tag; the tag used to reference the source host + # agent_host_tag = "agent_host" + + # probe = false + # Period = "30s" + + ## SNMP community string. + # community = "public" + + ## Number of retries to attempt. + # retries = 3 + + ## The GETBULK max-repetitions parameter. + # max_repetitions = 10 + + ## SNMPv3 authentication and encryption options. + ## + ## Security Name. + # sec_name = "myuser" + ## Authentication protocol; one of "MD5", "SHA", "SHA224", "SHA256", "SHA384", "SHA512" or "". + # auth_protocol = "MD5" + ## Authentication password. + # auth_password = "pass" + ## Security Level; one of "noAuthNoPriv", "authNoPriv", or "authPriv". + # sec_level = "authNoPriv" + ## Context Name. + # context_name = "" + ## Privacy protocol used for encrypted messages; one of "DES", "AES" or "". + # priv_protocol = "" + ## Privacy password used for encrypted messages. + # priv_password = "" + + ## Add fields and tables defining the variables you wish to collect. This + ## example collects the system uptime and interface variables. Reference the + ## full plugin documentation for configuration details. +` + +// execCommand is so tests can mock out exec.Command usage. +var execCommand = exec.Command + +// execCmd executes the specified command, returning the STDOUT content. +// If command exits with error status, the output is captured into the returned error. +func execCmd(arg0 string, args ...string) ([]byte, error) { + if wlog.LogLevel() == wlog.DEBUG { + quoted := make([]string, 0, len(args)) + for _, arg := range args { + quoted = append(quoted, fmt.Sprintf("%q", arg)) + } + log.Printf("D! [inputs.snmp] executing %q %s", arg0, strings.Join(quoted, " ")) + } + + out, err := execCommand(arg0, args...).Output() + if err != nil { + if err, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("%s: %w", bytes.TrimRight(err.Stderr, "\r\n"), err) + } + return nil, err + } + return out, nil +} + +// Snmp holds the configuration for the plugin. +type Snmp struct { + // The SNMP agent to query. Format is [SCHEME://]ADDR[:PORT] (e.g. + // udp://1.2.3.4:161). If the scheme is not specified then "udp" is used. + Agents []string `toml:"agents"` + + // The tag used to name the agent host + AgentHostTag string `toml:"agent_host_tag"` + + snmp.ClientConfig + + Tables []Table `toml:"table"` + + Probe bool `toml:"probe"` + Period string `toml:"interval"` + Packets int64 `toml:"packets"` + Alias string `toml:"alias"` + // Name & Fields are the elements of a Table. + // Telegraf chokes if we try to embed a Table. So instead we have to embed the + // fields of a Table, and construct a Table during runtime. + Name string // deprecated in 1.14; use name_override + Fields []Field `toml:"field"` + + connectionCache []snmpConnection + initialized bool +} + +func (s *Snmp) init() error { + if s.initialized { + return nil + } + + s.connectionCache = make([]snmpConnection, len(s.Agents)) + + for i := range s.Tables { + if err := s.Tables[i].Init(); err != nil { + return fmt.Errorf("initializing table %s: %w", s.Tables[i].Name, err) + } + } + + for i := range s.Fields { + if err := s.Fields[i].init(); err != nil { + return fmt.Errorf("initializing field %s: %w", s.Fields[i].Name, err) + } + } + + if len(s.AgentHostTag) == 0 { + s.AgentHostTag = "agent_host" + } + + s.initialized = true + return nil +} + +// Table holds the configuration for a SNMP table. +type Table struct { + // Name will be the name of the measurement. + Name string + + // Which tags to inherit from the top-level config. + InheritTags []string + + // Adds each row's table index as a tag. + IndexAsTag bool + + // Fields is the tags and values to look up. + Fields []Field `toml:"field"` + + // OID for automatic field population. + // If provided, init() will populate Fields with all the table columns of the + // given OID. + Oid string + + initialized bool +} + +// Init() builds & initializes the nested fields. +func (t *Table) Init() error { + //makes sure oid or name is set in config file + //otherwise snmp will produce metrics with an empty name + if t.Oid == "" && t.Name == "" { + return fmt.Errorf("SNMP table in config file is not named. One or both of the oid and name settings must be set") + } + + if t.initialized { + return nil + } + + if err := t.initBuild(); err != nil { + return err + } + + // initialize all the nested fields + for i := range t.Fields { + if err := t.Fields[i].init(); err != nil { + return fmt.Errorf("initializing field %s: %w", t.Fields[i].Name, err) + } + } + + t.initialized = true + return nil +} + +// initBuild initializes the table if it has an OID configured. If so, the +// net-snmp tools will be used to look up the OID and auto-populate the table's +// fields. +func (t *Table) initBuild() error { + if t.Oid == "" { + return nil + } + + _, _, oidText, fields, err := snmpTable(t.Oid) + if err != nil { + return err + } + + if t.Name == "" { + t.Name = oidText + } + + knownOIDs := map[string]bool{} + for _, f := range t.Fields { + knownOIDs[f.Oid] = true + } + for _, f := range fields { + if !knownOIDs[f.Oid] { + t.Fields = append(t.Fields, f) + } + } + + return nil +} + +// Field holds the configuration for a Field to look up. +type Field struct { + // Name will be the name of the field. + Name string + // OID is prefix for this field. The plugin will perform a walk through all + // OIDs with this as their parent. For each value found, the plugin will strip + // off the OID prefix, and use the remainder as the index. For multiple fields + // to show up in the same row, they must share the same index. + Oid string + // OidIndexSuffix is the trailing sub-identifier on a table record OID that will be stripped off to get the record's index. + OidIndexSuffix string + // OidIndexLength specifies the length of the index in OID path segments. It can be used to remove sub-identifiers that vary in content or length. + OidIndexLength int + // IsTag controls whether this OID is output as a tag or a value. + IsTag bool + // Conversion controls any type conversion that is done on the value. + // "float"/"float(0)" will convert the value into a float. + // "float(X)" will convert the value into a float, and then move the decimal before Xth right-most digit. + // "int" will conver the value into an integer. + // "hwaddr" will convert a 6-byte string to a MAC address. + // "ipaddr" will convert the value to an IPv4 or IPv6 address. + Conversion string + // Translate tells if the value of the field should be snmptranslated + Translate bool + + initialized bool +} + +// init() converts OID names to numbers, and sets the .Name attribute if unset. +func (f *Field) init() error { + if f.initialized { + return nil + } + + // check if oid needs translation or name is not set + if strings.ContainsAny(f.Oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") || f.Name == "" { + _, oidNum, oidText, conversion, err := SnmpTranslate(f.Oid) + if err != nil { + return fmt.Errorf("translating: %w", err) + } + f.Oid = oidNum + if f.Name == "" { + f.Name = oidText + } + if f.Conversion == "" { + f.Conversion = conversion + } + //TODO use textual convention conversion from the MIB + } + + f.initialized = true + return nil +} + +// RTable is the resulting table built from a Table. +type RTable struct { + // Name is the name of the field, copied from Table.Name. + Name string + // Time is the time the table was built. + Time time.Time + // Rows are the rows that were found, one row for each table OID index found. + Rows []RTableRow +} + +// RTableRow is the resulting row containing all the OID values which shared +// the same index. +type RTableRow struct { + // Tags are all the Field values which had IsTag=true. + Tags map[string]string + // Fields are all the Field values which had IsTag=false. + Fields map[string]interface{} +} + +type walkError struct { + msg string + err error +} + +func (e *walkError) Error() string { + return e.msg +} + +func (e *walkError) Unwrap() error { + return e.err +} + +func init() { + inputs.Add("snmp_heartbeat", func() telegraf.Input { + return &Snmp{ + Name: "snmp_heartbeat", + ClientConfig: snmp.ClientConfig{ + Retries: 3, + MaxRepetitions: 10, + Timeout: config.Duration(5 * time.Second), + Version: 2, + Community: "public", + }, + Packets: 3, + Alias: "", + } + }) +} + +// SampleConfig returns the default configuration of the input. +func (s *Snmp) SampleConfig() string { + return sampleConfig +} + +// Description returns a one-sentence description on the input. +func (s *Snmp) Description() string { + return description +} + +// Gather retrieves all the configured fields and tables. +// Any error encountered does not halt the process. The errors are accumulated +// and returned at the end. +func (s *Snmp) Gather(acc telegraf.Accumulator) error { + + if err := s.init(); err != nil { + return err + } + + var wg sync.WaitGroup + for i, agent := range s.Agents { + wg.Add(1) + go func(i int, agent string) { + defer wg.Done() + gs, err := s.getConnection(i) + if err != nil { + acc.AddError(fmt.Errorf("agent %s: %w", agent, err)) + return + } + + // First is the top-level fields. We treat the fields as table prefixes with an empty index. + t := Table{ + Name: s.Name, + Fields: s.Fields, + } + topTags := map[string]string{} + if err := s.gatherTable(acc, gs, t, topTags, false); err != nil { + if s.Probe { + rt := RTable{ + Name: t.Name, + Time: time.Now(), //TODO record time at start + Rows: make([]RTableRow, 0, 1), + } + rtr := RTableRow{} + rtr.Tags = map[string]string{} + rtr.Fields = map[string]interface{}{} + if _, ok := rtr.Tags[s.AgentHostTag]; !ok { + rtr.Tags[s.AgentHostTag] = gs.Host() + } + rtr.Fields["target_state"] = "Down" + if strings.Contains(s.Period, "m") { + period_str := strings.Trim(s.Period, "m") + period, err := strconv.Atoi(period_str) + if err == nil { + rtr.Fields["period"] = period * 60 + } + } else { + period_str := strings.Trim(s.Period, "s") + rtr.Fields["period"], err = strconv.Atoi(period_str) + } + rtr.Fields["average_rtt"] = 0 + rtr.Fields["minimum_rtt"] = 0 + rtr.Fields["maximum_rtt"] = 0 + rtr.Fields["jitter"] = 0 + rtr.Fields["pkt_loss_pct"] = 100 + + rt.Rows = append(rt.Rows, rtr) + + acc.AddFields(rt.Name, rtr.Fields, rtr.Tags, rt.Time) + } else { + acc.AddError(fmt.Errorf("agent %s: %w", agent, err)) + } + } + + // Now is the real tables. + for _, t := range s.Tables { + if err := s.gatherTable(acc, gs, t, topTags, true); err != nil { + acc.AddError(fmt.Errorf("agent %s: gathering table %s: %w", agent, t.Name, err)) + } + } + }(i, agent) + } + wg.Wait() + + return nil +} + +func (s *Snmp) gatherTable(acc telegraf.Accumulator, gs snmpConnection, t Table, topTags map[string]string, walk bool) error { + var rt *RTable + var err error + var avg_rtt = int64(0) + var rtt = int64(0) + var max_rtt = int64(0) + var min_rtt = int64(0) + var jitter = int64(0) + var jitter_value = int64(0) + var pkt_loss_pct = float64(0) + var round_time []int64 + + if s.Probe { + if s.Packets < 3 { + //Min required packets is 3. + s.Packets = 3 + } + for snmp_call := int64(1); snmp_call <= s.Packets; snmp_call++ { + var out_rt *RTable + start := time.Now() + out_rt, err = t.Build(gs, walk) + time_taken := time.Since(start).Milliseconds() + if time_taken == 0 { + time_taken = 1 + } + if err != nil { + log.Printf("Unable to fetch info for %s %v", gs.Host(), err) + } else { + rt = out_rt + round_time = append(round_time, time_taken) + } + } + // Return if none of the packets go through. + if len(round_time) == 0 { + return err + } + max_rtt = round_time[0] + min_rtt = round_time[0] + for index, ele := range round_time { + if max_rtt < ele { + max_rtt = ele + } + if min_rtt > ele { + min_rtt = ele + } + rtt = rtt + ele + // Jitter is calculated by adding difference of consecutive rtt. + if index+1 < len(round_time) { + jitter_value = jitter_value + (ele - round_time[index+1]) + } + avg_rtt = rtt / s.Packets + jitter = jitter_value / (s.Packets - 1) + if jitter < 0 { + jitter = -jitter + } + } + pkt_loss_pct = ((float64(s.Packets) - float64(len(round_time))) / float64(s.Packets)) * 100 + } else { + rt, err = t.Build(gs, walk) + if err != nil { + return err + } + } + + for _, tr := range rt.Rows { + if !walk { + // top-level table. Add tags to topTags. + for k, v := range tr.Tags { + topTags[k] = v + } + } else { + // real table. Inherit any specified tags. + for _, k := range t.InheritTags { + if v, ok := topTags[k]; ok { + tr.Tags[k] = v + } + } + } + if s.Probe { + tr.Fields["target_state"] = "Up" + if strings.Contains(s.Period, "m") { + period, err := strconv.Atoi(strings.Trim(s.Period, "m")) + if err == nil { + tr.Fields["period"] = period * 60 + } + } else { + tr.Fields["period"], err = strconv.Atoi(strings.Trim(s.Period, "s")) + } + + tr.Fields["average_rtt"] = avg_rtt + tr.Fields["minimum_rtt"] = min_rtt + tr.Fields["maximum_rtt"] = max_rtt + tr.Fields["jitter"] = jitter + tr.Fields["pkt_loss_pct"] = pkt_loss_pct + } + if _, ok := tr.Tags[s.AgentHostTag]; !ok { + tr.Tags[s.AgentHostTag] = gs.Host() + } + acc.AddFields(rt.Name, tr.Fields, tr.Tags, rt.Time) + } + + return nil +} + +// Build retrieves all the fields specified in the table and constructs the RTable. +func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { + rows := map[string]RTableRow{} + tagCount := 0 + for _, f := range t.Fields { + if f.IsTag { + tagCount++ + } + + if len(f.Oid) == 0 { + return nil, fmt.Errorf("cannot have empty OID on field %s", f.Name) + } + var oid string + if f.Oid[0] == '.' { + oid = f.Oid + } else { + // make sure OID has "." because the BulkWalkAll results do, and the prefix needs to match + oid = "." + f.Oid + } + + // ifv contains a mapping of table OID index to field value + ifv := map[string]interface{}{} + + if !walk { + + // This is used when fetching non-table fields. Fields configured a the top + // scope of the plugin. + // We fetch the fields directly, and add them to ifv as if the index were an + // empty string. This results in all the non-table fields sharing the same + // index, and being added on the same row. + if pkt, err := gs.Get([]string{oid}); err != nil { + if errors.Is(err, gosnmp.ErrUnknownSecurityLevel) { + return nil, fmt.Errorf("unknown security level (sec_level)") + } else if errors.Is(err, gosnmp.ErrUnknownUsername) { + return nil, fmt.Errorf("unknown username (sec_name)") + } else if errors.Is(err, gosnmp.ErrWrongDigest) { + return nil, fmt.Errorf("wrong digest (auth_protocol, auth_password)") + } else if errors.Is(err, gosnmp.ErrDecryption) { + return nil, fmt.Errorf("decryption error (priv_protocol, priv_password)") + } else { + return nil, fmt.Errorf("performing get on field %s: %w", f.Name, err) + } + } else if pkt != nil && len(pkt.Variables) > 0 && pkt.Variables[0].Type != gosnmp.NoSuchObject && pkt.Variables[0].Type != gosnmp.NoSuchInstance { + ent := pkt.Variables[0] + fv, err := fieldConvert(f.Conversion, ent.Value) + if err != nil { + return nil, fmt.Errorf("converting %q (OID %s) for field %s: %w", ent.Value, ent.Name, f.Name, err) + } + ifv[""] = fv + } + } else { + err := gs.Walk(oid, func(ent gosnmp.SnmpPDU) error { + if len(ent.Name) <= len(oid) || ent.Name[:len(oid)+1] != oid+"." { + return &walkError{} // break the walk + } + + idx := ent.Name[len(oid):] + if f.OidIndexSuffix != "" { + if !strings.HasSuffix(idx, f.OidIndexSuffix) { + // this entry doesn't match our OidIndexSuffix. skip it + return nil + } + idx = idx[:len(idx)-len(f.OidIndexSuffix)] + } + if f.OidIndexLength != 0 { + i := f.OidIndexLength + 1 // leading separator + idx = strings.Map(func(r rune) rune { + if r == '.' { + i-- + } + if i < 1 { + return -1 + } + return r + }, idx) + } + + // snmptranslate table field value here + if f.Translate { + if entOid, ok := ent.Value.(string); ok { + _, _, oidText, _, err := SnmpTranslate(entOid) + if err == nil { + // If no error translating, the original value for ent.Value should be replaced + ent.Value = oidText + } + } + } + + fv, err := fieldConvert(f.Conversion, ent.Value) + if err != nil { + return &walkError{ + msg: fmt.Sprintf("converting %q (OID %s) for field %s", ent.Value, ent.Name, f.Name), + err: err, + } + } + ifv[idx] = fv + return nil + }) + if err != nil { + // Our callback always wraps errors in a walkError. + // If this error isn't a walkError, we know it's not + // from the callback + if _, ok := err.(*walkError); !ok { + return nil, fmt.Errorf("performing bulk walk for field %s: %w", f.Name, err) + } + } + } + + for idx, v := range ifv { + rtr, ok := rows[idx] + if !ok { + rtr = RTableRow{} + rtr.Tags = map[string]string{} + rtr.Fields = map[string]interface{}{} + rows[idx] = rtr + } + if t.IndexAsTag && idx != "" { + if idx[0] == '.' { + idx = idx[1:] + } + rtr.Tags["index"] = idx + } + // don't add an empty string + if vs, ok := v.(string); !ok || vs != "" { + if f.IsTag { + if ok { + rtr.Tags[f.Name] = vs + } else { + rtr.Tags[f.Name] = fmt.Sprintf("%v", v) + } + } else { + rtr.Fields[f.Name] = v + } + } + } + } + + rt := RTable{ + Name: t.Name, + Time: time.Now(), //TODO record time at start + Rows: make([]RTableRow, 0, len(rows)), + } + for _, r := range rows { + rt.Rows = append(rt.Rows, r) + } + return &rt, nil +} + +// snmpConnection is an interface which wraps a *gosnmp.GoSNMP object. +// We interact through an interface so we can mock it out in tests. +type snmpConnection interface { + Host() string + //BulkWalkAll(string) ([]gosnmp.SnmpPDU, error) + Walk(string, gosnmp.WalkFunc) error + Get(oids []string) (*gosnmp.SnmpPacket, error) +} + +// getConnection creates a snmpConnection (*gosnmp.GoSNMP) object and caches the +// result using `agentIndex` as the cache key. This is done to allow multiple +// connections to a single address. It is an error to use a connection in +// more than one goroutine. +func (s *Snmp) getConnection(idx int) (snmpConnection, error) { + if gs := s.connectionCache[idx]; gs != nil { + return gs, nil + } + + agent := s.Agents[idx] + + var err error + var gs snmp.GosnmpWrapper + gs, err = snmp.NewWrapper(s.ClientConfig) + if err != nil { + return nil, err + } + + err = gs.SetAgent(agent) + if err != nil { + return nil, err + } + + s.connectionCache[idx] = gs + + if err := gs.Connect(); err != nil { + return nil, fmt.Errorf("setting up connection: %w", err) + } + + return gs, nil +} + +// fieldConvert converts from any type according to the conv specification +func fieldConvert(conv string, v interface{}) (interface{}, error) { + if conv == "" { + if bs, ok := v.([]byte); ok { + return string(bs), nil + } + return v, nil + } + + var d int + if _, err := fmt.Sscanf(conv, "float(%d)", &d); err == nil || conv == "float" { + switch vt := v.(type) { + case float32: + v = float64(vt) / math.Pow10(d) + case float64: + v = float64(vt) / math.Pow10(d) + case int: + v = float64(vt) / math.Pow10(d) + case int8: + v = float64(vt) / math.Pow10(d) + case int16: + v = float64(vt) / math.Pow10(d) + case int32: + v = float64(vt) / math.Pow10(d) + case int64: + v = float64(vt) / math.Pow10(d) + case uint: + v = float64(vt) / math.Pow10(d) + case uint8: + v = float64(vt) / math.Pow10(d) + case uint16: + v = float64(vt) / math.Pow10(d) + case uint32: + v = float64(vt) / math.Pow10(d) + case uint64: + v = float64(vt) / math.Pow10(d) + case []byte: + vf, _ := strconv.ParseFloat(string(vt), 64) + v = vf / math.Pow10(d) + case string: + vf, _ := strconv.ParseFloat(vt, 64) + v = vf / math.Pow10(d) + } + return v, nil + } + + if conv == "int" { + switch vt := v.(type) { + case float32: + v = int64(vt) + case float64: + v = int64(vt) + case int: + v = int64(vt) + case int8: + v = int64(vt) + case int16: + v = int64(vt) + case int32: + v = int64(vt) + case int64: + v = vt + case uint: + v = int64(vt) + case uint8: + v = int64(vt) + case uint16: + v = int64(vt) + case uint32: + v = int64(vt) + case uint64: + v = int64(vt) + case []byte: + v, _ = strconv.ParseInt(string(vt), 10, 64) + case string: + v, _ = strconv.ParseInt(vt, 10, 64) + } + return v, nil + } + + if conv == "hwaddr" { + switch vt := v.(type) { + case string: + v = net.HardwareAddr(vt).String() + case []byte: + v = net.HardwareAddr(vt).String() + default: + return nil, fmt.Errorf("invalid type (%T) for hwaddr conversion", v) + } + return v, nil + } + + split := strings.Split(conv, ":") + if split[0] == "hextoint" && len(split) == 3 { + endian := split[1] + bit := split[2] + + bv, ok := v.([]byte) + if !ok { + return v, nil + } + + if endian == "LittleEndian" { + switch bit { + case "uint64": + v = binary.LittleEndian.Uint64(bv) + case "uint32": + v = binary.LittleEndian.Uint32(bv) + case "uint16": + v = binary.LittleEndian.Uint16(bv) + default: + return nil, fmt.Errorf("invalid bit value (%s) for hex to int conversion", bit) + } + } else if endian == "BigEndian" { + switch bit { + case "uint64": + v = binary.BigEndian.Uint64(bv) + case "uint32": + v = binary.BigEndian.Uint32(bv) + case "uint16": + v = binary.BigEndian.Uint16(bv) + default: + return nil, fmt.Errorf("invalid bit value (%s) for hex to int conversion", bit) + } + } else { + return nil, fmt.Errorf("invalid Endian value (%s) for hex to int conversion", endian) + } + + return v, nil + } + + if conv == "ipaddr" { + var ipbs []byte + + switch vt := v.(type) { + case string: + ipbs = []byte(vt) + case []byte: + ipbs = vt + default: + return nil, fmt.Errorf("invalid type (%T) for ipaddr conversion", v) + } + + switch len(ipbs) { + case 4, 16: + v = net.IP(ipbs).String() + default: + return nil, fmt.Errorf("invalid length (%d) for ipaddr conversion", len(ipbs)) + } + + return v, nil + } + + return nil, fmt.Errorf("invalid conversion type '%s'", conv) +} + +type snmpTableCache struct { + mibName string + oidNum string + oidText string + fields []Field + err error +} + +var snmpTableCaches map[string]snmpTableCache +var snmpTableCachesLock sync.Mutex + +// snmpTable resolves the given OID as a table, providing information about the +// table and fields within. +func snmpTable(oid string) (mibName string, oidNum string, oidText string, fields []Field, err error) { + snmpTableCachesLock.Lock() + if snmpTableCaches == nil { + snmpTableCaches = map[string]snmpTableCache{} + } + + var stc snmpTableCache + var ok bool + if stc, ok = snmpTableCaches[oid]; !ok { + stc.mibName, stc.oidNum, stc.oidText, stc.fields, stc.err = snmpTableCall(oid) + snmpTableCaches[oid] = stc + } + + snmpTableCachesLock.Unlock() + return stc.mibName, stc.oidNum, stc.oidText, stc.fields, stc.err +} + +func snmpTableCall(oid string) (mibName string, oidNum string, oidText string, fields []Field, err error) { + mibName, oidNum, oidText, _, err = SnmpTranslate(oid) + if err != nil { + return "", "", "", nil, fmt.Errorf("translating: %w", err) + } + + mibPrefix := mibName + "::" + oidFullName := mibPrefix + oidText + + // first attempt to get the table's tags + tagOids := map[string]struct{}{} + // We have to guess that the "entry" oid is `oid+".1"`. snmptable and snmptranslate don't seem to have a way to provide the info. + if out, err := execCmd("snmptranslate", "-Td", oidFullName+".1"); err == nil { + scanner := bufio.NewScanner(bytes.NewBuffer(out)) + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, " INDEX") { + continue + } + + i := strings.Index(line, "{ ") + if i == -1 { // parse error + continue + } + line = line[i+2:] + i = strings.Index(line, " }") + if i == -1 { // parse error + continue + } + line = line[:i] + for _, col := range strings.Split(line, ", ") { + tagOids[mibPrefix+col] = struct{}{} + } + } + } + + // this won't actually try to run a query. The `-Ch` will just cause it to dump headers. + out, err := execCmd("snmptable", "-Ch", "-Cl", "-c", "public", "127.0.0.1", oidFullName) + if err != nil { + return "", "", "", nil, fmt.Errorf("getting table columns: %w", err) + } + scanner := bufio.NewScanner(bytes.NewBuffer(out)) + scanner.Scan() + cols := scanner.Text() + if len(cols) == 0 { + return "", "", "", nil, fmt.Errorf("could not find any columns in table") + } + for _, col := range strings.Split(cols, " ") { + if len(col) == 0 { + continue + } + _, isTag := tagOids[mibPrefix+col] + fields = append(fields, Field{Name: col, Oid: mibPrefix + col, IsTag: isTag}) + } + + return mibName, oidNum, oidText, fields, err +} + +type snmpTranslateCache struct { + mibName string + oidNum string + oidText string + conversion string + err error +} + +var snmpTranslateCachesLock sync.Mutex +var snmpTranslateCaches map[string]snmpTranslateCache + +// snmpTranslate resolves the given OID. +func SnmpTranslate(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) { + snmpTranslateCachesLock.Lock() + if snmpTranslateCaches == nil { + snmpTranslateCaches = map[string]snmpTranslateCache{} + } + + var stc snmpTranslateCache + var ok bool + if stc, ok = snmpTranslateCaches[oid]; !ok { + // This will result in only one call to snmptranslate running at a time. + // We could speed it up by putting a lock in snmpTranslateCache and then + // returning it immediately, and multiple callers would then release the + // snmpTranslateCachesLock and instead wait on the individual + // snmpTranslation.Lock to release. But I don't know that the extra complexity + // is worth it. Especially when it would slam the system pretty hard if lots + // of lookups are being performed. + + stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err = snmpTranslateCall(oid) + snmpTranslateCaches[oid] = stc + } + + snmpTranslateCachesLock.Unlock() + + return stc.mibName, stc.oidNum, stc.oidText, stc.conversion, stc.err +} + +func snmpTranslateCall(oid string) (mibName string, oidNum string, oidText string, conversion string, err error) { + var out []byte + if strings.ContainsAny(oid, ":abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") { + out, err = execCmd("snmptranslate", "-Td", "-Ob", oid) + } else { + out, err = execCmd("snmptranslate", "-Td", "-Ob", "-m", "all", oid) + if err, ok := err.(*exec.Error); ok && err.Err == exec.ErrNotFound { + // Silently discard error if snmptranslate not found and we have a numeric OID. + // Meaning we can get by without the lookup. + return "", oid, oid, "", nil + } + } + if err != nil { + return "", "", "", "", err + } + + scanner := bufio.NewScanner(bytes.NewBuffer(out)) + ok := scanner.Scan() + if !ok && scanner.Err() != nil { + return "", "", "", "", fmt.Errorf("getting OID text: %w", scanner.Err()) + } + + oidText = scanner.Text() + + i := strings.Index(oidText, "::") + if i == -1 { + // was not found in MIB. + if bytes.Contains(out, []byte("[TRUNCATED]")) { + return "", oid, oid, "", nil + } + // not truncated, but not fully found. We still need to parse out numeric OID, so keep going + oidText = oid + } else { + mibName = oidText[:i] + oidText = oidText[i+2:] + } + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasPrefix(line, " -- TEXTUAL CONVENTION ") { + tc := strings.TrimPrefix(line, " -- TEXTUAL CONVENTION ") + switch tc { + case "MacAddress", "PhysAddress": + conversion = "hwaddr" + case "InetAddressIPv4", "InetAddressIPv6", "InetAddress", "IPSIpAddress": + conversion = "ipaddr" + } + } else if strings.HasPrefix(line, "::= { ") { + objs := strings.TrimPrefix(line, "::= { ") + objs = strings.TrimSuffix(objs, " }") + + for _, obj := range strings.Split(objs, " ") { + if len(obj) == 0 { + continue + } + if i := strings.Index(obj, "("); i != -1 { + obj = obj[i+1:] + oidNum += "." + obj[:strings.Index(obj, ")")] + } else { + oidNum += "." + obj + } + } + break + } + } + + return mibName, oidNum, oidText, conversion, nil +} diff --git a/plugins/inputs/snmp_trap/snmp_trap.go b/plugins/inputs/snmp_trap/snmp_trap.go index 9fffd8968d593..0d5812586df94 100644 --- a/plugins/inputs/snmp_trap/snmp_trap.go +++ b/plugins/inputs/snmp_trap/snmp_trap.go @@ -1,35 +1,39 @@ package snmp_trap import ( + "bufio" + "bytes" "fmt" "net" - "os" - "path/filepath" + "os/exec" + "regexp" "strconv" "strings" + "sync" "time" "github.com/influxdata/telegraf" "github.com/influxdata/telegraf/config" + "github.com/influxdata/telegraf/internal" "github.com/influxdata/telegraf/plugins/inputs" - "github.com/sleepinggenius2/gosmi" - "github.com/sleepinggenius2/gosmi/types" "github.com/gosnmp/gosnmp" ) var defaultTimeout = config.Duration(time.Second * 5) +type execer func(config.Duration, string, ...string) ([]byte, error) + type mibEntry struct { mibName string oidText string + enumMap map[int]string } type SnmpTrap struct { ServiceAddress string `toml:"service_address"` Timeout config.Duration `toml:"timeout"` Version string `toml:"version"` - Path []string `toml:"path"` // Settings for version 3 // Values: "noAuthNoPriv", "authNoPriv", "authPriv" @@ -42,15 +46,19 @@ type SnmpTrap struct { PrivProtocol string `toml:"priv_protocol"` PrivPassword string `toml:"priv_password"` - acc telegraf.Accumulator - listener *gosnmp.TrapListener - timeFunc func() time.Time - lookupFunc func(string) (mibEntry, error) - errCh chan error + acc telegraf.Accumulator + listener *gosnmp.TrapListener + timeFunc func() time.Time + errCh chan error makeHandlerWrapper func(gosnmp.TrapHandlerFunc) gosnmp.TrapHandlerFunc Log telegraf.Logger `toml:"-"` + + cacheLock sync.Mutex + cache map[string]mibEntry + + execCmd execer } var sampleConfig = ` @@ -62,10 +70,6 @@ var sampleConfig = ` ## 1024. See README.md for details ## # service_address = "udp://:162" - ## - ## Path to mib files - # path = ["/usr/share/snmp/mibs"] - ## ## Timeout running snmptranslate command # timeout = "5s" ## Snmp version, defaults to 2c @@ -102,7 +106,6 @@ func init() { inputs.Add("snmp_trap", func() telegraf.Input { return &SnmpTrap{ timeFunc: time.Now, - lookupFunc: lookup, ServiceAddress: "udp://:162", Timeout: defaultTimeout, Version: "2c", @@ -110,50 +113,20 @@ func init() { }) } -func (s *SnmpTrap) Init() error { - // must init, append path for each directory, load module for every file - // or gosmi will fail without saying why - gosmi.Init() - err := s.getMibsPath() +func realExecCmd(timeout config.Duration, arg0 string, args ...string) ([]byte, error) { + cmd := exec.Command(arg0, args...) + var out bytes.Buffer + cmd.Stdout = &out + err := internal.RunTimeout(cmd, time.Duration(timeout)) if err != nil { - s.Log.Errorf("Could not get path %v", err) + return nil, err } - return nil + return out.Bytes(), nil } -func (s *SnmpTrap) getMibsPath() error { - var folders []string - for _, mibPath := range s.Path { - gosmi.AppendPath(mibPath) - folders = append(folders, mibPath) - err := filepath.Walk(mibPath, func(path string, info os.FileInfo, err error) error { - if info.Mode()&os.ModeSymlink != 0 { - s, _ := os.Readlink(path) - folders = append(folders, s) - } - return nil - }) - if err != nil { - s.Log.Errorf("Filepath could not be walked %v", err) - } - for _, folder := range folders { - err := filepath.Walk(folder, func(path string, info os.FileInfo, err error) error { - if info.IsDir() { - gosmi.AppendPath(path) - } else if info.Mode()&os.ModeSymlink == 0 { - _, err := gosmi.LoadModule(info.Name()) - if err != nil { - s.Log.Errorf("Module could not be loaded %v", err) - } - } - return nil - }) - if err != nil { - s.Log.Errorf("Filepath could not be walked %v", err) - } - } - folders = []string{} - } +func (s *SnmpTrap) Init() error { + s.cache = map[string]mibEntry{} + s.execCmd = realExecCmd return nil } @@ -277,7 +250,6 @@ func (s *SnmpTrap) Start(acc telegraf.Accumulator) error { func (s *SnmpTrap) Stop() { s.listener.Close() - defer gosmi.Exit() err := <-s.errCh if nil != err { s.Log.Errorf("Error stopping trap listener %v", err) @@ -298,7 +270,6 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { tags["version"] = packet.Version.String() tags["source"] = addr.IP.String() - if packet.Version == gosnmp.Version1 { // Follow the procedure described in RFC 2576 3.1 to // translate a v1 trap to v2. @@ -311,7 +282,7 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { } if trapOid != "" { - e, err := s.lookupFunc(trapOid) + e, err := s.lookup(trapOid) if err != nil { s.Log.Errorf("Error resolving V1 OID, oid=%s, source=%s: %v", trapOid, tags["source"], err) return @@ -339,6 +310,14 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { // only handles textual convention for ip and mac // addresses + e, err := s.lookup(v.Name) + if nil != err { + s.Log.Errorf("Error resolving OID oid=%s, source=%s: %v", v.Name, tags["source"], err) + return + } + + name := e.oidText + switch v.Type { case gosnmp.ObjectIdentifier: val, ok := v.Value.(string) @@ -349,7 +328,7 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { var e mibEntry var err error - e, err = s.lookupFunc(val) + e, err = s.lookup(val) if nil != err { s.Log.Errorf("Error resolving value OID, oid=%s, source=%s: %v", val, tags["source"], err) return @@ -363,18 +342,16 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { setTrapOid(tags, val, e) continue } + + case gosnmp.Integer: + if val, ok := e.enumMap[v.Value.(int)]; ok { + value = val + } + default: value = v.Value } - e, err := s.lookupFunc(v.Name) - if nil != err { - s.Log.Errorf("Error resolving OID oid=%s, source=%s: %v", v.Name, tags["source"], err) - return - } - - name := e.oidText - fields[name] = value } @@ -396,16 +373,50 @@ func makeTrapHandler(s *SnmpTrap) gosnmp.TrapHandlerFunc { } } -func lookup(oid string) (e mibEntry, err error) { - var node gosmi.SmiNode - node, err = gosmi.GetNodeByOID(types.OidMustFromString(oid)) +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 snmptranslate + 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) - // ensure modules are loaded or node will be empty (might not error) if err != nil { return e, err } - e.oidText = node.RenderQualified() + scanner := bufio.NewScanner(bytes.NewBuffer(out)) + + // This reads first line + 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 { @@ -413,5 +424,35 @@ func lookup(oid string) (e mibEntry, err error) { } e.mibName = e.oidText[:i] e.oidText = e.oidText[i+2:] + + // read other lines here + e.enumMap = make(map[int]string) + syntaxRE := regexp.MustCompile(`\{(.*?)\}`) + enumComponentRE := regexp.MustCompile(`\((.*?)\)`) + for scanner.Scan() { + if err = scanner.Err(); err != nil { + return e, err + } + trapLine := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(trapLine, "SYNTAX") { + enumStringMatches := syntaxRE.FindStringSubmatch(trapLine) + if len(enumStringMatches) > 0 { + enumString := strings.TrimLeft(enumStringMatches[0], "{") + enumStringTrimmed := strings.TrimRight(enumString, "}") + enumStringList := strings.Split(enumStringTrimmed, ",") + for _, s := range enumStringList { + enumComponentString := strings.TrimSpace(s) + enumComponent := enumComponentRE.FindStringSubmatch(enumComponentString) + intVal, err := strconv.Atoi(enumComponent[1]) + if err != nil { + // handle error + return e, err + } + e.enumMap[intVal] = enumComponentString + } + break + } + } + } return e, nil }