diff --git a/.gitignore b/.gitignore index c225dc090..8d0fe16fd 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/mta-sts-resolver \ No newline at end of file diff --git a/data/Dockerfiles/acme/acme.sh b/data/Dockerfiles/acme/acme.sh index 15b757ff9..69b18bc1f 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/Dockerfile b/data/Dockerfiles/postfix/Dockerfile index 5449360b3..d1a32917d 100644 --- a/data/Dockerfiles/postfix/Dockerfile +++ b/data/Dockerfiles/postfix/Dockerfile @@ -1,9 +1,19 @@ +FROM golang:1.25-bookworm AS builder +WORKDIR /src + +ENV CGO_ENABLED=0 \ + GO111MODULE=on + +RUN git clone https://github.com/Zuplu/postfix-tlspol.git && \ + cd postfix-tlspol && \ + scripts/build.sh build-only + 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 \ @@ -48,6 +58,7 @@ COPY rspamd-pipe-spam /usr/local/bin/rspamd-pipe-spam COPY whitelist_forwardinghosts.sh /usr/local/bin/whitelist_forwardinghosts.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.sh \ /usr/local/bin/rspamd-pipe-ham \ diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index e5dbf88fc..fcb6c26a2 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -3,6 +3,8 @@ trap "postfix stop" EXIT [[ ! -d /opt/postfix/conf/sql/ ]] && mkdir -p /opt/postfix/conf/sql/ +[[ ! -d /etc/postfix-tlspol ]] && mkdir -p /etc/postfix-tlspol +[[ ! -d /var/lib/postfix-tlspol ]] && mkdir -p /var/lib/postfix-tlspol # Wait for MySQL to warm-up while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do @@ -503,6 +505,26 @@ if [[ ! -f /opt/postfix/conf/custom_postscreen_whitelist.cidr ]]; then EOF fi +cat < /opt/postfix/conf/postfix-tlspol/config.yaml +server: + address: 127.0.0.1:8642 + + log-level: info + + prefetch: true + + cache-file: /var/lib/postfix-tlspol/cache.db + +dns: + # must support DNSSEC + address: 127.0.0.11:53 +EOF + +# Fixing local command execution of postfix-tlspol with symlink to config +if [ ! -L /etc/postfix-tlspol/config.yaml ]; then + ln -s /opt/postfix/conf/postfix-tlspol/config.yaml /etc/postfix-tlspol/config.yaml +fi + # Fix Postfix permissions chown -R root:postfix /opt/postfix/conf/sql/ /opt/postfix/conf/custom_transport.pcre chmod 640 /opt/postfix/conf/sql/*.cf /opt/postfix/conf/custom_transport.pcre @@ -524,4 +546,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/Dockerfiles/postfix/supervisord.conf b/data/Dockerfiles/postfix/supervisord.conf index ba70f8edf..26fec862c 100644 --- a/data/Dockerfiles/postfix/supervisord.conf +++ b/data/Dockerfiles/postfix/supervisord.conf @@ -11,6 +11,15 @@ stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 autostart=true +[program:postfix-tlspol] +startsecs=10 +autorestart=true +command=/usr/local/bin/postfix-tlspol -config /opt/postfix/conf/postfix-tlspol/config.yaml +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:postfix] command=/opt/postfix.sh stdout_logfile=/dev/stdout 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 574bdb052..23fe7b788 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..e4354c40c 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:127.0.0.1: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 6ea4f5717..a74d182cf 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -1400,6 +1400,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']; @@ -3741,6 +3815,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(); @@ -5029,6 +5222,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)) { @@ -5414,6 +5621,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 c55158dbb..cbccbcde4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -338,7 +338,7 @@ 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 @@ -440,7 +440,7 @@ services: condition: service_started unbound-mailcow: condition: service_healthy - image: ghcr.io/mailcow/acme:1.93 + image: ghcr.io/mailcow/acme:1.94 dns: - ${IPV4_NETWORK:-172.22.1}.254 environment: