mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-05-27 10:44:33 +00:00
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 <sunil@medhas.org> Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
parent
4b024b269b
commit
e43e144040
@ -78,11 +78,20 @@ class EmailServerApp(plinth.app.App):
|
|||||||
self.add(webserver)
|
self.add(webserver)
|
||||||
|
|
||||||
# Let's Encrypt event hook
|
# Let's Encrypt event hook
|
||||||
letsencrypt = LetsEncrypt('letsencrypt-email-server',
|
letsencrypt = LetsEncrypt(
|
||||||
domains=get_domains,
|
'letsencrypt-email-server-postfix', domains='*',
|
||||||
daemons=['postfix', 'dovecot'],
|
daemons=['postfix'], should_copy_certificates=True,
|
||||||
should_copy_certificates=False,
|
private_key_path='/etc/postfix/letsencrypt/{domain}/chain.pem',
|
||||||
managing_app='email_server')
|
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)
|
self.add(letsencrypt)
|
||||||
|
|
||||||
def _add_ui_components(self):
|
def _add_ui_components(self):
|
||||||
@ -138,7 +147,6 @@ class EmailServerApp(plinth.app.App):
|
|||||||
results = super().diagnose()
|
results = super().diagnose()
|
||||||
results.extend([r.summarize() for r in audit.ldap.get()])
|
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.spam.get()])
|
||||||
results.extend([r.summarize() for r in audit.tls.get()])
|
|
||||||
results.extend([r.summarize() for r in audit.rcube.get()])
|
results.extend([r.summarize() for r in audit.rcube.get()])
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@ -166,10 +174,11 @@ def setup(helper, old_version=None):
|
|||||||
|
|
||||||
# Setup
|
# Setup
|
||||||
helper.call('post', audit.home.repair)
|
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.domain.set_domains)
|
||||||
helper.call('post', audit.ldap.repair)
|
helper.call('post', audit.ldap.repair)
|
||||||
helper.call('post', audit.spam.repair)
|
helper.call('post', audit.spam.repair)
|
||||||
helper.call('post', audit.tls.repair)
|
|
||||||
helper.call('post', audit.rcube.repair)
|
helper.call('post', audit.rcube.repair)
|
||||||
|
|
||||||
# Reload
|
# Reload
|
||||||
|
|||||||
@ -10,6 +10,8 @@ from plinth.modules import config
|
|||||||
from plinth.modules.email_server import postconf
|
from plinth.modules.email_server import postconf
|
||||||
from plinth.modules.names.components import DomainName
|
from plinth.modules.names.components import DomainName
|
||||||
|
|
||||||
|
from . import tls
|
||||||
|
|
||||||
|
|
||||||
def get_domains():
|
def get_domains():
|
||||||
"""Return the current domain configuration."""
|
"""Return the current domain configuration."""
|
||||||
@ -39,16 +41,20 @@ def action_set_domains(primary_domain, all_domains):
|
|||||||
primary_domain = _clean_domain(primary_domain)
|
primary_domain = _clean_domain(primary_domain)
|
||||||
|
|
||||||
defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'}
|
defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'}
|
||||||
all_domains = set(all_domains).union(defaults)
|
my_destination = ', '.join(set(all_domains).union(defaults))
|
||||||
conf = {
|
conf = {
|
||||||
'myhostname': primary_domain,
|
'myhostname': primary_domain,
|
||||||
'mydomain': primary_domain,
|
'mydomain': primary_domain,
|
||||||
'mydestination': ', '.join(all_domains)
|
'mydestination': my_destination
|
||||||
}
|
}
|
||||||
postconf.set_many(conf)
|
postconf.set_many(conf)
|
||||||
pathlib.Path('/etc/mailname').write_text(primary_domain + '\n')
|
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'],
|
subprocess.run(['systemctl', 'try-reload-or-restart', 'postfix'],
|
||||||
check=True)
|
check=True)
|
||||||
|
subprocess.run(['systemctl', 'try-reload-or-restart', 'dovecot'],
|
||||||
|
check=True)
|
||||||
|
|
||||||
|
|
||||||
def _clean_domain(domain):
|
def _clean_domain(domain):
|
||||||
|
|||||||
@ -1,29 +1,19 @@
|
|||||||
"""TLS configuration"""
|
"""TLS configuration for postfix and dovecot."""
|
||||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
# 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 plinth.modules.email_server import interproc, postconf
|
||||||
|
|
||||||
from . import models
|
|
||||||
|
|
||||||
# Mozilla Guideline v5.6, Postfix 1.17.7, OpenSSL 1.1.1d, intermediate
|
# Mozilla Guideline v5.6, Postfix 1.17.7, OpenSSL 1.1.1d, intermediate
|
||||||
# Generated 2021-08
|
# Generated 2021-08
|
||||||
# https://ssl-config.mozilla.org/
|
# https://ssl-config.mozilla.org/
|
||||||
tls_medium_cipherlist = [
|
_tls_medium_cipherlist = [
|
||||||
'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256',
|
'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256',
|
||||||
'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384',
|
'ECDHE-ECDSA-AES256-GCM-SHA384', 'ECDHE-RSA-AES256-GCM-SHA384',
|
||||||
'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305',
|
'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305',
|
||||||
'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384'
|
'DHE-RSA-AES128-GCM-SHA256', 'DHE-RSA-AES256-GCM-SHA384'
|
||||||
]
|
]
|
||||||
|
|
||||||
postfix_config = {
|
_postfix_config = {
|
||||||
# Enable TLS
|
# Enable TLS
|
||||||
'smtpd_tls_security_level': 'may',
|
'smtpd_tls_security_level': 'may',
|
||||||
|
|
||||||
@ -41,7 +31,7 @@ postfix_config = {
|
|||||||
'smtpd_tls_mandatory_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
|
'smtpd_tls_mandatory_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
|
||||||
'smtpd_tls_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
|
'smtpd_tls_protocols': '!SSLv2, !SSLv3, !TLSv1, !TLSv1.1',
|
||||||
'smtpd_tls_mandatory_ciphers': 'medium',
|
'smtpd_tls_mandatory_ciphers': 'medium',
|
||||||
'tls_medium_cipherlist': ':'.join(tls_medium_cipherlist),
|
'tls_medium_cipherlist': ':'.join(_tls_medium_cipherlist),
|
||||||
'tls_preempt_cipherlist': 'no',
|
'tls_preempt_cipherlist': 'no',
|
||||||
|
|
||||||
# Postfix SMTP client
|
# Postfix SMTP client
|
||||||
@ -59,147 +49,39 @@ postfix_config = {
|
|||||||
'tls_high_cipherlist': '$tls_medium_cipherlist',
|
'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
|
def set_postfix_config(primary_domain, all_domains):
|
||||||
ssl_cert = <{cert}
|
"""Set postfix configuration for TLS certificates."""
|
||||||
ssl_key = <{key}
|
tls_sni_map = '/etc/postfix/freedombox-tls-sni.map'
|
||||||
"""
|
config = dict(_postfix_config)
|
||||||
|
config.update({
|
||||||
logger = logging.getLogger(__name__)
|
'tls_server_sni_maps':
|
||||||
|
tls_sni_map,
|
||||||
|
'smtpd_tls_chain_files':
|
||||||
def get():
|
f'/etc/postfix/letsencrypt/{primary_domain}/chain.pem'
|
||||||
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
|
|
||||||
})
|
})
|
||||||
|
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):
|
def set_dovecot_config(primary_domain, all_domains):
|
||||||
content = dovecot_cert_template.format(cert=cert, key=key)
|
"""Set dovecot configuration for TLS certificates."""
|
||||||
with interproc.atomically_rewrite(dovecot_cert_config) as fd:
|
content = f'''# This file is managed by FreedomBox
|
||||||
fd.write(content)
|
ssl_cert = </etc/dovecot/letsencrypt/{primary_domain}/cert.pem
|
||||||
|
ssl_key = </etc/dovecot/letsencrypt/{primary_domain}/privkey.pem
|
||||||
|
'''
|
||||||
def check_postfix_cert_usage(title=''):
|
for domain in all_domains:
|
||||||
prefix = '/etc/letsencrypt/live/'
|
content += f'''
|
||||||
diagnosis = models.Diagnosis(title, action='set_cert')
|
local_name {domain} {{
|
||||||
conf = postconf.get_many_unsafe(
|
ssl_cert = </etc/dovecot/letsencrypt/{domain}/cert.pem
|
||||||
['smtpd_tls_cert_file', 'smtpd_tls_key_file'])
|
ssl_key = </etc/dovecot/letsencrypt/{domain}/privkey.pem
|
||||||
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):
|
dovecot_cert_config = '/etc/dovecot/conf.d/91-freedombox-tls.conf'
|
||||||
diagnosis.error("Privkey file not in Let's Encrypt directory")
|
with interproc.atomically_rewrite(dovecot_cert_config) as file_handle:
|
||||||
|
file_handle.write(content)
|
||||||
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
|
|
||||||
|
|||||||
@ -46,7 +46,7 @@ class EmailServerView(ExceptionsMixin, AppView):
|
|||||||
app_id = 'email_server'
|
app_id = 'email_server'
|
||||||
form_class = forms.DomainForm
|
form_class = forms.DomainForm
|
||||||
template_name = 'email_server.html'
|
template_name = 'email_server.html'
|
||||||
audit_modules = ('tls', 'rcube')
|
audit_modules = ('rcube', )
|
||||||
|
|
||||||
def get_initial(self):
|
def get_initial(self):
|
||||||
"""Return the initial values to populate in the form."""
|
"""Return the initial values to populate in the form."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user