diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index 45c841024..305a10bf3 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -137,6 +137,7 @@ class EmailServerApp(plinth.app.App): 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 @@ -147,6 +148,7 @@ def setup(helper, old_version=None): helper.call('post', audit.ldap.repair) helper.call('post', audit.spam.repair) helper.call('post', audit.tls.repair) + helper.call('post', audit.rcube.repair) for srvname in managed_services: actions.superuser_run('service', ['reload', srvname]) # Final step: expose service daemons to public internet diff --git a/plinth/modules/email_server/audit/__init__.py b/plinth/modules/email_server/audit/__init__.py index 4b548e7b4..fa4690531 100644 --- a/plinth/modules/email_server/audit/__init__.py +++ b/plinth/modules/email_server/audit/__init__.py @@ -6,7 +6,8 @@ Provides diagnosis and repair of email server configuration issues from . import domain from . import home from . import ldap +from . import rcube from . import spam from . import tls -__all__ = ['domain', 'home', 'ldap', 'spam', 'tls'] +__all__ = ['domain', 'home', 'ldap', 'rcube', 'spam', 'tls'] diff --git a/plinth/modules/email_server/audit/rcube.py b/plinth/modules/email_server/audit/rcube.py new file mode 100644 index 000000000..606dd0f39 --- /dev/null +++ b/plinth/modules/email_server/audit/rcube.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later + +import json +import logging +import os + +from django.utils.translation import ugettext_lazy as _ +from plinth import actions + +from . import models +from plinth.modules.email_server.lock import Mutex +from plinth.modules.email_server.modconf import ConfigInjector + + +config_path = '/etc/roundcube/config.inc.php' +boundary_pattern = '//[ ]*--[ ]*(BEGIN|END)[ ]+FREEDOMBOX CONFIG$' +boundary_format = '//-- {} FREEDOMBOX CONFIG' + +rconf_template = """// +// The following section is managed by FreedomBox +// Be careful not to edit +include_once("/etc/roundcube/freedombox_mail.inc.php"); +""" + +logger = logging.getLogger(__name__) +rcube_mutex = Mutex('rcube-config') + + +def get(): + translation_table = { + 'rc_installed': _('RoundCube availability'), + 'rc_config_header': _('FreedomBox header in RoundCube config'), + } + output = actions.superuser_run('email_server', ['-i', 'rcube', 'check']) + results = json.loads(output) + for i in range(0, len(results)): + name = translation_table.get(results[i][0], results[i][0]) + diagnosis = models.Diagnosis(name) + if results[i][1] == 'error': + diagnosis.error('Failed') + results[i] = diagnosis + + return results + + +def repair(): + actions.superuser_run('email_server', ['-i', 'rcube', 'set_up']) + + +def action_check(): + results = _action_check() + print(json.dumps(results)) + + +def _action_check(): + results = [] + if not os.path.exists(config_path): + results.append(['rc_installed', 'error']) + return results + + injector = ConfigInjector(boundary_pattern, boundary_format) + if injector.has_header_line(config_path): + results.append(['rc_config_header', 'pass']) + else: + results.append(['rc_config_header', 'error']) + + return results + + +def action_set_up(): + with rcube_mutex.lock_all(): + _inject_rcube_config() + + +def _inject_rcube_config(): + if not os.path.exists(config_path): + logger.warning('Roundcube has not been installed') + return + logger.info('Opening rcube config file %s', config_path) + injector = ConfigInjector(boundary_pattern, boundary_format) + injector.do_template_string(rconf_template, config_path) diff --git a/plinth/modules/email_server/audit/spam.py b/plinth/modules/email_server/audit/spam.py index ca2f6caf3..f325965fa 100644 --- a/plinth/modules/email_server/audit/spam.py +++ b/plinth/modules/email_server/audit/spam.py @@ -10,6 +10,7 @@ from plinth import actions from . import models from plinth.modules.email_server import interproc, lock, postconf +from plinth.modules.email_server.modconf import ConfigInjector milter_config = { 'milter_mail_macros': 'i ' + ' '.join([ @@ -65,9 +66,8 @@ egress_filter_cleanup_options = { # Rspamd config -rspamd_boundary = re.compile('#[ ]*--[ ]*([A-Z]{3,5})[ ]+FREEDOMBOX CONFIG$') -rspamd_header = '#-- BEGIN FREEDOMBOX CONFIG\n' -rspamd_footer = '#-- END FREEDOMBOX CONFIG\n' +rspamd_re = re.compile('#[ ]*--[ ]*([A-Z]{3,5})[ ]+FREEDOMBOX CONFIG$') +rspamd_format = '#-- {} FREEDOMBOX CONFIG' rspamd_mutex = lock.Mutex('rspamd-config') logger = logging.getLogger(__name__) @@ -103,61 +103,19 @@ def action_set_filter(): with postconf.mutex.lock_all(): fix_filter(check_filter()) + injector = ConfigInjector(rspamd_re, rspamd_format) with rspamd_mutex.lock_all(): # XXX Maybe use globbing? - _inject_rspamd_config('override', 'options.inc') - _inject_rspamd_config('local', 'milter_headers.conf') + _inject_rspamd_config(injector, 'override', 'options.inc') + _inject_rspamd_config(injector, 'local', 'milter_headers.conf') -def _inject_rspamd_config(type, name): +def _inject_rspamd_config(injector, type, name): template_path = '/etc/plinth/rspamd-config/%s_%s' % (type, name) config_path = '/etc/rspamd/%s.d/%s' % (type, name) logger.info('Opening Rspamd config file %s', config_path) - - template = None - config = None - try: - template = open(template_path, 'r') - config = open(config_path, 'a+') - with interproc.atomically_rewrite(config_path) as scratch: - config.seek(0) - inject_rspamd_config3(template, config, scratch) - finally: - if config is not None: - config.close() - if template is not None: - template.close() - - -def inject_rspamd_config3(template, config, scratch): - """Write modified rspamd config to the `scratch` stream""" - # Copy the original up to the config header line - for line in config: - match = rspamd_boundary.match(line.strip()) - if match and match.group(1) == 'BEGIN': - break - scratch.write(line) - if not line.endswith('\n'): # in case no new line was at the eof - scratch.write('\n') - - # Inject template data - scratch.write(rspamd_header) - for line in template: - scratch.write(line) - if not line.endswith('\n'): # in case no new line was at the eof - scratch.write('\n') - scratch.write(rspamd_footer) - - # Find the config trailer line - for line in config: - match = rspamd_boundary.match(line.strip()) - if match and match.group(1) == 'END': - break - - # Copy the original - for line in config: - scratch.write(line) # keep original file ending style + injector.do_template_file(template_path, config_path) def _compile_sieve(): diff --git a/plinth/modules/email_server/audit/tls.py b/plinth/modules/email_server/audit/tls.py index ceb48b3d6..a8c150aeb 100644 --- a/plinth/modules/email_server/audit/tls.py +++ b/plinth/modules/email_server/audit/tls.py @@ -22,7 +22,9 @@ tls_medium_cipherlist = [ postfix_config = { # Enable TLS 'smtpd_tls_security_level': 'may', - 'smtpd_tls_auth_only': 'yes', + + # Allow unencrypted auth on port 25, needed by Roundcube + 'smtpd_tls_auth_only': 'no', # Debugging information 'smtpd_tls_received_header': 'yes', diff --git a/plinth/modules/email_server/data/etc/roundcube/freedombox_mail.inc.php b/plinth/modules/email_server/data/etc/roundcube/freedombox_mail.inc.php new file mode 100644 index 000000000..62d858db0 --- /dev/null +++ b/plinth/modules/email_server/data/etc/roundcube/freedombox_mail.inc.php @@ -0,0 +1,8 @@ + diff --git a/plinth/modules/email_server/modconf.py b/plinth/modules/email_server/modconf.py new file mode 100644 index 000000000..d78f9b194 --- /dev/null +++ b/plinth/modules/email_server/modconf.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Generic config modifying utilities""" + +import contextlib +import io +import re + +from . import interproc + + +class ConfigInjector: + def __init__(self, match, generate): + self.re_pattern = re.compile(match) + self.boundary_fmt = generate + '\n' + + def do_file_des(self, template, config, scratch): + """Write modified config to the `scratch` stream""" + if not isinstance(template, io.TextIOBase): + raise TypeError('Not a text IO stream: template') + self._inject_config3(template, config, scratch) + + def _inject_config3(self, template, config, scratch): + # Copy the original config up to header line + for line in config: + match = self.re_pattern.match(line.strip()) + if match and match.group(1) == 'BEGIN': + break + scratch.write(line) + if not line.endswith('\n'): # in case no new line was at the eof + scratch.write('\n') + + # Write header line + scratch.write(self.boundary_fmt.format('BEGIN')) + # Write template to scratch + for line in template: + scratch.write(line) + # in case no new line was at the eof + if not line.endswith('\n'): + scratch.write('\n') + # Write footer line + scratch.write(self.boundary_fmt.format('END')) + + # Find the trailer line in config + for line in config: + match = self.re_pattern.match(line.strip()) + if match and match.group(1) == 'END': + break + + # Copy the original + for line in config: + scratch.write(line) # keep original file ending style + + def do_template_file(self, template_path, config_path): + with open(template_path, 'r') as template: + with self._open_config(config_path) as (config, scratch): + self._inject_config3(template, config, scratch) + + def do_template_string(self, template_string, config_path): + with self._open_config(config_path) as (config, scratch): + self._inject_config3([template_string], config, scratch) + + @contextlib.contextmanager + def _open_config(self, config_path): + with open(config_path, 'a+') as config: + with interproc.atomically_rewrite(config_path) as scratch: + config.seek(0) + yield config, scratch + + def has_header_line(self, config_path): + with open(config_path, 'r') as config_fd: + return self._has_header_line(config_fd) + + def _has_header_line(self, config_fd): + for line in config_fd: + match = self.re_pattern.match(line.strip()) + if match and match.group(1) == 'BEGIN': + return True + return False