From fdc6f2390815dae7c24ecb2d17e654750629ca61 Mon Sep 17 00:00:00 2001 From: fliu <10025-fliu@users.noreply.salsa.debian.org> Date: Thu, 17 Jun 2021 00:57:46 +0000 Subject: [PATCH] email: Implement `email_server ipc set_sasl` and `set_submission` - Rewrote action script to eliminate stdin communication - Changed return type of audit.*.get() - An audit can return multiple lines of diagnostics - Move recommended endpoint URLs into function docstrings --- actions/email_server | 55 +++++----------- plinth/modules/email_server/__init__.py | 6 +- plinth/modules/email_server/audit/domain.py | 2 +- plinth/modules/email_server/audit/ldap.py | 45 +++++++++++-- plinth/modules/email_server/postconf.py | 71 +++++++++++++++------ 5 files changed, 112 insertions(+), 67 deletions(-) diff --git a/actions/email_server b/actions/email_server index 8d83ad2c0..4512367f5 100755 --- a/actions/email_server +++ b/actions/email_server @@ -3,15 +3,23 @@ import logging import os -import signal import sys -import plinth.modules.email_server.postconf as postconf +import plinth.modules.email_server.audit as audit logger = logging.getLogger(__name__) EXIT_SYNTAX = 10 -EXIT_TIMEOUT = 20 +EXIT_PERM = 20 + + +def reserved_for_root(fun): + def wrapped(*args, **kwargs): + if os.getuid() != 0: + logger.critical('This action is reserved for root') + sys.exit(EXIT_PERM) + return fun(*args, **kwargs) + return wrapped def main(): @@ -24,43 +32,14 @@ def main(): globals()[function_name]() -def ipc_postconf_set_many_v1(): - """Set postconf values""" - if os.getuid() != 0: - logger.warning('Run as root?') - new_config = _postconf_parse_stdin() - with postconf.postconf_mutex.lock_all(): - for key, value in new_config.items(): - postconf.set_no_lock_assuming_root(key, value) +@reserved_for_root +def ipc_set_sasl(): + audit.ldap.action_set_sasl() -def _postconf_parse_stdin(): - new_config = {} - # Set timeout handler - signal.signal(signal.SIGALRM, _timeout_handler) - while True: - key = _timed_input('Key: ') - if not key: break - postconf.validate_key(key) - value = _timed_input('Value: ') - postconf.validate_value(value) - new_config[key] = value - return new_config - - -def _timed_input(prompt): - # Set timeout - signal.alarm(3) - result = input(prompt) - # Disable timeout - signal.alarm(0) - return result - - -def _timeout_handler(signum, frame): - # https://stackoverflow.com/a/1336751 - logger.critical('[Time out]') - sys.exit(EXIT_TIMEOUT) +@reserved_for_root +def ipc_set_submission(): + audit.ldap.action_set_submission() if __name__ == '__main__': diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index e8e78d0a2..72bdfba3f 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -13,7 +13,7 @@ from plinth.modules.firewall.components import Firewall from . import audit from . import manifest -version = 1 +version = 31 managed_packages = ['postfix', 'dovecot-pop3d', 'dovecot-imapd', 'dovecot-lmtpd', 'dovecot-ldap', 'dovecot-managesieved'] managed_services = ['postfix', 'dovecot'] @@ -86,8 +86,8 @@ class EmailServerApp(plinth.app.App): def diagnose(self): """Run diagnostics and return the results""" results = super().diagnose() - results.append(audit.domain.get().summarize()) - results.append(audit.ldap.get().summarize()) + results.extend([r.summarize() for r in audit.domain.get()]) + results.extend([r.summarize() for r in audit.ldap.get()]) return results diff --git a/plinth/modules/email_server/audit/domain.py b/plinth/modules/email_server/audit/domain.py index 65815a132..194002637 100644 --- a/plinth/modules/email_server/audit/domain.py +++ b/plinth/modules/email_server/audit/domain.py @@ -6,7 +6,7 @@ from . import models def get(): # Stub - return models.Result('Email domains') + return [models.Result('Email domains')] def repair(): diff --git a/plinth/modules/email_server/audit/ldap.py b/plinth/modules/email_server/audit/ldap.py index 4da939640..10123eec7 100644 --- a/plinth/modules/email_server/audit/ldap.py +++ b/plinth/modules/email_server/audit/ldap.py @@ -1,4 +1,8 @@ +"""Audit of LDAP and mail submission settings""" # SPDX-License-Identifier: AGPL-3.0-or-later + +from plinth import actions + import plinth.modules.email_server.postconf as postconf from . import models @@ -8,18 +12,49 @@ default_config = { 'smtpd_sasl_path': 'private/auth' } +submission_flags = postconf.ServiceFlags( + service='submission', type='inet', private='n', unpriv='-', chroot='y', + wakeup='-', maxproc='-', command_args='smtpd' +) + +default_submission_options = { + 'syslog_name': 'postfix/submission', + 'smtpd_tls_security_level': 'encrypt', + 'smtpd_client_restrictions': 'permit_sasl_authenticated,reject', + 'smtpd_relay_restrictions': 'permit_sasl_authenticated,reject' +} + -# GET /audit/domain def get(): - """Compare current values with the default. Generate an audit report""" - results = models.Result('LDAP for user accounts') + """Compare current values with the default. Generate an audit report + + Recommended endpoint name: + GET /audit/ldap + """ + results = models.Result('Postfix uses Dovecot for SASL authentication') current_config = postconf.get_many(list(default_config.keys())) for key, value in default_config.items(): if current_config[key] != value: results.fails.append('{} should equal {}'.format(key, value)) - return results + return [results] -# POST /audit/domain/repair def repair(): + """Tries to repair LDAP and mail submission settings + + Recommended endpoint name: + POST /audit/ldap/repair + """ + actions.superuser_run('email_server', ['ipc', 'set_sasl']) + actions.superuser_run('email_server', ['ipc', 'set_submission']) + + +def action_set_sasl(): + """Called by email_server ipc set_sasl""" postconf.set_many(default_config) + + +def action_set_submission(): + """Called by email_server ipc set_submission""" + postconf.set_master_cf_options(service_flags=submission_flags, + options=default_submission_options) diff --git a/plinth/modules/email_server/postconf.py b/plinth/modules/email_server/postconf.py index b4dd8fee7..575668913 100644 --- a/plinth/modules/email_server/postconf.py +++ b/plinth/modules/email_server/postconf.py @@ -1,6 +1,7 @@ """Postconf wrapper providing thread-safe operations""" # SPDX-License-Identifier: AGPL-3.0-or-later +import dataclasses import re import subprocess import plinth.actions @@ -9,38 +10,67 @@ from .lock import Mutex postconf_mutex = Mutex('plinth-email-postconf.lock') +@dataclasses.dataclass +class ServiceFlags: + service: str + type: str + private: str + unpriv: str + chroot: str + wakeup: str + maxproc: str + command_args: str + + def serialize(self) -> str: + return ' '.join([self.service, self.type, self.private, self.unpriv, + self.chroot, self.wakeup, self.maxproc, + self.command_args]) + + def get_many(key_list): """Acquire resource lock. Get the list of postconf values as specified. Return a key-value map""" result = {} + for key in key_list: + validate_key(key) with postconf_mutex.lock_all(): for key in key_list: - result[key] = get_no_lock(key) + result[key] = get_unsafe(key) return result def set_many(kv_map): """Acquire resource lock. Set the list of postconf values as specified""" - # Encode email_server ipc input - lines = [] for key, value in kv_map.items(): validate_key(key) validate_value(value) - lines.append(key) - lines.append(value) - lines.append('\n') - stdin = '\n'.join(lines).encode('utf-8') - # Run action script as root - args = ['ipc', 'postconf_set_many_v1'] - with postconf_mutex.lock_threads_only(): - # The action script will take care of file locking - plinth.actions.superuser_run('email_server', args, input=stdin) + with postconf_mutex.lock_all(): + for key, value in kv_map.items(): + set_unsafe(key, value) -def get_no_lock(key): - """Get postconf value (no locking)""" - validate_key(key) +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') + for key, value in options.items(): + validate_key(key) + validate_value(value) + + service_slash_type = service_flags.service + '/' + service_flags.type + flag_string = service_flags.serialize() + + with postconf_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(): + # /sbin/postconf -P "service/type/short_key=value" + set_unsafe(service_slash_type + '/' + short_key, value, '-P') + + +def get_unsafe(key): + """Get postconf value (no locking, no sanitization)""" result = _run(['/sbin/postconf', key]) match = key + ' = ' if not result.startswith(match): @@ -48,11 +78,12 @@ def get_no_lock(key): return result[len(match):].strip() -def set_no_lock_assuming_root(key, value): - """Set postconf value (assuming root and no locking)""" - validate_key(key) - validate_value(value) - _run(['/sbin/postconf', '{}={}'.format(key, value)]) +def set_unsafe(key, value, flag=''): + """Set postconf value (assuming root, no locking, no sanitization)""" + if flag: + _run(['/sbin/postconf', flag, '{}={}'.format(key, value)]) + else: + _run(['/sbin/postconf', '{}={}'.format(key, value)]) def _run(args):