--- /dev/null
+#!/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:]))