From: Mike Frysinger Date: Tue, 14 Jun 2016 18:10:44 +0000 (-0400) Subject: le-renew: helper script for renewing letsencrypt certs X-Git-Url: https://git.wh0rd.org/?p=home.git;a=commitdiff_plain;h=ab74211fd761be58dc38265e0844f072bf70dde0 le-renew: helper script for renewing letsencrypt certs --- diff --git a/.bin/le-renew b/.bin/le-renew new file mode 100755 index 0000000..f5728d4 --- /dev/null +++ b/.bin/le-renew @@ -0,0 +1,168 @@ +#!/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:]))