#!/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" # A prefix this wide is a poisoned decision, not a bad network. MIN_DECISION_PREFIXLEN = {4: 8, 6: 16} 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__) networks = [] for d in decisions: if d["type"] != "ban" or d["scope"].lower() != "range": continue network = ipaddress.ip_network(d["value"], strict=False) if network.prefixlen < MIN_DECISION_PREFIXLEN[network.version]: print("skipping suspiciously wide range decision %s" % network) continue networks.append(network) return networks 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)