email: Use privileged decorator for actions

Tests:

- Functional tests work (uninstall test does not work)
- Initial setup works
  - Domains are setup
  - Home is setup (others don't have permission for /var/mail)
  - Aliases configuration is setup
  - Postfix is setup
  - rspamd is setup
- Changing primary domain works
- Adding/removing domains works
- Error during operations is handle properly: getting dkim key
- Setting up DKIM key when changing, adding/removing domain works
  - Showing DKIM key in app page works

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
Sunil Mohan Adapa 2022-09-02 11:19:21 -07:00 committed by James Valleroy
parent a579c648fd
commit 5389303e98
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
12 changed files with 63 additions and 147 deletions

View File

@ -1,66 +0,0 @@
#!/usr/bin/python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configuration helper for email server.
"""
import argparse
import logging
import os
import sys
import plinth.log
from plinth.modules.email import privileged
EXIT_SYNTAX = 10
EXIT_PERM = 20
logger = logging.getLogger(__file__)
def main():
"""Parse arguments."""
plinth.log.action_init()
parser = argparse.ArgumentParser()
parser.add_argument('module', help='Module to trigger action in')
parser.add_argument('action', help='Action to trigger in module')
parser.add_argument('arguments', help='String arguments for action',
nargs='*')
args = parser.parse_args()
try:
_call(args.module, args.action, args.arguments)
except Exception as exception:
logger.exception(exception)
sys.exit(1)
def _call(module_name, action_name, arguments):
"""Import the module and run action as superuser."""
if os.getuid() != 0:
logger.critical('This action is reserved for root')
sys.exit(EXIT_PERM)
# We only run actions defined in the privileged module
if module_name not in privileged.__all__:
logger.critical('Bad module name: %r', module_name)
sys.exit(EXIT_SYNTAX)
module = getattr(privileged, module_name)
try:
action = getattr(module, 'action_' + action_name)
except AttributeError:
logger.critical('Bad action: %s/%r', module_name, action_name)
sys.exit(EXIT_SYNTAX)
for argument in arguments:
if not isinstance(argument, str):
logger.critical('Bad argument: %s', argument)
sys.exit(EXIT_SYNTAX)
action(*arguments)
if __name__ == '__main__':
main()

View File

@ -1,7 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
FreedomBox app to manage an email server.
"""
"""FreedomBox app to manage an email server."""
import logging
@ -49,12 +47,13 @@ logger = logging.getLogger(__name__)
class EmailApp(plinth.app.App):
"""FreedomBox app for an email server."""
app_id = 'email'
_version = 1
def __init__(self):
"""The app's constructor"""
"""Initialize the email app."""
super().__init__()
info = plinth.app.Info(app_id=self.app_id, version=self._version,
@ -180,13 +179,14 @@ class EmailApp(plinth.app.App):
super().setup(old_version)
# Setup
privileged.home.setup()
privileged.setup_home()
self.get_component('letsencrypt-email-postfix').setup_certificates()
self.get_component('letsencrypt-email-dovecot').setup_certificates()
privileged.domain.set_domains()
privileged.postfix.setup()
privileged.domain.set_all_domains()
aliases.first_setup()
privileged.setup_postfix()
aliases.setup_common_aliases(_get_first_admin())
privileged.spam.setup()
privileged.setup_spam()
# Restart daemons
actions.superuser_run('service', ['try-restart', 'postfix'])
@ -218,7 +218,7 @@ def on_domain_added(sender, domain_type, name, description='', services=None,
if app.needs_setup():
return
privileged.domain.set_domains()
privileged.domain.set_all_domains()
def on_domain_removed(sender, domain_type, name='', **kwargs):
@ -227,4 +227,4 @@ def on_domain_removed(sender, domain_type, name='', **kwargs):
if app.needs_setup():
return
privileged.domain.set_domains()
privileged.domain.set_all_domains()

View File

@ -1,14 +1,12 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Manage email aliases stored in sqlite database.
"""
"""Manage email aliases stored in sqlite database."""
import contextlib
import pwd
import sqlite3
from dataclasses import dataclass
from plinth import actions
from . import privileged
@dataclass
@ -75,7 +73,7 @@ def delete(username, aliases):
def first_setup():
"""Create the database file and schema inside it."""
actions.superuser_run('email', ['aliases', 'setup'])
privileged.aliases.setup_aliases()
# Create schema if not exists
query = '''

View File

@ -12,12 +12,11 @@ See: https://rspamd.com/doc/modules/dkim_signing.html
from dataclasses import dataclass
from typing import Union
from plinth.errors import ActionError
@dataclass
class Entry: # pylint: disable=too-many-instance-attributes
"""A DNS entry."""
type_: str
value: str
domain: Union[str, None] = None
@ -55,12 +54,12 @@ def get_entries():
f'rua=mailto:postmaster@{domain}; ')
]
try:
dkim_public_key = privileged.dkim.get_public_key(domain)
dkim_public_key = privileged.get_dkim_public_key(domain)
dkim_entries = [
Entry(domain='dkim._domainkey', type_='TXT',
value=f'v=DKIM1; k=rsa; p={dkim_public_key}')
]
except ActionError:
except Exception:
dkim_entries = []
autoconfig_entries = [

View File

@ -1,10 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Provides privileged actions that run as root.
"""
"""Provides privileged actions that run as root."""
from . import aliases, dkim, domain, home, postfix, spam, tls
from .aliases import setup_aliases
from .dkim import get_dkim_public_key, setup_dkim
from .domain import set_domains
from .home import setup_home
from .postfix import setup_postfix
from .spam import setup_spam
__all__ = ['aliases', 'domain', 'dkim', 'home', 'postfix', 'spam', 'tls']
from .aliases import action_setup
__all__ = [
'setup_aliases', 'get_dkim_public_key', 'setup_dkim', 'set_domains',
'setup_home', 'setup_postfix', 'setup_spam'
]

View File

@ -1,13 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Privileged operations for managing aliases.
"""
"""Privileged operations for managing aliases."""
import pathlib
import shutil
from plinth.actions import privileged
def action_setup():
@privileged
def setup_aliases():
"""Create a the sqlite3 database to be managed by FreedomBox."""
path = pathlib.Path('/var/lib/postfix/freedombox-aliases/')
path.mkdir(mode=0o750, exist_ok=True)

View File

@ -1,6 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Generate DKIM keys for signing outgoing messages.
"""Generate DKIM keys for signing outgoing messages.
See: https://rspamd.com/doc/modules/dkim_signing.html
"""
@ -10,7 +9,7 @@ import re
import shutil
import subprocess
from plinth import actions
from plinth.actions import privileged
_keys_dir = pathlib.Path('/var/lib/rspamd/dkim/')
@ -23,24 +22,19 @@ def _validate_domain_name(domain):
raise ValueError('Invalid domain name')
def get_public_key(domain):
"""Return the DKIM public key for the given domain."""
output = actions.superuser_run('email',
['dkim', 'get_dkim_public_key', domain])
return output.strip()
def action_get_dkim_public_key(domain):
@privileged
def get_dkim_public_key(domain: str) -> str:
"""Privileged action to get the public key from DKIM key."""
_validate_domain_name(domain)
key_file = _keys_dir / f'{domain}.dkim.key'
output = subprocess.check_output(
['openssl', 'rsa', '-in',
str(key_file), '-pubout'], stderr=subprocess.DEVNULL)
print(''.join(output.decode().splitlines()[1:-1]))
return ''.join(output.decode().splitlines()[1:-1])
def action_setup_dkim(domain):
@privileged
def setup_dkim(domain: str):
"""Create DKIM key for a given domain."""
_validate_domain_name(domain)

View File

@ -10,13 +10,13 @@ See: http://www.postfix.org/postconf.5.html#myhostname
import pathlib
import re
from plinth.actions import superuser_run
from plinth.actions import privileged
from plinth.app import App
from plinth.modules import config
from plinth.modules.email import postfix
from plinth.modules.names.components import DomainName
from . import tls
from . import dkim, tls
def get_domains():
@ -28,7 +28,7 @@ def get_domains():
return {'primary_domain': conf['mydomain'], 'all_domains': domains}
def set_domains(primary_domain=None):
def set_all_domains(primary_domain=None):
"""Set the primary domain and all the domains for postfix."""
all_domains = DomainName.list_names()
if not primary_domain:
@ -37,10 +37,8 @@ def set_domains(primary_domain=None):
primary_domain = config.get_domainname() or list(all_domains)[0]
# Update configuration and don't restart daemons
superuser_run(
'email',
['domain', 'set_domains', primary_domain, ','.join(all_domains)])
superuser_run('email', ['dkim', 'setup_dkim', primary_domain])
set_domains(primary_domain, list(all_domains))
dkim.setup_dkim(primary_domain)
# Copy certificates (self-signed if needed) and restart daemons
app = App.get('email')
@ -48,9 +46,10 @@ def set_domains(primary_domain=None):
app.get_component('letsencrypt-email-dovecot').setup_certificates()
def action_set_domains(primary_domain, all_domains):
@privileged
def set_domains(primary_domain: str, all_domains: list[str]):
"""Set the primary domain and all the domains for postfix."""
all_domains = [_clean_domain(domain) for domain in all_domains.split(',')]
all_domains = [_clean_domain(domain) for domain in all_domains]
primary_domain = _clean_domain(primary_domain)
defaults = {'$myhostname', 'localhost.$mydomain', 'localhost'}

View File

@ -8,20 +8,15 @@ https://doc.dovecot.org/configuration_manual/authentication/user_databases_userd
import subprocess
from plinth import actions
from plinth.actions import privileged
def setup():
@privileged
def setup_home():
"""Set correct permissions on /var/mail/ directory.
For each user, /var/mail/<user> is the 'dovecot mail home' for that user.
Dovecot creates new directories with the same permissions as the parent
directory. Ensure that 'others' can access /var/mail/.
directory. Ensure that 'others' can't access /var/mail/.
"""
actions.superuser_run('email', ['home', 'setup'])
def action_setup():
"""Run chmod on /var/mail to remove all permissions for 'others'."""
subprocess.run(['chmod', 'o-rwx', '/var/mail'], check=True)

View File

@ -1,7 +1,8 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Configure postfix to use auth and local delivery with dovecot. Start smtps and
submission services. Setup aliases database.
"""Configure postix.
- Configure postfix to use auth and local delivery with dovecot.
- Start SMTPS and submission services. Setup aliases database.
See:
https://doc.dovecot.org/configuration_manual/howto/postfix_and_dovecot_sasl/
@ -9,9 +10,8 @@ See: https://doc.dovecot.org/configuration_manual/howto/postfix_dovecot_lmtp/
See: http://www.postfix.org/TLS_README.html
"""
from plinth import actions
from plinth.actions import privileged
from .. import aliases
from .. import postfix as postconf
default_config = {
@ -57,13 +57,9 @@ smtps_service = postconf.Service(service='smtps', type_='inet', private='n',
SQLITE_ALIASES = 'sqlite:/etc/postfix/freedombox-aliases.cf'
def setup():
"""Set SASL, mail submission, and user lookup settings."""
aliases.first_setup()
actions.superuser_run('email', ['postfix', 'setup'])
def action_setup():
@privileged
def setup_postfix():
"""Configure postfix."""
postconf.set_config(default_config)
_setup_submission()
_setup_alias_maps()

View File

@ -10,7 +10,7 @@ import pathlib
import re
import subprocess
from plinth import actions
from plinth.actions import privileged
from plinth.modules.email import postfix
_milter_config = {
@ -19,12 +19,8 @@ _milter_config = {
}
def setup():
"""Trigger a privileged setup action."""
actions.superuser_run('email', ['spam', 'setup'])
def action_setup():
@privileged
def setup_spam():
"""Compile sieve filters and set rspamd/postfix configuration."""
_compile_sieve()
_setup_rspamd()

View File

@ -41,7 +41,7 @@ class EmailAppView(AppView):
new_data = form.cleaned_data
if old_data['primary_domain'] != new_data['primary_domain']:
try:
privileged.domain.set_domains(new_data['primary_domain'])
privileged.domain.set_all_domains(new_data['primary_domain'])
messages.success(self.request, _('Configuration updated'))
except Exception:
messages.error(self.request,