diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index c8d6faff1..15fe9df30 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -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, diff --git a/plinth/modules/email_server/aliases/__init__.py b/plinth/modules/email_server/aliases/__init__.py new file mode 100644 index 000000000..e510dd3c3 --- /dev/null +++ b/plinth/modules/email_server/aliases/__init__.py @@ -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) + diff --git a/plinth/modules/email_server/aliases/models.py b/plinth/modules/email_server/aliases/models.py new file mode 100644 index 000000000..ac3277fe1 --- /dev/null +++ b/plinth/modules/email_server/aliases/models.py @@ -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) diff --git a/plinth/modules/email_server/audit/ldap.py b/plinth/modules/email_server/audit/ldap.py index 790b8a953..f0af7a7bd 100644 --- a/plinth/modules/email_server/audit/ldap.py +++ b/plinth/modules/email_server/audit/ldap.py @@ -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 diff --git a/plinth/modules/email_server/audit/models.py b/plinth/modules/email_server/audit/models.py index a08efb0a2..9debf504e 100644 --- a/plinth/modules/email_server/audit/models.py +++ b/plinth/modules/email_server/audit/models.py @@ -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 diff --git a/plinth/modules/email_server/audit/spam.py b/plinth/modules/email_server/audit/spam.py index f0ad26fe3..8a8f0c24b 100644 --- a/plinth/modules/email_server/audit/spam.py +++ b/plinth/modules/email_server/audit/spam.py @@ -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()) diff --git a/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf b/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf new file mode 100644 index 000000000..c8919becf --- /dev/null +++ b/plinth/modules/email_server/data/etc/postfix/freedombox-username-to-uid-number.cf @@ -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 diff --git a/plinth/modules/email_server/forms.py b/plinth/modules/email_server/forms.py index 4f6ecdac7..a38123a8a 100644 --- a/plinth/modules/email_server/forms.py +++ b/plinth/modules/email_server/forms.py @@ -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) diff --git a/plinth/modules/email_server/manifest.py b/plinth/modules/email_server/manifest.py index 3a6bdae77..6426b50fe 100644 --- a/plinth/modules/email_server/manifest.py +++ b/plinth/modules/email_server/manifest.py @@ -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'] +} diff --git a/plinth/modules/email_server/postconf.py b/plinth/modules/email_server/postconf.py index caf443ea5..749e6f99d 100644 --- a/plinth/modules/email_server/postconf.py +++ b/plinth/modules/email_server/postconf.py @@ -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""" diff --git a/plinth/modules/email_server/templates/alias.html b/plinth/modules/email_server/templates/alias.html new file mode 100644 index 000000000..e8b072d49 --- /dev/null +++ b/plinth/modules/email_server/templates/alias.html @@ -0,0 +1,52 @@ +{% extends "app.html" %} + +{% load bootstrap %} +{% load i18n %} + +{% block configuration %} + + {{ tabs|safe }} +
+ {% trans "There was a problem with your request. Please try again." %} +
+ {% for message in error %} +{{ message }}
+ {% endfor %} +{% trans "You have no email aliases." %}
+ {% else %} + + {% endif %} + +