host_firewall: GreenSnow set greensnow_v4 drops inbound new connections of the listed hosts

This commit is contained in:
2026-10-30 09:01:25 +00:00
parent f92b36ceaa
commit ca34bd8363
6 changed files with 234 additions and 3 deletions
+9 -1
View File
@@ -83,7 +83,8 @@ 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: ) and scanner (fw-scanners-in: ) chains, one limit each.
# 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.
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
@@ -104,6 +105,13 @@ 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
+129
View File
@@ -0,0 +1,129 @@
#!/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}")
@@ -0,0 +1,8 @@
[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
@@ -0,0 +1,10 @@
[Unit]
Description=Daily refresh of the host firewall GreenSnow set
[Timer]
OnCalendar=daily
RandomizedDelaySec=6h
Persistent=true
[Install]
WantedBy=timers.target
+54 -1
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]
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]
register: firewall_units
- name: Install the rollback script
@@ -287,6 +287,59 @@
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
+24 -1
View File
@@ -82,6 +82,9 @@ 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) }}
@@ -154,6 +157,12 @@ table inet host {
ip saddr @scanners_v4 jump scanners_in
ip6 saddr @scanners_v6 jump scanners_in
{% endif %}
{% if firewall.greensnow %}
# After the established rule: only inbound connections of GreenSnow hosts drop.
ip saddr @greensnow_v4 jump greensnow_in
{% endif %}
ct state invalid jump drop_log
iif lo accept
@@ -317,6 +326,16 @@ table inet host {
drop
}
{% endif %}
{% if firewall.greensnow %}
# Own limit and prefix, so GreenSnow 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: "
drop
}
{% endif %}
{% if firewall.block_smtp_out or blocklists %}
# Outbound mail goes through the mail host only. Loopback stays open for a
@@ -394,7 +413,7 @@ table inet host {
ip6 saddr @geo_v6 accept
{% endif %}
}
{% if forward_guard or blocklists or firewall.scanners or rl_forward %}
{% if forward_guard or blocklists or firewall.scanners or firewall.greensnow or rl_forward %}
chain forward {
type filter hook forward priority filter; policy accept;
@@ -423,6 +442,10 @@ table inet host {
ct state new ip saddr @scanners_v4 jump scanners_in
ct state new ip6 saddr @scanners_v6 jump scanners_in
{% endif %}
{% if firewall.greensnow %}
ct state new ip saddr @greensnow_v4 jump greensnow_in
{% endif %}
{% for guard in forward_guard %}
# {{ guard.key }}