#!/usr/bin/python3 # Managed by Ansible (role host_firewall), do not edit. """Rebuilds the internet scanner sets scanners_v4 and scanners_v6 of table inet host. Source: the cidr directory of OpenFilters/internet-scanners (Unlicense), read from the tarball of the default branch. A failed download or sanity check exits non-zero before anything changes, so the previous sets stay in place. """ import io import ipaddress import json import os import re import shutil import subprocess import sys import tarfile import tempfile import time import urllib.request PERSISTED = "/etc/nftables.d/host-scanners.nft" # One CIDR or address per line: the public addresses of the hosts, the # CrowdSec whitelist and the admin, scraper and cluster sets of the firewall. ALLOWLIST = "/etc/nft-host-scanners.allowlist" URL = "https://codeload.github.com/OpenFilters/internet-scanners/tar.gz/refs/heads/main" USER_AGENT = "ruekov-host-firewall (nft-host-scanners)" MEMBER = re.compile(r"^[^/]+/cidr/[a-z0-9]+_v([46])\.txt$") MAX_BYTES = 50 * 1024 * 1024 # Lower bounds that catch an empty or truncated answer (the list holds about # 21000 IPv4 and 3000 IPv6 entries). MIN_ENTRIES = {4: 5000, 6: 500} # A prefix this wide is a parse error, not a scanner network. MIN_PREFIXLEN = {4: 12, 6: 24} # Private, CGNAT (NetBird), loopback, link-local, ULA and multicast/reserved # space: an entry overlapping one of these never lands in a 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", "::/128", "::1/128", "::ffff:0:0/96", "fc00::/7", "fe80::/10", "ff00::/8")] def fail(message): print(f"nft-host-scanners: {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: data = response.read(MAX_BYTES + 1) if len(data) > MAX_BYTES: fail(f"answer from {URL} exceeds {MAX_BYTES} bytes") return data except OSError as e: error = e time.sleep(5 * (attempt + 1)) fail(f"download of {URL} failed: {error}") def parse(data): """Returns {4: [networks], 6: [networks]} from the cidr/*_v4.txt and *_v6.txt files.""" networks = {4: [], 6: []} try: archive = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") except (tarfile.TarError, OSError) as e: fail(f"unreadable archive: {e}") with archive: for member in archive: match = MEMBER.match(member.name) if not match or not member.isfile(): continue version = int(match.group(1)) for line in archive.extractfile(member).read().decode("utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue try: # Host bits set (34.19.127.176/27) are masked, not rejected. network = ipaddress.ip_network(line, strict=False) except ValueError: fail(f"unparsable line in {member.name}: {line!r}") if network.version != version: fail(f"IPv{network.version} entry in {member.name}: {line!r}") networks[version].append(network) return networks def read_allowlist(): entries = [] with open(ALLOWLIST, encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue try: entries.append(ipaddress.ip_network(line, strict=False)) except ValueError: fail(f"unparsable line in {ALLOWLIST}: {line!r}") return entries def own_addresses(): result = subprocess.run(["ip", "-j", "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") in ("inet", "inet6")] def main(): listed = parse(fetch()) for version, minimum in MIN_ENTRIES.items(): if len(listed[version]) < minimum: fail(f"only {len(listed[version])} IPv{version} entries") protected = EXCLUDED + read_allowlist() own = own_addresses() merged = {} for version in (4, 6): kept, protected_skipped, own_skipped = [], 0, 0 for network in listed[version]: if network.prefixlen < MIN_PREFIXLEN[version]: fail(f"suspiciously wide prefix {network}") if any(p.version == version and network.overlaps(p) for p in protected): protected_skipped += 1 elif any(a.version == version and a in network for a in own): own_skipped += 1 else: kept.append(network) merged[version] = list(ipaddress.collapse_addresses(kept)) print(f"scanners_v{version}: {len(listed[version])} entries, {protected_skipped} in private, reserved or " f"allowlisted space and {own_skipped} holding an own address skipped -> {len(merged[version])} merged") persisted = "# Generated by nft-host-scanners, do not edit.\ntable inet host {\n" for version in (4, 6): elements = ",\n\t\t".join(str(n) for n in merged[version]) + "," persisted += (f"\tset scanners_v{version} {{ type ipv{version}_addr; flags interval; auto-merge; elements = {{\n" f"\t\t{elements}\n\t}} }}\n") persisted += "}\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_scanners_check {") checked = subprocess.run(["nft", "-c", "-f", "-"], input=check_rules, capture_output=True, text=True) if checked.returncode != 0: fail(f"nft rejects the sets: {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) else: os.remove(PERSISTED) fail(f"reloading the rule set failed, kept the previous sets: {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 sets on any surprise fail(f"{type(e).__name__}: {e}")