acmecert: Added a simple built-in DER decoder to not have to rely on external openssl.
[utils.git] / acmecert
CommitLineData
61d08fc2
FT
1#!/usr/bin/python3
2
f1b49ff6
FT
3#### ACME client (only http-01 challenges supported thus far)
4
d2252d10 5import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading
61d08fc2
FT
6import urllib.request
7import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
8
f1b49ff6
FT
9### General utilities
10
28d5a321
FT
11class msgerror(Exception):
12 def report(self, out):
13 out.write("acmecert: undefined error\n")
14
61d08fc2
FT
15def base64url(dat):
16 return binascii.b2a_base64(dat).decode("us-ascii").translate({43: 45, 47: 95, 61: None}).strip()
17
18def 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
23class 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
47class dererror(Exception):
48 pass
49
50class pemerror(Exception):
51 pass
52
53def 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
74class 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 233class 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 263class 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
302def getnonce():
303 with urllib.request.urlopen(directory()["newNonce"]) as resp:
304 resp.read()
305 return resp.headers["Replay-Nonce"]
306
307def 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
318class 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):
357 self = cls(err.code, json.load(err), **kw)
358 return self
359
360def 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:
372 return json.load(resp), resp.headers
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
380class 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
392class 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
429service = "https://acme-v02.api.letsencrypt.org/directory"
430_directory = None
431def directory():
432 global _directory
433 if _directory is None:
434 with req(service) as resp:
435 _directory = json.load(resp)
436 return _directory
61d08fc2
FT
437
438def 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
443def 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
448def 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
455def 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
479class 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
496def 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 556invdata = threading.local()
cc8619b5
FT
557commands = {}
558
28d5a321
FT
559class 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
568def 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)
574commands["reg"] = cmd_reg
575
576def 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
581commands["validate-acct"] = cmd_validate_acct
582
583def 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 588commands["acct-info"] = cmd_acct_info
cc8619b5
FT
589
590def 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"]))
600commands["order"] = cmd_order
601
602def 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)
615commands["http-auth"] = cmd_http_auth
616
617def 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))
630commands["get"] = cmd_get
631
632def 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))
645commands["http-order"] = cmd_http_order
646
647def 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)
653commands["check-cert"] = cmd_check_cert
654
655def cmd_directory(args):
656 "usage: acmecert directory"
657 pprint.pprint(directory())
658commands["directory"] = cmd_directory
659
f1b49ff6
FT
660## Main invocation
661
61d08fc2 662def 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
679def 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
713if __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)