diff --git a/README.md b/README.md index ecdd3d52..7af374f4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Supported firewalls: - nftables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - ipset only (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - pf (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: ) + - ipfw (IPV4 :heavy_check_mark: / IPV6 :heavy_check_mark: ) # Installation diff --git a/config/crowdsec-firewall-bouncer.yaml b/config/crowdsec-firewall-bouncer.yaml index 83002fc6..66dd938b 100644 --- a/config/crowdsec-firewall-bouncer.yaml +++ b/config/crowdsec-firewall-bouncer.yaml @@ -57,6 +57,10 @@ pf: # an empty string disables the anchor anchor_name: "" +# ipfw (FreeBSD): the bouncer only manages the "blacklists_ipv4"/"blacklists_ipv6" +# named tables above, you must create them and the rule(s) referencing them +# (e.g. "ipfw add deny ip from table(crowdsec-blacklists) to any") beforehand. + prometheus: enabled: false listen_addr: 127.0.0.1 diff --git a/pkg/backend/backend.go b/pkg/backend/backend.go index af66f990..1066e0f8 100644 --- a/pkg/backend/backend.go +++ b/pkg/backend/backend.go @@ -11,6 +11,7 @@ import ( "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/dryrun" + "github.com/crowdsecurity/cs-firewall-bouncer/pkg/ipfw" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/iptables" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/nftables" "github.com/crowdsecurity/cs-firewall-bouncer/pkg/pf" @@ -59,6 +60,10 @@ func isPFSupported(runtimeOS string) bool { return supported } +func isIPFWSupported(runtimeOS string) bool { + return runtimeOS == "freebsd" +} + func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) { var err error @@ -102,6 +107,15 @@ func NewBackend(config *cfg.BouncerConfig) (*BackendCTX, error) { if err != nil { return nil, err } + case cfg.IpfwMode: + if !isIPFWSupported(runtime.GOOS) { + log.Warning("ipfw mode can only work with freebsd. It is available on other platforms only for testing purposes") + } + + b.firewall, err = ipfw.NewIPFW(config) + if err != nil { + return nil, err + } case "dry-run": b.firewall, err = dryrun.NewDryRun(config) if err != nil { diff --git a/pkg/cfg/config.go b/pkg/cfg/config.go index ad668983..7835b17a 100644 --- a/pkg/cfg/config.go +++ b/pkg/cfg/config.go @@ -31,6 +31,7 @@ const ( IptablesMode = "iptables" NftablesMode = "nftables" PfMode = "pf" + IpfwMode = "ipfw" DryRunMode = "dry-run" ) @@ -146,7 +147,7 @@ func NewConfig(reader io.Reader) (*BouncerConfig, error) { if err != nil { return nil, err } - case IpsetMode, IptablesMode: + case IpsetMode, IptablesMode, IpfwMode: // nothing specific to do case PfMode: err := pfConfig(config) diff --git a/pkg/ipfw/ipfw.go b/pkg/ipfw/ipfw.go new file mode 100644 index 00000000..3aeccfae --- /dev/null +++ b/pkg/ipfw/ipfw.go @@ -0,0 +1,178 @@ +package ipfw + +import ( + "fmt" + "os/exec" + "strings" + + log "github.com/sirupsen/logrus" + + "github.com/crowdsecurity/crowdsec/pkg/models" + + "github.com/crowdsecurity/cs-firewall-bouncer/pkg/cfg" + "github.com/crowdsecurity/cs-firewall-bouncer/pkg/types" +) + +type ipfw struct { + inet *ipfwContext + inet6 *ipfwContext + decisionsToAdd []*models.Decision + decisionsToDelete []*models.Decision +} + +const ipfwCmd = "/sbin/ipfw" + +func NewIPFW(config *cfg.BouncerConfig) (types.Backend, error) { + ret := &ipfw{} + + inetCtx := &ipfwContext{ + table: config.BlacklistsIpv4, + version: "ipv4", + } + + inet6Ctx := &ipfwContext{ + table: config.BlacklistsIpv6, + version: "ipv6", + } + + if !config.DisableIPV4 { + ret.inet = inetCtx + } + + if !config.DisableIPV6 { + ret.inet6 = inet6Ctx + } + + return ret, nil +} + +func execIpfw(arg ...string) *exec.Cmd { + log.Debugf("Running: %s %s", ipfwCmd, arg) + + return exec.Command(ipfwCmd, arg...) +} + +func (fw *ipfw) Init() error { + if _, err := exec.LookPath(ipfwCmd); err != nil { + return fmt.Errorf("%s command not found: %w", ipfwCmd, err) + } + + if fw.inet != nil { + if err := fw.inet.init(); err != nil { + return err + } + } + + if fw.inet6 != nil { + if err := fw.inet6.init(); err != nil { + return err + } + } + + return nil +} + +func (fw *ipfw) Commit() error { + defer fw.reset() + + if err := fw.commitDeletedDecisions(); err != nil { + return err + } + + return fw.commitAddedDecisions() +} + +func (fw *ipfw) Add(decision *models.Decision) error { + fw.decisionsToAdd = append(fw.decisionsToAdd, decision) + return nil +} + +func (fw *ipfw) reset() { + fw.decisionsToAdd = make([]*models.Decision, 0) + fw.decisionsToDelete = make([]*models.Decision, 0) +} + +func (fw *ipfw) commitDeletedDecisions() error { + ipv4decisions := make([]*models.Decision, 0) + ipv6decisions := make([]*models.Decision, 0) + + for _, d := range fw.decisionsToDelete { + if strings.Contains(*d.Value, ":") && fw.inet6 != nil { + ipv6decisions = append(ipv6decisions, d) + } else if fw.inet != nil { + ipv4decisions = append(ipv4decisions, d) + } + } + + if len(ipv6decisions) > 0 { + if fw.inet6 == nil { + log.Debugf("not removing '%d' decisions because ipv6 is disabled", len(ipv6decisions)) + } else if err := fw.inet6.delete(ipv6decisions); err != nil { + return err + } + } + + if len(ipv4decisions) > 0 { + if fw.inet == nil { + log.Debugf("not removing '%d' decisions because ipv4 is disabled", len(ipv4decisions)) + } else if err := fw.inet.delete(ipv4decisions); err != nil { + return err + } + } + + return nil +} + +func (fw *ipfw) commitAddedDecisions() error { + ipv4decisions := make([]*models.Decision, 0) + ipv6decisions := make([]*models.Decision, 0) + + for _, d := range fw.decisionsToAdd { + if strings.Contains(*d.Value, ":") && fw.inet6 != nil { + ipv6decisions = append(ipv6decisions, d) + } else if fw.inet != nil { + ipv4decisions = append(ipv4decisions, d) + } + } + + if len(ipv6decisions) > 0 { + if fw.inet6 == nil { + log.Debugf("not adding '%d' decisions because ipv6 is disabled", len(ipv6decisions)) + } else if err := fw.inet6.add(ipv6decisions); err != nil { + return err + } + } + + if len(ipv4decisions) > 0 { + if fw.inet == nil { + log.Debugf("not adding '%d' decisions because ipv4 is disabled", len(ipv4decisions)) + } else if err := fw.inet.add(ipv4decisions); err != nil { + return err + } + } + + return nil +} + +func (fw *ipfw) Delete(decision *models.Decision) error { + fw.decisionsToDelete = append(fw.decisionsToDelete, decision) + return nil +} + +func (fw *ipfw) ShutDown() error { + log.Infof("flushing 'crowdsec' table(s)") + + if fw.inet != nil { + if err := fw.inet.shutDown(); err != nil { + return fmt.Errorf("unable to flush %s table (%s): ", fw.inet.version, fw.inet.table) + } + } + + if fw.inet6 != nil { + if err := fw.inet6.shutDown(); err != nil { + return fmt.Errorf("unable to flush %s table (%s): ", fw.inet6.version, fw.inet6.table) + } + } + + return nil +} diff --git a/pkg/ipfw/ipfw_context.go b/pkg/ipfw/ipfw_context.go new file mode 100644 index 00000000..7adc0d86 --- /dev/null +++ b/pkg/ipfw/ipfw_context.go @@ -0,0 +1,148 @@ +package ipfw + +import ( + "bufio" + "fmt" + "os" + + log "github.com/sirupsen/logrus" + + "github.com/crowdsecurity/crowdsec/pkg/models" +) + +type ipfwContext struct { + table string + version string +} + +const backendName = "ipfw" + +func decisionsToIPs(decisions []*models.Decision) []string { + ips := make([]string, 0, len(decisions)) + + for _, d := range decisions { + if d == nil || d.Value == nil { + continue + } + + ips = append(ips, *d.Value) + } + + return ips +} + +// writeScript writes a sequence of "table " commands to a +// temp file, to be run in a single batch with "ipfw -q ". +func writeScript(table, action string, ips []string) (string, error) { + f, err := os.CreateTemp("", "crowdsec-ipfw-*.txt") + if err != nil { + return "", err + } + + name := f.Name() + done := false + + defer func() { + if !done { + _ = f.Close() + _ = os.Remove(name) + } + }() + + w := bufio.NewWriter(f) + for _, ip := range ips { + if _, err = fmt.Fprintf(w, "table %s %s %s\n", table, action, ip); err != nil { + return "", err + } + } + + if err = w.Flush(); err != nil { + return "", err + } + + if err = f.Close(); err != nil { + return "", err + } + + done = true + + return name, nil +} + +// checkTable makes sure the table already exists, it must be created +// beforehand along with the rule(s) referencing it (e.g. "deny ip from +// table() to any"), the bouncer only manages table membership. +func (ctx *ipfwContext) checkTable() error { + log.Infof("Checking ipfw table: %s", ctx.table) + + cmd := execIpfw("table", ctx.table, "info") + + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("table %s doesn't exist: %s - %w", ctx.table, out, err) + } + + return nil +} + +func (ctx *ipfwContext) shutDown() error { + cmd := execIpfw("table", ctx.table, "flush") + log.Infof("ipfw table clean-up: %s", cmd) + + if out, err := cmd.CombinedOutput(); err != nil { + log.Errorf("Error while flushing table (%s): %v --> %s", cmd, err, out) + } + + return nil +} + +func (ctx *ipfwContext) add(decisions []*models.Decision) error { + log.Debugf("Adding %d decisions", len(decisions)) + + ips := decisionsToIPs(decisions) + + file, err := writeScript(ctx.table, "add", ips) + if err != nil { + return fmt.Errorf("writing decisions to temp file: %w", err) + } + defer os.Remove(file) + + cmd := execIpfw("-q", file) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("error while adding to table (%s): %w --> %s", cmd, err, out) + } + + return nil +} + +func (ctx *ipfwContext) delete(decisions []*models.Decision) error { + log.Debugf("Removing %d decisions", len(decisions)) + + ips := decisionsToIPs(decisions) + + file, err := writeScript(ctx.table, "delete", ips) + if err != nil { + return fmt.Errorf("writing decisions to temp file: %w", err) + } + defer os.Remove(file) + + cmd := execIpfw("-q", file) + if out, err := cmd.CombinedOutput(); err != nil { + log.Infof("Error while deleting from table (%s): %v --> %s", cmd, err, out) + } + + return nil +} + +func (ctx *ipfwContext) init() error { + if err := ctx.shutDown(); err != nil { + return fmt.Errorf("ipfw table flush failed: %w", err) + } + + if err := ctx.checkTable(); err != nil { + return fmt.Errorf("ipfw init failed: %w", err) + } + + log.Infof("%s initiated for %s", backendName, ctx.version) + + return nil +} diff --git a/pkg/ipfw/metrics.go b/pkg/ipfw/metrics.go new file mode 100644 index 00000000..1652b17a --- /dev/null +++ b/pkg/ipfw/metrics.go @@ -0,0 +1,117 @@ +package ipfw + +import ( + "bufio" + "fmt" + "strconv" + "strings" + + "github.com/prometheus/client_golang/prometheus" + log "github.com/sirupsen/logrus" + + "github.com/crowdsecurity/cs-firewall-bouncer/pkg/metrics" +) + +type counter struct { + packets uint64 + bytes uint64 +} + +// parseMetrics reads the output of "ipfw -a list" and extracts the packet/byte +// counters of the rule(s) referencing one of the given tables, e.g.: +// +// 00100 16 4096 deny ip from table(crowdsec-blacklists) to any +func parseMetrics(reader *strings.Reader, tables []string) map[string]counter { + ret := make(map[string]counter) + + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) < 4 { + continue + } + + packets, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + + bytes, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + continue + } + + rule := strings.Join(fields[3:], " ") + + for _, table := range tables { + // table references look like "table(name)" or "table(name,value)" + if strings.Contains(rule, fmt.Sprintf("table(%s)", table)) || + strings.Contains(rule, fmt.Sprintf("table(%s,", table)) { + ret[table] = counter{packets: packets, bytes: bytes} + } + } + } + + return ret +} + +// countIPs returns the number of IPs in a table. +func countIPs(table string) int { + cmd := execIpfw("table", table, "list") + + out, err := cmd.Output() + if err != nil { + log.Errorf("failed to run 'ipfw table %s list': %s", table, err) + return 0 + } + + // one IP per line + return strings.Count(string(out), "\n") +} + +// CollectMetrics collects metrics from ipfw. +// In ipfw 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 ipfw tables. +func (fw *ipfw) CollectMetrics() { + tables := []string{} + + if fw.inet != nil { + tables = append(tables, fw.inet.table) + } + + if fw.inet6 != nil { + tables = append(tables, fw.inet6.table) + } + + cmd := execIpfw("-a", "list") + + out, err := cmd.Output() + if err != nil { + log.Errorf("failed to run 'ipfw -a list': %s", err) + return + } + + reader := strings.NewReader(string(out)) + stats := parseMetrics(reader, tables) + + for _, table := range tables { + st, ok := stats[table] + if !ok { + continue + } + + droppedPackets := float64(st.packets) + droppedBytes := float64(st.bytes) + bannedIPs := countIPs(table) + + if fw.inet != nil && table == fw.inet.table { + metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedPackets) + metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(droppedBytes) + metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv4", "origin": ""}).Set(float64(bannedIPs)) + } else if fw.inet6 != nil && table == fw.inet6.table { + metrics.Map[metrics.DroppedPackets].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedPackets) + metrics.Map[metrics.DroppedBytes].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(droppedBytes) + metrics.Map[metrics.ActiveBannedIPs].Gauge.With(prometheus.Labels{"ip_type": "ipv6", "origin": ""}).Set(float64(bannedIPs)) + } + } +} diff --git a/pkg/ipfw/metrics_test.go b/pkg/ipfw/metrics_test.go new file mode 100644 index 00000000..32d7141b --- /dev/null +++ b/pkg/ipfw/metrics_test.go @@ -0,0 +1,42 @@ +package ipfw + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseMetrics(t *testing.T) { + metricsInput := `00100 16 4096 deny ip from table(crowdsec-blacklists) to any +00200 8 2048 deny ip from table(crowdsec6-blacklists) to any +65535 0 0 allow ip from any to any` + + 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, uint64(16), ip4Metrics.packets) + assert.Equal(t, uint64(4096), ip4Metrics.bytes) + + ip6Metrics := metrics["crowdsec6-blacklists"] + assert.Equal(t, uint64(8), ip6Metrics.packets) + assert.Equal(t, uint64(2048), ip6Metrics.bytes) +} + +func TestParseMetricsNoPrefixCollision(t *testing.T) { + metricsInput := `00100 16 4096 deny ip from table(crowdsec-blacklists6) to any` + + reader := strings.NewReader(metricsInput) + tables := []string{"crowdsec-blacklists"} + + metrics := parseMetrics(reader, tables) + + assert.NotContains(t, metrics, "crowdsec-blacklists") +}