From e43e1440404fcbac82d0aafc263a073882ecbd94 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 7 Dec 2021 13:16:23 -0800 Subject: [PATCH] email_server: Re-implement TLS configuration - Use LetsEncrypt component to perform TLS certificate copying instead of custom implementation. - Use two components to copy the certificates to dovecot and postfix separately. - Add support for multiple domains using SNI. Provide all the certificates. Use primary domain's certificate as the fallback certificate. - Drop the diagnose/repair approach due to its complexity. Tests: - Installing the app works. After installation, all TLS parameters are show as expected by 'postconf' command and 'doveconf' command. - A default domain is selected by default. This will reflect as primary domain in TLS certificate configuration. - When primary domain is changed, the configuration is updated to reflect the default certificate path but SNI configuration is unchanged in dovecot and postfix. - Postfix and dovecot are restarted after setup. - There are no configuration error shows in postfix/dovecot logs. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/email_server/__init__.py | 23 ++- plinth/modules/email_server/audit/domain.py | 10 +- plinth/modules/email_server/audit/tls.py | 190 ++++---------------- plinth/modules/email_server/views.py | 2 +- 4 files changed, 61 insertions(+), 164 deletions(-) diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index 5934f5b94..fc10d4d57 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -78,11 +78,20 @@ class EmailServerApp(plinth.app.App): self.add(webserver) # Let's Encrypt event hook - letsencrypt = LetsEncrypt('letsencrypt-email-server', - domains=get_domains, - daemons=['postfix', 'dovecot'], - should_copy_certificates=False, - managing_app='email_server') + letsencrypt = LetsEncrypt( + 'letsencrypt-email-server-postfix', domains='*', + daemons=['postfix'], should_copy_certificates=True, + private_key_path='/etc/postfix/letsencrypt/{domain}/chain.pem', + certificate_path='/etc/postfix/letsencrypt/{domain}/chain.pem', + user_owner='root', group_owner='root', managing_app='email_server') + self.add(letsencrypt) + + letsencrypt = LetsEncrypt( + 'letsencrypt-email-server-dovecot', domains='*', + daemons=['dovecot'], should_copy_certificates=True, + private_key_path='/etc/dovecot/letsencrypt/{domain}/privkey.pem', + certificate_path='/etc/dovecot/letsencrypt/{domain}/cert.pem', + user_owner='root', group_owner='root', managing_app='email_server') self.add(letsencrypt) def _add_ui_components(self): @@ -138,7 +147,6 @@ class EmailServerApp(plinth.app.App): results = super().diagnose() results.extend([r.summarize() for r in audit.ldap.get()]) results.extend([r.summarize() for r in audit.spam.get()]) - results.extend([r.summarize() for r in audit.tls.get()]) results.extend([r.summarize() for r in audit.rcube.get()]) return results @@ -166,10 +174,11 @@ def setup(helper, old_version=None): # Setup helper.call('post', audit.home.repair) + app.get_component('letsencrypt-email-server-postfix').setup_certificates() + app.get_component('letsencrypt-email-server-dovecot').setup_certificates() helper.call('post', audit.domain.set_domains) helper.call('post', audit.ldap.repair) helper.call('post', audit.spam.repair) - helper.call('post', audit.tls.repair) helper.call('post', audit.rcube.repair) # Reload diff --git a/plinth/modules/email_server/audit/domain.py b/plinth/modules/email_server/audit/domain.py index d91762f66..5e8d341f7 100644 --- a/plinth/modules/email_server/audit/domain.py +++ b/plinth/modules/email_server/audit/domain.py @@ -10,6 +10,8 @@ from plinth.modules import config from plinth.modules.email_server import postconf from plinth.modules.names.components import DomainName +from . import tls + def get_domains(): """Return the current domain configuration.""" @@ -39,16 +41,20 @@ def action_set_domains(primary_domain, all_domains): primary_domain = _clean_domain(primary_domain) defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'} - all_domains = set(all_domains).union(defaults) + my_destination = ', '.join(set(all_domains).union(defaults)) conf = { 'myhostname': primary_domain, 'mydomain': primary_domain, - 'mydestination': ', '.join(all_domains) + 'mydestination': my_destination } postconf.set_many(conf) pathlib.Path('/etc/mailname').write_text(primary_domain + '\n') + tls.set_postfix_config(primary_domain, all_domains) + tls.set_dovecot_config(primary_domain, all_domains) subprocess.run(['systemctl', 'try-reload-or-restart', 'postfix'], check=True) + subprocess.run(['systemctl', 'try-reload-or-restart', 'dovecot'], + check=True) def _clean_domain(domain): diff --git a/plinth/modules/email_server/audit/tls.py b/plinth/modules/email_server/audit/tls.py index 55d92ca57..669782d0c 100644 --- a/plinth/modules/email_server/audit/tls.py +++ b/plinth/modules/email_server/audit/tls.py @@ -1,29 +1,19 @@ -"""TLS configuration""" +"""TLS configuration for postfix and dovecot.""" # SPDX-License-Identifier: AGPL-3.0-or-later -import json -import logging -import os -import sys - -from django.utils.translation import gettext_lazy as _ - -from plinth import actions from plinth.modules.email_server import interproc, postconf -from . import models - # Mozilla Guideline v5.6, Postfix 1.17.7, OpenSSL 1.1.1d, intermediate # Generated 2021-08 # https://ssl-config.mozilla.org/ -tls_medium_cipherlist = [ +_tls_medium_cipherlist = [ 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', 'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384' ] -postfix_config = { +_postfix_config = { # Enable TLS 'smtpd_tls_security_level': 'may', @@ -41,7 +31,7 @@ postfix_config = { 'smtpd_tls_mandatory_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1', 'smtpd_tls_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1', 'smtpd_tls_mandatory_ciphers': 'medium', - 'tls_medium_cipherlist': ':'.join(tls_medium_cipherlist), + 'tls_medium_cipherlist': ':'.join(_tls_medium_cipherlist), 'tls_preempt_cipherlist': 'no', # Postfix SMTP client @@ -59,147 +49,39 @@ postfix_config = { 'tls_high_cipherlist': '$tls_medium_cipherlist', } -dovecot_cert_config = '/etc/dovecot/conf.d/91-freedombox-ssl.conf' -dovecot_cert_template = """# This file is managed by FreedomBox -ssl_cert = <{cert} -ssl_key = <{key} -""" - -logger = logging.getLogger(__name__) - - -def get(): - results = [] - _get_regular_results(results) - _get_superuser_results(results) - return results - - -def _get_regular_results(results): - translation_table = [ - (check_tls, _('Postfix TLS parameters')), - (check_postfix_cert_usage, _('Postfix uses a TLS certificate')), - ] - with postconf.mutex.lock_all(): - for check, title in translation_table: - results.append(check(title)) - - -def _get_superuser_results(results): - translation = { - 'cert_availability': _('Has a TLS certificate'), - } - dump = actions.superuser_run('email_server', ['tls', 'check']) - for jmap in json.loads(dump): - results.append(models.Diagnosis.from_json(jmap, translation.get)) - - -def repair(): - actions.superuser_run('email_server', ['tls', 'set_up']) - - -def repair_component(action): - action_to_services = {'set_cert': ['dovecot', 'postfix']} - if action not in action_to_services: # action not allowed - return - actions.superuser_run('email_server', ['tls', action]) - return action_to_services[action] - - -def check_tls(title=''): - diagnosis = models.MainCfDiagnosis(title) - diagnosis.compare(postfix_config, postconf.get_many_unsafe) - return diagnosis - - -def repair_tls(diagnosis): - diagnosis.apply_changes(postconf.set_many_unsafe) - - -def try_set_up_certificates(): - cert_folder = find_cert_folder() - if not cert_folder: - logger.warning('Could not find a suitable TLS certificate') - return - logger.info('Using TLS certificate in %s', cert_folder) - - cert = cert_folder + '/cert.pem' - key = cert_folder + '/privkey.pem' - write_postfix_cert_config(cert, key) - write_dovecot_cert_config(cert, key) - - -def find_cert_folder() -> str: - directory = '/etc/letsencrypt/live' - domains_available = [] - try: - listdir_result = os.listdir(directory) - except OSError: - return '' - - for item in listdir_result: - if item[0] != '.' and os.path.isdir(directory + '/' + item): - domains_available.append(item) - domains_available.sort() - - if len(domains_available) == 0: - return '' - if len(domains_available) == 1: - return directory + '/' + domains_available[0] - # XXX Cannot handle the case with multiple domains - if len(domains_available) > 1: - return '' - - -def write_postfix_cert_config(cert, key): - postconf.set_many_unsafe({ - 'smtpd_tls_cert_file': cert, - 'smtpd_tls_key_file': key +def set_postfix_config(primary_domain, all_domains): + """Set postfix configuration for TLS certificates.""" + tls_sni_map = '/etc/postfix/freedombox-tls-sni.map' + config = dict(_postfix_config) + config.update({ + 'tls_server_sni_maps': + tls_sni_map, + 'smtpd_tls_chain_files': + f'/etc/postfix/letsencrypt/{primary_domain}/chain.pem' }) + postconf.set_many_unsafe(config) + content = '# This file is managed by FreedomBox\n' + for domain in all_domains: + content += f'{domain} /etc/postfix/letsencrypt/{domain}/chain.pem\n' + + with interproc.atomically_rewrite(tls_sni_map) as file_handle: + file_handle.write(content) -def write_dovecot_cert_config(cert, key): - content = dovecot_cert_template.format(cert=cert, key=key) - with interproc.atomically_rewrite(dovecot_cert_config) as fd: - fd.write(content) - - -def check_postfix_cert_usage(title=''): - prefix = '/etc/letsencrypt/live/' - diagnosis = models.Diagnosis(title, action='set_cert') - conf = postconf.get_many_unsafe( - ['smtpd_tls_cert_file', 'smtpd_tls_key_file']) - if not conf['smtpd_tls_cert_file'].startswith(prefix): - diagnosis.error("Cert file not in Let's Encrypt directory") - if not conf['smtpd_tls_key_file'].startswith(prefix): - diagnosis.error("Privkey file not in Let's Encrypt directory") - - return diagnosis - - -def su_check_cert_availability(title=''): - diagnosis = models.Diagnosis(title) - if find_cert_folder() == '': - diagnosis.error("Could not find a Let's Encrypt certificate") - return diagnosis - - -def action_set_up(): - with postconf.mutex.lock_all(): - repair_tls(check_tls()) - try_set_up_certificates() - - -def action_set_cert(): - with postconf.mutex.lock_all(): - try_set_up_certificates() - - -def action_check(): - checks = ('cert_availability', ) - results = [] - for check_name in checks: - check_function = globals()['su_check_' + check_name] - results.append(check_function(check_name).to_json()) - json.dump(results, sys.stdout, indent=0) # indent=0 adds a new line +def set_dovecot_config(primary_domain, all_domains): + """Set dovecot configuration for TLS certificates.""" + content = f'''# This file is managed by FreedomBox +ssl_cert =