[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