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
+9 -13
View File
@@ -93,17 +93,20 @@ 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 (fw-spamhaus-in: , fw-spamhaus-out: ), FireHOL (fw-firehol-in: , fw-firehol-out: ), scanner (fw-scanners-in: ) and
# GreenSnow (fw-greensnow-in: ) chains, one limit each.
# Shared by the Spamhaus (fw-spamhaus-in: , fw-spamhaus-out: ), FireHOL (fw-firehol-in: , fw-firehol-out: ), bruteforceblocker (fw-bruteforceblocker-in: ) and scanner (fw-scanners-in: ) 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 lists (IPv4 only, FireHOL has no IPv6 lists), from github.com/firehol/blocklist-ipsets:
# - level1 and level2 (level2 includes GreenSnow) and the malware set (vxvault and cybercrime): treated like the
# Spamhaus lists, with the prefixes fw-firehol-in: and fw-firehol-out: and the same log limit.
# - bruteforceblocker: inbound new connections from the listed hosts drop, outbound traffic is not filtered. Log
# prefix fw-bruteforceblocker-in: with its own limit, which CrowdSec whitelists.
# Entries in private or reserved space and ones holding an address of the host are left out. All sets are refreshed
# daily by nft-host-firehol.
firehol: true
# Internet-wide scanners (Censys, Shodan, ONYPHE, Shadowserver, ...), IPv4
# and IPv6: the cidr files of github.com/OpenFilters/internet-scanners
@@ -115,13 +118,6 @@ firewall:
# of any host, and ones holding an address of the host are left out
# (/etc/nft-host-scanners.allowlist). Refreshed daily by nft-host-scanners.
scanners: true
# GreenSnow (IPv4 single addresses, compromised and dynamic hosts from the
# FireHOL blocklist-ipsets): inbound new connections from the listed hosts
# drop, outbound traffic is not filtered. Log prefix fw-greensnow-in: with its
# own limit, which CrowdSec whitelists. Entries in private or reserved space
# and ones holding an address of the host are left out. Refreshed daily by
# nft-host-greensnow.
greensnow: 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
+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
+52 -66
View File
@@ -59,7 +59,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, nft-host-firehol.service, nft-host-firehol.timer, nft-host-scanners.service, nft-host-scanners.timer, nft-host-greensnow.service, nft-host-greensnow.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, nft-host-scanners.service, nft-host-scanners.timer]
register: firewall_units
- name: Install the rollback script
@@ -174,7 +174,7 @@
path: /etc/nftables.d/host-spamhaus.nft
state: absent
- name: FireHOL set
- name: FireHOL sets
when: firewall.firehol
block:
- name: Install the FireHOL set updater
@@ -183,25 +183,31 @@
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
# The rule set includes this file and uses all of its sets, so a missing file or set would fail the load at boot.
- name: Look for the FireHOL sets
ansible.builtin.command: grep -cE 'set (firehol_v4|firehol_level2_v4|bruteforceblocker_v4|malware_v4) ' /etc/nftables.d/host-firehol.nft
register: firewall_firehol_sets
changed_when: false
failed_when: false
check_mode: false
- name: Read the FireHOL set file
- name: Build the FireHOL sets when missing
ansible.builtin.command: /usr/local/sbin/nft-host-firehol
when: firewall_firehol_sets.stdout != '4'
- name: Read the FireHOL sets file
ansible.builtin.stat:
path: /etc/nftables.d/host-firehol.nft
register: firewall_firehol
- name: Require non-empty FireHOL set
- name: Require non-empty FireHOL sets
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
- name: Enable the FireHOL sets timer
ansible.builtin.systemd:
name: nft-host-firehol.timer
enabled: true
@@ -210,10 +216,10 @@
# Check mode does not install the unit.
ignore_errors: "{{ ansible_check_mode }}"
- name: Stop the FireHOL set refresh
- name: Stop the FireHOL sets refresh
when: not firewall.firehol
block:
- name: Disable the FireHOL set timer
- name: Disable the FireHOL sets timer
ansible.builtin.systemd:
name: nft-host-firehol.timer
enabled: false
@@ -222,7 +228,7 @@
# Check mode does not install the unit.
ignore_errors: "{{ ansible_check_mode }}"
- name: Remove the FireHOL set file
- name: Remove the FireHOL sets file
ansible.builtin.file:
path: /etc/nftables.d/host-firehol.nft
state: absent
@@ -287,59 +293,6 @@
path: /etc/nftables.d/host-scanners.nft
state: absent
- name: GreenSnow set
when: firewall.greensnow
block:
- name: Install the GreenSnow set updater
ansible.builtin.copy:
src: nft-host-greensnow
dest: /usr/local/sbin/nft-host-greensnow
mode: "0755"
# The rule set includes this file, so a missing one would fail the load at boot.
- name: Build the GreenSnow set on first run
ansible.builtin.command: /usr/local/sbin/nft-host-greensnow
args:
creates: /etc/nftables.d/host-greensnow.nft
- name: Read the GreenSnow set file
ansible.builtin.stat:
path: /etc/nftables.d/host-greensnow.nft
register: firewall_greensnow
- name: Require non-empty GreenSnow set
ansible.builtin.assert:
that: firewall_greensnow.stat.exists and firewall_greensnow.stat.size > 0
fail_msg: /etc/nftables.d/host-greensnow.nft is missing or empty, refusing to apply the firewall
# Check mode neither installs the updater nor builds the set.
when: not ansible_check_mode
- name: Enable the GreenSnow set timer
ansible.builtin.systemd:
name: nft-host-greensnow.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 GreenSnow set refresh
when: not firewall.greensnow
block:
- name: Disable the GreenSnow set timer
ansible.builtin.systemd:
name: nft-host-greensnow.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 GreenSnow set file
ansible.builtin.file:
path: /etc/nftables.d/host-greensnow.nft
state: absent
- name: Render the rule set
ansible.builtin.template:
src: host.nft.j2
@@ -414,3 +367,36 @@
dest: /etc/nftables.d/host.nft.good
remote_src: true
mode: "0644"
# GreenSnow is part of FireHOL level2. The rule file goes last: the last confirmed rule set (host.nft.good) and a
# reload by a refresh service still include it until the new rule set is confirmed. The set itself leaves the live
# table with the reload of the new rule set.
- name: Look for the GreenSnow timer
ansible.builtin.stat:
path: /etc/systemd/system/nft-host-greensnow.timer
register: firewall_greensnow_timer
- name: Stop the GreenSnow refresh
ansible.builtin.systemd:
name: nft-host-greensnow.timer
enabled: false
state: stopped
when: firewall_greensnow_timer.stat.exists
# Check mode does not stop the timer.
ignore_errors: "{{ ansible_check_mode }}"
- name: Remove the GreenSnow files
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/systemd/system/nft-host-greensnow.service
- /etc/systemd/system/nft-host-greensnow.timer
- /usr/local/sbin/nft-host-greensnow
- /etc/nftables.d/host-greensnow.nft
register: firewall_greensnow_files
- name: Reload systemd after removing the GreenSnow units
ansible.builtin.systemd:
daemon_reload: true
when: firewall_greensnow_files is changed
+18 -13
View File
@@ -86,9 +86,6 @@ include "/etc/nftables.d/host-firehol.nft"
{% if firewall.scanners %}
include "/etc/nftables.d/host-scanners.nft"
{% endif %}
{% if firewall.greensnow %}
include "/etc/nftables.d/host-greensnow.nft"
{% endif %}
table inet host {
{{ addr_set('admin_v4', 'ipv4_addr', firewall.admin.v4) }}
@@ -173,6 +170,8 @@ table inet host {
{% endif %}
{% if firewall.firehol %}
ip saddr @firehol_v4 jump firehol_in
ip saddr @firehol_level2_v4 jump firehol_in
ip saddr @malware_v4 jump firehol_in
{% endif %}
{% endif %}
@@ -193,10 +192,10 @@ table inet host {
ip6 saddr @scanners_v6 jump scanners_in
{% endif %}
{% if firewall.greensnow %}
{% if firewall.firehol %}
# After the established rule: only inbound connections of GreenSnow hosts drop.
ip saddr @greensnow_v4 jump greensnow_in
# After the established rule: only inbound connections of bruteforceblocker hosts drop.
ip saddr @bruteforceblocker_v4 jump bruteforceblocker_in
{% endif %}
ct state invalid jump drop_log
@@ -361,13 +360,13 @@ table inet host {
drop
}
{% endif %}
{% if firewall.greensnow %}
{% if firewall.firehol %}
# Own limit and prefix, so GreenSnow hits use up neither the fw-in-drop budget
# Own limit and prefix, so bruteforceblocker hits use up neither the fw-in-drop budget
# nor the one of the blocklists. CrowdSec whitelists the prefix: the listed
# hosts are known and need no decision.
chain greensnow_in {
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-greensnow-in: "
chain bruteforceblocker_in {
limit rate {{ firewall.log.spamhaus_rate }} log prefix "fw-bruteforceblocker-in: "
drop
}
{% endif %}
@@ -385,6 +384,8 @@ table inet host {
{% if firewall.firehol %}
ip daddr @firehol_v4 jump firehol_out
ip daddr @firehol_level2_v4 jump firehol_out
ip daddr @malware_v4 jump firehol_out
{% endif %}
{% if firewall.block_smtp_out %}
@@ -448,7 +449,7 @@ table inet host {
ip6 saddr @geo_v6 accept
{% endif %}
}
{% if forward_guard or blocklists or firewall.scanners or firewall.greensnow or rl_forward or firewall.block_smtp_out %}
{% if forward_guard or blocklists or firewall.scanners or rl_forward or firewall.block_smtp_out %}
chain forward {
type filter hook forward priority filter; policy accept;
@@ -471,15 +472,19 @@ table inet host {
ip saddr @firehol_v4 jump firehol_in
ip daddr @firehol_v4 jump firehol_out
ip saddr @firehol_level2_v4 jump firehol_in
ip daddr @firehol_level2_v4 jump firehol_out
ip saddr @malware_v4 jump firehol_in
ip daddr @malware_v4 jump firehol_out
{% endif %}
{% if firewall.scanners %}
ct state new ip saddr @scanners_v4 jump scanners_in
ct state new ip6 saddr @scanners_v6 jump scanners_in
{% endif %}
{% if firewall.greensnow %}
{% if firewall.firehol %}
ct state new ip saddr @greensnow_v4 jump greensnow_in
ct state new ip saddr @bruteforceblocker_v4 jump bruteforceblocker_in
{% endif %}
{% for guard in forward_guard %}