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.
       
 (HTM) 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,27019";
       
         # block people 10 days
         expire = 60 * 60 * 24 * 10; # in seconds, 0 to disable expiration , max is 2147483
       
         rules = table: [
           "INPUT -i ${wan_interface} -p tcp -m multiport --dports ${ports-to-block} -m state --state NEW -m recent --set"
           "INPUT -i ${wan_interface} -p tcp -m multiport --dports ${ports-to-block} -m state --state NEW -m recent --update --seconds 10 --hitcount 1 -j SET --add-set ${table} src"
           "INPUT -i ${wan_interface} -p tcp -m set --match-set ${table} src -j nixos-fw-refuse"
           "INPUT -i ${wan_interface} -p udp -m set --match-set ${table} src -j nixos-fw-refuse"
         ];
       
         create-rules =
           lib.concatStringsSep "\n"
           (
             builtins.map (rule: "iptables -C " + rule + " || iptables -A " + rule) (rules "blocked")
             ++ builtins.map (rule: "ip6tables -C " + rule + " || ip6tables -A " + rule) (rules "blocked6")
           );
       
         delete-rules =
           lib.concatStringsSep "\n"
           (
             builtins.map (rule: "iptables -C " + rule + " && iptables -D " + rule) (rules "blocked")
             ++ builtins.map (rule: "ip6tables -C " + rule + " && ip6tables -D " + rule) (rules "blocked6")
           );
       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 exists.
       sept. 29 23:24:22 interbus firewall-reload[94340]: Failed to reload firewall... Stopping
       sept. 29 23:24:22 interbus systemd[1]: firewall.service: Control process exited, code=exited, status=1/FAILURE
       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.