letsencrypt: Add option to let Plinth "manage" certbot's renewal hooks

- add action commands for managing and running hooks

- adding template, view and url for managing and running hooks

- disable Plinth hook management on domain change
This commit is contained in:
Johannes Keyser 2017-07-02 19:11:57 +02:00 committed by James Valleroy
parent 1d4289068a
commit 1069f84154
No known key found for this signature in database
GPG Key ID: 77C0C75E7B650808
6 changed files with 334 additions and 6 deletions

View File

@ -26,11 +26,17 @@ import os
import subprocess import subprocess
import sys import sys
import re import re
import configobj
import shutil
from plinth import action_utils from plinth import action_utils
from plinth.modules.config import config
TEST_MODE = False TEST_MODE = False
LIVE_DIRECTORY = '/etc/letsencrypt/live/' 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_PREFIX = '/etc/apache2/sites-available/'
APACHE_CONFIGURATION = ''' APACHE_CONFIGURATION = '''
<IfModule mod_gnutls.c> <IfModule mod_gnutls.c>
@ -91,6 +97,33 @@ def parse_arguments():
delete_parser.add_argument('--domain', required=True, delete_parser.add_argument('--domain', required=True,
help='Domain name to delete certificate of') 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 subparsers.required = True
return parser.parse_args() return parser.parse_args()
@ -104,7 +137,7 @@ def get_certificate_expiry(domain):
def get_validity_status(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 = subprocess.check_output(['certbot', 'certificates', '-d', domain])
output = output.decode(sys.stdout.encoding) output = output.decode(sys.stdout.encoding)
@ -119,8 +152,11 @@ def get_validity_status(domain):
return validity return validity
def subcommand_get_status(_): def get_status():
"""Return a JSON dictionary of currently configured domains.""" """
Return Python dictionary of currently configured domains.
Should be run as root, otherwise might yield a wrong, empty answer.
"""
try: try:
domains = os.listdir(LIVE_DIRECTORY) domains = os.listdir(LIVE_DIRECTORY)
except OSError: except OSError:
@ -138,7 +174,12 @@ def subcommand_get_status(_):
action_utils.webserver_is_enabled(domain, kind='site'), action_utils.webserver_is_enabled(domain, kind='site'),
'validity': get_validity_status(domain) '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})) print(json.dumps({'domains': domain_status}))
@ -168,7 +209,7 @@ def subcommand_obtain(arguments):
command = [ command = [
'certbot', 'certonly', '--text', '--agree-tos', 'certbot', 'certonly', '--text', '--agree-tos',
'--register-unsafely-without-email', '--domain', arguments.domain, '--register-unsafely-without-email', '--domain', arguments.domain,
'--authenticator', 'webroot', '--webroot-path', '/var/www/html/', '--authenticator', AUTHENTICATOR, '--webroot-path', WEB_ROOT_PATH,
'--renew-by-default'] '--renew-by-default']
if TEST_MODE: if TEST_MODE:
command.append('--staging') command.append('--staging')
@ -185,6 +226,164 @@ def subcommand_obtain(arguments):
action_utils.webserver_enable(domain, kind='site') 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): def subcommand_delete(arguments):
"""Disable a domain and delete the certificate.""" """Disable a domain and delete the certificate."""
domain = arguments.domain domain = arguments.domain

View File

@ -20,12 +20,13 @@ Plinth module for using Let's Encrypt.
""" """
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from plinth import actions
from plinth import action_utils from plinth import action_utils
from plinth import cfg from plinth import cfg
from plinth.menu import main_menu from plinth.menu import main_menu
from plinth.modules import names from plinth.modules import names
from plinth.utils import format_lazy from plinth.utils import format_lazy
from plinth.signals import domainname_change
version = 1 version = 1
@ -63,6 +64,7 @@ def init():
menu = main_menu.get('system') menu = main_menu.get('system')
menu.add_urlname(_('Certificates (Let\'s Encrypt)'), menu.add_urlname(_('Certificates (Let\'s Encrypt)'),
'glyphicon-lock', 'letsencrypt:index') 'glyphicon-lock', 'letsencrypt:index')
domainname_change.connect(on_domainname_change)
def setup(helper, old_version=None): def setup(helper, old_version=None):
@ -82,3 +84,14 @@ def diagnose():
results.append(action_utils.diagnose_url('https://' + domain)) results.append(action_utils.diagnose_url('https://' + domain))
return results 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)

View File

@ -49,7 +49,13 @@
<tbody> <tbody>
{% for domain, domain_status in status.domains.items %} {% for domain, domain_status in status.domains.items %}
<tr> <tr>
<td>{{ domain }}</td> <td>
{% if domain == status.current_domain.name %}
<b>{{ domain }}</b>
{% else %}
{{ domain }}
{% endif %}
</td>
<td> <td>
{% if domain_status.certificate_available and domain_status.validity == "valid" %} {% if domain_status.certificate_available and domain_status.validity == "valid" %}
<span class="label label-success"> <span class="label label-success">
@ -128,6 +134,62 @@
</div> </div>
</div> </div>
<form class="form" method="post"
action="{% url 'letsencrypt:toggle_hooks' status.current_domain.name %}">
{% csrf_token %}
<div class="checkbox">
<label>
{% if status.current_domain.name and status.current_domain.has_cert %}
<input type="checkbox" name="toggle_hooks" id="id_toggle_hooks"
{% if status.current_domain.manage_hooks_enabled %}checked{% endif %}
onchange="this.form.submit();">
</input>
<noscript>
<button class="btn btn-sm btn-default" type="submit">
{% trans "Update config" %}</button>
</noscript>
{% else %}
<input type="checkbox" name="toggle_hooks" id="id_toggle_hooks"
class="disabled"></input>
<noscript>
<button class="btn btn-sm btn-default disabled" type="submit">
{% trans "Update config" %}</button>
</noscript>
{% endif %}
<span>
{% if status.current_domain.name %}
{% blocktrans with current_domain=status.current_domain.name %}
Let Plinth manage certificate renewal of
<b>{{ current_domain }}</b> (recommmended)
{% endblocktrans %}
{% else %}
{% blocktrans with current_domain=status.current_domain.name %}
Let Plinth manage certificate renewal of the current domain (recommmended)
{% endblocktrans %}
{% endif %}
</span>
</label>
</div>
<p class="help-block">
{% 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 %}
<b>No certificate available for the current domain.</b>
First obtain a certificate to enable management of its renewal.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
<b>No current domain is configured.</b>
First configure a domain to enable management of its certificates.
{% endblocktrans %}
{% endif %}
</p>
</form>
{% include "diagnostics_button.html" with module="letsencrypt" enabled=True %} {% include "diagnostics_button.html" with module="letsencrypt" enabled=True %}
{% else %} {% else %}
{% blocktrans trimmed %} {% blocktrans trimmed %}

View File

@ -31,4 +31,6 @@ urlpatterns = [
name='obtain'), name='obtain'),
url(r'^sys/letsencrypt/delete/(?P<domain>[^/]+)/$', views.delete, url(r'^sys/letsencrypt/delete/(?P<domain>[^/]+)/$', views.delete,
name='delete'), name='delete'),
url(r'^sys/letsencrypt/toggle_hooks/(?P<domain>[^/]+)/$',
views.toggle_hooks, name='toggle_hooks'),
] ]

View File

@ -32,6 +32,7 @@ from plinth import actions
from plinth.errors import ActionError from plinth.errors import ActionError
from plinth.modules import letsencrypt from plinth.modules import letsencrypt
from plinth.modules import names from plinth.modules import names
from plinth.modules.config import config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -69,14 +70,48 @@ def obtain(request, domain):
"""Obtain and install a certificate for a given domain.""" """Obtain and install a certificate for a given domain."""
try: try:
actions.superuser_run('letsencrypt', ['obtain', '--domain', domain]) actions.superuser_run('letsencrypt', ['obtain', '--domain', domain])
actions.superuser_run('letsencrypt', ['manage_hooks', 'enable'])
messages.success( messages.success(
request, _('Certificate successfully obtained for domain {domain}') request, _('Certificate successfully obtained for domain {domain}')
.format(domain=domain)) .format(domain=domain))
successful_obtain = True
except ActionError as exception: except ActionError as exception:
messages.error( messages.error(
request, request,
_('Failed to obtain certificate for domain {domain}: {error}') _('Failed to obtain certificate for domain {domain}: {error}')
.format(domain=domain, error=exception.args[2])) .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')) return redirect(reverse_lazy('letsencrypt:index'))
@ -102,6 +137,12 @@ def get_status():
"""Get the current settings.""" """Get the current settings."""
status = actions.superuser_run('letsencrypt', ['get-status']) status = actions.superuser_run('letsencrypt', ['get-status'])
status = json.loads(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(): for domain_type, domains in names.domains.items():
# XXX: Remove when Let's Encrypt supports .onion addresses # XXX: Remove when Let's Encrypt supports .onion addresses
@ -112,3 +153,13 @@ def get_status():
status['domains'].setdefault(domain, {}) status['domains'].setdefault(domain, {})
return status 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'

View File

@ -6,3 +6,4 @@ psutil
python-apt python-apt
python-augeas python-augeas
ruamel.yaml ruamel.yaml
configobj