email: Implement alias management

- Separate alias database from system
- Block mail to system users, without backscatter
- Alias management UI for non-admin users
- Enabling/Disabling aliases (mails to /dev/null)

Misc. changes

- Daemon management
- Backup information
- Postconf diagnostics interface
This commit is contained in:
fliu 2021-07-14 00:27:07 +00:00 committed by Sunil Mohan Adapa
parent a9ac51eb7b
commit 4375828703
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
13 changed files with 570 additions and 30 deletions

View File

@ -17,9 +17,10 @@ from . import manifest
version = 1
managed_packages = ['postfix', 'dovecot-pop3d', 'dovecot-imapd',
'dovecot-lmtpd', 'dovecot-ldap', 'dovecot-managesieved',
'rspamd']
managed_services = ['postfix', 'dovecot']
'dovecot-ldap', 'dovecot-lmtpd', 'dovecot-managesieved',
'rspamd', 'clamav', 'clamav-daemon']
managed_services = ['postfix', 'dovecot', 'rspamd', 'redis', 'clamav-daemon',
'clamav-freshclam']
app = None
@ -62,7 +63,7 @@ class EmailServerApp(plinth.app.App):
name=info.name,
short_description=info.short_description,
icon='roundcube',
configure_url=reverse_lazy('email_server'),
url=reverse_lazy('email_server:my_aliases'),
clients=manifest.clients,
login_required=True
)
@ -85,6 +86,9 @@ class EmailServerApp(plinth.app.App):
listen_ports=dovecot_ports)
self.add(postfixd)
self.add(dovecotd)
for name in ('rspamd', 'redis', 'clamav-daemon', 'clamav-freshclam'):
daemon = plinth.daemon.Daemon('daemon-' + name, name)
self.add(daemon)
# Ports
firewall = Firewall('firewall-email', info.name,

View File

@ -0,0 +1,151 @@
"""Manages email aliases"""
# SPDX-License-Identifier: AGPL-3.0-or-later
import contextlib
import dbm
import logging
import os
import pwd
import sqlite3
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
from plinth.modules.email_server import lock
from . import models
map_db_schema_script = """
PRAGMA journal_mode=WAL;
BEGIN;
CREATE TABLE IF NOT EXISTS Alias (
email_name TEXT NOT NULL,
uid_number INTEGER NOT NULL,
status INTEGER NOT NULL,
PRIMARY KEY (email_name)
);
COMMIT;
"""
mailsrv_dir = '/var/lib/plinth/mailsrv'
hash_db_path = mailsrv_dir + '/aliases'
sqlite_db_path = mailsrv_dir + '/aliases.sqlite3'
alias_sync_mutex = lock.Mutex('alias-sync')
logger = logging.getLogger(__name__)
@contextlib.contextmanager
def db_cursor():
# Turn ON autocommit mode
con = sqlite3.connect(sqlite_db_path, isolation_level=None)
con.row_factory = sqlite3.Row
try:
cur = con.cursor()
yield cur
finally:
con.close()
def get(uid_number):
s = 'SELECT * FROM Alias WHERE uid_number=?'
with db_cursor() as cur:
rows = cur.execute(s, (uid_number,))
result = [models.Alias(**r) for r in rows]
return result
def put(uid_number, email_name):
s = """INSERT INTO Alias(email_name, uid_number, status)
SELECT ?,?,? WHERE NOT EXISTS(
SELECT 1 FROM Alias WHERE email_name=?
)"""
email_name = models.sanitize_email_name(email_name)
# email_name cannot be the same as a user name
try:
pwd.getpwnam(email_name)
raise ValidationError(_('The alias was taken'))
except KeyError:
pass
with db_cursor() as cur:
cur.execute(s, (email_name, uid_number, 1, email_name))
if cur.rowcount == 0:
raise ValidationError(_('The alias was taken'))
schedule_hash_update()
def delete(uid_number, alias_list):
s = 'DELETE FROM Alias WHERE uid_number=? AND email_name=?'
for i in range(len(alias_list)):
alias_list[i] = models.sanitize_email_name(alias_list[i])
parameter_seq = ((uid_number, a) for a in alias_list)
with db_cursor() as cur:
cur.execute('BEGIN')
cur.executemany(s, parameter_seq)
cur.execute('COMMIT')
schedule_hash_update()
def set_enabled(uid_number, alias_list):
return _set_status(uid_number, alias_list, 1)
def set_disabled(uid_number, alias_list):
return _set_status(uid_number, alias_list, 0)
def _set_status(uid_number, alias_list, status):
s = 'UPDATE Alias SET status=? WHERE uid_number=? AND email_name=?'
for i in range(len(alias_list)):
alias_list[i] = models.sanitize_email_name(alias_list[i])
parameter_seq = ((status, uid_number, a) for a in alias_list)
with db_cursor() as cur:
cur.execute('BEGIN')
cur.executemany(s, parameter_seq)
cur.execute('COMMIT')
schedule_hash_update()
def schedule_hash_update():
tmp = hash_db_path + '-tmp'
with alias_sync_mutex.lock_all(), db_cursor() as cur:
all_aliases = cur.execute('SELECT * FROM Alias')
# Delete the temp file if exists
if os.path.exists(tmp):
os.unlink(tmp)
# Create new alias db at temp path
db = dbm.ndbm.open(tmp, 'c')
try:
for row in all_aliases:
alias = models.Alias(**row)
key = alias.email_name.encode('ascii') + b'\0'
if alias.enabled:
value = str(alias.uid_number).encode('ascii') + b'\0'
else:
value = b'/dev/null\0'
db[key] = value
finally:
db.close()
# Atomically replace old alias db, rename(2)
os.rename(tmp + '.db', hash_db_path + '.db')
def first_setup():
_create_db_schema_if_not_exists()
schedule_hash_update()
def _create_db_schema_if_not_exists():
# Create folder
if not os.path.isdir(mailsrv_dir):
os.mkdir(mailsrv_dir)
# Create schema if not exists
with db_cursor() as cur:
cur.executescript(map_db_schema_script)

View File

@ -0,0 +1,31 @@
import re
from dataclasses import dataclass, field, InitVar
from django.core.exceptions import ValidationError
from django.utils.translation import ugettext_lazy as _
email_positive_pattern = re.compile('^[a-zA-Z0-9-_\\.]+')
def sanitize_email_name(email_name):
email_name = email_name.strip().lower()
if len(email_name) < 2:
raise ValidationError(_('Must be at least 2 characters long'))
if not re.match('^[a-z0-9-_\\.]+$', email_name):
raise ValidationError(_('Contains illegal characters'))
if not re.match('^[a-z0-9].*[a-z0-9]$', email_name):
raise ValidationError(_('Must start and end with a-z or 0-9'))
if re.match('^[0-9]+$', email_name):
raise ValidationError(_('Cannot be a number'))
return email_name
@dataclass
class Alias:
uid_number: int
email_name: str
enabled: bool = field(init=False)
status: InitVar[int]
def __post_init__(self, status):
self.enabled = (status != 0)

View File

@ -1,4 +1,5 @@
"""Audit of LDAP and mail submission settings"""
"""Provides the diagnosis of SASL, mail submission, and user database lookup
configurations"""
# SPDX-License-Identifier: AGPL-3.0-or-later
import logging
@ -6,6 +7,7 @@ import logging
from plinth import actions
import plinth.modules.email_server.postconf as postconf
import plinth.modules.email_server.aliases as aliases
from . import models
default_config = {
@ -40,6 +42,12 @@ default_smtps_options = {
'smtpd_relay_restrictions': 'permit_sasl_authenticated,reject'
}
MAILSRV_DIR = '/var/lib/plinth/mailsrv'
ETC_ALIASES = 'hash:/etc/aliases'
BEFORE_ALIASES = 'ldap:/etc/postfix/freedombox-username-to-uid-number.cf'
AFTER_ALIASES = 'hash:' + aliases.hash_db_path
logger = logging.getLogger(__name__)
@ -50,19 +58,27 @@ def get():
GET /audit/ldap
"""
results = []
with postconf.postconf_mutex.lock_all():
with postconf.mutex.lock_all():
results.append(check_sasl())
results.append(check_alias_maps())
results.append(check_local_recipient_maps())
return results
def repair():
"""Tries to repair LDAP and mail submission settings
"""Tries to repair SASL, mail submission, and user lookup settings
Recommended endpoint name:
POST /audit/ldap/repair
"""
actions.superuser_run('email_server', ['-i', 'ldap', 'set_sasl'])
actions.superuser_run('email_server', ['-i', 'ldap', 'set_submission'])
aliases.first_setup()
actions.superuser_run('email_server', ['-i', 'ldap', 'set_up'])
def action_set_up():
action_set_sasl()
action_set_submission()
action_set_ulookup()
def check_sasl():
@ -79,13 +95,13 @@ def fix_sasl(diagnosis):
def action_set_sasl():
"""Called by email_server ipc ldap set_sasl"""
with postconf.postconf_mutex.lock_all():
"""Handles email_server -i ldap set_sasl"""
with postconf.mutex.lock_all():
fix_sasl(check_sasl())
def action_set_submission():
"""Called by email_server ipc set_submission"""
"""Handles email_server -i ldap set_submission"""
logger.info('Set postfix service: %r', default_submission_options)
postconf.set_master_cf_options(service_flags=submission_flags,
options=default_submission_options)
@ -93,3 +109,96 @@ def action_set_submission():
logger.info('Set postfix service: %r', default_smtps_options)
postconf.set_master_cf_options(service_flags=smtps_flags,
options=default_smtps_options)
def check_alias_maps():
"""Check the ability to mail to usernames and user aliases"""
diagnosis = models.MainCfDiagnosis('Postfix alias maps')
analysis = models.AliasMapsAnalysis()
analysis.parsed = postconf.parse_maps_by_key_unsafe('alias_maps')
analysis.isystem = list_find(analysis.parsed, ETC_ALIASES)
analysis.ibefore = list_find(analysis.parsed, BEFORE_ALIASES)
analysis.iafter = list_find(analysis.parsed, AFTER_ALIASES)
if analysis.ibefore == -1 or analysis.iafter == -1:
diagnosis.flag_once('alias_maps', user=analysis)
diagnosis.critical('Required maps not in list')
if analysis.ibefore > analysis.iafter:
diagnosis.flag_once('alias_maps', user=analysis)
diagnosis.critical('Insecure map order')
return diagnosis
def fix_alias_maps(diagnosis):
unresolved_issues = list(diagnosis.unresolved_issues())
if 'alias_maps' in unresolved_issues:
analysis = diagnosis.user['alias_maps']
# Delete *all* references to BEFORE_ALIASES and AFTER_ALIASES
for i in range(len(analysis.parsed)):
if analysis.parsed[i] in (BEFORE_ALIASES, AFTER_ALIASES):
analysis.parsed[i] = ''
# Does hash:/etc/aliases exist?
if analysis.isystem >= 0:
# Put the maps around hash:/etc/aliases
val = '%s %s %s' % (BEFORE_ALIASES, ETC_ALIASES, AFTER_ALIASES)
analysis.parsed[analysis.isystem] = val
else:
# To the end
analysis.parsed.append(BEFORE_ALIASES)
analysis.parsed.append(AFTER_ALIASES)
# List -> string
fixed = ' '.join(filter(None, analysis.parsed))
diagnosis.advice['alias_maps'] = fixed
diagnosis.assert_resolved()
logging.info('Setting postfix config: %r', diagnosis.advice)
postconf.set_many_unsafe(diagnosis.advice)
def check_local_recipient_maps():
diagnosis = models.MainCfDiagnosis('Postfix local recipient maps')
lrcpt_maps = postconf.parse_maps_by_key_unsafe('local_recipient_maps')
list_modified = False
# Block mails to system users
# local_recipient_maps must not contain proxy:unix:passwd.byname
ipasswd = list_find(lrcpt_maps, 'proxy:unix:passwd.byname')
if ipasswd >= 0:
diagnosis.critical('Mail to system users (/etc/passwd) possible')
# Propose a fix
lrcpt_maps[ipasswd] = ''
list_modified = True
if list_modified:
fix = ' '.join(filter(None, lrcpt_maps))
diagnosis.flag('local_recipient_maps', corrected_value=fix)
return diagnosis
def fix_local_recipient_maps(diagnosis):
diagnosis.assert_resolved()
logging.info('Setting postfix config: %r', diagnosis.advice)
postconf.set_many_unsafe(diagnosis.advice)
def action_set_ulookup():
"""Handles email_server -i ldap set_ulookup"""
with postconf.mutex.lock_all():
fix_alias_maps(check_alias_maps())
fix_local_recipient_maps(check_local_recipient_maps())
def list_find(lst, element, start=None, end=None):
if start is None:
start = 0
if end is None:
end = len(lst)
if start < 0 or end < 0:
return -1
try:
return lst.index(element, start, end)
except ValueError:
return -1

View File

@ -1,6 +1,8 @@
"""Audit models"""
# SPDX-License-Identifier: AGPL-3.0-or-later
import dataclasses
import logging
import typing
logger = logging.getLogger(__name__)
@ -57,8 +59,15 @@ class MainCfDiagnosis(Diagnosis):
self.user = {}
def flag(self, key, corrected_value=None, user=None):
self.advice[key] = corrected_value
self.user[key] = user
if key in self.advice:
raise ValueError('Key has been flagged')
else:
self.advice[key] = corrected_value
self.user[key] = user
def flag_once(self, key, **kwargs):
if key not in self.advice:
self.flag(key, **kwargs)
def unresolved_issues(self):
"""Returns an interator of dictionary keys"""
@ -79,3 +88,14 @@ class MainCfDiagnosis(Diagnosis):
unresolved issue"""
if None in self.advice.values():
raise UnresolvedIssueError('Assertion failed')
@dataclasses.dataclass(init=False)
class AliasMapsAnalysis:
parsed = typing.List[str]
ibefore = int
isystem = int
iafter = int
def __init__(self):
pass

View File

@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
def get():
results = []
with postconf.postconf_mutex.lock_all():
with postconf.mutex.lock_all():
results.append(check_filter())
return results
@ -48,5 +48,5 @@ def fix_filter(diagnosis):
def action_set_filter():
with postconf.postconf_mutex.lock_all():
with postconf.mutex.lock_all():
fix_filter(check_filter())

View File

@ -0,0 +1,9 @@
# This file is managed by FreedomBox
# Map user name to UID number
bind = no
server_host = ldap://127.0.0.1
search_base = dc=thisbox
query_filter = (&(objectClass=posixAccount)(uid=%s))
result_attribute = uidNumber

View File

@ -1,6 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django import forms
from django.utils.translation import ugettext_lazy as _
class EmailServerForm(forms.Form):
pass
domain = forms.CharField(label=_('domain'), max_length=256)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
class AliasCreationForm(forms.Form):
email_name = forms.CharField(label=_('New alias (without @domain)'),
max_length=50)

View File

@ -50,3 +50,14 @@ clients = [{
'url': store_url('google-play', 'eu.faircode.email')
}]
}]
backup = {
'data': {
'directories': [
'/var/lib/plinth/mailsrv',
'/etc/postfix',
'/etc/dovecot'
]
},
'services': ['postfix', 'dovecot']
}

View File

@ -6,7 +6,7 @@ import re
import subprocess
from .lock import Mutex
postconf_mutex = Mutex('email-postconf')
mutex = Mutex('email-postconf')
@dataclasses.dataclass
@ -31,7 +31,7 @@ def get_many(key_list):
Return a key-value map"""
for key in key_list:
validate_key(key)
with postconf_mutex.lock_all():
with mutex.lock_all():
return get_many_unsafe(key_list)
@ -48,7 +48,7 @@ def set_many(kv_map):
validate_key(key)
validate_value(value)
with postconf_mutex.lock_all():
with mutex.lock_all():
set_many_unsafe(kv_map)
@ -57,7 +57,7 @@ def set_many_unsafe(kv_map):
set_unsafe(key, value)
def set_master_cf_options(service_flags, options):
def set_master_cf_options(service_flags, options={}):
"""Acquire resource lock. Set master.cf service options"""
if not isinstance(service_flags, ServiceFlags):
raise TypeError('service_flags')
@ -68,7 +68,7 @@ def set_master_cf_options(service_flags, options):
service_slash_type = service_flags.service + '/' + service_flags.type
flag_string = service_flags.serialize()
with postconf_mutex.lock_all():
with mutex.lock_all():
# /sbin/postconf -M "service/type=flag_string"
set_unsafe(service_slash_type, flag_string, '-M')
for short_key, value in options.items():
@ -93,6 +93,23 @@ def set_unsafe(key, value, flag=''):
_run(['/sbin/postconf', '{}={}'.format(key, value)])
def parse_maps(raw_value):
if '{' in raw_value or '}' in raw_value:
raise ValueError('Unsupported map list format')
value_list = []
for segment in raw_value.split(','):
for sub_segment in segment.strip().split(' '):
sub_segment = sub_segment.strip()
if sub_segment:
value_list.append(sub_segment)
return value_list
def parse_maps_by_key_unsafe(key):
return parse_maps(get_unsafe(key))
def _run(args):
"""Run process. Capture and return standard output as a string. Raise a
RuntimeError on non-zero exit codes"""

View File

@ -0,0 +1,52 @@
{% extends "app.html" %}
{% load bootstrap %}
{% load i18n %}
{% block configuration %}
{{ tabs|safe }}
<h3>{% trans "Alias Management" %}</h3>
{% if error %}
<div class="alert alert-danger" role="alert">
<p>
{% trans "There was a problem with your request. Please try again." %}
</p>
{% for message in error %}
<p>{{ message }}</p>
{% endfor %}
</div>
{% endif %}
{% if no_alias %}
<p>{% trans "You have no email aliases." %}</p>
{% else %}
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ alias_boxes|safe }}
<input type="hidden" name="form" value="Checkboxes">
<input class="btn btn-secondary" type="submit" name="btn_disable"
value="{% trans 'Disable selected' %}">
<input class="btn btn-secondary" type="submit" name="btn_enable"
value="{% trans 'Enable selected' %}">
<input class="btn btn-danger" type="submit" name="btn_delete"
value="{% trans 'Delete selected' %}">
</form>
{% endif %}
<h4>{% trans "Create a new email alias" %}</h4>
<form action="{{ request.path }}" method="post">
{% csrf_token %}
{{ form|bootstrap }}
<input type="hidden" name="form" value="AliasCreationForm">
<input class="btn btn-primary" type="submit" name="btn_add"
value="{% trans 'Add' %}">
</form>
{% endblock %}

View File

@ -1,8 +1,11 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from django.urls import path
from plinth.utils import non_admin_view
from . import views
urlpatterns = [
path('apps/email_server/', views.EmailServerView.as_view(), name='index')
path('apps/email_server/', views.EmailServerView.as_view(), name='index'),
path('apps/email_server/my_aliases',
non_admin_view(views.AliasView.as_view()), name='my_aliases')
]

View File

@ -1,18 +1,23 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
import io
import itertools
import pwd
import plinth.views
from django.shortcuts import render
from django.core.exceptions import ValidationError
from django.utils.html import escape
from django.utils.translation import ugettext_lazy as _
from django.views.generic.base import TemplateView
from . import forms
from . import aliases
tabs = [
('', 'Home'),
('alias', 'Alias'),
('relay', 'Relay'),
('security', 'Security')
('', _('Home')),
('alias', _('Alias')),
('relay', _('Relay')),
('security', _('Security'))
]
@ -34,6 +39,126 @@ class EmailServerView(plinth.views.AppView):
return context
class AliasView(TemplateView):
class Checkboxes:
def __init__(self, post=None, initial=None):
self.models = initial
self.post = post
self.cleaned_data = {}
def render(self):
if self.models is None:
raise RuntimeError('Uninitialized form')
sb = io.StringIO()
enabled = [a.email_name for a in self.models if a.enabled]
disabled = [a.email_name for a in self.models if not a.enabled]
if len(enabled) > 0:
sb.write('<fieldset>')
sb.write('<legend>%s</legend>' % escape(_('Enabled')))
self._render_boxes(enabled, 'enabled', sb)
sb.write('</fieldset>')
if len(disabled) > 0:
sb.write('<fieldset>')
sb.write('<legend>%s</legend>' % escape(_('Disabled')))
self._render_boxes(disabled, 'disabled', sb)
sb.write('</fieldset>')
return sb.getvalue()
@staticmethod
def _render_boxes(email_names, suffix, sb):
for i, email_name in enumerate(email_names):
input_id = 'cb_alias_%s_%d' % (suffix, i)
value = escape(email_name)
sb.write('<div>')
sb.write('<input type="checkbox" name="alias" ')
sb.write('id="%s" value="%s">' % (input_id, value))
sb.write('<label for="%s">%s</label>' % (input_id, value))
sb.write('</div>')
def is_valid(self):
lst = list(filter(None, self.post.getlist('alias')))
if not lst:
return False
else:
self.cleaned_data['alias'] = lst
return True
template_name = 'alias.html'
form_classes = (forms.AliasCreationForm, Checkboxes)
def get_context_data(self, *args, **kwargs):
context = super().get_context_data(*args, **kwargs)
context['form'] = forms.AliasCreationForm()
uid = pwd.getpwnam(self.request.user.username).pw_uid
models = aliases.get(uid)
if len(models) > 0:
form = AliasView.Checkboxes(initial=models)
context['alias_boxes'] = form.render()
else:
context['no_alias'] = True
return context
def find_form(self, post):
form_name = post.get('form')
for cls in self.form_classes:
if cls.__name__ == form_name:
return cls(post)
raise ValidationError('Form was unspecified')
def find_button(self, post):
key_filter = (k for k in post.keys() if k.startswith('btn_'))
lst = list(itertools.islice(key_filter, 2))
if len(lst) != 1:
raise ValidationError('Bad post data')
if not isinstance(lst[0], str):
raise ValidationError('Bad post data')
return lst[0][len('btn_'):]
def post(self, request):
try:
return self._post(request)
except ValidationError as e:
context = self.get_context_data()
context['error'] = e
return self.render_to_response(context, status=400)
def _post(self, request):
form = self.find_form(request.POST)
button = self.find_button(request.POST)
if not form.is_valid():
raise ValidationError('Form invalid')
if isinstance(form, AliasView.Checkboxes):
if button not in ('delete', 'disable', 'enable'):
raise ValidationError('Bad button')
return self.alias_operation_form_valid(form, button)
if isinstance(form, forms.AliasCreationForm):
if button != 'add':
raise ValidationError('Bad button')
return self.alias_creation_form_valid(form, button)
raise RuntimeError('Unknown form')
def alias_operation_form_valid(self, form, button):
uid = pwd.getpwnam(self.request.user.username).pw_uid
alias_list = form.cleaned_data['alias']
if button == 'delete':
aliases.delete(uid, alias_list)
elif button == 'disable':
aliases.set_disabled(uid, alias_list)
elif button == 'enable':
aliases.set_enabled(uid, alias_list)
return self.render_to_response(self.get_context_data())
def alias_creation_form_valid(self, form, button):
uid = pwd.getpwnam(self.request.user.username).pw_uid
aliases.put(uid, form.cleaned_data['email_name'])
return self.render_to_response(self.get_context_data())
def render_tabs(request):
sb = io.StringIO()
sb.write('<ul class="nav nav-tabs">')
@ -49,7 +174,7 @@ def render_tabs(request):
sb.write('<li class="nav-item">')
sb.write('<a class="nav-link {cls}" href="{href}">{text}</a>'.format(
cls=cls, href=href, text=escape(_(link_text))
cls=cls, href=href, text=escape(link_text)
))
sb.write('</li>')
sb.write('</ul>')