diff --git a/roles/common/files/aggregate-cidr-addresses.pl b/roles/common/files/aggregate-cidr-addresses.pl new file mode 100755 index 0000000..292116c --- /dev/null +++ b/roles/common/files/aggregate-cidr-addresses.pl @@ -0,0 +1,89 @@ +#!/usr/bin/perl +# +# aggregate-cidr-addresses - combine a list of CIDR address blocks +# Copyright (C) 2001,2007 Mark Suter +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see L. +# +# [MJS 22 Oct 2001] Aggregate CIDR addresses +# [MJS 9 Oct 2007] Overlap idea from Anthony Ledesma at theplanet dot com. +# [MJS 16 Feb 2012] Prompted to clarify license by Alexander Talos-Zens - at at univie dot ac dot at +# [MJS 21 Feb 2012] IPv6 fixes by Alexander Talos-Zens +# [MJS 21 Feb 2012] Split ranges into prefixes (fixes a 10+ year old bug) + +use strict; +use warnings; +use English qw( -no_match_vars ); +use Net::IP; + +## Read in all the IP addresses +my @addrs = map { Net::IP->new($_) or die "$PROGRAM_NAME: Not an IP: \"$_\"."; } + map { / \A \s* (.+?) \s* \Z /msix and $1; } <>; + +## Split any ranges into prefixes +@addrs = map { + defined $_->prefixlen ? $_ : map { Net::IP->new($_) } + $_->find_prefixes +} @addrs; + +## Sort the IP addresses +@addrs = sort { $a->version <=> $b->version or $a->bincomp( 'lt', $b ) ? -1 : $a->bincomp( 'gt', $b ) ? 1 : 0 } @addrs; + +## Handle overlaps +my $count = 0; +my $current = $addrs[0]; +foreach my $next ( @addrs[ 1 .. $#addrs ] ) { + my $r = $current->overlaps($next); + if ( $current->version != $next->version or $r == $IP_NO_OVERLAP ) { + $current = $next; + $count++; + } + elsif ( $r == $IP_A_IN_B_OVERLAP ) { + $current = $next; + splice @addrs, $count, 1; + } + elsif ( $r == $IP_B_IN_A_OVERLAP or $r == $IP_IDENTICAL ) { + splice @addrs, $count + 1, 1; + } + else { + die "$PROGRAM_NAME: internal error - overlaps() returned an unexpected value!\n"; + } +} + +## Keep aggregating until we don't change anything +my $change = 1; +while ($change) { + $change = 0; + my @new_addrs = (); + $current = $addrs[0]; + foreach my $next ( @addrs[ 1 .. $#addrs ] ) { + if ( my $total = $current->aggregate($next) ) { + $current = $total; + $change = 1; + } + else { + push @new_addrs, $current; + $current = $next; + } + } + push @new_addrs, $current; + @addrs = @new_addrs; +} + +## Print out the IP addresses +foreach (@addrs) { + print $_->prefix(), "\n"; +} + +# $Id: aggregate-cidr-addresses,v 1.9 2012/02/21 10:14:22 suter Exp suter $ diff --git a/roles/common/files/spamhaus-ipv4.nft b/roles/common/files/spamhaus-ipv4.nft new file mode 100644 index 0000000..5c483b7 --- /dev/null +++ b/roles/common/files/spamhaus-ipv4.nft @@ -0,0 +1,5 @@ +#!/usr/sbin/nft -f + +define SPAMHAUS_IPV4 = { +192.168.254.254/32 +} diff --git a/roles/common/files/spamhaus-ipv6.nft b/roles/common/files/spamhaus-ipv6.nft new file mode 100644 index 0000000..32a2c78 --- /dev/null +++ b/roles/common/files/spamhaus-ipv6.nft @@ -0,0 +1,5 @@ +#!/usr/sbin/nft -f + +define SPAMHAUS_IPV6 = { +fd21:3523:74e0:7301::/64 +} diff --git a/roles/common/files/update-spamhaus-nftables.service b/roles/common/files/update-spamhaus-nftables.service new file mode 100644 index 0000000..b67ea08 --- /dev/null +++ b/roles/common/files/update-spamhaus-nftables.service @@ -0,0 +1,27 @@ +[Unit] +Description=Update Spamhaus lists +# This service will fail if nftables is not running so we use Requires to make +# sure that nftables is started. +Requires=nftables.service +# Make sure the network is up and nftables is started +After=network-online.target nftables.service +Wants=network-online.target update-spamhaus-nftables.timer + +[Service] +# https://www.ctrl.blog/entry/systemd-service-hardening.html +# Doesn't need access to /home or /root +ProtectHome=true +# Possibly only works on Ubuntu 18.04+ +ProtectKernelTunables=true +ProtectSystem=full +# Newer systemd can use ReadWritePaths to list files, but this works everywhere +ReadWriteDirectories=/etc/nftables +PrivateTmp=true +WorkingDirectory=/var/tmp + +SyslogIdentifier=update-spamhaus-nftables +ExecStart=/usr/bin/flock -x update-spamhaus-nftables.lck \ + /usr/local/bin/update-spamhaus-nftables.sh + +[Install] +WantedBy=multi-user.target diff --git a/roles/common/files/update-spamhaus-nftables.sh b/roles/common/files/update-spamhaus-nftables.sh new file mode 100755 index 0000000..e040bd4 --- /dev/null +++ b/roles/common/files/update-spamhaus-nftables.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# update-spamhaus-nftables.sh v0.0.1 +# +# Download Spamhaus DROP lists and load them into nftables sets. +# +# See: https://www.spamhaus.org/drop/ +# +# Copyright (C) 2021 Alan Orth +# +# SPDX-License-Identifier: GPL-3.0-only + +# Exit on first error +set -o errexit + +spamhaus_ipv4_set_path=/etc/nftables/spamhaus-ipv4.nft +spamhaus_ipv6_set_path=/etc/nftables/spamhaus-ipv6.nft + +function download() { + echo "Downloading $1" + wget -q -O - "https://www.spamhaus.org/drop/$1" > "$1" +} + +download drop.txt +download edrop.txt +download dropv6.txt + +if [[ -f "drop.txt" && -f "edrop.txt" ]]; then + echo "Processing IPv4 DROP lists" + + spamhaus_ipv4_list_temp=$(mktemp) + spamhaus_ipv4_set_temp=$(mktemp) + + # Extract all networks from drop.txt and edrop.txt, skipping blank lines and + # comments. Use aggregate-cidr-addresses.pl to merge overlapping IPv4 CIDR + # ranges to work around a firewalld bug. + # + # See: https://bugzilla.redhat.com/show_bug.cgi?id=1836571 + cat drop.txt edrop.txt | sed -e '/^$/d' -e '/^;.*/d' -e 's/[[:space:]];[[:space:]].*//' | aggregate-cidr-addresses.pl > "$spamhaus_ipv4_list_temp" + + echo "Building spamhaus-ipv4 set" + cat << NFT_HEAD > "$spamhaus_ipv4_set_temp" +#!/usr/sbin/nft -f + +define SPAMHAUS_IPV4 = { +NFT_HEAD + + while read -r network; do + # nftables doesn't mind if the last element in the set has a trailing + # comma so we don't need to do anything special here. + echo "$network," >> "$spamhaus_ipv4_set_temp" + done < $spamhaus_ipv4_list_temp + + echo "}" >> "$spamhaus_ipv4_set_temp" + + install -m 0600 "$spamhaus_ipv4_set_temp" "$spamhaus_ipv4_set_path" + + rm -f "$spamhaus_ipv4_list_temp" "$spamhaus_ipv4_set_temp" +fi + +if [[ -f "dropv6.txt" ]]; then + echo "Processing IPv6 DROP lists" + + spamhaus_ipv6_list_temp=$(mktemp) + spamhaus_ipv6_set_temp=$(mktemp) + + sed -e '/^$/d' -e '/^;.*/d' -e 's/[[:space:]];[[:space:]].*//' dropv6.txt > "$spamhaus_ipv6_list_temp" + + echo "Building spamhaus-ipv6 set" + cat << NFT_HEAD > "$spamhaus_ipv6_set_temp" +#!/usr/sbin/nft -f + +define SPAMHAUS_IPV6 = { +NFT_HEAD + + while read -r network; do + echo "$network," >> "$spamhaus_ipv6_set_temp" + done < $spamhaus_ipv6_list_temp + + echo "}" >> "$spamhaus_ipv6_set_temp" + + install -m 0600 "$spamhaus_ipv6_set_temp" "$spamhaus_ipv6_set_path" + + rm -f "$spamhaus_ipv6_list_temp" "$spamhaus_ipv6_set_temp" +fi + +echo "Reloading nftables" +# The spamhaus nftables sets are included by nftables.conf +/usr/sbin/nft -f /etc/nftables.conf + +rm -v drop.txt edrop.txt dropv6.txt diff --git a/roles/common/files/update-spamhaus-nftables.timer b/roles/common/files/update-spamhaus-nftables.timer new file mode 100644 index 0000000..626b3ae --- /dev/null +++ b/roles/common/files/update-spamhaus-nftables.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Update Spamhaus lists + +[Timer] +# Once a day at midnight +OnCalendar=*-*-* 00:00:00 +# Add a random delay of 0–3600 seconds +RandomizedDelaySec=3600 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/roles/common/handlers/main.yml b/roles/common/handlers/main.yml index 8f53961..2d84af2 100644 --- a/roles/common/handlers/main.yml +++ b/roles/common/handlers/main.yml @@ -15,3 +15,6 @@ - name: reload systemd systemd: daemon_reload=yes + +- name: reload nftables + systemd: name=nftables state=reloaded diff --git a/roles/common/tasks/firewall_Debian.yml b/roles/common/tasks/firewall_Debian.yml index 7bc6921..aeaddde 100644 --- a/roles/common/tasks/firewall_Debian.yml +++ b/roles/common/tasks/firewall_Debian.yml @@ -1,7 +1,9 @@ --- +# Debian 11 will use nftables directly, with no firewalld. - block: - name: Set Debian firewall packages + when: ansible_distribution_major_version is version('10', '<=') set_fact: debian_firewall_packages: - firewalld @@ -9,10 +11,41 @@ - fail2ban - python3-systemd # for fail2ban systemd backend - - name: Install firewalld and deps - when: ansible_distribution_major_version is version('9', '>=') + - name: Set Debian firewall packages + when: ansible_distribution_major_version is version('11', '>=') + set_fact: + debian_firewall_packages: + - fail2ban + - libnet-ip-perl # for aggregate-cidr-addresses.pl + - nftables + - python3-systemd + + - name: Install firewall packages apt: pkg={{ debian_firewall_packages }} state=present + - name: Start and enable nftables + when: ansible_distribution_major_version is version('11', '>=') + systemd: name=nftables state=started enabled=yes + + - name: Copy nftables.conf + when: ansible_distribution_major_version is version('11', '>=') + template: src=nftables.conf.j2 dest=/etc/nftables.conf owner=root mode=0644 + notify: + - reload nftables + + - name: Create /etc/nftables extra config directory + when: ansible_distribution_major_version is version('11', '>=') + file: path=/etc/nftables state=directory owner=root mode=0755 + + - name: Copy extra nftables configuration files + when: ansible_distribution_major_version is version('11', '>=') + copy: src={{ item }} dest=/etc/nftables/{{ item }} owner=root group=root mode=0644 + loop: + - spamhaus-ipv4.nft + - spamhaus-ipv6.nft + notify: + - reload nftables + - name: Use iptables backend in firewalld when: ansible_distribution_major_version is version('10', '==') lineinfile: @@ -35,17 +68,17 @@ - restart firewalld - name: Copy firewalld public zone file - when: ansible_distribution_major_version is version('9', '>=') + when: ansible_distribution_major_version is version('10', '<=') template: src=public.xml.j2 dest=/etc/firewalld/zones/public.xml owner=root mode=0600 - name: Format public.xml firewalld zone file - when: ansible_distribution_major_version is version('9', '>=') + when: ansible_distribution_major_version is version('10', '<=') command: tidy -xml -iq -m -w 0 /etc/firewalld/zones/public.xml notify: - restart firewalld - - name: Copy ipsets of abusive IPs - when: ansible_distribution_major_version is version('9', '>=') + - name: Copy firewalld ipsets of abusive IPs + when: ansible_distribution_major_version is version('10', '<=') copy: src={{ item }} dest=/etc/firewalld/ipsets/{{ item }} owner=root group=root mode=0600 loop: - abusers-ipv4.xml @@ -55,29 +88,49 @@ notify: - restart firewalld - - name: Copy Spamhaus update script - when: ansible_distribution_version is version('9', '>=') + - name: Copy Spamhaus firewalld update script + when: ansible_distribution_version is version('10', '<=') copy: src=update-spamhaus-lists.sh dest=/usr/local/bin/update-spamhaus-lists.sh mode=0755 owner=root group=root - - name: Copy Spamhaus systemd units - when: ansible_distribution_version is version('9', '>=') + - name: Copy Spamhaus firewalld systemd units + when: ansible_distribution_version is version('10', '<=') copy: src={{ item }} dest=/etc/systemd/system/{{ item }} mode=0644 owner=root group=root loop: - update-spamhaus-lists.service - update-spamhaus-lists.timer - register: spamhaus_systemd_units + register: spamhaus_firewalld_systemd_units + + - name: Copy Spamhaus nftables update scripts + when: ansible_distribution_version is version('11', '>=') + copy: src={{ item }} dest=/usr/local/bin/{{ item }} mode=0755 owner=root group=root + loop: + - update-spamhaus-nftables.sh + - aggregate-cidr-addresses.pl + + - name: Copy Spamhaus nftables systemd units + when: ansible_distribution_version is version('11', '>=') + copy: src={{ item }} dest=/etc/systemd/system/{{ item }} mode=0644 owner=root group=root + loop: + - update-spamhaus-nftables.service + - update-spamhaus-nftables.timer + register: spamhaus_nftables_systemd_units # need to reload to pick up service/timer/environment changes - name: Reload systemd daemon systemd: daemon_reload=yes - when: spamhaus_systemd_units is changed + when: spamhaus_firewalld_systemd_units is changed or + spamhaus_nftables_systemd_units is changed - - name: Start and enable Spamhaus update timer - when: ansible_distribution_version is version('9', '>=') + - name: Start and enable Spamhaus firewalld update timer + when: ansible_distribution_version is version('10', '<=') systemd: name=update-spamhaus-lists.timer state=started enabled=yes notify: - restart firewalld + - name: Start and enable Spamhaus nftables update timer + when: ansible_distribution_version is version('11', '>=') + systemd: name=update-spamhaus-nftables.timer state=started enabled=yes + - include_tasks: fail2ban.yml when: ansible_distribution_major_version is version('9', '>=') tags: firewall diff --git a/roles/common/templates/nftables.conf.j2 b/roles/common/templates/nftables.conf.j2 new file mode 100755 index 0000000..65bd30e --- /dev/null +++ b/roles/common/templates/nftables.conf.j2 @@ -0,0 +1,65 @@ +#!/usr/sbin/nft -f +# +# Initially based on: https://wiki.nftables.org/wiki-nftables/index.php/Simple_ruleset_for_a_server +# + +flush ruleset + +# Lists updated daily by update-spamhaus-nftables.sh +include "/etc/nftables/spamhaus-ipv4.nft" +include "/etc/nftables/spamhaus-ipv6.nft" + +# Notes: +# - tables hold chains, chains hold rules +# - inet is for both ipv4 and ipv6 +table inet filter { + set spamhaus-ipv4 { + type ipv4_addr + # if the set contains prefixes we need to use the interval flag + flags interval + elements = $SPAMHAUS_IPV4 + } + + set spamhaus-ipv6 { + type ipv6_addr + flags interval + elements = $SPAMHAUS_IPV6 + } + + chain input { + type filter hook input priority 0; + + # Allow traffic from established and related packets. + ct state {established, related} accept + + # Drop invalid packets. + ct state invalid counter drop + + # Drop packets matching the spamhaus sets early. + ip saddr @spamhaus-ipv4 counter drop + ip6 saddr @spamhaus-ipv6 counter drop + + # Allow loopback traffic. + iifname lo accept + + # Allow all ICMP and IGMP traffic, but enforce a rate limit + # to help prevent some types of flood attacks. + ip protocol icmp limit rate 4/second accept + ip6 nexthdr ipv6-icmp limit rate 4/second accept + ip protocol igmp limit rate 4/second accept + + ip saddr 172.20.0.1 ct state new tcp dport 22 counter accept + + # everything else + reject with icmpx type port-unreachable + } + chain forward { + type filter hook forward priority 0; + } + chain output { + type filter hook output priority 0; + # Drop outgoing packets matching the spamhaus sets too + ip daddr @spamhaus-ipv4 counter drop + ip6 daddr @spamhaus-ipv6 counter drop + } +}