From ae882fea704cd844ff6ca38904eae1f5e1e8da9d Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Wed, 1 Dec 2021 18:07:57 -0800 Subject: [PATCH] email_server: Simplify domain configuration form - By default, receive mail for all the domains on the system. - Allow user to select a primary domain. This domain is used for TLS certificate, automatically adding domain to sender address, etc. - Don't expose postfix configuration parameters. Tests: - On installation, the domain list populated in postfix. Primary domain is the one set in the config module. If it is not set, any other domain from configured domains is taken. - When not installed, adding/removing domains does not cause errors. - Changing the domain in the domain view works. mydomain has the primary domain set. myhostname has primary domain set. mydestination has default values and in addition has all the domains on the system. - /etc/mailname is populated with the primary domain. Signed-off-by: Sunil Mohan Adapa Reviewed-by: James Valleroy --- plinth/modules/email_server/__init__.py | 27 +- plinth/modules/email_server/audit/domain.py | 338 +++----------------- plinth/modules/email_server/forms.py | 33 +- plinth/modules/email_server/views.py | 18 +- 4 files changed, 85 insertions(+), 331 deletions(-) diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index d74df3802..915b024fe 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -15,6 +15,7 @@ from plinth.modules.config import get_domainname from plinth.modules.firewall.components import Firewall from plinth.modules.letsencrypt.components import LetsEncrypt from plinth.package import Packages, remove +from plinth.signals import domain_added, domain_removed from . import audit, manifest @@ -126,10 +127,15 @@ class EmailServerApp(plinth.app.App): ports=all_port_names, is_external=True) self.add(firewall) + @staticmethod + def post_init(): + """Perform post initialization operations.""" + domain_added.connect(on_domain_added) + domain_removed.connect(on_domain_removed) + def diagnose(self): """Run diagnostics and return the results""" results = super().diagnose() - results.extend([r.summarize() for r in audit.domain.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.tls.get()]) @@ -160,7 +166,7 @@ def setup(helper, old_version=None): # Setup helper.call('post', audit.home.repair) - helper.call('post', audit.domain.repair) + 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) @@ -173,3 +179,20 @@ def setup(helper, old_version=None): # Expose to public internet helper.call('post', app.enable) + + +def on_domain_added(sender, domain_type, name, description='', services=None, + **kwargs): + """Handle addition of a new domain.""" + if app.needs_setup(): + return + + audit.domain.set_domains() + + +def on_domain_removed(sender, domain_type, name, **kwargs): + """Handle removal of a domain.""" + if app.needs_setup(): + return + + audit.domain.set_domains() diff --git a/plinth/modules/email_server/audit/domain.py b/plinth/modules/email_server/audit/domain.py index bfdfb3714..d91762f66 100644 --- a/plinth/modules/email_server/audit/domain.py +++ b/plinth/modules/email_server/audit/domain.py @@ -1,317 +1,57 @@ """Configure email domains""" # SPDX-License-Identifier: AGPL-3.0-or-later -import io -import json -import os import pathlib import re -import select import subprocess -import sys -import time - -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ from plinth.actions import superuser_run -from plinth.errors import ActionError -from plinth.modules.config import get_domainname -from plinth.modules.email_server import interproc, postconf - -from . import models - -EXIT_VALIDATION = 40 - -managed_keys = ['_mailname', 'mydomain', 'myhostname', 'mydestination'] +from plinth.modules import config +from plinth.modules.email_server import postconf +from plinth.modules.names.components import DomainName -class ClientError(RuntimeError): - pass - - -def get(): - translation_table = [ - (check_postfix_domains, _('Postfix domain name config')), - ] - results = [] - with postconf.mutex.lock_all(): - for check, title in translation_table: - results.append(check(title)) - return results - - -def repair(): - superuser_run('email_server', ['domain', 'set_up']) - - -def repair_component(action_name): - allowed_actions = {'set_up': ['postfix']} - if action_name not in allowed_actions: - return - superuser_run('email_server', ['domain', action_name]) - return allowed_actions[action_name] - - -def action_set_up(): - with postconf.mutex.lock_all(): - fix_postfix_domains(check_postfix_domains()) - - -def check_postfix_domains(title=''): - diagnosis = models.MainCfDiagnosis(title, action='set_up') - domain = get_domainname() or 'localhost' - postconf_keys = (k for k in managed_keys if not k.startswith('_')) - conf = postconf.get_many_unsafe(postconf_keys, flag='-x') - - dest_set = set(postconf.parse_maps(conf['mydestination'])) - deletion_set = set() - - temp = _amend_mailname(domain) - if temp is not None: - diagnosis.error('Set /etc/mailname to %s', temp) - diagnosis.flag('_mailname', temp) - - # Amend $mydomain and conf['mydomain'] - temp = _amend_mydomain(conf['mydomain'], domain) - if temp is not None: - diagnosis.error('Set $mydomain to %s', temp) - diagnosis.flag('mydomain', temp) - deletion_set.add(conf['mydomain']) - conf['mydomain'] = temp - - # Amend $myhostname and conf['myhostname'] - temp = _amend_myhostname(conf['myhostname'], conf['mydomain']) - if temp is not None: - diagnosis.error('Set $myhostname to %s', temp) - diagnosis.flag('myhostname', temp) - deletion_set.add(conf['myhostname']) - conf['myhostname'] = temp - - # Delete old domain names - deletion_set.discard('localhost') - dest_set.difference_update(deletion_set) - - # Amend $mydestination - temp = _amend_mydestination(dest_set, conf['mydomain'], conf['myhostname'], - diagnosis.error) - if temp is not None: - diagnosis.flag('mydestination', temp) - elif len(deletion_set) > 0: - corrected_value = ', '.join(sorted(dest_set)) - diagnosis.error('Update $mydestination') - diagnosis.flag('mydestination', corrected_value) - - return diagnosis - - -def _amend_mailname(domain): - with open('/etc/mailname', 'r') as fd: - mailname = fd.readline().strip() - - # If mailname is not localhost, refresh it - if mailname != 'localhost': - temp = _change_to_domain_name(mailname, domain, False) - if temp != mailname: - return temp - - return None - - -def _amend_mydomain(conf_value, domain): - temp = _change_to_domain_name(conf_value, domain, False) - if temp != conf_value: - return temp - - return None - - -def _amend_myhostname(conf_value, mydomain): - if conf_value != mydomain: - if not conf_value.endswith('.' + mydomain): - return mydomain - - return None - - -def _amend_mydestination(dest_set, mydomain, myhostname, error): - addition_set = set() - if mydomain not in dest_set: - error('Value of $mydomain is not in $mydestination') - addition_set.add('$mydomain') - addition_set.add('$myhostname') - if myhostname not in dest_set: - error('Value of $myhostname is not in $mydestination') - addition_set.add('$mydomain') - addition_set.add('$myhostname') - if 'localhost' not in dest_set: - error('localhost is not in $mydestination') - addition_set.add('localhost') - - if addition_set: - addition_set.update(dest_set) - return ', '.join(sorted(addition_set)) - - return None - - -def _change_to_domain_name(value, domain, allow_old_fqdn): - # Detect invalid values - if not value or '.' not in value: - return domain - - if not allow_old_fqdn and value != domain: - return domain - else: - return value - - -def fix_postfix_domains(diagnosis): - diagnosis.apply_changes(_apply_domain_changes) - - -def _apply_domain_changes(conf_dict): - for key, value in conf_dict.items(): - if key.startswith('_'): - update = globals()['su_set' + key] - update(value) - - post = {k: v for (k, v) in conf_dict.items() if not k.startswith('_')} - postconf.set_many_unsafe(post) - - -def get_domain_config(): +def get_domains(): """Return the current domain configuration.""" - postconf_keys = [key for key in managed_keys if not key.startswith('_')] - config = postconf.get_many(postconf_keys) - config['_mailname'] = pathlib.Path('/etc/mailname').read_text().strip() - return config + conf = postconf.get_many(['mydomain', 'mydestination']) + domains = set(postconf.parse_maps(conf['mydestination'])) + defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'} + domains.difference_update(defaults) + return {'primary_domain': conf['mydomain'], 'all_domains': domains} -def set_keys(raw): - # Serialize the keys we know - config_dict = {k: v for (k, v) in raw.items() if k in managed_keys} - if not config_dict: - raise ClientError('To update a key, specify a new value') +def set_domains(primary_domain=None): + """Set the primary domain and all the domains for postfix. """ + all_domains = DomainName.list_names() + if not primary_domain: + primary_domain = get_domains()['primary_domain'] + if primary_domain not in all_domains: + primary_domain = config.get_domainname() or list(all_domains)[0] - ipc = b'%s\n' % json.dumps(config_dict).encode('utf8') - if len(ipc) > 4096: - raise ClientError('POST data exceeds max line length') - - try: - superuser_run('email_server', ['domain', 'set_keys'], input=ipc) - except ActionError as e: - stdout = e.args[1] - if not stdout.startswith('ClientError:'): - raise RuntimeError('Action script failure') from e - else: - raise ValidationError(stdout) from e + superuser_run( + 'email_server', + ['domain', 'set_domains', primary_domain, ','.join(all_domains)]) -def action_set_keys(): - try: - _action_set_keys() - except ClientError as e: - print('ClientError:', end=' ') - print(e.args[0]) - sys.exit(EXIT_VALIDATION) +def action_set_domains(primary_domain, all_domains): + """Set the primary domain and all the domains for postfix. """ + all_domains = [_clean_domain(domain) for domain in all_domains.split(',')] + primary_domain = _clean_domain(primary_domain) + + defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'} + all_domains = set(all_domains).union(defaults) + conf = { + 'myhostname': primary_domain, + 'mydomain': primary_domain, + 'mydestination': ', '.join(all_domains) + } + postconf.set_many(conf) + pathlib.Path('/etc/mailname').write_text(primary_domain + '\n') + subprocess.run(['systemctl', 'try-reload-or-restart', 'postfix'], + check=True) -def _action_set_keys(): - line = _stdin_readline() - if not line.startswith('{') or not line.endswith('}\n'): - raise ClientError('Bad stdin data') - - clean_dict = {} - # Input validation - for key, value in json.loads(line).items(): - if key not in managed_keys: - raise ClientError('Key not allowed: %r' % key) - if not isinstance(value, str): - raise ClientError('Bad value type from key: %r' % key) - clean_function = globals()['clean_' + key.lstrip('_')] - clean_dict[key] = clean_function(value) - - # Apply changes (postconf) - postconf_dict = dict( - filter(lambda kv: not kv[0].startswith('_'), clean_dict.items())) - postconf.set_many(postconf_dict) - - # Apply changes (special) - for key, value in clean_dict.items(): - if key.startswith('_'): - set_function = globals()['su_set' + key] - set_function(value) - - # Important: reload postfix after acquiring lock - with postconf.mutex.lock_all(): - # systemctl reload postfix - args = ['systemctl', 'reload', 'postfix'] - completed = subprocess.run(args, capture_output=True, check=False) - if completed.returncode != 0: - interproc.log_subprocess(completed) - raise OSError('Could not reload postfix') - - -def clean_mailname(mailname): - mailname = mailname.lower().strip() - if not re.match('^[a-z0-9-\\.]+$', mailname): - raise ClientError('Invalid character in host/domain/mail name') - # XXX: need more verification - return mailname - - -def clean_mydomain(raw): - return clean_mailname(raw) - - -def clean_myhostname(raw): - return clean_mailname(raw) - - -def clean_mydestination(raw): - ascii_code = (ord(c) for c in raw) - valid = all(32 <= a <= 126 for a in ascii_code) - if not valid: - raise ClientError('Bad input for $mydestination') - else: - return raw - - -def su_set_mailname(cleaned): - with interproc.atomically_rewrite('/etc/mailname') as fd: - fd.write(cleaned) - fd.write('\n') - - -def _stdin_readline(): - membuf = io.BytesIO() - bytes_saved = 0 - fd = sys.stdin.buffer - time_started = time.monotonic() - - # Reading stdin with timeout - # https://stackoverflow.com/a/21429655 - os.set_blocking(fd.fileno(), False) - - while bytes_saved < 4096: - rlist, wlist, xlist = select.select([fd], [], [], 1.0) - if fd in rlist: - data = os.read(fd.fileno(), 4096) - membuf.write(data) - bytes_saved += len(data) - if len(data) == 0 or b'\n' in data: # end of file or line - break - if time.monotonic() - time_started > 5: - raise TimeoutError() - - # Read a line - membuf.seek(0) - line = membuf.readline() - if not line.endswith(b'\n'): - raise ClientError('Line was too long') - - try: - return line.decode('utf8') - except ValueError as e: - raise ClientError('UTF-8 decode failed') from e +def _clean_domain(domain): + domain = domain.lower().strip() + assert re.match('^[a-z0-9-\\.]+$', domain) + return domain diff --git a/plinth/modules/email_server/forms.py b/plinth/modules/email_server/forms.py index 854f47685..53fac7788 100644 --- a/plinth/modules/email_server/forms.py +++ b/plinth/modules/email_server/forms.py @@ -9,6 +9,8 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.utils.translation import gettext_lazy as _ +from plinth.modules.names.components import DomainName + from . import aliases as aliases_module domain_validator = RegexValidator(r'^[A-Za-z0-9-\.]+$', @@ -25,27 +27,20 @@ class EmailServerForm(forms.Form): super().__init__(*args, **kwargs) -class DomainsForm(forms.Form): - _mailname = forms.CharField(required=True, strip=True, - validators=[domain_validator]) - mydomain = forms.CharField(required=True, strip=True, - validators=[domain_validator]) - myhostname = forms.CharField(required=True, strip=True, - validators=[domain_validator]) - mydestination = forms.CharField(required=True, strip=True, - validators=[destination_validator]) +def _get_domain_choices(): + """Double domain entries for inclusion in the choice field.""" + return ((domain.name, domain.name) for domain in DomainName.list()) - def clean(self): - """Convert values to lower case.""" - data = self.cleaned_data - if '_mailname' in data: - data['_mailname'] = data['_mailname'].lower() - if 'myhostname' in data: - data['myhostname'] = data['myhostname'].lower() - - if 'mydestination' in data: - data['mydestination'] = data['mydestination'].lower() +class DomainForm(forms.Form): + primary_domain = forms.ChoiceField( + choices=_get_domain_choices, + label=_('Primary domain'), + help_text=_( + 'Mails are received for all domains configured in the system. ' + 'Among these, select the most important one.'), + required=True, + ) class AliasCreateForm(forms.Form): diff --git a/plinth/modules/email_server/views.py b/plinth/modules/email_server/views.py index 98aa33b89..daa799c01 100644 --- a/plinth/modules/email_server/views.py +++ b/plinth/modules/email_server/views.py @@ -45,7 +45,7 @@ class EmailServerView(ExceptionsMixin, AppView): """Server configuration page""" app_id = 'email_server' template_name = 'email_server.html' - audit_modules = ('domain', 'tls', 'rcube') + audit_modules = ('tls', 'rcube') def get_context_data(self, *args, **kwargs): dlist = [] @@ -183,14 +183,15 @@ class AliasView(FormView): class DomainsView(FormView): """View to allow editing domain related settings.""" template_name = 'form.html' - form_class = forms.DomainsForm + form_class = forms.DomainForm prefix = 'domain' success_url = reverse_lazy('email_server:domains') def get_initial(self): """Return the initial values to populate in the form.""" initial = super().get_initial() - initial.update(audit.domain.get_domain_config()) + domains = audit.domain.get_domains() + initial['primary_domain'] = domains['primary_domain'] return initial def get_context_data(self, **kwargs): @@ -203,18 +204,13 @@ class DomainsView(FormView): """Update the settings for changed domain values.""" old_data = form.initial new_data = form.cleaned_data - config = {} - for key in form.initial: - if old_data[key] != new_data[key]: - config[key] = new_data[key] - - if config: + if old_data['primary_domain'] != new_data['primary_domain']: try: - audit.domain.set_keys(config) + audit.domain.set_domains(new_data['primary_domain']) messages.success(self.request, _('Configuration updated')) except Exception: messages.success(self.request, - _('Error updating configuration')) + _('An error occurred during configuration.')) else: messages.info(self.request, _('Setting unchanged'))