128 lines
4.9 KiB
Python
128 lines
4.9 KiB
Python
#!/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}")
|