From d758008c1ec5ea57103f60c4f6944feef97d4df2 Mon Sep 17 00:00:00 2001 From: Nathan Ferch Date: Wed, 29 Nov 2017 19:32:50 -0500 Subject: [PATCH] Add input plugin for OpenBSD/FreeBSD pf (#3405) --- plugins/inputs/all/all.go | 1 + plugins/inputs/pf/README.md | 68 ++++++++++ plugins/inputs/pf/pf.go | 192 +++++++++++++++++++++++++++ plugins/inputs/pf/pf_test.go | 243 +++++++++++++++++++++++++++++++++++ 4 files changed, 504 insertions(+) create mode 100644 plugins/inputs/pf/README.md create mode 100644 plugins/inputs/pf/pf.go create mode 100644 plugins/inputs/pf/pf_test.go diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index ec66f64f4..aaf5b6ae7 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -64,6 +64,7 @@ import ( _ "github.com/influxdata/telegraf/plugins/inputs/openldap" _ "github.com/influxdata/telegraf/plugins/inputs/opensmtpd" _ "github.com/influxdata/telegraf/plugins/inputs/passenger" + _ "github.com/influxdata/telegraf/plugins/inputs/pf" _ "github.com/influxdata/telegraf/plugins/inputs/phpfpm" _ "github.com/influxdata/telegraf/plugins/inputs/ping" _ "github.com/influxdata/telegraf/plugins/inputs/postfix" diff --git a/plugins/inputs/pf/README.md b/plugins/inputs/pf/README.md new file mode 100644 index 000000000..ed058671b --- /dev/null +++ b/plugins/inputs/pf/README.md @@ -0,0 +1,68 @@ +# PF Plugin + +The pf plugin gathers information from the FreeBSD/OpenBSD pf firewall. Currently it can retrive information about the state table: the number of current entries in the table, and counters for the number of searches, inserts, and removals to the table. + +The pf plugin retrives this information by invoking the `pfstat` command. The `pfstat` command requires read access to the device file `/dev/pf`. You have several options to permit telegraf to run `pfctl`: + +* Run telegraf as root. This is strongly discouraged. +* Change the ownership and permissions for /dev/pf such that the user telegraf runs at can read the /dev/pf device file. This is probably not that good of an idea either. +* Configure sudo to grant telegraf to run `pfctl` as root. This is the most restrictive option, but require sudo setup. + +### Using sudo + +You may edit your sudo configuration with the following: + +```sudo +telegraf ALL=(root) NOPASSWD: /sbin/pfctl -s info +``` + +### Configuration: + +```toml + # use sudo to run pfctl + use_sudo = false +``` + +### Measurements & Fields: + + +- pf + - entries (integer, count) + - searches (integer, count) + - inserts (integer, count) + - removals (integer, count) + +### Example Output: + +``` +> pfctl -s info +Status: Enabled for 0 days 00:26:05 Debug: Urgent + +State Table Total Rate + current entries 2 + searches 11325 7.2/s + inserts 5 0.0/s + removals 3 0.0/s +Counters + match 11226 7.2/s + bad-offset 0 0.0/s + fragment 0 0.0/s + short 0 0.0/s + normalize 0 0.0/s + memory 0 0.0/s + bad-timestamp 0 0.0/s + congestion 0 0.0/s + ip-option 0 0.0/s + proto-cksum 0 0.0/s + state-mismatch 0 0.0/s + state-insert 0 0.0/s + state-limit 0 0.0/s + src-limit 0 0.0/s + synproxy 0 0.0/s +``` + +``` +> ./telegraf --config telegraf.conf --input-filter pf --test +* Plugin: inputs.pf, Collection 1 +> pf,host=columbia entries=3i,searches=2668i,inserts=12i,removals=9i 1510941775000000000 +``` diff --git a/plugins/inputs/pf/pf.go b/plugins/inputs/pf/pf.go new file mode 100644 index 000000000..9712ee8a6 --- /dev/null +++ b/plugins/inputs/pf/pf.go @@ -0,0 +1,192 @@ +package pf + +import ( + "bufio" + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/plugins/inputs" +) + +const measurement = "pf" +const pfctlCommand = "pfctl" + +type PF struct { + PfctlCommand string + PfctlArgs []string + UseSudo bool + StateTable []*Entry + infoFunc func() (string, error) +} + +func (pf *PF) Description() string { + return "Gather counters from PF" +} + +func (pf *PF) SampleConfig() string { + return ` + ## PF require root access on most systems. + ## Setting 'use_sudo' to true will make use of sudo to run pfctl. + ## Users must configure sudo to allow telegraf user to run pfctl with no password. + ## pfctl can be restricted to only list command "pfctl -s info". + use_sudo = false +` +} + +// Gather is the entrypoint for the plugin. +func (pf *PF) Gather(acc telegraf.Accumulator) error { + if pf.PfctlCommand == "" { + var err error + if pf.PfctlCommand, pf.PfctlArgs, err = pf.buildPfctlCmd(); err != nil { + acc.AddError(fmt.Errorf("Can't construct pfctl commandline: %s", err)) + return nil + } + } + + o, err := pf.infoFunc() + if err != nil { + acc.AddError(err) + return nil + } + + if perr := pf.parsePfctlOutput(o, acc); perr != nil { + acc.AddError(perr) + } + return nil +} + +var errParseHeader = fmt.Errorf("Cannot find header in %s output", pfctlCommand) + +func errMissingData(tag string) error { + return fmt.Errorf("struct data for tag \"%s\" not found in %s output", tag, pfctlCommand) +} + +type pfctlOutputStanza struct { + HeaderRE *regexp.Regexp + ParseFunc func([]string, telegraf.Accumulator) error + Found bool +} + +var pfctlOutputStanzas = []*pfctlOutputStanza{ + &pfctlOutputStanza{ + HeaderRE: regexp.MustCompile("^State Table"), + ParseFunc: parseStateTable, + }, +} + +var anyTableHeaderRE = regexp.MustCompile("^[A-Z]") + +func (pf *PF) parsePfctlOutput(pfoutput string, acc telegraf.Accumulator) error { + scanner := bufio.NewScanner(strings.NewReader(pfoutput)) + for scanner.Scan() { + line := scanner.Text() + for _, s := range pfctlOutputStanzas { + if s.HeaderRE.MatchString(line) { + var stanzaLines []string + scanner.Scan() + line = scanner.Text() + for !anyTableHeaderRE.MatchString(line) { + stanzaLines = append(stanzaLines, line) + scanner.Scan() + line = scanner.Text() + } + if perr := s.ParseFunc(stanzaLines, acc); perr != nil { + return perr + } + s.Found = true + } + } + } + for _, s := range pfctlOutputStanzas { + if !s.Found { + return errParseHeader + } + } + return nil +} + +type Entry struct { + Field string + PfctlTitle string + Value int64 +} + +var StateTable = []*Entry{ + &Entry{"entries", "current entries", -1}, + &Entry{"searches", "searches", -1}, + &Entry{"inserts", "inserts", -1}, + &Entry{"removals", "removals", -1}, +} + +var stateTableRE = regexp.MustCompile(`^ (.*?)\s+(\d+)`) + +func parseStateTable(lines []string, acc telegraf.Accumulator) error { + for _, v := range lines { + entries := stateTableRE.FindStringSubmatch(v) + if entries != nil { + for _, f := range StateTable { + if f.PfctlTitle == entries[1] { + var err error + if f.Value, err = strconv.ParseInt(entries[2], 10, 64); err != nil { + return err + } + } + } + } + } + + fields := make(map[string]interface{}) + for _, v := range StateTable { + if v.Value == -1 { + return errMissingData(v.PfctlTitle) + } + fields[v.Field] = v.Value + } + + acc.AddFields(measurement, fields, make(map[string]string)) + return nil +} + +func (pf *PF) callPfctl() (string, error) { + cmd := execCommand(pf.PfctlCommand, pf.PfctlArgs...) + out, oerr := cmd.Output() + if oerr != nil { + ee, ok := oerr.(*exec.ExitError) + if !ok { + return string(out), fmt.Errorf("error running %s: %s: (unable to get stderr)", pfctlCommand, oerr) + } + return string(out), fmt.Errorf("error running %s: %s: %s", pfctlCommand, oerr, ee.Stderr) + } + return string(out), oerr +} + +var execLookPath = exec.LookPath +var execCommand = exec.Command + +func (pf *PF) buildPfctlCmd() (string, []string, error) { + cmd, err := execLookPath(pfctlCommand) + if err != nil { + return "", nil, fmt.Errorf("can't locate %s: %v", pfctlCommand, err) + } + args := []string{"-s", "info"} + if pf.UseSudo { + args = append([]string{cmd}, args...) + cmd, err = execLookPath("sudo") + if err != nil { + return "", nil, fmt.Errorf("can't locate sudo: %v", err) + } + } + return cmd, args, nil +} + +func init() { + inputs.Add("pf", func() telegraf.Input { + pf := new(PF) + pf.infoFunc = pf.callPfctl + return pf + }) +} diff --git a/plugins/inputs/pf/pf_test.go b/plugins/inputs/pf/pf_test.go new file mode 100644 index 000000000..233e72592 --- /dev/null +++ b/plugins/inputs/pf/pf_test.go @@ -0,0 +1,243 @@ +package pf + +import ( + "log" + "reflect" + "strconv" + "testing" + + "github.com/influxdata/telegraf/testutil" +) + +type measurementResult struct { + tags map[string]string + fields map[string]interface{} +} + +func TestPfctlInvocation(t *testing.T) { + type pfctlInvocationTestCase struct { + config PF + cmd string + args []string + } + + var testCases = []pfctlInvocationTestCase{ + // 0: no sudo + pfctlInvocationTestCase{ + config: PF{UseSudo: false}, + cmd: "fakepfctl", + args: []string{"-s", "info"}, + }, + // 1: with sudo + pfctlInvocationTestCase{ + config: PF{UseSudo: true}, + cmd: "fakesudo", + args: []string{"fakepfctl", "-s", "info"}, + }, + } + + for i, tt := range testCases { + execLookPath = func(cmd string) (string, error) { return "fake" + cmd, nil } + t.Run(strconv.Itoa(i), func(t *testing.T) { + log.Printf("running #%d\n", i) + cmd, args, err := tt.config.buildPfctlCmd() + if err != nil { + t.Fatalf("error when running buildPfctlCmd: %s", err) + } + if tt.cmd != cmd || !reflect.DeepEqual(tt.args, args) { + t.Errorf("%d: expected %s - %#v got %s - %#v", i, tt.cmd, tt.args, cmd, args) + } + }) + } +} + +func TestPfMeasurements(t *testing.T) { + type pfTestCase struct { + TestInput string + err error + measurements []measurementResult + } + + testCases := []pfTestCase{ + // 0: nil input should raise an error + pfTestCase{TestInput: "", err: errParseHeader}, + // 1: changes to pfctl output should raise an error + pfTestCase{TestInput: `Status: Enabled for 161 days 21:24:45 Debug: Urgent + +Interface Stats for re1 IPv4 IPv6 + Bytes In 2585823744614 1059233657221 + Bytes Out 1227266932673 3274698578875 + Packets In + Passed 2289953086 1945437219 + Blocked 392835739 48609 + Packets Out + Passed 1649146326 2605569054 + Blocked 107 0 + +State Table Total Rate + Current Entrys 649 + searches 18421725761 1317.0/s + inserts 156762508 11.2/s + removals 156761859 11.2/s +Counters + match 473002784 33.8/s + bad-offset 0 0.0/s + fragment 2729 0.0/s + short 107 0.0/s + normalize 1685 0.0/s + memory 101 0.0/s + bad-timestamp 0 0.0/s + congestion 0 0.0/s + ip-option 152301 0.0/s + proto-cksum 108 0.0/s + state-mismatch 24393 0.0/s + state-insert 92 0.0/s + state-limit 0 0.0/s + src-limit 0 0.0/s + synproxy 0 0.0/s +`, + err: errMissingData("current entries"), + }, + // 2: bad numbers should raise an error + pfTestCase{TestInput: `Status: Enabled for 0 days 00:26:05 Debug: Urgent + +State Table Total Rate + current entries -23 + searches 11325 7.2/s + inserts 5 0.0/s + removals 3 0.0/s +Counters + match 11226 7.2/s + bad-offset 0 0.0/s + fragment 0 0.0/s + short 0 0.0/s + normalize 0 0.0/s + memory 0 0.0/s + bad-timestamp 0 0.0/s + congestion 0 0.0/s + ip-option 0 0.0/s + proto-cksum 0 0.0/s + state-mismatch 0 0.0/s + state-insert 0 0.0/s + state-limit 0 0.0/s + src-limit 0 0.0/s + synproxy 0 0.0/s +`, + err: errMissingData("current entries"), + }, + pfTestCase{TestInput: `Status: Enabled for 0 days 00:26:05 Debug: Urgent + +State Table Total Rate + current entries 2 + searches 11325 7.2/s + inserts 5 0.0/s + removals 3 0.0/s +Counters + match 11226 7.2/s + bad-offset 0 0.0/s + fragment 0 0.0/s + short 0 0.0/s + normalize 0 0.0/s + memory 0 0.0/s + bad-timestamp 0 0.0/s + congestion 0 0.0/s + ip-option 0 0.0/s + proto-cksum 0 0.0/s + state-mismatch 0 0.0/s + state-insert 0 0.0/s + state-limit 0 0.0/s + src-limit 0 0.0/s + synproxy 0 0.0/s +`, + measurements: []measurementResult{ + measurementResult{ + fields: map[string]interface{}{ + "entries": int64(2), + "searches": int64(11325), + "inserts": int64(5), + "removals": int64(3)}, + tags: map[string]string{}, + }, + }, + }, + pfTestCase{TestInput: `Status: Enabled for 161 days 21:24:45 Debug: Urgent + +Interface Stats for re1 IPv4 IPv6 + Bytes In 2585823744614 1059233657221 + Bytes Out 1227266932673 3274698578875 + Packets In + Passed 2289953086 1945437219 + Blocked 392835739 48609 + Packets Out + Passed 1649146326 2605569054 + Blocked 107 0 + +State Table Total Rate + current entries 649 + searches 18421725761 1317.0/s + inserts 156762508 11.2/s + removals 156761859 11.2/s +Counters + match 473002784 33.8/s + bad-offset 0 0.0/s + fragment 2729 0.0/s + short 107 0.0/s + normalize 1685 0.0/s + memory 101 0.0/s + bad-timestamp 0 0.0/s + congestion 0 0.0/s + ip-option 152301 0.0/s + proto-cksum 108 0.0/s + state-mismatch 24393 0.0/s + state-insert 92 0.0/s + state-limit 0 0.0/s + src-limit 0 0.0/s + synproxy 0 0.0/s +`, + measurements: []measurementResult{ + measurementResult{ + fields: map[string]interface{}{ + "entries": int64(649), + "searches": int64(18421725761), + "inserts": int64(156762508), + "removals": int64(156761859)}, + tags: map[string]string{}, + }, + }, + }, + } + + for i, tt := range testCases { + t.Run(strconv.Itoa(i), func(t *testing.T) { + log.Printf("running #%d\n", i) + pf := &PF{ + infoFunc: func() (string, error) { + return tt.TestInput, nil + }, + } + acc := new(testutil.Accumulator) + err := acc.GatherError(pf.Gather) + if !reflect.DeepEqual(tt.err, err) { + t.Errorf("%d: expected error '%#v' got '%#v'", i, tt.err, err) + } + n := 0 + for j, v := range tt.measurements { + if len(acc.Metrics) < n+1 { + t.Errorf("%d: expected at least %d values got %d", i, n+1, len(acc.Metrics)) + break + } + m := acc.Metrics[n] + if !reflect.DeepEqual(m.Measurement, measurement) { + t.Errorf("%d %d: expected measurement '%#v' got '%#v'\n", i, j, measurement, m.Measurement) + } + if !reflect.DeepEqual(m.Tags, v.tags) { + t.Errorf("%d %d: expected tags\n%#v got\n%#v\n", i, j, v.tags, m.Tags) + } + if !reflect.DeepEqual(m.Fields, v.fields) { + t.Errorf("%d %d: expected fields\n%#v got\n%#v\n", i, j, v.fields, m.Fields) + } + n++ + } + }) + } +}