#!/usr/bin/python2 # We need to force py2 until M2Crypto is ported: # https://gitlab.com/m2crypto/m2crypto/issues/114 # 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 try: from cStringIO import StringIO except ImportError: from io import StringIO import datetime import logging import logging.handlers import M2Crypto import os import pytz 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) as f: data = f.read() return M2Crypto.X509.load_cert_string(data) 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) webroot_path = conf.get('[webroot_map', domain) cert_path = os.path.realpath(conf.get('globals', 'cert')) cert = load_cert(cert_path) stamp = cert.get_not_after() now = pytz.timezone('UTC').localize(datetime.datetime.now()) delta = stamp.get_datetime() - now logging.info('%s: expires in %2s days', domain, delta.days) cmd = [ 'certbot', 'certonly', '--webroot', '--webroot-path', webroot_path, '-d', domain, ] san = cert.get_ext('subjectAltName').get_value() domains = [x.strip()[4:] for x in san.split(',')] domains.remove(domain) for d in domains: cmd += ['-d', d] if delta.days < 30: logging.info('%s: renewing', domain) logging.info('%s: %s', domain, cmd) if not dry_run: subprocess.check_call(cmd) ret = 1 # Try to revoke the old one. cmd = ['certbot', 'revoke', '--cert-path', cert_path] logging.info('%s: revoking old cert', domain) logging.info('%s: %s', domain, cmd) if not dry_run: subprocess.check_call(cmd) 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:]))