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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # 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'] __all__ = [
'setup_aliases', 'get_dkim_public_key', 'setup_dkim', 'set_domains',
from .aliases import action_setup 'setup_home', 'setup_postfix', 'setup_spam'
]

View File

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

View File

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

View File

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

View File

@ -8,20 +8,15 @@ https://doc.dovecot.org/configuration_manual/authentication/user_databases_userd
import subprocess import subprocess
from plinth import actions from plinth.actions import privileged
def setup(): @privileged
def setup_home():
"""Set correct permissions on /var/mail/ directory. """Set correct permissions on /var/mail/ directory.
For each user, /var/mail/<user> is the 'dovecot mail home' for that user. 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 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) subprocess.run(['chmod', 'o-rwx', '/var/mail'], check=True)

View File

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

View File

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

View File

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