diff --git a/host_firewall/defaults/main.yml b/host_firewall/defaults/main.yml index 0de43c0..304a683 100644 --- a/host_firewall/defaults/main.yml +++ b/host_firewall/defaults/main.yml @@ -54,12 +54,17 @@ 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 drop (fw-spamhaus-in: ) and reject (fw-spamhaus-out: ) chains, one limit each. + # Shared by the Spamhaus (fw-spamhaus-in: , fw-spamhaus-out: ) and FireHOL (fw-firehol-in: , fw-firehol-out: ) 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 # are refreshed daily by nft-host-spamhaus. spamhaus: true + # FireHOL level1 (IPv4): treated like the Spamhaus lists, with the prefixes + # fw-firehol-in: and fw-firehol-out: and the same log limit. Entries in + # private or reserved space and ones holding an address of the host are left + # out. Refreshed daily by nft-host-firehol. + firehol: 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-firehol b/host_firewall/files/nft-host-firehol new file mode 100644 index 0000000..d56ea0d --- /dev/null +++ b/host_firewall/files/nft-host-firehol @@ -0,0 +1,127 @@ +#!/usr/bin/python3 +# Managed by Ansible (role host_firewall), do not edit. +"""Rebuilds the FireHOL level1 set firehol_v4 of table inet host. + +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-firehol.nft" +URL = "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset" +USER_AGENT = "ruekov-host-firewall (nft-host-firehol)" + +# Lower bound that catches an empty or truncated answer. +MIN_ENTRIES = 1000 +# A prefix this wide is a parse error, not a bad network. +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-firehol: {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 any(network.overlaps(e) for e in EXCLUDED): + excluded += 1 + elif any(address in network for address in own): + own_skipped += 1 + else: + if network.prefixlen < MIN_PREFIXLEN: + fail(f"suspiciously wide prefix {network}") + kept.append(network) + merged = list(ipaddress.collapse_addresses(kept)) + print(f"firehol_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-firehol, do not edit.\n" + "table inet host {\n" + "\tset firehol_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_firehol_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-firehol.service b/host_firewall/files/nft-host-firehol.service new file mode 100644 index 0000000..2323946 --- /dev/null +++ b/host_firewall/files/nft-host-firehol.service @@ -0,0 +1,8 @@ +[Unit] +Description=Refresh the FireHOL set of the host firewall +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/nft-host-firehol diff --git a/host_firewall/files/nft-host-firehol.timer b/host_firewall/files/nft-host-firehol.timer new file mode 100644 index 0000000..3e0b6b5 --- /dev/null +++ b/host_firewall/files/nft-host-firehol.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily refresh of the host firewall FireHOL 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 ac56c14..28f4347 100644 --- a/host_firewall/tasks/main.yml +++ b/host_firewall/tasks/main.yml @@ -43,7 +43,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] + 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] register: firewall_units - name: Install the rollback script @@ -158,6 +158,59 @@ path: /etc/nftables.d/host-spamhaus.nft state: absent +- name: FireHOL set + when: firewall.firehol + block: + - name: Install the FireHOL set updater + ansible.builtin.copy: + src: nft-host-firehol + dest: /usr/local/sbin/nft-host-firehol + mode: "0755" + + # The rule set includes this file, so a missing one would fail the load at boot. + - name: Build the FireHOL set on first run + ansible.builtin.command: /usr/local/sbin/nft-host-firehol + args: + creates: /etc/nftables.d/host-firehol.nft + + - name: Read the FireHOL set file + ansible.builtin.stat: + path: /etc/nftables.d/host-firehol.nft + register: firewall_firehol + + - name: Require non-empty FireHOL set + ansible.builtin.assert: + that: firewall_firehol.stat.exists and firewall_firehol.stat.size > 0 + fail_msg: /etc/nftables.d/host-firehol.nft is missing or empty, refusing to apply the firewall + # Check mode neither installs the updater nor builds the sets. + when: not ansible_check_mode + + - name: Enable the FireHOL set timer + ansible.builtin.systemd: + name: nft-host-firehol.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 FireHOL set refresh + when: not firewall.firehol + block: + - name: Disable the FireHOL set timer + ansible.builtin.systemd: + name: nft-host-firehol.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 FireHOL set file + ansible.builtin.file: + path: /etc/nftables.d/host-firehol.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 613ca10..824ba06 100644 --- a/host_firewall/templates/host.nft.j2 +++ b/host_firewall/templates/host.nft.j2 @@ -20,6 +20,7 @@ set {{ name }} { {% set masquerade = firewall.masquerade | default({}, true) | dict2items | rejectattr('value', 'none') | map(attribute='value') | unique | list %} {% set scrapers = firewall.scrapers | default({}, true) %} {% set forward_guard = firewall.forward_guard | default({}, true) | dict2items | rejectattr('value', 'none') | list %} +{% set blocklists = firewall.spamhaus or firewall.firehol %} # Declare-then-delete makes every load one atomic transaction that replaces # only these tables; Cilium, Docker, NetBird and CrowdSec tables stay untouched. @@ -34,6 +35,9 @@ include "/etc/nftables.d/host-geo.nft" {% if firewall.spamhaus %} include "/etc/nftables.d/host-spamhaus.nft" {% endif %} +{% if firewall.firehol %} +include "/etc/nftables.d/host-firehol.nft" +{% endif %} table inet host { {{ addr_set('admin_v4', 'ipv4_addr', firewall.admin.v4) }} @@ -51,12 +55,17 @@ table inet host { chain input { type filter hook input priority filter; policy drop; -{% if firewall.spamhaus %} +{% if blocklists %} # Before the established rule: outbound is blocked too, so listed # addresses have no legitimate replies. +{% if firewall.spamhaus %} ip saddr @spamhaus_v4 jump spamhaus_in ip6 saddr @spamhaus_v6 jump spamhaus_in +{% endif %} +{% if firewall.firehol %} + ip saddr @firehol_v4 jump firehol_in +{% endif %} {% endif %} ct state established,related accept @@ -122,7 +131,19 @@ table inet host { reject } {% endif %} -{% if firewall.block_smtp_out or firewall.spamhaus %} +{% if firewall.firehol %} + + chain firehol_in { + limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-in: " + drop + } + + chain firehol_out { + limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-firehol-out: " + reject + } +{% endif %} +{% if firewall.block_smtp_out or blocklists %} # Outbound mail goes through the mail host only. Loopback stays open for a # local MTA; the log lines carry OUT=, which CrowdSec does not count as a scan. @@ -133,6 +154,10 @@ table inet host { ip daddr @spamhaus_v4 jump spamhaus_out ip6 daddr @spamhaus_v6 jump spamhaus_out {% endif %} +{% if firewall.firehol %} + + ip daddr @firehol_v4 jump firehol_out +{% endif %} {% if firewall.block_smtp_out %} oifname != "lo" tcp dport 25 limit rate {{ firewall.log.out_rate }} log prefix "fw-out-reject: " @@ -195,7 +220,7 @@ table inet host { ip6 saddr @geo_v6 accept {% endif %} } -{% if forward_guard or firewall.spamhaus %} +{% if forward_guard or blocklists %} chain forward { type filter hook forward priority filter; policy accept; @@ -206,6 +231,11 @@ table inet host { ip daddr @spamhaus_v4 jump spamhaus_out ip6 daddr @spamhaus_v6 jump spamhaus_out {% endif %} +{% if firewall.firehol %} + + ip saddr @firehol_v4 jump firehol_in + ip daddr @firehol_v4 jump firehol_out +{% endif %} {% for guard in forward_guard %} # {{ guard.key }}