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,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)