Browse Source

add go.d fail2ban (#17501)

* add go.d fail2ban

* update contexts
Ilya Mashchenko 10 months ago
parent
commit
3557536386

+ 1 - 0
src/go/collectors/go.d.plugin/README.md

@@ -72,6 +72,7 @@ see the appropriate collector readme.
 | [energid](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/energid)                       |          Energi Core          |
 | [envoy](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/envoy)                           |             Envoy             |
 | [example](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/example)                       |               -               |
+| [fail2ban](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/fail2ban)                     |        Fail2Ban Jails         |
 | [filecheck](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/filecheck)                   |     Files and Directories     |
 | [fluentd](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/fluentd)                       |            Fluentd            |
 | [freeradius](https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/freeradius)                 |          FreeRADIUS           |

+ 1 - 0
src/go/collectors/go.d.plugin/config/go.d.conf

@@ -35,6 +35,7 @@ modules:
 #  elasticsearch: yes
 #  envoy: yes
 #  example: no
+#  fail2ban: yes
 #  filecheck: yes
 #  fluentd: yes
 #  freeradius: yes

+ 5 - 0
src/go/collectors/go.d.plugin/config/go.d/fail2ban.conf

@@ -0,0 +1,5 @@
+## All available configuration options, their descriptions and default values:
+## https://github.com/netdata/netdata/tree/master/src/go/collectors/go.d.plugin/modules/fail2ban#readme
+
+jobs:
+  - name: fail2ban

+ 75 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/charts.go

@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/netdata/netdata/go/go.d.plugin/agent/module"
+)
+
+const (
+	prioJailBannedIPs = module.Priority + iota
+	prioJailActiveFailures
+)
+
+var jailChartsTmpl = module.Charts{
+	jailCurrentBannedIPs.Copy(),
+	jailActiveFailures.Copy(),
+}
+
+var (
+	jailCurrentBannedIPs = module.Chart{
+		ID:       "jail_%s_banned_ips",
+		Title:    "Fail2Ban Jail banned IPs",
+		Units:    "addresses",
+		Fam:      "bans",
+		Ctx:      "fail2ban.jail_banned_ips",
+		Type:     module.Line,
+		Priority: prioJailBannedIPs,
+		Dims: module.Dims{
+			{ID: "jail_%s_currently_banned", Name: "banned"},
+		},
+	}
+	jailActiveFailures = module.Chart{
+		ID:       "jail_%s_active_failures",
+		Title:    "Fail2Ban Jail active failures",
+		Units:    "failures",
+		Fam:      "failures",
+		Ctx:      "fail2ban.jail_active_failures",
+		Type:     module.Line,
+		Priority: prioJailActiveFailures,
+		Dims: module.Dims{
+			{ID: "jail_%s_currently_failed", Name: "active_failures"},
+		},
+	}
+)
+
+func (f *Fail2Ban) addJailCharts(jail string) {
+	charts := jailChartsTmpl.Copy()
+
+	for _, chart := range *charts {
+		chart.ID = fmt.Sprintf(chart.ID, jail)
+		chart.Labels = []module.Label{
+			{Key: "jail", Value: jail},
+		}
+		for _, dim := range chart.Dims {
+			dim.ID = fmt.Sprintf(dim.ID, jail)
+		}
+	}
+
+	if err := f.Charts().Add(*charts...); err != nil {
+		f.Warning(err)
+	}
+}
+
+func (f *Fail2Ban) removeJailCharts(jail string) {
+	px := fmt.Sprintf("jail_%s_", jail)
+	for _, chart := range *f.Charts() {
+		if strings.HasPrefix(chart.ID, px) {
+			chart.MarkRemove()
+			chart.MarkNotCreated()
+		}
+	}
+}

+ 163 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/collect.go

@@ -0,0 +1,163 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"time"
+)
+
+func (f *Fail2Ban) collect() (map[string]int64, error) {
+	now := time.Now()
+
+	if now.Sub(f.lastDiscoverTime) > f.discoverEvery || f.forceDiscover {
+		jails, err := f.discoverJails()
+		if err != nil {
+			return nil, err
+		}
+		f.jails = jails
+		f.lastDiscoverTime = now
+		f.forceDiscover = false
+	}
+
+	mx := make(map[string]int64)
+
+	if err := f.collectJails(mx); err != nil {
+		return nil, err
+	}
+
+	return mx, nil
+}
+
+func (f *Fail2Ban) discoverJails() ([]string, error) {
+	bs, err := f.exec.status()
+	if err != nil {
+		return nil, err
+	}
+
+	jails, err := parseFail2banStatus(bs)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(jails) == 0 {
+		return nil, errors.New("no jails found")
+	}
+
+	f.Debugf("discovered %d jails: %v", len(jails), jails)
+
+	return jails, nil
+}
+
+func (f *Fail2Ban) collectJails(mx map[string]int64) error {
+	seen := make(map[string]bool)
+
+	for _, jail := range f.jails {
+		f.Debugf("querying status for jail '%s'", jail)
+		bs, err := f.exec.jailStatus(jail)
+		if err != nil {
+			if errors.Is(err, errJailNotExist) {
+				f.forceDiscover = true
+				continue
+			}
+			return err
+		}
+
+		failed, banned, err := parseFail2banJailStatus(bs)
+		if err != nil {
+			return err
+		}
+
+		if !f.seenJails[jail] {
+			f.seenJails[jail] = true
+			f.addJailCharts(jail)
+		}
+		seen[jail] = true
+
+		px := fmt.Sprintf("jail_%s_", jail)
+
+		mx[px+"currently_failed"] = failed
+		mx[px+"currently_banned"] = banned
+	}
+
+	for jail := range f.seenJails {
+		if !seen[jail] {
+			delete(f.seenJails, jail)
+			f.removeJailCharts(jail)
+		}
+	}
+
+	return nil
+}
+
+func parseFail2banJailStatus(jailStatus []byte) (failed, banned int64, err error) {
+	const (
+		failedSub = "Currently failed:"
+		bannedSub = "Currently banned:"
+	)
+
+	var failedFound, bannedFound bool
+
+	sc := bufio.NewScanner(bytes.NewReader(jailStatus))
+
+	for sc.Scan() && !(failedFound && bannedFound) {
+		text := strings.TrimSpace(sc.Text())
+		if text == "" {
+			continue
+		}
+
+		if !failedFound {
+			if i := strings.Index(text, failedSub); i != -1 {
+				failedFound = true
+				s := strings.TrimSpace(text[i+len(failedSub):])
+				if failed, err = strconv.ParseInt(s, 10, 64); err != nil {
+					return 0, 0, fmt.Errorf("failed to parse currently failed value (%s): %v", s, err)
+				}
+			}
+		}
+		if !bannedFound {
+			if i := strings.Index(text, bannedSub); i != -1 {
+				bannedFound = true
+				s := strings.TrimSpace(text[i+len(bannedSub):])
+				if banned, err = strconv.ParseInt(s, 10, 64); err != nil {
+					return 0, 0, fmt.Errorf("failed to parse currently banned value (%s): %v", s, err)
+				}
+			}
+		}
+	}
+
+	if !failedFound || !bannedFound {
+		return 0, 0, errors.New("failed to find failed and banned values")
+	}
+
+	return failed, banned, nil
+}
+
+func parseFail2banStatus(status []byte) ([]string, error) {
+	const sub = "Jail list:"
+
+	var jails []string
+
+	sc := bufio.NewScanner(bytes.NewReader(status))
+
+	for sc.Scan() {
+		text := strings.TrimSpace(sc.Text())
+
+		if i := strings.Index(text, sub); i != -1 {
+			s := strings.ReplaceAll(text[i+len(sub):], ",", "")
+			jails = strings.Fields(s)
+			break
+		}
+	}
+
+	if len(jails) == 0 {
+		return nil, errors.New("failed to find jails")
+	}
+
+	return jails, nil
+}

+ 35 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/config_schema.json

@@ -0,0 +1,35 @@
+{
+  "jsonSchema": {
+    "$schema": "http://json-schema.org/draft-07/schema#",
+    "title": "Fail2Ban collector configuration.",
+    "type": "object",
+    "properties": {
+      "update_every": {
+        "title": "Update every",
+        "description": "Data collection interval, measured in seconds.",
+        "type": "integer",
+        "minimum": 1,
+        "default": 10
+      },
+      "timeout": {
+        "title": "Timeout",
+        "description": "Timeout for executing the binary, specified in seconds.",
+        "type": "number",
+        "minimum": 0.5,
+        "default": 2
+      }
+    },
+    "additionalProperties": false,
+    "patternProperties": {
+      "^name$": {}
+    }
+  },
+  "uiSchema": {
+    "uiOptions": {
+      "fullPage": true
+    },
+    "timeout": {
+      "ui:help": "Accepts decimals for precise control (e.g., type 1.5 for 1.5 seconds)."
+    }
+  }
+}

+ 57 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/exec.go

@@ -0,0 +1,57 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"os/exec"
+	"strings"
+	"time"
+
+	"github.com/netdata/netdata/go/go.d.plugin/logger"
+)
+
+var errJailNotExist = errors.New("jail not exist")
+
+func newFail2BanClientCliExec(ndsudoPath string, timeout time.Duration, log *logger.Logger) *fail2banClientCliExec {
+	return &fail2banClientCliExec{
+		Logger:     log,
+		ndsudoPath: ndsudoPath,
+		timeout:    timeout,
+	}
+}
+
+type fail2banClientCliExec struct {
+	*logger.Logger
+
+	ndsudoPath string
+	timeout    time.Duration
+}
+
+func (e *fail2banClientCliExec) status() ([]byte, error) {
+	return e.execute("fail2ban-client-status")
+}
+
+func (e *fail2banClientCliExec) jailStatus(jail string) ([]byte, error) {
+	return e.execute("fail2ban-client-status-jail", "--jail", jail)
+}
+
+func (e *fail2banClientCliExec) execute(args ...string) ([]byte, error) {
+	ctx, cancel := context.WithTimeout(context.Background(), e.timeout)
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, e.ndsudoPath, args...)
+	e.Debugf("executing '%s'", cmd)
+
+	bs, err := cmd.Output()
+	if err != nil {
+		if strings.HasPrefix(strings.TrimSpace(string(bs)), "Sorry but the jail") {
+			return nil, errJailNotExist
+		}
+		return nil, fmt.Errorf("error on '%s': %v", cmd, err)
+	}
+
+	return bs, nil
+}

+ 111 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/fail2ban.go

@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	_ "embed"
+	"errors"
+	"time"
+
+	"github.com/netdata/netdata/go/go.d.plugin/agent/module"
+	"github.com/netdata/netdata/go/go.d.plugin/pkg/web"
+)
+
+//go:embed "config_schema.json"
+var configSchema string
+
+func init() {
+	module.Register("fail2ban", module.Creator{
+		JobConfigSchema: configSchema,
+		Defaults: module.Defaults{
+			UpdateEvery: 10,
+		},
+		Create: func() module.Module { return New() },
+	})
+}
+
+func New() *Fail2Ban {
+	return &Fail2Ban{
+		Config: Config{
+			Timeout: web.Duration(time.Second * 2),
+		},
+		charts:        &module.Charts{},
+		discoverEvery: time.Minute * 5,
+		seenJails:     make(map[string]bool),
+	}
+}
+
+type Config struct {
+	UpdateEvery int          `yaml:"update_every" json:"update_every"`
+	Timeout     web.Duration `yaml:"timeout" json:"timeout"`
+}
+
+type (
+	Fail2Ban struct {
+		module.Base
+		Config `yaml:",inline" json:""`
+
+		charts *module.Charts
+
+		exec fail2banClientCli
+
+		discoverEvery    time.Duration
+		lastDiscoverTime time.Time
+		forceDiscover    bool
+		jails            []string
+
+		seenJails map[string]bool
+	}
+	fail2banClientCli interface {
+		status() ([]byte, error)
+		jailStatus(s string) ([]byte, error)
+	}
+)
+
+func (f *Fail2Ban) Configuration() any {
+	return f.Config
+}
+
+func (f *Fail2Ban) Init() error {
+	f2bClientExec, err := f.initFail2banClientCliExec()
+	if err != nil {
+		f.Errorf("fail2ban-client exec initialization: %v", err)
+		return err
+	}
+	f.exec = f2bClientExec
+
+	return nil
+}
+
+func (f *Fail2Ban) Check() error {
+	mx, err := f.collect()
+	if err != nil {
+		f.Error(err)
+		return err
+	}
+
+	if len(mx) == 0 {
+		return errors.New("no metrics collected")
+	}
+
+	return nil
+}
+
+func (f *Fail2Ban) Charts() *module.Charts {
+	return f.charts
+}
+
+func (f *Fail2Ban) Collect() map[string]int64 {
+	mx, err := f.collect()
+	if err != nil {
+		f.Error(err)
+	}
+
+	if len(mx) == 0 {
+		return nil
+	}
+
+	return mx
+}
+
+func (f *Fail2Ban) Cleanup() {}

+ 238 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/fail2ban_test.go

@@ -0,0 +1,238 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	"errors"
+	"os"
+	"testing"
+
+	"github.com/netdata/netdata/go/go.d.plugin/agent/module"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	dataConfigJSON, _ = os.ReadFile("testdata/config.json")
+	dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
+
+	dataStatus, _     = os.ReadFile("testdata/fail2ban-status.txt")
+	dataJailStatus, _ = os.ReadFile("testdata/fail2ban-jail-status.txt")
+)
+
+func Test_testDataIsValid(t *testing.T) {
+	for name, data := range map[string][]byte{
+		"dataConfigJSON": dataConfigJSON,
+		"dataConfigYAML": dataConfigYAML,
+
+		"dataStatus":     dataStatus,
+		"dataJailStatus": dataJailStatus,
+	} {
+		require.NotNil(t, data, name)
+
+	}
+}
+
+func TestFail2Ban_Configuration(t *testing.T) {
+	module.TestConfigurationSerialize(t, &Fail2Ban{}, dataConfigJSON, dataConfigYAML)
+}
+
+func TestFail2Ban_Init(t *testing.T) {
+	tests := map[string]struct {
+		config   Config
+		wantFail bool
+	}{
+		"fails if failed to locate ndsudo": {
+			wantFail: true,
+			config:   New().Config,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			f2b := New()
+			f2b.Config = test.config
+
+			if test.wantFail {
+				assert.Error(t, f2b.Init())
+			} else {
+				assert.NoError(t, f2b.Init())
+			}
+		})
+	}
+}
+
+func TestFail2Ban_Cleanup(t *testing.T) {
+	tests := map[string]struct {
+		prepare func() *Fail2Ban
+	}{
+		"not initialized exec": {
+			prepare: func() *Fail2Ban {
+				return New()
+			},
+		},
+		"after check": {
+			prepare: func() *Fail2Ban {
+				f2b := New()
+				f2b.exec = prepareMockOk()
+				_ = f2b.Check()
+				return f2b
+			},
+		},
+		"after collect": {
+			prepare: func() *Fail2Ban {
+				f2b := New()
+				f2b.exec = prepareMockOk()
+				_ = f2b.Collect()
+				return f2b
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			f2b := test.prepare()
+
+			assert.NotPanics(t, f2b.Cleanup)
+		})
+	}
+}
+
+func TestFail2Ban_Charts(t *testing.T) {
+	assert.NotNil(t, New().Charts())
+}
+
+func TestFail2Ban_Check(t *testing.T) {
+	tests := map[string]struct {
+		prepareMock func() *mockFail2BanClientCliExec
+		wantFail    bool
+	}{
+		"success multiple jails": {
+			wantFail:    false,
+			prepareMock: prepareMockOk,
+		},
+		"error on status": {
+			wantFail:    true,
+			prepareMock: prepareMockErrOnStatus,
+		},
+		"empty response (no jails)": {
+			prepareMock: prepareMockEmptyResponse,
+			wantFail:    true,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			f2b := New()
+			mock := test.prepareMock()
+			f2b.exec = mock
+
+			if test.wantFail {
+				assert.Error(t, f2b.Check())
+			} else {
+				assert.NoError(t, f2b.Check())
+			}
+		})
+	}
+}
+
+func TestFail2Ban_Collect(t *testing.T) {
+	tests := map[string]struct {
+		prepareMock func() *mockFail2BanClientCliExec
+		wantMetrics map[string]int64
+	}{
+		"success multiple jails": {
+			prepareMock: prepareMockOk,
+			wantMetrics: map[string]int64{
+				"jail_dovecot_currently_banned": 30,
+				"jail_dovecot_currently_failed": 10,
+				"jail_sshd_currently_banned":    30,
+				"jail_sshd_currently_failed":    10,
+			},
+		},
+		"error on status": {
+			prepareMock: prepareMockErrOnStatus,
+			wantMetrics: nil,
+		},
+		"empty response (no jails)": {
+			prepareMock: prepareMockEmptyResponse,
+			wantMetrics: nil,
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			f2b := New()
+			mock := test.prepareMock()
+			f2b.exec = mock
+
+			mx := f2b.Collect()
+
+			assert.Equal(t, test.wantMetrics, mx)
+			if len(test.wantMetrics) > 0 {
+				assert.Len(t, *f2b.Charts(), len(jailChartsTmpl)*2)
+				testMetricsHasAllChartsDims(t, f2b, mx)
+			}
+		})
+	}
+}
+
+func testMetricsHasAllChartsDims(t *testing.T, f2b *Fail2Ban, mx map[string]int64) {
+	for _, chart := range *f2b.Charts() {
+		if chart.Obsolete {
+			continue
+		}
+		for _, dim := range chart.Dims {
+			_, ok := mx[dim.ID]
+			assert.Truef(t, ok, "collected metrics has no data for dim '%s' chart '%s'", dim.ID, chart.ID)
+		}
+		for _, v := range chart.Vars {
+			_, ok := mx[v.ID]
+			assert.Truef(t, ok, "collected metrics has no data for var '%s' chart '%s'", v.ID, chart.ID)
+		}
+	}
+}
+
+func prepareMockOk() *mockFail2BanClientCliExec {
+	return &mockFail2BanClientCliExec{
+		statusData:     dataStatus,
+		jailStatusData: dataJailStatus,
+	}
+}
+
+func prepareMockErrOnStatus() *mockFail2BanClientCliExec {
+	return &mockFail2BanClientCliExec{
+		errOnStatus:    true,
+		statusData:     dataStatus,
+		jailStatusData: dataJailStatus,
+	}
+}
+
+func prepareMockEmptyResponse() *mockFail2BanClientCliExec {
+	return &mockFail2BanClientCliExec{}
+}
+
+type mockFail2BanClientCliExec struct {
+	errOnStatus bool
+	statusData  []byte
+
+	errOnJailStatus bool
+	jailStatusData  []byte
+}
+
+func (m *mockFail2BanClientCliExec) status() ([]byte, error) {
+	if m.errOnStatus {
+		return nil, errors.New("mock.status() error")
+	}
+
+	return m.statusData, nil
+}
+
+func (m *mockFail2BanClientCliExec) jailStatus(_ string) ([]byte, error) {
+	if m.errOnJailStatus {
+		return nil, errors.New("mock.jailStatus() error")
+	}
+
+	return m.jailStatusData, nil
+}

+ 23 - 0
src/go/collectors/go.d.plugin/modules/fail2ban/init.go

@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package fail2ban
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+
+	"github.com/netdata/netdata/go/go.d.plugin/agent/executable"
+)
+
+func (f *Fail2Ban) initFail2banClientCliExec() (fail2banClientCli, error) {
+	ndsudoPath := filepath.Join(executable.Directory, "ndsudo")
+	if _, err := os.Stat(ndsudoPath); err != nil {
+		return nil, fmt.Errorf("ndsudo executable not found: %v", err)
+
+	}
+
+	f2bClientExec := newFail2BanClientCliExec(ndsudoPath, f.Timeout.Duration(), f.Logger)
+
+	return f2bClientExec, nil
+}

Some files were not shown because too many files changed in this diff