diff --git a/.gitignore b/.gitignore index c225dc090..a06c3da7f 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,4 @@ refresh_images.sh update_diffs/ create_cold_standby.sh !data/conf/nginx/mailcow_auth.conf +data/conf/postfix/postfix-tlspol \ No newline at end of file diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index b5cf2b585..a9f071fc5 100755 --- a/data/Dockerfiles/acme/acme.sh +++ b/data/Dockerfiles/acme/acme.sh @@ -206,7 +206,7 @@ while true; do if [[ ${AUTODISCOVER_SAN} == "y" ]]; then # Fetch certs for autoconfig and autodiscover subdomains - ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig') + ADDITIONAL_WC_ARR+=('autodiscover' 'autoconfig' 'mta-sts') fi if [[ ${SKIP_IP_CHECK} != "y" ]]; then diff --git a/data/Dockerfiles/postfix-tlspol/Dockerfile b/data/Dockerfiles/postfix-tlspol/Dockerfile new file mode 100644 index 000000000..95a035265 --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/Dockerfile @@ -0,0 +1,49 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /src + +ENV CGO_ENABLED=0 \ + GO111MODULE=on \ + VERSION=1.8.14 + +RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \ + cd /src/postfix-tlspol && \ + scripts/build.sh build-only + + +FROM debian:bookworm-slim +LABEL maintainer="The Infrastructure Company GmbH " + +ARG DEBIAN_FRONTEND=noninteractive +ENV LC_ALL=C + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + dirmngr \ + dnsutils \ + iputils-ping \ + sudo \ + supervisor \ + redis-tools \ + syslog-ng \ + syslog-ng-core \ + syslog-ng-mod-redis \ + tzdata \ + && rm -rf /var/lib/apt/lists/* \ + && touch /etc/default/locale + +COPY supervisord.conf /etc/supervisor/supervisord.conf +COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf +COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf +COPY postfix-tlspol.sh /opt/postfix-tlspol.sh +COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh +COPY docker-entrypoint.sh /docker-entrypoint.sh +COPY --from=builder /src/postfix-tlspol/build/postfix-tlspol /usr/local/bin/postfix-tlspol + +RUN chmod +x /opt/postfix-tlspol.sh \ + /usr/local/sbin/stop-supervisor.sh \ + /docker-entrypoint.sh +RUN rm -rf /tmp/* /var/tmp/* + +ENTRYPOINT ["/docker-entrypoint.sh"] + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/supervisord.conf"] \ No newline at end of file diff --git a/data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh b/data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh new file mode 100755 index 000000000..8c4f2c43e --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then + cp /etc/syslog-ng/syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng.conf +fi + +exec "$@" \ No newline at end of file diff --git a/data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh b/data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh new file mode 100755 index 000000000..407a08f6f --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/postfix-tlspol.sh @@ -0,0 +1,52 @@ +#!/bin/bash + +LOGLVL=info + +if [ ${DEV_MODE} != "n" ]; then + echo -e "\e[31mEnabling debug mode\e[0m" + set -x + LOGLVL=debug +fi + +[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol +[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol + +until dig +short mailcow.email > /dev/null; do + echo "Waiting for DNS..." + sleep 1 +done + +# Do not attempt to write to slave +if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then + export REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning" +else + export REDIS_CMDLINE="redis-cli -h redis -p 6379 -a ${REDISPASS} --no-auth-warning" +fi + +until [[ $(${REDIS_CMDLINE} PING) == "PONG" ]]; do + echo "Waiting for Redis..." + sleep 2 +done + +echo "Waiting for Postfix..." +until ping postfix -c1 > /dev/null; do + sleep 1 +done +echo "Postfix OK" + +cat < /etc/postfix-tlspol/config.yaml +server: + address: 0.0.0.0:8642 + + log-level: ${LOGLVL} + + prefetch: true + + cache-file: /var/lib/postfix-tlspol/cache.db + +dns: + # must support DNSSEC + address: 127.0.0.11:53 +EOF + +/usr/local/bin/postfix-tlspol -config /etc/postfix-tlspol/config.yaml \ No newline at end of file diff --git a/data/Dockerfiles/postfix-tlspol/stop-supervisor.sh b/data/Dockerfiles/postfix-tlspol/stop-supervisor.sh new file mode 100755 index 000000000..5394490ce --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/stop-supervisor.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +printf "READY\n"; + +while read line; do + echo "Processing Event: $line" >&2; + kill -3 $(cat "/var/run/supervisord.pid") +done < /dev/stdin diff --git a/data/Dockerfiles/postfix-tlspol/supervisord.conf b/data/Dockerfiles/postfix-tlspol/supervisord.conf new file mode 100644 index 000000000..90cf785ad --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/supervisord.conf @@ -0,0 +1,25 @@ +[supervisord] +pidfile=/var/run/supervisord.pid +nodaemon=true +user=root + +[program:syslog-ng] +command=/usr/sbin/syslog-ng --foreground --no-caps +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autostart=true + +[program:postfix-tlspol] +startsecs=10 +autorestart=true +command=/opt/postfix-tlspol.sh +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[eventlistener:processes] +command=/usr/local/sbin/stop-supervisor.sh +events=PROCESS_STATE_STOPPED, PROCESS_STATE_EXITED, PROCESS_STATE_FATAL \ No newline at end of file diff --git a/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf b/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf new file mode 100644 index 000000000..3862a3547 --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/syslog-ng-redis_slave.conf @@ -0,0 +1,45 @@ +@version: 3.38 +@include "scl.conf" +options { + chain_hostnames(off); + flush_lines(0); + use_dns(no); + dns_cache(no); + use_fqdn(no); + owner("root"); group("adm"); perm(0640); + stats_freq(0); + bad_hostname("^gconfd$"); +}; +source s_src { + unix-stream("/dev/log"); + internal(); +}; +destination d_stdout { pipe("/dev/stdout"); }; +destination d_redis_ui_log { + redis( + host("`REDIS_SLAVEOF_IP`") + persist-name("redis1") + port(`REDIS_SLAVEOF_PORT`) + auth("`REDISPASS`") + command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") + ); +}; +filter f_mail { facility(mail); }; +# start +# overriding warnings are still displayed when the entrypoint runs its initial check +# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs +# Some other warnings are ignored +filter f_ignore { + not match("overriding earlier entry" value("MESSAGE")); + not match("TLS SNI from checks.mailcow.email" value("MESSAGE")); + not match("no SASL support" value("MESSAGE")); + not facility (local0, local1, local2, local3, local4, local5, local6, local7); +}; +# end +log { + source(s_src); + filter(f_ignore); + destination(d_stdout); + filter(f_mail); + destination(d_redis_ui_log); +}; diff --git a/data/Dockerfiles/postfix-tlspol/syslog-ng.conf b/data/Dockerfiles/postfix-tlspol/syslog-ng.conf new file mode 100644 index 000000000..7126c1250 --- /dev/null +++ b/data/Dockerfiles/postfix-tlspol/syslog-ng.conf @@ -0,0 +1,45 @@ +@version: 3.38 +@include "scl.conf" +options { + chain_hostnames(off); + flush_lines(0); + use_dns(no); + dns_cache(no); + use_fqdn(no); + owner("root"); group("adm"); perm(0640); + stats_freq(0); + bad_hostname("^gconfd$"); +}; +source s_src { + unix-stream("/dev/log"); + internal(); +}; +destination d_stdout { pipe("/dev/stdout"); }; +destination d_redis_ui_log { + redis( + host("redis-mailcow") + persist-name("redis1") + port(6379) + auth("`REDISPASS`") + command("LPUSH" "POSTFIX_MAILLOG" "$(format-json time=\"$S_UNIXTIME\" priority=\"$PRIORITY\" program=\"$PROGRAM\" message=\"$MESSAGE\")\n") + ); +}; +filter f_mail { facility(mail); }; +# start +# overriding warnings are still displayed when the entrypoint runs its initial check +# warnings logged by postfix-mailcow to syslog are hidden to reduce repeating msgs +# Some other warnings are ignored +filter f_ignore { + not match("overriding earlier entry" value("MESSAGE")); + not match("TLS SNI from checks.mailcow.email" value("MESSAGE")); + not match("no SASL support" value("MESSAGE")); + not facility (local0, local1, local2, local3, local4, local5, local6, local7); +}; +# end +log { + source(s_src); + filter(f_ignore); + destination(d_stdout); + filter(f_mail); + destination(d_redis_ui_log); +}; diff --git a/data/Dockerfiles/postfix/Dockerfile b/data/Dockerfiles/postfix/Dockerfile index 5449360b3..994612ec4 100644 --- a/data/Dockerfiles/postfix/Dockerfile +++ b/data/Dockerfiles/postfix/Dockerfile @@ -1,9 +1,9 @@ FROM debian:bookworm-slim -LABEL maintainer = "The Infrastructure Company GmbH " +LABEL maintainer="The Infrastructure Company GmbH " ARG DEBIAN_FRONTEND=noninteractive -ENV LC_ALL C +ENV LC_ALL=C RUN dpkg-divert --local --rename --add /sbin/initctl \ && ln -sf /bin/true /sbin/initctl \ diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index e5dbf88fc..0a6494ed6 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -524,4 +524,4 @@ if [[ $? != 0 ]]; then else postfix -c /opt/postfix/conf start sleep 126144000 -fi +fi \ No newline at end of file diff --git a/data/conf/nginx/templates/nginx.conf.j2 b/data/conf/nginx/templates/nginx.conf.j2 index ff5f8f184..d71d63e56 100644 --- a/data/conf/nginx/templates/nginx.conf.j2 +++ b/data/conf/nginx/templates/nginx.conf.j2 @@ -48,13 +48,21 @@ http { listen {{ HTTP_PORT }} default_server; listen [::]:{{ HTTP_PORT }} default_server; - server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }}; + server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.* {{ ADDITIONAL_SERVER_NAMES | join(' ') }}; if ( $request_uri ~* "%0A|%0D" ) { return 403; } location ^~ /.well-known/acme-challenge/ { allow all; default_type "text/plain"; } + location ^~ /.well-known/mta-sts.txt { + allow all; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass {{ PHPFPMHOST }}:9002; + include /etc/nginx/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php; + fastcgi_param PATH_INFO $fastcgi_path_info; + } location / { return 301 https://$host$uri$is_args$args; } @@ -82,7 +90,7 @@ http { ssl_certificate /etc/ssl/mail/cert.pem; ssl_certificate_key /etc/ssl/mail/key.pem; - server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.*; + server_name {{ MAILCOW_HOSTNAME }} autodiscover.* autoconfig.* mta-sts.*; include /etc/nginx/includes/sites-default.conf; } diff --git a/data/conf/nginx/templates/sites-default.conf.j2 b/data/conf/nginx/templates/sites-default.conf.j2 index 0a175c6cb..f73ab0a49 100644 --- a/data/conf/nginx/templates/sites-default.conf.j2 +++ b/data/conf/nginx/templates/sites-default.conf.j2 @@ -76,6 +76,14 @@ location ^~ /.well-known/acme-challenge/ { allow all; default_type "text/plain"; } +location ^~ /.well-known/mta-sts.txt { + allow all; + fastcgi_split_path_info ^(.+\.php)(/.+)$; + fastcgi_pass {{ PHPFPMHOST }}:9002; + include /etc/nginx/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root/mta-sts.php; + fastcgi_param PATH_INFO $fastcgi_path_info; +} rewrite ^/.well-known/caldav$ /SOGo/dav/ permanent; rewrite ^/.well-known/carddav$ /SOGo/dav/ permanent; diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf index 07065f045..f091cb3f9 100644 --- a/data/conf/postfix/main.cf +++ b/data/conf/postfix/main.cf @@ -152,7 +152,7 @@ smtp_sasl_auth_enable = yes smtp_sasl_password_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_sender_dependent.cf smtp_sasl_security_options = smtp_sasl_mechanism_filter = plain, login -smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf +smtp_tls_policy_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_tls_policy_override_maps.cf socketmap:inet:postfix-tlspol:8642:QUERY smtp_header_checks = pcre:/opt/postfix/conf/anonymize_headers.pcre mail_name = Postcow # local_transport map catches local destinations and prevents routing local dests when the next map would route "*" diff --git a/data/web/edit.php b/data/web/edit.php index 7ce4d0c6a..57cf24bd2 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -48,6 +48,12 @@ if (isset($_SESSION['mailcow_cc_role'])) { $rl = ratelimit('get', 'domain', $domain); $rlyhosts = relayhost('get'); $domain_footer = mailbox('get', 'domain_wide_footer', $domain); + $mta_sts = mailbox('get', 'mta_sts', $domain); + if (count($mta_sts) == 0) { + $mta_sts = false; + } elseif (isset($mta_sts['mx'])) { + $mta_sts['mx'] = implode(',', $mta_sts['mx']); + } $template = 'edit/domain.twig'; $template_data = [ 'acl' => $_SESSION['acl'], @@ -58,6 +64,7 @@ if (isset($_SESSION['mailcow_cc_role'])) { 'dkim' => dkim('details', $domain), 'domain_details' => $result, 'domain_footer' => $domain_footer, + 'mta_sts' => $mta_sts, 'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]), 'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'), 'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"]) diff --git a/data/web/inc/ajax/dns_diagnostics.php b/data/web/inc/ajax/dns_diagnostics.php index 15cb3a30f..b48239e10 100644 --- a/data/web/inc/ajax/dns_diagnostics.php +++ b/data/web/inc/ajax/dns_diagnostics.php @@ -71,6 +71,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm // Init records array $spf_link = 'SPF Record Syntax
'; $dmarc_link = 'DMARC Assistant'; + $mtasts_report_link = 'TLS Report Record Syntax'; $records = array(); @@ -128,6 +129,27 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm ); } + $mta_sts = mailbox('get', 'mta_sts', $domain); + if (count($mta_sts) > 0 && $mta_sts['active'] == 1) { + if (!in_array($domain, $alias_domains)) { + $records[] = array( + 'mta-sts.' . $domain, + 'CNAME', + $mailcow_hostname + ); + } + $records[] = array( + '_mta-sts.' . $domain, + 'TXT', + "v={$mta_sts['version']};id={$mta_sts['id']};", + ); + $records[] = array( + '_smtp._tls.' . $domain, + 'TXT', + $mtasts_report_link, + ); + } + $records[] = array( $domain, 'TXT', @@ -341,15 +363,25 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm } foreach ($currents as &$current) { + if ($current['type'] == "TXT" && + stripos(strtolower($current['txt']), 'v=sts') === 0) { + if (strtolower($current[$data_field[$current['type']]]) == strtolower($record[2])) { + $state = state_good; + } + else { + $state = state_nomatch; + } + $state .= '
' . $current[$data_field[$current['type']]]; + } if ($current['type'] == 'TXT' && - stripos($current['txt'], 'v=dmarc') === 0 && - $record[2] == $dmarc_link) { + stripos($current['txt'], 'v=dmarc') === 0 && + $record[2] == $dmarc_link) { $current['txt'] = str_replace(' ', '', $current['txt']); $state = $current[$data_field[$current['type']]] . state_optional; } elseif ($current['type'] == 'TXT' && - stripos($current['txt'], 'v=spf') === 0 && - $record[2] == $spf_link) { + stripos($current['txt'], 'v=spf') === 0 && + $record[2] == $spf_link) { $state = state_nomatch; $rslt = get_spf_allowed_hosts($record[0], true); if (in_array($ip, $rslt) && in_array(expand_ipv6($ip6), $rslt)) { @@ -358,8 +390,8 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm $state .= '
' . $current[$data_field[$current['type']]] . state_optional; } elseif ($current['type'] == 'TXT' && - stripos($current['txt'], 'v=dkim') === 0 && - stripos($record[2], 'v=dkim') === 0) { + stripos($current['txt'], 'v=dkim') === 0 && + stripos($record[2], 'v=dkim') === 0) { preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $current[$data_field[$current['type']]], $dkim_matches_current); preg_match('/v=DKIM1;.*k=rsa;.*p=([^;]*).*/i', $record[2], $dkim_matches_good); if ($dkim_matches_current[1] == $dkim_matches_good[1]) { @@ -367,7 +399,7 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm } } elseif ($current['type'] != 'TXT' && - isset($data_field[$current['type']]) && $state != state_good) { + isset($data_field[$current['type']]) && $state != state_good) { $state = state_nomatch; if ($current[$data_field[$current['type']]] == $record[2]) { $state = state_good; diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 53e4f2cbf..c161d9958 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -1401,6 +1401,80 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { return mailbox('add', 'mailbox', $mailbox_attributes); break; + case 'mta_sts': + $domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46); + $version = strtolower($_data['version']); + $mode = strtolower($_data['mode']); + $mx = explode(",", preg_replace('/\s+/', '', $_data['mx'])); + $max_age = intval($_data['max_age']); + $active = (intval($_data['active']) == 1) ? 1 : 0; + $id = date('YmdHis'); + + if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => 'access_denied' + ); + return false; + } + if (empty($version) || !in_array($version, array('stsv1'))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('version_invalid', htmlspecialchars($domain)) + ); + return false; + } + if (empty($mode) || !in_array($mode, array('enforce', 'testing', 'none'))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('mode_invalid', htmlspecialchars($domain)) + ); + return false; + } + if (empty($max_age) || $max_age < 0 || $max_age > 31536000) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('max_age_invalid', htmlspecialchars($domain)) + ); + return false; + } + foreach ($mx as $index => $mx_domain) { + $mx_domain = idn_to_ascii(strtolower(trim($mx_domain)), 0, INTL_IDNA_VARIANT_UTS46); + if (!is_valid_domain_name($mx_domain)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('mx_invalid', htmlspecialchars($mx_domain)) + ); + return false; + } + } + + try { + $stmt = $pdo->prepare("INSERT INTO `mta_sts` (`id`, `domain`, `version`, `mode`, `mx`, `max_age`, `active`) + VALUES (:id, :domain, :version, :mode, :mx, :max_age, :active)"); + $stmt->execute(array( + ':id' => $id, + ':domain' => $domain, + ':version' => $version, + ':mode' => $mode, + ':mx' => implode(",", $mx), + ':max_age' => $max_age, + ':active' => $active + )); + } catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data), + 'msg' => $e->getMessage() + ); + return false; + } + break; case 'resource': $domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46); $description = $_data['description']; @@ -3742,6 +3816,125 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { return true; break; + case 'mta_sts': + if (!is_array($_data['domains'])) { + $domains = array(); + $domains[] = $_data['domains']; + } + else { + $domains = $_data['domains']; + } + + foreach ($domains as $domain) { + $domain = idn_to_ascii(strtolower(trim($domain)), 0, INTL_IDNA_VARIANT_UTS46); + + if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => 'access_denied' + ); + continue; + } + + $is_now = mailbox('get', 'mta_sts', $domain); + if (!empty($is_now)) { + $version = (isset($_data['version'])) ? strtolower($_data['version']) : $is_now['version']; + $active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active']; + $active = ($active == 1) ? 1 : 0; + $mode = (isset($_data['mode'])) ? strtolower($_data['mode']) : $is_now['mode']; + $mx = (isset($_data['mx'])) ? explode(",", preg_replace('/\s+/', '', $_data['mx'])) : $is_now['mx']; + $max_age = (isset($_data['max_age'])) ? intval($_data['max_age']) : $is_now['max_age']; + + // Update ID if neccesary + if ($version != strtolower($is_now['version']) || + $mode != strtolower($is_now['mode']) || + $mx != $is_now['mx'] || + $max_age != $is_now['max_age']) { + $id = date('YmdHis'); + } else { + $id = $is_now['id']; + } + + } else { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), + 'msg' => 'access_denied' + ); + continue; + } + + if (empty($version) || !in_array($version, array('stsv1'))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('version_invalid', htmlspecialchars($version)) + ); + continue; + } + if (empty($mode) || !in_array($mode, array('enforce', 'testing', 'none'))) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('mode_invalid', htmlspecialchars($domain)) + ); + continue; + } + if (empty($max_age) || $max_age < 0 || $max_age > 31557600) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('max_age_invalid', htmlspecialchars($domain)) + ); + continue; + } + foreach ($mx as $index => $mx_domain) { + $mx_domain = idn_to_ascii(strtolower(trim($mx_domain)), 0, INTL_IDNA_VARIANT_UTS46); + $invalid_mx = false; + if (!is_valid_domain_name($mx_domain)) { + $invalid_mx = $mx_domain; + break; + } + } + if ($invalid_mx) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('mx_invalid', htmlspecialchars($invalid_mx)) + ); + continue; + } + + try { + $stmt = $pdo->prepare("UPDATE `mta_sts` SET `id` = :id, `version` = :version, `mode` = :mode, `mx` = :mx, `max_age` = :max_age, `active` = :active WHERE `domain` = :domain"); + $stmt->execute(array( + ':id' => $id, + ':domain' => $domain, + ':version' => $version, + ':mode' => $mode, + ':mx' => implode(",", $mx), + ':max_age' => $max_age, + ':active' => $active + )); + } catch (PDOException $e) { + $_SESSION['return'][] = array( + 'type' => 'danger', + 'log' => array(__FUNCTION__, $_action, $_type, $_data), + 'msg' => $e->getMessage() + ); + continue; + } + + $_SESSION['return'][] = array( + 'type' => 'success', + 'log' => array(__FUNCTION__, $_action, $_type, $_data, $_attr), + 'msg' => array('object_modified', $domain) + ); + } + + return true; + break; case 'resource': if (!is_array($_data['name'])) { $names = array(); @@ -5030,6 +5223,20 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { return $rows; } break; + case 'mta_sts': + $stmt = $pdo->prepare("SELECT * FROM `mta_sts` WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $_data, + )); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + if (empty($row)){ + return []; + } + $row['mx'] = explode(',', $row['mx']); + $row['version'] = strtoupper(substr($row['version'], 0, 3)) . substr($row['version'], 3); + + return $row; + break; case 'resource_details': $resourcedata = array(); if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) { @@ -5415,6 +5622,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { $stmt->execute(array( ':domain' => $domain, )); + $stmt = $pdo->prepare("DELETE FROM `mta_sts` WHERE `domain` = :domain"); + $stmt->execute(array( + ':domain' => $domain, + )); $stmt = $pdo->query("DELETE FROM `admin` WHERE `superadmin` = 0 AND `username` NOT IN (SELECT `username`FROM `domain_admins`);"); $stmt = $pdo->query("DELETE FROM `da_acl` WHERE `username` NOT IN (SELECT `username`FROM `domain_admins`);"); try { diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 6a9d042a9..ecb87c380 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -4,7 +4,7 @@ function init_db_schema() try { global $pdo; - $db_version = "06082025_1611"; + $db_version = "19082025_1436"; $stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); @@ -475,6 +475,23 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + "mta_sts" => array( + "cols" => array( + "id" => "BIGINT NOT NULL", + "domain" => "VARCHAR(255) NOT NULL", + "version" => "VARCHAR(255) NOT NULL", + "mode" => "VARCHAR(255) NOT NULL", + "mx" => "VARCHAR(255) NOT NULL", + "max_age" => "VARCHAR(255) NOT NULL", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'" + ), + "keys" => array( + "primary" => array( + "" => array("domain") + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "user_acl" => array( "cols" => array( "username" => "VARCHAR(255) NOT NULL", diff --git a/data/web/json_api.php b/data/web/json_api.php index a480b0ae0..4d7159ad2 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -324,6 +324,9 @@ if (isset($_GET['query'])) { case "app-passwd": process_add_return(app_passwd('add', $attr)); break; + case "mta-sts": + process_add_return(mailbox('add', 'mta_sts', $attr)); + break; // return no route found if no case is matched default: http_response_code(404); @@ -2001,6 +2004,9 @@ if (isset($_GET['query'])) { case "reset-password-notification": process_edit_return(reset_password('edit_notification', $attr)); break; + case "mta-sts": + process_edit_return(mailbox('edit', 'mta_sts', array_merge(array('domains' => $items), $attr))); + break; // return no route found if no case is matched default: http_response_code(404); diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 5887499b3..1d2edc1b4 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -482,10 +482,13 @@ "mailboxes_in_use": "Maximale Anzahl an Mailboxen muss größer oder gleich %d sein", "malformed_username": "Benutzername hat ein falsches Format", "map_content_empty": "Inhalt darf nicht leer sein", + "max_age_invalid": "Maximales Alter %s ist ungültig", "max_alias_exceeded": "Anzahl an Alias-Adressen überschritten", "max_mailbox_exceeded": "Anzahl an Mailboxen überschritten (%d von %d)", "max_quota_in_use": "Mailbox-Speicherplatzlimit muss größer oder gleich %d MiB sein", "maxquota_empty": "Max. Speicherplatz pro Mailbox darf nicht 0 sein.", + "mode_invalid": "Modus %s ist ungültig", + "mx_invalid": "MX-Eintrag %s ist ungültig", "mysql_error": "MySQL-Fehler: %s", "network_host_invalid": "Netzwerk oder Host ungültig: %s", "next_hop_interferes": "%s verhindert das Hinzufügen von Next Hop %s", @@ -545,6 +548,7 @@ "username_invalid": "Benutzername %s kann nicht verwendet werden", "validity_missing": "Bitte geben Sie eine Gültigkeitsdauer an", "value_missing": "Bitte alle Felder ausfüllen", + "version_invalid": "Version %s ist ungültig", "yotp_verification_failed": "Yubico OTP-Verifizierung fehlgeschlagen: %s", "template_exists": "Vorlage %s existiert bereits", "template_id_invalid": "Vorlagen-ID %s ungültig", @@ -703,6 +707,17 @@ "maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)", "mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.", "mins_interval": "Intervall (min)", + "mta_sts": "MTA-STS", + "mta_sts_info": "MTA-STS ist ein Standard, der den E-Mail-Versand zwischen Mailservern zwingt, TLS mit gültigen Zertifikaten zu verwenden.
Er wird verwendet, wenn DANE aufgrund fehlender oder nicht unterstützter DNSSEC nicht möglich ist.
Hinweis: Wenn die empfangende Domain DANE mit DNSSEC unterstützt, wird DANE immer bevorzugt – MTA-STS fungiert nur als Fallback.", + "mta_sts_version": "Version", + "mta_sts_version_info": "Definiert die Version des MTA-STS-Standards – derzeit ist nur STSv1 gültig.", + "mta_sts_mode": "Modus", + "mta_sts_mode_info": "Es gibt drei Modi zur Auswahl:
  • testing – Die Richtlinie wird nur überwacht, Verstöße haben keine Auswirkungen.
  • enforce – Die Richtlinie wird strikt durchgesetzt, Verbindungen ohne gültiges TLS werden abgelehnt.
  • none – Die Richtlinie wird veröffentlicht, aber nicht angewendet.
", + "mta_sts_max_age": "Maximales Alter", + "mta_sts_max_age_info": "Zeit in Sekunden, die empfangende Mailserver diese Richtlinie zwischenspeichern dürfen, bevor sie erneut abgerufen wird.", + "mta_sts_mx": "MX-Server", + "mta_sts_mx_info": "Erlaubt das Senden nur an explizit aufgeführte Mailserver-Hostnamen; der sendende MTA überprüft, ob der DNS-MX-Hostname mit der Richtlinienliste übereinstimmt, und erlaubt die Zustellung nur mit einem gültigen TLS-Zertifikat (schützt vor MITM).", + "mta_sts_mx_notice": "Es können mehrere MX-Server angegeben werden (durch Kommas getrennt).", "multiple_bookings": "Mehrfaches Buchen", "nexthop": "Next Hop", "none_inherit": "Keine Auswahl / Erben", @@ -852,7 +867,7 @@ "add_tls_policy_map": "TLS-Richtlinieneintrag hinzufügen", "address_rewriting": "Adressumschreibung", "alias": "Alias", - "alias_domain_alias_hint": "Alias-Adressen werden nicht automatisch auch auf Domain-Alias Adressen angewendet. Eine Alias-Adresse mein-alias@domain bildet demnach nicht die Adresse mein-alias@alias-domain ab.
E-Mail-Weiterleitungen an externe Postfächer sollten über Sieve (SOGo Weiterleitung oder im Reiter \"Filter\") angelegt werden. Der Button \"Alias über Alias-Domains expandieren\" erstellt fehlende Alias-Adressen in Alias-Domains.", + "alias_domain_alias_hint": "Alias-Adressen werden nicht automatisch auch auf Domain-Alias Adressen angewendet. Eine Alias-Adresse mein-alias@domain bildet demnach nicht die Adresse mein-alias@alias-domain ab.
E-Mail-Weiterleitungen an externe Postfächer sollten über Sieve (SOGo Weiterleitung oder im Reiter Filter) angelegt werden. Der Button Alias über Alias-Domains expandieren erstellt fehlende Alias-Adressen in Alias-Domains.", "alias_domain_backupmx": "Alias-Domain für Relay-Domain inaktiv", "aliases": "Aliasse", "allow_from_smtp": "Nur folgende IPs für SMTP erlauben", @@ -1248,7 +1263,7 @@ "delete_ays": "Soll der Löschvorgang wirklich ausgeführt werden?", "direct_aliases": "Direkte Alias-Adressen", "direct_aliases_desc": "Nur direkte Alias-Adressen werden für benutzerdefinierte Einstellungen berücksichtigt.", - "direct_protocol_access": "Der Hauptbenutzer hat direkten, externen Zugriff auf folgende Protokolle und Anwendungen. Diese Einstellung wird vom Administrator gesteuert. App-Passwörter können verwendet werden, um individuelle Zugänge für Protokolle und Anwendungen zu erstellen.
Der Button \"Webmail\" kann unabhängig der Einstellung immer verwendet werden.", + "direct_protocol_access": "Der Hauptbenutzer hat direkten, externen Zugriff auf folgende Protokolle und Anwendungen. Diese Einstellung wird vom Administrator gesteuert. App-Passwörter können verwendet werden, um individuelle Zugänge für Protokolle und Anwendungen zu erstellen.
Der Button Webmail kann unabhängig der Einstellung immer verwendet werden.", "eas_reset": "ActiveSync-Geräte-Cache zurücksetzen", "eas_reset_help": "In vielen Fällen kann ein ActiveSync-Profil durch das Zurücksetzen des Caches repariert werden.
Vorsicht: Alle Elemente werden erneut heruntergeladen!", "eas_reset_now": "Jetzt zurücksetzen", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 727fd687c..78059c0b8 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -483,10 +483,13 @@ "mailboxes_in_use": "Max. mailboxes must be greater or equal to %d", "malformed_username": "Malformed username", "map_content_empty": "Map content cannot be empty", + "max_age_invalid": "Max age %s is invalid", "max_alias_exceeded": "Max. aliases exceeded", "max_mailbox_exceeded": "Max. mailboxes exceeded (%d of %d)", "max_quota_in_use": "Mailbox quota must be greater or equal to %d MiB", "maxquota_empty": "Max. quota per mailbox must not be 0.", + "mode_invalid": "Mode %s is invalid", + "mx_invalid": "MX record %s is invalid", "mysql_error": "MySQL error: %s", "network_host_invalid": "Invalid network or host: %s", "next_hop_interferes": "%s interferes with nexthop %s", @@ -550,6 +553,7 @@ "username_invalid": "Username %s cannot be used", "validity_missing": "Please assign a period of validity", "value_missing": "Please provide all values", + "version_invalid": "Version %s is invalid", "yotp_verification_failed": "Yubico OTP verification failed: %s" }, "datatables": { @@ -704,6 +708,17 @@ "maxbytespersecond": "Max. bytes per second
(0 = unlimited)", "mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.", "mins_interval": "Interval (min)", + "mta_sts": "MTA-STS", + "mta_sts_info": "MTA-STS is a standard that enforces email delivery between mail servers to use TLS with valid certificates.
It is used when DANE is not possible due to missing or unsupported DNSSEC.
Note: If the receiving domain supports DANE with DNSSEC, DANE is always preferred – MTA-STS only acts as a fallback.", + "mta_sts_version": "Version", + "mta_sts_version_info": "Defines the version of the MTA-STS standard – currently only STSv1 is valid." , + "mta_sts_mode": "Mode", + "mta_sts_mode_info": "There are three modes to choose from:
  • testing – policy is only monitored, violations have no impact.
  • enforce – policy is strictly enforced, connections without valid TLS are rejected.
  • none – policy is published but not applied.
", + "mta_sts_max_age": "Max age", + "mta_sts_max_age_info": "Time in seconds that receiving mail servers may cache this policy until refetching.", + "mta_sts_mx": "MX server", + "mta_sts_mx_info": "Allows sending only to explicitly listed mail server hostnames; the sending MTA checks if the DNS MX hostname matches the policy list, and only allows delivery with a valid TLS certificate (guards against MITM).", + "mta_sts_mx_notice": "Multiple MX servers can be specified (separated by commas).", "multiple_bookings": "Multiple bookings", "none_inherit": "None / Inherit", "nexthop": "Next hop", diff --git a/data/web/mta-sts.php b/data/web/mta-sts.php new file mode 100644 index 000000000..51c39eb2f --- /dev/null +++ b/data/web/mta-sts.php @@ -0,0 +1,30 @@ + diff --git a/data/web/templates/edit/domain.twig b/data/web/templates/edit/domain.twig index 6d1d85c73..774b30996 100644 --- a/data/web/templates/edit/domain.twig +++ b/data/web/templates/edit/domain.twig @@ -8,6 +8,7 @@ +
@@ -278,6 +279,82 @@ +
+
+
+ +
+
+

{{ lang.edit.mta_sts }}

+

{{ lang.edit.mta_sts_info|raw }}

+
+ + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ + {{ lang.edit.mta_sts_mx_notice|raw }} +
+
+
+
+
+ +
+
+
+
+
+ {% if mta_sts == false %} + + {% else %} + + {% endif %} +
+
+
+
+
+
diff --git a/docker-compose.yml b/docker-compose.yml index 378ad2148..27eacedb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -251,7 +251,7 @@ services: - sogo dovecot-mailcow: - image: ghcr.io/mailcow/dovecot:nightly-29072025 + image: ghcr.io/mailcow/dovecot:nightly-28082025 depends_on: - mysql-mailcow - netfilter-mailcow @@ -338,12 +338,14 @@ services: - dovecot postfix-mailcow: - image: ghcr.io/mailcow/postfix:1.80 + image: ghcr.io/mailcow/postfix:1.81 depends_on: mysql-mailcow: condition: service_started unbound-mailcow: condition: service_healthy + postfix-tlspol-mailcow: + condition: service_started volumes: - ./data/hooks/postfix:/hooks:Z - ./data/conf/postfix:/opt/postfix/conf:z @@ -378,6 +380,28 @@ services: aliases: - postfix + postfix-tlspol-mailcow: + image: ghcr.io/mailcow/postfix-tlspol:1.0 + depends_on: + unbound-mailcow: + condition: service_healthy + volumes: + - postfix-tlspol-vol-1:/var/lib/postfix-tlspol + environment: + - LOG_LINES=${LOG_LINES:-9999} + - TZ=${TZ} + - REDIS_SLAVEOF_IP=${REDIS_SLAVEOF_IP:-} + - REDIS_SLAVEOF_PORT=${REDIS_SLAVEOF_PORT:-} + - REDISPASS=${REDISPASS} + - DEV_MODE=${DEV_MODE:-n} + restart: always + dns: + - ${IPV4_NETWORK:-172.22.1}.254 + networks: + mailcow-network: + aliases: + - postfix-tlspol + memcached-mailcow: image: memcached:alpine restart: always @@ -441,7 +465,7 @@ services: condition: service_started unbound-mailcow: condition: service_healthy - image: ghcr.io/mailcow/acme:nightly-29072025 + image: ghcr.io/mailcow/acme:nightly-28082025 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: @@ -658,6 +682,7 @@ volumes: redis-vol-1: rspamd-vol-1: postfix-vol-1: + postfix-tlspol-vol-1: crypt-vol-1: sogo-web-vol-1: sogo-userdata-backup-vol-1: