Browse Source

Merge branch 'master' into patch-1

Daniel Roesler 7 years ago
parent
commit
e89444a8f9
10 changed files with 260 additions and 172 deletions
  1. 1 1
      .travis.yml
  2. 18 16
      README.md
  3. 134 135
      acme_tiny.py
  4. 2 0
      setup.cfg
  5. 30 0
      setup.py
  6. 4 1
      tests/README.md
  7. 1 0
      tests/__init__.py
  8. 3 1
      tests/monkey.py
  9. 24 0
      tests/test_install.py
  10. 43 18
      tests/test_module.py

+ 1 - 1
.travis.yml

@@ -14,6 +14,6 @@ before_install:
 install:
 install:
   - pip install -r tests/requirements.txt
   - pip install -r tests/requirements.txt
 script:
 script:
-  - coverage run --source ./ --omit ./tests/server.py -m unittest tests
+  - coverage run --source ./ --omit ./tests/server.py,./setup.py -m unittest tests
 after_success:
 after_success:
   - coveralls
   - coveralls

+ 18 - 16
README.md

@@ -9,9 +9,9 @@ to be run on your server and have access to your private Let's Encrypt account
 key, I tried to make it as tiny as possible (currently less than 200 lines).
 key, I tried to make it as tiny as possible (currently less than 200 lines).
 The only prerequisites are python and openssl.
 The only prerequisites are python and openssl.
 
 
-**PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE KEYS!**
+**PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE ACCOUNT KEY!**
 
 
-##Donate
+## Donate
 
 
 If this script is useful to you, please donate to the EFF. I don't work there,
 If this script is useful to you, please donate to the EFF. I don't work there,
 but they do fantastic work.
 but they do fantastic work.
@@ -30,7 +30,7 @@ with the corresponding private key. If you don't understand what I just said,
 this script likely isn't for you! Please use the official Let's Encrypt
 this script likely isn't for you! Please use the official Let's Encrypt
 [client](https://github.com/letsencrypt/letsencrypt).
 [client](https://github.com/letsencrypt/letsencrypt).
 To accomplish this you need to initially create a key, that can be used by
 To accomplish this you need to initially create a key, that can be used by
-acme-tiny, to register a account for you and sign all following requests.
+acme-tiny, to register an account for you and sign all following requests.
 
 
 ```
 ```
 openssl genrsa 4096 > account.key
 openssl genrsa 4096 > account.key
@@ -116,28 +116,22 @@ and read your private account key and CSR.
 
 
 ```
 ```
 #run the script on your server
 #run the script on your server
-python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed.crt
+python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed_chain.crt
 ```
 ```
 
 
 ### Step 5: Install the certificate
 ### Step 5: Install the certificate
 
 
-The signed https certificate that is output by this script can be used along
+The signed https certificate chain that is output by this script can be used along
 with your private key to run an https server. You need to include them in the
 with your private key to run an https server. You need to include them in the
 https settings in your web server's configuration. Here's an example on how to
 https settings in your web server's configuration. Here's an example on how to
 configure an nginx server:
 configure an nginx server:
 
 
-```
-#NOTE: For nginx, you need to append the Let's Encrypt intermediate cert to your cert
-wget -O - https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem > intermediate.pem
-cat signed.crt intermediate.pem > chained.pem
-```
-
 ```nginx
 ```nginx
 server {
 server {
     listen 443 ssl;
     listen 443 ssl;
     server_name yoursite.com, www.yoursite.com;
     server_name yoursite.com, www.yoursite.com;
 
 
-    ssl_certificate /path/to/chained.pem;
+    ssl_certificate /path/to/signed_chain.crt;
     ssl_certificate_key /path/to/domain.key;
     ssl_certificate_key /path/to/domain.key;
     ssl_session_timeout 5m;
     ssl_session_timeout 5m;
     ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
     ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
@@ -172,9 +166,7 @@ for example script).
 Example of a `renew_cert.sh`:
 Example of a `renew_cert.sh`:
 ```sh
 ```sh
 #!/usr/bin/sh
 #!/usr/bin/sh
-python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /var/www/challenges/ > /tmp/signed.crt || exit
-wget -O - https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem > intermediate.pem
-cat /tmp/signed.crt intermediate.pem > /path/to/chained.pem
+python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /var/www/challenges/ > /path/to/signed_chain.crt || exit
 service nginx reload
 service nginx reload
 ```
 ```
 
 
@@ -183,6 +175,16 @@ service nginx reload
 0 0 1 * * /path/to/renew_cert.sh 2>> /var/log/acme_tiny.log
 0 0 1 * * /path/to/renew_cert.sh 2>> /var/log/acme_tiny.log
 ```
 ```
 
 
+NOTE: Since Let's Encrypt's ACME v2 release (acme-tiny 4.0.0+), the intermediate
+certificate is included in the issued certificate download, so you no longer have
+to independently download the intermediate certificate and concatenate it to your
+signed certificate. If you have an bash script using acme-tiny <4.0 (e.g. before
+2018-03-17) with acme-tiny 4.0.0+, then you may be adding the intermediate
+certificate to your signed_chain.crt twice (not a big deal, it should still work fine,
+but just makes the certificate slightly larger than it needs to be). To fix,
+simply remove the bash code where you're downloading the intermediate and adding
+it to the acme-tiny certificate output.
+
 ## Permissions
 ## Permissions
 
 
 The biggest problem you'll likely come across while setting up and running this
 The biggest problem you'll likely come across while setting up and running this
@@ -190,7 +192,7 @@ script is permissions. You want to limit access to your account private key and
 challenge web folder as much as possible. I'd recommend creating a user
 challenge web folder as much as possible. I'd recommend creating a user
 specifically for handling this script, the account private key, and the
 specifically for handling this script, the account private key, and the
 challenge folder. Then add the ability for that user to write to your installed
 challenge folder. Then add the ability for that user to write to your installed
-certificate file (e.g. `/path/to/chained.pem`) and reload your webserver. That
+certificate file (e.g. `/path/to/signed_chain.crt`) and reload your webserver. That
 way, the cron script will do its thing, overwrite your old certificate, and
 way, the cron script will do its thing, overwrite your old certificate, and
 reload your webserver without having permission to do anything else.
 reload your webserver without having permission to do anything else.
 
 

+ 134 - 135
acme_tiny.py

@@ -1,73 +1,93 @@
 #!/usr/bin/env python
 #!/usr/bin/env python
+# Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny
 import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
 import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging
 try:
 try:
-    from urllib.request import urlopen # Python 3
+    from urllib.request import urlopen, Request # Python 3
 except ImportError:
 except ImportError:
-    from urllib2 import urlopen # Python 2
+    from urllib2 import urlopen, Request # Python 2
 
 
-#DEFAULT_CA = "https://acme-staging.api.letsencrypt.org"
-DEFAULT_CA = "https://acme-v01.api.letsencrypt.org"
+DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD
+DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
 
 
 LOGGER = logging.getLogger(__name__)
 LOGGER = logging.getLogger(__name__)
 LOGGER.addHandler(logging.StreamHandler())
 LOGGER.addHandler(logging.StreamHandler())
 LOGGER.setLevel(logging.INFO)
 LOGGER.setLevel(logging.INFO)
 
 
-def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA):
-    # helper function base64 encode for jose spec
+def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None):
+    directory, acct_headers, alg, jwk = None, None, None, None # global variables
+
+    # helper functions - base64 encode for jose spec
     def _b64(b):
     def _b64(b):
         return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "")
         return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "")
 
 
+    # helper function - run external commands
+    def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"):
+        proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        out, err = proc.communicate(cmd_input)
+        if proc.returncode != 0:
+            raise IOError("{0}\n{1}".format(err_msg, err))
+        return out
+
+    # helper function - make request and automatically parse json response
+    def _do_request(url, data=None, err_msg="Error", depth=0):
+        try:
+            resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"}))
+            resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers
+            resp_data = json.loads(resp_data) # try to parse json results
+        except ValueError:
+            pass # ignore json parsing errors
+        except IOError as e:
+            resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e)
+            code, headers = getattr(e, "code", None), {}
+        if depth < 100 and code == 400 and json.loads(resp_data)['type'] == "urn:ietf:params:acme:error:badNonce":
+            raise IndexError(resp_data) # allow 100 retrys for bad nonces
+        if code not in [200, 201, 204]:
+            raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data))
+        return resp_data, code, headers
+
+    # helper function - make signed requests
+    def _send_signed_request(url, payload, err_msg, depth=0):
+        payload64 = _b64(json.dumps(payload).encode('utf8'))
+        new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce']
+        protected = {"url": url, "alg": alg, "nonce": new_nonce}
+        protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']})
+        protected64 = _b64(json.dumps(protected).encode('utf8'))
+        protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8')
+        out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error")
+        data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)})
+        try:
+            return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth)
+        except IndexError: # retry bad nonces (they raise IndexError)
+            return _send_signed_request(url, payload, err_msg, depth=(depth + 1))
+
+    # helper function - poll until complete
+    def _poll_until_not(url, pending_statuses, err_msg):
+        while True:
+            result, _, _ = _do_request(url, err_msg=err_msg)
+            if result['status'] in pending_statuses:
+                time.sleep(2)
+                continue
+            return result
+
     # parse account key to get public key
     # parse account key to get public key
     log.info("Parsing account key...")
     log.info("Parsing account key...")
-    proc = subprocess.Popen(["openssl", "rsa", "-in", account_key, "-noout", "-text"],
-        stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    out, err = proc.communicate()
-    if proc.returncode != 0:
-        raise IOError("OpenSSL Error: {0}".format(err))
-    pub_hex, pub_exp = re.search(
-        r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)",
-        out.decode('utf8'), re.MULTILINE|re.DOTALL).groups()
+    out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error")
+    pub_pattern = r"modulus:\n\s+00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)"
+    pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups()
     pub_exp = "{0:x}".format(int(pub_exp))
     pub_exp = "{0:x}".format(int(pub_exp))
     pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
     pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp
-    header = {
-        "alg": "RS256",
-        "jwk": {
-            "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
-            "kty": "RSA",
-            "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
-        },
+    alg = "RS256"
+    jwk = {
+        "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))),
+        "kty": "RSA",
+        "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))),
     }
     }
-    accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':'))
+    accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':'))
     thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
     thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest())
 
 
-    # helper function make signed requests
-    def _send_signed_request(url, payload):
-        payload64 = _b64(json.dumps(payload).encode('utf8'))
-        protected = copy.deepcopy(header)
-        protected["nonce"] = urlopen(CA + "/directory").headers['Replay-Nonce']
-        protected64 = _b64(json.dumps(protected).encode('utf8'))
-        proc = subprocess.Popen(["openssl", "dgst", "-sha256", "-sign", account_key],
-            stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-        out, err = proc.communicate("{0}.{1}".format(protected64, payload64).encode('utf8'))
-        if proc.returncode != 0:
-            raise IOError("OpenSSL Error: {0}".format(err))
-        data = json.dumps({
-            "header": header, "protected": protected64,
-            "payload": payload64, "signature": _b64(out),
-        })
-        try:
-            resp = urlopen(url, data.encode('utf8'))
-            return resp.getcode(), resp.read()
-        except IOError as e:
-            return getattr(e, "code", None), getattr(e, "read", e.__str__)()
-
     # find domains
     # find domains
     log.info("Parsing CSR...")
     log.info("Parsing CSR...")
-    proc = subprocess.Popen(["openssl", "req", "-in", csr, "-noout", "-text"],
-        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    out, err = proc.communicate()
-    if proc.returncode != 0:
-        raise IOError("Error loading {0}: {1}".format(csr, err))
+    out = _cmd(["openssl", "req", "-in", csr, "-noout", "-text"], err_msg="Error loading {0}".format(csr))
     domains = set([])
     domains = set([])
     common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8'))
     common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8'))
     if common_name is not None:
     if common_name is not None:
@@ -77,34 +97,37 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA):
         for san in subject_alt_names.group(1).split(", "):
         for san in subject_alt_names.group(1).split(", "):
             if san.startswith("DNS:"):
             if san.startswith("DNS:"):
                 domains.add(san[4:])
                 domains.add(san[4:])
+    log.info("Found domains: {0}".format(", ".join(domains)))
 
 
-    # get the certificate domains and expiration
+    # get the ACME directory of urls
+    log.info("Getting directory...")
+    directory_url = CA + "/directory" if CA != DEFAULT_CA else directory_url # backwards compatibility with deprecated CA kwarg
+    directory, _, _ = _do_request(directory_url, err_msg="Error getting directory")
+    log.info("Directory found!")
+
+    # create account, update contact details (if any), and set the global key identifier
     log.info("Registering account...")
     log.info("Registering account...")
-    code, result = _send_signed_request(CA + "/acme/new-reg", {
-        "resource": "new-reg",
-        "agreement": json.loads(urlopen(CA + "/directory").read().decode('utf8'))['meta']['terms-of-service'],
-    })
-    if code == 201:
-        log.info("Registered!")
-    elif code == 409:
-        log.info("Already registered!")
-    else:
-        raise ValueError("Error registering: {0} {1}".format(code, result))
-
-    # verify each domain
-    for domain in domains:
+    reg_payload = {"termsOfServiceAgreed": True}
+    account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering")
+    log.info("Registered!" if code == 201 else "Already registered!")
+    if contact is not None:
+        account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details")
+        log.info("Updated contact details:\n{0}".format("\n".join(account['contact'])))
+
+    # create a new order
+    log.info("Creating new order...")
+    order_payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
+    order, _, order_headers = _send_signed_request(directory['newOrder'], order_payload, "Error creating new order")
+    log.info("Order created!")
+
+    # get the authorizations that need to be completed
+    for auth_url in order['authorizations']:
+        authorization, _, _ = _do_request(auth_url, err_msg="Error getting challenges")
+        domain = authorization['identifier']['value']
         log.info("Verifying {0}...".format(domain))
         log.info("Verifying {0}...".format(domain))
 
 
-        # get new challenge
-        code, result = _send_signed_request(CA + "/acme/new-authz", {
-            "resource": "new-authz",
-            "identifier": {"type": "dns", "value": domain},
-        })
-        if code != 201:
-            raise ValueError("Error requesting challenges: {0} {1}".format(code, result))
-
-        # make the challenge file
-        challenge = [c for c in json.loads(result.decode('utf8'))['challenges'] if c['type'] == "http-01"][0]
+        # find the http-01 challenge and write the challenge file
+        challenge = [c for c in authorization['challenges'] if c['type'] == "http-01"][0]
         token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
         token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token'])
         keyauthorization = "{0}.{1}".format(token, thumbprint)
         keyauthorization = "{0}.{1}".format(token, thumbprint)
         wellknown_path = os.path.join(acme_dir, token)
         wellknown_path = os.path.join(acme_dir, token)
@@ -112,86 +135,62 @@ def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA):
             wellknown_file.write(keyauthorization)
             wellknown_file.write(keyauthorization)
 
 
         # check that the file is in place
         # check that the file is in place
-        wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token)
         try:
         try:
-            resp = urlopen(wellknown_url)
-            resp_data = resp.read().decode('utf8').strip()
-            assert resp_data == keyauthorization
-        except (IOError, AssertionError):
+            wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format(domain, token)
+            assert(disable_check or _do_request(wellknown_url)[0] == keyauthorization)
+        except (AssertionError, ValueError) as e:
             os.remove(wellknown_path)
             os.remove(wellknown_path)
-            raise ValueError("Wrote file to {0}, but couldn't download {1}".format(
-                wellknown_path, wellknown_url))
-
-        # notify challenge are met
-        code, result = _send_signed_request(challenge['uri'], {
-            "resource": "challenge",
-            "keyAuthorization": keyauthorization,
-        })
-        if code != 202:
-            raise ValueError("Error triggering challenge: {0} {1}".format(code, result))
-
-        # wait for challenge to be verified
-        while True:
-            try:
-                resp = urlopen(challenge['uri'])
-                challenge_status = json.loads(resp.read().decode('utf8'))
-            except IOError as e:
-                raise ValueError("Error checking challenge: {0} {1}".format(
-                    e.code, json.loads(e.read().decode('utf8'))))
-            if challenge_status['status'] == "pending":
-                time.sleep(2)
-            elif challenge_status['status'] == "valid":
-                log.info("{0} verified!".format(domain))
-                os.remove(wellknown_path)
-                break
-            else:
-                raise ValueError("{0} challenge did not pass: {1}".format(
-                    domain, challenge_status))
-
-    # get the new certificate
+            raise ValueError("Wrote file to {0}, but couldn't download {1}: {2}".format(wellknown_path, wellknown_url, e))
+
+        # say the challenge is done
+        _send_signed_request(challenge['url'], {}, "Error submitting challenges: {0}".format(domain))
+        authorization = _poll_until_not(auth_url, ["pending"], "Error checking challenge status for {0}".format(domain))
+        if authorization['status'] != "valid":
+            raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization))
+        log.info("{0} verified!".format(domain))
+
+    # finalize the order with the csr
     log.info("Signing certificate...")
     log.info("Signing certificate...")
-    proc = subprocess.Popen(["openssl", "req", "-in", csr, "-outform", "DER"],
-        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-    csr_der, err = proc.communicate()
-    code, result = _send_signed_request(CA + "/acme/new-cert", {
-        "resource": "new-cert",
-        "csr": _b64(csr_der),
-    })
-    if code != 201:
-        raise ValueError("Error signing certificate: {0} {1}".format(code, result))
-
-    # return signed certificate!
+    csr_der = _cmd(["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error")
+    _send_signed_request(order['finalize'], {"csr": _b64(csr_der)}, "Error finalizing order")
+
+    # poll the order to monitor when it's done
+    order = _poll_until_not(order_headers['Location'], ["pending", "processing"], "Error checking order status")
+    if order['status'] != "valid":
+        raise ValueError("Order failed: {0}".format(order))
+
+    # download the certificate
+    certificate_pem, _, _ = _do_request(order['certificate'], err_msg="Certificate download failed")
     log.info("Certificate signed!")
     log.info("Certificate signed!")
-    return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format(
-        "\n".join(textwrap.wrap(base64.b64encode(result).decode('utf8'), 64)))
+    return certificate_pem
 
 
-def main(argv):
+def main(argv=None):
     parser = argparse.ArgumentParser(
     parser = argparse.ArgumentParser(
         formatter_class=argparse.RawDescriptionHelpFormatter,
         formatter_class=argparse.RawDescriptionHelpFormatter,
         description=textwrap.dedent("""\
         description=textwrap.dedent("""\
-            This script automates the process of getting a signed TLS certificate from
-            Let's Encrypt using the ACME protocol. It will need to be run on your server
-            and have access to your private account key, so PLEASE READ THROUGH IT! It's
-            only ~200 lines, so it won't take long.
-
-            ===Example Usage===
-            python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed.crt
-            ===================
-
-            ===Example Crontab Renewal (once per month)===
-            0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed.crt 2>> /var/log/acme_tiny.log
-            ==============================================
+            This script automates the process of getting a signed TLS certificate from Let's Encrypt using
+            the ACME protocol. It will need to be run on your server and have access to your private
+            account key, so PLEASE READ THROUGH IT! It's only ~200 lines, so it won't take long.
+
+            Example Usage:
+            python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > signed_chain.crt
+
+            Example Crontab Renewal (once per month):
+            0 0 1 * * python /path/to/acme_tiny.py --account-key /path/to/account.key --csr /path/to/domain.csr --acme-dir /usr/share/nginx/html/.well-known/acme-challenge/ > /path/to/signed_chain.crt 2>> /var/log/acme_tiny.log
             """)
             """)
     )
     )
     parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key")
     parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key")
     parser.add_argument("--csr", required=True, help="path to your certificate signing request")
     parser.add_argument("--csr", required=True, help="path to your certificate signing request")
     parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory")
     parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory")
     parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
     parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors")
-    parser.add_argument("--ca", default=DEFAULT_CA, help="certificate authority, default is Let's Encrypt")
+    parser.add_argument("--disable-check", default=False, action="store_true", help="disable checking if the challenge file is hosted correctly before telling the CA")
+    parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt")
+    parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!")
+    parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key")
 
 
     args = parser.parse_args(argv)
     args = parser.parse_args(argv)
     LOGGER.setLevel(args.quiet or LOGGER.level)
     LOGGER.setLevel(args.quiet or LOGGER.level)
-    signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca)
+    signed_crt = get_crt(args.account_key, args.csr, args.acme_dir, log=LOGGER, CA=args.ca, disable_check=args.disable_check, directory_url=args.directory_url, contact=args.contact)
     sys.stdout.write(signed_crt)
     sys.stdout.write(signed_crt)
 
 
 if __name__ == "__main__": # pragma: no cover
 if __name__ == "__main__": # pragma: no cover

+ 2 - 0
setup.cfg

@@ -0,0 +1,2 @@
+[wheel]
+universal=True

+ 30 - 0
setup.py

@@ -0,0 +1,30 @@
+from setuptools import setup
+
+setup(
+    name="acme-tiny",
+    use_scm_version=True,
+    url="https://github.com/diafygi/acme-tiny",
+    author="Daniel Roesler",
+    author_email="diafygi@gmail.com",
+    description="A tiny script to issue and renew TLS certs from Let's Encrypt",
+    license="MIT",
+    py_modules=['acme_tiny'],
+    entry_points={'console_scripts': [
+        'acme-tiny = acme_tiny:main',
+    ]},
+    setup_requires=['setuptools_scm'],
+    classifiers = [
+        'Development Status :: 5 - Production/Stable',
+        'Intended Audience :: System Administrators',
+        'License :: OSI Approved :: MIT License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Programming Language :: Python :: 2',
+        'Programming Language :: Python :: 2.7',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.3',
+        'Programming Language :: Python :: 3.4',
+        'Programming Language :: Python :: 3.5',
+        'Programming Language :: Python :: 3.6',
+    ]
+)

+ 4 - 1
tests/README.md

@@ -9,6 +9,9 @@ explains how to setup and test acme-tiny yourself.
 1. Make a test subdomain for a server you control. Set it as an environmental
 1. Make a test subdomain for a server you control. Set it as an environmental
 variable on your local test setup.
 variable on your local test setup.
   * On your local: `export TRAVIS_DOMAIN=travis-ci.gethttpsforfree.com`
   * On your local: `export TRAVIS_DOMAIN=travis-ci.gethttpsforfree.com`
+  * Configure the webserver on `$TRAVIS_DOMAIN` for redirection of
+    `http://$TRAVIS_DOMAIN/.well-known/acme-challenge/` to
+    `http://localhost:8888/`
 2. Generate a shared secret between your local test setup and your server.
 2. Generate a shared secret between your local test setup and your server.
   * `openssl rand -base64 32`
   * `openssl rand -base64 32`
   * On your local: `export TRAVIS_SESSION="<random_string_here>"`
   * On your local: `export TRAVIS_SESSION="<random_string_here>"`
@@ -24,7 +27,7 @@ variable on your local test setup.
   * `pip install -r requirements.txt`
   * `pip install -r requirements.txt`
 5. Run the test suit on your local.
 5. Run the test suit on your local.
   * `cd /path/to/acme-tiny`
   * `cd /path/to/acme-tiny`
-  * `coverage run --source ./ --omit ./tests/server.py -m unittest tests`
+  * `coverage run --source ./ --omit ./tests/server.py,./setup.py -m unittest tests`
 
 
 ## Why use FUSE?
 ## Why use FUSE?
 
 

+ 1 - 0
tests/__init__.py

@@ -1 +1,2 @@
 from .test_module import TestModule
 from .test_module import TestModule
+from .test_install import TestInstall

+ 3 - 1
tests/monkey.py

@@ -29,7 +29,9 @@ def gen_keys():
     # subject alt-name domain
     # subject alt-name domain
     san_csr = NamedTemporaryFile()
     san_csr = NamedTemporaryFile()
     san_conf = NamedTemporaryFile()
     san_conf = NamedTemporaryFile()
-    san_conf.write(open("/etc/ssl/openssl.cnf").read().encode("utf8"))
+    for openssl_cnf in ['/etc/pki/tls/openssl.cnf', '/etc/ssl/openssl.cnf']:
+        if os.path.exists(openssl_cnf): break
+    san_conf.write(open(openssl_cnf).read().encode("utf8"))
     san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(DOMAIN).encode("utf8"))
     san_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(DOMAIN).encode("utf8"))
     san_conf.seek(0)
     san_conf.seek(0)
     Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name,
     Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name,

+ 24 - 0
tests/test_install.py

@@ -0,0 +1,24 @@
+import unittest
+import os
+import tempfile
+import shutil
+import subprocess
+
+
+class TestInstall(unittest.TestCase):
+    def setUp(self):
+        self.tempdir = tempfile.mkdtemp()
+        subprocess.check_call(["virtualenv", self.tempdir])
+
+    def tearDown(self):
+        shutil.rmtree(self.tempdir)
+
+    def virtualenv_bin(self, cmd):
+        return os.path.join(self.tempdir, "bin", cmd)
+
+    def test_install(self):
+        subprocess.check_call([self.virtualenv_bin("python"), "setup.py", "install"])
+
+    def test_cli(self):
+        self.test_install()
+        subprocess.check_call([self.virtualenv_bin("acme-tiny"), "-h"])

+ 43 - 18
tests/test_module.py

@@ -1,4 +1,4 @@
-import unittest, os, sys, tempfile
+import unittest, os, sys, tempfile, logging
 from subprocess import Popen, PIPE
 from subprocess import Popen, PIPE
 try:
 try:
     from StringIO import StringIO # Python 2
     from StringIO import StringIO # Python 2
@@ -14,7 +14,7 @@ class TestModule(unittest.TestCase):
     "Tests for acme_tiny.get_crt()"
     "Tests for acme_tiny.get_crt()"
 
 
     def setUp(self):
     def setUp(self):
-        self.CA = "https://acme-staging.api.letsencrypt.org"
+        self.DIR_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
         self.tempdir = tempfile.mkdtemp()
         self.tempdir = tempfile.mkdtemp()
         self.fuse_proc = Popen(["python", "tests/monkey.py", self.tempdir])
         self.fuse_proc = Popen(["python", "tests/monkey.py", self.tempdir])
 
 
@@ -31,13 +31,12 @@ class TestModule(unittest.TestCase):
             "--account-key", KEYS['account_key'].name,
             "--account-key", KEYS['account_key'].name,
             "--csr", KEYS['domain_csr'].name,
             "--csr", KEYS['domain_csr'].name,
             "--acme-dir", self.tempdir,
             "--acme-dir", self.tempdir,
-            "--ca", self.CA,
+            "--directory-url", self.DIR_URL,
         ])
         ])
         sys.stdout.seek(0)
         sys.stdout.seek(0)
         crt = sys.stdout.read().encode("utf8")
         crt = sys.stdout.read().encode("utf8")
         sys.stdout = old_stdout
         sys.stdout = old_stdout
-        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE,
-            stdout=PIPE, stderr=PIPE).communicate(crt)
+        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt)
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
 
 
     def test_success_san(self):
     def test_success_san(self):
@@ -48,13 +47,12 @@ class TestModule(unittest.TestCase):
             "--account-key", KEYS['account_key'].name,
             "--account-key", KEYS['account_key'].name,
             "--csr", KEYS['san_csr'].name,
             "--csr", KEYS['san_csr'].name,
             "--acme-dir", self.tempdir,
             "--acme-dir", self.tempdir,
-            "--ca", self.CA,
+            "--directory-url", self.DIR_URL,
         ])
         ])
         sys.stdout.seek(0)
         sys.stdout.seek(0)
         crt = sys.stdout.read().encode("utf8")
         crt = sys.stdout.read().encode("utf8")
         sys.stdout = old_stdout
         sys.stdout = old_stdout
-        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE,
-            stdout=PIPE, stderr=PIPE).communicate(crt)
+        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt)
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
 
 
     def test_success_cli(self):
     def test_success_cli(self):
@@ -64,10 +62,9 @@ class TestModule(unittest.TestCase):
             "--account-key", KEYS['account_key'].name,
             "--account-key", KEYS['account_key'].name,
             "--csr", KEYS['domain_csr'].name,
             "--csr", KEYS['domain_csr'].name,
             "--acme-dir", self.tempdir,
             "--acme-dir", self.tempdir,
-            "--ca", self.CA,
+            "--directory-url", self.DIR_URL,
         ], stdout=PIPE, stderr=PIPE).communicate()
         ], stdout=PIPE, stderr=PIPE).communicate()
-        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE,
-            stdout=PIPE, stderr=PIPE).communicate(crt)
+        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt)
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
         self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
 
 
     def test_missing_account_key(self):
     def test_missing_account_key(self):
@@ -77,7 +74,7 @@ class TestModule(unittest.TestCase):
                 "--account-key", "/foo/bar",
                 "--account-key", "/foo/bar",
                 "--csr", KEYS['domain_csr'].name,
                 "--csr", KEYS['domain_csr'].name,
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
@@ -91,7 +88,7 @@ class TestModule(unittest.TestCase):
                 "--account-key", KEYS['account_key'].name,
                 "--account-key", KEYS['account_key'].name,
                 "--csr", "/foo/bar",
                 "--csr", "/foo/bar",
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
@@ -105,7 +102,7 @@ class TestModule(unittest.TestCase):
                 "--account-key", KEYS['weak_key'].name,
                 "--account-key", KEYS['weak_key'].name,
                 "--csr", KEYS['domain_csr'].name,
                 "--csr", KEYS['domain_csr'].name,
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
@@ -119,21 +116,21 @@ class TestModule(unittest.TestCase):
                 "--account-key", KEYS['account_key'].name,
                 "--account-key", KEYS['account_key'].name,
                 "--csr", KEYS['invalid_csr'].name,
                 "--csr", KEYS['invalid_csr'].name,
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
         self.assertIsInstance(result, ValueError)
         self.assertIsInstance(result, ValueError)
         self.assertIn("Invalid character in DNS name", result.args[0])
         self.assertIn("Invalid character in DNS name", result.args[0])
 
 
-    def test_nonexistant_domain(self):
+    def test_nonexistent_domain(self):
         """ Should be unable verify a nonexistent domain """
         """ Should be unable verify a nonexistent domain """
         try:
         try:
             result = acme_tiny.main([
             result = acme_tiny.main([
                 "--account-key", KEYS['account_key'].name,
                 "--account-key", KEYS['account_key'].name,
                 "--csr", KEYS['nonexistent_csr'].name,
                 "--csr", KEYS['nonexistent_csr'].name,
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
@@ -147,10 +144,38 @@ class TestModule(unittest.TestCase):
                 "--account-key", KEYS['account_key'].name,
                 "--account-key", KEYS['account_key'].name,
                 "--csr", KEYS['account_csr'].name,
                 "--csr", KEYS['account_csr'].name,
                 "--acme-dir", self.tempdir,
                 "--acme-dir", self.tempdir,
-                "--ca", self.CA,
+                "--directory-url", self.DIR_URL,
             ])
             ])
         except Exception as e:
         except Exception as e:
             result = e
             result = e
         self.assertIsInstance(result, ValueError)
         self.assertIsInstance(result, ValueError)
         self.assertIn("certificate public key must be different than account key", result.args[0])
         self.assertIn("certificate public key must be different than account key", result.args[0])
 
 
+    def test_contact(self):
+        """ Make sure optional contact details can be set """
+        # add a logging handler that captures the info log output
+        log_output = StringIO()
+        debug_handler = logging.StreamHandler(log_output)
+        acme_tiny.LOGGER.addHandler(debug_handler)
+        # call acme_tiny with new contact details
+        old_stdout = sys.stdout
+        sys.stdout = StringIO()
+        result = acme_tiny.main([
+            "--account-key", KEYS['account_key'].name,
+            "--csr", KEYS['domain_csr'].name,
+            "--acme-dir", self.tempdir,
+            "--directory-url", self.DIR_URL,
+            "--contact", "mailto:devteam@example.com", "mailto:boss@example.com",
+        ])
+        sys.stdout.seek(0)
+        crt = sys.stdout.read().encode("utf8")
+        sys.stdout = old_stdout
+        log_output.seek(0)
+        log_string = log_output.read().encode("utf8")
+        # make sure the certificate was issued and the contact details were updated
+        out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt)
+        self.assertIn("Issuer: CN=Fake LE Intermediate", out.decode("utf8"))
+        self.assertIn("Updated contact details:\nmailto:devteam@example.com\nmailto:boss@example.com", log_string.decode("utf8"))
+        # remove logging capture
+        acme_tiny.LOGGER.removeHandler(debug_handler)
+