[packages/fail2ban] Add subnet patch: count and ban whole subnets; rel 2
arekm
arekm at pld-linux.org
Wed Aug 26 13:37:10 CEST 2026
commit 0c72393bdcd0138c3bf433bb57b52661c00f7a56
Author: Arkadiusz Miśkiewicz <arekm at maven.pl>
Date: Tue Aug 25 21:29:47 2026 +0200
Add subnet patch: count and ban whole subnets; rel 2
New jail/filter option "subnet = <plen4> [<plen6>]" masks captured IPs
before counting, so e.g. a /24 shares one ticket and is banned as
x.x.x.0/24. Related to upstream (gh-927).
fail2ban-subnet.patch | 259 ++++++++++++++++++++++++++++++++++++++++++++++++++
fail2ban.spec | 4 +-
2 files changed, 262 insertions(+), 1 deletion(-)
---
diff --git a/fail2ban.spec b/fail2ban.spec
index b92f9f7..320e40a 100644
--- a/fail2ban.spec
+++ b/fail2ban.spec
@@ -6,7 +6,7 @@ Summary: Ban IPs that make too many password failures
Summary(pl.UTF-8): Blokowanie IP powodujących zbyt dużo prób logowań z błędnym hasłem
Name: fail2ban
Version: 1.1.1
-Release: 1
+Release: 2
License: GPL
Group: Daemons
Source0: https://github.com/fail2ban/fail2ban/archive/%{version}.tar.gz
@@ -22,6 +22,7 @@ Patch6: %{name}-restore-before-filter.patch
Patch7: %{name}-gil-switchinterval.patch
Patch8: %{name}-restore-idle-gate.patch
Patch10: %{name}-nginx-block-map-printf.patch
+Patch11: %{name}-subnet.patch
URL: http://fail2ban.sourceforge.net/
BuildRequires: python3-devel
BuildRequires: python3-modules
@@ -62,6 +63,7 @@ z sshd czy plikami logów serwera WWW Apache.
%patch -P7 -p1
%patch -P8 -p1
%patch -P10 -p1
+%patch -P11 -p1
rm setup.cfg
# jail.conf includes it (logifiles.patch), so the config has to be complete already in the build tree
diff --git a/fail2ban-subnet.patch b/fail2ban-subnet.patch
new file mode 100644
index 0000000..a8619a0
--- /dev/null
+++ b/fail2ban-subnet.patch
@@ -0,0 +1,259 @@
+--- a/fail2ban/client/fail2banregex.py 2026-08-15 14:25:41.000000000 +0200
++++ b/fail2ban/client/fail2banregex.py 2026-08-25 20:29:45.663180759 +0200
+@@ -148,6 +148,9 @@
+ Option("--usedns", action='store', default=None,
+ help="DNS specified replacement of tags <HOST> in regexp "
+ "('yes' - matches all form of hosts, 'no' - IP addresses only)"),
++ Option("--subnet", action='store', default=None,
++ help="aggregate captured addresses into subnets by prefix "
++ "length(s) '<plen4>[ <plen6>]', e.g. '24 64'"),
+ Option("-L", "--maxlines", type=int, default=0,
+ help="maxlines for multi-line regex."),
+ Option("-m", "--journalmatch",
+@@ -279,6 +282,12 @@
+ self.setDatePattern(opts.datepattern)
+ if opts.usedns:
+ self._filter.setUseDns(opts.usedns)
++ if opts.subnet:
++ try:
++ self._filter.setSubnet(opts.subnet)
++ except ValueError as e: # pragma: no cover
++ output("ERROR: %s" % e)
++ sys.exit(255)
+ self._filter.returnRawHost = opts.raw
+ self._filter.checkAllRegex = opts.checkAllRegex and not opts.out
+ # ignore pending (without ID/IP), added to matches if it hits later (if ID/IP can be retrieved)
+--- a/fail2ban/client/filterreader.py 2026-08-15 14:25:41.000000000 +0200
++++ b/fail2ban/client/filterreader.py 2026-08-25 18:21:24.629851169 +0200
+@@ -38,6 +38,7 @@
+
+ _configOpts = {
+ "usedns": ["string", None],
++ "subnet": ["string", None],
+ "prefregex": ["string", None],
+ "ignoreregex": ["string", None],
+ "failregex": ["string", None],
+@@ -89,7 +90,7 @@
+ stream.insert(0 if opt == 'usedns' else prio0idx,
+ ["set", jailName, opt, value])
+ prio0idx += 1
+- elif opt == 'datepattern':
++ elif opt in ('datepattern', 'subnet'):
+ stream.append(["set", jailName, opt, value])
+ elif opt == 'journalmatch':
+ for match in value.split("\n"):
+--- a/fail2ban/protocol.py 2026-08-15 14:25:41.000000000 +0200
++++ b/fail2ban/protocol.py 2026-08-25 20:29:51.299847426 +0200
+@@ -104,6 +104,7 @@
+ ["set <JAIL> bantime <TIME>", "sets the number of seconds <TIME> a host will be banned for <JAIL>"],
+ ["set <JAIL> datepattern <PATTERN>", "sets the <PATTERN> used to match date/times for <JAIL>"],
+ ["set <JAIL> usedns <VALUE>", "sets the usedns mode for <JAIL>"],
++["set <JAIL> subnet <VALUE>", "sets subnet aggregation prefix length(s) '<plen4>[ <plen6>]' for <JAIL> (captured addresses are masked to subnets for counting and banning), empty value to disable"],
+ ["set <JAIL> attempt <IP> [<failure1> ... <failureN>]", "manually notify about <IP> failure"],
+ ["set <JAIL> banip <IP> ... <IP>", "manually Ban <IP> for <JAIL>"],
+ ["set <JAIL> unbanip [--report-absent] <IP> ... <IP>", "manually Unban <IP> in <JAIL>"],
+@@ -137,6 +138,7 @@
+ ["get <JAIL> bantime", "gets the time a host is banned for <JAIL>"],
+ ["get <JAIL> datepattern", "gets the pattern used to match date/times for <JAIL>"],
+ ["get <JAIL> usedns", "gets the usedns setting for <JAIL>"],
++["get <JAIL> subnet", "gets the subnet aggregation prefix lengths for <JAIL>"],
+ ["get <JAIL> banip [<SEP>|--with-time]", "gets the list of of banned IP addresses for <JAIL>. Optionally the separator character ('<SEP>', default is space) or the option '--with-time' (printing the times of ban) may be specified. The IPs are ordered by end of ban."],
+ ["get <JAIL> maxretry", "gets the number of failures allowed for <JAIL>"],
+ ["get <JAIL> maxmatches", "gets the max number of matches stored in memory per ticket in <JAIL>"],
+--- a/fail2ban/server/filter.py 2026-08-25 18:18:56.549029625 +0200
++++ b/fail2ban/server/filter.py 2026-08-25 20:46:28.169847248 +0200
+@@ -76,6 +76,8 @@
+ self.__ignoreRegex = list()
+ ## Use DNS setting
+ self.setUseDns(useDns)
++ ## Aggregate captured addresses into subnets, prefix length per family (IPv4, IPv6):
++ self.__subnet = (None, None)
+ ## The amount of time to look back.
+ self.__findTime = 600
+ ## Ignore own IPs flag:
+@@ -274,6 +276,43 @@
+ return self.__useDns
+
+ ##
++ # Set subnet aggregation - prefix length(s) the captured addresses are
++ # masked to before counting and banning, so that all addresses from one
++ # subnet share a single failure counter and get banned as a whole subnet.
++ # @param value "<plen4>[ <plen6>]" (e.g. "24" or "24 64"), empty to disable
++
++ def setSubnet(self, value):
++ if value in (None, ""):
++ subnet = (None, None)
++ else:
++ # space/comma separated "<plen4> <plen6>"; '/' is not a separator
++ # (it would silently turn a "/24" typo into an IPv6-only setting).
++ # Empty tokens from leading/trailing/repeated separators are dropped
++ # so position stays v4-then-v6 - a leading separator must never shift
++ # the IPv4 value into the IPv6 slot:
++ v = [t for t in re.split(r"[\s,]+", str(value).strip()) if t != ""]
++ if len(v) > 2:
++ raise ValueError("too many prefix lengths %r for subnet (at most 2: IPv4 IPv6)" % (value,))
++ v += [""]
++ plen4 = int(v[0]) if v[0] != "" else None
++ plen6 = int(v[1]) if v[1] != "" else None
++ if plen4 is not None and not 0 < plen4 <= 32:
++ raise ValueError("invalid IPv4 prefix length %r for subnet" % (plen4,))
++ if plen6 is not None and not 0 < plen6 <= 128:
++ raise ValueError("invalid IPv6 prefix length %r for subnet" % (plen6,))
++ # full-length prefix means no aggregation for that family:
++ subnet = (plen4 if plen4 != 32 else None, plen6 if plen6 != 128 else None)
++ logSys.debug("Setting subnet = %s for %s", value, self)
++ self.__subnet = subnet
++
++ ##
++ # Get subnet aggregation prefix lengths
++ # @return tuple of prefix lengths (IPv4, IPv6), (None, None) if disabled
++
++ def getSubnet(self):
++ return self.__subnet
++
++ ##
+ # Set the time needed to find a failure.
+ #
+ # This value tells the filter how long it has to take failures into
+@@ -591,7 +630,11 @@
+ return v
+
+ # check own IPs should be ignored and 'ip' is self IP:
+- if self.__ignoreSelf and ip in DNSUtils.getSelfIPs():
++ # a subnet-aggregated ticket (plen < full) covers, not equals, the self
++ # IP, so plain set membership misses it - also ignore when the ticket's
++ # subnet contains one of our own addresses:
++ if self.__ignoreSelf and (ip in DNSUtils.getSelfIPs() or
++ (not ip.isSingle and any(ip.contains(s) for s in DNSUtils.getSelfIPs()))):
+ self.logIgnoreIp(ip, log_ignore, ignore_source="ignoreself rule")
+ if self.__ignoreCache: c.set(key, True)
+ return True
+@@ -949,12 +992,17 @@
+ # ip-address or host:
+ ip = fail.get('ip4')
+ if ip is not None:
+- cidr = int(fail.get('cidr') or IPAddr.FAM_IPv4)
++ # explicit cidr from log wins over configured subnet aggregation;
++ # with a prefix length IPAddr masks the host bits, so the ticket
++ # becomes the subnet and failures aggregate per subnet:
++ cidr = int(fail.get('cidr') or
++ (self.__subnet[0] if self.__subnet[0] is not None else IPAddr.FAM_IPv4))
+ raw = True
+ else:
+ ip = fail.get('ip6')
+ if ip is not None:
+- cidr = int(fail.get('cidr') or IPAddr.FAM_IPv6)
++ cidr = int(fail.get('cidr') or
++ (self.__subnet[1] if self.__subnet[1] is not None else IPAddr.FAM_IPv6))
+ raw = True
+ else:
+ ip = fail.get('dns')
+--- a/fail2ban/server/server.py 2026-08-25 18:18:56.555938326 +0200
++++ b/fail2ban/server/server.py 2026-08-25 18:21:09.773184505 +0200
+@@ -502,7 +502,13 @@
+
+ def getUseDns(self, name):
+ return self.__jails[name].filter.getUseDns()
+-
++
++ def setSubnet(self, name, value):
++ self.__jails[name].filter.setSubnet(value)
++
++ def getSubnet(self, name):
++ return self.__jails[name].filter.getSubnet()
++
+ def setMaxMatches(self, name, value):
+ self.__jails[name].filter.failManager.maxMatches = value
+
+--- a/fail2ban/server/transmitter.py 2026-08-15 14:25:41.000000000 +0200
++++ b/fail2ban/server/transmitter.py 2026-08-25 18:21:20.679851169 +0200
+@@ -315,6 +315,11 @@
+ self.__server.setUseDns(name, value)
+ if self.__quiet: return
+ return self.__server.getUseDns(name)
++ elif command[1] == "subnet":
++ value = command[2]
++ self.__server.setSubnet(name, value)
++ if self.__quiet: return
++ return self.__server.getSubnet(name)
+ elif command[1] == "findtime":
+ value = command[2]
+ self.__server.setFindTime(name, value)
+@@ -467,6 +472,8 @@
+ return self.__server.getIgnoreRegex(name)
+ elif command[1] == "usedns":
+ return self.__server.getUseDns(name)
++ elif command[1] == "subnet":
++ return self.__server.getSubnet(name)
+ elif command[1] == "findtime":
+ return self.__server.getFindTime(name)
+ elif command[1] == "datepattern":
+--- a/fail2ban/tests/filtertestcase.py 2026-08-25 18:18:56.556301944 +0200
++++ b/fail2ban/tests/filtertestcase.py 2026-08-25 20:47:17.163180573 +0200
+@@ -298,6 +298,49 @@
+ (r"^%Y-%m-%d-%H%M%S\.%f %z **",
+ r"^Year-Month-Day-24hourMinuteSecond\.Microseconds Zone offset **"))
+
++ def testGetSetSubnet(self):
++ self.assertEqual(self.filter.getSubnet(), (None, None))
++ self.filter.setSubnet("24")
++ self.assertEqual(self.filter.getSubnet(), (24, None))
++ self.filter.setSubnet("24 64")
++ self.assertEqual(self.filter.getSubnet(), (24, 64))
++ # full-length prefix means no aggregation for that family:
++ self.filter.setSubnet("32 128")
++ self.assertEqual(self.filter.getSubnet(), (None, None))
++ self.filter.setSubnet("")
++ self.assertEqual(self.filter.getSubnet(), (None, None))
++ self.assertRaises(ValueError, self.filter.setSubnet, "33")
++ self.assertRaises(ValueError, self.filter.setSubnet, "24 129")
++ self.assertRaises(ValueError, self.filter.setSubnet, "24 64 12")
++ # a leading separator must not shift the IPv4 value into the IPv6 slot:
++ self.filter.setSubnet(",24")
++ self.assertEqual(self.filter.getSubnet(), (24, None))
++ self.filter.setSubnet(" 24 64 ")
++ self.assertEqual(self.filter.getSubnet(), (24, 64))
++ # '/' is not a separator - a "/24" typo is rejected, not silently
++ # reinterpreted as IPv6-only:
++ self.assertRaises(ValueError, self.filter.setSubnet, "/24")
++
++ def testSubnetAggregation(self):
++ self.filter.addFailRegex(r"failure from <ADDR>$")
++ self.filter.setDatePattern(r"^%Y-%m-%d %H:%M:%S")
++ self.filter.checkFindTime = False
++ self.filter.setSubnet("24 64")
++ def fid(ip):
++ failures = self.filter.processLine("2005-01-01 12:00:00 failure from " + ip)
++ self.assertEqual(len(failures), 1)
++ return failures[0][1]
++ # different addresses from one net fold into the same (masked) ticket:
++ self.assertEqual(fid("192.0.2.133"), fid("192.0.2.5"))
++ self.assertEqual(str(fid("192.0.2.133")), "192.0.2.0/24")
++ # IPv6 masked to /64, compressed and full forms fold together:
++ self.assertEqual(fid("2001:db8:0:1::1"), fid("2001:db8:0:1:2:3:4:5"))
++ self.assertEqual(str(fid("2001:db8:0:1::1")), "2001:db8:0:1::/64")
++ # disabled again - single addresses, distinct tickets:
++ self.filter.setSubnet(None)
++ self.assertNotEqual(fid("192.0.2.133"), fid("192.0.2.5"))
++ self.assertEqual(str(fid("192.0.2.133")), "192.0.2.133")
++
+ def testGetSetLogTimeZone(self):
+ self.assertEqual(self.filter.getLogTimeZone(), None)
+ self.filter.setLogTimeZone('UTC')
+@@ -359,6 +402,16 @@
+ self.assertTrue(self.filter.inIgnoreIPList(ip))
+ self.assertLogged("[%s] Ignore %s by %s" % (self.jail.name, ip, "ignoreself rule"))
+
++ def testIgnoreSelfSubnet(self):
++ # a subnet-aggregated ticket covers (not equals) the self IP, so plain
++ # set membership misses it - ignoreself must still catch it:
++ self.filter.ignoreSelf = True
++ self.filter.setSubnet("24 64")
++ self.pruneLog()
++ ip = IPAddr("127.0.0.1", 24) # 127.0.0.0/24, contains loopback self IP
++ self.assertTrue(self.filter.inIgnoreIPList(ip))
++ self.assertLogged("[%s] Ignore %s by %s" % (self.jail.name, ip, "ignoreself rule"))
++
+ def testIgnoreIPOK(self):
+ ipList = "127.0.0.1", "192.168.0.1", "255.255.255.255", "99.99.99.99"
+ for ip in ipList:
================================================================
---- gitweb:
http://git.pld-linux.org/gitweb.cgi/packages/fail2ban.git/commitdiff/0c72393bdcd0138c3bf433bb57b52661c00f7a56
More information about the pld-cvs-commit
mailing list