mirror of
https://github.com/freedombox/FreedomBox.git
synced 2026-09-19 04:59:01 +00:00
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:
parent
a579c648fd
commit
5389303e98
@ -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()
|
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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 = '''
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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'
|
||||||
|
]
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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'}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user