diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py index 6eec66181..835714a7b 100644 --- a/plinth/modules/letsencrypt/__init__.py +++ b/plinth/modules/letsencrypt/__init__.py @@ -14,22 +14,25 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ Plinth module for using Let's Encrypt. """ +import json +import logging + 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.modules.config import config from plinth.utils import format_lazy -from plinth.signals import domainname_change from plinth import module_loader - +from plinth.signals import domainname_change, domain_added, domain_removed version = 1 @@ -50,8 +53,8 @@ description = [ '{box_name} can automatically obtain and setup digital ' 'certificates for each available domain. It does so by proving ' 'itself to be the owner of a domain to Let\'s Encrypt, a ' - 'certificate authority (CA).'), box_name=_(cfg.box_name)), - + 'certificate authority (CA).'), + box_name=_(cfg.box_name)), _('Let\'s Encrypt is a free, automated, and open certificate ' 'authority, run for the public\'s benefit by the Internet Security ' 'Research Group (ISRG). Please read and agree with the ' @@ -59,11 +62,11 @@ description = [ 'Subscriber Agreement before using this service.') ] - service = None MODULES_WITH_HOOKS = ['ejabberd'] LIVE_DIRECTORY = '/etc/letsencrypt/live/' +logger = logging.getLogger(__name__) def init(): @@ -72,6 +75,8 @@ def init(): menu.add_urlname(name, 'glyphicon-lock', 'letsencrypt:index', short_description) domainname_change.connect(on_domainname_change) + domain_added.connect(on_domain_added) + domain_removed.connect(on_domain_removed) def setup(helper, old_version=None): @@ -93,6 +98,24 @@ def diagnose(): return results +def try_action(domain, action): + actions.superuser_run('letsencrypt', [action, '--domain', domain]) + + +def enable_renewal_management(domain): + if domain == config.get_domainname(): + try: + actions.superuser_run('letsencrypt', ['manage_hooks', 'enable']) + logger.info( + _('Certificate renewal management enabled for {domain}.') + .format(domain=domain)) + except ActionError as exception: + logger.error( + _('Failed to enable certificate renewal management for ' + '{domain}: {error}').format( + domain=domain, error=exception.args[2])) + + def on_domainname_change(sender, old_domainname, new_domainname, **kwargs): """Disable renewal hook management after a domain name change.""" del sender # Unused @@ -125,3 +148,65 @@ def get_installed_modules(): and module.setup_helper.get_state() == 'up-to-date'] return installed_modules + + +def on_domain_added(sender, domain_type='', name='', description='', + services=None, **kwargs): + """Obtain a certificate for the new domain""" + if domain_type == 'hiddenservice': + return False + + # Check if a cert if already available + for domain_name, domain_status in get_status()['domains'].items(): + if domain_name == name and \ + domain_status.certificate_available and \ + domain_status.validity == 'valid': + return False + + try: + # Obtaining certs during tests isn't expected to succeed + if sender != 'test': + try_action(name, 'obtain') + enable_renewal_management(name) + logger.info("Obtained a Let\'s Encrypt certificate for " + name) + return True + except ActionError as ex: + return False + + +def on_domain_removed(sender, domain_type, name='', **kwargs): + """Revoke Let's Encrypt certificate for the removed domain""" + try: + if sender != 'test' and name: + try_action(name, 'revoke') + logger.info("Revoked the Let\'s Encrypt certificate for " + name) + return True + except ActionError as exception: + logger.warn( + ('Failed to revoke certificate for domain {domain}: {error}') + .format(domain=name, error=exception.args[2])) + return False + + +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_status': get_manage_hooks_status() + } + status['current_domain'] = current_domain + + for domain_type, domains in names.domains.items(): + # XXX: Remove when Let's Encrypt supports .onion addresses + if domain_type == 'hiddenservice': + continue + + for domain in domains: + status['domains'].setdefault(domain, {}) + + return status diff --git a/plinth/modules/letsencrypt/tests/test_domain_name_changes.py b/plinth/modules/letsencrypt/tests/test_domain_name_changes.py new file mode 100644 index 000000000..0bc3fa563 --- /dev/null +++ b/plinth/modules/letsencrypt/tests/test_domain_name_changes.py @@ -0,0 +1,38 @@ +# +# This file is part of Plinth. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +""" +Tests for letsencrypt module. +""" + +import unittest + +from .. import on_domain_added, on_domain_removed + + +class TestDomainNameChanges(unittest.TestCase): + """Test for automatically obtaining and revoking Let's Encrypt certs""" + + def test_add_onion_domain(self): + self.assertFalse( + on_domain_added('test', 'hiddenservice', 'ddddd.onion')) + + def test_add_valid_domain(self): + self.assertTrue( + on_domain_added('test', 'domainname', 'subdomain.domain.tld')) + + def test_remove_domain(self): + self.assertTrue(on_domain_removed('test', '', 'somedomain.tld')) diff --git a/plinth/modules/letsencrypt/views.py b/plinth/modules/letsencrypt/views.py index b87a818af..0968fa81c 100644 --- a/plinth/modules/letsencrypt/views.py +++ b/plinth/modules/letsencrypt/views.py @@ -14,12 +14,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . # - """ Plinth module for using Let's Encrypt. """ -import json import logging from django.contrib import messages @@ -31,16 +29,15 @@ from django.views.decorators.http import require_POST from plinth import actions from plinth.errors import ActionError +from plinth.modules import config from plinth.modules import letsencrypt -from plinth.modules import names -from plinth.modules.config import config logger = logging.getLogger(__name__) def index(request): """Serve configuration page.""" - status = get_status() + status = letsencrypt.get_status() return TemplateResponse(request, 'letsencrypt.html', {'title': letsencrypt.name, 'description': letsencrypt.description, @@ -53,11 +50,12 @@ def index(request): def revoke(request, domain): """Revoke a certificate for a given domain.""" try: - actions.superuser_run('letsencrypt', ['revoke', '--domain', domain]) + letsencrypt.try_action(domain, 'revoke') messages.success( - request, _('Certificate successfully revoked for domain {domain}.' - 'This may take a few moments to take effect.') - .format(domain=domain)) + request, + _('Certificate successfully revoked for domain {domain}.' + 'This may take a few moments to take effect.').format( + domain=domain)) except ActionError as exception: messages.error( request, @@ -71,19 +69,22 @@ def revoke(request, domain): def obtain(request, domain): """Obtain and install a certificate for a given domain.""" try: - actions.superuser_run('letsencrypt', ['obtain', '--domain', domain]) + letsencrypt.try_action(domain, 'obtain') messages.success( - request, _('Certificate successfully obtained for domain {domain}') - .format(domain=domain)) - successful_obtain = True + request, + _('Certificate successfully obtained for domain {domain}').format( + domain=domain)) + enable_renewal_management(request, domain) 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 + return redirect(reverse_lazy('letsencrypt:index')) - if domain == config.get_domainname() and successful_obtain: + +def enable_renewal_management(request, domain): + if domain == config.get_domainname(): try: actions.superuser_run('letsencrypt', ['manage_hooks', 'enable']) messages.success( @@ -94,10 +95,8 @@ def obtain(request, domain): messages.error( request, _('Failed to enable certificate renewal management for ' - '{domain}: {error}') - .format(domain=domain, error=exception.args[2])) - - return redirect(reverse_lazy('letsencrypt:index')) + '{domain}: {error}').format( + domain=domain, error=exception.args[2])) @require_POST @@ -126,8 +125,7 @@ def toggle_hooks(request, domain): messages.error( request, _('Failed to switch certificate renewal management for {domain}: ' - '{error}') - .format(domain=domain, error=exception.args[2])) + '{error}').format(domain=domain, error=exception.args[2])) return redirect(reverse_lazy('letsencrypt:index')) @@ -183,14 +181,14 @@ def delete(request, domain): messages.error( request, _('Failed to disable certificate renewal management for {domain}: ' - '{error}') - .format(domain=domain, error=exception.args[2])) + '{error}').format(domain=domain, error=exception.args[2])) try: actions.superuser_run('letsencrypt', ['delete', '--domain', domain]) messages.success( - request, _('Certificate successfully deleted for domain {domain}') - .format(domain=domain)) + request, + _('Certificate successfully deleted for domain {domain}').format( + domain=domain)) except ActionError as exception: messages.error( request, @@ -198,27 +196,3 @@ def delete(request, domain): .format(domain=domain, error=exception.args[2])) return redirect(reverse_lazy('letsencrypt:index')) - - -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_status': letsencrypt.get_manage_hooks_status() - } - status['current_domain'] = current_domain - - for domain_type, domains in names.domains.items(): - # XXX: Remove when Let's Encrypt supports .onion addresses - if domain_type == 'hiddenservice': - continue - - for domain in domains: - status['domains'].setdefault(domain, {}) - - return status