#!/usr/bin/python #-*- coding:utf-8 -*- # pylint: disable=invalid-name """Renew Let's Encrypt certs! To generate a new set of certs: $ certbot certonly --webroot \\ --webroot-path /var/www/wh0rd/ -d wh0rd.org -d www.wh0rd.org \\ --webroot-path /var/www/rss/ -d rss.wh0rd.org """ from __future__ import print_function import argparse try: import configparser except ImportError: import ConfigParser as configparser import cryptography.hazmat.backends from cryptography import x509 try: from cStringIO import StringIO except ImportError: from io import StringIO import datetime import logging import logging.handlers import os import subprocess import sys LE_BASE = '/etc/letsencrypt' def get_parser(): """Return an ArgumentParser() for this module.""" parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter) parser.add_argument('-n', '--dry-run', default=False, action='store_true', help='Do not actually update certs') parser.add_argument('--cronjob', default=False, action='store_true', help='Exit non-zero if no certs were changed') return parser def setup_logging(debug=False, syslog=None): """Setup the logging module just the way we like it.""" if syslog is None: syslog = not os.isatty(sys.stdin.fileno()) if syslog: handler = logging.handlers.SysLogHandler(address='/dev/log') else: handler = logging.StreamHandler(stream=sys.stdout) fmt = '%(asctime)s: %(levelname)-7s: %(message)s' datefmt = '%a, %d %b %Y %H:%M:%S letsencrypt' level = logging.DEBUG if debug else logging.INFO formatter = logging.Formatter(fmt, datefmt) handler.setFormatter(formatter) logger = logging.getLogger() logger.addHandler(handler) logger.setLevel(level) def load_cert(path): """Load the cert at |path|""" with open(path, 'rb') as f: data = f.read() return x509.load_pem_x509_certificate( data, cryptography.hazmat.backends.default_backend()) def load_live_cert(domain): """Load the live cert for |domain|""" path = os.path.join(LE_BASE, 'live', domain, 'cert.pem') return load_cert(path) def load_conf(domain): """Load the LE config file for |domain|""" path = os.path.join(LE_BASE, 'renewal', domain + '.conf') # The config file format is almost enough for the configparser. # We need to insert a section header for the first few items. fp = StringIO() fp.write('[globals]\n') fp.write(open(path).read()) fp.seek(0) conf = configparser.RawConfigParser() conf.readfp(fp) return conf def process_domain(domain, dry_run=False): """Update |domain|'s certs as needed.""" ret = 0 logging.info('%s: checking', domain) conf = load_conf(domain) try: webroot_path = conf.get('[webroot_map', domain) except configparser.NoOptionError: webroot_path = conf.get('renewalparams', 'webroot_path') # The conf writing has a bug here where it appends a comma. webroot_path = webroot_path.rstrip(',') cert_path = os.path.realpath(conf.get('globals', 'cert')) cert = load_cert(cert_path) delta = cert.not_valid_after - datetime.datetime.utcnow() logging.info('%s: expires in %2s days', domain, delta.days) cmd = [ 'certbot', 'certonly', '--webroot', '--webroot-path', webroot_path, '-d', domain, ] domains = [] try: san = cert.extensions.get_extension_for_oid( x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME) domains = san.value.get_values_for_type(x509.DNSName) except x509.ExtensionNotFound: pass for d in domains: cmd += ['-d', d] if delta.days < 30: logging.info('%s: renewing', domain) logging.info('%s: %s', domain, ' '.join(cmd)) if not dry_run: try: subprocess.check_call(cmd) except subprocess.CalledProcessError: logging.error('failed', exc_info=True) return 0 ret = 1 # Try to revoke the old one. cmd = ['certbot', 'revoke', '--no-delete-after-revoke', '--cert-path', cert_path] logging.info('%s: revoking old cert', domain) logging.info('%s: %s', domain, ' '.join(cmd)) if not dry_run: try: subprocess.check_call(cmd, stdin=open('/dev/null', 'r')) except subprocess.CalledProcessError: logging.error('failed', exc_info=True) else: logging.info('%s: up-to-date!', domain) return ret def main(argv): """The main() entry point!""" parser = get_parser() opts = parser.parse_args(argv) setup_logging() cnt = 0 domains = [x[:-5] for x in os.listdir('/etc/letsencrypt/renewal')] for domain in domains: cnt += process_domain(domain, dry_run=opts.dry_run) if opts.cronjob: if cnt: return 0 else: return 1 if __name__ == '__main__': sys.exit(main(sys.argv[1:]))