diff --git a/actions/ejabberd b/actions/ejabberd index 42090ae30..70d1acfa0 100755 --- a/actions/ejabberd +++ b/actions/ejabberd @@ -25,15 +25,20 @@ import argparse import os import shutil import socket +import stat import subprocess +import sys import ruamel.yaml from plinth import action_utils +from plinth.modules.config import config +from plinth.modules.letsencrypt import LIVE_DIRECTORY as LE_LIVE_DIRECTORY EJABBERD_CONFIG = '/etc/ejabberd/ejabberd.yml' EJABBERD_BACKUP = '/var/log/ejabberd/ejabberd.dump' EJABBERD_BACKUP_NEW = '/var/log/ejabberd/ejabberd_new.dump' +EJABBERD_ORIG_CERT = '/etc/ejabberd/ejabberd.pem' def parse_arguments(): @@ -85,6 +90,11 @@ def parse_arguments(): choices=('enable', 'disable', 'status'), help=help_MAM) + help_LE = "Add/drop Let's Encrypt certificate if configured domain matches" + letsencrypt = subparsers.add_parser('letsencrypt', help=help_LE) + letsencrypt.add_argument('command', choices=('add', 'drop'), help=help_LE) + letsencrypt.add_argument('--domain', help='Domain name to drop.') + subparsers.required = True return parser.parse_args() @@ -262,6 +272,90 @@ def subcommand_mam(argument): action_utils.service_restart('ejabberd') +def subcommand_letsencrypt(arguments): + """ + Add/drop usage of Let's Encrypt cert. The command 'add' applies only to + current domain, will be called by action 'letsencrypt run_renew_hooks', + when certbot renews the cert (if ejabberd is selected for cert use). + Drop of a cert must be possible for any domain to respond to domain change. + """ + current_domain = config.get_domainname() + + with open(EJABBERD_CONFIG, 'r') as file_handle: + conf = ruamel.yaml.round_trip_load(file_handle, preserve_quotes=True) + + if arguments.domain is not None and arguments.domain not in conf['hosts']: + print('Aborted: Current domain "%s" not configured for ejabberd.' + % arguments.domain) + sys.exit(1) + + if arguments.command == 'add' and arguments.domain is not None \ + and arguments.domain != current_domain: + print('Aborted: Only certificate of current domain "%s" can be added.' + % current_domain) + sys.exit(2) + + if arguments.domain is None: + arguments.domain = current_domain + + cert_folder = '/etc/ejabberd/letsencrypt/' + arguments.domain + cert_file = cert_folder + '/ejabberd.pem' + + if arguments.command == 'add': + le_folder = os.path.join(LE_LIVE_DIRECTORY, current_domain) + le_privkey = os.path.join(le_folder, 'privkey.pem') + le_fullchain = os.path.join(le_folder, 'fullchain.pem') + + if not os.path.exists(le_folder): + print('Aborted: No certificate directory at %s.' % le_folder) + sys.exit(3) + + if not os.path.exists(cert_folder): + os.makedirs(cert_folder) + shutil.chown(cert_folder, 'ejabberd', 'ejabberd') + + with open(cert_file, 'w') as outfile: + with open(le_privkey, 'r') as infile: + for line in infile: + if line.strip(): + outfile.write(line) + with open(le_fullchain, 'r') as infile: + for line in infile: + if line.strip(): + outfile.write(line) + shutil.chown(cert_file, 'ejabberd', 'ejabberd') + os.chmod(cert_file, stat.S_IRUSR | stat.S_IWUSR) + + cert_file = ruamel.yaml.scalarstring.DoubleQuotedScalarString( + cert_file) + conf['s2s_certfile'] = cert_file + + for listen_port in conf['listen']: + if 'certfile' in listen_port: + listen_port['certfile'] = cert_file + + else: # arguments.command == 'drop' (ensured by parser) + orig_cert_file = ruamel.yaml.scalarstring.DoubleQuotedScalarString( + EJABBERD_ORIG_CERT) + + for listen_port in conf['listen']: + if 'certfile' in listen_port \ + and listen_port['certfile'] == cert_file: + listen_port['certfile'] = orig_cert_file + + if conf['s2s_certfile'] == cert_file: + conf['s2s_certfile'] = orig_cert_file + + if os.path.exists(cert_folder): + shutil.rmtree(cert_folder) + + with open(EJABBERD_CONFIG, 'w') as file_handle: + ruamel.yaml.round_trip_dump(conf, file_handle) + + if action_utils.service_is_running('ejabberd'): + action_utils.service_restart('ejabberd') + + def main(): """Parse arguments and perform all duties""" arguments = parse_arguments() diff --git a/actions/letsencrypt b/actions/letsencrypt index 5e55598f9..b7f0e464f 100755 --- a/actions/letsencrypt +++ b/actions/letsencrypt @@ -27,14 +27,15 @@ import shutil import subprocess import sys import re - import configobj from plinth import action_utils +from plinth.errors import ActionError from plinth.modules.config import config +from plinth.modules import letsencrypt as le + TEST_MODE = False -LIVE_DIRECTORY = '/etc/letsencrypt/live/' RENEWAL_DIRECTORY = '/etc/letsencrypt/renewal/' AUTHENTICATOR = 'webroot' WEB_ROOT_PATH = '/var/www/html' @@ -104,6 +105,10 @@ def parse_arguments(): choices=('enable', 'disable', 'status')) manage_hook_parser.add_argument('--domain', help='Domain for hook management command.') + help_module_arg = 'For enable: Also use LE cert with other Plinth modules.' + manage_hook_parser.add_argument('--modules', help=help_module_arg, + nargs='+', default=[], + choices=le.MODULES_WITH_HOOKS) help_domain_arg = 'Domain name to run the hook scripts with.' help_module_arg = 'Include hooks from the provided module names.' @@ -111,19 +116,25 @@ def parse_arguments(): run_pre_hooks_parser = subparsers.add_parser('run_pre_hooks', help=help_pre_hooks) run_pre_hooks_parser.add_argument('--domain', help=help_domain_arg) - run_pre_hooks_parser.add_argument('--modules', help=help_module_arg) + run_pre_hooks_parser.add_argument('--modules', help=help_module_arg, + nargs='+', default=[], + choices=le.MODULES_WITH_HOOKS) help_renew_hooks = 'Maintenance tasks after a cert is actually renewed.' run_renew_hooks_parser = subparsers.add_parser('run_renew_hooks', help=help_renew_hooks) run_renew_hooks_parser.add_argument('--domain', help=help_domain_arg) - run_renew_hooks_parser.add_argument('--modules', help=help_module_arg) + run_renew_hooks_parser.add_argument('--modules', help=help_module_arg, + nargs='+', default=[], + choices=le.MODULES_WITH_HOOKS) help_post_hooks = 'Maintenance tasks after a cert is obtained or renewed.' run_post_hooks_parser = subparsers.add_parser('run_post_hooks', help=help_post_hooks) run_post_hooks_parser.add_argument('--domain', help=help_domain_arg) - run_post_hooks_parser.add_argument('--modules', help=help_module_arg) + run_post_hooks_parser.add_argument('--modules', help=help_module_arg, + nargs='+', default=[], + choices=le.MODULES_WITH_HOOKS) subparsers.required = True return parser.parse_args() @@ -131,7 +142,7 @@ def parse_arguments(): def get_certificate_expiry(domain): """Return the expiry date of a certificate.""" - certificate_file = os.path.join(LIVE_DIRECTORY, domain, 'cert.pem') + certificate_file = os.path.join(le.LIVE_DIRECTORY, domain, 'cert.pem') output = subprocess.check_output(['openssl', 'x509', '-enddate', '-noout', '-in', certificate_file]) return output.decode().strip().split('=')[1] @@ -159,12 +170,12 @@ def get_status(): Should be run as root, otherwise might yield a wrong, empty answer. """ try: - domains = os.listdir(LIVE_DIRECTORY) + domains = os.listdir(le.LIVE_DIRECTORY) except OSError: domains = [] domains = [domain for domain in domains - if os.path.isdir(os.path.join(LIVE_DIRECTORY, domain))] + if os.path.isdir(os.path.join(le.LIVE_DIRECTORY, domain))] domain_status = {} for domain in domains: @@ -189,7 +200,7 @@ def subcommand_revoke(arguments): domain = arguments.domain command = ['certbot', 'revoke', '--domain', domain, '--cert-path', - os.path.join(LIVE_DIRECTORY, domain, 'cert.pem')] + os.path.join(le.LIVE_DIRECTORY, domain, 'cert.pem')] if TEST_MODE: command.append('--staging') @@ -229,8 +240,8 @@ def subcommand_obtain(arguments): def subcommand_manage_hooks(arguments): """ - Enable/disable/show status of certbot's pre-, renew-, and post-hooks. - Command enable only edits current domain, RENEWAL_DIRECTORY/DOMAIN.config, + Enable/disable/status of certbot's pre-, renew-, & post-hooks for renewal. + Enable edits renewal config of current domain (RENEWAL_DIR/DOMAIN.config), and creates a backup beforehand that is restored if disable is called. Commands disable and status work without error on any domain string. """ @@ -279,7 +290,7 @@ def subcommand_manage_hooks(arguments): call_post = script_path + ' run_post_hooks --domain ' + arguments.domain config_plinth = {'renewalparams': {'authenticator': AUTHENTICATOR, - 'webroot_path': [WEB_ROOT_PATH], + # 'webroot_path': [WEB_ROOT_PATH], # removed by renew... 'webroot_map': {arguments.domain: WEB_ROOT_PATH}, 'installer': 'None', 'pre_hook': call_pre, @@ -290,23 +301,46 @@ def subcommand_manage_hooks(arguments): for line in config_certbot.initial_comment]) if arguments.command == 'status': + # check for presence of expected minimal configuration config_checks = [(entry in config_certbot['renewalparams']) and (str(config_plinth['renewalparams'][entry]) in str(config_certbot['renewalparams'][entry])) for entry in config_plinth['renewalparams'].keys()] - if all(config_checks): - print('enabled') - else: + + if not all(config_checks): print('disabled') + sys.exit(0) + + # is enabled; check for which selected modules (only for renew_hook) + cmd_str = config_certbot['renewalparams']['renew_hook'] + module_list = [] + for mod_str in le.MODULES_WITH_HOOKS: + mod_pattern = 'letsencrypt .*--modules .*%s.*' % mod_str + match = re.search(mod_pattern, cmd_str) + if match is not None: + module_list.append(mod_str) + + if module_list != []: + print('enabled, with modules: ' + ', '.join(module_list)) + else: + print('enabled, without modules') elif arguments.command == 'enable': if not config_edited_by_plinth: shutil.copy(config_path, config_backup_path) config_certbot.initial_comment.append(comment_plinth) + if arguments.modules != []: + call_renew += ' --modules ' + ' '.join(arguments.modules) + config_plinth['renewalparams']['renew_hook'] = call_renew + config_certbot['renewalparams'].update(config_plinth['renewalparams']) config_certbot.write() - print('enabled successfully') + + if arguments.modules != []: + print('enabled, with modules: ' + ', '.join(arguments.modules)) + else: + print('enabled, without modules') elif arguments.command == 'disable': # if changed, restore from backup; refuse disabling if no backup exists @@ -338,16 +372,19 @@ def subcommand_run_pre_hooks(arguments): print('Aborted: Current domain is %s, but called for %s.' % (current_domain, arguments.domain)) sys.exit(2) + sys.exit(0) def subcommand_run_renew_hooks(arguments): """ - Execute all needed maintenance tasks when a cert is renewed. + Execute ALL maintenance tasks when (just after) certbot renews a cert, i.e. + run all tasks of every app/plinth module that supports usage of LE certs. If registered as certbot's renew-hook, this script gets ONLY executed when certbot successfully renewed a certificate; with Debian default config, this means it would run about once every 60 days (renewals get executed if a cert is <30 days before expiry, and current default is 90 days). + Errors will be logged by certbot to /var/log/letsencrypt/letsencrypt.log. """ # Require current domain, to avoid confusion (e.g. call from old cron job). if not arguments.domain: @@ -363,9 +400,66 @@ def subcommand_run_renew_hooks(arguments): if action_utils.service_is_running('apache2'): action_utils.service_restart('apache2') + for module in arguments.modules: + _run_action(module, ['letsencrypt', 'add']) # OK if only 1 module + # TODO: If >1 modules, collect errors and raise just one in the end, + # for certbot to log ALL failed attempts, not just 1st fail. + # try: + # _run_action(module, ['letsencrypt', 'add']) + # except Exception as err: + # pass + sys.exit(0) +def _run_action(action, action_options=None): + """ + Run a specific action from another module, for the run_renew_hooks command. + This function is a simplified version of plinth/actions.py, to enable + somewhat safe calls of other actions from outside the Plinth process. + The comments about the action contracts refer to plinth/actions.py. + """ + if action_options is None: + action_options = [] + + # Contract 3A and 3B: don't call anything outside of the actions directory. + # Assume the current path is the actions directly. + script_path = os.path.realpath(__file__) + actions_dir, _ = os.path.split(script_path) + if os.sep in action: + raise ValueError('Action cannot contain: ' + os.sep) + + cmd = os.path.join(actions_dir, action) + if not os.path.realpath(cmd).startswith(actions_dir): + raise ValueError('Action has to be in directory %s' % actions_dir) + + # Contract 3C: interpret shell escape sequences as literal file names. + # Contract 3E: fail if the action doesn't exist or exists elsewhere. + if not os.access(cmd, os.F_OK): + raise ValueError('Action must exist in action directory.') + + cmd = [cmd] + + # Contract: 3C, 3D: don't allow shell special characters in + # options be interpreted by the shell. + if action_options: + if not isinstance(action_options, (list, tuple)): + raise ValueError('Options must be list or tuple.') + + cmd += list(action_options) # No escaping necessary + + # Contract 3C: don't interpret shell escape sequences. + # Contract 5 (and 6-ish). + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + shell=False) + + output, error = proc.communicate() + output, error = output.decode(), error.decode() + if proc.returncode != 0: + raise ActionError(action, output, error) + + def subcommand_run_post_hooks(arguments): """ Execute all needed maintenance tasks AFTER a cert is obtained/renewed. @@ -382,6 +476,7 @@ def subcommand_run_post_hooks(arguments): print('Aborted: Current domain is %s, but called for %s.' % (current_domain, arguments.domain)) sys.exit(2) + sys.exit(0) diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py index 88134b1ba..6eec66181 100644 --- a/plinth/modules/letsencrypt/__init__.py +++ b/plinth/modules/letsencrypt/__init__.py @@ -23,10 +23,12 @@ from django.utils.translation import ugettext_lazy as _ from plinth import actions from plinth import action_utils from plinth import cfg +from plinth.errors import ActionError from plinth.menu import main_menu from plinth.modules import names from plinth.utils import format_lazy from plinth.signals import domainname_change +from plinth import module_loader version = 1 @@ -60,6 +62,9 @@ description = [ service = None +MODULES_WITH_HOOKS = ['ejabberd'] +LIVE_DIRECTORY = '/etc/letsencrypt/live/' + def init(): """Intialize the module.""" @@ -94,6 +99,29 @@ def on_domainname_change(sender, old_domainname, new_domainname, **kwargs): del new_domainname # Unused del kwargs # Unused + for module in MODULES_WITH_HOOKS: + actions.superuser_run(module, ['letsencrypt', 'drop', + '--domain', old_domainname], async=True) actions.superuser_run('letsencrypt', ['manage_hooks', 'disable', '--domain', old_domainname], async=True) + + +def get_manage_hooks_status(): + """Return status of hook management for current domain.""" + try: + output = actions.superuser_run('letsencrypt', + ['manage_hooks', 'status']) + except ActionError: + return False + + return output.strip() + + +def get_installed_modules(): + installed_modules = [module_name for module_name, module in + module_loader.loaded_modules.items() + if module_name in MODULES_WITH_HOOKS + and module.setup_helper.get_state() == 'up-to-date'] + + return installed_modules diff --git a/plinth/modules/letsencrypt/templates/letsencrypt.html b/plinth/modules/letsencrypt/templates/letsencrypt.html index 47c37b772..536eba7f5 100644 --- a/plinth/modules/letsencrypt/templates/letsencrypt.html +++ b/plinth/modules/letsencrypt/templates/letsencrypt.html @@ -34,6 +34,9 @@ {% endblock %} {% block configuration %} + +
+ {% blocktrans trimmed %} + If you have a Let's Encrypt certificate for your current domain, you may + let {{ box_name }} manage its renewal process. This also enables other apps + to use that certificate, so most users would not prompted with security + warnings when using them. + {% endblocktrans %} +
+ - {% include "diagnostics_button.html" with module="letsencrypt" enabled=True %} + + {% else %} {% blocktrans trimmed %} No domains have been configured. Configure domains to be able to diff --git a/plinth/modules/letsencrypt/urls.py b/plinth/modules/letsencrypt/urls.py index e2d22cf03..dbf8476cd 100644 --- a/plinth/modules/letsencrypt/urls.py +++ b/plinth/modules/letsencrypt/urls.py @@ -33,4 +33,6 @@ urlpatterns = [ name='delete'), url(r'^sys/letsencrypt/toggle_hooks/(?P