diff --git a/actions/letsencrypt b/actions/letsencrypt index 0baa1e7b9..d7da00e42 100755 --- a/actions/letsencrypt +++ b/actions/letsencrypt @@ -26,11 +26,17 @@ import os import subprocess import sys import re +import configobj +import shutil from plinth import action_utils +from plinth.modules.config import config TEST_MODE = False LIVE_DIRECTORY = '/etc/letsencrypt/live/' +RENEWAL_DIRECTORY = '/etc/letsencrypt/renewal/' +AUTHENTICATOR = 'webroot' +WEB_ROOT_PATH = '/var/www/html' APACHE_PREFIX = '/etc/apache2/sites-available/' APACHE_CONFIGURATION = ''' @@ -91,6 +97,33 @@ def parse_arguments(): delete_parser.add_argument('--domain', required=True, help='Domain name to delete certificate of') + help_hooks = 'Plinth management of certificates (for current domain).' + manage_hook_parser = subparsers.add_parser('manage_hooks', help=help_hooks) + manage_hook_parser.add_argument('command', help=help_hooks, + choices=('enable', 'disable', 'status')) + manage_hook_parser.add_argument('--domain', + help='Domain for hook management command.') + + help_domain_arg = 'Domain name to run the hook scripts with.' + help_module_arg = 'Include hooks from the provided module names.' + help_pre_hooks = 'Maintenance tasks before a cert is obtained or renewed.' + 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) + + 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) + + 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) + subparsers.required = True return parser.parse_args() @@ -104,7 +137,7 @@ def get_certificate_expiry(domain): def get_validity_status(domain): - """Return validity status of a certificate, e.g. valid, revoked, expired.""" + """Return validity status of a certificate, e.g. valid, revoked, expired""" output = subprocess.check_output(['certbot', 'certificates', '-d', domain]) output = output.decode(sys.stdout.encoding) @@ -119,8 +152,11 @@ def get_validity_status(domain): return validity -def subcommand_get_status(_): - """Return a JSON dictionary of currently configured domains.""" +def get_status(): + """ + Return Python dictionary of currently configured domains. + Should be run as root, otherwise might yield a wrong, empty answer. + """ try: domains = os.listdir(LIVE_DIRECTORY) except OSError: @@ -138,7 +174,12 @@ def subcommand_get_status(_): action_utils.webserver_is_enabled(domain, kind='site'), 'validity': get_validity_status(domain) } + return domain_status + +def subcommand_get_status(_): + """Print a JSON dictionary of currently configured domains.""" + domain_status = get_status() print(json.dumps({'domains': domain_status})) @@ -168,7 +209,7 @@ def subcommand_obtain(arguments): command = [ 'certbot', 'certonly', '--text', '--agree-tos', '--register-unsafely-without-email', '--domain', arguments.domain, - '--authenticator', 'webroot', '--webroot-path', '/var/www/html/', + '--authenticator', AUTHENTICATOR, '--webroot-path', WEB_ROOT_PATH, '--renew-by-default'] if TEST_MODE: command.append('--staging') @@ -185,6 +226,164 @@ def subcommand_obtain(arguments): action_utils.webserver_enable(domain, kind='site') +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, + and creates a backup beforehand that is restored if disable is called. + Commands disable and status work without error on any domain string. + """ + if arguments.command not in ('enable', 'disable', 'status'): + print('Aborted: Unknown command "%s".' % arguments.command) + sys.exit(1) + + cmd_is_enable = arguments.command == 'enable' + current_domain = config.get_domainname() + if not arguments.domain: + arguments.domain = current_domain + + if cmd_is_enable: + if not current_domain: + print('Aborted: No current domain set.') + sys.exit(2) + + if not arguments.domain == current_domain: + print('Aborted: Passed domain (%s) is not current domain (%s).' % + (arguments.domain, current_domain)) + sys.exit(3) + + domain_status = get_status() + if current_domain not in domain_status: + print('Aborted: Current domain (%s) has no LE certificate.' % + current_domain) + sys.exit(4) + + config_path = RENEWAL_DIRECTORY + arguments.domain + '.conf' + if not os.path.exists(config_path): + msg, code = ('Aborted', 5) if cmd_is_enable else ('Disabled', 0) + print('%s: No certbot config file at %s.' % (msg, config_path)) + sys.exit(code) + + config_certbot = configobj.ConfigObj(config_path) + if 'renewalparams' not in config_certbot: + msg, code = ('Aborted', 6) if cmd_is_enable else ('Disabled', 0) + print('%s: No section [renewalparams] in config file at %s.' + % (msg, config_path)) + sys.exit(code) + + script_path = os.path.realpath(__file__) + config_backup_path = config_path + '_plinth_backup' + call_pre = script_path + ' run_pre_hooks --domain ' + arguments.domain + call_renew = script_path + ' run_renew_hooks --domain ' + arguments.domain + call_post = script_path + ' run_post_hooks --domain ' + arguments.domain + config_plinth = {'renewalparams': + {'authenticator': AUTHENTICATOR, + 'webroot_path': [WEB_ROOT_PATH], + 'webroot_map': {arguments.domain: WEB_ROOT_PATH}, + 'installer': 'None', + 'pre_hook': call_pre, + 'renew_hook': call_renew, + 'post_hook': call_post}} + comment_plinth = '# This file was edited by Plinth.' + config_edited_by_plinth = any(['edited by plinth' in line.lower() + for line in config_certbot.initial_comment]) + + if arguments.command == 'status': + 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: + print('disabled') + + elif arguments.command == 'enable': + if not config_edited_by_plinth: + shutil.copy(config_path, config_backup_path) + config_certbot.initial_comment.append(comment_plinth) + + config_certbot['renewalparams'].update(config_plinth['renewalparams']) + config_certbot.write() + print('enabled successfully') + + elif arguments.command == 'disable': + # if changed, restore from backup; refuse disabling if no backup exists + if not config_edited_by_plinth: + print('Disabled: Nothing to do, hook management was not enabled.') + elif os.path.exists(config_backup_path): + shutil.move(config_backup_path, config_path) + print('disabled successfully') + else: + print('Aborted: No backup config file at %s.' % config_backup_path) + sys.exit(7) + + sys.exit(0) + + +def subcommand_run_pre_hooks(arguments): + """ + Execute all needed maintenance tasks BEFORE a cert is obtained/renewed. + If registered as certbot's pre-hook, this script gets ALWAYS executed when + certbot attempts a renewal, irrespective of necessity/success (2x per day). + """ + # Require current domain, to avoid confusion (e.g. call from old cron job). + if not arguments.domain: + print('Aborted: You must specify the current domain.') + sys.exit(1) + + current_domain = config.get_domainname() + if not arguments.domain == current_domain: + 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. + 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). + """ + # Require current domain, to avoid confusion (e.g. call from old cron job). + if not arguments.domain: + print('Aborted: You must specify the current domain.') + sys.exit(1) + + current_domain = config.get_domainname() + if not arguments.domain == current_domain: + print('Aborted: Current domain is %s, but called for %s.' % + (current_domain, arguments.domain)) + sys.exit(2) + + if action_utils.service_is_running('apache2'): + action_utils.service_restart('apache2') + + sys.exit(0) + + +def subcommand_run_post_hooks(arguments): + """ + Execute all needed maintenance tasks AFTER a cert is obtained/renewed. + If registered as certbot's post-hook, this script gets ALWAYS executed when + certbot attempts a renewal, irrespective of necessity/success (2x per day). + """ + # Require current domain, to avoid confusion (e.g. call from old cron job). + if not arguments.domain: + print('Aborted: You must specify the current domain.') + sys.exit(1) + + current_domain = config.get_domainname() + if not arguments.domain == current_domain: + print('Aborted: Current domain is %s, but called for %s.' % + (current_domain, arguments.domain)) + sys.exit(2) + sys.exit(0) + + def subcommand_delete(arguments): """Disable a domain and delete the certificate.""" domain = arguments.domain diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py index 3f9978f37..5e1870165 100644 --- a/plinth/modules/letsencrypt/__init__.py +++ b/plinth/modules/letsencrypt/__init__.py @@ -20,12 +20,13 @@ Plinth module for using Let's Encrypt. """ from django.utils.translation import ugettext_lazy as _ - +from plinth import actions from plinth import action_utils from plinth import cfg from plinth.menu import main_menu from plinth.modules import names from plinth.utils import format_lazy +from plinth.signals import domainname_change version = 1 @@ -63,6 +64,7 @@ def init(): menu = main_menu.get('system') menu.add_urlname(_('Certificates (Let\'s Encrypt)'), 'glyphicon-lock', 'letsencrypt:index') + domainname_change.connect(on_domainname_change) def setup(helper, old_version=None): @@ -82,3 +84,14 @@ def diagnose(): results.append(action_utils.diagnose_url('https://' + domain)) return results + + +def on_domainname_change(sender, old_domainname, new_domainname, **kwargs): + """Disable renewal hook management after a domain name change.""" + del sender # Unused + del new_domainname # Unused + del kwargs # Unused + + actions.superuser_run('letsencrypt', ['manage_hooks', 'disable', + '--domain', old_domainname], + async=True) diff --git a/plinth/modules/letsencrypt/templates/letsencrypt.html b/plinth/modules/letsencrypt/templates/letsencrypt.html index 4ec299a6c..6d5da3d79 100644 --- a/plinth/modules/letsencrypt/templates/letsencrypt.html +++ b/plinth/modules/letsencrypt/templates/letsencrypt.html @@ -49,7 +49,13 @@ {% for domain, domain_status in status.domains.items %} - {{ domain }} + + {% if domain == status.current_domain.name %} + {{ domain }} + {% else %} + {{ domain }} + {% endif %} + {% if domain_status.certificate_available and domain_status.validity == "valid" %} @@ -128,6 +134,62 @@ +
+ {% csrf_token %} +
+ +
+

+ {% if status.current_domain.name and status.current_domain.has_cert %} + {% blocktrans trimmed %} + If {{ box_name }} manages the certificate renewal for the current domain, + it will ensure that all apps that use the certificate can use it, as soon + as it gets renewed. + {% endblocktrans %} + {% elif not status.current_domain.has_cert %} + {% blocktrans trimmed %} + No certificate available for the current domain. + First obtain a certificate to enable management of its renewal. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed %} + No current domain is configured. + First configure a domain to enable management of its certificates. + {% endblocktrans %} + {% endif %} +

+
{% include "diagnostics_button.html" with module="letsencrypt" enabled=True %} {% else %} {% blocktrans trimmed %} diff --git a/plinth/modules/letsencrypt/urls.py b/plinth/modules/letsencrypt/urls.py index 00e8bed47..e2d22cf03 100644 --- a/plinth/modules/letsencrypt/urls.py +++ b/plinth/modules/letsencrypt/urls.py @@ -31,4 +31,6 @@ urlpatterns = [ name='obtain'), url(r'^sys/letsencrypt/delete/(?P[^/]+)/$', views.delete, name='delete'), + url(r'^sys/letsencrypt/toggle_hooks/(?P[^/]+)/$', + views.toggle_hooks, name='toggle_hooks'), ] diff --git a/plinth/modules/letsencrypt/views.py b/plinth/modules/letsencrypt/views.py index 5b5e78ca1..e86826178 100644 --- a/plinth/modules/letsencrypt/views.py +++ b/plinth/modules/letsencrypt/views.py @@ -32,6 +32,7 @@ from plinth import actions from plinth.errors import ActionError from plinth.modules import letsencrypt from plinth.modules import names +from plinth.modules.config import config logger = logging.getLogger(__name__) @@ -69,14 +70,48 @@ def obtain(request, domain): """Obtain and install a certificate for a given domain.""" try: actions.superuser_run('letsencrypt', ['obtain', '--domain', domain]) + actions.superuser_run('letsencrypt', ['manage_hooks', 'enable']) messages.success( request, _('Certificate successfully obtained for domain {domain}') .format(domain=domain)) + successful_obtain = True except ActionError as exception: messages.error( request, _('Failed to obtain certificate for domain {domain}: {error}') .format(domain=domain, error=exception.args[2])) + successful_obtain = False + + if domain == config.get_domainname() and successful_obtain: + try: + actions.superuser_run('letsencrypt', ['manage_hooks', 'enable']) + messages.success( + request, _('Certificate management enabled for {domain}.') + .format(domain=domain)) + except ActionError as exception: + messages.error( + request, + _('Failed to enable certificate management for {domain}: ' + '{error}') + .format(domain=domain, error=exception.args[2])) + + return redirect(reverse_lazy('letsencrypt:index')) + + +@require_POST +def toggle_hooks(request, domain): + """Toggle pointing of certbot's hooks to Plinth, for the current domain.""" + subcommand = 'disable' if _hooks_manage_enabled() else 'enable' + try: + actions.superuser_run('letsencrypt', ['manage_hooks', subcommand]) + messages.success( + request, _('Certificate management changed for domain {domain}') + .format(domain=domain)) + except ActionError as exception: + messages.error( + request, + _('Failed to switch certificate management for {domain}: {error}') + .format(domain=domain, error=exception.args[2])) return redirect(reverse_lazy('letsencrypt:index')) @@ -102,6 +137,12 @@ def get_status(): """Get the current settings.""" status = actions.superuser_run('letsencrypt', ['get-status']) status = json.loads(status) + curr_dom = config.get_domainname() + current_domain = {'name': curr_dom, + 'has_cert': curr_dom in status['domains'] and + status['domains'][curr_dom]['certificate_available'], + 'manage_hooks_enabled': _hooks_manage_enabled()} + status['current_domain'] = current_domain for domain_type, domains in names.domains.items(): # XXX: Remove when Let's Encrypt supports .onion addresses @@ -112,3 +153,13 @@ def get_status(): status['domains'].setdefault(domain, {}) return status + + +def _hooks_manage_enabled(): + """Return status of hook management for current domain.""" + try: + output = actions.superuser_run('letsencrypt', + ['manage_hooks', 'status']) + except ActionError: + return False + return output.strip() == 'enabled' diff --git a/requirements.txt b/requirements.txt index 7f1697565..2e39357e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ psutil python-apt python-augeas ruamel.yaml +configobj