From ca34bd83637bf21d6659555b4b08449d6d0dd6e5 Mon Sep 17 00:00:00 2001 From: Ruakij Date: Fri, 30 Oct 2026 09:01:25 +0000 Subject: [PATCH] host_firewall: GreenSnow set greensnow_v4 drops inbound new connections of the listed hosts --- host_firewall/defaults/main.yml | 10 +- host_firewall/files/nft-host-greensnow | 129 ++++++++++++++++++ .../files/nft-host-greensnow.service | 8 ++ host_firewall/files/nft-host-greensnow.timer | 10 ++ host_firewall/tasks/main.yml | 55 +++++++- host_firewall/templates/host.nft.j2 | 25 +++- 6 files changed, 234 insertions(+), 3 deletions(-) create mode 100755 host_firewall/files/nft-host-greensnow create mode 100644 host_firewall/files/nft-host-greensnow.service create mode 100644 host_firewall/files/nft-host-greensnow.timer diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index a763e28..b702b97 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -83,7 +83,8 @@ firewall: # Lets the kernel log the nft log statements of container network # namespaces (LXC guests) into the host kernel log. all_netns: false - # Shared by the Spamhaus (fw-spamhaus-in: , fw-spamhaus-out: ), FireHOL (fw-firehol-in: , fw-firehol-out: ) and scanner (fw-scanners-in: ) chains, one limit each. + # Shared by the Spamhaus (fw-spamhaus-in: , fw-spamhaus-out: ), FireHOL (fw-firehol-in: , fw-firehol-out: ), scanner (fw-scanners-in: ) and + # GreenSnow (fw-greensnow-in: ) chains, one limit each. spamhaus_rate: "6/minute burst 10 packets" # Spamhaus DROP, DROPv6 and ASN-DROP: traffic from the listed networks drops # (input, forward), traffic to them is rejected (output, forward). The sets @@ -104,6 +105,13 @@ firewall: # of any host, and ones holding an address of the host are left out # (/etc/nft-host-scanners.allowlist). Refreshed daily by nft-host-scanners. scanners: true + # GreenSnow (IPv4 single addresses, compromised and dynamic hosts from the + # FireHOL blocklist-ipsets): inbound new connections from the listed hosts + # drop, outbound traffic is not filtered. Log prefix fw-greensnow-in: with its + # own limit, which CrowdSec whitelists. Entries in private or reserved space + # and ones holding an address of the host are left out. Refreshed daily by + # nft-host-greensnow. + greensnow: true # Reject outbound TCP 25; the mail group turns it off. block_smtp_out: true # EU-27 plus CH, NO, GB, IS, LI (ipdeny country codes). Empty leaves the geo diff --git a/host_firewall/files/nft-host-greensnow b/host_firewall/files/nft-host-greensnow new file mode 100755 index 0000000..2b63d04 --- /dev/null +++ b/host_firewall/files/nft-host-greensnow @@ -0,0 +1,129 @@ +#!/usr/bin/python3 +# Managed by Ansible (role host_firewall), do not edit. +"""Rebuilds the GreenSnow set greensnow_v4 of table inet host. + +Source: the GreenSnow list of the FireHOL blocklist-ipsets (single IPv4 +addresses, comment lines start with #). A failed download or sanity check +exits non-zero before anything changes, so the previous set stays in place. +""" +import ipaddress +import json +import os +import shutil +import subprocess +import sys +import tempfile +import time +import urllib.request + +PERSISTED = "/etc/nftables.d/host-greensnow.nft" +URL = "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/greensnow.ipset" +USER_AGENT = "ruekov-host-firewall (nft-host-greensnow)" + +# Lower bound that catches an empty or truncated answer (the list holds about +# 4700 entries). +MIN_ENTRIES = 500 +# A prefix this wide is a parse error, not a bad address. +MIN_PREFIXLEN = 8 +# Private, CGNAT (NetBird), loopback, link-local and multicast/reserved space: +# an entry overlapping one of these never lands in the set, so the own +# infrastructure cannot block itself. +EXCLUDED = [ipaddress.ip_network(n) for n in ( + "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", + "169.254.0.0/16", "172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/3")] + + +def fail(message): + print(f"nft-host-greensnow: {message}", file=sys.stderr) + sys.exit(1) + + +def fetch(): + error = None + for attempt in range(3): + try: + request = urllib.request.Request(URL, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(request, timeout=120) as response: + return response.read().decode("utf-8") + except OSError as e: + error = e + time.sleep(5 * (attempt + 1)) + fail(f"download of {URL} failed: {error}") + + +def own_addresses(): + result = subprocess.run(["ip", "-j", "-4", "address", "show"], capture_output=True, text=True) + if result.returncode != 0: + fail(f"ip address show failed: {result.stderr.strip()}") + return [ipaddress.ip_address(a["local"]) for link in json.loads(result.stdout) + for a in link.get("addr_info", []) if a.get("family") == "inet"] + + +def main(): + networks = [] + for line in fetch().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + try: + networks.append(ipaddress.IPv4Network(line)) + except ValueError: + fail(f"unparsable line: {line!r}") + if len(networks) < MIN_ENTRIES: + fail(f"only {len(networks)} entries") + + own = own_addresses() + kept, excluded, own_skipped = [], 0, 0 + for network in networks: + if network.prefixlen < MIN_PREFIXLEN: + fail(f"suspiciously wide prefix {network}") + if any(network.overlaps(e) for e in EXCLUDED): + excluded += 1 + elif any(address in network for address in own): + own_skipped += 1 + else: + kept.append(network) + merged = list(ipaddress.collapse_addresses(kept)) + print(f"greensnow_v4: {len(networks)} entries, {excluded} in private or reserved space and " + f"{own_skipped} holding an own address skipped -> {len(merged)} merged") + + elements = ",\n\t\t".join(str(n) for n in merged) + "," + persisted = ("# Generated by nft-host-greensnow, do not edit.\n" + "table inet host {\n" + "\tset greensnow_v4 { type ipv4_addr; flags interval; auto-merge; elements = {\n" + f"\t\t{elements}\n\t}} }}\n}}\n") + + # Checked under another table name: a dry run against the live table + # name changes the live sets for its duration and unlisted addresses match. + check_rules = persisted.replace("table inet host {", "table inet host_greensnow_check {") + checked = subprocess.run(["nft", "-c", "-f", "-"], input=check_rules, capture_output=True, text=True) + if checked.returncode != 0: + fail(f"nft rejects the set: {checked.stderr.strip()}") + + with tempfile.TemporaryDirectory() as tmp: + previous = None + if os.path.exists(PERSISTED): + previous = os.path.join(tmp, "previous.nft") + shutil.copyfile(PERSISTED, previous) + with open(PERSISTED + ".new", "w", encoding="utf-8") as f: + f.write(persisted) + os.chmod(PERSISTED + ".new", 0o644) + os.replace(PERSISTED + ".new", PERSISTED) + + # A reload replaces the whole table, sets included, in one transaction. + active = subprocess.run(["systemctl", "-q", "is-active", "nft-host.service"]).returncode == 0 + if active: + reloaded = subprocess.run(["systemctl", "reload", "nft-host.service"], capture_output=True, text=True) + if reloaded.returncode != 0: + if previous: + shutil.copyfile(previous, PERSISTED) + fail(f"reloading the rule set failed, kept the previous set: {reloaded.stderr.strip()}") + print("reloaded the rule set" if active else "persisted only") + + +try: + main() +except SystemExit: + raise +except Exception as e: # keep the previous set on any surprise + fail(f"{type(e).__name__}: {e}") diff --git a/host_firewall/files/nft-host-greensnow.service b/host_firewall/files/nft-host-greensnow.service new file mode 100644 index 0000000..5bce4af --- /dev/null +++ b/host_firewall/files/nft-host-greensnow.service @@ -0,0 +1,8 @@ +[Unit] +Description=Refresh the GreenSnow set of the host firewall +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/nft-host-greensnow diff --git a/host_firewall/files/nft-host-greensnow.timer b/host_firewall/files/nft-host-greensnow.timer new file mode 100644 index 0000000..07ff81e --- /dev/null +++ b/host_firewall/files/nft-host-greensnow.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily refresh of the host firewall GreenSnow set + +[Timer] +OnCalendar=daily +RandomizedDelaySec=6h +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/host_firewall/tasks/main.yml b/host_firewall/tasks/main.yml index f0fda78..f207c8b 100644 --- a/host_firewall/tasks/main.yml +++ b/host_firewall/tasks/main.yml @@ -59,7 +59,7 @@ src: "{{ item }}" dest: "/etc/systemd/system/{{ item }}" mode: "0644" - loop: [nft-host.service, nft-host-geo.service, nft-host-geo.timer, nft-host-spamhaus.service, nft-host-spamhaus.timer, nft-host-firehol.service, nft-host-firehol.timer, nft-host-scanners.service, nft-host-scanners.timer] + loop: [nft-host.service, nft-host-geo.service, nft-host-geo.timer, nft-host-spamhaus.service, nft-host-spamhaus.timer, nft-host-firehol.service, nft-host-firehol.timer, nft-host-scanners.service, nft-host-scanners.timer, nft-host-greensnow.service, nft-host-greensnow.timer] register: firewall_units - name: Install the rollback script @@ -287,6 +287,59 @@ path: /etc/nftables.d/host-scanners.nft state: absent +- name: GreenSnow set + when: firewall.greensnow + block: + - name: Install the GreenSnow set updater + ansible.builtin.copy: + src: nft-host-greensnow + dest: /usr/local/sbin/nft-host-greensnow + mode: "0755" + + # The rule set includes this file, so a missing one would fail the load at boot. + - name: Build the GreenSnow set on first run + ansible.builtin.command: /usr/local/sbin/nft-host-greensnow + args: + creates: /etc/nftables.d/host-greensnow.nft + + - name: Read the GreenSnow set file + ansible.builtin.stat: + path: /etc/nftables.d/host-greensnow.nft + register: firewall_greensnow + + - name: Require non-empty GreenSnow set + ansible.builtin.assert: + that: firewall_greensnow.stat.exists and firewall_greensnow.stat.size > 0 + fail_msg: /etc/nftables.d/host-greensnow.nft is missing or empty, refusing to apply the firewall + # Check mode neither installs the updater nor builds the set. + when: not ansible_check_mode + + - name: Enable the GreenSnow set timer + ansible.builtin.systemd: + name: nft-host-greensnow.timer + enabled: true + state: started + daemon_reload: "{{ firewall_units is changed }}" + # Check mode does not install the unit. + ignore_errors: "{{ ansible_check_mode }}" + +- name: Stop the GreenSnow set refresh + when: not firewall.greensnow + block: + - name: Disable the GreenSnow set timer + ansible.builtin.systemd: + name: nft-host-greensnow.timer + enabled: false + state: stopped + daemon_reload: "{{ firewall_units is changed }}" + # Check mode does not install the unit. + ignore_errors: "{{ ansible_check_mode }}" + + - name: Remove the GreenSnow set file + ansible.builtin.file: + path: /etc/nftables.d/host-greensnow.nft + state: absent + - name: Render the rule set ansible.builtin.template: src: host.nft.j2 diff --git a/host_firewall/templates/host.nft.j2 b/host_firewall/templates/host.nft.j2 index e0ee024..20e1f79 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -82,6 +82,9 @@ include "/etc/nftables.d/host-firehol.nft" {% if firewall.scanners %} include "/etc/nftables.d/host-scanners.nft" {% endif %} +{% if firewall.greensnow %} +include "/etc/nftables.d/host-greensnow.nft" +{% endif %} table inet host { {{ addr_set('admin_v4', 'ipv4_addr', firewall.admin.v4) }} @@ -154,6 +157,12 @@ table inet host { ip saddr @scanners_v4 jump scanners_in ip6 saddr @scanners_v6 jump scanners_in +{% endif %} +{% if firewall.greensnow %} + + # After the established rule: only inbound connections of GreenSnow hosts drop. + ip saddr @greensnow_v4 jump greensnow_in + {% endif %} ct state invalid jump drop_log iif lo accept @@ -317,6 +326,16 @@ table inet host { drop } {% endif %} +{% if firewall.greensnow %} + + # Own limit and prefix, so GreenSnow hits use up neither the fw-in-drop budget + # nor the one of the blocklists. CrowdSec whitelists the prefix: the listed + # hosts are known and need no decision. + chain greensnow_in { + limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-greensnow-in: " + drop + } +{% endif %} {% if firewall.block_smtp_out or blocklists %} # Outbound mail goes through the mail host only. Loopback stays open for a @@ -394,7 +413,7 @@ table inet host { ip6 saddr @geo_v6 accept {% endif %} } -{% if forward_guard or blocklists or firewall.scanners or rl_forward %} +{% if forward_guard or blocklists or firewall.scanners or firewall.greensnow or rl_forward %} chain forward { type filter hook forward priority filter; policy accept; @@ -423,6 +442,10 @@ table inet host { ct state new ip saddr @scanners_v4 jump scanners_in ct state new ip6 saddr @scanners_v6 jump scanners_in {% endif %} +{% if firewall.greensnow %} + + ct state new ip saddr @greensnow_v4 jump greensnow_in +{% endif %} {% for guard in forward_guard %} # {{ guard.key }}