Repository: fail2ban/fail2ban (language: python)

Issue:
[BR]: ipsets created by firewallcmd-ipset action only ban up to 65536 IPs
<!--
  - Before reporting, please make sure to search the open and closed issues for any reports in the past.
  - Use this issue template to report a bug in the fail2ban engine (not in a filter or jail).
  - If you want to request a feature or a new filter, please use "Feature request" or "Filter request" instead.
  - If you have rather some question, please open or join to some discussion.

  We will be very grateful, if your problem was described as completely as possible,
  enclosing excerpts from logs (if possible within DEBUG mode, if no errors evident
  within INFO mode), and configuration in particular of effected relevant settings
  (e.g., with ` fail2ban-client -d | grep 'affected-jail-name' ` for a particular
  jail troubleshooting).
  Thank you in advance for the details, because such issues like "It does not work" 
  alone could not help to resolve anything!
  Thanks! 
  (you can remove this paragraph and other comments upon reading)
-->

### Environment:

<!--
  Fill out and check (`[x]`) the boxes which apply. If your Fail2Ban version is outdated, 
  and you can't verify that the issue persists in the recent release, better seek support 
  from the distribution you obtained Fail2Ban from
-->

- Fail2Ban version: 1.0.2-2
- OS, including release name/version : Debian 12
- [x] Fail2Ban installed via OS/distribution mechanisms
- [x] You have not applied any additional foreign patches to the codebase
- [x] Some customizations were done to the configuration (provide details below is so)

```ini
# /etc/fail2ban/jail.local
[DEFAULT]
bantime   = 1year
banaction = firewallcmd-ipset[actiontype=<multiport>,blocktype=DROP]
banaction_allports = firewallcmd-ipset[actiontype=<allports>,blocktype=DROP]
```

### The issue:

On hosts where `bantime` is set to a long duration (e.g. 1 year) and/or subject to constant bruteforce attempts, and where the `firewallcmd-ipset` is used as `banaction`, the number of banned IPs quickly exceeds the max number of elements in the `ipset` created by the action. This number is `65536`, which is the default when creating an ipset without using the `maxelem NUMBER` option. 

When this limit is reached, no more IPs can be added to the ipset and offenders are no longer effectively banned:

```
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- exec: ipset -exist add f2b-sshd 1.2.3.4/24 timeout 0
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- stderr: 'ipset v7.17: Hash is full, cannot add more elements'
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- returned 1
fail2ban.actions        [433696]: ERROR   Failed to execute ban jail 'sshd' action 'firewallcmd-ipset' info 'ActionInfo({'ip': '1.2.3.4/24', 'family': 'inet4', 'fid': <function Actions.ActionInfo.<lambda> at 0x7f3a03fb9e40>, 'raw-ticket': <function Actions.ActionInfo.<lambda> at 0x7f3a03fba5c0>})': Error banning 1.2.3.4/24
```

#### Steps to reproduce

- Set up a jail with a high rate of positive matches (e.g. `sshd` on a publicly exposed SSH server, or jail+filter that bans hosts scanning for `wp_admin/`, `.git/config` on webserver virtualhosts, etc.) and/or long ban time
- Set `firewallcmd-ipset` as `banaction` for the jail (or as default `banaction`)
- Wait until the number of banned hosts reaches 65536

#### Expected behavior

The jail should keep doing its job regardless of the number of IP addresses added to it. There are 2³² IPv4 addresses and while I do not expect to ever ban most of them, a jail like the one I mentioned above will definitely exceed 65536 banned IPs after some time.

I suggest making the `maxelem` ipset option configurable through banaction options (e.g. `banaction = firewallcmd-ipset[actiontype=<multiport>,blocktype=DROP,maxelem=1000000]`, _or_ setting a high value for this option by default.

#### Observed behavior

- Error message above in fail2ban logs
- ipset stops growing, new offenders not banned

#### Any additional information

- This might also affect other `ipset`-based actions? (did not check)
- There is a workaround which involves editing the following lines in `/etc/fail2ban/action.d/firewallcmd-ipset.conf` (add `maxelem 1000000` option):

```ini
[ipstype_ipset]
actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> maxelem 1000000 <familyopt>
```
```ini
[ipstype_firewalld]
actionstart = firewall-cmd --direct --new-ipset=<ipmset> --type=hash:ip --option=timeout=<default-ipsettime> --option=maxelem=1000000 <firewalld_familyopt>
```

However it is not practical, because it involves editing a config/action file provided by fail2ban (as far as I know there is no way to override these specific settings from a `.local` or `.d/*conf` file?).

Filing this as a bug report because it makes such jails ineffective (and I did not notice this problem for a while, and stumbled on it while manually checking fail2ban logs), and the workaround for it is not optimal.

As far as I know, and after checking the [ipset manpage](https://manpages.debian.org/bullseye/ipset/ipset.8.en.html) there are no downsides to a larger `maxelem` value, it does not appear to affect memory usage like the `hashsize` or `bucketsize` options do.

### Configuration, dump and another helpful excerpts

Relevant info provided above.

#### Any customizations done to /etc/fail2ban/ configuration

Relevant info provided above.

#### Relevant parts of /var/log/fail2ban.log file:

```
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- exec: ipset -exist add f2b-ssh 1.2.3.4/24 timeout 0
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- stderr: 'ipset v7.17: Hash is full, cannot add more elements'
fail2ban.utils          [433696]: ERROR   7f3a01e05ed0 -- returned 1
fail2ban.actions        [433696]: ERROR   Failed to execute ban jail 'sshd' action 'firewallcmd-ipset' info 'ActionInfo({'ip': '1.2.3.4/24', 'family': 'inet4', 'fid': <function Actions.ActionInfo.<lambda> at 0x7f3a03fb9e40>, 'raw-ticket': <function Actions.ActionInfo.<lambda> at 0x7f3a03fba5c0>})': Error banning 1.2.3.4/24
```

#### Relevant lines from monitored log files:

Any line that matches a jail with high positives rate.

Thanks a lot for maintaining fail2ban.


Discussion / hints:
> On hosts where bantime is set to a long duration (e.g. 1 year) 

This is still one of cons of long or persistent banning (besides wasting of system resources, bothering net subsystem, extra system load etc), but also totally unneeded, especially if one consider than evildoers often change the IPs, let alone dynamic IPs.
Even with incremental banning (`bantime.increment = true`), it is recommended to set `bantime.maxtime` to several weeks, e. g. `5w` is fully enough - if some recidive evildoer returns back after 5 weeks ban, it will be immediately banned again for 5 weeks (so basically it'd get only 10 attempts in year), but other banned occasionally so not a repeat offenders become forgotten.

> there are no downsides to a larger maxelem value

Well indirectly it can result to few effective distribution in a hash tables across the buckets, but probably it'd be marginal.

From other point of view - which range will be large enough? Or with other words, would be 1M enough?

> Filing this as a bug report because it makes such jails ineffective

Are you interested in providing a pull request with a "fix" for all ipset related actions?
Something with a parameter that can be simply changed:
```diff
- actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> <familyopt>
+ actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> maxelem <maxelm> <familyopt>
  ...
  [Init]
  ...
+ maxelm = 262144
```
> From other point of view - which range will be large enough? Or with other words, would be 1M enough?

There is no way to know in advance, as it depends on the rate of auth failures, duration of the ban... I think the default of 65536 is as good as any (and consistent with ipset's default) - the "bug" in this case being a combination of: it makes the jail ineffective when the ipset is full, it is semi-silent unless you actively watch the logs, and once you are aware of it, it cannot be changed without editing fail2ban's built-in actions. Just fixing the last point would be enough to make it more manageable/tolerable for my use case.

Fair point about using shorter duration bans + the recidive filter, I will consider it in the future. However I would still like to consider IPs actively engaging in bruteforce as permanently "bad", so keeping them in the ban list is appropriate.

> Are you interested in providing a pull request with a "fix" for all ipset related actions?

Sure, I will have a look at it today, thank you for the pointers.


Fix patch:
diff --git a/ChangeLog b/ChangeLog
index f41eb156ce..5d1e933b16 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -13,6 +13,7 @@ ver. 1.0.3-dev-1 (20??/??/??) - development nightly edition
 ### Fixes
 * circumvent SEGFAULT in a python's socket module by getaddrinfo with disabled IPv6 (gh-3438)
 * `action.d/cloudflare-token.conf` - fixes gh-3479, url-encode args by unban
+* `action.d/*ipset*`: make `maxelem` ipset option configurable through banaction arguments (gh-3564)
 
 ### New Features and Enhancements
 * better auto-detection for IPv6 support (`allowipv6 = auto` by default), trying to check sysctl net.ipv6.conf.all.disable_ipv6
diff --git a/config/action.d/firewallcmd-ipset.conf b/config/action.d/firewallcmd-ipset.conf
index c36ba69467..15d22f9e64 100644
--- a/config/action.d/firewallcmd-ipset.conf
+++ b/config/action.d/firewallcmd-ipset.conf
@@ -35,7 +35,7 @@ actionunban = <ipstype_<ipsettype>/actionunban>
 
 [ipstype_ipset]
 
-actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> <familyopt>
+actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> maxelem <maxelem> <familyopt>
 
 actionflush = ipset flush <ipmset>
 
@@ -47,7 +47,7 @@ actionunban = ipset -exist del <ipmset> <ip>
 
 [ipstype_firewalld]
 
-actionstart = firewall-cmd --direct --new-ipset=<ipmset> --type=hash:ip --option=timeout=<default-ipsettime> <firewalld_familyopt>
+actionstart = firewall-cmd --direct --new-ipset=<ipmset> --type=hash:ip --option=timeout=<default-ipsettime> --option=maxelem=<maxelem> <firewalld_familyopt>
 
 # TODO: there doesn't seem to be an explicit way to invoke the ipset flush function using firewall-cmd
 actionflush = 
@@ -77,6 +77,12 @@ default-ipsettime = 0
 # Values:  [ NUM ]  Default: 0 (managed by fail2ban by unban)
 ipsettime = 0
 
+# Option: maxelem
+# Notes:  maximal number of elements which can be stored in the ipset
+#         You may want to increase this for long-duration/high-volume jails
+# Values: [ NUM ] Default: 65536
+maxelem = 65536
+
 # expresion to caclulate timeout from bantime, example:
 # banaction = %(known/banaction)s[ipsettime='<timeout-bantime>']
 timeout-bantime = $([ "<bantime>" -le 2147483 ] && echo "<bantime>" || echo 0)
diff --git a/config/action.d/iptables-ipset-proto4.conf b/config/action.d/iptables-ipset-proto4.conf
index 3762428493..d33008be55 100644
--- a/config/action.d/iptables-ipset-proto4.conf
+++ b/config/action.d/iptables-ipset-proto4.conf
@@ -27,7 +27,7 @@ before = iptables.conf
 # Notes.:  command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false).
 # Values:  CMD
 #
-actionstart = ipset --create f2b-<name> iphash
+actionstart = ipset --create f2b-<name> maxelem <maxelem> iphash
               <_ipt_add_rules>
 
 
@@ -64,3 +64,11 @@ actionunban = ipset --test f2b-<name> <ip> && ipset --del f2b-<name> <ip>
 # Several capabilities used internaly:
 
 rule-jump = -m set --match-set f2b-<name> src -j <blocktype>
+
+[Init]
+
+# Option: maxelem
+# Notes:  maximal number of elements which can be stored in the ipset
+#         You may want to increase this for long-duration/high-volume jails
+# Values: [ NUM ] Default: 65536
+maxelem = 65536
diff --git a/config/action.d/iptables-ipset.conf b/config/action.d/iptables-ipset.conf
index b44e6ec4e2..15aadd92c6 100644
--- a/config/action.d/iptables-ipset.conf
+++ b/config/action.d/iptables-ipset.conf
@@ -24,7 +24,7 @@ before = iptables.conf
 # Notes.:  command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false).
 # Values:  CMD
 #
-actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> <familyopt>
+actionstart = ipset -exist create <ipmset> hash:ip timeout <default-ipsettime> maxelem <maxelem> <familyopt>
               <_ipt_add_rules>
 
 # Option:  actionflush
@@ -76,6 +76,12 @@ default-ipsettime = 0
 # Values:  [ NUM ]  Default: 0 (managed by fail2ban by unban)
 ipsettime = 0
 
+# Option: maxelem
+# Notes:  maximal number of elements which can be stored in the ipset
+#         You may want to increase this for long-duration/high-volume jails
+# Values: [ NUM ] Default: 65536
+maxelem = 65536
+
 # expresion to caclulate timeout from bantime, example:
 # banaction = %(known/banaction)s[ipsettime='<timeout-bantime>']
 timeout-bantime = $([ "<bantime>" -le 2147483 ] && echo "<bantime>" || echo 0)
diff --git a/config/action.d/shorewall-ipset-proto6.conf b/config/action.d/shorewall-ipset-proto6.conf
index eacb53d92e..6f1e02d388 100644
--- a/config/action.d/shorewall-ipset-proto6.conf
+++ b/config/action.d/shorewall-ipset-proto6.conf
@@ -51,7 +51,7 @@
 # Values:  CMD
 #
 actionstart = if ! ipset -quiet -name list f2b-<name> >/dev/null;
-              then ipset -quiet -exist create f2b-<name> hash:ip timeout <default-ipsettime>;
+              then ipset -quiet -exist create f2b-<name> hash:ip timeout <default-ipsettime> maxelem <maxelem>;
               fi
 
 # Option:  actionstop
@@ -91,3 +91,11 @@ ipsettime = 0
 # expresion to caclulate timeout from bantime, example:
 # banaction = %(known/banaction)s[ipsettime='<timeout-bantime>']
 timeout-bantime = $([ "<bantime>" -le 2147483 ] && echo "<bantime>" || echo 0)
+
+[Init]
+
+# Option: maxelem
+# Notes:  maximal number of elements which can be stored in the ipset
+#         You may want to increase this for long-duration/high-volume jails
+# Values: [ NUM ] Default: 65536
+maxelem = 65536


Test patch:
diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py
index 7cba9add77..695c80ee08 100644
--- a/fail2ban/tests/servertestcase.py
+++ b/fail2ban/tests/servertestcase.py
@@ -1597,12 +1597,12 @@ def testCheckStockCommandActions(self):
 					"`done`",
 				),
 				'ip4-start': (
-					"`ipset -exist create f2b-j-w-iptables-ipset hash:ip timeout 0 `",
+					"`ipset -exist create f2b-j-w-iptables-ipset hash:ip timeout 0 maxelem 65536 `",
 					"`{ iptables -w -C INPUT -p $proto -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset src -j REJECT --reject-with icmp-port-unreachable >/dev/null 2>&1; } || "
 					 "{ iptables -w -I INPUT -p $proto -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset src -j REJECT --reject-with icmp-port-unreachable; }`",
 				), 
 				'ip6-start': (
-					"`ipset -exist create f2b-j-w-iptables-ipset6 hash:ip timeout 0 family inet6`",
+					"`ipset -exist create f2b-j-w-iptables-ipset6 hash:ip timeout 0 maxelem 65536 family inet6`",
 					"`{ ip6tables -w -C INPUT -p $proto -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset6 src -j REJECT --reject-with icmp6-port-unreachable >/dev/null 2>&1; } || "
 					 "{ ip6tables -w -I INPUT -p $proto -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset6 src -j REJECT --reject-with icmp6-port-unreachable; }`",
 				),
@@ -1646,12 +1646,12 @@ def testCheckStockCommandActions(self):
 					"`done`",
 				),
 				'ip4-start': (
-					"`ipset -exist create f2b-j-w-iptables-ipset-ap hash:ip timeout 0 `",
+					"`ipset -exist create f2b-j-w-iptables-ipset-ap hash:ip timeout 0 maxelem 65536 `",
 					"`{ iptables -w -C INPUT -p $proto -m set --match-set f2b-j-w-iptables-ipset-ap src -j REJECT --reject-with icmp-port-unreachable >/dev/null 2>&1; } || "
 					 "{ iptables -w -I INPUT -p $proto -m set --match-set f2b-j-w-iptables-ipset-ap src -j REJECT --reject-with icmp-port-unreachable; }",
 				), 
 				'ip6-start': (
-					"`ipset -exist create f2b-j-w-iptables-ipset-ap6 hash:ip timeout 0 family inet6`",
+					"`ipset -exist create f2b-j-w-iptables-ipset-ap6 hash:ip timeout 0 maxelem 65536 family inet6`",
 					"`{ ip6tables -w -C INPUT -p $proto -m set --match-set f2b-j-w-iptables-ipset-ap6 src -j REJECT --reject-with icmp6-port-unreachable >/dev/null 2>&1; } || "
 					 "{ ip6tables -w -I INPUT -p $proto -m set --match-set f2b-j-w-iptables-ipset-ap6 src -j REJECT --reject-with icmp6-port-unreachable; }",
 				),
@@ -1979,11 +1979,11 @@ def testCheckStockCommandActions(self):
 			('j-w-fwcmd-ipset', 'firewallcmd-ipset[name=%(__name__)s, port="http", protocol="tcp", chain="<known/chain>"]', {
 				'ip4': (' f2b-j-w-fwcmd-ipset ',), 'ip6': (' f2b-j-w-fwcmd-ipset6 ',),
 				'ip4-start': (
-					"`ipset -exist create f2b-j-w-fwcmd-ipset hash:ip timeout 0 `",
+					"`ipset -exist create f2b-j-w-fwcmd-ipset hash:ip timeout 0 maxelem 65536 `",
 					"`firewall-cmd --direct --add-rule ipv4 filter INPUT_direct 0 -p tcp -m multiport --dports http -m set --match-set f2b-j-w-fwcmd-ipset src -j REJECT --reject-with icmp-port-unreachable`",
 				), 
 				'ip6-start': (
-					"`ipset -exist create f2b-j-w-fwcmd-ipset6 hash:ip timeout 0 family inet6`",
+					"`ipset -exist create f2b-j-w-fwcmd-ipset6 hash:ip timeout 0 maxelem 65536 family inet6`",
 					"`firewall-cmd --direct --add-rule ipv6 filter INPUT_direct 0 -p tcp -m multiport --dports http -m set --match-set f2b-j-w-fwcmd-ipset6 src -j REJECT --reject-with icmp6-port-unreachable`",
 				),
 				'flush': (
@@ -2015,11 +2015,11 @@ def testCheckStockCommandActions(self):
 			('j-w-fwcmd-ipset-ap', 'firewallcmd-ipset[name=%(__name__)s, actiontype=<allports>, protocol="tcp", chain="<known/chain>"]', {
 				'ip4': (' f2b-j-w-fwcmd-ipset-ap ',), 'ip6': (' f2b-j-w-fwcmd-ipset-ap6 ',),
 				'ip4-start': (
-					"`ipset -exist create f2b-j-w-fwcmd-ipset-ap hash:ip timeout 0 `",
+					"`ipset -exist create f2b-j-w-fwcmd-ipset-ap hash:ip timeout 0 maxelem 65536 `",
 					"`firewall-cmd --direct --add-rule ipv4 filter INPUT_direct 0 -p tcp -m set --match-set f2b-j-w-fwcmd-ipset-ap src -j REJECT --reject-with icmp-port-unreachable`",
 				), 
 				'ip6-start': (
-					"`ipset -exist create f2b-j-w-fwcmd-ipset-ap6 hash:ip timeout 0 family inet6`",
+					"`ipset -exist create f2b-j-w-fwcmd-ipset-ap6 hash:ip timeout 0 maxelem 65536 family inet6`",
 					"`firewall-cmd --direct --add-rule ipv6 filter INPUT_direct 0 -p tcp -m set --match-set f2b-j-w-fwcmd-ipset-ap6 src -j REJECT --reject-with icmp6-port-unreachable`",
 				),
 				'flush': (