diff --git a/README.md b/README.md index 11e21f91..a338d2b6 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Supported firewalls: - iptables (IPv4 :heavy_check_mark: / IPv6 :heavy_check_mark: ) - 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: ) ## Installation @@ -87,7 +88,7 @@ iptables_chains: - FORWARD ``` - - `mode` can be set to `iptables`, `nftables` or `ipset` + - `mode` can be set to `iptables`, `nftables` , `ipset` or `pf` - `update_frequency` controls how often the bouncer is going to query the local API - `api_url` and `api_key` control local API parameters. - `iptables_chains` allows (in _iptables_ mode) to control in which chain rules are going to be inserted. (if empty, bouncer will only maintain ipset lists) @@ -111,3 +112,12 @@ logs can be found in `/var/log/cs-firewall-bouncer.log` - mode `nftables` relies on github.com/google/nftables to create table, chain and set. - mode `iptables` relies on `iptables` and `ipset` commands to insert `match-set` directives and maintain associated ipsets - mode `ipset` relies on `ipset` and only manage contents of the sets (they need to exist at startup and will be flushed rather than created) + - mode `pf` relies on `pfctl` command to alter the tables. You are required to create the following tables on your `pf.conf` configuration: + + ``` + # create crowdsec ipv4 table +table persist + +# create crowdsec ipv6 table +table persist + ``` diff --git a/backend.go b/backend.go index 4f045b54..a5d49d1e 100644 --- a/backend.go +++ b/backend.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "runtime" "github.com/crowdsecurity/crowdsec/pkg/models" log "github.com/sirupsen/logrus" @@ -48,6 +49,19 @@ func (b *backendCTX) Delete(decision *models.Decision) error { return nil } +func isPFSupported(runtimeOS string) bool { + var supported bool + + switch runtimeOS { + case "openbsd", "freebsd": + supported = true + default: + supported = false + } + + return supported +} + func newBackend(config *bouncerConfig) (*backendCTX, error) { var ok bool @@ -58,6 +72,9 @@ func newBackend(config *bouncerConfig) (*backendCTX, error) { } switch config.Mode { case "iptables", "ipset": + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("iptables and ipset is linux only") + } tmpCtx, err := newIPTables(config) if err != nil { return nil, err @@ -67,6 +84,9 @@ func newBackend(config *bouncerConfig) (*backendCTX, error) { return nil, fmt.Errorf("unexpected type '%T' for iptables context", tmpCtx) } case "nftables": + if runtime.GOOS != "linux" { + return nil, fmt.Errorf("nftables is linux only") + } tmpCtx, err := newNFTables(config) if err != nil { return nil, err @@ -75,6 +95,18 @@ func newBackend(config *bouncerConfig) (*backendCTX, error) { if !ok { return nil, fmt.Errorf("unexpected type '%T' for nftables context", tmpCtx) } + case "pf": + if !isPFSupported(runtime.GOOS) { + return nil, fmt.Errorf("pf mode is supported only for openbsd and freebsd") + } + tmpCtx, err := newPF(config) + if err != nil { + return nil, err + } + b.firewall, ok = tmpCtx.(backend) + if !ok { + return nil, fmt.Errorf("unexpected type '%T' for pf context", tmpCtx) + } default: return b, fmt.Errorf("firewall '%s' is not supported", config.Mode) } diff --git a/iptables.go b/iptables.go index 4cfa8b01..c8dd491d 100644 --- a/iptables.go +++ b/iptables.go @@ -1,3 +1,5 @@ +// +build linux + package main import ( diff --git a/iptables_context.go b/iptables_context.go index e28101c7..eb0c7fc0 100644 --- a/iptables_context.go +++ b/iptables_context.go @@ -1,3 +1,5 @@ +// +build linux + package main import ( diff --git a/iptables_stub.go b/iptables_stub.go new file mode 100644 index 00000000..b1276a2e --- /dev/null +++ b/iptables_stub.go @@ -0,0 +1,7 @@ +// +build !linux + +package main + +func newIPTables(config *bouncerConfig) (interface{}, error) { + return nil, nil +} diff --git a/nftables.go b/nftables.go index e8f952fc..e374735a 100644 --- a/nftables.go +++ b/nftables.go @@ -1,3 +1,5 @@ +// +build linux + package main import ( diff --git a/nftables_stub.go b/nftables_stub.go new file mode 100644 index 00000000..3a457c8b --- /dev/null +++ b/nftables_stub.go @@ -0,0 +1,7 @@ +// +build !linux + +package main + +func newNFTables(config *bouncerConfig) (interface{}, error) { + return nil, nil +} diff --git a/pf.go b/pf.go new file mode 100644 index 00000000..f519dd75 --- /dev/null +++ b/pf.go @@ -0,0 +1,211 @@ +// +build openbsd freebsd + +package main + +import ( + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/crowdsecurity/crowdsec/pkg/models" + "github.com/pkg/errors" + + log "github.com/sirupsen/logrus" +) + +type pfContext struct { + proto string + table string + version string +} + +type pf struct { + inet *pfContext + inet6 *pfContext +} + +const ( + backendName = "pf" + + pfinetTable = "crowdsec-blacklists" + pfinet6Table = "crowdsec6-blacklists" + + pfctlCmd = "/sbin/pfctl" + pfDevice = "/dev/pf" + + addBanFormat = "%s: add ban on %s for %s sec (%s)" + delBanFormat = "%s: del ban on %s for %s sec (%s)" +) + +func newPF(config *bouncerConfig) (interface{}, error) { + ret := &pf{} + + inetCtx := &pfContext{ + table: pfinetTable, + proto: "inet", + version: "ipv4", + } + + inet6Ctx := &pfContext{ + table: pfinet6Table, + proto: "inet6", + version: "ipv6", + } + + ret.inet = inetCtx + + if config.DisableIPV6 { + return ret, nil + } + + ret.inet6 = inet6Ctx + + return ret, nil +} + +func (ctx *pfContext) checkTable() error { + log.Infof("Checking pf table: %s", ctx.table) + + cmd := exec.Command(pfctlCmd, "-s", "Tables") + out, err := cmd.CombinedOutput() + + if err != nil { + return errors.Wrapf(err, "pfctl error : %v - %s", err, string(out)) + } else if !strings.Contains(string(out), ctx.table) { + return errors.Errorf("table %s doesn't exist", ctx.table) + } + + return nil +} + +func (ctx *pfContext) shutDown() error { + cmd := exec.Command(pfctlCmd, "-t", ctx.table, "-T", "flush") + log.Infof("pf table clean-up : %s", cmd.String()) + if out, err := cmd.CombinedOutput(); err != nil { + log.Errorf("Error while flushing table (%s): %v - %s", err, string(out)) + } + + return nil +} + +func (ctx *pfContext) Add(decision *models.Decision) error { + banDuration, err := time.ParseDuration(*decision.Duration) + if err != nil { + return err + } + log.Debugf(addBanFormat, backendName, *decision.Value, strconv.Itoa(int(banDuration.Seconds())), *decision.Scenario) + cmd := exec.Command(pfctlCmd, "-t", ctx.table, "-T", "add", *decision.Value) + log.Debugf("pfctl add : %s", cmd.String()) + if out, err := cmd.CombinedOutput(); err != nil { + log.Infof("Error while adding to table (%s): %v --> %s", cmd.String(), err, string(out)) + } + + return nil +} + +func (ctx *pfContext) Delete(decision *models.Decision) error { + banDuration, err := time.ParseDuration(*decision.Duration) + if err != nil { + return err + } + log.Debugf(delBanFormat, backendName, *decision.Value, strconv.Itoa(int(banDuration.Seconds())), *decision.Scenario) + cmd := exec.Command(pfctlCmd, "-t", ctx.table, "-T", "delete", *decision.Value) + log.Debugf("pfctl del : %s", cmd.String()) + if out, err := cmd.CombinedOutput(); err != nil { + log.Infof("Error while deleting from table (%s): %v --> %s", cmd.String(), err, string(out)) + } + return nil +} + +func initPF(ctx *pfContext) error { + + if err := ctx.shutDown(); err != nil { + return fmt.Errorf("pf table flush failed: %s", err.Error()) + } + if err := ctx.checkTable(); err != nil { + return fmt.Errorf("pf init failed: %s", err.Error()) + } + log.Infof("%s initiated for %s", backendName, ctx.version) + + return nil +} + +func (pf *pf) Init() error { + + if _, err := os.Stat(pfDevice); err != nil { + return fmt.Errorf("%s device not found: %s", pfDevice, err.Error()) + } + + if _, err := exec.LookPath(pfctlCmd); err != nil { + return fmt.Errorf("%s command not found: %s", pfctlCmd, err.Error()) + } + + if err := initPF(pf.inet); err != nil { + return err + } + + if pf.inet6 != nil { + if err := initPF(pf.inet6); err != nil { + return err + } + } + + return nil +} + +func (pf *pf) Add(decision *models.Decision) error { + if strings.Contains(*decision.Value, ":") && pf.inet6 != nil { // inet6 + if pf.inet6 != nil { + if err := pf.inet6.Add(decision); err != nil { + return fmt.Errorf("failed to add ban ip '%s' to inet6 table", *decision.Value) + } + } else { + log.Debugf("not adding '%s' because ipv6 is disabled", *decision.Value) + return nil + } + } else { // inet + if err := pf.inet.Add(decision); err != nil { + return fmt.Errorf("failed adding ban ip '%s' to inet table", *decision.Value) + } + } + + return nil +} + +func (pf *pf) Delete(decision *models.Decision) error { + if strings.Contains(*decision.Value, ":") { // ipv6 + if pf.inet6 != nil { + if err := pf.inet6.Delete(decision); err != nil { + return fmt.Errorf("failed to remove ban ip '%s' from inet6 table", *decision.Value) + } + } else { + log.Debugf("not removing '%s' because ipv6 is disabled", *decision.Value) + return nil + } + } else { // ipv4 + if err := pf.inet.Delete(decision); err != nil { + return fmt.Errorf("failed to remove ban ip '%s' from inet6 table", *decision.Value) + } + } + + return nil +} + +func (pf *pf) ShutDown() error { + log.Infof("flushing 'crowdsec' table(s)") + + if err := pf.inet.shutDown(); err != nil { + return fmt.Errorf("unable to flush %s table (%s): ", pf.inet.version, pf.inet.table) + } + + if pf.inet6 != nil { + if err := pf.inet6.shutDown(); err != nil { + return fmt.Errorf("unable to flush %s table (%s): ", pf.inet6.version, pf.inet6.table) + } + } + + return nil +} diff --git a/pf_stub.go b/pf_stub.go new file mode 100644 index 00000000..55f82cb4 --- /dev/null +++ b/pf_stub.go @@ -0,0 +1,7 @@ +// +build !openbsd,!freebsd + +package main + +func newPF(config *bouncerConfig) (interface{}, error) { + return nil, nil +}