]>
Commit | Line | Data |
---|---|---|
1 | #!/usr/bin/python2 | |
2 | # We need to force py2 until M2Crypto is ported: | |
3 | # https://gitlab.com/m2crypto/m2crypto/issues/114 | |
4 | # pylint: disable=invalid-name | |
5 | ||
6 | """Renew Let's Encrypt certs! | |
7 | ||
8 | To generate a new set of certs: | |
9 | $ certbot certonly --webroot \\ | |
10 | --webroot-path /var/www/wh0rd/ -d wh0rd.org -d www.wh0rd.org \\ | |
11 | --webroot-path /var/www/rss/ -d rss.wh0rd.org | |
12 | """ | |
13 | ||
14 | from __future__ import print_function | |
15 | ||
16 | import argparse | |
17 | try: | |
18 | import configparser | |
19 | except ImportError: | |
20 | import ConfigParser as configparser | |
21 | try: | |
22 | from cStringIO import StringIO | |
23 | except ImportError: | |
24 | from io import StringIO | |
25 | import datetime | |
26 | import logging | |
27 | import logging.handlers | |
28 | import M2Crypto | |
29 | import os | |
30 | import pytz | |
31 | import subprocess | |
32 | import sys | |
33 | ||
34 | ||
35 | LE_BASE = '/etc/letsencrypt' | |
36 | ||
37 | ||
38 | def get_parser(): | |
39 | """Return an ArgumentParser() for this module.""" | |
40 | parser = argparse.ArgumentParser(description=__doc__, | |
41 | formatter_class=argparse.RawTextHelpFormatter) | |
42 | parser.add_argument('-n', '--dry-run', default=False, | |
43 | action='store_true', | |
44 | help='Do not actually update certs') | |
45 | parser.add_argument('--cronjob', default=False, | |
46 | action='store_true', | |
47 | help='Exit non-zero if no certs were changed') | |
48 | return parser | |
49 | ||
50 | ||
51 | def setup_logging(debug=False, syslog=None): | |
52 | """Setup the logging module just the way we like it.""" | |
53 | if syslog is None: | |
54 | syslog = not os.isatty(sys.stdin.fileno()) | |
55 | ||
56 | if syslog: | |
57 | handler = logging.handlers.SysLogHandler(address='/dev/log') | |
58 | else: | |
59 | handler = logging.StreamHandler(stream=sys.stdout) | |
60 | ||
61 | fmt = '%(asctime)s: %(levelname)-7s: %(message)s' | |
62 | ||
63 | datefmt = '%a, %d %b %Y %H:%M:%S letsencrypt' | |
64 | ||
65 | level = logging.DEBUG if debug else logging.INFO | |
66 | ||
67 | formatter = logging.Formatter(fmt, datefmt) | |
68 | handler.setFormatter(formatter) | |
69 | ||
70 | logger = logging.getLogger() | |
71 | logger.addHandler(handler) | |
72 | logger.setLevel(level) | |
73 | ||
74 | ||
75 | def load_cert(path): | |
76 | """Load the cert at |path|""" | |
77 | with open(path) as f: | |
78 | data = f.read() | |
79 | return M2Crypto.X509.load_cert_string(data) | |
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 | webroot_path = conf.get('[webroot_map', domain) | |
111 | ||
112 | cert_path = os.path.realpath(conf.get('globals', 'cert')) | |
113 | ||
114 | cert = load_cert(cert_path) | |
115 | stamp = cert.get_not_after() | |
116 | now = pytz.timezone('UTC').localize(datetime.datetime.now()) | |
117 | delta = stamp.get_datetime() - now | |
118 | logging.info('%s: expires in %2s days', domain, delta.days) | |
119 | ||
120 | cmd = [ | |
121 | 'certbot', | |
122 | 'certonly', '--webroot', | |
123 | '--webroot-path', webroot_path, | |
124 | '-d', domain, | |
125 | ] | |
126 | san = cert.get_ext('subjectAltName').get_value() | |
127 | domains = [x.strip()[4:] for x in san.split(',')] | |
128 | domains.remove(domain) | |
129 | for d in domains: | |
130 | cmd += ['-d', d] | |
131 | if delta.days < 30: | |
132 | logging.info('%s: renewing', domain) | |
133 | logging.info('%s: %s', domain, cmd) | |
134 | if not dry_run: | |
135 | subprocess.check_call(cmd) | |
136 | ret = 1 | |
137 | # Try to revoke the old one. | |
138 | cmd = ['certbot', 'revoke', '--cert-path', cert_path] | |
139 | logging.info('%s: revoking old cert', domain) | |
140 | logging.info('%s: %s', domain, cmd) | |
141 | if not dry_run: | |
142 | subprocess.check_call(cmd) | |
143 | else: | |
144 | logging.info('%s: up-to-date!', domain) | |
145 | ||
146 | return ret | |
147 | ||
148 | ||
149 | def main(argv): | |
150 | """The main() entry point!""" | |
151 | parser = get_parser() | |
152 | opts = parser.parse_args(argv) | |
153 | setup_logging() | |
154 | ||
155 | cnt = 0 | |
156 | domains = [x[:-5] for x in os.listdir('/etc/letsencrypt/renewal')] | |
157 | for domain in domains: | |
158 | cnt += process_domain(domain, dry_run=opts.dry_run) | |
159 | ||
160 | if opts.cronjob: | |
161 | if cnt: | |
162 | return 0 | |
163 | else: | |
164 | return 1 | |
165 | ||
166 | ||
167 | if __name__ == '__main__': | |
168 | sys.exit(main(sys.argv[1:])) |