email: Implement view for setting up domains

This commit is contained in:
fliu 2021-07-24 00:31:21 +00:00 committed by Sunil Mohan Adapa
parent 502cfa4953
commit a234407b97
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
4 changed files with 245 additions and 61 deletions

View File

@ -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

View File

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

View File

@ -7,40 +7,29 @@
{{ block.super }}
<div class="form-group">
<strong>/etc/mailname</strong>
<span> = {{ mailname }}</span>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ mailname_form|bootstrap }}
<input type="hidden" name="form" value="MailnameForm">
<input class="btn btn-primary" type="submit" name="btn_mailname"
value="{% trans 'Change' %}">
</form>
</div>
<form action="{{ request.path }}" method="post">
<div class="form-group">
<strong>$mydomain</strong>
<span> = {{ mydomain }}</span>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ mydomain_form|bootstrap }}
<input type="hidden" name="form" value="MydomainForm">
<input class="btn btn-primary" type="submit" name="btn_mydomain"
value="{% trans 'Change' %}">
</form>
</div>
{% for data in fields %}
<div class="form-group">
<div class="form-text">
<strong>{{ data.name }}</strong> = {{ data.value }}
</div> <!-- end form-text -->
<div class="row">
<label for="text_{{ data.key }}" class="col-sm-2 col-form-label">
{% trans "New value" %}
</label>
<div class="col-sm-10">
<input type="text" class="form-control" id="text_{{ data.key }}"
name="{{ data.key }}" value="{{ data.new_value }}">
</div>
</div> <!-- end row -->
</div> <!-- end form-group -->
{% endfor %}
{% csrf_token %}
<input class="btn btn-primary" type="submit" name="btn_update"
value="{% trans 'Update' %}">
</form>
<div class="form-group">
<strong>$mydestination</strong>
<span> = {{ mydestination }}</span>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ mydestination_form|bootstrap }}
<input type="hidden" name="form" value="MydestinationForm">
<input class="btn btn-primary" type="submit" name="btn_mydestination"
value="{% trans 'Change' %}">
</form>
</div>
{% endblock %}

View File

@ -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())