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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user