acmecert: Added a simple built-in DER decoder to not have to rely on external openssl.
[utils.git] / acmecert
1 #!/usr/bin/python3
2
3 #### ACME client (only http-01 challenges supported thus far)
4
5 import sys, os, getopt, binascii, json, pprint, signal, time, calendar, threading
6 import urllib.request
7 import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
8
9 ### General utilities
10
11 class msgerror(Exception):
12     def report(self, out):
13         out.write("acmecert: undefined error\n")
14
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
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)
33         else:
34             self.opened = True
35             self.fp = open(name, mode)
36
37     def __enter__(self):
38         return self.fp
39
40     def __exit__(self, *excinfo):
41         if self.opened:
42             self.fp.close()
43         return False
44
45 ### Crypto utilities
46
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
233 class certificate(object):
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()
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):
261         return cls(derdecoder.frompem(fp.read(), {"CERTIFICATE", "X509 CERTIFICATE"}))
262
263 class signreq(object):
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
290     def domains(self):
291         return [nm[1] for nm in self.altnames if nm[0] == "DNS"]
292
293     def der(self):
294         return self.raw
295
296     @classmethod
297     def read(cls, fp):
298         return cls(pemdec(fp.read(), {"CERTIFICATE REQUEST"}))
299
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):
357         self = cls(err.code, json.load(err), **kw)
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:
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
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
427 ### ACME protocol
428
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:
435             _directory = json.load(resp)
436     return _directory
437
438 def register(keysize=4096):
439     key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read)
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
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
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)
538                     elif resp["status"] == "pending":
539                         # I don't think this should happen, but it
540                         # does. LE bug? Anyway, just retry.
541                         if n < 5:
542                             time.sleep(2)
543                         else:
544                             break
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
554 ### Invocation and commands
555
556 invdata = threading.local()
557 commands = {}
558
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
566 ## User commands
567
568 def cmd_reg(args):
569     "usage: acmecert reg [OUTPUT-FILE]"
570     acct = register()
571     os.umask(0o077)
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:
580         account.read(fp).validate()
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())
588 commands["acct-info"] = cmd_acct_info
589
590 def cmd_order(args):
591     "usage: acmecert order ACCOUNT-FILE CSR [OUTPUT-FILE]"
592     if len(args) < 3: raise usageerr()
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
660 ## Main invocation
661
662 def usage(out):
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,))
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":
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)
692             sys.exit(0)
693         elif o == "-D":
694             service = a
695     if len(args) < 1:
696         usage(sys.stderr)
697         sys.exit(1)
698     cmd = commands.get(args[0])
699     if cmd is None:
700         sys.stderr.write("acmecert: unknown command: %s\n" % (args[0],))
701         usage(sys.stderr)
702         sys.exit(1)
703     try:
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)
711         sys.exit(1)
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)