diff --git a/data/Dockerfiles/netfilter/main.py b/data/Dockerfiles/netfilter/main.py index b2322ff8d..e7c3a5cdc 100644 --- a/data/Dockerfiles/netfilter/main.py +++ b/data/Dockerfiles/netfilter/main.py @@ -147,7 +147,7 @@ def ban(address): logdebug("Ban net: %s" % net) if not net in bans: - bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0} + bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0, 'banned': False} logdebug("Initing new ban counter for %s" % net) current_attempt = time.time() @@ -162,6 +162,9 @@ def ban(address): logdebug("%s attempts now %d" % (net, bans[net]['attempts'])) if bans[net]['attempts'] >= MAX_ATTEMPTS: + if bans[net].get('banned'): + logdebug("%s is already actively banned -- skipping duplicate ban()" % net) + return cur_time = int(round(time.time())) NET_BAN_TIME = calcNetBanTime(bans[net]['ban_counter']) logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 )) @@ -174,6 +177,7 @@ def ban(address): logdebug("Calling tables.banIPv6(%s)" % net) tables.banIPv6(net) + bans[net]['banned'] = True logdebug("Updating F2B_ACTIVE_BANS[%s]=%d" % (net, cur_time + NET_BAN_TIME)) r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME) @@ -228,6 +232,7 @@ def unban(net): logdebug("Unban for %s, setting attempts=0, ban_counter+=1" % net_str) bans[net]['attempts'] = 0 bans[net]['ban_counter'] += 1 + bans[net]['banned'] = False def safe_unban(net, reason=''): try: diff --git a/data/Dockerfiles/netfilter/modules/NFTables.py b/data/Dockerfiles/netfilter/modules/NFTables.py index 7740fa59f..30da32d86 100644 --- a/data/Dockerfiles/netfilter/modules/NFTables.py +++ b/data/Dockerfiles/netfilter/modules/NFTables.py @@ -100,10 +100,18 @@ class NFTables: self.logger.logInfo(f"Clear completed: {_family}") def banIPv4(self, source): + # nft insert rule has no dedup: a second ban() call for a net that's + # already banned would add a second identical DROP rule, which unban() + # (single-match delete) can never fully clear -- an orphaned rule. + if self.is_already_banned(source, "ip"): + return False ban_dict = self.get_ban_ip_dict(source, "ip") return self.nft_exec_dict(ban_dict) def banIPv6(self, source): + # See banIPv4() -- same duplicate-insert/orphaned-rule risk applies. + if self.is_already_banned(source, "ip6"): + return False ban_dict = self.get_ban_ip_dict(source, "ip6") return self.nft_exec_dict(ban_dict) @@ -437,6 +445,34 @@ class NFTables: return json_command + def is_already_banned(self, ipaddr: str, _family: str): + _chain_opts = {'family': _family, 'table': 'filter', 'name': self.chain_name} + command = self.get_base_dict() + command['nftables'].append({'list': {'chain': _chain_opts} }) + kernel_ruleset = self.nft_exec_dict(command) + if not kernel_ruleset: + return False + + candidate_net = ipaddress.ip_network(ipaddr, strict=False) + for _object in kernel_ruleset["nftables"]: + if not _object.get("rule"): + continue + rule = _object["rule"]["expr"][0]["match"] + if not "payload" in rule["left"]: + continue + left_opt = rule["left"]["payload"] + if left_opt["protocol"] != _family or left_opt["field"] != "saddr": + continue + rule_right = rule["right"] + if isinstance(rule_right, dict): + current_rule_ip = rule_right["prefix"]["addr"] + '/' + str(rule_right["prefix"]["len"]) + else: + current_rule_ip = rule_right + if ipaddress.ip_network(current_rule_ip) == candidate_net: + return True + + return False + def get_unban_ip_dict(self, ipaddr:str, _family: str): json_command = self.get_base_dict() # Command: 'nft list chain {s_family} filter MAILCOW'