email: Code cleanup, address reviews

- View: security.html -> email_security.html
- Setup: will not install Redis
- Setup: put clamav packages on hold
- Crash recovery: opening firewall ports becomes the last post action
- Crash recovery: group postconf.set_many into small transactions
- Crash recovery: safer postconf.set_master_cf_options
This commit is contained in:
fliu 2021-08-10 03:32:45 +00:00 committed by Sunil Mohan Adapa
parent 240b67c49e
commit 2f77d99899
No known key found for this signature in database
GPG Key ID: 43EA1CFF0AA7C5F2
6 changed files with 119 additions and 57 deletions

View File

@ -16,26 +16,51 @@ from . import audit
from . import manifest
version = 1
managed_packages = ['postfix-ldap', 'dovecot-pop3d', 'dovecot-imapd',
'dovecot-ldap', 'dovecot-lmtpd', 'dovecot-managesieved',
'rspamd', 'clamav', 'clamav-daemon']
managed_services = ['postfix', 'dovecot', 'rspamd', 'redis', 'clamav-daemon',
'clamav-freshclam']
packages = [
'postfix-ldap', 'dovecot-pop3d', 'dovecot-imapd',
'dovecot-ldap', 'dovecot-lmtpd', 'dovecot-managesieved',
]
packages_bloat = ['rspamd']
clamav_packages = ['clamav', 'clamav-daemon']
clamav_daemons = ['clamav-daemon', 'clamav-freshclam']
port_info = {
'postfix': ('smtp', 25, 'smtps', 465, 'smtp-submission', 587),
'dovecot': ('imaps', 993, 'pop3s', 995),
}
managed_services = ['postfix', 'dovecot', 'rspamd']
managed_packages = packages + packages_bloat
app = None
class EmailServerApp(plinth.app.App):
"""FreedomBox email server app"""
app_id = 'email_server'
app_name = _('Email Server')
def __init__(self):
"""The app's constructor"""
super().__init__()
self._add_ui_components()
self._add_daemons()
self._add_firewall_ports()
# /rspamd location
webserver = Webserver('webserver-email', # unique id
'email-server-freedombox', # config file name
urls=['https://{host}/rspamd'])
self.add(webserver)
def _add_ui_components(self):
info = plinth.app.Info(
app_id=self.app_id,
version=version,
name=_('Email Server'),
name=self.app_name,
short_description=_('Powered by Postfix, Dovecot & Rspamd'),
manual_page='EmailServer',
clients=manifest.clients,
@ -53,11 +78,6 @@ class EmailServerApp(plinth.app.App):
)
self.add(menu_item)
# /rspamd location
webserver = Webserver('webserver-email', 'email-server-freedombox',
urls=['https://{host}/rspamd'])
self.add(webserver)
shortcut = plinth.frontpage.Shortcut(
'shortcut_' + self.app_id,
name=info.name,
@ -69,30 +89,28 @@ class EmailServerApp(plinth.app.App):
)
self.add(shortcut)
postfix_ports = []
dovecot_ports = []
all_firewalld_ports = []
for port in (25, 465, 587):
postfix_ports.extend([(port, 'tcp4'), (port, 'tcp6')])
for port in (993, 995):
dovecot_ports.extend([(port, 'tcp4'), (port, 'tcp6')])
all_firewalld_ports.extend(['smtp', 'smtps', 'smtp-submission'])
all_firewalld_ports.extend(['pop3s', 'imaps'])
# Manage daemons
postfixd = plinth.daemon.Daemon('daemon-postfix', 'postfix',
listen_ports=postfix_ports)
dovecotd = plinth.daemon.Daemon('daemon-dovecot', 'dovecot',
listen_ports=dovecot_ports)
self.add(postfixd)
self.add(dovecotd)
for name in ('rspamd', 'redis', 'clamav-daemon', 'clamav-freshclam'):
daemon = plinth.daemon.Daemon('daemon-' + name, name)
def _add_daemons(self):
for srvname in managed_services:
# Construct `listen_ports` parameter for the daemon
mixed = port_info.get(srvname, ())
port_numbers = [v for v in mixed if isinstance(v, int)]
listen = []
for n in port_numbers:
listen.append((n, 'tcp4'))
listen.append((n, 'tcp6'))
# Add daemon
daemon = plinth.daemon.Daemon('daemon-' + srvname, srvname,
listen_ports=listen)
self.add(daemon)
# Ports
firewall = Firewall('firewall-email', info.name,
ports=all_firewalld_ports, is_external=True)
def _add_firewall_ports(self):
all_port_names = []
for mixed in port_info.values():
port_names = [v for v in mixed if isinstance(v, str)]
all_port_names.extend(port_names)
firewall = Firewall('firewall-email', self.app_name,
ports=all_port_names, is_external=True)
self.add(firewall)
def diagnose(self):
@ -106,9 +124,11 @@ class EmailServerApp(plinth.app.App):
def setup(helper, old_version=None):
"""Installs and configures module"""
helper.install(managed_packages)
helper.install(packages)
helper.install(packages_bloat, skip_recommends=True)
helper.call('post', audit.ldap.repair)
helper.call('post', audit.spam.repair)
for srvname in managed_services:
actions.superuser_run('service', ['reload', srvname])
# Final step: expose service daemons to public internet
helper.call('post', app.enable)
for service_name in managed_services:
actions.superuser_run('service', ['reload', service_name])

View File

@ -102,11 +102,8 @@ def action_set_sasl():
def action_set_submission():
"""Handles email_server -i ldap set_submission"""
logger.info('Set postfix service: %r', default_submission_options)
postconf.set_master_cf_options(service_flags=submission_flags,
options=default_submission_options)
logger.info('Set postfix service: %r', default_smtps_options)
postconf.set_master_cf_options(service_flags=smtps_flags,
options=default_smtps_options)
@ -139,7 +136,7 @@ def fix_alias_maps(diagnosis):
for i in range(len(analysis.parsed)):
if analysis.parsed[i] in (BEFORE_ALIASES, AFTER_ALIASES):
analysis.parsed[i] = ''
# Does hash:/etc/aliases exist?
# Does hash:/etc/aliases exist in list?
if analysis.isystem >= 0:
# Put the maps around hash:/etc/aliases
val = '%s %s %s' % (BEFORE_ALIASES, ETC_ALIASES, AFTER_ALIASES)

View File

@ -1,15 +1,20 @@
"""Postconf wrapper providing thread-safe operations"""
# SPDX-License-Identifier: AGPL-3.0-or-later
import dataclasses
import logging
import re
import subprocess
from dataclasses import dataclass
from typing import ClassVar
from . import interproc
from .lock import Mutex
logger = logging.getLogger(__name__)
mutex = Mutex('email-postconf')
@dataclasses.dataclass
@dataclass
class ServiceFlags:
service: str
type: str
@ -20,10 +25,31 @@ class ServiceFlags:
maxproc: str
command_args: str
crash_handler: ClassVar[str] = '/dev/null/plinth-crash'
def _get_flags_ordered(self):
return [self.service, self.type, self.private, self.unpriv,
self.chroot, self.wakeup, self.maxproc, self.command_args]
def serialize(self) -> str:
return ' '.join([self.service, self.type, self.private, self.unpriv,
self.chroot, self.wakeup, self.maxproc,
self.command_args])
ordered = self._get_flags_ordered()
return ' '.join(ordered)
def serialize_temp(self) -> str:
ordered = self._get_flags_ordered()
ordered[-1] = self.crash_handler
return ' '.join(ordered)
def try_remove_crash_handler(self, line) -> str:
pattern = re.compile('([^ \\t]+)[ \\t]+([a-z]+)[ \\t]+')
match = pattern.match(line)
if match is None:
return None
if match.group(1) != self.service or match.group(2) != self.type:
return None
if not line.rstrip().endswith(self.crash_handler):
return None
return line.replace(self.crash_handler, self.command_args)
def get_many(key_list):
@ -52,9 +78,16 @@ def set_many(kv_map):
set_many_unsafe(kv_map)
def set_many_unsafe(kv_map):
def set_many_unsafe(kv_map, flag=''):
args = ['/sbin/postconf']
if not kv_map:
return
if flag:
args.append(flag)
for key, value in kv_map.items():
set_unsafe(key, value)
args.append('{}={}'.format(key, value))
_run(args)
def set_master_cf_options(service_flags, options={}):
@ -65,15 +98,19 @@ def set_master_cf_options(service_flags, options={}):
validate_key(key)
validate_value(value)
service_slash_type = service_flags.service + '/' + service_flags.type
flag_string = service_flags.serialize()
service_key = service_flags.service + '/' + service_flags.type
long_opts = {service_key + '/' + k: v for (k, v) in options.items()}
logger.info('Setting %s service: %r', service_flags.service, options)
# Crash resistant config setting:
# /sbin/postconf -M "service/type=<temp flag string>"
# /sbin/postconf -P "service/type/k=v" ...
# Delete placeholder string /dev/null/plinth-crash
with mutex.lock_all():
# /sbin/postconf -M "service/type=flag_string"
set_unsafe(service_slash_type, flag_string, '-M')
for short_key, value in options.items():
# /sbin/postconf -P "service/type/short_key=value"
set_unsafe(service_slash_type + '/' + short_key, value, '-P')
set_unsafe(service_key, service_flags.serialize_temp(), '-M')
set_many_unsafe(long_opts, '-P')
_master_remove_crash_handler(service_flags)
def get_unsafe(key):
@ -122,6 +159,14 @@ def _run(args):
raise RuntimeError('Unicode decoding failed') from unicode_error
def _master_remove_crash_handler(service_flags):
with interproc.atomically_rewrite('/etc/postfix/master.cf') as writer:
with open('/etc/postfix/master.cf') as reader:
for line in reader:
cleaned = service_flags.try_remove_crash_handler(line)
writer.write(line if cleaned is None else cleaned)
def validate_key(key):
"""Validate postconf key format. Raises ValueError"""
if not re.match('^[a-zA-Z][a-zA-Z0-9_]*$', key):

View File

@ -7,7 +7,7 @@ from . import views
urlpatterns = [
path('apps/email_server/', views.EmailServerView.as_view(), name='index'),
path('apps/email_server/security', views.TLSView.as_view()),
path('apps/email_server/email_security', views.TLSView.as_view()),
path('apps/email_server/domains', views.DomainView.as_view()),
path('apps/email_server/my_mail',

View File

@ -21,7 +21,7 @@ class TabMixin(View):
('', _('Home')),
('my_mail', _('My Mail')),
('my_aliases', _('My Aliases')),
('security', _('Security')),
('email_security', _('Security')),
('domains', _('Domains'))
]
@ -218,7 +218,7 @@ class AliasView(TabMixin, TemplateView):
class TLSView(TabMixin, TemplateView):
template_name = 'security.html'
template_name = 'email_security.html'
class DomainView(TabMixin, TemplateView):