132 lines
4.7 KiB
Django/Jinja
132 lines
4.7 KiB
Django/Jinja
#!/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)
|