From 9040d456edf04c1e97cf4a89887416e1978ce478 Mon Sep 17 00:00:00 2001 From: andryyy Date: Thu, 29 Jun 2017 10:25:32 +0200 Subject: [PATCH 01/14] [acme-mailcow] Auto-detect container ids for restart; Restart containers after restore --- data/Dockerfiles/acme/Dockerfile | 1 + data/Dockerfiles/acme/docker-entrypoint.sh | 30 +++++++++++++--------- docker-compose.yml | 3 +-- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/data/Dockerfiles/acme/Dockerfile b/data/Dockerfiles/acme/Dockerfile index efcfcb97..b3fd77fd 100644 --- a/data/Dockerfiles/acme/Dockerfile +++ b/data/Dockerfiles/acme/Dockerfile @@ -8,6 +8,7 @@ RUN apk add --update --no-cache \ curl \ openssl \ bind-tools \ + jq \ mariadb-client COPY docker-entrypoint.sh /srv/docker-entrypoint.sh diff --git a/data/Dockerfiles/acme/docker-entrypoint.sh b/data/Dockerfiles/acme/docker-entrypoint.sh index bbfe0cba..7b02de80 100755 --- a/data/Dockerfiles/acme/docker-entrypoint.sh +++ b/data/Dockerfiles/acme/docker-entrypoint.sh @@ -2,6 +2,7 @@ ACME_BASE=/var/lib/acme SSL_EXAMPLE=/var/lib/ssl-example + mkdir -p ${ACME_BASE}/acme/private restart_containers(){ @@ -45,14 +46,14 @@ else echo "Restoring previous acme certificate and restarting script..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem - exec $(readlink -f "$0") + exec env TRIGGER_RESTART=1 $(readlink -f "$0") fi ISSUER="mailcow" else echo "Restoring mailcow snake-oil certificates and restarting script..." cp ${SSL_EXAMPLE}/cert.pem ${ACME_BASE}/cert.pem cp ${SSL_EXAMPLE}/key.pem ${ACME_BASE}/key.pem - exec $(readlink -f "$0") + exec env TRIGGER_RESTART=1 $(readlink -f "$0") fi fi @@ -66,6 +67,11 @@ while true; do declare -a ADDITIONAL_VALIDATED_SAN IFS=',' read -r -a ADDITIONAL_SAN_ARR <<< "${ADDITIONAL_SAN}" IPV4=$(curl -4s https://mailcow.email/ip.php) + # Container ids may have changed + CONTAINERS_RESTART=($(curl --silent --unix-socket /var/run/docker.sock http/containers/json | \ + jq -rc 'map(select(.Names[] | contains ("nginx-mailcow") or contains ("postfix-mailcow") or contains ("dovecot-mailcow"))) | .[] .Id' | \ + tr "\n" " ")) + while read line; do SQL_DOMAIN_ARR+=("${line}") @@ -127,7 +133,7 @@ while true; do fi done - ALL_VALIDATED=($(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} ${VALIDATED_MAILCOW_HOSTNAME})) + ALL_VALIDATED="$(echo ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} ${VALIDATED_MAILCOW_HOSTNAME})" if [[ -z ${ALL_VALIDATED[*]} ]]; then echo "Cannot validate hostnames, skipping Let's Encrypt..." echo 0 @@ -136,7 +142,7 @@ while true; do ORPHANED_SAN=($(echo ${SAN_ARRAY_NOW[*]} ${VALIDATED_CONFIG_DOMAINS[*]} ${ADDITIONAL_VALIDATED_SAN[*]} ${MAILCOW_HOSTNAME} | tr ' ' '\n' | sort | uniq -u )) if [[ ! -z ${ORPHANED_SAN[*]} ]] && [[ ${ISSUER} != *"mailcow"* ]]; then DATE=$(date +%Y-%m-%d_%H_%M_%S) - echo "Found orphaned SAN(s) ${ORPHANED_SAN[*]} in certificate, moving old files to ${ACME_BASE}/acme/private/${DATE}.bak/" + echo "Found orphaned SAN ${ORPHANED_SAN[*]} in certificate, moving old files to ${ACME_BASE}/acme/private/${DATE}.bak/, keeping key file..." mkdir -p ${ACME_BASE}/acme/private/${DATE}.bak/ [[ -f ${ACME_BASE}/acme/private/account.key ]] && mv ${ACME_BASE}/acme/private/account.key ${ACME_BASE}/acme/private/${DATE}.bak/ mv ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/acme/private/${DATE}.bak/ @@ -159,11 +165,11 @@ while true; do # restart docker containers if ! verify_hash_match ${ACME_BASE}/cert.pem ${ACME_BASE}/key.pem; then - echo "Certificate was successfully request, but key and certificate have non-matching hashes, restoring mailcow snake-oil and restarting containers..." + echo "Certificate was successfully requested, but key and certificate have non-matching hashes, restoring mailcow snake-oil and restarting containers..." cp ${SSL_EXAMPLE}/cert.pem ${ACME_BASE}/cert.pem cp ${SSL_EXAMPLE}/key.pem ${ACME_BASE}/key.pem fi - restart_containers ${CONTAINERS_RESTART} + restart_containers ${CONTAINERS_RESTART[*]} ;; 1) # failure if [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ]]; then @@ -171,7 +177,7 @@ while true; do cp ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ${ACME_BASE}/key.pem TRIGGER_RESTART=1 - elif [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ]]; then + elif [[ -f ${ACME_BASE}/acme/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/privkey.pem ]]; then echo "Error requesting certificate, restoring from previous acme request and restarting containers..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem @@ -183,20 +189,20 @@ while true; do cp ${SSL_EXAMPLE}/key.pem ${ACME_BASE}/key.pem TRIGGER_RESTART=1 fi - [[ ${TRIGGER_RESTART} == 1 ]] && restart_containers ${CONTAINERS_RESTART} + [[ ${TRIGGER_RESTART} == 1 ]] && restart_containers ${CONTAINERS_RESTART[*]} exit 1;; 2) # no change if ! diff ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem; then echo "Certificate was not changed, but active certificate does not match the verified certificate, fixing and restarting containers..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem - restart_containers ${CONTAINERS_RESTART} + restart_containers ${CONTAINERS_RESTART[*]} fi if ! verify_hash_match ${ACME_BASE}/cert.pem ${ACME_BASE}/key.pem; then echo "Certificate was not changed, but hashes do not match, restoring from previous acme request and restarting containers..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem - restart_containers ${CONTAINERS_RESTART} + restart_containers ${CONTAINERS_RESTART[*]} fi ;; *) # unspecified @@ -205,7 +211,7 @@ while true; do cp ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ${ACME_BASE}/key.pem TRIGGER_RESTART=1 - elif [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/${DATE}.bak/privkey.pem ]]; then + elif [[ -f ${ACME_BASE}/acme/fullchain.pem ]] && [[ -f ${ACME_BASE}/acme/private/privkey.pem ]]; then echo "Error requesting certificate, restoring from previous acme request and restarting containers..." cp ${ACME_BASE}/acme/fullchain.pem ${ACME_BASE}/cert.pem cp ${ACME_BASE}/acme/private/privkey.pem ${ACME_BASE}/key.pem @@ -217,7 +223,7 @@ while true; do cp ${SSL_EXAMPLE}/key.pem ${ACME_BASE}/key.pem TRIGGER_RESTART=1 fi - [[ ${TRIGGER_RESTART} == 1 ]] && restart_containers ${CONTAINERS_RESTART} + [[ ${TRIGGER_RESTART} == 1 ]] && restart_containers ${CONTAINERS_RESTART[*]} exit 1;; esac diff --git a/docker-compose.yml b/docker-compose.yml index 9157b950..c0388967 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -293,13 +293,12 @@ services: acme-mailcow: depends_on: - nginx-mailcow - image: mailcow/acme:1.7 + image: mailcow/acme:1.8 build: ./data/Dockerfiles/acme dns: - 172.22.1.254 dns_search: mailcow-network environment: - - CONTAINERS_RESTART=mailcowdockerized_postfix-mailcow_1 mailcowdockerized_dovecot-mailcow_1 mailcowdockerized_nginx-mailcow_1 - ADDITIONAL_SAN=${ADDITIONAL_SAN} - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - DBNAME=${DBNAME} From 6c64ffbd49cd99f0b4649703aa68b6e4e4b390b2 Mon Sep 17 00:00:00 2001 From: andryyy Date: Thu, 29 Jun 2017 10:29:56 +0200 Subject: [PATCH 02/14] [acme-mailcow] Auto-detect container ids for restart; Restart containers after restore --- data/web/admin.php | 2 +- data/web/inc/functions.fail2ban.inc.php | 93 +++++++++++++++++++++++++ data/web/inc/functions.inc.php | 90 ------------------------ data/web/inc/prerequisites.inc.php | 1 + data/web/json_api.php | 2 +- data/web/lang/lang.de.php | 2 + data/web/lang/lang.en.php | 2 + 7 files changed, 100 insertions(+), 92 deletions(-) create mode 100644 data/web/inc/functions.fail2ban.inc.php diff --git a/data/web/admin.php b/data/web/admin.php index 787129f0..8c01b340 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -274,7 +274,7 @@ $tfa_data = get_tfa();
Fail2Ban parameters
diff --git a/data/web/inc/functions.fail2ban.inc.php b/data/web/inc/functions.fail2ban.inc.php new file mode 100644 index 00000000..c9644d5f --- /dev/null +++ b/data/web/inc/functions.fail2ban.inc.php @@ -0,0 +1,93 @@ +Get('F2B_BAN_TIME'); + $data['max_attempts'] = $redis->Get('F2B_MAX_ATTEMPTS'); + $data['retry_window'] = $redis->Get('F2B_RETRY_WINDOW'); + $wl = $redis->hGetAll('F2B_WHITELIST'); + if (is_array($wl)) { + foreach ($wl as $key => $value) { + $tmp_data[] = $key; + } + $data['whitelist'] = implode(PHP_EOL, $tmp_data); + } + else { + $data['whitelist'] = ""; + } + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + return $data; + break; + case 'edit': + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + $is_now = fail2ban('get'); + if (!empty($is_now)) { + $ban_time = intval((isset($_data['ban_time'])) ? $_data['ban_time'] : $is_now['ban_time']); + $max_attempts = intval((isset($_data['max_attempts'])) ? $_data['max_attempts'] : $is_now['active_int']); + $retry_window = intval((isset($_data['retry_window'])) ? $_data['retry_window'] : $is_now['retry_window']); + } + else { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + $wl = $_data['whitelist']; + $ban_time = ($ban_time < 60) ? 60 : $ban_time; + $max_attempts = ($max_attempts < 1) ? 1 : $max_attempts; + $retry_window = ($retry_window < 1) ? 1 : $retry_window; + try { + $redis->Set('F2B_BAN_TIME', $ban_time); + $redis->Set('F2B_MAX_ATTEMPTS', $max_attempts); + $redis->Set('F2B_RETRY_WINDOW', $retry_window); + $redis->Del('F2B_WHITELIST'); + if(!empty($wl)) { + $wl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $wl)); + if (is_array($wl_array)) { + foreach ($wl_array as $wl_item) { + $cidr = explode('/', $wl_item); + if (filter_var($cidr[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && (!isset($cidr[1]) || ($cidr[1] >= 0 && $cidr[1] <= 32))) { + $redis->hSet('F2B_WHITELIST', $wl_item, 1); + } + elseif (filter_var($cidr[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && (!isset($cidr[1]) || ($cidr[1] >= 0 && $cidr[1] <= 128))) { + $redis->hSet('F2B_WHITELIST', $wl_item, 1); + } + } + } + } + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => sprintf($lang['success']['f2b_modified']) + ); + break; + } +} \ No newline at end of file diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 68a09531..948214e9 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -1435,94 +1435,4 @@ function get_logs($container, $lines = 100) { } return false; } -function get_f2b_parameters() { - global $lang; - global $redis; - $data = array(); - if ($_SESSION['mailcow_cc_role'] != "admin") { - return false; - } - try { - $data['ban_time'] = $redis->Get('F2B_BAN_TIME'); - $data['max_attempts'] = $redis->Get('F2B_MAX_ATTEMPTS'); - $data['retry_window'] = $redis->Get('F2B_RETRY_WINDOW'); - $wl = $redis->hGetAll('F2B_WHITELIST'); - if (is_array($wl)) { - foreach ($wl as $key => $value) { - $tmp_data[] = $key; - } - $data['whitelist'] = implode(PHP_EOL, $tmp_data); - } - else { - $data['whitelist'] = ""; - } - } - catch (RedisException $e) { - $_SESSION['return'] = array( - 'type' => 'danger', - 'msg' => 'Redis: '.$e - ); - return false; - } - return $data; -} -function edit_f2b_parameters($postarray) { - global $lang; - global $redis; - if ($_SESSION['mailcow_cc_role'] != "admin") { - $_SESSION['return'] = array( - 'type' => 'danger', - 'msg' => sprintf($lang['danger']['access_denied']) - ); - return false; - } - $is_now = get_f2b_parameters(); - if (!empty($is_now)) { - $ban_time = intval((isset($postarray['ban_time'])) ? $postarray['ban_time'] : $is_now['ban_time']); - $max_attempts = intval((isset($postarray['max_attempts'])) ? $postarray['max_attempts'] : $is_now['active_int']); - $retry_window = intval((isset($postarray['retry_window'])) ? $postarray['retry_window'] : $is_now['retry_window']); - } - else { - $_SESSION['return'] = array( - 'type' => 'danger', - 'msg' => sprintf($lang['danger']['access_denied']) - ); - return false; - } - $wl = $postarray['whitelist']; - $ban_time = ($ban_time < 60) ? 60 : $ban_time; - $max_attempts = ($max_attempts < 1) ? 1 : $max_attempts; - $retry_window = ($retry_window < 1) ? 1 : $retry_window; - try { - $redis->Set('F2B_BAN_TIME', $ban_time); - $redis->Set('F2B_MAX_ATTEMPTS', $max_attempts); - $redis->Set('F2B_RETRY_WINDOW', $retry_window); - $redis->Del('F2B_WHITELIST'); - if(!empty($wl)) { - $wl_array = array_map('trim', preg_split( "/( |,|;|\n)/", $wl)); - if (is_array($wl_array)) { - foreach ($wl_array as $wl_item) { - $cidr = explode('/', $wl_item); - if (filter_var($cidr[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && (!isset($cidr[1]) || ($cidr[1] >= 0 && $cidr[1] <= 32))) { - $redis->hSet('F2B_WHITELIST', $wl_item, 1); - } - elseif (filter_var($cidr[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) && (!isset($cidr[1]) || ($cidr[1] >= 0 && $cidr[1] <= 128))) { - $redis->hSet('F2B_WHITELIST', $wl_item, 1); - } - } - } - } - } - catch (RedisException $e) { - $_SESSION['return'] = array( - 'type' => 'danger', - 'msg' => 'Redis: '.$e - ); - return false; - } - $_SESSION['return'] = array( - 'type' => 'success', - 'msg' => 'Saved changes to Fail2ban configuration' - ); -} ?> diff --git a/data/web/inc/prerequisites.inc.php b/data/web/inc/prerequisites.inc.php index a1dd4d58..b8abb804 100644 --- a/data/web/inc/prerequisites.inc.php +++ b/data/web/inc/prerequisites.inc.php @@ -64,6 +64,7 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.mailbox.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.policy.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.dkim.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fwdhost.inc.php'; +require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/functions.fail2ban.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/init_db.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/triggers.inc.php'; init_db_schema(); diff --git a/data/web/json_api.php b/data/web/json_api.php index d891f88d..0ed10556 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -1925,7 +1925,7 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u // No items if (isset($_POST['attr'])) { $attr = (array)json_decode($_POST['attr'], true); - if (edit_f2b_parameters($attr) === false) { + if (fail2ban('edit', $attr) === false) { if (isset($_SESSION['return'])) { echo json_encode($_SESSION['return']); } diff --git a/data/web/lang/lang.de.php b/data/web/lang/lang.de.php index a9009fcc..ea8fe9da 100644 --- a/data/web/lang/lang.de.php +++ b/data/web/lang/lang.de.php @@ -49,6 +49,7 @@ $lang['success']['aliasd_modified'] = 'Änderungen an Alias-Domain %s wurden ges $lang['success']['mailbox_modified'] = 'Änderungen an Mailbox %s wurden gespeichert'; $lang['success']['resource_modified'] = "Änderungen an Ressource %s wurden gespeichert"; $lang['success']['object_modified'] = "Änderungen an Objekt %s wurden gespeichert"; +$lang['success']['f2b_modified'] = "Änderungen an Fail2ban Parametern wurden gespeichert"; $lang['success']['msg_size_saved'] = 'Limit wurde gesetzt'; $lang['danger']['aliasd_not_found'] = 'Alias-Domain nicht gefunden'; $lang['danger']['targetd_not_found'] = 'Ziel-Domain nicht gefunden'; @@ -417,6 +418,7 @@ $lang['tfa']['enter_qr_code'] = "Falls Sie den angezeigten QR-Code nicht scannen $lang['tfa']['confirm_totp_token'] = "Bitte bestätigen Sie die Änderung durch Eingabe eines generierten Tokens"; $lang['admin']['search_domain_da'] = 'Domains durchsuchen'; +$lang['admin']['f2b_parameters'] = 'Fail2ban Parameter'; $lang['admin']['restrictions'] = 'Postfix Restriktionen'; $lang['admin']['rr'] = 'Postfix Empfänger Restriktionen'; $lang['admin']['sr'] = 'Postfix Sender Restriktionen'; diff --git a/data/web/lang/lang.en.php b/data/web/lang/lang.en.php index c49a535f..d6d4256a 100644 --- a/data/web/lang/lang.en.php +++ b/data/web/lang/lang.en.php @@ -51,6 +51,7 @@ $lang['success']['aliasd_modified'] = "Changes to alias domain have been saved"; $lang['success']['mailbox_modified'] = "Changes to mailbox %s have been saved"; $lang['success']['resource_modified'] = "Changes to mailbox %s have been saved"; $lang['success']['object_modified'] = "Changes to object %s have been saved"; +$lang['success']['f2b_modified'] = "Changes to Fail2ban parameters have been saved"; $lang['success']['msg_size_saved'] = "Message size limit has been set"; $lang['danger']['aliasd_not_found'] = "Alias domain not found"; $lang['danger']['targetd_not_found'] = "Target domain not found"; @@ -421,6 +422,7 @@ $lang['tfa']['scan_qr_code'] = "Please scan the following code with your authent $lang['tfa']['enter_qr_code'] = "Your TOTP code if your device cannot scan QR codes"; $lang['tfa']['confirm_totp_token'] = "Please confirm your changes by entering the generated token"; +$lang['admin']['f2b_parameters'] = 'Fail2ban parameters'; $lang['admin']['search_domain_da'] = 'Search domains'; $lang['admin']['restrictions'] = 'Postfix Restrictions'; $lang['admin']['rr'] = 'Postfix Recipient Restrictions'; From 372da9b5579638f16239d7ba3e661e6a79c086a3 Mon Sep 17 00:00:00 2001 From: andryyy Date: Thu, 29 Jun 2017 10:30:25 +0200 Subject: [PATCH 03/14] [Web] Move Fail2ban function, add lang strings --- data/web/admin.php | 10 +++++----- data/web/lang/lang.de.php | 4 ++++ data/web/lang/lang.en.php | 4 ++++ 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/data/web/admin.php b/data/web/admin.php index 8c01b340..eabaaa84 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -271,26 +271,26 @@ $tfa_data = get_tfa();
-
Fail2Ban parameters
+
- +
- +
- +
- +
diff --git a/data/web/lang/lang.de.php b/data/web/lang/lang.de.php index ea8fe9da..513f0622 100644 --- a/data/web/lang/lang.de.php +++ b/data/web/lang/lang.de.php @@ -419,6 +419,10 @@ $lang['tfa']['confirm_totp_token'] = "Bitte bestätigen Sie die Änderung durch $lang['admin']['search_domain_da'] = 'Domains durchsuchen'; $lang['admin']['f2b_parameters'] = 'Fail2ban Parameter'; +$lang['admin']['f2b_ban_time'] = 'Banzeit (s)'; +$lang['admin']['f2b_max_attempts'] = 'Max. Versuche'; +$lang['admin']['f2b_retry_window'] = 'Wiederholungen im Zeitraum von (s)'; +$lang['admin']['f2b_whitelist'] = 'Whitelist für Netzwerke und Hosts'; $lang['admin']['restrictions'] = 'Postfix Restriktionen'; $lang['admin']['rr'] = 'Postfix Empfänger Restriktionen'; $lang['admin']['sr'] = 'Postfix Sender Restriktionen'; diff --git a/data/web/lang/lang.en.php b/data/web/lang/lang.en.php index d6d4256a..b56b6c7d 100644 --- a/data/web/lang/lang.en.php +++ b/data/web/lang/lang.en.php @@ -423,6 +423,10 @@ $lang['tfa']['enter_qr_code'] = "Your TOTP code if your device cannot scan QR co $lang['tfa']['confirm_totp_token'] = "Please confirm your changes by entering the generated token"; $lang['admin']['f2b_parameters'] = 'Fail2ban parameters'; +$lang['admin']['f2b_ban_time'] = 'Ban time (s)'; +$lang['admin']['f2b_max_attempts'] = 'Max. attempts'; +$lang['admin']['f2b_retry_window'] = 'Retry window (s) for max. attempts'; +$lang['admin']['f2b_whitelist'] = 'Whitelisted networks/hosts'; $lang['admin']['search_domain_da'] = 'Search domains'; $lang['admin']['restrictions'] = 'Postfix Restrictions'; $lang['admin']['rr'] = 'Postfix Recipient Restrictions'; From a6b60aebb8aee486503d4633b4d4b8b421c17097 Mon Sep 17 00:00:00 2001 From: andryyy Date: Thu, 29 Jun 2017 11:30:14 +0200 Subject: [PATCH 04/14] [Fai2ban] Added auto-detection for container names; Allow multiple rules for each container; log rule id and container on match --- data/Dockerfiles/fail2ban/logwatch.py | 45 ++++++++++++++++++++------- docker-compose.yml | 2 +- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/data/Dockerfiles/fail2ban/logwatch.py b/data/Dockerfiles/fail2ban/logwatch.py index b7430988..5dec15e2 100644 --- a/data/Dockerfiles/fail2ban/logwatch.py +++ b/data/Dockerfiles/fail2ban/logwatch.py @@ -19,12 +19,30 @@ if re.search(yes_regex, os.getenv('SKIP_FAIL2BAN', 0)): raise SystemExit r = redis.StrictRedis(host='172.22.1.249', decode_responses=True, port=6379, db=0) -RULES = { - 'mailcowdockerized_postfix-mailcow_1': 'warning: .*\[([0-9a-f\.:]+)\]: SASL .* authentication failed', - 'mailcowdockerized_dovecot-mailcow_1': '-login: Disconnected \(auth failed, .*\): user=.*, method=.*, rip=([0-9a-f\.:]+),', - 'mailcowdockerized_sogo-mailcow_1': 'SOGo.* Login from \'([0-9a-f\.:]+)\' for user .* might not have worked', - 'mailcowdockerized_php-fpm-mailcow_1': 'Mailcow UI: Invalid password for .* by ([0-9a-f\.:]+)', -} +client = docker.from_env() + +for container in client.containers.list(): + if "postfix-mailcow" in container.name: + postfix_container = container.name + elif "dovecot-mailcow" in container.name: + dovecot_container = container.name + elif "sogo-mailcow" in container.name: + sogo_container = container.name + elif "php-fpm-mailcow" in container.name: + php_fpm_container = container.name + +RULES = {} + +RULES[postfix_container] = {} +RULES[dovecot_container] = {} +RULES[sogo_container] = {} +RULES[php_fpm_container] = {} + +RULES[postfix_container][1] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .* authentication failed' +RULES[dovecot_container][1] = '-login: Disconnected \(auth failed, .*\): user=.*, method=.*, rip=([0-9a-f\.:]+),' +RULES[sogo_container][1] = 'SOGo.* Login from \'([0-9a-f\.:]+)\' for user .* might not have worked' +RULES[php_fpm_container][1] = 'Mailcow UI: Invalid password for .* by ([0-9a-f\.:]+)' + r.setnx("F2B_BAN_TIME", "1800") r.setnx("F2B_MAX_ATTEMPTS", "10") @@ -135,12 +153,17 @@ def watch(container): log['message'] = "Watching %s" % container r.lpush("F2B_LOG", json.dumps(log, ensure_ascii=False)) print "Watching", container - client = docker.from_env() for msg in client.containers.get(container).attach(stream=True, logs=False): - result = re.search(RULES[container], msg) - if result: - addr = result.group(1) - ban(addr) + for rule_id, rule_regex in RULES[container].iteritems(): + result = re.search(rule_regex, msg) + if result: + addr = result.group(1) + print "%s matched rule id %d in %s" % (addr, rule_id, container) + log['time'] = int(round(time.time())) + log['priority'] = "warn" + log['message'] = "%s matched rule id %d in %s" % (addr, rule_id, container) + r.lpush("F2B_LOG", json.dumps(log, ensure_ascii=False)) + ban(addr) def autopurge(): while not quit_now: diff --git a/docker-compose.yml b/docker-compose.yml index c0388967..4ee191e7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -318,7 +318,7 @@ services: - acme fail2ban-mailcow: - image: mailcow/fail2ban:1.3 + image: mailcow/fail2ban:1.5 build: ./data/Dockerfiles/fail2ban depends_on: - dovecot-mailcow From 5ab11c0c1eaa68a6be97f4578750e73980e95bb5 Mon Sep 17 00:00:00 2001 From: andryyy Date: Fri, 30 Jun 2017 20:29:55 +0200 Subject: [PATCH 05/14] [ACME] Show err instead of empty var when IP lookup failed --- data/Dockerfiles/acme/docker-entrypoint.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/data/Dockerfiles/acme/docker-entrypoint.sh b/data/Dockerfiles/acme/docker-entrypoint.sh index 7b02de80..d83e27f1 100755 --- a/data/Dockerfiles/acme/docker-entrypoint.sh +++ b/data/Dockerfiles/acme/docker-entrypoint.sh @@ -7,6 +7,7 @@ mkdir -p ${ACME_BASE}/acme/private restart_containers(){ for container in $*; do + echo "Restarting ${container}..." curl -X POST \ --unix-socket /var/run/docker.sock \ "http/containers/${container}/restart" @@ -68,10 +69,7 @@ while true; do IFS=',' read -r -a ADDITIONAL_SAN_ARR <<< "${ADDITIONAL_SAN}" IPV4=$(curl -4s https://mailcow.email/ip.php) # Container ids may have changed - CONTAINERS_RESTART=($(curl --silent --unix-socket /var/run/docker.sock http/containers/json | \ - jq -rc 'map(select(.Names[] | contains ("nginx-mailcow") or contains ("postfix-mailcow") or contains ("dovecot-mailcow"))) | .[] .Id' | \ - tr "\n" " ")) - + CONTAINERS_RESTART=($(curl --silent --unix-socket /var/run/docker.sock http/containers/json | jq -rc 'map(select(.Names[] | contains ("nginx-mailcow") or contains ("postfix-mailcow") or contains ("dovecot-mailcow"))) | .[] .Id' | tr "\n" " ")) while read line; do SQL_DOMAIN_ARR+=("${line}") @@ -81,7 +79,7 @@ while true; do A_CONFIG=$(dig A autoconfig.${SQL_DOMAIN} +short | tail -n 1) if [[ ! -z ${A_CONFIG} ]]; then echo "Found A record for autoconfig.${SQL_DOMAIN}: ${A_CONFIG}" - if [[ ${IPV4} == ${A_CONFIG} ]]; then + if [[ ${IPV4:-ERR} == ${A_CONFIG} ]]; then echo "Confirmed A record autoconfig.${SQL_DOMAIN}" VALIDATED_CONFIG_DOMAINS+=("autoconfig.${SQL_DOMAIN}") else @@ -94,7 +92,7 @@ while true; do A_DISCOVER=$(dig A autodiscover.${SQL_DOMAIN} +short | tail -n 1) if [[ ! -z ${A_DISCOVER} ]]; then echo "Found A record for autodiscover.${SQL_DOMAIN}: ${A_DISCOVER}" - if [[ ${IPV4} == ${A_DISCOVER} ]]; then + if [[ ${IPV4:-ERR} == ${A_DISCOVER} ]]; then echo "Confirmed A record autodiscover.${SQL_DOMAIN}" VALIDATED_CONFIG_DOMAINS+=("autodiscover.${SQL_DOMAIN}") else @@ -108,7 +106,7 @@ while true; do A_MAILCOW_HOSTNAME=$(dig A ${MAILCOW_HOSTNAME} +short | tail -n 1) if [[ ! -z ${A_MAILCOW_HOSTNAME} ]]; then echo "Found A record for ${MAILCOW_HOSTNAME}: ${A_MAILCOW_HOSTNAME}" - if [[ ${IPV4} == ${A_MAILCOW_HOSTNAME} ]]; then + if [[ ${IPV4:-ERR} == ${A_MAILCOW_HOSTNAME} ]]; then echo "Confirmed A record ${MAILCOW_HOSTNAME}" VALIDATED_MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} else @@ -122,7 +120,7 @@ while true; do A_SAN=$(dig A ${SAN} +short | tail -n 1) if [[ ! -z ${A_SAN} ]]; then echo "Found A record for ${SAN}: ${A_SAN}" - if [[ ${IPV4} == ${A_SAN} ]]; then + if [[ ${IPV4:-ERR} == ${A_SAN} ]]; then echo "Confirmed A record ${SAN}" ADDITIONAL_VALIDATED_SAN+=("${SAN}") else From ae5ce6568d3df037d9cc30b7e0cb99595fea3bca Mon Sep 17 00:00:00 2001 From: andryyy Date: Fri, 30 Jun 2017 20:30:30 +0200 Subject: [PATCH 06/14] [Dovecot] Dovecot 2.2.31, Pigeonhole 0.4.19 --- data/Dockerfiles/dovecot/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/Dockerfiles/dovecot/Dockerfile b/data/Dockerfiles/dovecot/Dockerfile index 54ae995e..fc84459c 100644 --- a/data/Dockerfiles/dovecot/Dockerfile +++ b/data/Dockerfiles/dovecot/Dockerfile @@ -3,8 +3,8 @@ LABEL maintainer "Andre Peters " ARG DEBIAN_FRONTEND=noninteractive ENV LC_ALL C -ENV DOVECOT_VERSION 2.2.30.2 -ENV PIGEONHOLE_VERSION 0.4.18 +ENV DOVECOT_VERSION 2.2.31 +ENV PIGEONHOLE_VERSION 0.4.19 RUN apt-get update && apt-get -y install \ automake \ From 49e72b6c72adc86fdd33460c88c54824298bd21c Mon Sep 17 00:00:00 2001 From: andryyy Date: Fri, 30 Jun 2017 20:31:04 +0200 Subject: [PATCH 07/14] Reduce Unbound health check, push Dovecot and ACME image versions --- docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4ee191e7..e8e57f2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: condition: service_healthy healthcheck: test: ["CMD", "nslookup", "mailcow.email", "127.0.0.1"] - interval: 3s - timeout: 3s + interval: 60s + timeout: 5s retries: 5 volumes: - ./data/conf/unbound/unbound.conf:/etc/unbound/unbound.conf:ro @@ -171,7 +171,7 @@ services: - sogo dovecot-mailcow: - image: mailcow/dovecot:1.0 + image: mailcow/dovecot:1.1 build: ./data/Dockerfiles/dovecot depends_on: unbound-mailcow: @@ -293,7 +293,7 @@ services: acme-mailcow: depends_on: - nginx-mailcow - image: mailcow/acme:1.8 + image: mailcow/acme:1.9 build: ./data/Dockerfiles/acme dns: - 172.22.1.254 From 744784cb62d0c3bcf0341db9ef70f779dded35ed Mon Sep 17 00:00:00 2001 From: andryyy Date: Fri, 30 Jun 2017 21:50:21 +0200 Subject: [PATCH 08/14] [UI] Initial: Import private keys for DKIM/ARC --- data/web/admin.php | 21 +++++++ data/web/inc/functions.dkim.inc.php | 91 ++++++++++++++++++++++++++++- data/web/json_api.php | 33 +++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/data/web/admin.php b/data/web/admin.php index eabaaa84..fe99739e 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -230,6 +230,27 @@ $tfa_data = get_tfa();
+ + ↳ Import private key +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
diff --git a/data/web/inc/functions.dkim.inc.php b/data/web/inc/functions.dkim.inc.php index 16fb4da1..7871a0d8 100644 --- a/data/web/inc/functions.dkim.inc.php +++ b/data/web/inc/functions.dkim.inc.php @@ -88,6 +88,84 @@ function dkim($_action, $_data = null) { return false; } break; + case 'import': + if ($_SESSION['mailcow_cc_role'] != "admin") { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['access_denied']) + ); + return false; + } + $private_key_input = trim($_data['private_key_file']); + $private_key_normalized = preg_replace('~\r\n?~', "\n", $private_key_input); + $private_key = openssl_pkey_get_private($private_key_normalized); + if ($ssl_error = openssl_error_string()) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Private key error: ' . $ssl_error + ); + return false; + } + // Explode by nl + $pem_public_key_array = explode(PHP_EOL, trim(openssl_pkey_get_details($private_key)['key'])); + // Remove first and last line/item + array_shift($pem_public_key_array); + array_pop($pem_public_key_array); + // Implode as single string + $pem_public_key = implode('', $pem_public_key_array); + $dkim_selector = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : 'dkim'; + $domain = $_data['domain']; + if (!is_valid_domain_name($domain)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['dkim_domain_or_sel_invalid']) + ); + return false; + } + if ($redis->hGet('DKIM_PUB_KEYS', $domain)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['dkim_domain_or_sel_invalid']) + ); + return false; + } + if (!ctype_alnum($dkim_selector)) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => sprintf($lang['danger']['dkim_domain_or_sel_invalid']) + ); + return false; + } + try { + $redis->hSet('DKIM_PUB_KEYS', $domain, $pem_public_key); + $redis->hSet('DKIM_SELECTORS', $domain, $dkim_selector); + $redis->hSet('DKIM_PRIV_KEYS', $dkim_selector . '.' . $domain, $private_key_normalized); + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + unset($private_key_normalized); + unset($private_key); + unset($private_key_input); + try { + } + catch (RedisException $e) { + $_SESSION['return'] = array( + 'type' => 'danger', + 'msg' => 'Redis: '.$e + ); + return false; + } + $_SESSION['return'] = array( + 'type' => 'success', + 'msg' => sprintf($lang['success']['dkim_added']) + ); + return true; + break; case 'details': if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) { return false; @@ -95,7 +173,18 @@ function dkim($_action, $_data = null) { $dkimdata = array(); if ($redis_dkim_key_data = $redis->hGet('DKIM_PUB_KEYS', $_data)) { $dkimdata['pubkey'] = $redis_dkim_key_data; - $dkimdata['length'] = (strlen($dkimdata['pubkey']) < 391) ? 1024 : 2048; + if (strlen($dkimdata['pubkey']) < 391) { + $dkimdata['length'] = "1024"; + } + elseif (strlen($dkimdata['pubkey']) < 736) { + $dkimdata['length'] = "2048"; + } + elseif (strlen($dkimdata['pubkey']) < 1416) { + $dkimdata['length'] = "4096"; + } + else { + $dkimdata['length'] = ">= 8192"; + } $dkimdata['dkim_txt'] = 'v=DKIM1;k=rsa;t=s;s=email;p=' . $redis_dkim_key_data; $dkimdata['dkim_selector'] = $redis->hGet('DKIM_SELECTORS', $_data); } diff --git a/data/web/json_api.php b/data/web/json_api.php index 0ed10556..e4086520 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -390,6 +390,39 @@ if (isset($_SESSION['mailcow_cc_role']) || isset($_SESSION['pending_mailcow_cc_u )); } break; + case "dkim_import": + if (isset($_POST['attr'])) { + $attr = (array)json_decode($_POST['attr'], true); + if (dkim('import', $attr) === false) { + if (isset($_SESSION['return'])) { + echo json_encode($_SESSION['return']); + } + else { + echo json_encode(array( + 'type' => 'error', + 'msg' => 'Cannot add item' + )); + } + } + else { + if (isset($_SESSION['return'])) { + echo json_encode($_SESSION['return']); + } + else { + echo json_encode(array( + 'type' => 'success', + 'msg' => 'Task completed' + )); + } + } + } + else { + echo json_encode(array( + 'type' => 'error', + 'msg' => 'Cannot find attributes in post data' + )); + } + break; case "domain-admin": if (isset($_POST['attr'])) { $attr = (array)json_decode($_POST['attr'], true); From 60ce2367386809d6ba5d5432b7a6750985f7d239 Mon Sep 17 00:00:00 2001 From: andryyy Date: Sat, 1 Jul 2017 23:12:14 +0200 Subject: [PATCH 09/14] [Web] Language strings for DKIM import tool --- data/web/admin.php | 10 +++++----- data/web/lang/lang.de.php | 4 +++- data/web/lang/lang.en.php | 3 +++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/data/web/admin.php b/data/web/admin.php index fe99739e..f402fd61 100644 --- a/data/web/admin.php +++ b/data/web/admin.php @@ -231,24 +231,24 @@ $tfa_data = get_tfa(); - ↳ Import private key +
- +
- +
- +
- +
diff --git a/data/web/lang/lang.de.php b/data/web/lang/lang.de.php index 513f0622..eef13b3f 100644 --- a/data/web/lang/lang.de.php +++ b/data/web/lang/lang.de.php @@ -417,7 +417,9 @@ $lang['tfa']['scan_qr_code'] = "Bitte scannen Sie jetzt den angezeigten QR-Code: $lang['tfa']['enter_qr_code'] = "Falls Sie den angezeigten QR-Code nicht scannen können, verwenden Sie bitte nachstehenden Sicherheitsschlüssel"; $lang['tfa']['confirm_totp_token'] = "Bitte bestätigen Sie die Änderung durch Eingabe eines generierten Tokens"; -$lang['admin']['search_domain_da'] = 'Domains durchsuchen'; +$lang['admin']['private_key'] = 'Private Key'; +$lang['admin']['import'] = 'Importieren'; +$lang['admin']['import_private_key'] = 'Private Key importieren'; $lang['admin']['f2b_parameters'] = 'Fail2ban Parameter'; $lang['admin']['f2b_ban_time'] = 'Banzeit (s)'; $lang['admin']['f2b_max_attempts'] = 'Max. Versuche'; diff --git a/data/web/lang/lang.en.php b/data/web/lang/lang.en.php index b56b6c7d..3be72fec 100644 --- a/data/web/lang/lang.en.php +++ b/data/web/lang/lang.en.php @@ -422,6 +422,9 @@ $lang['tfa']['scan_qr_code'] = "Please scan the following code with your authent $lang['tfa']['enter_qr_code'] = "Your TOTP code if your device cannot scan QR codes"; $lang['tfa']['confirm_totp_token'] = "Please confirm your changes by entering the generated token"; +$lang['admin']['private_key'] = 'Private key'; +$lang['admin']['import'] = 'Import'; +$lang['admin']['import_private_key'] = 'Import private key'; $lang['admin']['f2b_parameters'] = 'Fail2ban parameters'; $lang['admin']['f2b_ban_time'] = 'Ban time (s)'; $lang['admin']['f2b_max_attempts'] = 'Max. attempts'; From 685313095293daa58ecd34bb6f85717210fec8a3 Mon Sep 17 00:00:00 2001 From: andryyy Date: Sat, 1 Jul 2017 23:13:41 +0200 Subject: [PATCH 10/14] [Compose] Less hammering on mailcow.email/ip.php, ability to disable IPv4 check in mailcow.conf, don't complain about missing ADDITIONAL_SAN --- docker-compose.yml | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index e8e57f2c..3c10656e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,9 @@ services: condition: service_healthy healthcheck: test: ["CMD", "nslookup", "mailcow.email", "127.0.0.1"] - interval: 60s - timeout: 5s - retries: 5 + interval: 120s + timeout: 7s + retries: 10 volumes: - ./data/conf/unbound/unbound.conf:/etc/unbound/unbound.conf:ro restart: always @@ -27,9 +27,9 @@ services: command: mysqld --max_allowed_packet=128M healthcheck: test: ["CMD", "mysqladmin", "ping", "--host", "localhost", "--silent"] - interval: 10s - timeout: 30s - retries: 5 + interval: 60s + timeout: 7s + retries: 10 volumes: - mysql-vol-1:/var/lib/mysql/ - ./data/conf/mysql/:/etc/mysql/conf.d/:ro @@ -299,12 +299,13 @@ services: - 172.22.1.254 dns_search: mailcow-network environment: - - ADDITIONAL_SAN=${ADDITIONAL_SAN} + - ADDITIONAL_SAN=${ADDITIONAL_SAN:-} - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - DBNAME=${DBNAME} - DBUSER=${DBUSER} - DBPASS=${DBPASS} - SKIP_LETS_ENCRYPT=${SKIP_LETS_ENCRYPT:-n} + - SKIP_IP_CHECK=${SKIP_IP_CHECK:-n} volumes: - ./data/web/.well-known/acme-challenge:/var/www/acme:rw - ./data/assets/ssl:/var/lib/acme/:rw From 4b7ac93c75969ba03500e97072a2739a42898c9d Mon Sep 17 00:00:00 2001 From: andryyy Date: Sat, 1 Jul 2017 23:14:13 +0200 Subject: [PATCH 11/14] Add SKIP_IP_CHECK for ACME to mailcow.conf --- generate_config.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/generate_config.sh b/generate_config.sh index 4cd17074..433b9001 100755 --- a/generate_config.sh +++ b/generate_config.sh @@ -81,6 +81,9 @@ ADDITIONAL_SAN= # To never run acme-mailcow for Let's Encrypt, set this to y SKIP_LETS_ENCRYPT=n +# Skip IPv4 check in ACME container +SKIP_IP_CHECK=n + # To never run fail2ban-mailcow SKIP_FAIL2BAN=n From afc8c93c07ecfa60723b7ad0918fe8707919f9cd Mon Sep 17 00:00:00 2001 From: andryyy Date: Sat, 1 Jul 2017 23:14:27 +0200 Subject: [PATCH 12/14] [Rspamd] Cleanup settings map --- data/conf/rspamd/dynmaps/settings.php | 269 +++++++++----------------- 1 file changed, 93 insertions(+), 176 deletions(-) diff --git a/data/conf/rspamd/dynmaps/settings.php b/data/conf/rspamd/dynmaps/settings.php index 3976954c..c05eeb0e 100644 --- a/data/conf/rspamd/dynmaps/settings.php +++ b/data/conf/rspamd/dynmaps/settings.php @@ -4,15 +4,10 @@ The match section performs AND operation on different matches: for example, if y then the rule matches only when from AND rcpt match. For similar matches, the OR rule applies: if you have multiple rcpt matches, then any of these will trigger the rule. If a rule is triggered then no more rules are matched. */ -function parse_email($email) { - if(!filter_var($email, FILTER_VALIDATE_EMAIL)) return false; - $a = strrpos($email, '@'); - return array('local' => substr($email, 0, $a), 'domain' => substr($email, $a)); -} header('Content-Type: text/plain'); require_once "vars.inc.php"; -ini_set('error_reporting', 0); +ini_set('error_reporting', 1); $dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name; $opt = [ @@ -29,6 +24,77 @@ catch (PDOException $e) { exit; } +function parse_email($email) { + if(!filter_var($email, FILTER_VALIDATE_EMAIL)) return false; + $a = strrpos($email, '@'); + return array('local' => substr($email, 0, $a), 'domain' => substr($email, $a)); +} + +function ucl_rcpts($object, $type) { + global $pdo; + if ($type == 'mailbox') { + // Standard aliases + $stmt = $pdo->prepare("SELECT `address` FROM `alias` + WHERE `goto` LIKE :object_goto + AND `address` NOT LIKE '@%' + AND `address` != :object_address"); + $stmt->execute(array( + ':object_goto' => '%' . $object . '%', + ':object_address' => $object + )); + $standard_aliases = $stmt->fetchAll(PDO::FETCH_ASSOC); + while ($row = array_shift($standard_aliases)) { + $local = parse_email($row['address'])['local']; + $domain = parse_email($row['address'])['domain']; + if (!empty($local) && !empty($domain)) { + $rcpt[] = '/' . $local . '\+.*' . $domain . '/'; + } + $rcpt[] = $row['address']; + } + // Aliases by alias domains + $stmt = $pdo->prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `alias` FROM `mailbox` + LEFT OUTER JOIN `alias_domain` ON `mailbox`.`domain` = `alias_domain`.`target_domain` + WHERE `mailbox`.`username` = :object"); + $stmt->execute(array( + ':object' => $object + )); + $by_domain_aliases = $stmt->fetchAll(PDO::FETCH_ASSOC); + array_filter($by_domain_aliases); + while ($row = array_shift($by_domain_aliases)) { + if (!empty($row['alias'])) { + $local = parse_email($row['alias'])['local']; + $domain = parse_email($row['alias'])['domain']; + if (!empty($local) && !empty($domain)) { + $rcpt[] = '/' . $local . '\+.*' . $domain . '/'; + } + $rcpt[] = $row['alias']; + } + } + // Mailbox self + $local = parse_email($row['object'])['local']; + $domain = parse_email($row['object'])['domain']; + if (!empty($local) && !empty($domain)) { + $rcpt[] = '/' . $local . '\+.*' . $domain . '/'; + } + $rcpt[] = $object; + } + elseif ($type == 'domain') { + // Domain self + $rcpt[] = '/.*@' . $object . '/'; + $stmt = $pdo->prepare("SELECT `alias_domain` FROM `alias_domain` + WHERE `target_domain` = :object"); + $stmt->execute(array(':object' => $row['object'])); + $alias_domains = $stmt->fetchAll(PDO::FETCH_ASSOC); + array_filter($alias_domains); + while ($row = array_shift($alias_domains)) { + $rcpt[] = '/.*@' . $row['alias_domain'] . '/'; + } + } + if (!empty($rcpt)) { + return $rcpt; + } + return false; +} ?> settings { score_ { - priority = low; + priority = 4; + rcpt = ""; +prepare("SELECT `option`, `value` FROM `filterconf` WHERE (`option` = 'highspamlevel' OR `option` = 'lowspamlevel') AND `object`= :object"); $stmt->execute(array(':object' => $row['object'])); $spamscore = $stmt->fetchAll(PDO::FETCH_COLUMN|PDO::FETCH_GROUP); - - $stmt = $pdo->prepare("SELECT GROUP_CONCAT(REPLACE(`value`, '*', '.*') SEPARATOR '|') AS `value` FROM `filterconf` - WHERE (`object`= :object OR `object`= :object_domain) - AND (`option` = 'blacklist_from' OR `option` = 'whitelist_from')"); - $stmt->execute(array(':object' => $row['object'], ':object_domain' => substr(strrchr($row['object'], "@"), 1))); - $grouped_lists = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($grouped_lists); - while ($grouped_list = array_shift($grouped_lists)) { - $value_sane = preg_replace("/\.\./", ".", (preg_replace("/\*/", ".*", $grouped_list['value']))); - if (!empty($value_sane)) { -?> - from = "/^((?!).)*$/"; - - rcpt = "/\+.*/"; - - rcpt = ""; -prepare("SELECT `address` FROM `alias` WHERE `goto` LIKE :object_goto AND `address` NOT LIKE '@%' AND `address` != :object_address"); - $stmt->execute(array(':object_goto' => '%' . $row['object'] . '%', ':object_address' => $row['object'])); - $rows_aliases_1 = $stmt->fetchAll(PDO::FETCH_ASSOC); - while ($row_aliases_1 = array_shift($rows_aliases_1)) { - $local = parse_email($row_aliases_1['address'])['local']; - $domain = parse_email($row_aliases_1['address'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; -prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `aliases` FROM `mailbox` - LEFT OUTER JOIN `alias_domain` on `mailbox`.`domain` = `alias_domain`.`target_domain` - WHERE `mailbox`.`username` = :object"); - $stmt->execute(array(':object' => $row['object'])); - $rows_aliases_2 = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_aliases_2); - while ($row_aliases_2 = array_shift($rows_aliases_2)) { - if (!empty($row_aliases_2['aliases'])) { - $local = parse_email($row_aliases_2['aliases'])['local']; - $domain = parse_email($row_aliases_2['aliases'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; - apply "default" { actions { @@ -145,70 +156,23 @@ while ($row = array_shift($rows)) { - priority = medium; - rcpt = "/.*@/"; + priority = 5; prepare("SELECT `alias_domain` FROM `alias_domain` - WHERE `target_domain` = :object"); - $stmt->execute(array(':object' => $row['object'])); - $rows_domain_aliases = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_domain_aliases); - while ($row_domain_aliases = array_shift($rows_domain_aliases)) { + foreach (ucl_rcpts($row['object'], 'mailbox') as $rcpt) { ?> - rcpt = "/.*@/"; + rcpt = ""; - priority = high; + priority = 6; - rcpt = "/\+.*/"; + rcpt = ""; - rcpt = ""; -prepare("SELECT `address` FROM `alias` WHERE `goto` LIKE :object_goto AND `address` NOT LIKE '@%' AND `address` != :object_address"); - $stmt->execute(array(':object_goto' => '%' . $row['object'] . '%', ':object_address' => $row['object'])); - $rows_aliases_wl_1 = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_aliases_wl_1); - while ($row_aliases_wl_1 = array_shift($rows_aliases_wl_1)) { - $local = parse_email($row_aliases_wl_1['address'])['local']; - $domain = parse_email($row_aliases_wl_1['address'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; -prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `aliases` FROM `mailbox` - LEFT OUTER JOIN `alias_domain` on `mailbox`.`domain` = `alias_domain`.`target_domain` - WHERE `mailbox`.`username` = :object"); - $stmt->execute(array(':object' => $row['object'])); - $rows_aliases_wl_2 = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_aliases_wl_2); - while ($row_aliases_wl_2 = array_shift($rows_aliases_wl_2)) { - if (!empty($row_aliases_wl_2['aliases'])) { - $local = parse_email($row_aliases_wl_2['aliases'])['local']; - $domain = parse_email($row_aliases_wl_2['aliases'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; - apply "default" { @@ -243,70 +207,23 @@ while ($row = array_shift($rows)) { - priority = medium; - rcpt = "/.*@/"; + priority = 5; prepare("SELECT `alias_domain` FROM `alias_domain` - WHERE `target_domain` = :object"); - $stmt->execute(array(':object' => $row['object'])); - $rows_domain_aliases = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_domain_aliases); - while ($row_domain_aliases = array_shift($rows_domain_aliases)) { + foreach (ucl_rcpts($row['object'], 'mailbox') as $rcpt) { ?> - rcpt = "/.*@/"; + rcpt = ""; - priority = high; + priority = 6; - rcpt = "/\+.*/"; + rcpt = ""; - rcpt = ""; -prepare("SELECT `address` FROM `alias` WHERE `goto` LIKE :object_goto AND `address` NOT LIKE '@%' AND `address` != :object_address"); - $stmt->execute(array(':object_goto' => '%' . $row['object'] . '%', ':object_address' => $row['object'])); - $rows_aliases_bl_1 = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_aliases_bl_1); - while ($row_aliases_bl_1 = array_shift($rows_aliases_bl_1)) { - $local = parse_email($row_aliases_bl_1['address'])['local']; - $domain = parse_email($row_aliases_bl_1['address'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; -prepare("SELECT CONCAT(`local_part`, '@', `alias_domain`.`alias_domain`) AS `aliases` FROM `mailbox` - LEFT OUTER JOIN `alias_domain` on `mailbox`.`domain` = `alias_domain`.`target_domain` - WHERE `mailbox`.`username` = :object"); - $stmt->execute(array(':object' => $row['object'])); - $rows_aliases_bl_2 = $stmt->fetchAll(PDO::FETCH_ASSOC); - array_filter($rows_aliases_bl_2); - while ($row_aliases_bl_2 = array_shift($rows_aliases_bl_2)) { - if (!empty($row_aliases_bl_2['aliases'])) { - $local = parse_email($row_aliases_bl_2['aliases'])['local']; - $domain = parse_email($row_aliases_bl_2['aliases'])['domain']; - if (!empty($local) && !empty($local)) { -?> - rcpt = "/\+.*/"; - - rcpt = ""; - apply "default" { @@ -319,4 +236,4 @@ while ($row = array_shift($rows)) { -} \ No newline at end of file +} From cf594fbabdf059c41f87b61a656db30442318c41 Mon Sep 17 00:00:00 2001 From: andryyy Date: Sat, 1 Jul 2017 23:20:45 +0200 Subject: [PATCH 13/14] [Compose] Reduce interval of healthcheck to not wait 3 minutes for a healthy state... --- docker-compose.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 3c10656e..d69bdc3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,7 @@ services: condition: service_healthy healthcheck: test: ["CMD", "nslookup", "mailcow.email", "127.0.0.1"] - interval: 120s + interval: 30s timeout: 7s retries: 10 volumes: @@ -27,7 +27,7 @@ services: command: mysqld --max_allowed_packet=128M healthcheck: test: ["CMD", "mysqladmin", "ping", "--host", "localhost", "--silent"] - interval: 60s + interval: 10s timeout: 7s retries: 10 volumes: @@ -299,7 +299,7 @@ services: - 172.22.1.254 dns_search: mailcow-network environment: - - ADDITIONAL_SAN=${ADDITIONAL_SAN:-} + - ADDITIONAL_SAN=${ADDITIONAL_SAN:- } - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - DBNAME=${DBNAME} - DBUSER=${DBUSER} From b7cb4ac9d5a2cb340df73874b5f9c82714df9f1a Mon Sep 17 00:00:00 2001 From: andryyy Date: Sun, 2 Jul 2017 11:10:35 +0200 Subject: [PATCH 14/14] [Fail2ban] Added more regex to match failed or disallowed logins to Dovecot, changed Mailcow to mailcow --- data/Dockerfiles/fail2ban/logwatch.py | 5 ++++- data/web/inc/functions.inc.php | 4 ++-- docker-compose.yml | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/data/Dockerfiles/fail2ban/logwatch.py b/data/Dockerfiles/fail2ban/logwatch.py index 5dec15e2..74bc26b5 100644 --- a/data/Dockerfiles/fail2ban/logwatch.py +++ b/data/Dockerfiles/fail2ban/logwatch.py @@ -40,8 +40,11 @@ RULES[php_fpm_container] = {} RULES[postfix_container][1] = 'warning: .*\[([0-9a-f\.:]+)\]: SASL .* authentication failed' RULES[dovecot_container][1] = '-login: Disconnected \(auth failed, .*\): user=.*, method=.*, rip=([0-9a-f\.:]+),' +RULES[dovecot_container][2] = '-login: Disconnected \(no auth .+\): user=.+, rip=([0-9a-f\.:]+), lip.+' +RULES[dovecot_container][3] = '-login: Aborted login \(no auth .+\): user=.+, rip=([0-9a-f\.:]+), lip.+' +RULES[dovecot_container][4] = '-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+' RULES[sogo_container][1] = 'SOGo.* Login from \'([0-9a-f\.:]+)\' for user .* might not have worked' -RULES[php_fpm_container][1] = 'Mailcow UI: Invalid password for .* by ([0-9a-f\.:]+)' +RULES[php_fpm_container][1] = 'mailcow UI: Invalid password for .* by ([0-9a-f\.:]+)' r.setnx("F2B_BAN_TIME", "1800") diff --git a/data/web/inc/functions.inc.php b/data/web/inc/functions.inc.php index 948214e9..8e7b7cfd 100644 --- a/data/web/inc/functions.inc.php +++ b/data/web/inc/functions.inc.php @@ -229,11 +229,11 @@ function check_login($user, $pass) { } if (!isset($_SESSION['ldelay'])) { $_SESSION['ldelay'] = "0"; - error_log("Mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); + error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); } elseif (!isset($_SESSION['mailcow_cc_username'])) { $_SESSION['ldelay'] = $_SESSION['ldelay']+0.5; - error_log("Mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); + error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']); } sleep($_SESSION['ldelay']); } diff --git a/docker-compose.yml b/docker-compose.yml index d69bdc3f..853dbc36 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -319,7 +319,7 @@ services: - acme fail2ban-mailcow: - image: mailcow/fail2ban:1.5 + image: mailcow/fail2ban:1.4 build: ./data/Dockerfiles/fail2ban depends_on: - dovecot-mailcow