diff --git a/pkg/iptables/iptables.go b/pkg/iptables/iptables.go index febba9d9..9d75a7be 100644 --- a/pkg/iptables/iptables.go +++ b/pkg/iptables/iptables.go @@ -56,22 +56,26 @@ func NewIPTables(config *cfg.BouncerConfig) (types.Backend, error) { v6Sets := make(map[string]*ipsetcmd.IPSet) ipv4Ctx := &ipTablesContext{ - version: "v4", - SetName: config.BlacklistsIpv4, - SetType: config.SetType, - SetSize: config.SetSize, - Chains: []string{}, - defaultSet: defaultSet, - target: target, + version: "v4", + SetName: config.BlacklistsIpv4, + SetType: config.SetType, + SetSize: config.SetSize, + Chains: []string{}, + defaultSet: defaultSet, + target: target, + loggingEnabled: config.DenyLog, + loggingPrefix: config.DenyLogPrefix, } ipv6Ctx := &ipTablesContext{ - version: "v6", - SetName: config.BlacklistsIpv6, - SetType: config.SetType, - SetSize: config.SetSize, - Chains: []string{}, - defaultSet: defaultSet, - target: target, + version: "v6", + SetName: config.BlacklistsIpv6, + SetType: config.SetType, + SetSize: config.SetSize, + Chains: []string{}, + defaultSet: defaultSet, + target: target, + loggingEnabled: config.DenyLog, + loggingPrefix: config.DenyLogPrefix, } ipv4Ctx.iptablesSaveBin, err = exec.LookPath("iptables-save") diff --git a/pkg/iptables/iptables_context.go b/pkg/iptables/iptables_context.go index c1a704b0..984ddb7a 100644 --- a/pkg/iptables/iptables_context.go +++ b/pkg/iptables/iptables_context.go @@ -19,6 +19,7 @@ import ( ) const chainName = "CROWDSEC_CHAIN" +const loggingChainName = "CROWDSEC_LOG" type ipTablesContext struct { version string @@ -42,6 +43,9 @@ type ipTablesContext struct { //Store the origin of the decisions, and use the index in the slice as the name //This is not stable (ie, between two runs, the index of a set can change), but it's (probably) not an issue originSetMapping []string + + loggingEnabled bool + loggingPrefix string } func (ctx *ipTablesContext) setupChain() { @@ -69,6 +73,43 @@ func (ctx *ipTablesContext) setupChain() { continue } } + + if ctx.loggingEnabled { + // Create the logging chain + cmd = []string{"-N", loggingChainName, "-t", "filter"} + + c = exec.Command(ctx.iptablesBin, cmd...) + + log.Infof("Creating logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) + + if out, err := c.CombinedOutput(); err != nil { + log.Errorf("error while creating logging chain : %v --> %s", err, string(out)) + return + } + + // Insert the logging rule + cmd = []string{"-I", loggingChainName, "-j", "LOG", "--log-prefix", ctx.loggingPrefix} + + c = exec.Command(ctx.iptablesBin, cmd...) + + log.Infof("Adding logging rule : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) + + if out, err := c.CombinedOutput(); err != nil { + log.Errorf("error while adding logging rule : %v --> %s", err, string(out)) + } + + // Add the desired target to the logging chain + + cmd = []string{"-A", loggingChainName, "-j", ctx.target} + + c = exec.Command(ctx.iptablesBin, cmd...) + + log.Infof("Adding target rule to logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) + + if out, err := c.CombinedOutput(); err != nil { + log.Errorf("error while setting logging chain policy : %v --> %s", err, string(out)) + } + } } func (ctx *ipTablesContext) deleteChain() { @@ -105,10 +146,38 @@ func (ctx *ipTablesContext) deleteChain() { if out, err := c.CombinedOutput(); err != nil { log.Errorf("error while deleting chain : %v --> %s", err, string(out)) } + + if ctx.loggingEnabled { + cmd = []string{"-F", loggingChainName} + + c = exec.Command(ctx.iptablesBin, cmd...) + + log.Infof("Flushing logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) + + if out, err := c.CombinedOutput(); err != nil { + log.Errorf("error while flushing logging chain : %v --> %s", err, string(out)) + } + + cmd = []string{"-X", loggingChainName} + + c = exec.Command(ctx.iptablesBin, cmd...) + + log.Infof("Deleting logging chain : %s %s", ctx.iptablesBin, strings.Join(cmd, " ")) + + if out, err := c.CombinedOutput(); err != nil { + log.Errorf("error while deleting logging chain : %v --> %s", err, string(out)) + } + } } func (ctx *ipTablesContext) createRule(setName string) { - cmd := []string{"-I", chainName, "-m", "set", "--match-set", setName, "src", "-j", ctx.target} + target := ctx.target + + if ctx.loggingEnabled { + target = loggingChainName + } + + cmd := []string{"-I", chainName, "-m", "set", "--match-set", setName, "src", "-j", target} c := exec.Command(ctx.iptablesBin, cmd...) diff --git a/test/backends/iptables/crowdsec-firewall-bouncer-logging.yaml b/test/backends/iptables/crowdsec-firewall-bouncer-logging.yaml new file mode 100644 index 00000000..4d75774a --- /dev/null +++ b/test/backends/iptables/crowdsec-firewall-bouncer-logging.yaml @@ -0,0 +1,15 @@ +mode: iptables +update_frequency: 0.1s +log_mode: stdout +log_dir: ./ +log_level: info +api_url: http://127.0.0.1:8081/ +api_key: 1237adaf7a1724ac68a3288828820a67 +disable_ipv6: false +deny_action: DROP +deny_log: true +deny_log_prefix: "blocked by crowdsec" +supported_decisions_types: + - ban +iptables_chains: + - INPUT diff --git a/test/backends/iptables/test_iptables.py b/test/backends/iptables/test_iptables.py index 9edda8d2..079ce8e0 100644 --- a/test/backends/iptables/test_iptables.py +++ b/test/backends/iptables/test_iptables.py @@ -7,18 +7,20 @@ from time import sleep from test.backends.mock_lapi import MockLAPI -from test.backends.utils import generate_n_decisions, run_cmd +from test.backends.utils import generate_n_decisions, run_cmd, new_decision SCRIPT_DIR = Path(os.path.dirname(os.path.realpath(__file__))) PROJECT_ROOT = SCRIPT_DIR.parent.parent.parent BINARY_PATH = PROJECT_ROOT.joinpath("crowdsec-firewall-bouncer") CONFIG_PATH = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer.yaml") +CONFIG_PATH_LOGGING = SCRIPT_DIR.joinpath("crowdsec-firewall-bouncer-logging.yaml") SET_NAME_IPV4 = "crowdsec-blacklists-0" SET_NAME_IPV6 = "crowdsec6-blacklists-0" RULES_CHAIN_NAME = "CROWDSEC_CHAIN" +LOGGING_CHAIN_NAME = "CROWDSEC_LOG" CHAIN_NAME = "INPUT" class TestIPTables(unittest.TestCase): @@ -175,3 +177,55 @@ def get_set_elements(set_name, with_timeout=False): to_add = member.find("elem").text elements.add(to_add) return elements + + +class TestIPTablesLogging(unittest.TestCase): + def setUp(self): + self.fb = subprocess.Popen([BINARY_PATH, "-c", CONFIG_PATH_LOGGING]) + self.lapi = MockLAPI() + self.lapi.start() + return super().setUp() + + def tearDown(self): + self.fb.kill() + self.fb.wait() + self.lapi.stop() + + def testLogging(self): + #We use 1.1.1.1 because we want to see some dropped packets in the logs + #We know this IP responds to ping, and the response will be dropped by the firewall + d = new_decision("1.1.1.1") + self.lapi.ds.insert_decisions([d]) + sleep(3) + + #Check if our logging chain is in place + + output = run_cmd("iptables", "-L", LOGGING_CHAIN_NAME) + rules = [line for line in output.split("\n") if 'anywhere' in line] + + #2 rules: one logging, one generic drop + self.assertEqual(len(rules), 2) + + #Check if the logging chain is called from the main chain + output = run_cmd("iptables", "-L", CHAIN_NAME) + + rules = [line for line in output.split("\n") if RULES_CHAIN_NAME in line] + + self.assertEqual(len(rules), 1) + + #Check if logging/drop chain is called from the rules chain + output = run_cmd("iptables", "-L", RULES_CHAIN_NAME) + + rules = [line for line in output.split("\n") if LOGGING_CHAIN_NAME in line] + + self.assertEqual(len(rules), 1) + + #Now, try to ping the IP + + output = run_cmd("curl", "--connect-timeout", "1", "1.1.1.1", ignore_error=True) #We don't care about the output, we just want to trigger the rule + + #Check if the firewall has logged the dropped response + + output = run_cmd("dmesg | tail -n 10", shell=True) + + assert 'blocked by crowdsec' in output \ No newline at end of file diff --git a/test/backends/utils.py b/test/backends/utils.py index d27b08e4..5335f9c5 100644 --- a/test/backends/utils.py +++ b/test/backends/utils.py @@ -2,8 +2,8 @@ from ipaddress import ip_address -def run_cmd(*cmd, ignore_error=False): - p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) +def run_cmd(*cmd, ignore_error=False, shell=False): + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, shell=shell) if not ignore_error and p.returncode: raise SystemExit(f"{cmd} exited with non-zero code with following logs:\n {p.stdout}") @@ -34,3 +34,13 @@ def generate_n_decisions(n: int, action="ban", dup_count=0, ipv4=True, duration= decisions += decisions[: n % unique_decision_count] decisions *= n // unique_decision_count return decisions + +def new_decision(ip: str): + return { + "value": ip, + "scope": "ip", + "type": "ban", + "origin": "script", + "duration": "4h", + "reason": "for testing", + } \ No newline at end of file