From cde0b470642c7c28e28379342d816bb214f6377a Mon Sep 17 00:00:00 2001 From: fliu <10025-fliu@users.noreply.salsa.debian.org> Date: Wed, 16 Jun 2021 04:04:31 +0000 Subject: [PATCH] email: Enable LDAP by calling postconf in a thread-safe way - Implemented `email_server ipc postconf_set_many_v1` - Implemented `lock.Mutex` (fcntl.lockf and threading.Lock based mutex) - FIXME: Lock file permissions - Implemented `postconf` (thread-safe postconf operations) - Started using service orientation --- actions/email_server | 67 ++++++++++++++++ plinth/modules/email_server/__init__.py | 9 +++ plinth/modules/email_server/audit/__init__.py | 5 ++ plinth/modules/email_server/audit/domain.py | 14 ++++ plinth/modules/email_server/audit/ldap.py | 25 ++++++ plinth/modules/email_server/audit/models.py | 32 ++++++++ plinth/modules/email_server/lock.py | 39 +++++++++ plinth/modules/email_server/postconf.py | 80 +++++++++++++++++++ 8 files changed, 271 insertions(+) mode change 100644 => 100755 actions/email_server create mode 100644 plinth/modules/email_server/audit/__init__.py create mode 100644 plinth/modules/email_server/audit/domain.py create mode 100644 plinth/modules/email_server/audit/ldap.py create mode 100644 plinth/modules/email_server/audit/models.py create mode 100644 plinth/modules/email_server/lock.py create mode 100644 plinth/modules/email_server/postconf.py diff --git a/actions/email_server b/actions/email_server old mode 100644 new mode 100755 index e69de29bb..8d83ad2c0 --- a/actions/email_server +++ b/actions/email_server @@ -0,0 +1,67 @@ +#!/usr/bin/python3 +# SPDX-License-Identifier: AGPL-3.0-or-later + +import logging +import os +import signal +import sys + +import plinth.modules.email_server.postconf as postconf + +logger = logging.getLogger(__name__) + +EXIT_SYNTAX = 10 +EXIT_TIMEOUT = 20 + + +def main(): + if len(sys.argv) != 3: + sys.exit(EXIT_SYNTAX) + if sys.argv[1] != 'ipc': + sys.exit(EXIT_SYNTAX) + + function_name = 'ipc_' + sys.argv[2] + 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) + + +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) + + +if __name__ == '__main__': + main() diff --git a/plinth/modules/email_server/__init__.py b/plinth/modules/email_server/__init__.py index 65211a9c0..e8e78d0a2 100644 --- a/plinth/modules/email_server/__init__.py +++ b/plinth/modules/email_server/__init__.py @@ -10,6 +10,7 @@ import plinth.frontpage import plinth.menu from plinth.modules.firewall.components import Firewall +from . import audit from . import manifest version = 1 @@ -82,7 +83,15 @@ class EmailServerApp(plinth.app.App): ports=all_firewalld_ports, is_external=True) self.add(firewall) + def diagnose(self): + """Run diagnostics and return the results""" + results = super().diagnose() + results.append(audit.domain.get().summarize()) + results.append(audit.ldap.get().summarize()) + return results + def setup(helper, old_version=None): """Installs and configures module""" helper.install(managed_packages) + helper.call('post', audit.ldap.repair) diff --git a/plinth/modules/email_server/audit/__init__.py b/plinth/modules/email_server/audit/__init__.py new file mode 100644 index 000000000..d82a9cd89 --- /dev/null +++ b/plinth/modules/email_server/audit/__init__.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +from . import ldap +from . import domain + +__all__ = ['ldap', 'domain'] diff --git a/plinth/modules/email_server/audit/domain.py b/plinth/modules/email_server/audit/domain.py new file mode 100644 index 000000000..65815a132 --- /dev/null +++ b/plinth/modules/email_server/audit/domain.py @@ -0,0 +1,14 @@ +"""The domain audit resource""" +# SPDX-License-Identifier: AGPL-3.0-or-later + +from . import models + + +def get(): + # Stub + return models.Result('Email domains') + + +def repair(): + # Stub + raise RuntimeError() diff --git a/plinth/modules/email_server/audit/ldap.py b/plinth/modules/email_server/audit/ldap.py new file mode 100644 index 000000000..4da939640 --- /dev/null +++ b/plinth/modules/email_server/audit/ldap.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +import plinth.modules.email_server.postconf as postconf +from . import models + +default_config = { + 'smtpd_sasl_auth_enable': 'yes', + 'smtpd_sasl_type': 'dovecot', + 'smtpd_sasl_path': 'private/auth' +} + + +# GET /audit/domain +def get(): + """Compare current values with the default. Generate an audit report""" + results = models.Result('LDAP for user accounts') + 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 + + +# POST /audit/domain/repair +def repair(): + postconf.set_many(default_config) diff --git a/plinth/modules/email_server/audit/models.py b/plinth/modules/email_server/audit/models.py new file mode 100644 index 000000000..ec91a9280 --- /dev/null +++ b/plinth/modules/email_server/audit/models.py @@ -0,0 +1,32 @@ +"""Audit models""" +# SPDX-License-Identifier: AGPL-3.0-or-later +import logging + +logger = logging.getLogger(__name__) + + +class Result: + def __init__(self, title): + self.title = title + self.fails = [] + self.errors = [] + + def summarize(self, log=True): + """Return a 2-element list for the diagnose function in AppView""" + if log: + self.write_logs() + + if self.errors: + return [self.title, 'error'] + elif self.fails: + return [self.title, 'failed'] + else: + return [self.title, 'passed'] + + def write_logs(self): + """Log errors and failures""" + logger.debug('Ran audit: ' + self.title) + for message in self.errors: + logger.critical(message) + for message in self.fails: + logger.error(message) diff --git a/plinth/modules/email_server/lock.py b/plinth/modules/email_server/lock.py new file mode 100644 index 000000000..bdf138ba7 --- /dev/null +++ b/plinth/modules/email_server/lock.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +import contextlib +import fcntl +import os +import threading + + +class Mutex: + """File and pthread lock based resource mutex""" + + def __init__(self, lock_file): + self.thread_mutex = threading.Lock() + self.lock_path = '/var/lock/' + lock_file + + @contextlib.contextmanager + def lock_threads_only(self): + """Acquire the thread lock but not the file lock""" + self.thread_mutex.acquire(timeout=5) + try: + yield + finally: + self.thread_mutex.release() + + @contextlib.contextmanager + def lock_all(self): + """Acquire both the thread lock and the file lock""" + with self.lock_threads_only(): + fd = open(self.lock_path, 'wb') + # FIXME: Who can lock? + try: + os.fchmod(fd.fileno(), 0o666) # rw-rw-rw- + except OSError: + pass + fcntl.lockf(fd, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.lockf(fd, fcntl.LOCK_UN) + fd.close() diff --git a/plinth/modules/email_server/postconf.py b/plinth/modules/email_server/postconf.py new file mode 100644 index 000000000..b4dd8fee7 --- /dev/null +++ b/plinth/modules/email_server/postconf.py @@ -0,0 +1,80 @@ +"""Postconf wrapper providing thread-safe operations""" +# SPDX-License-Identifier: AGPL-3.0-or-later + +import re +import subprocess +import plinth.actions +from .lock import Mutex + +postconf_mutex = Mutex('plinth-email-postconf.lock') + + +def get_many(key_list): + """Acquire resource lock. Get the list of postconf values as specified. + Return a key-value map""" + result = {} + with postconf_mutex.lock_all(): + for key in key_list: + result[key] = get_no_lock(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) + + +def get_no_lock(key): + """Get postconf value (no locking)""" + validate_key(key) + result = _run(['/sbin/postconf', key]) + match = key + ' = ' + if not result.startswith(match): + raise KeyError(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 _run(args): + """Run process. Capture and return standard output as a string. Raise a + RuntimeError on non-zero exit codes""" + try: + result = subprocess.run(args, check=True, capture_output=True) + return result.stdout.decode('utf-8') + except subprocess.SubprocessError as subprocess_error: + raise RuntimeError('Subprocess failed') from subprocess_error + except UnicodeDecodeError as unicode_error: + raise RuntimeError('Unicode decoding failed') from unicode_error + + +def validate_key(key): + """Validate postconf key format. Raises ValueError""" + if not re.match('^[a-zA-Z][a-zA-Z0-9_]*$', key): + raise ValueError('Invalid postconf key format') + + +def validate_value(value): + """Validate postconf value format. Raises ValueError""" + for c in value: + if ord(c) < 32: + raise ValueError('Value contains control characters')