acmecert: Handle application/problem responses better.
[utils.git] / acmecert
index 9cf9b6a..12e1847 100755 (executable)
--- a/acmecert
+++ b/acmecert
@@ -1,16 +1,20 @@
 #!/usr/bin/python3
 
-import sys, os, getopt, binascii, json, pprint, signal, time
+import sys, os, getopt, binascii, json, pprint, signal, time, threading
 import urllib.request
 import Crypto.PublicKey.RSA, Crypto.Random, Crypto.Hash.SHA256, Crypto.Signature.PKCS1_v1_5
 
+class msgerror(Exception):
+    def report(self, out):
+        out.write("acmecert: undefined error\n")
+
 service = "https://acme-v02.api.letsencrypt.org/directory"
 _directory = None
 def directory():
     global _directory
     if _directory is None:
         with req(service) as resp:
-            _directory = json.loads(resp.read().decode("utf-8"))
+            _directory = json.load(resp)
     return _directory
 
 def base64url(dat):
@@ -37,6 +41,48 @@ def req(url, data=None, ctype=None, headers={}, method=None, **kws):
         req.add_header("Content-Type", ctype)
     return urllib.request.urlopen(req)
 
+class problem(msgerror):
+    def __init__(self, code, data, *args, url=None, **kw):
+        super().__init__(*args, **kw)
+        self.code = code
+        self.data = data
+        self.url = url
+        if not isinstance(data, dict):
+            raise ValueError("unexpected problem object type: %r" % (data,))
+
+    @property
+    def type(self):
+        return self.data.get("type", "about:blank")
+    @property
+    def title(self):
+        return self.data.get("title")
+    @property
+    def detail(self):
+        return self.data.get("detail")
+
+    def report(self, out):
+        extra = None
+        if self.title is None:
+            msg = self.detail
+            if "\n" in msg:
+                extra, msg = msg, None
+        else:
+            msg = self.title
+            extra = self.detail
+        if msg is None:
+            msg = self.data.get("type")
+        if msg is not None:
+            out.write("acemcert: %s: %s\n" % (
+                ("remote service error" if self.url is None else self.url),
+                ("unspecified error" if msg is None else msg)))
+        if extra is not None:
+            out.write("%s\n" % (extra,))
+
+    @classmethod
+    def read(cls, err, **kw):
+        self = cls(err.code, json.load(err), **kw)
+        return self
+
 def jreq(url, data, auth):
     authdata = {"alg": "RS256", "url": url, "nonce": getnonce()}
     authdata.update(auth.authdata())
@@ -47,8 +93,13 @@ def jreq(url, data, auth):
         data = base64url(json.dumps(data).encode("us-ascii"))
     seal = base64url(auth.sign(("%s.%s" % (authdata, data)).encode("us-ascii")))
     enc = {"protected": authdata, "payload": data, "signature": seal}
-    with req(url, data=enc) as resp:
-        return json.loads(resp.read().decode("utf-8")), resp.headers
+    try:
+        with req(url, data=enc) as resp:
+            return json.load(resp), resp.headers
+    except urllib.error.HTTPError as exc:
+        if exc.headers["Content-Type"] == "application/problem+json":
+            raise problem.read(exc, url=url)
+        raise
 
 class certificate(object):
     @property
@@ -189,8 +240,6 @@ class htconfig(object):
 
 def register(keysize=4096):
     key = Crypto.PublicKey.RSA.generate(keysize, Crypto.Random.new().read)
-    # jwk = {"kty": "RSA", "e": ebignum(key.e), "n": ebignum(key.n)}
-    # cjwk = json.dumps(jwk, separators=(',', ':'), sort_keys=True)
     data, headers = jreq(directory()["newAccount"], {"termsOfServiceAgreed": True}, jwkauth(key))
     return account(headers["Location"], key)
     
@@ -251,7 +300,10 @@ def authorder(acct, htconf, orderid):
                     elif resp["status"] == "pending":
                         # I don't think this should happen, but it
                         # does. LE bug? Anyway, just retry.
-                        break
+                        if n < 5:
+                            time.sleep(2)
+                        else:
+                            break
                     elif resp["status"] == "valid":
                         break
                     else:
@@ -305,11 +357,16 @@ class maybeopen(object):
             self.fp.close()
         return False
 
-class usageerr(Exception):
-    pass
-
+invdata = threading.local()
 commands = {}
 
+class usageerr(msgerror):
+    def __init__(self):
+        self.cmd = invdata.cmd
+
+    def report(self, out):
+        out.write("%s\n" % (self.cmd.__doc__,))
+
 def cmd_reg(args):
     "usage: acmecert reg [OUTPUT-FILE]"
     acct = register()
@@ -444,9 +501,13 @@ def main(argv):
         usage(sys.stderr)
         sys.exit(1)
     try:
-        cmd(args)
-    except usageerr:
-        sys.stderr.write("%s\n" % (cmd.__doc__,))
+        try:
+            invdata.cmd = cmd
+            cmd(args)
+        finally:
+            invdata.cmd = None
+    except msgerror as exc:
+        exc.report(sys.stderr)
         sys.exit(1)
 
 if __name__ == "__main__":