Commit | Line | Data |
---|---|---|
61d08fc2 FT |
1 | #!/usr/bin/python3 |
2 | ||
f1b49ff6 FT |
3 | #### ACME client (only http-01 challenges supported thus far) |
4 | ||
d2252d10 | 5 | import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading |
61d08fc2 FT |
6 | import urllib.request |
7 | import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5 | |
8 | ||
f1b49ff6 FT |
9 | ### General utilities |
10 | ||
28d5a321 FT |
11 | class msgerror(Exception): |
12 | def report(self, out): | |
13 | out.write("acmecert: undefined error\n") | |
14 | ||
61d08fc2 FT |
15 | def base64url(dat): |
16 | return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip() | |
17 | ||
18 | def ebignum(num): | |
19 | h = "%x" % num | |
20 | if len(h) % 2 == 1: h = "0" + h | |
21 | return base64url(binascii.a2b_hex(h)) | |
22 | ||
f1b49ff6 FT |
23 | class maybeopen(object): |
24 | def __init__(self, name, mode): | |
25 | if name == "-": | |
26 | self.opened = False | |
27 | if mode == "r": | |
28 | self.fp = sys.stdin | |
29 | elif mode == "w": | |
30 | self.fp = sys.stdout | |
31 | else: | |
32 | raise ValueError(mode) | |
0305dfdc | 33 | else: |
f1b49ff6 FT |
34 | self.opened = True |
35 | self.fp = open(name, mode) | |
0305dfdc | 36 | |
f1b49ff6 FT |
37 | def __enter__(self): |
38 | return self.fp | |
0305dfdc | 39 | |
f1b49ff6 FT |
40 | def __exit__(self, *excinfo): |
41 | if self.opened: | |
42 | self.fp.close() | |
43 | return False | |
44 | ||
45 | ### Crypto utilities | |
61d08fc2 | 46 | |
d2252d10 FT |
47 | class dererror(Exception): |
48 | pass | |
49 | ||
50 | class pemerror(Exception): | |
51 | pass | |
52 | ||
53 | def pemdec(pem, ptypes): | |
54 | if isinstance(ptypes, str): | |
55 | ptypes = [ptypes] | |
56 | p = 0 | |
57 | while True: | |
58 | p = pem.find("-----BEGIN ", p) | |
59 | if p < 0: | |
60 | raise pemerror("could not find any %s in PEM-encoded data" % (ptypes,)) | |
61 | p2 = pem.find("-----", p + 11) | |
62 | if p2 < 0: | |
63 | raise pemerror("incomplete PEM header") | |
64 | ptype = pem[p + 11 : p2] | |
65 | if ptype not in ptypes: | |
66 | p = p2 + 5 | |
67 | continue | |
68 | p3 = pem.find("-----END " + ptype + "-----", p2 + 5) | |
69 | if p3 < 0: | |
70 | raise pemerror("incomplete PEM data") | |
71 | pem = pem[p2 + 5 : p3] | |
72 | return binascii.a2b_base64(pem) | |
73 | ||
74 | class derdecoder(object): | |
75 | def __init__(self, data, offset=0, size=None): | |
76 | self.data = data | |
77 | self.offset = offset | |
78 | self.size = len(data) if size is None else size | |
79 | ||
80 | def end(self): | |
81 | return self.offset >= self.size | |
82 | ||
83 | def byte(self): | |
84 | if self.offset >= self.size: | |
85 | raise dererror("unexpected end-of-data") | |
86 | ret = self.data[self.offset] | |
87 | self.offset += 1 | |
88 | return ret | |
89 | ||
90 | def splice(self, ln): | |
91 | if self.offset + ln > self.size: | |
92 | raise dererror("unexpected end-of-data") | |
93 | ret = self.data[self.offset : self.offset + ln] | |
94 | self.offset += ln | |
95 | return ret | |
96 | ||
97 | def dectag(self): | |
98 | h = self.byte() | |
99 | cl = (h & 0xc0) >> 6 | |
100 | cons = (h & 0x20) != 0 | |
101 | tag = h & 0x1f | |
102 | if tag == 0x1f: | |
103 | raise dererror("extended type tags not supported") | |
104 | return cl, cons, tag | |
105 | ||
106 | def declen(self): | |
107 | h = self.byte() | |
108 | if (h & 0x80) == 0: | |
109 | return h | |
110 | if h == 0x80: | |
111 | raise dererror("indefinite lengths not supported in DER") | |
112 | if h == 0xff: | |
113 | raise dererror("invalid length byte") | |
114 | n = h & 0x7f | |
115 | ret = 0 | |
116 | for i in range(n): | |
117 | ret = (ret << 8) + self.byte() | |
118 | return ret | |
119 | ||
120 | def get(self): | |
121 | cl, cons, tag = self.dectag() | |
122 | ln = self.declen() | |
123 | return cons, cl, tag, self.splice(ln) | |
124 | ||
125 | def getcons(self, ckcl, cktag): | |
126 | cons, cl, tag, data = self.get() | |
127 | if not cons: | |
128 | raise dererror("expected constructed value") | |
129 | if (ckcl != None and ckcl != cl) or (cktag != None and cktag != tag): | |
130 | raise dererror("unexpected value tag: got (%d, %d), expected (%d, %d)" % (cl, tag, ckcl, cktag)) | |
131 | return derdecoder(data) | |
132 | ||
133 | def getint(self): | |
134 | cons, cl, tag, data = self.get() | |
135 | if (cons, cl, tag) == (False, 0, 2): | |
136 | ret = 0 | |
137 | for b in data: | |
138 | ret = (ret << 8) + b | |
139 | return ret | |
140 | raise dererror("unexpected integer type: (%s, %d, %d)" % (cons, cl, tag)) | |
141 | ||
142 | def getstr(self): | |
143 | cons, cl, tag, data = self.get() | |
144 | if (cons, cl, tag) == (False, 0, 12): | |
145 | return data.decode("utf-8") | |
146 | if (cons, cl, tag) == (False, 0, 13): | |
147 | return data.decode("us-ascii") | |
148 | if (cons, cl, tag) == (False, 0, 22): | |
149 | return data.decode("us-ascii") | |
150 | if (cons, cl, tag) == (False, 0, 30): | |
151 | return data.decode("utf-16-be") | |
152 | raise dererror("unexpected string type: (%s, %d, %d)" % (cons, cl, tag)) | |
153 | ||
154 | def getbytes(self): | |
155 | cons, cl, tag, data = self.get() | |
156 | if (cons, cl, tag) == (False, 0, 4): | |
157 | return data | |
158 | raise dererror("unexpected byte-string type: (%s, %d, %d)" % (cons, cl, tag)) | |
159 | ||
160 | def getoid(self): | |
161 | cons, cl, tag, data = self.get() | |
162 | if (cons, cl, tag) == (False, 0, 6): | |
163 | ret = [] | |
164 | ret.append(data[0] // 40) | |
165 | ret.append(data[0] % 40) | |
166 | p = 1 | |
167 | while p < len(data): | |
168 | n = 0 | |
169 | v = data[p] | |
170 | p += 1 | |
171 | while v & 0x80: | |
172 | n = (n + (v & 0x7f)) * 128 | |
173 | v = data[p] | |
174 | p += 1 | |
175 | n += v | |
176 | ret.append(n) | |
177 | return tuple(ret) | |
178 | raise dererror("unexpected object-id type: (%s, %d, %d)" % (cons, cl, tag)) | |
179 | ||
180 | @staticmethod | |
181 | def parsetime(data, c): | |
182 | if c: | |
183 | y = int(data[0:4]) | |
184 | data = data[4:] | |
185 | else: | |
186 | y = int(data[0:2]) | |
187 | y += 1900 if y > 50 else 2000 | |
188 | data = data[2:] | |
189 | m = int(data[0:2]) | |
190 | d = int(data[2:4]) | |
191 | H = int(data[4:6]) | |
192 | data = data[6:] | |
193 | if data[:1].isdigit(): | |
194 | M = int(data[0:2]) | |
195 | data = data[2:] | |
196 | else: | |
197 | M = 0 | |
198 | if data[:1].isdigit(): | |
199 | S = int(data[0:2]) | |
200 | data = data[2:] | |
201 | else: | |
202 | S = 0 | |
203 | if data[:1] == '.': | |
204 | p = 1 | |
205 | while len(data) < p and data[p].isdigit(): | |
206 | p += 1 | |
207 | S += float("0." + data[1:p]) | |
208 | data = data[p:] | |
209 | if len(data) < 1: | |
210 | raise dererror("unspecified local time not supported for decoding") | |
211 | if data[0] == 'Z': | |
212 | tz = 0 | |
213 | elif data[0] == '+': | |
214 | tz = (int(data[1:3]) * 60) + int(data[3:5]) | |
215 | elif data[0] == '-': | |
216 | tz = -((int(data[1:3]) * 60) + int(data[3:5])) | |
217 | else: | |
218 | raise dererror("cannot parse X.690 timestamp") | |
219 | return calendar.timegm((y, m, d, H, M, S)) - (tz * 60) | |
220 | ||
221 | def gettime(self): | |
222 | cons, cl, tag, data = self.get() | |
223 | if (cons, cl, tag) == (False, 0, 23): | |
224 | return self.parsetime(data.decode("us-ascii"), False) | |
225 | if (cons, cl, tag) == (False, 0, 24): | |
226 | return self.parsetime(data.decode("us-ascii"), True) | |
227 | raise dererror("unexpected time type: (%s, %d, %d)" % (cons, cl, tag)) | |
228 | ||
229 | @classmethod | |
230 | def frompem(cls, pem, ptypes): | |
231 | return cls(pemdec(pem, ptypes)) | |
232 | ||
14a46eff | 233 | class certificate(object): |
d2252d10 FT |
234 | def __init__(self, der): |
235 | ci = der.getcons(0, 16).getcons(0, 16) | |
236 | self.ver = ci.getcons(2, 0).getint() | |
237 | self.serial = ci.getint() | |
238 | ci.getcons(0, 16) # Signature algorithm | |
239 | ci.getcons(0, 16) # Issuer | |
240 | vl = ci.getcons(0, 16) | |
241 | self.startdate = vl.gettime() | |
242 | self.enddate = vl.gettime() | |
14a46eff FT |
243 | |
244 | def expiring(self, timespec): | |
245 | if timespec.endswith("y"): | |
246 | timespec = int(timespec[:-1]) * 365 * 86400 | |
247 | elif timespec.endswith("m"): | |
248 | timespec = int(timespec[:-1]) * 30 * 86400 | |
249 | elif timespec.endswith("w"): | |
250 | timespec = int(timespec[:-1]) * 7 * 86400 | |
251 | elif timespec.endswith("d"): | |
252 | timespec = int(timespec[:-1]) * 86400 | |
253 | elif timespec.endswith("h"): | |
254 | timespec = int(timespec[:-1]) * 3600 | |
255 | else: | |
256 | timespec = int(timespec) | |
257 | return (self.enddate - time.time()) < timespec | |
258 | ||
259 | @classmethod | |
260 | def read(cls, fp): | |
d2252d10 | 261 | return cls(derdecoder.frompem(fp.read(), {"CERTIFICATE", "X509 CERTIFICATE"})) |
14a46eff | 262 | |
61d08fc2 | 263 | class signreq(object): |
d2252d10 FT |
264 | def __init__(self, der): |
265 | self.raw = der | |
266 | req = derdecoder(der).getcons(0, 16).getcons(0, 16) | |
267 | self.ver = req.getint() | |
268 | req.getcons(0, 16) # Subject | |
269 | req.getcons(0, 16) # Public key | |
270 | self.altnames = [] | |
271 | if not req.end(): | |
272 | attrs = req.getcons(2, 0) | |
273 | while not attrs.end(): | |
274 | attr = attrs.getcons(0, 16) | |
275 | anm = attr.getoid() | |
276 | if anm == (1, 2, 840, 113549, 1, 9, 14): | |
277 | # Certificate extension request | |
278 | exts = attr.getcons(0, 17).getcons(0, 16) | |
279 | while not exts.end(): | |
280 | ext = exts.getcons(0, 16) | |
281 | extnm = ext.getoid() | |
282 | if extnm == (2, 5, 29, 17): | |
283 | # Subject alternative names | |
284 | names = derdecoder(ext.getbytes()).getcons(0, 16) | |
285 | while not names.end(): | |
286 | cons, cl, tag, data = names.get() | |
287 | if (cons, cl, tag) == (False, 2, 2): | |
288 | self.altnames.append(("DNS", data.decode("us-ascii"))) | |
289 | ||
61d08fc2 | 290 | def domains(self): |
d2252d10 | 291 | return [nm[1] for nm in self.altnames if nm[0] == "DNS"] |
61d08fc2 FT |
292 | |
293 | def der(self): | |
d2252d10 | 294 | return self.raw |
61d08fc2 FT |
295 | |
296 | @classmethod | |
297 | def read(cls, fp): | |
d2252d10 | 298 | return cls(pemdec(fp.read(), {"CERTIFICATE REQUEST"})) |
61d08fc2 | 299 | |
f1b49ff6 FT |
300 | ### Somewhat general request utilities |
301 | ||
302 | def getnonce(): | |
303 | with urllib.request.urlopen(directory()["newNonce"]) as resp: | |
304 | resp.read() | |
305 | return resp.headers["Replay-Nonce"] | |
306 | ||
307 | def req(url, data=None, ctype=None, headers={}, method=None, **kws): | |
308 | if data is not None and not isinstance(data, bytes): | |
309 | data = json.dumps(data).encode("utf-8") | |
310 | ctype = "application/jose+json" | |
311 | req = urllib.request.Request(url, data=data, method=method) | |
312 | for hnam, hval in headers.items(): | |
313 | req.add_header(hnam, hval) | |
314 | if ctype is not None: | |
315 | req.add_header("Content-Type", ctype) | |
316 | return urllib.request.urlopen(req) | |
317 | ||
318 | class problem(msgerror): | |
319 | def __init__(self, code, data, *args, url=None, **kw): | |
320 | super().__init__(*args, **kw) | |
321 | self.code = code | |
322 | self.data = data | |
323 | self.url = url | |
324 | if not isinstance(data, dict): | |
325 | raise ValueError("unexpected problem object type: %r" % (data,)) | |
326 | ||
327 | @property | |
328 | def type(self): | |
329 | return self.data.get("type", "about:blank") | |
330 | @property | |
331 | def title(self): | |
332 | return self.data.get("title") | |
333 | @property | |
334 | def detail(self): | |
335 | return self.data.get("detail") | |
336 | ||
337 | def report(self, out): | |
338 | extra = None | |
339 | if self.title is None: | |
340 | msg = self.detail | |
341 | if "\n" in msg: | |
342 | extra, msg = msg, None | |
343 | else: | |
344 | msg = self.title | |
345 | extra = self.detail | |
346 | if msg is None: | |
347 | msg = self.data.get("type") | |
348 | if msg is not None: | |
349 | out.write("acemcert: %s: %s\n" % ( | |
350 | ("remote service error" if self.url is None else self.url), | |
351 | ("unspecified error" if msg is None else msg))) | |
352 | if extra is not None: | |
353 | out.write("%s\n" % (extra,)) | |
354 | ||
355 | @classmethod | |
356 | def read(cls, err, **kw): | |
1995d63b | 357 | self = cls(err.code, json.loads(err.read().decode("utf-8")), **kw) |
f1b49ff6 FT |
358 | return self |
359 | ||
360 | def jreq(url, data, auth): | |
361 | authdata = {"alg": "RS256", "url": url, "nonce": getnonce()} | |
362 | authdata.update(auth.authdata()) | |
363 | authdata = base64url(json.dumps(authdata).encode("us-ascii")) | |
364 | if data is None: | |
365 | data = "" | |
366 | else: | |
367 | data = base64url(json.dumps(data).encode("us-ascii")) | |
368 | seal = base64url(auth.sign(("%s.%s" % (authdata, data)).encode("us-ascii"))) | |
369 | enc = {"protected": authdata, "payload": data, "signature": seal} | |
370 | try: | |
371 | with req(url, data=enc) as resp: | |
1995d63b | 372 | return json.loads(resp.read().decode("utf-8")), resp.headers |
f1b49ff6 FT |
373 | except urllib.error.HTTPError as exc: |
374 | if exc.headers["Content-Type"] == "application/problem+json": | |
375 | raise problem.read(exc, url=url) | |
376 | raise | |
377 | ||
378 | ## Authentication | |
379 | ||
61d08fc2 FT |
380 | class jwkauth(object): |
381 | def __init__(self, key): | |
382 | self.key = key | |
383 | ||
384 | def authdata(self): | |
385 | return {"jwk": {"kty": "RSA", "e": ebignum(self.key.e), "n": ebignum(self.key.n)}} | |
386 | ||
387 | def sign(self, data): | |
388 | dig = Crypto.Hash.SHA256.new() | |
389 | dig.update(data) | |
390 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
391 | ||
392 | class account(object): | |
393 | def __init__(self, uri, key): | |
394 | self.uri = uri | |
395 | self.key = key | |
396 | ||
397 | def authdata(self): | |
398 | return {"kid": self.uri} | |
399 | ||
400 | def sign(self, data): | |
401 | dig = Crypto.Hash.SHA256.new() | |
402 | dig.update(data) | |
403 | return Crypto.Signature.PKCS1_v1_5.new(self.key).sign(dig) | |
404 | ||
405 | def getinfo(self): | |
406 | data, headers = jreq(self.uri, None, self) | |
407 | return data | |
408 | ||
409 | def validate(self): | |
410 | data = self.getinfo() | |
411 | if data.get("status", "") != "valid": | |
412 | raise Exception("account is not valid: %s" % (data.get("status", "\"\""))) | |
413 | ||
414 | def write(self, out): | |
415 | out.write("%s\n" % (self.uri,)) | |
416 | out.write("%s\n" % (self.key.exportKey().decode("us-ascii"),)) | |
417 | ||
418 | @classmethod | |
419 | def read(cls, fp): | |
420 | uri = fp.readline() | |
421 | if uri == "": | |
422 | raise Exception("missing account URI") | |
423 | uri = uri.strip() | |
424 | key = Crypto.PublicKey.RSA.importKey(fp.read()) | |
425 | return cls(uri, key) | |
426 | ||
f1b49ff6 | 427 | ### ACME protocol |
61d08fc2 | 428 | |
f1b49ff6 FT |
429 | service = "https://acme-v02.api.letsencrypt.org/directory" |
430 | _directory = None | |
431 | def directory(): | |
432 | global _directory | |
433 | if _directory is None: | |
434 | with req(service) as resp: | |
1995d63b | 435 | _directory = json.loads(resp.read().decode("utf-8")) |
f1b49ff6 | 436 | return _directory |
61d08fc2 FT |
437 | |
438 | def register(keysize=4096): | |
439 | key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read) | |
61d08fc2 FT |
440 | data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key)) |
441 | return account(headers["Location"], key) | |
442 | ||
443 | def mkorder(acct, csr): | |
444 | data, headers = jreq(directory()["newOrder"], {"identifiers": [{"type": "dns", "value": dn} for dn in csr.domains()]}, acct) | |
445 | data["acmecert.location"] = headers["Location"] | |
446 | return data | |
447 | ||
448 | def httptoken(acct, ch): | |
449 | jwk = {"kty": "RSA", "e": ebignum(acct.key.e), "n": ebignum(acct.key.n)} | |
450 | dig = Crypto.Hash.SHA256.new() | |
451 | dig.update(json.dumps(jwk, separators=(',', ':'), sort_keys=True).encode("us-ascii")) | |
452 | khash = base64url(dig.digest()) | |
453 | return ch["token"], ("%s.%s" % (ch["token"], khash)) | |
454 | ||
f1b49ff6 FT |
455 | def finalize(acct, csr, orderid): |
456 | order, headers = jreq(orderid, None, acct) | |
457 | if order["status"] == "valid": | |
458 | pass | |
459 | elif order["status"] == "ready": | |
460 | jreq(order["finalize"], {"csr": base64url(csr.der())}, acct) | |
461 | for n in range(30): | |
462 | resp, headers = jreq(orderid, None, acct) | |
463 | if resp["status"] == "processing": | |
464 | time.sleep(2) | |
465 | elif resp["status"] == "valid": | |
466 | order = resp | |
467 | break | |
468 | else: | |
469 | raise Exception("unexpected order status when finalizing: %s" % resp["status"]) | |
470 | else: | |
471 | raise Exception("order finalization timed out") | |
472 | else: | |
473 | raise Exception("unexpected order state when finalizing: %s" % (order["status"],)) | |
474 | with req(order["certificate"]) as resp: | |
475 | return resp.read().decode("us-ascii") | |
476 | ||
477 | ## http-01 challenge | |
478 | ||
479 | class htconfig(object): | |
480 | def __init__(self): | |
481 | self.roots = {} | |
482 | ||
483 | @classmethod | |
484 | def read(cls, fp): | |
485 | self = cls() | |
486 | for ln in fp: | |
487 | words = ln.split() | |
488 | if len(words) < 1 or ln[0] == '#': | |
489 | continue | |
490 | if words[0] == "root": | |
491 | self.roots[words[1]] = words[2] | |
492 | else: | |
493 | sys.stderr.write("acmecert: warning: unknown htconfig directive: %s\n" % (words[0])) | |
494 | return self | |
495 | ||
61d08fc2 FT |
496 | def authorder(acct, htconf, orderid): |
497 | order, headers = jreq(orderid, None, acct) | |
498 | valid = False | |
499 | tries = 0 | |
500 | while not valid: | |
501 | valid = True | |
502 | tries += 1 | |
503 | if tries > 5: | |
504 | raise Exception("challenges refuse to become valid even after 5 retries") | |
505 | for authuri in order["authorizations"]: | |
506 | auth, headers = jreq(authuri, None, acct) | |
507 | if auth["status"] == "valid": | |
508 | continue | |
509 | elif auth["status"] == "pending": | |
510 | pass | |
511 | else: | |
512 | raise Exception("unknown authorization status: %s" % (auth["status"],)) | |
513 | valid = False | |
514 | if auth["identifier"]["type"] != "dns": | |
515 | raise Exception("unknown authorization type: %s" % (auth["identifier"]["type"],)) | |
516 | dn = auth["identifier"]["value"] | |
517 | if dn not in htconf.roots: | |
518 | raise Exception("no configured ht-root for domain name %s" % (dn,)) | |
519 | for ch in auth["challenges"]: | |
520 | if ch["type"] == "http-01": | |
521 | break | |
522 | else: | |
523 | raise Exception("no http-01 challenge for %s" % (dn,)) | |
524 | root = htconf.roots[dn] | |
525 | tokid, tokval = httptoken(acct, ch) | |
526 | tokpath = os.path.join(root, tokid); | |
527 | fp = open(tokpath, "w") | |
528 | try: | |
529 | with fp: | |
530 | fp.write(tokval) | |
531 | with req("http://%s/.well-known/acme-challenge/%s" % (dn, tokid)) as resp: | |
532 | if resp.read().decode("utf-8") != tokval: | |
533 | raise Exception("challenge from %s does not match written value" % (dn,)) | |
534 | for n in range(30): | |
535 | resp, headers = jreq(ch["url"], {}, acct) | |
536 | if resp["status"] == "processing": | |
537 | time.sleep(2) | |
db705a3b FT |
538 | elif resp["status"] == "pending": |
539 | # I don't think this should happen, but it | |
540 | # does. LE bug? Anyway, just retry. | |
62b251ca FT |
541 | if n < 5: |
542 | time.sleep(2) | |
543 | else: | |
544 | break | |
61d08fc2 FT |
545 | elif resp["status"] == "valid": |
546 | break | |
547 | else: | |
548 | raise Exception("unexpected challenge status for %s when validating: %s" % (dn, resp["status"])) | |
549 | else: | |
550 | raise Exception("challenge processing timed out for %s" % (dn,)) | |
551 | finally: | |
552 | os.unlink(tokpath) | |
553 | ||
f1b49ff6 | 554 | ### Invocation and commands |
cc8619b5 | 555 | |
28d5a321 | 556 | invdata = threading.local() |
cc8619b5 FT |
557 | commands = {} |
558 | ||
28d5a321 FT |
559 | class usageerr(msgerror): |
560 | def __init__(self): | |
561 | self.cmd = invdata.cmd | |
562 | ||
563 | def report(self, out): | |
564 | out.write("%s\n" % (self.cmd.__doc__,)) | |
565 | ||
f1b49ff6 FT |
566 | ## User commands |
567 | ||
cc8619b5 FT |
568 | def cmd_reg(args): |
569 | "usage: acmecert reg [OUTPUT-FILE]" | |
570 | acct = register() | |
bfe6116d | 571 | os.umask(0o077) |
cc8619b5 FT |
572 | with maybeopen(args[1] if len(args) > 1 else "-", "w") as fp: |
573 | acct.write(fp) | |
574 | commands["reg"] = cmd_reg | |
575 | ||
576 | def cmd_validate_acct(args): | |
577 | "usage: acmecert validate-acct ACCOUNT-FILE" | |
578 | if len(args) < 2: raise usageerr() | |
579 | with maybeopen(args[1], "r") as fp: | |
40a14578 | 580 | account.read(fp).validate() |
cc8619b5 FT |
581 | commands["validate-acct"] = cmd_validate_acct |
582 | ||
583 | def cmd_acct_info(args): | |
584 | "usage: acmecert acct-info ACCOUNT-FILE" | |
585 | if len(args) < 2: raise usageerr() | |
586 | with maybeopen(args[1], "r") as fp: | |
587 | pprint.pprint(account.read(fp).getinfo()) | |
9cef04aa | 588 | commands["acct-info"] = cmd_acct_info |
cc8619b5 FT |
589 | |
590 | def cmd_order(args): | |
591 | "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]" | |
8cea2234 | 592 | if len(args) < 3: raise usageerr() |
cc8619b5 FT |
593 | with maybeopen(args[1], "r") as fp: |
594 | acct = account.read(fp) | |
595 | with maybeopen(args[2], "r") as fp: | |
596 | csr = signreq.read(fp) | |
597 | order = mkorder(acct, csr) | |
598 | with maybeopen(args[3] if len(args) > 3 else "-", "w") as fp: | |
599 | fp.write("%s\n" % (order["acmecert.location"])) | |
600 | commands["order"] = cmd_order | |
601 | ||
602 | def cmd_http_auth(args): | |
603 | "usage: acmecert http-auth ACCOUNT-FILE HTTP-CONFIG {ORDER-ID|ORDER-FILE}" | |
604 | if len(args) < 4: raise usageerr() | |
605 | with maybeopen(args[1], "r") as fp: | |
606 | acct = account.read(fp) | |
607 | with maybeopen(args[2], "r") as fp: | |
608 | htconf = htconfig.read(fp) | |
609 | if "://" in args[3]: | |
610 | orderid = args[3] | |
611 | else: | |
612 | with maybeopen(args[3], "r") as fp: | |
613 | orderid = fp.readline().strip() | |
614 | authorder(acct, htconf, orderid) | |
615 | commands["http-auth"] = cmd_http_auth | |
616 | ||
617 | def cmd_get(args): | |
618 | "usage: acmecert get ACCOUNT-FILE CSR {ORDER-ID|ORDER-FILE}" | |
619 | if len(args) < 4: raise usageerr() | |
620 | with maybeopen(args[1], "r") as fp: | |
621 | acct = account.read(fp) | |
622 | with maybeopen(args[2], "r") as fp: | |
623 | csr = signreq.read(fp) | |
624 | if "://" in args[3]: | |
625 | orderid = args[3] | |
626 | else: | |
627 | with maybeopen(args[3], "r") as fp: | |
628 | orderid = fp.readline().strip() | |
629 | sys.stdout.write(finalize(acct, csr, orderid)) | |
630 | commands["get"] = cmd_get | |
631 | ||
632 | def cmd_http_order(args): | |
633 | "usage: acmecert http-order ACCOUNT-FILE CSR HTTP-CONFIG [OUTPUT-FILE]" | |
634 | if len(args) < 4: raise usageerr() | |
635 | with maybeopen(args[1], "r") as fp: | |
636 | acct = account.read(fp) | |
637 | with maybeopen(args[2], "r") as fp: | |
638 | csr = signreq.read(fp) | |
639 | with maybeopen(args[3], "r") as fp: | |
640 | htconf = htconfig.read(fp) | |
641 | orderid = mkorder(acct, csr)["acmecert.location"] | |
642 | authorder(acct, htconf, orderid) | |
643 | with maybeopen(args[4] if len(args) > 4 else "-", "w") as fp: | |
644 | fp.write(finalize(acct, csr, orderid)) | |
645 | commands["http-order"] = cmd_http_order | |
646 | ||
647 | def cmd_check_cert(args): | |
648 | "usage: acmecert check-cert CERT-FILE TIME-SPEC" | |
649 | if len(args) < 3: raise usageerr() | |
650 | with maybeopen(args[1], "r") as fp: | |
651 | crt = certificate.read(fp) | |
652 | sys.exit(1 if crt.expiring(args[2]) else 0) | |
653 | commands["check-cert"] = cmd_check_cert | |
654 | ||
655 | def cmd_directory(args): | |
656 | "usage: acmecert directory" | |
657 | pprint.pprint(directory()) | |
658 | commands["directory"] = cmd_directory | |
659 | ||
f1b49ff6 FT |
660 | ## Main invocation |
661 | ||
61d08fc2 | 662 | def usage(out): |
cc8619b5 FT |
663 | out.write("usage: acmecert [-D SERVICE] COMMAND [ARGS...]\n") |
664 | out.write(" acmecert -h [COMMAND]\n") | |
665 | buf = " COMMAND is any of: " | |
666 | f = True | |
667 | for cmd in commands: | |
668 | if len(buf) + len(cmd) > 70: | |
669 | out.write("%s\n" % (buf,)) | |
670 | buf = " " | |
671 | f = True | |
672 | if not f: | |
673 | buf += ", " | |
674 | buf += cmd | |
675 | f = False | |
676 | if not f: | |
677 | out.write("%s\n" % (buf,)) | |
61d08fc2 FT |
678 | |
679 | def main(argv): | |
680 | global service | |
681 | opts, args = getopt.getopt(argv[1:], "hD:") | |
682 | for o, a in opts: | |
683 | if o == "-h": | |
cc8619b5 FT |
684 | if len(args) > 0: |
685 | cmd = commands.get(args[0]) | |
686 | if cmd is None: | |
687 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) | |
688 | sys.exit(1) | |
689 | sys.stdout.write("%s\n" % (cmd.__doc__,)) | |
690 | else: | |
691 | usage(sys.stdout) | |
61d08fc2 FT |
692 | sys.exit(0) |
693 | elif o == "-D": | |
694 | service = a | |
695 | if len(args) < 1: | |
696 | usage(sys.stderr) | |
697 | sys.exit(1) | |
cc8619b5 FT |
698 | cmd = commands.get(args[0]) |
699 | if cmd is None: | |
61d08fc2 FT |
700 | sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],)) |
701 | usage(sys.stderr) | |
702 | sys.exit(1) | |
cc8619b5 | 703 | try: |
28d5a321 FT |
704 | try: |
705 | invdata.cmd = cmd | |
706 | cmd(args) | |
707 | finally: | |
708 | invdata.cmd = None | |
709 | except msgerror as exc: | |
710 | exc.report(sys.stderr) | |
cc8619b5 | 711 | sys.exit(1) |
61d08fc2 FT |
712 | |
713 | if __name__ == "__main__": | |
714 | try: | |
715 | main(sys.argv) | |
716 | except KeyboardInterrupt: | |
717 | signal.signal(signal.SIGINT, signal.SIG_DFL) | |
718 | os.kill(os.getpid(), signal.SIGINT) |