host_firewall: FireHOL level1 as its own set firehol_v4 next to Spamhaus, daily updater and firewall.firehol opt-out

This commit is contained in:
2026-10-24 17:31:45 +00:00
parent 312be161b9
commit c55a96c0ea
6 changed files with 238 additions and 5 deletions
+6 -1
View File
@@ -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
+127
View File
@@ -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}")
@@ -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
@@ -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
+54 -1
View File
@@ -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
+33 -3
View File
@@ -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 }}