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'))