From 81e7a9dc6a08e1fbdeb744d30e88368d0bb2bf52 Mon Sep 17 00:00:00 2001 From: Ruakij Date: Sat, 24 Oct 2026 08:10:34 +0000 Subject: [PATCH] crowdsec_range_bouncer: enforce CrowdSec range decisions in nftables interval sets, the firewall bouncer stores only the network address of a range --- .../files/crowdsec-range-bouncer.service | 15 +++ .../files/crowdsec-range-bouncer.timer | 10 ++ crowdsec_range_bouncer/handlers/main.yml | 7 + crowdsec_range_bouncer/tasks/main.yml | 53 ++++++++ .../templates/crowdsec-range-bouncer.json.j2 | 1 + .../templates/crowdsec-range-bouncer.py.j2 | 124 ++++++++++++++++++ crowdsec_range_bouncer/vars/main.yml | 6 + 7 files changed, 216 insertions(+) create mode 100644 crowdsec_range_bouncer/files/crowdsec-range-bouncer.service create mode 100644 crowdsec_range_bouncer/files/crowdsec-range-bouncer.timer create mode 100644 crowdsec_range_bouncer/handlers/main.yml create mode 100644 crowdsec_range_bouncer/tasks/main.yml create mode 100644 crowdsec_range_bouncer/templates/crowdsec-range-bouncer.json.j2 create mode 100644 crowdsec_range_bouncer/templates/crowdsec-range-bouncer.py.j2 create mode 100644 crowdsec_range_bouncer/vars/main.yml diff --git a/crowdsec_range_bouncer/files/crowdsec-range-bouncer.service b/crowdsec_range_bouncer/files/crowdsec-range-bouncer.service new file mode 100644 index 0000000..2743cef --- /dev/null +++ b/crowdsec_range_bouncer/files/crowdsec-range-bouncer.service @@ -0,0 +1,15 @@ +[Unit] +Description=Mirror the CrowdSec range ban decisions into the nftables table inet crowdsec_ranges +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/crowdsec-range-bouncer +TimeoutStartSec=60 +StateDirectory=crowdsec-range-bouncer +StateDirectoryMode=0700 +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true diff --git a/crowdsec_range_bouncer/files/crowdsec-range-bouncer.timer b/crowdsec_range_bouncer/files/crowdsec-range-bouncer.timer new file mode 100644 index 0000000..7e62bec --- /dev/null +++ b/crowdsec_range_bouncer/files/crowdsec-range-bouncer.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Mirror the CrowdSec range ban decisions into nftables every minute + +[Timer] +OnBootSec=30s +OnUnitActiveSec=60s +AccuracySec=5s + +[Install] +WantedBy=timers.target diff --git a/crowdsec_range_bouncer/handlers/main.yml b/crowdsec_range_bouncer/handlers/main.yml new file mode 100644 index 0000000..70e97d5 --- /dev/null +++ b/crowdsec_range_bouncer/handlers/main.yml @@ -0,0 +1,7 @@ +--- +- name: Run the range bouncer + ansible.builtin.systemd: + name: crowdsec-range-bouncer.service + state: started + # Check mode does not install the unit. + ignore_errors: "{{ ansible_check_mode }}" diff --git a/crowdsec_range_bouncer/tasks/main.yml b/crowdsec_range_bouncer/tasks/main.yml new file mode 100644 index 0000000..085b3ff --- /dev/null +++ b/crowdsec_range_bouncer/tasks/main.yml @@ -0,0 +1,53 @@ +--- +# crowdsec-firewall-bouncer in nftables mode stores only the network address of +# a range decision (CIDR scope "Range") in a plain address set. This role keeps +# the ranges in interval sets of its own table, next to the firewall bouncer, +# which keeps handling the single addresses. +- name: Require LAPI settings + ansible.builtin.assert: + that: + - crowdsec.lapi_url | length > 0 + - crowdsec.range_bouncer_api_key | default('') | length > 0 + fail_msg: crowdsec.lapi_url and crowdsec.range_bouncer_api_key must be set + +- name: Install python3 and nftables + ansible.builtin.apt: + name: [python3, nftables] + state: present + +- name: Configure the range bouncer + ansible.builtin.template: + src: crowdsec-range-bouncer.json.j2 + dest: /etc/crowdsec-range-bouncer.json + mode: "0600" + no_log: true + notify: Run the range bouncer + +- name: Install the range bouncer + ansible.builtin.template: + src: crowdsec-range-bouncer.py.j2 + dest: /usr/local/sbin/crowdsec-range-bouncer + mode: "0755" + notify: Run the range bouncer + +- name: Install the systemd units + ansible.builtin.copy: + src: "{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + mode: "0644" + loop: [crowdsec-range-bouncer.service, crowdsec-range-bouncer.timer] + register: crowdsec_range_bouncer_units + notify: Run the range bouncer + +- name: Enable the range bouncer timer + ansible.builtin.systemd: + name: crowdsec-range-bouncer.timer + enabled: true + state: started + daemon_reload: "{{ crowdsec_range_bouncer_units is changed }}" + # Check mode does not install the unit. + ignore_errors: "{{ ansible_check_mode }}" + +# The first run has to succeed before the play moves on to the next host. +- name: Apply the changes now + ansible.builtin.meta: flush_handlers diff --git a/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.json.j2 b/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.json.j2 new file mode 100644 index 0000000..4e7b2aa --- /dev/null +++ b/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.json.j2 @@ -0,0 +1 @@ +{{ {'lapi_url': crowdsec.lapi_url, 'api_key': crowdsec.range_bouncer_api_key, 'allow': crowdsec_range_bouncer_allow} | to_nice_json }} diff --git a/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.py.j2 b/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.py.j2 new file mode 100644 index 0000000..7cd3053 --- /dev/null +++ b/crowdsec_range_bouncer/templates/crowdsec-range-bouncer.py.j2 @@ -0,0 +1,124 @@ +#!/usr/bin/python3 +# {{ ansible_managed }} +"""Mirror the CrowdSec range ban decisions into the nftables table inet crowdsec_ranges. + +Stateless: every run fetches all range decisions from the LAPI, builds the +whole table and loads it in one transaction. The table is only touched when +its text changed or the table is gone (nftables.service flushes the whole +ruleset on reload). Any error leaves the current table in place. +""" +import hashlib +import ipaddress +import json +import os +import subprocess +import sys +import urllib.request + +CONFIG = "/etc/crowdsec-range-bouncer.json" +STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/crowdsec-range-bouncer") +HASH_FILE = os.path.join(STATE_DIR, "last.sha256") +NFT = "/usr/sbin/nft" +TABLE = "crowdsec_ranges" + + +def fetch_ranges(lapi_url, api_key): + """Return the networks of all ban decisions with scope Range.""" + request = urllib.request.Request( + lapi_url.rstrip("/") + "/v1/decisions?scopes=range", + headers={"X-Api-Key": api_key, "User-Agent": "crowdsec-range-bouncer"}, + ) + with urllib.request.urlopen(request, timeout=20) as response: + decisions = json.load(response) + # The LAPI answers a literal null when nothing matches. + if decisions is None: + return [] + if not isinstance(decisions, list): + raise ValueError("unexpected LAPI answer: %s" % type(decisions).__name__) + return [ + ipaddress.ip_network(d["value"], strict=False) + for d in decisions + if d["type"] == "ban" and d["scope"].lower() == "range" + ] + + +def collapse(networks, version): + """Merge overlapping and adjacent networks: interval set elements must not overlap.""" + return [str(n) for n in ipaddress.collapse_addresses(n for n in networks if n.version == version)] + + +def set_text(name, address_type, elements): + text = "\tset %s {\n\t\ttype %s\n\t\tflags interval\n" % (name, address_type) + if elements: + text += "\t\telements = {\n\t\t\t%s\n\t\t}\n" % ",\n\t\t\t".join(elements) + return text + "\t}\n" + + +def table_text(allow, block): + # The accept of an allowed address only ends the chain of this table: + # host_firewall, Docker, Cilium and the CrowdSec firewall bouncer still decide. + # Same hooks and priority as the firewall bouncer: input, and forward for + # published Docker ports and guests. + rules = ( + "\t\tip saddr @allow4 accept\n" + "\t\tip6 saddr @allow6 accept\n" + "\t\tip saddr @block4 counter drop\n" + "\t\tip6 saddr @block6 counter drop\n" + ) + return ( + "table inet %(t)s {}\n" + "delete table inet %(t)s\n" + "table inet %(t)s {\n" + % {"t": TABLE} + + set_text("allow4", "ipv4_addr", collapse(allow, 4)) + + set_text("allow6", "ipv6_addr", collapse(allow, 6)) + + set_text("block4", "ipv4_addr", collapse(block, 4)) + + set_text("block6", "ipv6_addr", collapse(block, 6)) + + "\tchain input {\n\t\ttype filter hook input priority -10; policy accept;\n" + + "\t\tiifname \"lo\" accept\n" + + rules + + "\t}\n" + + "\tchain forward {\n\t\ttype filter hook forward priority -10; policy accept;\n" + + rules + + "\t}\n}\n" + ) + + +def table_exists(): + result = subprocess.run( + [NFT, "list", "table", "inet", TABLE], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + return result.returncode == 0 + + +def main(): + with open(CONFIG) as f: + config = json.load(f) + allow = [ipaddress.ip_network(v, strict=False) for v in config["allow"]] + block = fetch_ranges(config["lapi_url"], config["api_key"]) + text = table_text(allow, block) + digest = hashlib.sha256(text.encode()).hexdigest() + + try: + with open(HASH_FILE) as f: + last = f.read().strip() + except FileNotFoundError: + last = "" + if digest == last and table_exists(): + return + + subprocess.run([NFT, "-f", "-"], input=text.encode(), check=True) + tmp = HASH_FILE + ".tmp" + with open(tmp, "w") as f: + f.write(digest + "\n") + os.replace(tmp, HASH_FILE) + print("loaded %d IPv4 and %d IPv6 ranges from %d decisions" % ( + len(collapse(block, 4)), len(collapse(block, 6)), len(block))) + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print("error, keeping the current table: %s: %s" % (type(e).__name__, e), file=sys.stderr) + sys.exit(1) diff --git a/crowdsec_range_bouncer/vars/main.yml b/crowdsec_range_bouncer/vars/main.yml new file mode 100644 index 0000000..05e02c5 --- /dev/null +++ b/crowdsec_range_bouncer/vars/main.yml @@ -0,0 +1,6 @@ +--- +# Same sources as the crowdsec role (roles/crowdsec/vars/main.yml), plus loopback. +crowdsec_range_bouncer_allow: >- + {{ (crowdsec.whitelist | default({}) | dict2items | rejectattr('value', 'none') | map(attribute='value') | list + + groups['all'] | map('extract', hostvars, 'public_ips') | select('defined') | flatten + + ['127.0.0.0/8', '::1/128']) | unique }}