mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-06-10 11:00:22 +00:00
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:
parent
aaa6342f93
commit
cde0b47064
67
actions/email_server
Normal file → Executable file
67
actions/email_server
Normal file → Executable 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()
|
||||
@ -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)
|
||||
|
||||
5
plinth/modules/email_server/audit/__init__.py
Normal file
5
plinth/modules/email_server/audit/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
from . import ldap
|
||||
from . import domain
|
||||
|
||||
__all__ = ['ldap', 'domain']
|
||||
14
plinth/modules/email_server/audit/domain.py
Normal file
14
plinth/modules/email_server/audit/domain.py
Normal 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()
|
||||
25
plinth/modules/email_server/audit/ldap.py
Normal file
25
plinth/modules/email_server/audit/ldap.py
Normal 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)
|
||||
32
plinth/modules/email_server/audit/models.py
Normal file
32
plinth/modules/email_server/audit/models.py
Normal 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)
|
||||
39
plinth/modules/email_server/lock.py
Normal file
39
plinth/modules/email_server/lock.py
Normal 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()
|
||||
80
plinth/modules/email_server/postconf.py
Normal file
80
plinth/modules/email_server/postconf.py
Normal 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')
|
||||
Loading…
x
Reference in New Issue
Block a user