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