diff --git a/data/Dockerfiles/netfilter/server.py b/data/Dockerfiles/netfilter/server.py index 698137bf..982fa97c 100644 --- a/data/Dockerfiles/netfilter/server.py +++ b/data/Dockerfiles/netfilter/server.py @@ -16,6 +16,7 @@ import json import iptc import dns.resolver import dns.exception +import uuid while True: try: @@ -94,6 +95,8 @@ def verifyF2boptions(f2boptions): verifyF2boption(f2boptions,'retry_window', 600) verifyF2boption(f2boptions,'netban_ipv4', 32) verifyF2boption(f2boptions,'netban_ipv6', 128) + verifyF2boption(f2boptions,'banlist_id', str(uuid.uuid4())) + verifyF2boption(f2boptions,'manage_external', 0) def verifyF2boption(f2boptions, f2boption, f2bdefault): f2boptions[f2boption] = f2boptions[f2boption] if f2boption in f2boptions and f2boptions[f2boption] is not None else f2bdefault @@ -156,6 +159,7 @@ def mailcowChainOrder(): exit_code = 2 def ban(address): + global f2boptions global lock refreshF2boptions() BAN_TIME = int(f2boptions['ban_time']) @@ -197,7 +201,7 @@ def ban(address): cur_time = int(round(time.time())) NET_BAN_TIME = BAN_TIME if not BAN_TIME_INCREMENT else BAN_TIME * 2 ** bans[net]['ban_counter'] logCrit('Banning %s for %d minutes' % (net, NET_BAN_TIME / 60 )) - if type(ip) is ipaddress.IPv4Address: + if type(ip) is ipaddress.IPv4Address and int(f2boptions['manage_external']) != 1: with lock: chain = iptc.Chain(iptc.Table(iptc.Table.FILTER), 'MAILCOW') rule = iptc.Rule() @@ -206,7 +210,7 @@ def ban(address): rule.target = target if rule not in chain.rules: chain.insert_rule(rule) - else: + elif int(f2boptions['manage_external']) != 1: with lock: chain = iptc.Chain(iptc.Table6(iptc.Table6.FILTER), 'MAILCOW') rule = iptc.Rule6() @@ -251,6 +255,7 @@ def unban(net): bans[net]['ban_counter'] += 1 def permBan(net, unban=False): + global f2boptions global lock if type(ipaddress.ip_network(net, strict=False)) is ipaddress.IPv4Network: with lock: @@ -259,7 +264,7 @@ def permBan(net, unban=False): rule.src = net target = iptc.Target(rule, "REJECT") rule.target = target - if rule not in chain.rules and not unban: + if rule not in chain.rules and not unban and int(f2boptions['manage_external']) != 1: logCrit('Add host/network %s to blacklist' % net) chain.insert_rule(rule) r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time()))) @@ -274,7 +279,7 @@ def permBan(net, unban=False): rule.src = net target = iptc.Target(rule, "REJECT") rule.target = target - if rule not in chain.rules and not unban: + if rule not in chain.rules and not unban and int(f2boptions['manage_external']) != 1: logCrit('Add host/network %s to blacklist' % net) chain.insert_rule(rule) r.hset('F2B_PERM_BANS', '%s' % net, int(round(time.time()))) @@ -553,7 +558,7 @@ def initChain(): chain.insert_rule(rule) if __name__ == '__main__': - + refreshF2boptions() # In case a previous session was killed without cleanup clear() # Reinit MAILCOW chain diff --git a/data/web/admin.php b/data/web/admin.php index ebddb7b9..d0fcbc99 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -85,6 +85,8 @@ $cors_settings = cors('get'); $cors_settings['allowed_origins'] = str_replace(", ", "\n", $cors_settings['allowed_origins']); $cors_settings['allowed_methods'] = explode(", ", $cors_settings['allowed_methods']); +$f2b_data = fail2ban('get'); + $template = 'admin.twig'; $template_data = [ 'tfa_data' => $tfa_data, @@ -101,7 +103,8 @@ $template_data = [ 'domains' => $domains, 'all_domains' => $all_domains, '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'), 'qn_data' => quota_notification('get'), 'rsettings_map' => file_get_contents('http://nginx:8081/settings.php'), @@ -113,6 +116,7 @@ $template_data = [ 'password_complexity' => password_complexity('get'), 'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'], 'cors_settings' => $cors_settings, + 'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on', 'lang_admin' => json_encode($lang['admin']), 'lang_datatables' => json_encode($lang['datatables']) ]; diff --git a/data/web/inc/functions.fail2ban.inc.php b/data/web/inc/functions.fail2ban.inc.php index 2c4aa41d..5962237f 100644 --- a/data/web/inc/functions.fail2ban.inc.php +++ b/data/web/inc/functions.fail2ban.inc.php @@ -1,5 +1,5 @@ 128) ? 128 : $netban_ipv6; $f2b_options['max_attempts'] = ($max_attempts < 1) ? 1 : $max_attempts; $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 { $redis->Set('F2B_OPTIONS', json_encode($f2b_options)); $redis->Del('F2B_WHITELIST'); @@ -329,5 +332,71 @@ function fail2ban($_action, $_data = null) { 'msg' => 'f2b_modified' ); 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; } } diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 6418945c..3cff09b9 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -2246,6 +2246,21 @@ function cors($action, $data = null) { 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) { if ($lines === false) { diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php index b3b1cc13..9c5203e7 100644 --- a/data/web/inc/prerequisites.inc.php +++ b/data/web/inc/prerequisites.inc.php @@ -70,6 +70,8 @@ try { } } catch (Exception $e) { +// Stop when redis is not available +http_response_code(500); ?>
{{ lang.admin.f2b_manage_external_info }}
+{{ lang.admin.f2b_list_info|raw }}
diff --git a/docker-compose.yml b/docker-compose.yml index ac45857f..bd89ce8c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -427,7 +427,7 @@ services: - acme netfilter-mailcow: - image: mailcow/netfilter:1.52 + image: mailcow/netfilter:1.53 stop_grace_period: 30s depends_on: - dovecot-mailcow