]>
Commit | Line | Data |
---|---|---|
6dbeb0d6 MF |
1 | #!/usr/bin/python |
2 | #-*- coding:utf-8 -*- | |
ab74211f MF |
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 | |
6dbeb0d6 MF |
20 | import cryptography.hazmat.backends |
21 | from cryptography import x509 | |
ab74211f MF |
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 | |
ab74211f | 29 | import os |
ab74211f MF |
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|""" | |
6dbeb0d6 | 76 | with open(path, 'rb') as f: |
ab74211f | 77 | data = f.read() |
6dbeb0d6 MF |
78 | return x509.load_pem_x509_certificate( |
79 | data, cryptography.hazmat.backends.default_backend()) | |
ab74211f MF |
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) | |
881cee1e MF |
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(',') | |
ab74211f MF |
116 | |
117 | cert_path = os.path.realpath(conf.get('globals', 'cert')) | |
118 | ||
119 | cert = load_cert(cert_path) | |
6dbeb0d6 | 120 | delta = cert.not_valid_after - datetime.datetime.utcnow() |
ab74211f MF |
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 | ] | |
6dbeb0d6 MF |
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 | |
ab74211f MF |
136 | for d in domains: |
137 | cmd += ['-d', d] | |
138 | if delta.days < 30: | |
139 | logging.info('%s: renewing', domain) | |
462d58bb | 140 | logging.info('%s: %s', domain, ' '.join(cmd)) |
ab74211f | 141 | if not dry_run: |
462d58bb MF |
142 | try: |
143 | subprocess.check_call(cmd) | |
144 | except subprocess.CalledProcessError: | |
145 | logging.error('failed', exc_info=True) | |
146 | return 0 | |
ab74211f MF |
147 | ret = 1 |
148 | # Try to revoke the old one. | |
96e9a04a MF |
149 | cmd = ['certbot', 'revoke', '--no-delete-after-revoke', '--cert-path', |
150 | cert_path] | |
ab74211f | 151 | logging.info('%s: revoking old cert', domain) |
f33e57fb | 152 | logging.info('%s: %s', domain, ' '.join(cmd)) |
ab74211f | 153 | if not dry_run: |
462d58bb | 154 | try: |
f33e57fb | 155 | subprocess.check_call(cmd, stdin=open('/dev/null', 'r')) |
462d58bb MF |
156 | except subprocess.CalledProcessError: |
157 | logging.error('failed', exc_info=True) | |
ab74211f MF |
158 | else: |
159 | logging.info('%s: up-to-date!', domain) | |
160 | ||
161 | return ret | |
162 | ||
163 | ||
164 | def main(argv): | |
165 | """The main() entry point!""" | |
166 | parser = get_parser() | |
167 | opts = parser.parse_args(argv) | |
168 | setup_logging() | |
169 | ||
170 | cnt = 0 | |
171 | domains = [x[:-5] for x in os.listdir('/etc/letsencrypt/renewal')] | |
172 | for domain in domains: | |
173 | cnt += process_domain(domain, dry_run=opts.dry_run) | |
174 | ||
175 | if opts.cronjob: | |
176 | if cnt: | |
177 | return 0 | |
178 | else: | |
179 | return 1 | |
180 | ||
181 | ||
182 | if __name__ == '__main__': | |
183 | sys.exit(main(sys.argv[1:])) |