crowdsec_range_bouncer: enforce CrowdSec range decisions in nftables interval sets, the firewall bouncer stores only the network address of a range

This commit is contained in:
2026-10-24 08:10:34 +00:00
parent 5cac169224
commit 81e7a9dc6a
7 changed files with 216 additions and 0 deletions
@@ -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
@@ -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
+7
View File
@@ -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 }}"
+53
View File
@@ -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
@@ -0,0 +1 @@
{{ {'lapi_url': crowdsec.lapi_url, 'api_key': crowdsec.range_bouncer_api_key, 'allow': crowdsec_range_bouncer_allow} | to_nice_json }}
@@ -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)
+6
View File
@@ -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 }}