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:
@@ -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)
|
||||
Reference in New Issue
Block a user