diff --git a/plinth/modules/email_server/audit/domain.py b/plinth/modules/email_server/audit/domain.py index 1f831b433..cd3296513 100644 --- a/plinth/modules/email_server/audit/domain.py +++ b/plinth/modules/email_server/audit/domain.py @@ -1,7 +1,32 @@ -"""The domain audit resource""" +"""Configure email domains""" # SPDX-License-Identifier: AGPL-3.0-or-later +import contextlib +import io +import json +import os +import re +import select +import sys +import time +import uuid + +from types import SimpleNamespace + +from django.core.exceptions import ValidationError +from plinth.errors import ActionError +from plinth.actions import superuser_run + from . import models +from plinth.modules.email_server import postconf + +EXIT_VALIDATION = 40 + +managed_keys = ['_mailname', 'mydomain', 'myhostname', 'mydestination'] + + +class ClientError(RuntimeError): + pass def get(): @@ -12,3 +37,167 @@ def get(): def repair(): # Stub raise RuntimeError() + + +def get_domain_config(): + fields = [] + + # Special keys + mailname = SimpleNamespace(key='_mailname', name='/etc/mailname') + with open('/etc/mailname', 'r') as fd: + mailname.value = fd.readline().strip() + fields.append(mailname) + + # Postconf keys + postconf_keys = [k for k in managed_keys if not k.startswith('_')] + result_dict = postconf.get_many(postconf_keys) + for key, value in result_dict.items(): + field = SimpleNamespace(key=key, value=value, name='$' + key) + fields.append(field) + + return fields + + +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') + + 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', ['-i', '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 + + +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_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) + + +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 _atomically_rewrite('/etc/mailname', 'x') 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 + + +@contextlib.contextmanager +def _atomically_rewrite(filepath, mode): + successful = False + tmp = '%s.%s.plinth-tmp' % (filepath, uuid.uuid4().hex) + fd = open(tmp, mode) + + try: + # Let client write to a temporary file + yield fd + successful = True + finally: + fd.close() + + try: + if successful: + # Invoke rename(2) to atomically replace the original + os.rename(tmp, filepath) + finally: + # Delete temp file + try: + os.unlink(tmp) + except FileNotFoundError: + pass diff --git a/plinth/modules/email_server/forms.py b/plinth/modules/email_server/forms.py index 1c121f862..9567d1be1 100644 --- a/plinth/modules/email_server/forms.py +++ b/plinth/modules/email_server/forms.py @@ -13,15 +13,3 @@ class EmailServerForm(forms.Form): class AliasCreationForm(forms.Form): email_name = forms.CharField(label=_('New alias (without @domain)'), max_length=50) - - -class MailnameForm(forms.Form): - mailname = forms.CharField(label=_('New value'), max_length=256) - - -class MydomainForm(forms.Form): - mydomain = forms.CharField(label=_('New value'), max_length=256) - - -class MydestinationForm(forms.Form): - mydestination = forms.CharField(label=_('New value'), max_length=4000) diff --git a/plinth/modules/email_server/templates/domains.html b/plinth/modules/email_server/templates/domains.html index 4f5af63b3..bab811f11 100644 --- a/plinth/modules/email_server/templates/domains.html +++ b/plinth/modules/email_server/templates/domains.html @@ -7,40 +7,29 @@ {{ block.super }} -
- /etc/mailname - = {{ mailname }} -
- {% csrf_token %} - {{ mailname_form|bootstrap }} - - -
-
+
-
- $mydomain - = {{ mydomain }} - - {% csrf_token %} - {{ mydomain_form|bootstrap }} - - - -
+ {% for data in fields %} +
+
+ {{ data.name }} = {{ data.value }} +
+
+ +
+ +
+
+
+ {% endfor %} + + {% csrf_token %} + + -
- $mydestination - = {{ mydestination }} -
- {% csrf_token %} - {{ mydestination_form|bootstrap }} - - -
-
{% endblock %} diff --git a/plinth/modules/email_server/views.py b/plinth/modules/email_server/views.py index 4a21d02b6..1b0964bdb 100644 --- a/plinth/modules/email_server/views.py +++ b/plinth/modules/email_server/views.py @@ -69,6 +69,11 @@ class TabMixin(View): context['error'] = validation_error return self.render_to_response(context, status=status) + def render_exception(self, exception, status=500): + context = self.get_context_data() + context['error'] = [str(exception)] + return self.render_to_response(context, status=status) + def find_button(self, post): key_filter = (k for k in post.keys() if k.startswith('btn_')) lst = list(itertools.islice(key_filter, 2)) @@ -109,9 +114,7 @@ class MyMailView(TabMixin, TemplateView): except ValidationError as validation_error: return self.render_validation_error(validation_error) except RuntimeError as runtime_error: - context = self.get_context_data() - context['error'] = [str(runtime_error)] - return self.render_to_response(context, status=500) + return self.render_exception(runtime_error) def _post(self, request): if 'btn_mkhome' not in request.POST: @@ -197,8 +200,10 @@ class AliasView(TabMixin, TemplateView): def post(self, request): try: return self._post(request) - except ValidationError as e: - return self.render_validation_error(e) + except ValidationError as validation_error: + return self.render_validation_error(validation_error) + except Exception as exception: + return self.render_exception(exception) def _post(self, request): form = self.find_form(request.POST) @@ -241,17 +246,30 @@ class TLSView(TabMixin, TemplateView): class DomainView(TabMixin, TemplateView): template_name = 'domains.html' - form_classes = (forms.MailnameForm, forms.MydomainForm, - forms.MydestinationForm) def get_context_data(self, *args, **kwargs): context = super().get_context_data(*args, **kwargs) - context['mailname'] = 'placeholder' - context['mydomain'] = 'placeholder.exmaple.com' - context['mydestination'] = '$mydomain, placeholder.example' - - context['mailname_form'] = forms.MailnameForm() - context['mydomain_form'] = forms.MydomainForm() - context['mydestination_form'] = forms.MydestinationForm() - + fields = audit.domain.get_domain_config() + # If having post data, display the posted values + for field in fields: + field.new_value = self.request.POST.get(field.key, '') + context['fields'] = fields return context + + def post(self, request): + try: + return self._post(request) + except ValidationError as validation_error: + return self.render_validation_error(validation_error) + except RuntimeError as runtime_error: + return self.render_exception(runtime_error) + + def _post(self, request): + changed = {} + # Skip blank fields + for key, value in request.POST.items(): + value = value.strip() + if value: + changed[key] = value + audit.domain.set_keys(changed) + return self.render_to_response(self.get_context_data())