]> git.wh0rd.org Git - home.git/blob - .bin/le-renew
e7d4ec038ddc1d3299e65aed51784d83462fe004
[home.git] / .bin / le-renew
1 #!/usr/bin/python
2 #-*- coding:utf-8 -*-
3 # pylint: disable=invalid-name
4
5 """Renew Let's Encrypt certs!
6
7 To generate a new set of certs:
8 $ certbot certonly --webroot \\
9     --webroot-path /var/www/wh0rd/ -d wh0rd.org -d www.wh0rd.org \\
10     --webroot-path /var/www/rss/ -d rss.wh0rd.org
11 """
12
13 from __future__ import print_function
14
15 import argparse
16 try:
17     import configparser
18 except ImportError:
19     import ConfigParser as configparser
20 import cryptography.hazmat.backends
21 from cryptography import x509
22 try:
23     from cStringIO import StringIO
24 except ImportError:
25     from io import StringIO
26 import datetime
27 import logging
28 import logging.handlers
29 import os
30 import subprocess
31 import sys
32
33
34 LE_BASE = '/etc/letsencrypt'
35
36
37 def get_parser():
38     """Return an ArgumentParser() for this module."""
39     parser = argparse.ArgumentParser(description=__doc__,
40                                      formatter_class=argparse.RawTextHelpFormatter)
41     parser.add_argument('-n', '--dry-run', default=False,
42                         action='store_true',
43                         help='Do not actually update certs')
44     parser.add_argument('--cronjob', default=False,
45                         action='store_true',
46                         help='Exit non-zero if no certs were changed')
47     return parser
48
49
50 def setup_logging(debug=False, syslog=None):
51     """Setup the logging module just the way we like it."""
52     if syslog is None:
53         syslog = not os.isatty(sys.stdin.fileno())
54
55     if syslog:
56         handler = logging.handlers.SysLogHandler(address='/dev/log')
57     else:
58         handler = logging.StreamHandler(stream=sys.stdout)
59
60     fmt = '%(asctime)s: %(levelname)-7s: %(message)s'
61
62     datefmt = '%a, %d %b %Y %H:%M:%S letsencrypt'
63
64     level = logging.DEBUG if debug else logging.INFO
65
66     formatter = logging.Formatter(fmt, datefmt)
67     handler.setFormatter(formatter)
68
69     logger = logging.getLogger()
70     logger.addHandler(handler)
71     logger.setLevel(level)
72
73
74 def load_cert(path):
75     """Load the cert at |path|"""
76     with open(path, 'rb') as f:
77         data = f.read()
78         return x509.load_pem_x509_certificate(
79             data, cryptography.hazmat.backends.default_backend())
80
81
82 def load_live_cert(domain):
83     """Load the live cert for |domain|"""
84     path = os.path.join(LE_BASE, 'live', domain, 'cert.pem')
85     return load_cert(path)
86
87
88 def load_conf(domain):
89     """Load the LE config file for |domain|"""
90     path = os.path.join(LE_BASE, 'renewal', domain + '.conf')
91     # The config file format is almost enough for the configparser.
92     # We need to insert a section header for the first few items.
93     fp = StringIO()
94     fp.write('[globals]\n')
95     fp.write(open(path).read())
96     fp.seek(0)
97
98     conf = configparser.RawConfigParser()
99     conf.readfp(fp)
100     return conf
101
102
103 def process_domain(domain, dry_run=False):
104     """Update |domain|'s certs as needed."""
105     ret = 0
106
107     logging.info('%s: checking', domain)
108
109     conf = load_conf(domain)
110     try:
111         webroot_path = conf.get('[webroot_map', domain)
112     except configparser.NoOptionError:
113         webroot_path = conf.get('renewalparams', 'webroot_path')
114         # The conf writing has a bug here where it appends a comma.
115         webroot_path = webroot_path.rstrip(',')
116
117     cert_path = os.path.realpath(conf.get('globals', 'cert'))
118
119     cert = load_cert(cert_path)
120     delta = cert.not_valid_after - datetime.datetime.utcnow()
121     logging.info('%s: expires in %2s days', domain, delta.days)
122
123     cmd = [
124         'certbot',
125         'certonly', '--webroot',
126         '--webroot-path', webroot_path,
127         '-d', domain,
128     ]
129     domains = []
130     try:
131         san = cert.extensions.get_extension_for_oid(
132             x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME)
133         domains = san.value.get_values_for_type(x509.DNSName)
134     except x509.ExtensionNotFound:
135         pass
136     for d in domains:
137         cmd += ['-d', d]
138     if delta.days < 30:
139         logging.info('%s: renewing', domain)
140         logging.info('%s: %s', domain, ' '.join(cmd))
141         if not dry_run:
142             try:
143                 subprocess.check_call(cmd)
144             except subprocess.CalledProcessError:
145                 logging.error('failed', exc_info=True)
146                 return 0
147             ret = 1
148         # Try to revoke the old one.
149         cmd = ['certbot', 'revoke', '--cert-path', cert_path]
150         logging.info('%s: revoking old cert', domain)
151         logging.info('%s: %s', domain, ' '.join(cmd))
152         if not dry_run:
153             try:
154                 subprocess.check_call(cmd, stdin=open('/dev/null', 'r'))
155             except subprocess.CalledProcessError:
156                 logging.error('failed', exc_info=True)
157     else:
158         logging.info('%s: up-to-date!', domain)
159
160     return ret
161
162
163 def main(argv):
164     """The main() entry point!"""
165     parser = get_parser()
166     opts = parser.parse_args(argv)
167     setup_logging()
168
169     cnt = 0
170     domains = [x[:-5] for x in os.listdir('/etc/letsencrypt/renewal')]
171     for domain in domains:
172         cnt += process_domain(domain, dry_run=opts.dry_run)
173
174     if opts.cronjob:
175         if cnt:
176             return 0
177         else:
178             return 1
179
180
181 if __name__ == '__main__':
182     sys.exit(main(sys.argv[1:]))