Skip to content

Commit

Permalink
basic PF prometheus metrics (packets, bytes, banned ip count) (#349)
Browse files Browse the repository at this point in the history
  • Loading branch information
mmetc authored Jan 19, 2024
1 parent 7f706b6 commit eb7094b
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 10 deletions.
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func Execute() error {
})

if config.PrometheusConfig.Enabled {
if config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode {
if config.Mode == cfg.IptablesMode || config.Mode == cfg.NftablesMode || config.Mode == cfg.PfMode {
go backend.CollectMetrics()
prometheus.MustRegister(metrics.TotalDroppedBytes, metrics.TotalDroppedPackets, metrics.TotalActiveBannedIPs)
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/google/nftables v0.1.1-0.20230710063801-8a10f689006b
github.com/prometheus/client_golang v1.17.0
github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.8.4
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1
golang.org/x/sync v0.3.0
golang.org/x/sys v0.13.0
Expand All @@ -22,6 +23,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/go-openapi/analysis v0.21.4 // indirect
github.com/go-openapi/errors v0.20.4 // indirect
Expand All @@ -46,6 +48,7 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
Expand Down
3 changes: 2 additions & 1 deletion pkg/backend/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (b *BackendCTX) Delete(decision *models.Decision) error {
}

func (b *BackendCTX) CollectMetrics() {
log.Trace("Collecting backend-specific metrics")
b.firewall.CollectMetrics()
}

Expand All @@ -62,7 +63,7 @@ func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) {

b := &BackendCTX{}

log.Printf("backend type : %s", config.Mode)
log.Printf("backend type: %s", config.Mode)

if config.DisableIPV6 {
log.Println("IPV6 is disabled")
Expand Down
144 changes: 144 additions & 0 deletions pkg/pf/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package pf

import (
"bufio"
"regexp"
"slices"
"strconv"
"strings"
"time"

log "github.com/sirupsen/logrus"

"github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics"
)

type counter struct {
packets int
bytes int
}

var (
// table names can contain _ or - characters.
rexpTable = regexp.MustCompile(`^block .* from <(?P<table>[^ ]+)> .*"$`)
rexpMetrics = regexp.MustCompile(`^\s+\[.*Packets: (?P<packets>\d+)\s+Bytes: (?P<bytes>\d+).*\]$`)
)

func parseMetrics(reader *strings.Reader, tables []string) map[string]counter {
ret := make(map[string]counter)

// scan until we find a table name between <>
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
// parse the line and extract the table name
match := rexpTable.FindStringSubmatch(line)
if len(match) == 0 {
continue
}

table := match[1]
// if the table is not in the list of tables we want to parse, skip it
if !slices.Contains(tables, table) {
continue
}

// parse the line with the actual metrics
if !scanner.Scan() {
break
}

line = scanner.Text()

match = rexpMetrics.FindStringSubmatch(line)
if len(match) == 0 {
log.Errorf("failed to parse metrics: %s", line)
continue
}

packets, err := strconv.Atoi(match[1])
if err != nil {
log.Errorf("failed to parse metrics - dropped packets: %s", err)

packets = 0
}

bytes, err := strconv.Atoi(match[2])
if err != nil {
log.Errorf("failed to parse metrics - dropped bytes: %s", err)

bytes = 0
}

ret[table] = counter{
packets: packets,
bytes: bytes,
}
}

return ret
}

// countIPs returns the number of IPs in a table.
func (pf *pf) countIPs(table string) int {
cmd := execPfctl("", "-T", "show", "-t", table)

out, err := cmd.Output()
if err != nil {
log.Errorf("failed to run 'pfctl -T show -t %s': %s", table, err)
return 0
}

// one IP per line
return strings.Count(string(out), "\n")
}

// CollectMetrics collects metrics from pfctl.
// In pf mode the firewall rules are not controlled by the bouncer, so we can only
// trust they are set up correctly, and retrieve stats from the pfctl tables.
func (pf *pf) CollectMetrics() {
droppedPackets := float64(0)
droppedBytes := float64(0)

tables := []string{}

if pf.inet != nil {
tables = append(tables, pf.inet.table)
}

if pf.inet6 != nil {
tables = append(tables, pf.inet6.table)
}

t := time.NewTicker(metrics.MetricCollectionInterval)

for range t.C {
cmd := execPfctl("", "-v", "-sr")

out, err := cmd.Output()
if err != nil {
log.Errorf("failed to run 'pfctl -v -sr': %s", err)
continue
}

reader := strings.NewReader(string(out))
stats := parseMetrics(reader, tables)
bannedIPs := 0

for _, table := range tables {
st, ok := stats[table]
if !ok {
continue
}

droppedPackets += float64(st.packets)
droppedBytes += float64(st.bytes)

bannedIPs += pf.countIPs(table)
}

metrics.TotalDroppedPackets.Set(droppedPackets)
metrics.TotalDroppedBytes.Set(droppedBytes)
metrics.TotalActiveBannedIPs.Set(float64(bannedIPs))
}
}
34 changes: 34 additions & 0 deletions pkg/pf/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package pf

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestParseMetrics(t *testing.T) {
metricsInput := `block drop in quick inet from <crowdsec_blacklists> to any label "CrowdSec IPv4"
[ Evaluations: 1519 Packets: 16 Bytes: 4096 States: 0 ]
[ Inserted: uid 0 pid 14219 State Creations: 0 ]
block drop in quick inet6 from <crowdsec6_blacklists> to any label "CrowdSec IPv6"
[ Evaluations: 914 Packets: 8 Bytes: 2048 States: 0 ]
[ Inserted: uid 0 pid 14219 State Creations: 0 ]`

reader := strings.NewReader(metricsInput)
tables := []string{"crowdsec_blacklists", "crowdsec6_blacklists"}

metrics := parseMetrics(reader, tables)

require.Contains(t, metrics, "crowdsec_blacklists")
require.Contains(t, metrics, "crowdsec6_blacklists")

ip4Metrics := metrics["crowdsec_blacklists"]
assert.Equal(t, 16, ip4Metrics.packets)
assert.Equal(t, 4096, ip4Metrics.bytes)

ip6Metrics := metrics["crowdsec6_blacklists"]
assert.Equal(t, 8, ip6Metrics.packets)
assert.Equal(t, 2048, ip6Metrics.bytes)
}
6 changes: 2 additions & 4 deletions pkg/pf/pf.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ func NewPF(config *cfg.BouncerConfig) (types.Backend, error) {
return ret, nil
}

// execPfctl runs a pfctl command by prepending the anchor name if we have one.
// execPfctl runs a pfctl command by prepending the anchor name if needed.
// Some commands return an error if an anchor is specified.
func execPfctl(anchor string, arg ...string) *exec.Cmd {
if anchor != "" {
arg = append([]string{"-a", anchor}, arg...)
Expand Down Expand Up @@ -178,9 +179,6 @@ func (pf *pf) commitAddedDecisions() error {
return nil
}

func (pf *pf) CollectMetrics() {
}

func (pf *pf) Delete(decision *models.Decision) error {
pf.decisionsToDelete = append(pf.decisionsToDelete, decision)
return nil
Expand Down
2 changes: 1 addition & 1 deletion test/bouncer/test_firewall_bouncer.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_backend_mode(bouncer, fw_cfg_factory):
with bouncer(cfg) as fw:
fw.wait_for_lines_fnmatch([
"*Starting crowdsec-firewall-bouncer*",
"*backend type : dry-run*",
"*backend type: dry-run*",
"*backend.Init() called*",
"*unable to configure bouncer: config does not contain LAPI url*",
])
Expand Down
6 changes: 3 additions & 3 deletions test/bouncer/test_tls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factor

with bouncer(cfg) as cb:
cb.wait_for_lines_fnmatch([
"*backend type : dry-run*",
"*backend type: dry-run*",
"*Using API key auth*",
"*auth-api: auth with api key failed*",
"*tls: failed to verify certificate: x509: certificate signed by unknown authority*",
Expand All @@ -43,7 +43,7 @@ def test_tls_server(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factor

with bouncer(cfg) as cb:
cb.wait_for_lines_fnmatch([
"*backend type : dry-run*",
"*backend type: dry-run*",
"*Using CA cert *ca.crt*",
"*Using API key auth*",
"*Processing new and deleted decisions*",
Expand Down Expand Up @@ -95,7 +95,7 @@ def test_tls_mutual(crowdsec, certs_dir, api_key_factory, bouncer, fw_cfg_factor

with bouncer(cfg) as cb:
cb.wait_for_lines_fnmatch([
"*backend type : dry-run*",
"*backend type: dry-run*",
"*Using CA cert*",
"*Using cert auth with cert * and key *",
"*Processing new and deleted decisions . . .*",
Expand Down

0 comments on commit eb7094b

Please sign in to comment.