host_firewall: ban firehol level2, bruteforceblocker, vxvault and cybercrime; greensnow is part of level2

This commit is contained in:
2026-11-05 13:25:48 +00:00
parent ce851f59c6
commit 6a1e7b77e7
9 changed files with 127 additions and 269 deletions
+46 -28
View File
@@ -1,9 +1,12 @@
#!/usr/bin/python3
# Managed by Ansible (role host_firewall), do not edit.
"""Rebuilds the FireHOL level1 set firehol_v4 of table inet host.
"""Rebuilds the FireHOL sets of table inet host (IPv4 only, FireHOL has no IPv6 lists).
A failed download or sanity check exits non-zero before anything changes, so
the previous set stays in place.
Every set is built from one or more lists of the FireHOL blocklist-ipsets
(single addresses or CIDR networks, comment lines start with #). All sets go
into one file, so one reload replaces them together. A failed download or
sanity check of any list exits non-zero before anything changes, so the
previous sets stay in place.
"""
import ipaddress
import json
@@ -16,11 +19,18 @@ import time
import urllib.request
PERSISTED = "/etc/nftables.d/host-firehol.nft"
URL = "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/firehol_level1.netset"
BASE_URL = "https://raw.githubusercontent.com/firehol/blocklist-ipsets/master/"
USER_AGENT = "ruekov-host-firewall (nft-host-firehol)"
# Lower bound that catches an empty or truncated answer.
MIN_ENTRIES = 1000
# set name -> (lists merged into it, lower bound for their entries together that
# catches an empty or truncated answer; the lists hold about 4700 (level1),
# 18700 (level2), 500 (bruteforceblocker) and 70 + 500 (vxvault, cybercrime)).
SETS = {
"firehol_v4": (["firehol_level1.netset"], 1000),
"firehol_level2_v4": (["firehol_level2.netset"], 5000),
"bruteforceblocker_v4": (["bruteforceblocker.ipset"], 100),
"malware_v4": (["vxvault.ipset", "cybercrime.ipset"], 200),
}
# 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:
@@ -36,17 +46,18 @@ def fail(message):
sys.exit(1)
def fetch():
def fetch(name):
url = BASE_URL + name
error = None
for attempt in range(3):
try:
request = urllib.request.Request(URL, headers={"User-Agent": USER_AGENT})
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}")
fail(f"download of {url} failed: {error}")
def own_addresses():
@@ -57,20 +68,20 @@ def own_addresses():
for a in link.get("addr_info", []) if a.get("family") == "inet"]
def main():
def build_set(lists, minimum, own):
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")
for name in lists:
for line in fetch(name).splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
try:
networks.append(ipaddress.IPv4Network(line))
except ValueError:
fail(f"unparsable line in {name}: {line!r}")
if len(networks) < minimum:
fail(f"only {len(networks)} entries in {', '.join(lists)}")
own = own_addresses()
kept, excluded, own_skipped = [], 0, 0
for network in networks:
if any(network.overlaps(e) for e in EXCLUDED):
@@ -82,21 +93,28 @@ def main():
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")
return networks, excluded, own_skipped, merged
elements = ",\n\t\t".join(str(n) for n in merged) + ","
def main():
own = own_addresses()
definitions = []
for set_name, (lists, minimum) in SETS.items():
networks, excluded, own_skipped, merged = build_set(lists, minimum, own)
print(f"{set_name}: {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) + ","
definitions.append(f"\tset {set_name} {{ type ipv4_addr; flags interval; auto-merge; elements = {{\n"
f"\t\t{elements}\n\t}} }}\n")
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")
"table inet host {\n" + "".join(definitions) + "}\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()}")
fail(f"nft rejects the sets: {checked.stderr.strip()}")
with tempfile.TemporaryDirectory() as tmp:
previous = None
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Refresh the FireHOL set of the host firewall
Description=Refresh the FireHOL sets of the host firewall
Wants=network-online.target
After=network-online.target
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Daily refresh of the host firewall FireHOL set
Description=Daily refresh of the host firewall FireHOL sets
[Timer]
OnCalendar=daily
-129
View File
@@ -1,129 +0,0 @@
#!/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}")
@@ -1,8 +0,0 @@
[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
@@ -1,10 +0,0 @@
[Unit]
Description=Daily refresh of the host firewall GreenSnow set
[Timer]
OnCalendar=daily
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target