Merge pull request #5313 from mailcow/feat/f2b-banlist

[Web] add f2b_banlist endpoint
This commit is contained in:
Patrick Schult 2023-12-11 12:36:06 +01:00 committed by GitHub
commit c2e5dfd933
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 617 additions and 466 deletions

View File

@ -1,463 +1,469 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re import re
import os import os
import sys import sys
import time import time
import atexit import atexit
import signal import signal
import ipaddress import ipaddress
from collections import Counter from collections import Counter
from random import randint from random import randint
from threading import Thread from threading import Thread
from threading import Lock from threading import Lock
import redis import redis
import json import json
import dns.resolver import dns.resolver
import dns.exception import dns.exception
from modules.Logger import Logger import uuid
from modules.IPTables import IPTables from modules.Logger import Logger
from modules.NFTables import NFTables from modules.IPTables import IPTables
from modules.NFTables import NFTables
# connect to redis
while True: # connect to redis
try: while True:
redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '') try:
redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '') redis_slaveof_ip = os.getenv('REDIS_SLAVEOF_IP', '')
if "".__eq__(redis_slaveof_ip): redis_slaveof_port = os.getenv('REDIS_SLAVEOF_PORT', '')
r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0) if "".__eq__(redis_slaveof_ip):
else: r = redis.StrictRedis(host=os.getenv('IPV4_NETWORK', '172.22.1') + '.249', decode_responses=True, port=6379, db=0)
r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0) else:
r.ping() r = redis.StrictRedis(host=redis_slaveof_ip, decode_responses=True, port=redis_slaveof_port, db=0)
except Exception as ex: r.ping()
print('%s - trying again in 3 seconds' % (ex)) except Exception as ex:
time.sleep(3) print('%s - trying again in 3 seconds' % (ex))
else: time.sleep(3)
break else:
pubsub = r.pubsub() break
pubsub = r.pubsub()
# rename fail2ban to netfilter
if r.exists('F2B_LOG'): # rename fail2ban to netfilter
r.rename('F2B_LOG', 'NETFILTER_LOG') if r.exists('F2B_LOG'):
r.rename('F2B_LOG', 'NETFILTER_LOG')
# globals
WHITELIST = [] # globals
BLACKLIST= [] WHITELIST = []
bans = {} BLACKLIST= []
quit_now = False bans = {}
exit_code = 0 quit_now = False
lock = Lock() exit_code = 0
lock = Lock()
# init Logger
logger = Logger(r) # init Logger
# init backend logger = Logger(r)
backend = sys.argv[1] # init backend
if backend == "nftables": backend = sys.argv[1]
logger.logInfo('Using NFTables backend') if backend == "nftables":
tables = NFTables("MAILCOW", logger) logger.logInfo('Using NFTables backend')
else: tables = NFTables("MAILCOW", logger)
logger.logInfo('Using IPTables backend') else:
tables = IPTables("MAILCOW", logger) logger.logInfo('Using IPTables backend')
tables = IPTables("MAILCOW", logger)
def refreshF2boptions():
global f2boptions def refreshF2boptions():
global quit_now global f2boptions
global exit_code global quit_now
global exit_code
f2boptions = {}
f2boptions = {}
if not r.get('F2B_OPTIONS'):
f2boptions['ban_time'] = r.get('F2B_BAN_TIME') if not r.get('F2B_OPTIONS'):
f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME') f2boptions['ban_time'] = r.get('F2B_BAN_TIME')
f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT') f2boptions['max_ban_time'] = r.get('F2B_MAX_BAN_TIME')
f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS') f2boptions['ban_time_increment'] = r.get('F2B_BAN_TIME_INCREMENT')
f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW') f2boptions['max_attempts'] = r.get('F2B_MAX_ATTEMPTS')
f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4') f2boptions['retry_window'] = r.get('F2B_RETRY_WINDOW')
f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6') f2boptions['netban_ipv4'] = r.get('F2B_NETBAN_IPV4')
else: f2boptions['netban_ipv6'] = r.get('F2B_NETBAN_IPV6')
try: else:
f2boptions = json.loads(r.get('F2B_OPTIONS')) try:
except ValueError: f2boptions = json.loads(r.get('F2B_OPTIONS'))
logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json') except ValueError:
quit_now = True logger.logCrit('Error loading F2B options: F2B_OPTIONS is not json')
exit_code = 2 quit_now = True
exit_code = 2
verifyF2boptions(f2boptions)
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False)) verifyF2boptions(f2boptions)
r.set('F2B_OPTIONS', json.dumps(f2boptions, ensure_ascii=False))
def verifyF2boptions(f2boptions):
verifyF2boption(f2boptions,'ban_time', 1800) def verifyF2boptions(f2boptions):
verifyF2boption(f2boptions,'max_ban_time', 10000) verifyF2boption(f2boptions,'ban_time', 1800)
verifyF2boption(f2boptions,'ban_time_increment', True) verifyF2boption(f2boptions,'max_ban_time', 10000)
verifyF2boption(f2boptions,'max_attempts', 10) verifyF2boption(f2boptions,'ban_time_increment', True)
verifyF2boption(f2boptions,'retry_window', 600) verifyF2boption(f2boptions,'max_attempts', 10)
verifyF2boption(f2boptions,'netban_ipv4', 32) verifyF2boption(f2boptions,'retry_window', 600)
verifyF2boption(f2boptions,'netban_ipv6', 128) verifyF2boption(f2boptions,'netban_ipv4', 32)
verifyF2boption(f2boptions,'netban_ipv6', 128)
def verifyF2boption(f2boptions, f2boption, f2bdefault): verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4()))
f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault verifyF2boption(f2boptions,'manage_external', 0)
def refreshF2bregex(): def verifyF2boption(f2boptions, f2boption, f2bdefault):
global f2bregex f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault
global quit_now
global exit_code def refreshF2bregex():
if not r.get('F2B_REGEX'): global f2bregex
f2bregex = {} global quit_now
f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)' global exit_code
f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)' if not r.get('F2B_REGEX'):
f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+' f2bregex = {}
f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+' f2bregex[1] = 'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)'
f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+' f2bregex[2] = 'Rspamd UI: Invalid password by ([0-9a-f\.:]+)'
f2bregex[6] = '-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),' f2bregex[3] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+'
f2bregex[7] = '-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+' f2bregex[4] = 'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+'
f2bregex[8] = '-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+' f2bregex[5] = 'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+'
f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked' f2bregex[6] = '-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),'
f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+' f2bregex[7] = '-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False)) f2bregex[8] = '-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+'
else: f2bregex[9] = 'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked'
try: f2bregex[10] = '([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+'
f2bregex = {} r.set('F2B_REGEX', json.dumps(f2bregex, ensure_ascii=False))
f2bregex = json.loads(r.get('F2B_REGEX')) else:
except ValueError: try:
logger.logCrit('Error loading F2B options: F2B_REGEX is not json') f2bregex = {}
quit_now = True f2bregex = json.loads(r.get('F2B_REGEX'))
exit_code = 2 except ValueError:
logger.logCrit('Error loading F2B options: F2B_REGEX is not json')
def get_ip(address): quit_now = True
ip = ipaddress.ip_address(address) exit_code = 2
if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
ip = ip.ipv4_mapped def get_ip(address):
if ip.is_private or ip.is_loopback: ip = ipaddress.ip_address(address)
return False if type(ip) is ipaddress.IPv6Address and ip.ipv4_mapped:
ip = ip.ipv4_mapped
return ip if ip.is_private or ip.is_loopback:
return False
def ban(address):
global lock return ip
refreshF2boptions() def ban(address):
BAN_TIME = int(f2boptions['ban_time']) global f2boptions
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment']) global lock
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
RETRY_WINDOW = int(f2boptions['retry_window']) refreshF2boptions()
NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4']) BAN_TIME = int(f2boptions['ban_time'])
NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6']) BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
MAX_ATTEMPTS = int(f2boptions['max_attempts'])
ip = get_ip(address) RETRY_WINDOW = int(f2boptions['retry_window'])
if not ip: return NETBAN_IPV4 = '/' + str(f2boptions['netban_ipv4'])
address = str(ip) NETBAN_IPV6 = '/' + str(f2boptions['netban_ipv6'])
self_network = ipaddress.ip_network(address)
ip = get_ip(address)
with lock: if not ip: return
temp_whitelist = set(WHITELIST) address = str(ip)
if temp_whitelist: self_network = ipaddress.ip_network(address)
for wl_key in temp_whitelist:
wl_net = ipaddress.ip_network(wl_key, False) with lock:
if wl_net.overlaps(self_network): temp_whitelist = set(WHITELIST)
logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net)) if temp_whitelist:
return for wl_key in temp_whitelist:
wl_net = ipaddress.ip_network(wl_key, False)
net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False) if wl_net.overlaps(self_network):
net = str(net) logger.logInfo('Address %s is whitelisted by rule %s' % (self_network, wl_net))
return
if not net in bans:
bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0} net = ipaddress.ip_network((address + (NETBAN_IPV4 if type(ip) is ipaddress.IPv4Address else NETBAN_IPV6)), strict=False)
net = str(net)
current_attempt = time.time()
if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW: if not net in bans:
bans[net]['attempts'] = 0 bans[net] = {'attempts': 0, 'last_attempt': 0, 'ban_counter': 0}
bans[net]['attempts'] += 1 current_attempt = time.time()
bans[net]['last_attempt'] = current_attempt if current_attempt - bans[net]['last_attempt'] > RETRY_WINDOW:
bans[net]['attempts'] = 0
if bans[net]['attempts'] >= MAX_ATTEMPTS:
cur_time = int(round(time.time())) bans[net]['attempts'] += 1
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter'] bans[net]['last_attempt'] = current_attempt
logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
if type(ip) is ipaddress.IPv4Address: if bans[net]['attempts'] >= MAX_ATTEMPTS:
with lock: cur_time = int(round(time.time()))
tables.banIPv4(net) NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
else: logger.logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 ))
with lock: if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1:
tables.banIPv6(net) with lock:
tables.banIPv4(net)
r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME) elif int(f2boptions['manage_external']) != 1:
else: with lock:
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net)) tables.banIPv6(net)
def unban(net): r.hset('F2B_ACTIVE_BANS', '%s' % net, cur_time + NET_BAN_TIME)
global lock else:
logger.logWarn('%d more attempts in the next %d seconds until %s is banned' % (MAX_ATTEMPTS - bans[net]['attempts'], RETRY_WINDOW, net))
if not net in bans:
logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net) def unban(net):
r.hdel('F2B_QUEUE_UNBAN', '%s' % net) global lock
return
if not net in bans:
logger.logInfo('Unbanning %s' % net) logger.logInfo('%s is not banned, skipping unban and deleting from queue (if any)' % net)
if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network: r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
with lock: return
tables.unbanIPv4(net)
else: logger.logInfo('Unbanning %s' % net)
with lock: if type(ipaddress.ip_network(net)) is ipaddress.IPv4Network:
tables.unbanIPv6(net) with lock:
tables.unbanIPv4(net)
r.hdel('F2B_ACTIVE_BANS', '%s' % net) else:
r.hdel('F2B_QUEUE_UNBAN', '%s' % net) with lock:
if net in bans: tables.unbanIPv6(net)
bans[net]['attempts'] = 0
bans[net]['ban_counter'] += 1 r.hdel('F2B_ACTIVE_BANS', '%s' % net)
r.hdel('F2B_QUEUE_UNBAN', '%s' % net)
def permBan(net, unban=False): if net in bans:
global lock bans[net]['attempts'] = 0
bans[net]['ban_counter'] += 1
is_unbanned = False
is_banned = False def permBan(net, unban=False):
if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network: global f2boptions
with lock: global lock
if unban:
is_unbanned = tables.unbanIPv4(net) is_unbanned = False
else: is_banned = False
is_banned = tables.banIPv4(net) if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network:
else: with lock:
with lock: if unban:
if unban: is_unbanned = tables.unbanIPv4(net)
is_unbanned = tables.unbanIPv6(net) elif int(f2boptions['manage_external']) != 1:
else: is_banned = tables.banIPv4(net)
is_banned = tables.banIPv6(net) else:
with lock:
if unban:
if is_unbanned: is_unbanned = tables.unbanIPv6(net)
r.hdel('F2B_PERM_BANS', '%s' % net) elif int(f2boptions['manage_external']) != 1:
logger.logCrit('Removed host/network %s from blacklist' % net) is_banned = tables.banIPv6(net)
elif is_banned:
r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
logger.logCrit('Added host/network %s to blacklist' % net) if is_unbanned:
r.hdel('F2B_PERM_BANS', '%s' % net)
def clear(): logger.logCrit('Removed host/network %s from blacklist' % net)
global lock elif is_banned:
logger.logInfo('Clearing all bans') r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time())))
for net in bans.copy(): logger.logCrit('Added host/network %s to blacklist' % net)
unban(net)
with lock: def clear():
tables.clearIPv4Table() global lock
tables.clearIPv6Table() logger.logInfo('Clearing all bans')
r.delete('F2B_ACTIVE_BANS') for net in bans.copy():
r.delete('F2B_PERM_BANS') unban(net)
pubsub.unsubscribe() with lock:
tables.clearIPv4Table()
def watch(): tables.clearIPv6Table()
logger.logInfo('Watching Redis channel F2B_CHANNEL') r.delete('F2B_ACTIVE_BANS')
pubsub.subscribe('F2B_CHANNEL') r.delete('F2B_PERM_BANS')
pubsub.unsubscribe()
global quit_now
global exit_code def watch():
logger.logInfo('Watching Redis channel F2B_CHANNEL')
while not quit_now: pubsub.subscribe('F2B_CHANNEL')
try:
for item in pubsub.listen(): global quit_now
refreshF2bregex() global exit_code
for rule_id, rule_regex in f2bregex.items():
if item['data'] and item['type'] == 'message': while not quit_now:
try: try:
result = re.search(rule_regex, item['data']) for item in pubsub.listen():
except re.error: refreshF2bregex()
result = False for rule_id, rule_regex in f2bregex.items():
if result: if item['data'] and item['type'] == 'message':
addr = result.group(1) try:
ip = ipaddress.ip_address(addr) result = re.search(rule_regex, item['data'])
if ip.is_private or ip.is_loopback: except re.error:
continue result = False
logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data'])) if result:
ban(addr) addr = result.group(1)
except Exception as ex: ip = ipaddress.ip_address(addr)
logger.logWarn('Error reading log line from pubsub: %s' % ex) if ip.is_private or ip.is_loopback:
quit_now = True continue
exit_code = 2 logger.logWarn('%s matched rule id %s (%s)' % (addr, rule_id, item['data']))
ban(addr)
def snat4(snat_target): except Exception as ex:
global lock logger.logWarn('Error reading log line from pubsub: %s' % ex)
global quit_now quit_now = True
exit_code = 2
while not quit_now:
time.sleep(10) def snat4(snat_target):
with lock: global lock
tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24') global quit_now
def snat6(snat_target): while not quit_now:
global lock time.sleep(10)
global quit_now with lock:
tables.snat4(snat_target, os.getenv('IPV4_NETWORK', '172.22.1') + '.0/24')
while not quit_now:
time.sleep(10) def snat6(snat_target):
with lock: global lock
tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64')) global quit_now
def autopurge(): while not quit_now:
while not quit_now: time.sleep(10)
time.sleep(10) with lock:
refreshF2boptions() tables.snat6(snat_target, os.getenv('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'))
BAN_TIME = int(f2boptions['ban_time'])
MAX_BAN_TIME = int(f2boptions['max_ban_time']) def autopurge():
BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment']) while not quit_now:
MAX_ATTEMPTS = int(f2boptions['max_attempts']) time.sleep(10)
QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN') refreshF2boptions()
if QUEUE_UNBAN: BAN_TIME = int(f2boptions['ban_time'])
for net in QUEUE_UNBAN: MAX_BAN_TIME = int(f2boptions['max_ban_time'])
unban(str(net)) BAN_TIME_INCREMENT = bool(f2boptions['ban_time_increment'])
for net in bans.copy(): MAX_ATTEMPTS = int(f2boptions['max_attempts'])
if bans[net]['attempts'] >= MAX_ATTEMPTS: QUEUE_UNBAN = r.hgetall('F2B_QUEUE_UNBAN')
NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter'] if QUEUE_UNBAN:
TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt'] for net in QUEUE_UNBAN:
if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME: unban(str(net))
unban(net) for net in bans.copy():
if bans[net]['attempts'] >= MAX_ATTEMPTS:
def mailcowChainOrder(): NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter']
global lock TIME_SINCE_LAST_ATTEMPT = time.time() - bans[net]['last_attempt']
global quit_now if TIME_SINCE_LAST_ATTEMPT > NET_BAN_TIME or TIME_SINCE_LAST_ATTEMPT > MAX_BAN_TIME:
global exit_code unban(net)
while not quit_now:
time.sleep(10) def mailcowChainOrder():
with lock: global lock
quit_now, exit_code = tables.checkIPv4ChainOrder() global quit_now
if quit_now: return global exit_code
quit_now, exit_code = tables.checkIPv6ChainOrder() while not quit_now:
time.sleep(10)
def isIpNetwork(address): with lock:
try: quit_now, exit_code = tables.checkIPv4ChainOrder()
ipaddress.ip_network(address, False) if quit_now: return
except ValueError: quit_now, exit_code = tables.checkIPv6ChainOrder()
return False
return True def isIpNetwork(address):
try:
def genNetworkList(list): ipaddress.ip_network(address, False)
resolver = dns.resolver.Resolver() except ValueError:
hostnames = [] return False
networks = [] return True
for key in list:
if isIpNetwork(key): def genNetworkList(list):
networks.append(key) resolver = dns.resolver.Resolver()
else: hostnames = []
hostnames.append(key) networks = []
for hostname in hostnames: for key in list:
hostname_ips = [] if isIpNetwork(key):
for rdtype in ['A', 'AAAA']: networks.append(key)
try: else:
answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3) hostnames.append(key)
except dns.exception.Timeout: for hostname in hostnames:
logger.logInfo('Hostname %s timedout on resolve' % hostname) hostname_ips = []
break for rdtype in ['A', 'AAAA']:
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer): try:
continue answer = resolver.resolve(qname=hostname, rdtype=rdtype, lifetime=3)
except dns.exception.DNSException as dnsexception: except dns.exception.Timeout:
logger.logInfo('%s' % dnsexception) logger.logInfo('Hostname %s timedout on resolve' % hostname)
continue break
for rdata in answer: except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
hostname_ips.append(rdata.to_text()) continue
networks.extend(hostname_ips) except dns.exception.DNSException as dnsexception:
return set(networks) logger.logInfo('%s' % dnsexception)
continue
def whitelistUpdate(): for rdata in answer:
global lock hostname_ips.append(rdata.to_text())
global quit_now networks.extend(hostname_ips)
global WHITELIST return set(networks)
while not quit_now:
start_time = time.time() def whitelistUpdate():
list = r.hgetall('F2B_WHITELIST') global lock
new_whitelist = [] global quit_now
if list: global WHITELIST
new_whitelist = genNetworkList(list) while not quit_now:
with lock: start_time = time.time()
if Counter(new_whitelist) != Counter(WHITELIST): list = r.hgetall('F2B_WHITELIST')
WHITELIST = new_whitelist new_whitelist = []
logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST)) if list:
time.sleep(60.0 - ((time.time() - start_time) % 60.0)) new_whitelist = genNetworkList(list)
with lock:
def blacklistUpdate(): if Counter(new_whitelist) != Counter(WHITELIST):
global quit_now WHITELIST = new_whitelist
global BLACKLIST logger.logInfo('Whitelist was changed, it has %s entries' % len(WHITELIST))
while not quit_now: time.sleep(60.0 - ((time.time() - start_time) % 60.0))
start_time = time.time()
list = r.hgetall('F2B_BLACKLIST') def blacklistUpdate():
new_blacklist = [] global quit_now
if list: global BLACKLIST
new_blacklist = genNetworkList(list) while not quit_now:
if Counter(new_blacklist) != Counter(BLACKLIST): start_time = time.time()
addban = set(new_blacklist).difference(BLACKLIST) list = r.hgetall('F2B_BLACKLIST')
delban = set(BLACKLIST).difference(new_blacklist) new_blacklist = []
BLACKLIST = new_blacklist if list:
logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST)) new_blacklist = genNetworkList(list)
if addban: if Counter(new_blacklist) != Counter(BLACKLIST):
for net in addban: addban = set(new_blacklist).difference(BLACKLIST)
permBan(net=net) delban = set(BLACKLIST).difference(new_blacklist)
if delban: BLACKLIST = new_blacklist
for net in delban: logger.logInfo('Blacklist was changed, it has %s entries' % len(BLACKLIST))
permBan(net=net, unban=True) if addban:
time.sleep(60.0 - ((time.time() - start_time) % 60.0)) for net in addban:
permBan(net=net)
def quit(signum, frame): if delban:
global quit_now for net in delban:
quit_now = True permBan(net=net, unban=True)
time.sleep(60.0 - ((time.time() - start_time) % 60.0))
if __name__ == '__main__': def quit(signum, frame):
# In case a previous session was killed without cleanup global quit_now
clear() quit_now = True
# Reinit MAILCOW chain
# Is called before threads start, no locking
logger.logInfo("Initializing mailcow netfilter chain") if __name__ == '__main__':
tables.initChainIPv4() refreshF2boptions()
tables.initChainIPv6() # In case a previous session was killed without cleanup
clear()
watch_thread = Thread(target=watch) # Reinit MAILCOW chain
watch_thread.daemon = True # Is called before threads start, no locking
watch_thread.start() logger.logInfo("Initializing mailcow netfilter chain")
tables.initChainIPv4()
if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n': tables.initChainIPv6()
try:
snat_ip = os.getenv('SNAT_TO_SOURCE') watch_thread = Thread(target=watch)
snat_ipo = ipaddress.ip_address(snat_ip) watch_thread.daemon = True
if type(snat_ipo) is ipaddress.IPv4Address: watch_thread.start()
snat4_thread = Thread(target=snat4,args=(snat_ip,))
snat4_thread.daemon = True if os.getenv('SNAT_TO_SOURCE') and os.getenv('SNAT_TO_SOURCE') != 'n':
snat4_thread.start() try:
except ValueError: snat_ip = os.getenv('SNAT_TO_SOURCE')
print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address') snat_ipo = ipaddress.ip_address(snat_ip)
if type(snat_ipo) is ipaddress.IPv4Address:
if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n': snat4_thread = Thread(target=snat4,args=(snat_ip,))
try: snat4_thread.daemon = True
snat_ip = os.getenv('SNAT6_TO_SOURCE') snat4_thread.start()
snat_ipo = ipaddress.ip_address(snat_ip) except ValueError:
if type(snat_ipo) is ipaddress.IPv6Address: print(os.getenv('SNAT_TO_SOURCE') + ' is not a valid IPv4 address')
snat6_thread = Thread(target=snat6,args=(snat_ip,))
snat6_thread.daemon = True if os.getenv('SNAT6_TO_SOURCE') and os.getenv('SNAT6_TO_SOURCE') != 'n':
snat6_thread.start() try:
except ValueError: snat_ip = os.getenv('SNAT6_TO_SOURCE')
print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address') snat_ipo = ipaddress.ip_address(snat_ip)
if type(snat_ipo) is ipaddress.IPv6Address:
autopurge_thread = Thread(target=autopurge) snat6_thread = Thread(target=snat6,args=(snat_ip,))
autopurge_thread.daemon = True snat6_thread.daemon = True
autopurge_thread.start() snat6_thread.start()
except ValueError:
mailcowchainwatch_thread = Thread(target=mailcowChainOrder) print(os.getenv('SNAT6_TO_SOURCE') + ' is not a valid IPv6 address')
mailcowchainwatch_thread.daemon = True
mailcowchainwatch_thread.start() autopurge_thread = Thread(target=autopurge)
autopurge_thread.daemon = True
blacklistupdate_thread = Thread(target=blacklistUpdate) autopurge_thread.start()
blacklistupdate_thread.daemon = True
blacklistupdate_thread.start() mailcowchainwatch_thread = Thread(target=mailcowChainOrder)
mailcowchainwatch_thread.daemon = True
whitelistupdate_thread = Thread(target=whitelistUpdate) mailcowchainwatch_thread.start()
whitelistupdate_thread.daemon = True
whitelistupdate_thread.start() blacklistupdate_thread = Thread(target=blacklistUpdate)
blacklistupdate_thread.daemon = True
signal.signal(signal.SIGTERM, quit) blacklistupdate_thread.start()
atexit.register(clear)
whitelistupdate_thread = Thread(target=whitelistUpdate)
while not quit_now: whitelistupdate_thread.daemon = True
time.sleep(0.5) whitelistupdate_thread.start()
sys.exit(exit_code) signal.signal(signal.SIGTERM, quit)
atexit.register(clear)
while not quit_now:
time.sleep(0.5)
sys.exit(exit_code)

View File

@ -85,6 +85,8 @@ $cors_settings = cors('get');
$cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']); $cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']);
$cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']); $cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']);
$f2b_data = fail2ban('get');
$template = 'admin.twig'; $template = 'admin.twig';
$template_data = [ $template_data = [
'tfa_data' => $tfa_data, 'tfa_data' => $tfa_data,
@ -101,7 +103,8 @@ $template_data = [
'domains' => $domains, 'domains' => $domains,
'all_domains' => $all_domains, 'all_domains' => $all_domains,
'mailboxes' => $mailboxes, 'mailboxes' => $mailboxes,
'f2b_data' => fail2ban('get'), 'f2b_data' => $f2b_data,
'f2b_banlist_url' => getBaseUrl() . "/api/v1/get/fail2ban/banlist/" . $f2b_data['banlist_id'],
'q_data' => quarantine('settings'), 'q_data' => quarantine('settings'),
'qn_data' => quota_notification('get'), 'qn_data' => quota_notification('get'),
'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'), 'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'),
@ -113,6 +116,7 @@ $template_data = [
'password_complexity' => password_complexity('get'), 'password_complexity' => password_complexity('get'),
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'], 'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
'cors_settings' => $cors_settings, 'cors_settings' => $cors_settings,
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
'lang_admin' => json_encode($lang['admin']), 'lang_admin' => json_encode($lang['admin']),
'lang_datatables' => json_encode($lang['datatables']) 'lang_datatables' => json_encode($lang['datatables'])
]; ];

View File

@ -1,5 +1,5 @@
<?php <?php
function fail2ban($_action, $_data = null) { function fail2ban($_action, $_data = null, $_extra = null) {
global $redis; global $redis;
$_data_log = $_data; $_data_log = $_data;
switch ($_action) { switch ($_action) {
@ -247,6 +247,7 @@ function fail2ban($_action, $_data = null) {
$netban_ipv6 = intval((isset($_data['netban_ipv6'])) ? $_data['netban_ipv6'] : $is_now['netban_ipv6']); $netban_ipv6 = intval((isset($_data['netban_ipv6'])) ? $_data['netban_ipv6'] : $is_now['netban_ipv6']);
$wl = (isset($_data['whitelist'])) ? $_data['whitelist'] : $is_now['whitelist']; $wl = (isset($_data['whitelist'])) ? $_data['whitelist'] : $is_now['whitelist'];
$bl = (isset($_data['blacklist'])) ? $_data['blacklist'] : $is_now['blacklist']; $bl = (isset($_data['blacklist'])) ? $_data['blacklist'] : $is_now['blacklist'];
$manage_external = (isset($_data['manage_external'])) ? intval($_data['manage_external']) : 0;
} }
else { else {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
@ -266,6 +267,8 @@ function fail2ban($_action, $_data = null) {
$f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6; $f2b_options['netban_ipv6'] = ($netban_ipv6 > 128) ? 128 : $netban_ipv6;
$f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts; $f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts;
$f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window; $f2b_options['retry_window'] = ($retry_window < 1) ? 1 : $retry_window;
$f2b_options['banlist_id'] = $is_now['banlist_id'];
$f2b_options['manage_external'] = ($manage_external > 0) ? 1 : 0;
try { try {
$redis->Set('F2B_OPTIONS', json_encode($f2b_options)); $redis->Set('F2B_OPTIONS', json_encode($f2b_options));
$redis->Del('F2B_WHITELIST'); $redis->Del('F2B_WHITELIST');
@ -329,5 +332,71 @@ function fail2ban($_action, $_data = null) {
'msg' => 'f2b_modified' 'msg' => 'f2b_modified'
); );
break; break;
case 'banlist':
try {
$f2b_options = json_decode($redis->Get('F2B_OPTIONS'), true);
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
'msg' => array('redis_error', $e)
);
http_response_code(500);
return false;
}
if (is_array($_extra)) {
$_extra = $_extra[0];
}
if ($_extra != $f2b_options['banlist_id']){
http_response_code(404);
return false;
}
switch ($_data) {
case 'get':
try {
$bl = $redis->hKeys('F2B_BLACKLIST');
$active_bans = $redis->hKeys('F2B_ACTIVE_BANS');
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
'msg' => array('redis_error', $e)
);
http_response_code(500);
return false;
}
$banlist = implode("\n", array_merge($bl, $active_bans));
return $banlist;
break;
case 'refresh':
if ($_SESSION['mailcow_cc_role'] != "admin") {
return false;
}
$f2b_options['banlist_id'] = uuid4();
try {
$redis->Set('F2B_OPTIONS', json_encode($f2b_options));
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
'msg' => array('redis_error', $e)
);
return false;
}
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_data_log, $_extra),
'msg' => 'f2b_banlist_refreshed'
);
return true;
break;
}
break;
} }
} }

View File

@ -2246,6 +2246,21 @@ function cors($action, $data = null) {
break; break;
} }
} }
function getBaseURL() {
$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'];
$base_url = $protocol . '://' . $host;
return $base_url;
}
function uuid4() {
$data = openssl_random_pseudo_bytes(16);
$data[6] = chr(ord($data[6]) & 0x0f | 0x40);
$data[8] = chr(ord($data[8]) & 0x3f | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
function get_logs($application, $lines = false) { function get_logs($application, $lines = false) {
if ($lines === false) { if ($lines === false) {

View File

@ -70,6 +70,8 @@ try {
} }
} }
catch (Exception $e) { catch (Exception $e) {
// Stop when redis is not available
http_response_code(500);
?> ?>
<center style='font-family:sans-serif;'>Connection to Redis failed.<br /><br />The following error was reported:<br/><?=$e->getMessage();?></center> <center style='font-family:sans-serif;'>Connection to Redis failed.<br /><br />The following error was reported:<br/><?=$e->getMessage();?></center>
<?php <?php
@ -98,6 +100,7 @@ try {
} }
catch (PDOException $e) { catch (PDOException $e) {
// Stop when SQL connection fails // Stop when SQL connection fails
http_response_code(500);
?> ?>
<center style='font-family:sans-serif;'>Connection to database failed.<br /><br />The following error was reported:<br/> <?=$e->getMessage();?></center> <center style='font-family:sans-serif;'>Connection to database failed.<br /><br />The following error was reported:<br/> <?=$e->getMessage();?></center>
<?php <?php
@ -105,6 +108,7 @@ exit;
} }
// Stop when dockerapi is not available // Stop when dockerapi is not available
if (fsockopen("tcp://dockerapi", 443, $errno, $errstr) === false) { if (fsockopen("tcp://dockerapi", 443, $errno, $errstr) === false) {
http_response_code(500);
?> ?>
<center style='font-family:sans-serif;'>Connection to dockerapi container failed.<br /><br />The following error was reported:<br/><?=$errno;?> - <?=$errstr;?></center> <center style='font-family:sans-serif;'>Connection to dockerapi container failed.<br /><br />The following error was reported:<br/><?=$errno;?> - <?=$errstr;?></center>
<?php <?php

View File

@ -391,3 +391,11 @@ function addTag(tagAddElem, tag = null){
$(tagValuesElem).val(JSON.stringify(value_tags)); $(tagValuesElem).val(JSON.stringify(value_tags));
$(tagInputElem).val(''); $(tagInputElem).val('');
} }
function copyToClipboard(id) {
var copyText = document.getElementById(id);
copyText.select();
copyText.setSelectionRange(0, 99999);
// only works with https connections
navigator.clipboard.writeText(copyText.value);
mailcow_alert_box(lang.copy_to_clipboard, "success");
}

View File

@ -503,6 +503,16 @@ if (isset($_GET['query'])) {
print(json_encode($getArgs)); print(json_encode($getArgs));
$_SESSION['challenge'] = $WebAuthn->getChallenge(); $_SESSION['challenge'] = $WebAuthn->getChallenge();
return; return;
break;
case "fail2ban":
if (!isset($_SESSION['mailcow_cc_role'])){
switch ($object) {
case 'banlist':
header('Content-Type: text/plain');
echo fail2ban('banlist', 'get', $extra);
break;
}
}
break; break;
} }
if (isset($_SESSION['mailcow_cc_role'])) { if (isset($_SESSION['mailcow_cc_role'])) {
@ -1324,6 +1334,10 @@ if (isset($_GET['query'])) {
break; break;
case "fail2ban": case "fail2ban":
switch ($object) { switch ($object) {
case 'banlist':
header('Content-Type: text/plain');
echo fail2ban('banlist', 'get', $extra);
break;
default: default:
$data = fail2ban('get'); $data = fail2ban('get');
process_get_return($data); process_get_return($data);
@ -1943,7 +1957,14 @@ if (isset($_GET['query'])) {
process_edit_return(fwdhost('edit', array_merge(array('fwdhost' => $items), $attr))); process_edit_return(fwdhost('edit', array_merge(array('fwdhost' => $items), $attr)));
break; break;
case "fail2ban": case "fail2ban":
process_edit_return(fail2ban('edit', array_merge(array('network' => $items), $attr))); switch ($object) {
case 'banlist':
process_edit_return(fail2ban('banlist', 'refresh', $items));
break;
default:
process_edit_return(fail2ban('edit', array_merge(array('network' => $items), $attr)));
break;
}
break; break;
case "ui_texts": case "ui_texts":
process_edit_return(customize('edit', 'ui_texts', $attr)); process_edit_return(customize('edit', 'ui_texts', $attr));

View File

@ -148,6 +148,7 @@
"change_logo": "Logo ändern", "change_logo": "Logo ändern",
"configuration": "Konfiguration", "configuration": "Konfiguration",
"convert_html_to_text": "Konvertiere HTML zu reinem Text", "convert_html_to_text": "Konvertiere HTML zu reinem Text",
"copy_to_clipboard": "Text wurde in die Zwischenablage kopiert!",
"cors_settings": "CORS Einstellungen", "cors_settings": "CORS Einstellungen",
"credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.", "credentials_transport_warning": "<b>Warnung</b>: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.",
"customer_id": "Kunde", "customer_id": "Kunde",
@ -181,6 +182,8 @@
"f2b_blacklist": "Blacklist für Netzwerke und Hosts", "f2b_blacklist": "Blacklist für Netzwerke und Hosts",
"f2b_filter": "Regex-Filter", "f2b_filter": "Regex-Filter",
"f2b_list_info": "Ein Host oder Netzwerk auf der Blacklist wird immer eine Whitelist-Einheit überwiegen. <b>Die Aktualisierung der Liste dauert einige Sekunden.</b>", "f2b_list_info": "Ein Host oder Netzwerk auf der Blacklist wird immer eine Whitelist-Einheit überwiegen. <b>Die Aktualisierung der Liste dauert einige Sekunden.</b>",
"f2b_manage_external": "Fail2Ban extern verwalten",
"f2b_manage_external_info": "Fail2ban wird die Banlist weiterhin pflegen, jedoch werden keine aktiven Regeln zum blockieren gesetzt. Die unten generierte Banlist, kann verwendet werden, um den Datenverkehr extern zu blockieren.",
"f2b_max_attempts": "Max. Versuche", "f2b_max_attempts": "Max. Versuche",
"f2b_max_ban_time": "Maximale Bannzeit in Sekunden", "f2b_max_ban_time": "Maximale Bannzeit in Sekunden",
"f2b_netban_ipv4": "Netzbereich für IPv4-Banns (8-32)", "f2b_netban_ipv4": "Netzbereich für IPv4-Banns (8-32)",
@ -1035,6 +1038,7 @@
"domain_removed": "Domain %s wurde entfernt", "domain_removed": "Domain %s wurde entfernt",
"dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet", "dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet",
"eas_reset": "ActiveSync Gerät des Benutzers %s wurde zurückgesetzt", "eas_reset": "ActiveSync Gerät des Benutzers %s wurde zurückgesetzt",
"f2b_banlist_refreshed": "Banlist ID wurde erfolgreich erneuert.",
"f2b_modified": "Änderungen an Fail2ban-Parametern wurden gespeichert", "f2b_modified": "Änderungen an Fail2ban-Parametern wurden gespeichert",
"forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt", "forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt",
"forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt", "forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt",

View File

@ -154,6 +154,7 @@
"logo_dark_label": "Inverted for dark mode", "logo_dark_label": "Inverted for dark mode",
"configuration": "Configuration", "configuration": "Configuration",
"convert_html_to_text": "Convert HTML to plain text", "convert_html_to_text": "Convert HTML to plain text",
"copy_to_clipboard": "Text copied to clipboard!",
"cors_settings": "CORS Settings", "cors_settings": "CORS Settings",
"credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.", "credentials_transport_warning": "<b>Warning</b>: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.",
"customer_id": "Customer ID", "customer_id": "Customer ID",
@ -187,6 +188,8 @@
"f2b_blacklist": "Blacklisted networks/hosts", "f2b_blacklist": "Blacklisted networks/hosts",
"f2b_filter": "Regex filters", "f2b_filter": "Regex filters",
"f2b_list_info": "A blacklisted host or network will always outweigh a whitelist entity. <b>List updates will take a few seconds to be applied.</b>", "f2b_list_info": "A blacklisted host or network will always outweigh a whitelist entity. <b>List updates will take a few seconds to be applied.</b>",
"f2b_manage_external": "Manage Fail2Ban externally",
"f2b_manage_external_info": "Fail2ban will still maintain the banlist, but it will not actively set rules to block traffic. Use the generated banlist below to externally block the traffic.",
"f2b_max_attempts": "Max. attempts", "f2b_max_attempts": "Max. attempts",
"f2b_max_ban_time": "Max. ban time (s)", "f2b_max_ban_time": "Max. ban time (s)",
"f2b_netban_ipv4": "IPv4 subnet size to apply ban on (8-32)", "f2b_netban_ipv4": "IPv4 subnet size to apply ban on (8-32)",
@ -1046,6 +1049,7 @@
"domain_removed": "Domain %s has been removed", "domain_removed": "Domain %s has been removed",
"dovecot_restart_success": "Dovecot was restarted successfully", "dovecot_restart_success": "Dovecot was restarted successfully",
"eas_reset": "ActiveSync devices for user %s were reset", "eas_reset": "ActiveSync devices for user %s were reset",
"f2b_banlist_refreshed": "Banlist ID has been successfully refreshed.",
"f2b_modified": "Changes to Fail2ban parameters have been saved", "f2b_modified": "Changes to Fail2ban parameters have been saved",
"forwarding_host_added": "Forwarding host %s has been added", "forwarding_host_added": "Forwarding host %s has been added",
"forwarding_host_removed": "Forwarding host %s has been removed", "forwarding_host_removed": "Forwarding host %s has been removed",

View File

@ -42,6 +42,13 @@
<input type="number" class="form-control" id="f2b_netban_ipv6" name="netban_ipv6" value="{{ f2b_data.netban_ipv6 }}" required> <input type="number" class="form-control" id="f2b_netban_ipv6" name="netban_ipv6" value="{{ f2b_data.netban_ipv6 }}" required>
</div> </div>
</div> </div>
<div class="mb-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="f2b_manage_external" value="1" name="manage_external" {% if f2b_data.manage_external == 1 %}checked{% endif %}>
<label class="form-check-label" for="f2b_manage_external">{{ lang.admin.f2b_manage_external }}</label>
</div>
<p class="text-muted">{{ lang.admin.f2b_manage_external_info }}</p>
</div>
<hr> <hr>
<p class="text-muted">{{ lang.admin.f2b_list_info|raw }}</p> <p class="text-muted">{{ lang.admin.f2b_list_info|raw }}</p>
<div class="mb-2"> <div class="mb-2">
@ -90,6 +97,15 @@
{% if not f2b_data.active_bans and not f2b_data.perm_bans %} {% if not f2b_data.active_bans and not f2b_data.perm_bans %}
<i>{{ lang.admin.no_active_bans }}</i> <i>{{ lang.admin.no_active_bans }}</i>
{% endif %} {% endif %}
<form class="form-inline" data-id="f2b_banlist" role="form" method="post">
<div class="input-group mb-3">
<input type="text" class="form-control" aria-label="Banlist url" value="{{ f2b_banlist_url}}" id="banlist_url">
{% if is_https %}
<button class="btn btn-secondary" type="button" onclick="copyToClipboard('banlist_url')"><i class="bi bi-clipboard"></i></button>
{% endif %}
<button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
</div>
</form>
{% for active_ban in f2b_data.active_bans %} {% for active_ban in f2b_data.active_bans %}
<p> <p>
<span class="badge fs-7 bg-info d-block d-sm-inline-block"> <span class="badge fs-7 bg-info d-block d-sm-inline-block">