diff --git a/actions/letsencrypt b/actions/letsencrypt index c3c7e7a38..4b0de375c 100755 --- a/actions/letsencrypt +++ b/actions/letsencrypt @@ -61,6 +61,11 @@ def parse_arguments(): subparsers.add_parser('get-status', help='Return the status of configured domains.') + subparser = subparsers.add_parser( + 'get-modified-time', + help='Return the modified time for a certificate.') + subparser.add_argument('--domain', required=True, + help='Domain name to get modified time for') revoke_parser = subparsers.add_parser( 'revoke', help='Revoke certificate of a domain and disable website.') revoke_parser.add_argument('--domain', required=True, @@ -133,6 +138,12 @@ def get_certificate_expiry(domain): return output.decode().strip().split('=')[1] +def get_modified_time(domain): + """Return the last modified time of a certificate.""" + certificate_file = pathlib.Path(le.LIVE_DIRECTORY) / domain / 'cert.pem' + return int(certificate_file.stat().st_mtime) + + def get_validity_status(domain): """Return validity status of a certificate, e.g. valid, revoked, expired""" output = subprocess.check_output(['certbot', 'certificates', '-d', domain]) @@ -176,7 +187,9 @@ def get_status(): 'validity': get_validity_status(domain), 'lineage': - str(pathlib.Path(le.LIVE_DIRECTORY) / domain) + str(pathlib.Path(le.LIVE_DIRECTORY) / domain), + 'modified_time': + get_modified_time(domain) } return domain_status @@ -205,6 +218,11 @@ def subcommand_get_status(_): print(json.dumps({'domains': domain_status})) +def subcommand_get_modified_time(arguments): + """Print the modified time of a certificate as integer.""" + print(get_modified_time(arguments.domain)) + + def subcommand_revoke(arguments): """Disable a domain and revoke the certificate.""" domain = arguments.domain diff --git a/plinth/modules/letsencrypt/__init__.py b/plinth/modules/letsencrypt/__init__.py index a6bd1d042..4a9864ba8 100644 --- a/plinth/modules/letsencrypt/__init__.py +++ b/plinth/modules/letsencrypt/__init__.py @@ -20,6 +20,7 @@ FreedomBox app for using Let's Encrypt. import json import logging +import pathlib from django.utils.translation import ugettext_lazy as _ @@ -28,7 +29,8 @@ from plinth import app as app_module from plinth import cfg, menu from plinth.errors import ActionError from plinth.modules import names -from plinth.signals import domain_added, domain_removed, domainname_change +from plinth.signals import (domain_added, domain_removed, domainname_change, + post_module_loading) from plinth.utils import format_lazy from . import components @@ -64,6 +66,7 @@ description = [ manual_page = 'LetsEncrypt' LIVE_DIRECTORY = '/etc/letsencrypt/live/' +CERTIFICATE_CHECK_DELAY = 120 logger = logging.getLogger(__name__) app = None @@ -93,6 +96,8 @@ def init(): domain_added.connect(on_domain_added) domain_removed.connect(on_domain_removed) + post_module_loading.connect(_certificate_handle_modified) + def setup(helper, old_version=None): """Install and configure the module.""" @@ -207,3 +212,65 @@ def get_status(): status['domains'].setdefault(domain, {}) return status + + +def _certificate_handle_modified(**kwargs): + """Generate events for certificates that got modified during downtime. + + This runs as a synchronous method soon after initializing the apps. After + this is done, remaining initialization happens. + + This method is a wrapper over the read method to catch and print + exceptions. + + """ + logger.info('Checking if any Let\'s Encrypt certificates got renewed.') + try: + _certificate_handle_modified_internal() + except Exception: + logger.exception('Error triggering certificate events.') + + +def _certificate_handle_modified_internal(): + """Generate events for certificates that got modified during downtime.""" + status = get_status() + for domain, domain_status in status['domains'].items(): + if not domain_status: + continue + + lineage = domain_status['lineage'] + modified_time = domain_status['modified_time'] + if certificate_get_last_seen_modified_time(lineage) < modified_time: + logger.info('Certificate for %s got renewed offline.', domain) + components.on_certificate_event_sync('renewed', domain, lineage) + else: + logger.info('Certificate for %s is already the latest known.', + domain) + + +def certificate_get_last_seen_modified_time(lineage): + """Return the last seen expiry date of a certificate.""" + from plinth import kvstore + info = kvstore.get_default('letsencrypt_certificate_info', '{}') + info = json.loads(info) + try: + return info[str(lineage)]['last_seen_modified_time'] + except KeyError: + return 0 + + +def certificate_set_last_seen_modified_time(lineage): + """Write to store a certificate's last seen expiry date.""" + lineage = pathlib.Path(lineage) + output = actions.superuser_run( + 'letsencrypt', ['get-modified-time', '--domain', lineage.name]) + modified_time = int(output) + + from plinth import kvstore + info = kvstore.get_default('letsencrypt_certificate_info', '{}') + info = json.loads(info) + + certificate_info = info.setdefault(str(lineage), {}) + certificate_info['last_seen_modified_time'] = modified_time + + kvstore.set('letsencrypt_certificate_info', json.dumps(info)) diff --git a/plinth/modules/letsencrypt/components.py b/plinth/modules/letsencrypt/components.py index bad5bf902..f7a69cab0 100644 --- a/plinth/modules/letsencrypt/components.py +++ b/plinth/modules/letsencrypt/components.py @@ -398,3 +398,7 @@ def on_certificate_event_sync(event, domains, lineage): logger.exception( 'Error executing certificate hook for %s: %s, %s, %s: %s', component.component_id, event, domains, lineage, exception) + + if event in ('obtained', 'renewed'): + from plinth.modules import letsencrypt + letsencrypt.certificate_set_last_seen_modified_time(lineage)