Automatically ban ports scanner IPs on NixOS

Comment on Mastodon

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,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:

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.