diff --git a/CHANGELOG.md b/CHANGELOG.md index e3a7ed92..27cf921a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# v0.0.45 + +* add: (snmp) `timestamp` conversion for OIDs returning date/time strings (requires `timestamp_layout` to be set) [CIRC-8420] + # v0.0.44 * upd: go-trapmetrrics v0.0.8 diff --git a/plugins/inputs/snmp/README.md b/plugins/inputs/snmp/README.md index 772243e3..98a53a20 100644 --- a/plugins/inputs/snmp/README.md +++ b/plugins/inputs/snmp/README.md @@ -135,23 +135,31 @@ option operate similar to the `snmpget` utility. # 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. - ## string: Force convert a byte slice to a string (nonprintable characters will be converted to '_'). - ## regexp: Use a regular expression to extract a value from the input. + ## 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. + ## string: Force convert a byte slice to a string (nonprintable characters will be converted to '_'). + ## regexp: Use a regular expression to extract a value from the input. + ## timestamp: Parse the input as a timestamp and convert it to a unix epoch in UTC. # conversion = "" ## Regular expression used to extract value from input. ## Must contain a pattern named 'value' e.g. (?Pre). # regexp = "" ## Type of the value to be extracted from the input. (float|int|uint|text) # regexp_type = "" + ## Timestamp laytout - format of the string to be parsed as a timestamp. + ## e.g. + ## timestamp from oid timestamp_layout + ## "07/13/2025" "01/02/2006" + ## "03/03/2022 23:49:22" "01/02/2006 15:04:05" + ## See: https://pkg.go.dev/time#pkg-constants for more example layouts + # timestamp_layout = "" ``` #### Table diff --git a/plugins/inputs/snmp/snmp.go b/plugins/inputs/snmp/snmp.go index 1e5aad60..745ddb3e 100644 --- a/plugins/inputs/snmp/snmp.go +++ b/plugins/inputs/snmp/snmp.go @@ -279,12 +279,15 @@ type Field struct { // otherwise it will encoded as hex // if it is not a byte slice, it will be returned as-is // "regexp" Use a regular expression to extract a value from the input. + // "timestamp" string treated as a timestamp and parsed into unix epoch based on timestamp_layout pattern Conversion string // Regular expression used to extract value from input. // Must contain a pattern named 'value' e.g. (?Pre). Regexp string // Type of the value to be extracted from the input. (float|int|uint|text) RegexpType string + // Timestamp layout + TimestampLayout 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. @@ -598,7 +601,7 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { return nil, fmt.Errorf("performing get on field %s (oid:%s): %w", f.Name, oid, 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, f.rx, f.RegexpType, ent) + fv, err := fieldConvert(f, ent) if err != nil { return nil, fmt.Errorf("converting %q (OID %s) for field %s: %w", ent.Value, ent.Name, f.Name, err) } @@ -643,7 +646,7 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { } if f.TextMetric && f.Conversion == "lookup" { - fv, err := fieldConvert("int", nil, "", ent) + fv, err := fieldConvert(Field{Conversion: "int"}, ent) if err != nil { return &walkError{ msg: fmt.Sprintf("converting %q (OID %s) for field %s", ent.Value, ent.Name, f.Name), @@ -652,7 +655,7 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { } ifv[idx] = fv - fv, err = fieldConvert(f.Conversion, f.rx, f.RegexpType, ent) + fv, err = fieldConvert(f, ent) if err != nil { return &walkError{ msg: fmt.Sprintf("converting %q (OID %s) for field %s", ent.Value, ent.Name, f.Name), @@ -662,7 +665,7 @@ func (t Table) Build(gs snmpConnection, walk bool) (*RTable, error) { ifv[idx+"_desc"] = fv } else { - fv, err := fieldConvert(f.Conversion, f.rx, f.RegexpType, ent) + fv, err := fieldConvert(f, ent) if err != nil { return &walkError{ msg: fmt.Sprintf("converting %q (OID %s) for field %s", ent.Value, ent.Name, f.Name), @@ -799,10 +802,13 @@ func (s *Snmp) getConnection(idx int) (snmpConnection, error) { // "" will convert a byte slice into a string (if all runes are printable, otherwise it will return a hex string) // if the value is not a byte slice, it is returned as-is // "regex" will extract a value from the input -func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpPDU) (interface{}, error) { +// "timestamp" convert string to unix epoch based on timestamp_layout +// see: https://pkg.go.dev/time#pkg-constants and https://pkg.go.dev/time#Parse +func fieldConvert(f Field, sv gosnmp.SnmpPDU) (interface{}, error) { + v := sv.Value - if conv == "" { + if f.Conversion == "" { if bs, ok := v.([]byte); ok { for _, b := range bs { if !unicode.IsPrint(rune(b)) { @@ -814,7 +820,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - if conv == "string" { + if f.Conversion == "string" { if bs, ok := v.([]byte); ok { str := strings.Map(func(r rune) rune { if unicode.IsPrint(r) { @@ -827,8 +833,8 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - if conv == "regexp" { - if rx == nil { + if f.Conversion == "regexp" { + if f.rx == nil { return "", fmt.Errorf("regexp didn't compile, check log for errors") } @@ -842,14 +848,14 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return "", fmt.Errorf("unknown input type (%T) for regex", v) } - if rx.MatchString(input) { - valIdx := rx.SubexpIndex("value") - matches := rx.FindStringSubmatch(input) + if f.rx.MatchString(input) { + valIdx := f.rx.SubexpIndex("value") + matches := f.rx.FindStringSubmatch(input) if len(matches) < valIdx { - return "", fmt.Errorf("no match found for regex %q %q", input, rx.String()) + return "", fmt.Errorf("no match found for regex %q %q", input, f.rx.String()) } match := matches[valIdx] - switch rxtype { + switch f.RegexpType { case "float": fv, err := strconv.ParseFloat(match, 64) return fv, err @@ -864,10 +870,28 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP } } - return "", fmt.Errorf("no value found for regex %q %q", input, rx.String()) + return "", fmt.Errorf("no value found for regex %q %q", input, f.rx.String()) + } + + if f.Conversion == "timestamp" { + if f.TimestampLayout == "" { + return "", fmt.Errorf("no timestamp_layout provided for 'timestamp' conversion") + } + + vs, isString := v.(string) + if !isString { + return "", fmt.Errorf("could not convert to string %v", v) + } + + ts, err := time.Parse(f.TimestampLayout, vs) + if err != nil { + return "", fmt.Errorf("converting (%s): %w", vs, err) + } + + return ts.UTC().Unix(), nil } - if conv == "lookup" { + if f.Conversion == "lookup" { stc := TranslateOID(sv.Name) key := fmt.Sprintf("%d", v) if stc.valMap != nil { @@ -880,7 +904,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP } var d int - if _, err := fmt.Sscanf(conv, "float(%d)", &d); err == nil || conv == "float" { + if _, err := fmt.Sscanf(f.Conversion, "float(%d)", &d); err == nil || f.Conversion == "float" { switch vt := v.(type) { case float32: v = float64(vt) / math.Pow10(d) @@ -916,7 +940,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - if conv == "int" { + if f.Conversion == "int" { switch vt := v.(type) { case float32: v = int64(vt) @@ -950,7 +974,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - if conv == "hwaddr" { + if f.Conversion == "hwaddr" { switch vt := v.(type) { case string: v = net.HardwareAddr(vt).String() @@ -962,7 +986,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - if conv == "ipaddr" { + if f.Conversion == "ipaddr" { var ipbs []byte switch vt := v.(type) { @@ -984,7 +1008,7 @@ func fieldConvert(conv string, rx *regexp.Regexp, rxtype string, sv gosnmp.SnmpP return v, nil } - return nil, fmt.Errorf("invalid conversion type '%s'", conv) + return nil, fmt.Errorf("invalid conversion type '%s'", f.Conversion) } type snmpTableCache struct { diff --git a/plugins/inputs/snmp/snmp_test.go b/plugins/inputs/snmp/snmp_test.go index 4699eba0..6c333b88 100644 --- a/plugins/inputs/snmp/snmp_test.go +++ b/plugins/inputs/snmp/snmp_test.go @@ -708,8 +708,8 @@ func TestFieldConvert(t *testing.T) { input gosnmp.SnmpPDU }{ {input: gosnmp.SnmpPDU{Value: []byte("foo")}, conv: "", expected: string("foo")}, - {input: gosnmp.SnmpPDU{Value: []byte("foo\u00a0")}, conv: "", expected: string("666f6fc2a0")}, - {input: gosnmp.SnmpPDU{Value: []byte("foo\u00a0")}, conv: "string", expected: string("foo_")}, + {input: gosnmp.SnmpPDU{Value: []byte("foo\x07")}, conv: "", expected: string("666f6f07")}, + {input: gosnmp.SnmpPDU{Value: []byte("foo\x07")}, conv: "string", expected: string("foo_")}, {input: gosnmp.SnmpPDU{Value: "0.123"}, conv: "float", expected: float64(0.123)}, {input: gosnmp.SnmpPDU{Value: []byte("0.123")}, conv: "float", expected: float64(0.123)}, {input: gosnmp.SnmpPDU{Value: float32(0.123)}, conv: "float", expected: float64(float32(0.123))}, @@ -750,7 +750,7 @@ func TestFieldConvert(t *testing.T) { } for _, tc := range testTable { - act, err := fieldConvert(tc.conv, nil, "", tc.input) + act, err := fieldConvert(Field{Conversion: tc.conv}, tc.input) if !assert.NoError(t, err, "input=%T(%v) conv=%s expected=%T(%v)", tc.input, tc.input, tc.conv, tc.expected, tc.expected) { continue } @@ -789,13 +789,71 @@ func TestFieldConvert(t *testing.T) { }, } for _, tst := range rxTests { - v, err := fieldConvert("regexp", tst.rx, tst.rt, tst.input) + v, err := fieldConvert(Field{ + Conversion: "regexp", + rx: tst.rx, + RegexpType: tst.rt, + }, tst.input) if !assert.NoError(t, err, "input=%T(%v) rxtype=%s expected=%T(%v)", tst.input, tst.input, tst.rt, tst.expected, tst.expected) { continue } assert.EqualValues(t, tst.expected, v, "input=%T(%v) rxtype=%s expected=%T(%v)", tst.input, tst.input, tst.rt, tst.expected, tst.expected) } + timestampTests := []struct { + expected interface{} + layout string + input gosnmp.SnmpPDU + wantErr bool + }{ + { + input: gosnmp.SnmpPDU{Value: "07/13/2025"}, // CIRC-8420 + layout: "01/02/2006", + expected: uint64(1752364800), + wantErr: false, + }, + { + input: gosnmp.SnmpPDU{Value: "03/03/2022 23:49:22"}, // CIRC-8420 + layout: "01/02/2006 15:04:05", + expected: uint64(1646351362), + wantErr: false, + }, + { + input: gosnmp.SnmpPDU{Value: "03 Mar 22 23:49 MST"}, + layout: "02 Jan 06 15:04 MST", // RFC822 + expected: uint64(1646351340), + wantErr: false, + }, + { + input: gosnmp.SnmpPDU{Value: "03 Mar 22 23:49 -0700"}, + layout: "02 Jan 06 15:04 -0700", // RFC822Z + expected: uint64(1646376540), + wantErr: false, + }, + { + input: gosnmp.SnmpPDU{Value: "2022-03-03T23:49:22Z05:00"}, + layout: "2006-01-02T15:04:05Z05:00", // RFC339 + expected: uint64(1646351345), + wantErr: false, + }, + { + input: gosnmp.SnmpPDU{Value: "Thu Mar 3 23:48:22 2022"}, + layout: "Mon Jan _2 15:04:05 2006", // ANSIC + expected: uint64(1646351302), + wantErr: false, + }, + } + for _, tst := range timestampTests { + v, err := fieldConvert(Field{ + Conversion: "timestamp", + TimestampLayout: tst.layout, + }, tst.input) + if !assert.NoError(t, err, "input=%T(%v) layout=%s expected=%T(%v)", tst.input, tst.input, tst.layout, tst.expected, tst.expected) { + continue + } + assert.EqualValues(t, tst.expected, v, "input=%T(%v) layout=%s expected=%T(%v)", tst.input, tst.input, tst.layout, tst.expected, tst.expected) + } + } func TestSnmpTranslateCache_miss(t *testing.T) {