nftables

The proposed way to ban IPs with nftables uses its own reaction table. Inside, there are two sets and two rules. One set/rule couple is for IPv4 and the other one is for IPv6.

The IPs are banned on all ports, meaning banned IPs won't be able to connect on any service of the host.

We don't make use of nftables timeouts because we need reaction to handle the lifecycle of a ban. If you choose to unban with nftables timeouts, you won't have access to all of reaction features, as it won't know what's currently banned, nor how to unban an IP: showing bans with reaction show and unbanning with reaction flush can't be supported.

Start/Stop

We create the table with relevant rules and filters

{
  start: [
    ['nft', |||
    table inet reaction {
      set ipv4bans {
        type ipv4_addr
        flags interval
        auto-merge
      }
      set ipv6bans {
        type ipv6_addr
        flags interval
        auto-merge
      }
      chain input {
        type filter hook input priority 0
        policy accept
        ip saddr @ipv4bans drop
        ip6 saddr @ipv6bans drop
      }
    }
||| ],
  ],
}

We want reaction to delete all its setup when quitting:

{
  stop: [
    ['nft', 'delete table inet reaction'],
  ],
}

🚧 auto-merge has been reported not to work well with nftables < 1.0.7

Ban/Unban

IPv4

Now we can ban an IPv4 address with this command:

{
  cmd: ['nft', 'add element inet reaction ipv4bans { <ipv4> }']
}

And unban the IP with this command:

{
  cmd: ['nft', 'delete element inet reaction ipv4bans { <ipv4> }']
}

IPv6

IPv6 works the same way:

{
  cmd: ['nft', 'add element inet reaction ipv6bans { <ipv6> }']
}
{
  cmd: ['nft', 'delete element inet reaction ipv6bans { <ipv6> }']
}

IPv4/IPv6

A very small utility, nft46, has been written to unify ipv4 and ipv6 commands:

{
  cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }']
}
{
  cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }']
}

The X in the command will be changed to 4 or 6 at runtime depending on the IP provided. There must be a X before the curly brackets, then this sequence: {, at least one space, exactly one IP (v4 or v6), at least one space, a }. You can do it!

Wrapping this in a reusable JSONnet function

local banFor(time) = {
  ban: {
    cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }'],
  },
  unban: {
    cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }'],
    after: time,
  },
};

Real-world example

local banFor(time) = {
  ban: {
    cmd: ['nft46', 'add element inet reaction ipvXbans { <ip> }'],
  },
  unban: {
    cmd: ['nft46', 'delete element inet reaction ipvXbans { <ip> }'],
    after: time,
  },
};

{
  patterns: {
    ip: {
      regex: '...', // See patterns.md
    },
  },

  start: [
    ['nft', |||
    table inet reaction {
      set ipv4bans {
        type ipv4_addr
        flags interval
        auto-merge
      }
      set ipv6bans {
        type ipv6_addr
        flags interval
        auto-merge
      }
      chain input {
        type filter hook input priority 0
        policy accept
        ip saddr @ipv4bans drop
        ip6 saddr @ipv6bans drop
      }
    }
||| ],
  ],
  stop: [
    ['nft', 'delete table inet reaction'],
  ],

  streams: {
    // Ban hosts failing to connect via ssh
    ssh: {
      cmd: [' journalctl', '-fn0', '-u', 'sshd.service'],
      filters: {
        failedlogin: {
          regex: [
            @'authentication failure;.*rhost=<ip>',
            @'Connection reset by authenticating user .* <ip>',
            @'Failed password for .* from <ip>',
          ],
          retry: 3,
          retryperiod: '6h',
          actions: banFor('48h'),
        },
      },
    },
  },
}