| Title: Automatically ban ports scanner IPs on NixOS | |
| Author: Solène | |
| Date: 29 September 2022 | |
| Tags: linux security nixos firewall | |
| Description: This article presents how to automatically block IPs | |
| connecting to your server on undesirable ports. | |
| # Introduction | |
| Since I switched my server from OpenBSD to NixOS, I was missing a | |
| feature. The previous server was using iblock, a program I made to | |
| block IPs connecting on a list of ports, I don't like people knocking | |
| randomly on ports. | |
| iblock is simple, if you connect to any port on which it's listening, | |
| you get banned in the firewall. | |
| iblock project page | |
| I reimplemented it using iptables on NixOS. | |
| # How it works | |
| Iptables provides a feature adding an IP to a set if the address | |
| connects n times before s seconds. Let's just set it to ONCE so the | |
| address is banned on first connection. | |
| For the record, a "set" is an extra iptables feature allowing to add | |
| many IP addresses like an OpenBSD PF table. We need separate sets for | |
| IPv4 and IPv6, they don't mix well. | |
| # The implementation | |
| You can create a new nix file with this content and add it to the | |
| imports of your configuration file. | |
| ``` | |
| { | |
| lib, | |
| pkgs, | |
| ... | |
| }: let | |
| wan_interface = "eth0"; | |
| ports-to-block = "21,23,53,111,135,137,138,139,445,1433,25565,5432,3389,3306,… | |
| # block people 10 days | |
| expire = 60 * 60 * 24 * 10; # in seconds, 0 to disable expiration , max is 21… | |
| rules = table: [ | |
| "INPUT -i ${wan_interface} -p tcp -m multiport --dports ${ports-to-block} -… | |
| "INPUT -i ${wan_interface} -p tcp -m multiport --dports ${ports-to-block} -… | |
| "INPUT -i ${wan_interface} -p tcp -m set --match-set ${table} src -j nixos-… | |
| "INPUT -i ${wan_interface} -p udp -m set --match-set ${table} src -j nixos-… | |
| ]; | |
| create-rules = | |
| lib.concatStringsSep "\n" | |
| ( | |
| builtins.map (rule: "iptables -C " + rule + " || iptables -A " + rule) (r… | |
| ++ builtins.map (rule: "ip6tables -C " + rule + " || ip6tables -A " + rul… | |
| ); | |
| delete-rules = | |
| lib.concatStringsSep "\n" | |
| ( | |
| builtins.map (rule: "iptables -C " + rule + " && iptables -D " + rule) (r… | |
| ++ builtins.map (rule: "ip6tables -C " + rule + " && ip6tables -D " + rul… | |
| ); | |
| in { | |
| networking.firewall = { | |
| enable = true; | |
| extraPackages = [pkgs.ipset]; | |
| extraCommands = '' | |
| if test -f /var/lib/ipset.conf | |
| then | |
| ipset restore -! < /var/lib/ipset.conf | |
| else | |
| ipset -exist create blocked hash:ip ${ | |
| if expire > 0 | |
| then "timeout ${toString expire}" | |
| else "" | |
| } | |
| ipset -exist create blocked6 hash:ip family inet6 ${ | |
| if expire > 0 | |
| then "timeout ${toString expire}" | |
| else "" | |
| } | |
| fi | |
| ${create-rules} | |
| ''; | |
| extraStopCommands = '' | |
| ipset -exist create blocked hash:ip ${ | |
| if expire > 0 | |
| then "timeout ${toString expire}" | |
| else "" | |
| } | |
| ipset -exist create blocked6 hash:ip family inet6 ${ | |
| if expire > 0 | |
| then "timeout ${toString expire}" | |
| else "" | |
| } | |
| ipset save > /var/lib/ipset.conf | |
| ${delete-rules} | |
| ''; | |
| }; | |
| } | |
| ``` | |
| To explain this implementation without going into details: | |
| * rules are generated for IPv4 and IPv6 | |
| * rules are generated with a check if they exist before adding or | |
| removing them | |
| * ipset are created if they don't exist, and loaded / saved on disk in | |
| /var/lib/ipset.conf on start / stop | |
| # Caveat | |
| The configuration isn't stateless, it creates a file | |
| /var/lib/ipset.conf , so if you want to make changes like expiration | |
| time to the sets while they already exist, you will need to use ipset | |
| yourself. | |
| And most importantly, because of the way the firewall service is | |
| implemented, if you don't use this file anymore, the firewall won't | |
| reload. | |
| I've lost a lot of time figuring why: when NixOS reloads the firewall | |
| service, it uses the new reload script which doesn't include the | |
| cleanup from stopCommand, and this fails because the NixOS service | |
| didn't expect anything in the INPUT chain. | |
| ``` | |
| sept. 29 23:24:22 interbus systemd[1]: Reloading Firewall... | |
| sept. 29 23:24:22 interbus firewall-reload[94376]: iptables: Chain already exis… | |
| sept. 29 23:24:22 interbus firewall-reload[94340]: Failed to reload firewall...… | |
| sept. 29 23:24:22 interbus systemd[1]: firewall.service: Control process exited… | |
| sept. 29 23:24:22 interbus systemd[1]: Reload failed for Firewall. | |
| ``` | |
| In this case, you have to manually delete the rules in the INPUT chain | |
| in for IPv4 and IPv6, or reboot your system that will start with a | |
| fresh set, or flush all rules in iptables and restart the firewall | |
| service. | |
| # Conclusion | |
| I'll be able to publish again a list of IPs scanning my server, and | |
| this is also fun to see the list growing every minute. |