]> git.wh0rd.org - home.git/commitdiff
le-renew: helper script for renewing letsencrypt certs
authorMike Frysinger <vapier@gentoo.org>
Tue, 14 Jun 2016 18:10:44 +0000 (14:10 -0400)
committerMike Frysinger <vapier@gentoo.org>
Tue, 14 Jun 2016 18:10:44 +0000 (14:10 -0400)
.bin/le-renew [new file with mode: 0755]

diff --git a/.bin/le-renew b/.bin/le-renew
new file mode 100755 (executable)
index 0000000..f5728d4
--- /dev/null
@@ -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:]))