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); ?>
Connection to Redis failed.

The following error was reported:
getMessage();?>
Connection to database failed.

The following error was reported:
getMessage();?>
Connection to dockerapi container failed.

The following error was reported:
-
getChallenge(); 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; } if (isset($_SESSION['mailcow_cc_role'])) { @@ -1324,6 +1334,10 @@ if (isset($_GET['query'])) { break; case "fail2ban": switch ($object) { + case 'banlist': + header('Content-Type: text/plain'); + echo fail2ban('banlist', 'get', $extra); + break; default: $data = fail2ban('get'); process_get_return($data); @@ -1933,7 +1947,14 @@ if (isset($_GET['query'])) { process_edit_return(fwdhost('edit', array_merge(array('fwdhost' => $items), $attr))); break; 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; case "ui_texts": process_edit_return(customize('edit', 'ui_texts', $attr)); diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index d073c45c..a8232ef2 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -147,6 +147,7 @@ "change_logo": "Logo ändern", "configuration": "Konfiguration", "convert_html_to_text": "Konvertiere HTML zu reinem Text", + "copy_to_clipboard": "Text wurde in die Zwischenablage kopiert!", "cors_settings": "CORS Einstellungen", "credentials_transport_warning": "Warnung: Das Hinzufügen einer neuen Regel bewirkt die Aktualisierung der Authentifizierungsdaten aller vorhandenen Einträge mit identischem Next Hop.", "customer_id": "Kunde", @@ -180,6 +181,8 @@ "f2b_blacklist": "Blacklist für Netzwerke und Hosts", "f2b_filter": "Regex-Filter", "f2b_list_info": "Ein Host oder Netzwerk auf der Blacklist wird immer eine Whitelist-Einheit überwiegen. Die Aktualisierung der Liste dauert einige Sekunden.", + "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_ban_time": "Maximale Bannzeit in Sekunden", "f2b_netban_ipv4": "Netzbereich für IPv4-Banns (8-32)", @@ -1026,6 +1029,7 @@ "domain_removed": "Domain %s wurde entfernt", "dovecot_restart_success": "Dovecot wurde erfolgreich neu gestartet", "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", "forwarding_host_added": "Weiterleitungs-Host %s wurde hinzugefügt", "forwarding_host_removed": "Weiterleitungs-Host %s wurde entfernt", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 22475bb2..845713c2 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -153,6 +153,7 @@ "logo_dark_label": "Inverted for dark mode", "configuration": "Configuration", "convert_html_to_text": "Convert HTML to plain text", + "copy_to_clipboard": "Text copied to clipboard!", "cors_settings": "CORS Settings", "credentials_transport_warning": "Warning: Adding a new transport map entry will update the credentials for all entries with a matching next hop column.", "customer_id": "Customer ID", @@ -186,6 +187,8 @@ "f2b_blacklist": "Blacklisted networks/hosts", "f2b_filter": "Regex filters", "f2b_list_info": "A blacklisted host or network will always outweigh a whitelist entity. List updates will take a few seconds to be applied.", + "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_ban_time": "Max. ban time (s)", "f2b_netban_ipv4": "IPv4 subnet size to apply ban on (8-32)", @@ -1042,6 +1045,7 @@ "domain_removed": "Domain %s has been removed", "dovecot_restart_success": "Dovecot was restarted successfully", "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", "forwarding_host_added": "Forwarding host %s has been added", "forwarding_host_removed": "Forwarding host %s has been removed", diff --git a/data/web/templates/admin/tab-config-f2b.twig b/data/web/templates/admin/tab-config-f2b.twig index a353f7fa..bb4a2e85 100644 --- a/data/web/templates/admin/tab-config-f2b.twig +++ b/data/web/templates/admin/tab-config-f2b.twig @@ -42,6 +42,13 @@ +
+
+ + +
+

{{ lang.admin.f2b_manage_external_info }}

+

{{ lang.admin.f2b_list_info|raw }}

@@ -90,6 +97,15 @@ {% if not f2b_data.active_bans and not f2b_data.perm_bans %} {{ lang.admin.no_active_bans }} {% endif %} +
+
+ + {% if is_https %} + + {% endif %} + +
+
{% for active_ban in f2b_data.active_bans %}

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