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
This commit is contained in:
fliu 2021-06-16 04:04:31 +00:00 committed by Sunil Mohan Adapa
parent aaa6342f93
commit cde0b47064
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
8 changed files with 271 additions and 0 deletions

67
actions/email_server Normal file → Executable file
View File

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

View File

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

View File

@ -0,0 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from . import ldap
from . import domain
__all__ = ['ldap', 'domain']

View File

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

View File

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

View File

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

View File

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

View File

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