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 <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2021-12-01 18:07:57 -08:00 committed by James Valleroy
parent b12a07229c
commit ae882fea70
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
4 changed files with 85 additions and 331 deletions

View File

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

View File

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

View File

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

View File

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