├── .github └── workflows │ ├── full-tests-with-coverage.yml │ ├── pull-request-tests.yml │ └── staging-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── acme_tiny.py ├── setup.cfg ├── setup.py └── tests ├── README.md ├── __init__.py ├── requirements.txt ├── test_install.py ├── test_module.py └── utils.py /.github/workflows/full-tests-with-coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: [push] 3 | jobs: 4 | test: 5 | name: Run tests 6 | 7 | strategy: 8 | matrix: 9 | include: 10 | 11 | - test-name: ubuntu-20.04-python-2.7 12 | os: ubuntu-20.04 13 | python-version: 2.7 14 | 15 | - test-name: ubuntu-18.04-python-3.4 16 | os: ubuntu-18.04 17 | python-version: 3.4 18 | 19 | - test-name: ubuntu-20.04-python-3.5 20 | os: ubuntu-20.04 21 | python-version: 3.5 22 | 23 | - test-name: ubuntu-20.04-python-3.6 24 | os: ubuntu-20.04 25 | python-version: 3.6 26 | 27 | - test-name: ubuntu-20.04-python-3.7 28 | os: ubuntu-20.04 29 | python-version: 3.7 30 | 31 | - test-name: ubuntu-20.04-python-3.8 32 | os: ubuntu-20.04 33 | python-version: 3.8 34 | 35 | runs-on: ${{ matrix.os }} 36 | 37 | steps: 38 | 39 | - name: Checkout repo 40 | uses: actions/checkout@v2 41 | 42 | - name: Setup go 43 | uses: actions/setup-go@v1 44 | with: 45 | go-version: 1.13 46 | 47 | - name: Setup pebble 48 | run: | 49 | export PATH=$PATH:$(go env GOPATH)/bin 50 | go get -u github.com/letsencrypt/pebble/... 51 | cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... 52 | pebble -h || true 53 | 54 | - name: Setup python 55 | uses: actions/setup-python@v2 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install virtualenv 63 | pip install -U -r tests/requirements.txt 64 | 65 | - name: Run tests with coverage 66 | run: | 67 | export PEBBLE_BIN=$(go env GOPATH)/bin/pebble 68 | coverage run --source . --omit ./setup.py -m unittest tests 69 | 70 | - name: Print coverage report 71 | run: coverage report -m 72 | 73 | - name: Upload coverage to coveralls.io 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 77 | COVERALLS_SERVICE_NAME: github-actions 78 | COVERALLS_FLAG_NAME: ${{ matrix.test-name }} 79 | COVERALLS_PARALLEL: true 80 | run: | 81 | python -m pip install --upgrade coveralls 82 | coveralls 83 | 84 | coveralls: 85 | name: Indicate completion to coveralls.io 86 | needs: test 87 | runs-on: ubuntu-latest 88 | container: python:3-slim 89 | steps: 90 | - name: Finished 91 | env: 92 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 93 | run: | 94 | python -m pip install --upgrade coveralls 95 | coveralls --service=github --finish 96 | 97 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-tests.yml: -------------------------------------------------------------------------------- 1 | name: Pull Request Tests 2 | on: [pull_request] 3 | jobs: 4 | test: 5 | name: Run tests 6 | 7 | strategy: 8 | matrix: 9 | include: 10 | 11 | - test-name: ubuntu-20.04-python-2.7 12 | os: ubuntu-20.04 13 | python-version: 2.7 14 | 15 | - test-name: ubuntu-18.04-python-3.4 16 | os: ubuntu-18.04 17 | python-version: 3.4 18 | 19 | - test-name: ubuntu-20.04-python-3.5 20 | os: ubuntu-20.04 21 | python-version: 3.5 22 | 23 | - test-name: ubuntu-20.04-python-3.6 24 | os: ubuntu-20.04 25 | python-version: 3.6 26 | 27 | - test-name: ubuntu-20.04-python-3.7 28 | os: ubuntu-20.04 29 | python-version: 3.7 30 | 31 | - test-name: ubuntu-20.04-python-3.8 32 | os: ubuntu-20.04 33 | python-version: 3.8 34 | 35 | runs-on: ${{ matrix.os }} 36 | 37 | steps: 38 | 39 | - name: Checkout repo 40 | uses: actions/checkout@v2 41 | 42 | - name: Setup go 43 | uses: actions/setup-go@v1 44 | with: 45 | go-version: 1.13 46 | 47 | - name: Setup pebble 48 | run: | 49 | export PATH=$PATH:$(go env GOPATH)/bin 50 | go get -u github.com/letsencrypt/pebble/... 51 | cd $GOPATH/src/github.com/letsencrypt/pebble && go install ./... 52 | pebble -h || true 53 | 54 | - name: Setup python 55 | uses: actions/setup-python@v2 56 | with: 57 | python-version: ${{ matrix.python-version }} 58 | 59 | - name: Install dependencies 60 | run: | 61 | python -m pip install --upgrade pip 62 | pip install virtualenv 63 | pip install -U -r tests/requirements.txt 64 | 65 | - name: Run tests with coverage 66 | run: | 67 | export PEBBLE_BIN=$(go env GOPATH)/bin/pebble 68 | coverage run --source . --omit ./setup.py -m unittest tests 69 | 70 | - name: Print coverage report 71 | run: coverage report -m 72 | 73 | -------------------------------------------------------------------------------- /.github/workflows/staging-tests.yml: -------------------------------------------------------------------------------- 1 | name: Staging Tests 2 | on: [workflow_dispatch] 3 | jobs: 4 | test: 5 | name: Run tests 6 | 7 | strategy: 8 | matrix: 9 | include: 10 | 11 | - test-name: ubuntu-20.04-python-2.7 12 | os: ubuntu-20.04 13 | python-version: 2.7 14 | 15 | - test-name: ubuntu-18.04-python-3.4 16 | os: ubuntu-18.04 17 | python-version: 3.4 18 | 19 | - test-name: ubuntu-20.04-python-3.5 20 | os: ubuntu-20.04 21 | python-version: 3.5 22 | 23 | - test-name: ubuntu-20.04-python-3.6 24 | os: ubuntu-20.04 25 | python-version: 3.6 26 | 27 | - test-name: ubuntu-20.04-python-3.7 28 | os: ubuntu-20.04 29 | python-version: 3.7 30 | 31 | - test-name: ubuntu-20.04-python-3.8 32 | os: ubuntu-20.04 33 | python-version: 3.8 34 | 35 | runs-on: ${{ matrix.os }} 36 | 37 | steps: 38 | 39 | - name: Checkout repo 40 | uses: actions/checkout@v2 41 | 42 | - name: Setup python 43 | uses: actions/setup-python@v2 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | 47 | - name: Install dependencies 48 | run: | 49 | python -m pip install --upgrade pip 50 | pip install virtualenv 51 | pip install -U -r tests/requirements.txt 52 | 53 | - name: Mount staging.gethttpsforfree.com on local for serving challenge files 54 | env: 55 | STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} 56 | run: | 57 | sudo apt install sshfs 58 | export SSHFS_KEYFILE=$(pwd)/sshfs_key.pem 59 | touch $SSHFS_KEYFILE 60 | chmod 600 $SSHFS_KEYFILE 61 | echo "$STAGING_SSH_KEY" > $SSHFS_KEYFILE 62 | mkdir -p /tmp/challenge-files 63 | nohup sshfs -o StrictHostKeyChecking=no,debug,IdentityFile=$SSHFS_KEYFILE -p 2299 challengeuser@staging.gethttpsforfree.com:/acme-challenge /tmp/challenge-files & 64 | sleep 10 65 | ls -lah /tmp/challenge-files 66 | 67 | - name: Run tests using the Let's Encrypt staging server 68 | run: | 69 | export ACME_TINY_USE_STAGING="1" 70 | export ACME_TINY_DOMAIN="staging.gethttpsforfree.com" 71 | export ACME_TINY_SSHFS_CHALLENGE_DIR="/tmp/challenge-files" 72 | coverage run --source . --omit ./setup.py -m unittest tests 73 | 74 | - name: Print coverage report 75 | run: coverage report -m 76 | 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Daniel Roesler 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # acme-tiny 2 | 3 | [![Tests](https://github.com/diafygi/acme-tiny/actions/workflows/full-tests-with-coverage.yml/badge.svg)](https://github.com/diafygi/acme-tiny/actions/workflows/full-tests-with-coverage.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/diafygi/acme-tiny/badge.svg?branch=master)](https://coveralls.io/github/diafygi/acme-tiny?branch=master) 5 | 6 | This is a tiny, auditable script that you can throw on your server to issue 7 | and renew [Let's Encrypt](https://letsencrypt.org/) certificates. Since it has 8 | to be run on your server and have access to your private Let's Encrypt account 9 | key, I tried to make it as tiny as possible (currently less than 200 lines). 10 | The only prerequisites are python and openssl. 11 | 12 | **PLEASE READ THE SOURCE CODE! YOU MUST TRUST IT WITH YOUR PRIVATE ACCOUNT KEY!** 13 | 14 | ## Donate 15 | 16 | If this script is useful to you, please donate to the EFF. I don't work there, 17 | but they do fantastic work. 18 | 19 | [https://eff.org/donate/](https://eff.org/donate/) 20 | 21 | ## How to use this script 22 | 23 | If you already have a Let's Encrypt issued certificate and just want to renew, 24 | you should only have to do Steps 3 and 6. 25 | 26 | ### Step 1: Create a Let's Encrypt account private key (if you haven't already) 27 | 28 | You must have a public key registered with Let's Encrypt and sign your requests 29 | with the corresponding private key. If you don't understand what I just said, 30 | this script likely isn't for you! Please use the official Let's Encrypt 31 | [client](https://github.com/letsencrypt/letsencrypt). 32 | To accomplish this you need to initially create a key, that can be used by 33 | acme-tiny, to register an account for you and sign all following requests. 34 | 35 | ``` 36 | openssl genrsa 4096 > account.key 37 | ``` 38 | 39 | #### Use existing Let's Encrypt key 40 | 41 | Alternatively you can convert your key, previously generated by the original 42 | Let's Encrypt client. 43 | 44 | The private account key from the Let's Encrypt client is saved in the 45 | [JWK](https://tools.ietf.org/html/rfc7517) format. `acme-tiny` is using the PEM 46 | key format. To convert the key, you can use the tool 47 | [conversion script](https://gist.github.com/JonLundy/f25c99ee0770e19dc595) by JonLundy: 48 | 49 | ```sh 50 | # Download the script 51 | wget -O - "https://gist.githubusercontent.com/JonLundy/f25c99ee0770e19dc595/raw/6035c1c8938fae85810de6aad1ecf6e2db663e26/conv.py" > conv.py 52 | 53 | # Copy your private key to your working directory 54 | cp /etc/letsencrypt/accounts/acme-v01.api.letsencrypt.org/directory//private_key.json private_key.json 55 | 56 | # Create a DER encoded private key 57 | openssl asn1parse -noout -out private_key.der -genconf <(python2 conv.py private_key.json) 58 | 59 | # Convert to PEM 60 | openssl rsa -in private_key.der -inform der > account.key 61 | ``` 62 | 63 | ### Step 2: Create a certificate signing request (CSR) for your domains. 64 | 65 | The ACME protocol (what Let's Encrypt uses) requires a CSR file to be submitted 66 | to it, even for renewals. You can use the same CSR for multiple renewals. NOTE: 67 | you can't use your account private key as your domain private key! 68 | 69 | ``` 70 | # Generate a domain private key (if you haven't already) 71 | openssl genrsa 4096 > domain.key 72 | ``` 73 | 74 | ``` 75 | # For a single domain 76 | openssl req -new -sha256 -key domain.key -subj "/CN=yoursite.com" > domain.csr 77 | 78 | # For multiple domains (use this one if you want both www.yoursite.com and yoursite.com) 79 | openssl req -new -sha256 -key domain.key -subj "/" -addext "subjectAltName = DNS:yoursite.com, DNS:www.yoursite.com" > domain.csr 80 | 81 | # For multiple domains (same as above but works with openssl < 1.1.1) 82 | openssl req -new -sha256 -key domain.key -subj "/" -reqexts SAN -config <(cat /etc/ssl/openssl.cnf <(printf "[SAN]\nsubjectAltName=DNS:yoursite.com,DNS:www.yoursite.com")) > domain.csr 83 | ``` 84 | 85 | ### Step 3: Make your website host challenge files 86 | 87 | You must prove you own the domains you want a certificate for, so Let's Encrypt 88 | requires you host some files on them. This script will generate and write those 89 | files in the folder you specify, so all you need to do is make sure that this 90 | folder is served under the ".well-known/acme-challenge/" url path. NOTE: Let's 91 | Encrypt will perform a plain HTTP request to port 80 on your server, so you 92 | must serve the challenge files via HTTP (a redirect to HTTPS is fine too). 93 | 94 | ``` 95 | # Make some challenge folder (modify to suit your needs) 96 | mkdir -p /var/www/challenges/ 97 | ``` 98 | 99 | ```nginx 100 | # Example for nginx 101 | server { 102 | listen 80; 103 | server_name yoursite.com www.yoursite.com; 104 | 105 | location /.well-known/acme-challenge/ { 106 | alias /var/www/challenges/; 107 | try_files $uri =404; 108 | } 109 | 110 | ...the rest of your config 111 | } 112 | ``` 113 | 114 | ### Step 4: Get a signed certificate! 115 | 116 | Now that you have setup your server and generated all the needed files, run this 117 | script on your server with the permissions needed to write to the above folder 118 | and read your private account key and CSR. 119 | 120 | ``` 121 | # Run the script on your server 122 | python acme_tiny.py --account-key ./account.key --csr ./domain.csr --acme-dir /var/www/challenges/ > ./signed_chain.crt 123 | ``` 124 | 125 | ### Step 5: Install the certificate 126 | 127 | The signed https certificate chain that is output by this script can be used along 128 | with your private key to run an https server. You need to include them in the 129 | https settings in your web server's configuration. Here's an example on how to 130 | configure an nginx server: 131 | 132 | ```nginx 133 | server { 134 | listen 443 ssl; 135 | server_name yoursite.com www.yoursite.com; 136 | 137 | ssl_certificate /path/to/signed_chain.crt; 138 | ssl_certificate_key /path/to/domain.key; 139 | ssl_session_timeout 5m; 140 | ssl_protocols TLSv1.2; 141 | ssl_ciphers ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; 142 | ssl_session_cache shared:SSL:50m; 143 | ssl_dhparam /path/to/server.dhparam; 144 | ssl_prefer_server_ciphers on; 145 | 146 | ...the rest of your config 147 | } 148 | 149 | server { 150 | listen 80; 151 | server_name yoursite.com www.yoursite.com; 152 | 153 | location /.well-known/acme-challenge/ { 154 | alias /var/www/challenges/; 155 | try_files $uri =404; 156 | } 157 | 158 | ...the rest of your config 159 | } 160 | ``` 161 | 162 | ### Step 6: Setup an auto-renew cronjob 163 | 164 | Congrats! Your website is now using https! Unfortunately, Let's Encrypt 165 | certificates only last for 90 days, so you need to renew them often. No worries! 166 | It's automated! Just make a bash script and add it to your crontab (see below 167 | for example script). 168 | 169 | Example of a `renew_cert.sh`: 170 | ```sh 171 | #!/usr/bin/sh 172 | 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.tmp || exit 173 | mv /path/to/signed_chain.crt.tmp /path/to/signed_chain.crt 174 | service nginx reload 175 | ``` 176 | 177 | ``` 178 | # Example line in your crontab (runs once per month) 179 | 0 0 1 * * /path/to/renew_cert.sh 2>> /var/log/acme_tiny.log 180 | ``` 181 | 182 | **NOTE:** Since Let's Encrypt's ACME v2 release (acme-tiny 4.0.0+), the intermediate 183 | certificate is included in the issued certificate download, so you no longer have 184 | to independently download the intermediate certificate and concatenate it to your 185 | signed certificate. If you have an shell script or Makefile using acme-tiny <4.0 (e.g. before 186 | 2018-03-17) with acme-tiny 4.0.0+, then you may be adding the intermediate 187 | certificate to your signed_chain.crt twice (which 188 | [causes issues with at least GnuTLS 3.7.0](https://gitlab.com/gnutls/gnutls/-/issues/1131) 189 | besides making the certificate slightly larger than it needs to be). To fix, 190 | simply remove the bash code where you're downloading the intermediate and adding 191 | it to the acme-tiny certificate output. 192 | 193 | ## Permissions 194 | 195 | The biggest problem you'll likely come across while setting up and running this 196 | script is permissions. You want to limit access to your account private key and 197 | challenge web folder as much as possible. I'd recommend creating a user 198 | specifically for handling this script, the account private key, and the 199 | challenge folder. Then add the ability for that user to write to your installed 200 | certificate file (e.g. `/path/to/signed_chain.crt`) and reload your webserver. That 201 | way, the cron script will do its thing, overwrite your old certificate, and 202 | reload your webserver without having permission to do anything else. 203 | 204 | **BE SURE TO:** 205 | * Backup your account private key (e.g. `account.key`) 206 | * Don't allow this script to be able to read your domain private key! 207 | * Don't allow this script to be run as root! 208 | 209 | ## Staging Environment 210 | 211 | Let's Encrypt recommends testing new configurations against their staging servers, 212 | so when testing out your new setup, you can use 213 | `--directory-url https://acme-staging-v02.api.letsencrypt.org/directory` 214 | to issue fake test certificates instead of real ones from Let's Encrypt's production servers. 215 | See [https://letsencrypt.org/docs/staging-environment/](https://letsencrypt.org/docs/staging-environment/) 216 | for more details. 217 | 218 | ## Feedback/Contributing 219 | 220 | This project has a very, very limited scope and codebase. I'm happy to receive 221 | bug reports and pull requests, but please don't add any new features. This 222 | script must stay under 200 lines of code to ensure it can be easily audited by 223 | anyone who wants to run it. 224 | 225 | If you want to add features for your own setup to make things easier for you, 226 | please do! It's open source, so feel free to fork it and modify as necessary. 227 | -------------------------------------------------------------------------------- /acme_tiny.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright Daniel Roesler, under MIT license, see LICENSE at github.com/diafygi/acme-tiny 3 | import argparse, subprocess, json, os, sys, base64, binascii, time, hashlib, re, copy, textwrap, logging 4 | try: 5 | from urllib.request import urlopen, Request # Python 3 6 | except ImportError: # pragma: no cover 7 | from urllib2 import urlopen, Request # Python 2 8 | 9 | DEFAULT_CA = "https://acme-v02.api.letsencrypt.org" # DEPRECATED! USE DEFAULT_DIRECTORY_URL INSTEAD 10 | DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory" 11 | 12 | LOGGER = logging.getLogger(__name__) 13 | LOGGER.addHandler(logging.StreamHandler()) 14 | LOGGER.setLevel(logging.INFO) 15 | 16 | def get_crt(account_key, csr, acme_dir, log=LOGGER, CA=DEFAULT_CA, disable_check=False, directory_url=DEFAULT_DIRECTORY_URL, contact=None, check_port=None): 17 | directory, acct_headers, alg, jwk = None, None, None, None # global variables 18 | 19 | # helper functions - base64 encode for jose spec 20 | def _b64(b): 21 | return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") 22 | 23 | # helper function - run external commands 24 | def _cmd(cmd_list, stdin=None, cmd_input=None, err_msg="Command Line Error"): 25 | proc = subprocess.Popen(cmd_list, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 26 | out, err = proc.communicate(cmd_input) 27 | if proc.returncode != 0: 28 | raise IOError("{0}\n{1}".format(err_msg, err)) 29 | return out 30 | 31 | # helper function - make request and automatically parse json response 32 | def _do_request(url, data=None, err_msg="Error", depth=0): 33 | try: 34 | resp = urlopen(Request(url, data=data, headers={"Content-Type": "application/jose+json", "User-Agent": "acme-tiny"})) 35 | resp_data, code, headers = resp.read().decode("utf8"), resp.getcode(), resp.headers 36 | except IOError as e: 37 | resp_data = e.read().decode("utf8") if hasattr(e, "read") else str(e) 38 | code, headers = getattr(e, "code", None), {} 39 | try: 40 | resp_data = json.loads(resp_data) # try to parse json results 41 | except ValueError: 42 | pass # ignore json parsing errors 43 | if depth < 100 and code == 400 and resp_data['type'] == "urn:ietf:params:acme:error:badNonce": 44 | raise IndexError(resp_data) # allow 100 retrys for bad nonces 45 | if code not in [200, 201, 204]: 46 | raise ValueError("{0}:\nUrl: {1}\nData: {2}\nResponse Code: {3}\nResponse: {4}".format(err_msg, url, data, code, resp_data)) 47 | return resp_data, code, headers 48 | 49 | # helper function - make signed requests 50 | def _send_signed_request(url, payload, err_msg, depth=0): 51 | payload64 = "" if payload is None else _b64(json.dumps(payload).encode('utf8')) 52 | new_nonce = _do_request(directory['newNonce'])[2]['Replay-Nonce'] 53 | protected = {"url": url, "alg": alg, "nonce": new_nonce} 54 | protected.update({"jwk": jwk} if acct_headers is None else {"kid": acct_headers['Location']}) 55 | protected64 = _b64(json.dumps(protected).encode('utf8')) 56 | protected_input = "{0}.{1}".format(protected64, payload64).encode('utf8') 57 | out = _cmd(["openssl", "dgst", "-sha256", "-sign", account_key], stdin=subprocess.PIPE, cmd_input=protected_input, err_msg="OpenSSL Error") 58 | data = json.dumps({"protected": protected64, "payload": payload64, "signature": _b64(out)}) 59 | try: 60 | return _do_request(url, data=data.encode('utf8'), err_msg=err_msg, depth=depth) 61 | except IndexError: # retry bad nonces (they raise IndexError) 62 | return _send_signed_request(url, payload, err_msg, depth=(depth + 1)) 63 | 64 | # helper function - poll until complete 65 | def _poll_until_not(url, pending_statuses, err_msg): 66 | result, t0 = None, time.time() 67 | while result is None or result['status'] in pending_statuses: 68 | assert (time.time() - t0 < 3600), "Polling timeout" # 1 hour timeout 69 | time.sleep(0 if result is None else 2) 70 | result, _, _ = _send_signed_request(url, None, err_msg) 71 | return result 72 | 73 | # parse account key to get public key 74 | log.info("Parsing account key...") 75 | out = _cmd(["openssl", "rsa", "-in", account_key, "-noout", "-text"], err_msg="OpenSSL Error") 76 | pub_pattern = r"modulus:[\s]+?00:([a-f0-9\:\s]+?)\npublicExponent: ([0-9]+)" 77 | pub_hex, pub_exp = re.search(pub_pattern, out.decode('utf8'), re.MULTILINE|re.DOTALL).groups() 78 | pub_exp = "{0:x}".format(int(pub_exp)) 79 | pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp 80 | alg, jwk = "RS256", { 81 | "e": _b64(binascii.unhexlify(pub_exp.encode("utf-8"))), 82 | "kty": "RSA", 83 | "n": _b64(binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex).encode("utf-8"))), 84 | } 85 | accountkey_json = json.dumps(jwk, sort_keys=True, separators=(',', ':')) 86 | thumbprint = _b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) 87 | 88 | # find domains 89 | log.info("Parsing CSR...") 90 | out = _cmd(["openssl", "req", "-in", csr, "-noout", "-text"], err_msg="Error loading {0}".format(csr)) 91 | domains = set([]) 92 | common_name = re.search(r"Subject:.*? CN\s?=\s?([^\s,;/]+)", out.decode('utf8')) 93 | if common_name is not None: 94 | domains.add(common_name.group(1)) 95 | subject_alt_names = re.search(r"X509v3 Subject Alternative Name: (?:critical)?\n +([^\n]+)\n", out.decode('utf8'), re.MULTILINE|re.DOTALL) 96 | if subject_alt_names is not None: 97 | for san in subject_alt_names.group(1).split(", "): 98 | if san.startswith("DNS:"): 99 | domains.add(san[4:]) 100 | log.info(u"Found domains: {0}".format(", ".join(domains))) 101 | 102 | # get the ACME directory of urls 103 | log.info("Getting directory...") 104 | directory_url = CA + "/directory" if CA != DEFAULT_CA else directory_url # backwards compatibility with deprecated CA kwarg 105 | directory, _, _ = _do_request(directory_url, err_msg="Error getting directory") 106 | log.info("Directory found!") 107 | 108 | # create account, update contact details (if any), and set the global key identifier 109 | log.info("Registering account...") 110 | reg_payload = {"termsOfServiceAgreed": True} if contact is None else {"termsOfServiceAgreed": True, "contact": contact} 111 | account, code, acct_headers = _send_signed_request(directory['newAccount'], reg_payload, "Error registering") 112 | log.info("{0} Account ID: {1}".format("Registered!" if code == 201 else "Already registered!", acct_headers['Location'])) 113 | if contact is not None: 114 | account, _, _ = _send_signed_request(acct_headers['Location'], {"contact": contact}, "Error updating contact details") 115 | log.info("Updated contact details:\n{0}".format("\n".join(account['contact']))) 116 | 117 | # create a new order 118 | log.info("Creating new order...") 119 | order_payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]} 120 | order, _, order_headers = _send_signed_request(directory['newOrder'], order_payload, "Error creating new order") 121 | log.info("Order created!") 122 | 123 | # get the authorizations that need to be completed 124 | for auth_url in order['authorizations']: 125 | authorization, _, _ = _send_signed_request(auth_url, None, "Error getting challenges") 126 | domain = authorization['identifier']['value'] 127 | 128 | # skip if already valid 129 | if authorization['status'] == "valid": 130 | log.info("Already verified: {0}, skipping...".format(domain)) 131 | continue 132 | log.info("Verifying {0}...".format(domain)) 133 | 134 | # find the http-01 challenge and write the challenge file 135 | challenge = [c for c in authorization['challenges'] if c['type'] == "http-01"][0] 136 | token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) 137 | keyauthorization = "{0}.{1}".format(token, thumbprint) 138 | wellknown_path = os.path.join(acme_dir, token) 139 | with open(wellknown_path, "w") as wellknown_file: 140 | wellknown_file.write(keyauthorization) 141 | 142 | # check that the file is in place 143 | try: 144 | wellknown_url = "http://{0}{1}/.well-known/acme-challenge/{2}".format(domain, "" if check_port is None else ":{0}".format(check_port), token) 145 | assert (disable_check or _do_request(wellknown_url)[0] == keyauthorization) 146 | except (AssertionError, ValueError) as e: 147 | raise ValueError("Wrote file to {0}, but couldn't download {1}: {2}".format(wellknown_path, wellknown_url, e)) 148 | 149 | # say the challenge is done 150 | _send_signed_request(challenge['url'], {}, "Error submitting challenges: {0}".format(domain)) 151 | authorization = _poll_until_not(auth_url, ["pending"], "Error checking challenge status for {0}".format(domain)) 152 | if authorization['status'] != "valid": 153 | raise ValueError("Challenge did not pass for {0}: {1}".format(domain, authorization)) 154 | os.remove(wellknown_path) 155 | log.info("{0} verified!".format(domain)) 156 | 157 | # finalize the order with the csr 158 | log.info("Signing certificate...") 159 | csr_der = _cmd(["openssl", "req", "-in", csr, "-outform", "DER"], err_msg="DER Export Error") 160 | _send_signed_request(order['finalize'], {"csr": _b64(csr_der)}, "Error finalizing order") 161 | 162 | # poll the order to monitor when it's done 163 | order = _poll_until_not(order_headers['Location'], ["pending", "processing"], "Error checking order status") 164 | if order['status'] != "valid": 165 | raise ValueError("Order failed: {0}".format(order)) 166 | 167 | # download the certificate 168 | certificate_pem, _, _ = _send_signed_request(order['certificate'], None, "Certificate download failed") 169 | log.info("Certificate signed!") 170 | return certificate_pem 171 | 172 | def main(argv=None): 173 | parser = argparse.ArgumentParser( 174 | formatter_class=argparse.RawDescriptionHelpFormatter, 175 | description=textwrap.dedent("""\ 176 | This script automates the process of getting a signed TLS certificate from Let's Encrypt using the ACME protocol. 177 | It will need to be run on your server and have access to your private account key, so PLEASE READ THROUGH IT! 178 | It's only ~200 lines, so it won't take long. 179 | 180 | 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 181 | """) 182 | ) 183 | parser.add_argument("--account-key", required=True, help="path to your Let's Encrypt account private key") 184 | parser.add_argument("--csr", required=True, help="path to your certificate signing request") 185 | parser.add_argument("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") 186 | parser.add_argument("--quiet", action="store_const", const=logging.ERROR, help="suppress output except for errors") 187 | parser.add_argument("--disable-check", default=False, action="store_true", help="disable checking if the challenge file is hosted correctly before telling the CA") 188 | parser.add_argument("--directory-url", default=DEFAULT_DIRECTORY_URL, help="certificate authority directory url, default is Let's Encrypt") 189 | parser.add_argument("--ca", default=DEFAULT_CA, help="DEPRECATED! USE --directory-url INSTEAD!") 190 | parser.add_argument("--contact", metavar="CONTACT", default=None, nargs="*", help="Contact details (e.g. mailto:aaa@bbb.com) for your account-key") 191 | parser.add_argument("--check-port", metavar="PORT", default=None, help="what port to use when self-checking the challenge file, default is port 80") 192 | 193 | args = parser.parse_args(argv) 194 | LOGGER.setLevel(args.quiet or LOGGER.level) 195 | 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, check_port=args.check_port) 196 | sys.stdout.write(signed_crt) 197 | 198 | if __name__ == "__main__": # pragma: no cover 199 | main(sys.argv[1:]) 200 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal=True 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="acme-tiny", 5 | use_scm_version=True, 6 | url="https://github.com/diafygi/acme-tiny", 7 | author="Daniel Roesler", 8 | author_email="diafygi@gmail.com", 9 | description="A tiny script to issue and renew TLS certs from Let's Encrypt", 10 | license="MIT", 11 | py_modules=['acme_tiny'], 12 | entry_points={'console_scripts': [ 13 | 'acme-tiny = acme_tiny:main', 14 | ]}, 15 | setup_requires=['setuptools_scm'], 16 | classifiers = [ 17 | 'Development Status :: 5 - Production/Stable', 18 | 'Intended Audience :: System Administrators', 19 | 'License :: OSI Approved :: MIT License', 20 | 'Operating System :: OS Independent', 21 | 'Programming Language :: Python', 22 | 'Programming Language :: Python :: 2', 23 | 'Programming Language :: Python :: 2.7', 24 | 'Programming Language :: Python :: 3', 25 | 'Programming Language :: Python :: 3.3', 26 | 'Programming Language :: Python :: 3.4', 27 | 'Programming Language :: Python :: 3.5', 28 | 'Programming Language :: Python :: 3.6', 29 | 'Programming Language :: Python :: 3.7', 30 | 'Programming Language :: Python :: 3.8', 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # How to test acme-tiny 2 | 3 | Testing acme-tiny requires a bit of setup since it needs to interact with a local Let's Encrypt CA test server. This readme explains how to set up your local environment so you can run `acme-tiny` tests yourself. 4 | 5 | ## Setup instructions (default) 6 | 7 | In the default test setup, we use [pebble](https://github.com/letsencrypt/pebble) as the mock Let's Encrypt CA server on your local computer. So you need to install that before running the test suite. 8 | 9 | 1. Install the Let's Encrypt test server: `pebble` (instructions below are for Ubuntu 20.04, adjust as needed) 10 | * `sudo apt install golang` 11 | * `go get -u github.com/letsencrypt/pebble/...` 12 | * `cd ~/go/src/github.com/letsencrypt/pebble && go install ./...` 13 | * `~/go/bin/pebble -h` (should print out pebble usage help) 14 | 2. Setup a virtual environment for python: 15 | * `virtualenv -p python3 /tmp/venv` (creates the virtualenv) 16 | * `source /tmp/venv/bin/activate` (starts using the virtualenv) 17 | 3. Install `acme-tiny` test dependencies: 18 | * `cd /path/to/acme-tiny` 19 | * `pip install -U -r tests/requirements.txt` 20 | 4. Run the test suite on your local. 21 | * `cd /path/to/acme-tiny` 22 | * `unset ACME_TINY_USE_STAGING` (optional, if set previously to use staging) 23 | * `unset ACME_TINY_DOMAIN` (optional, if set previously to use staging) 24 | * `export ACME_TINY_PEBBLE_BIN="..."` (optional, if different from `"$HOME/go/bin/pebble"`) 25 | * `coverage erase` (removes any previous coverage data files) 26 | * `coverage run --source . --omit ./setup.py -m unittest tests` (runs the test suite) 27 | * `coverage report -m` (optional, prints out coverage summary in console) 28 | * `coverage html` (optional, generates html coverage report you can browse at `htmlcov/index.html`) 29 | 30 | ## Setup instructions (staging) 31 | 32 | We also allow running the test suite against the official Let's Encrypt [staging](https://letsencrypt.org/docs/staging-environment/) server. Since the staging server is run by Let's Encrypt, you need to actually host a real domain an serve real challenge files. The simplest way to do this is to mount your remote server's static challenge file directory (see example instructions below). 33 | 34 | 1. Run a static server with a real domain (e.g. `test.mydomain.com`) with a challenge directory (instructions below are for Ubuntu 20.04, adjust as needed). 35 | * `ssh ubuntu@test.mydomain.com` (log into your server) 36 | * `mkdir -p /tmp/testfiles/.well-known/acme-challenge` (make the ACME challenge file directory) 37 | * `cd /tmp/testfiles` (go to the test file base directory) 38 | * `sudo python3 -m http.server 80 --bind 0.0.0.0` (start listening on port 80, NOTE: needs to run as root) 39 | * Alternatively, if you are already have a web server running on port 80, adjust that server's config to serve files statically from your test directory. 40 | 2. Mount your server's challenge directory on your local system (instructions below are for Ubuntu 20.04, adjust as needed). 41 | * `sudo apt install sshfs` (if not already done, install sshfs) 42 | * `sshfs ubuntu@test.mydomain.com:/tmp/testfiles/.well-known/acme-challenge /tmp/challenge-files` 43 | 3. Setup a virtual environment for python: 44 | * `virtualenv -p python3 /tmp/venv` (creates the virtualenv) 45 | * `source /tmp/venv/bin/activate` (starts using the virtualenv) 46 | 4. Install `acme-tiny` test dependencies: 47 | * `cd /path/to/acme-tiny` 48 | * `pip install -U -r tests/requirements.txt` 49 | 5. Run the test suite on your local. 50 | * `cd /path/to/acme-tiny` 51 | * `export ACME_TINY_USE_STAGING="1"` 52 | * `export ACME_TINY_DOMAIN="test.mydomain.com"` 53 | * `export ACME_TINY_SSHFS_CHALLENGE_DIR="/tmp/challenge-files"` 54 | * `coverage erase` (removes any previous coverage data files) 55 | * `coverage run --source . --omit ./setup.py -m unittest tests` (runs the test suite) 56 | * `coverage report -m` (optional, prints out coverage summary in console) 57 | * `coverage html` (optional, generates html coverage report you can browse at `htmlcov/index.html`) 58 | 6. When done, unmount the remote directory 59 | * `umount /tmp/challenge-files` 60 | 61 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from .test_module import TestModule 2 | from .test_install import TestInstall 3 | -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | -------------------------------------------------------------------------------- /tests/test_install.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import os 3 | import tempfile 4 | import shutil 5 | import subprocess 6 | 7 | 8 | class TestInstall(unittest.TestCase): 9 | def setUp(self): 10 | self.tempdir = tempfile.mkdtemp() 11 | subprocess.check_call(["virtualenv", self.tempdir]) 12 | 13 | def tearDown(self): 14 | shutil.rmtree(self.tempdir) 15 | 16 | def virtualenv_bin(self, cmd): 17 | return os.path.join(self.tempdir, "bin", cmd) 18 | 19 | def test_install(self): 20 | subprocess.check_call([self.virtualenv_bin("python"), "setup.py", "install"]) 21 | 22 | def test_cli(self): 23 | self.test_install() 24 | subprocess.check_call([self.virtualenv_bin("acme-tiny"), "-h"]) 25 | -------------------------------------------------------------------------------- /tests/test_module.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import time 5 | import shutil 6 | import logging 7 | import unittest 8 | import tempfile 9 | from subprocess import Popen, PIPE 10 | 11 | try: 12 | from urllib.request import urlopen, Request # Python 3 13 | except ImportError: # pragma: no cover 14 | from urllib2 import urlopen, Request # Python 2 15 | 16 | try: 17 | from StringIO import StringIO # Python 2 18 | except ImportError: # pragma: no cover 19 | from io import StringIO # Python 3 20 | 21 | import acme_tiny 22 | from . import utils 23 | 24 | # test settings based on environmental variables 25 | PEBBLE_BIN = os.getenv("ACME_TINY_PEBBLE_BIN") or "{}/go/bin/pebble".format(os.getenv("HOME")) # default pebble install path 26 | DOMAIN = os.getenv("ACME_TINY_DOMAIN") or "local.gethttpsforfree.com" # default to domain that resolves to 127.0.0.1 27 | USE_STAGING = bool(os.getenv("ACME_TINY_USE_STAGING")) # default to false 28 | SSHFS_CHALLENGE_DIR = os.getenv("ACME_TINY_SSHFS_CHALLENGE_DIR") # default to None (only used if USE_STAGING is True) 29 | 30 | class TestModule(unittest.TestCase): 31 | """ 32 | Tests for acme_tiny.py functionality itself 33 | """ 34 | def setUp(self): 35 | """ 36 | Set up ACME server for each test (or use Let's Encrypt's staging server) 37 | """ 38 | # create new account keys every test 39 | self.KEYS = utils.gen_keys(DOMAIN) 40 | 41 | # use Let's Encrypt staging server 42 | if USE_STAGING: # pragma: no cover 43 | os.unsetenv("SSL_CERT_FILE") # use the default ssl trust store 44 | # config references 45 | self.tempdir = SSHFS_CHALLENGE_DIR 46 | self.check_port = "80" 47 | self.DIR_URL = "https://acme-staging-v02.api.letsencrypt.org/directory" 48 | # staging server errors 49 | self.account_key_error = "certificate public key must be different than account key" 50 | self.ca_issued_string = "(STAGING) Let's Encrypt" 51 | self.bad_character_error = "Domain name contains an invalid character" 52 | 53 | # default to using pebble server 54 | else: 55 | # config references 56 | self.tempdir = None # generated below 57 | self.DIR_URL = "https://localhost:14000/dir" 58 | self._pebble_server, self._pebble_config = utils.setup_pebble(PEBBLE_BIN) 59 | self.check_port = str(self._pebble_config['pebble']['httpPort']) 60 | self._challenge_file_server, self._base_tempdir, self.tempdir = utils.setup_local_fileserver(self.check_port, pebble_proc=self._pebble_server) 61 | # pebble server errors 62 | self.account_key_error = "CSR contains a public key for a known account" 63 | self.ca_issued_string = "Pebble Intermediate CA" 64 | self.bad_character_error = "Order included DNS identifier with a value containing an illegal character" 65 | 66 | def tearDown(self): 67 | """ 68 | Shut down sub processes (pebble, etc.) 69 | """ 70 | # only need to shut down stuff if using local servers (pebble) 71 | if not USE_STAGING: 72 | 73 | self._pebble_server.terminate() 74 | self._pebble_server.wait() 75 | os.remove(self._pebble_config['pebble']['certificate']) 76 | os.remove(self._pebble_config['pebble']['privateKey']) 77 | 78 | self._challenge_file_server.terminate() 79 | self._challenge_file_server.wait() 80 | shutil.rmtree(self._base_tempdir) 81 | 82 | def test_module_linecount(self): 83 | """ This project is supposed to remain under 200 lines """ 84 | test_dir = os.path.dirname(os.path.realpath(__file__)) 85 | module_path = os.path.abspath(os.path.join(test_dir, os.pardir, "acme_tiny.py")) 86 | out, err = Popen(["wc", "-l", module_path], stdout=PIPE, stderr=PIPE).communicate() 87 | num_lines = int(out.decode("utf8").split(" ", 1)[0]) 88 | self.assertTrue(num_lines <= 200) 89 | 90 | def test_success_domain(self): 91 | """ Successfully issue a certificate via subject alt name """ 92 | old_stdout = sys.stdout 93 | sys.stdout = StringIO() 94 | result = acme_tiny.main([ 95 | "--account-key", self.KEYS['account_key'].name, 96 | "--csr", self.KEYS['domain_csr'].name, 97 | "--acme-dir", self.tempdir, 98 | "--directory-url", self.DIR_URL, 99 | "--check-port", self.check_port, 100 | ]) 101 | sys.stdout.seek(0) 102 | crt = sys.stdout.read().encode("utf8") 103 | sys.stdout = old_stdout 104 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) 105 | self.assertIn(self.ca_issued_string, out.decode("utf8")) 106 | 107 | def test_skip_valid_authorizations(self): 108 | """ Authorizations that are already valid should be skipped """ 109 | # issue a valid cert 110 | self.test_success_domain() 111 | 112 | # add a logging handler that captures the info log output 113 | log_output = StringIO() 114 | debug_handler = logging.StreamHandler(log_output) 115 | acme_tiny.LOGGER.addHandler(debug_handler) 116 | 117 | # issue the cert again, where challenges should already be valid 118 | old_stdout = sys.stdout 119 | sys.stdout = StringIO() 120 | result = acme_tiny.main([ 121 | "--account-key", self.KEYS['account_key'].name, 122 | "--csr", self.KEYS['domain_csr'].name, 123 | "--acme-dir", self.tempdir, 124 | "--directory-url", self.DIR_URL, 125 | "--check-port", self.check_port, 126 | ]) 127 | sys.stdout.seek(0) 128 | crt = sys.stdout.read().encode("utf8") 129 | sys.stdout = old_stdout 130 | log_output.seek(0) 131 | log_string = log_output.read().encode("utf8") 132 | 133 | # remove logging capture 134 | acme_tiny.LOGGER.removeHandler(debug_handler) 135 | 136 | # should say the domain is already verified 137 | self.assertIn("Already verified: {0}, skipping...".format(DOMAIN), log_string.decode("utf8")) 138 | 139 | def test_success_cli(self): 140 | """ Successfully issue a certificate via command line interface """ 141 | crt, err = Popen([ 142 | "python", "acme_tiny.py", 143 | "--account-key", self.KEYS['account_key'].name, 144 | "--csr", self.KEYS['domain_csr'].name, 145 | "--acme-dir", self.tempdir, 146 | "--directory-url", self.DIR_URL, 147 | "--check-port", self.check_port, 148 | ], stdout=PIPE, stderr=PIPE).communicate() 149 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) 150 | self.assertIn(self.ca_issued_string, out.decode("utf8")) 151 | 152 | def test_missing_account_key(self): 153 | """ OpenSSL throws an error when the account key is missing """ 154 | try: 155 | result = acme_tiny.main([ 156 | "--account-key", "/foo/bar", 157 | "--csr", self.KEYS['domain_csr'].name, 158 | "--acme-dir", self.tempdir, 159 | "--directory-url", self.DIR_URL, 160 | "--check-port", self.check_port, 161 | ]) 162 | except Exception as e: 163 | result = e 164 | self.assertIsInstance(result, IOError) 165 | self.assertIn("unable to load Private Key", result.args[0]) 166 | 167 | def test_missing_csr(self): 168 | """ OpenSSL throws an error when the CSR is missing """ 169 | try: 170 | result = acme_tiny.main([ 171 | "--account-key", self.KEYS['account_key'].name, 172 | "--csr", "/foo/bar", 173 | "--acme-dir", self.tempdir, 174 | "--directory-url", self.DIR_URL, 175 | "--check-port", self.check_port, 176 | ]) 177 | except Exception as e: 178 | result = e 179 | self.assertIsInstance(result, IOError) 180 | self.assertIn("Error loading /foo/bar", result.args[0]) 181 | 182 | def test_invalid_domain(self): 183 | """ Let's Encrypt rejects invalid domains """ 184 | try: 185 | result = acme_tiny.main([ 186 | "--account-key", self.KEYS['account_key'].name, 187 | "--csr", self.KEYS['invalid_csr'].name, 188 | "--acme-dir", self.tempdir, 189 | "--directory-url", self.DIR_URL, 190 | "--check-port", self.check_port, 191 | ]) 192 | except Exception as e: 193 | result = e 194 | self.assertIsInstance(result, ValueError) 195 | self.assertIn(self.bad_character_error, result.args[0]) 196 | 197 | def test_nonexistent_domain(self): 198 | """ Should be unable verify a nonexistent domain """ 199 | try: 200 | result = acme_tiny.main([ 201 | "--account-key", self.KEYS['account_key'].name, 202 | "--csr", self.KEYS['nonexistent_csr'].name, 203 | "--acme-dir", self.tempdir, 204 | "--directory-url", self.DIR_URL, 205 | "--check-port", self.check_port, 206 | ]) 207 | except Exception as e: 208 | result = e 209 | self.assertIsInstance(result, ValueError) 210 | self.assertIn("but couldn't download", result.args[0]) 211 | 212 | def test_account_key_domain(self): 213 | """ Can't use the account key for the CSR """ 214 | try: 215 | result = acme_tiny.main([ 216 | "--account-key", self.KEYS['account_key'].name, 217 | "--csr", self.KEYS['account_csr'].name, 218 | "--acme-dir", self.tempdir, 219 | "--directory-url", self.DIR_URL, 220 | "--check-port", self.check_port, 221 | ]) 222 | except Exception as e: 223 | result = e 224 | self.assertIsInstance(result, ValueError) 225 | self.assertIn(self.account_key_error, result.args[0]) 226 | 227 | def test_contact(self): 228 | """ Make sure optional contact details can be set """ 229 | # add a logging handler that captures the info log output 230 | log_output = StringIO() 231 | debug_handler = logging.StreamHandler(log_output) 232 | acme_tiny.LOGGER.addHandler(debug_handler) 233 | # call acme_tiny with new contact details 234 | old_stdout = sys.stdout 235 | sys.stdout = StringIO() 236 | result = acme_tiny.main([ 237 | "--account-key", self.KEYS['account_key'].name, 238 | "--csr", self.KEYS['domain_csr'].name, 239 | "--acme-dir", self.tempdir, 240 | "--directory-url", self.DIR_URL, 241 | "--check-port", self.check_port, 242 | "--contact", "mailto:devteam@gethttpsforfree.com", "mailto:boss@gethttpsforfree.com", 243 | ]) 244 | sys.stdout.seek(0) 245 | crt = sys.stdout.read().encode("utf8") 246 | sys.stdout = old_stdout 247 | log_output.seek(0) 248 | log_string = log_output.read().encode("utf8") 249 | # make sure the certificate was issued and the contact details were updated 250 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) 251 | self.assertIn(self.ca_issued_string, out.decode("utf8")) 252 | self.assertTrue(( # can be in either order 253 | "Updated contact details:\nmailto:devteam@gethttpsforfree.com\nmailto:boss@gethttpsforfree.com" in log_string.decode("utf8") 254 | or "Updated contact details:\nmailto:boss@gethttpsforfree.com\nmailto:devteam@gethttpsforfree.com" in log_string.decode("utf8") 255 | )) 256 | # remove logging capture 257 | acme_tiny.LOGGER.removeHandler(debug_handler) 258 | 259 | def test_challenge_failure(self): 260 | """ Raises error if challenge doesn't pass """ 261 | # man-in-the-middle ACME requests to modify valid challenges so we raise that exception 262 | def urlopenMITM(*args, **kwargs): 263 | resp = urlopenOriginal(*args, **kwargs) 264 | resp._orig_read = resp.read() 265 | # modify valid challenges and authorizations to invalid 266 | try: 267 | resp_json = json.loads(resp._orig_read.decode("utf8")) 268 | if ( 269 | len(resp_json.get("challenges", [])) == 1 270 | and resp_json['challenges'][0]['status'] == "valid" 271 | and resp_json['status'] == "valid" 272 | ): 273 | resp_json['challenges'][0]['status'] = "invalid" 274 | resp_json['status'] = "invalid" 275 | resp._orig_read = json.dumps(resp_json).encode("utf8") 276 | except ValueError: 277 | pass 278 | # serve up modified response when read 279 | def multi_read(): 280 | return resp._orig_read 281 | resp.read = multi_read 282 | return resp 283 | 284 | # call acme-tiny with MITM'd urlopen 285 | urlopenOriginal = acme_tiny.urlopen 286 | acme_tiny.urlopen = urlopenMITM 287 | try: 288 | acme_tiny.main([ 289 | "--account-key", self.KEYS['account_key'].name, 290 | "--csr", self.KEYS['domain_csr'].name, 291 | "--acme-dir", self.tempdir, 292 | "--directory-url", self.DIR_URL, 293 | "--check-port", self.check_port, 294 | ]) 295 | except ValueError as e: 296 | result = e 297 | acme_tiny.urlopen = urlopenOriginal 298 | 299 | # should raise error that challenge didn't pass 300 | self.assertIn("Challenge did not pass for", result.args[0]) 301 | 302 | def test_malicious_challenge_token(self): 303 | """ Raises error if malicious challenge token is provided by the CA """ 304 | 305 | # assume the CA wants to try to fool you into serving up your password file 306 | malicious_token = "../../../../etc/passwd" 307 | cleaned_token = "____________etc_passwd" 308 | 309 | # man-in-the-middle ACME requests to modify the challenge token to something malicious 310 | def urlopenMITM(*args, **kwargs): 311 | resp = urlopenOriginal(*args, **kwargs) 312 | resp._orig_read = resp.read() 313 | try: 314 | resp_json = json.loads(resp._orig_read.decode("utf8")) 315 | if len([c for c in resp_json.get("challenges", []) if c['type'] == "http-01"]) == 1: 316 | challenge = [c for c in resp_json['challenges'] if c['type'] == "http-01"][0] 317 | challenge['token'] = malicious_token 318 | resp._orig_read = json.dumps(resp_json).encode("utf8") 319 | except ValueError: 320 | pass 321 | # serve up modified response when read 322 | def multi_read(): 323 | return resp._orig_read 324 | resp.read = multi_read 325 | return resp 326 | 327 | # call acme-tiny with MITM'd urlopen 328 | urlopenOriginal = acme_tiny.urlopen 329 | acme_tiny.urlopen = urlopenMITM 330 | try: 331 | acme_tiny.main([ 332 | "--account-key", self.KEYS['account_key'].name, 333 | "--csr", self.KEYS['domain_csr'].name, 334 | "--acme-dir", self.tempdir, 335 | "--directory-url", self.DIR_URL, 336 | "--check-port", self.check_port, 337 | ]) 338 | except ValueError as e: 339 | result = e 340 | acme_tiny.urlopen = urlopenOriginal 341 | 342 | # should raise error that challenge didn't pass 343 | self.assertIn("Challenge did not pass for", result.args[0]) 344 | 345 | # challenge file actually saved as a cleaned version 346 | resp = urlopen(Request("http://{0}:{1}/.well-known/acme-challenge/{2}".format(DOMAIN, self.check_port, cleaned_token))) 347 | token_data = resp.read().decode("utf8") 348 | self.assertIn(cleaned_token, token_data) 349 | 350 | def test_order_failure(self): 351 | """ Raises error if order doesn't complete """ 352 | # man-in-the-middle ACME requests to modify valid orders so we raise that exception 353 | def urlopenMITM(*args, **kwargs): 354 | resp = urlopenOriginal(*args, **kwargs) 355 | resp._orig_read = resp.read() 356 | # modify valid orders to invalid 357 | try: 358 | resp_json = json.loads(resp._orig_read.decode("utf8")) 359 | if ( 360 | resp_json.get("finalize", None) is not None 361 | and resp_json.get("status", None) == "valid" 362 | ): 363 | resp_json['status'] = "invalid" 364 | resp._orig_read = json.dumps(resp_json).encode("utf8") 365 | except ValueError: 366 | pass 367 | # serve up modified response when read 368 | def multi_read(): 369 | return resp._orig_read 370 | resp.read = multi_read 371 | return resp 372 | 373 | # call acme-tiny with MITM'd urlopen 374 | urlopenOriginal = acme_tiny.urlopen 375 | acme_tiny.urlopen = urlopenMITM 376 | try: 377 | acme_tiny.main([ 378 | "--account-key", self.KEYS['account_key'].name, 379 | "--csr", self.KEYS['domain_csr'].name, 380 | "--acme-dir", self.tempdir, 381 | "--directory-url", self.DIR_URL, 382 | "--check-port", self.check_port, 383 | ]) 384 | except ValueError as e: 385 | result = e 386 | acme_tiny.urlopen = urlopenOriginal 387 | 388 | # should raise error that challenge didn't pass 389 | self.assertIn("Order failed", result.args[0]) 390 | 391 | ########################### 392 | ## Pebble-specific tests ## 393 | ########################### 394 | 395 | @unittest.skipIf(USE_STAGING, "only checked on pebble server since staging can't have nonce retries set") 396 | def test_nonce_retry(self): 397 | """ Still works when lots of nonce retries """ 398 | # kill current pebble server 399 | self._pebble_server.terminate() 400 | self._pebble_server.wait() 401 | os.remove(self._pebble_config['pebble']['certificate']) 402 | os.remove(self._pebble_config['pebble']['privateKey']) 403 | # restart with new bad nonce rate 404 | self._pebble_server, self._pebble_config = utils.setup_pebble(PEBBLE_BIN, bad_nonces=90) 405 | # normal success test 406 | self.test_success_domain() 407 | 408 | @unittest.skipIf(USE_STAGING, "only checked on pebble server since ") 409 | def test_pebble_doesnt_support_cn_domains(self): 410 | """ Test that pebble server doesn't support CN subject domains """ 411 | try: 412 | result = acme_tiny.main([ 413 | "--account-key", self.KEYS['account_key'].name, 414 | "--csr", self.KEYS['cn_csr'].name, 415 | "--acme-dir", self.tempdir, 416 | "--directory-url", self.DIR_URL, 417 | "--check-port", self.check_port, 418 | ]) 419 | except Exception as e: 420 | result = e 421 | self.assertIsInstance(result, ValueError) 422 | self.assertIn("Order includes different number of DNSnames identifiers than CSR specifies", result.args[0]) 423 | 424 | ############################ 425 | ## Staging-specific tests ## 426 | ############################ 427 | 428 | @unittest.skipIf((not USE_STAGING), "only checked on staging since pebble doesn't support CN names") 429 | def test_success_cn(self): # pragma: no cover 430 | """ Successfully issue a certificate via common name """ 431 | old_stdout = sys.stdout 432 | sys.stdout = StringIO() 433 | result = acme_tiny.main([ 434 | "--account-key", self.KEYS['account_key'].name, 435 | "--csr", self.KEYS['cn_csr'].name, 436 | "--acme-dir", self.tempdir, 437 | "--directory-url", self.DIR_URL, 438 | #"--check-port", self.check_port, # defaults to port 80 anyway, so test that the default works 439 | ]) 440 | sys.stdout.seek(0) 441 | crt = sys.stdout.read().encode("utf8") 442 | sys.stdout = old_stdout 443 | out, err = Popen(["openssl", "x509", "-text", "-noout"], stdin=PIPE, stdout=PIPE, stderr=PIPE).communicate(crt) 444 | self.assertIn(self.ca_issued_string, out.decode("utf8")) 445 | 446 | @unittest.skipIf((not USE_STAGING), "only checked on staging since pebble doesn't check for weak keys") 447 | def test_weak_key(self): # pragma: no cover 448 | """ Let's Encrypt rejects weak keys """ 449 | try: 450 | result = acme_tiny.main([ 451 | "--account-key", self.KEYS['weak_key'].name, 452 | "--csr", self.KEYS['domain_csr'].name, 453 | "--acme-dir", self.tempdir, 454 | "--directory-url", self.DIR_URL, 455 | #"--check-port", self.check_port, # defaults to port 80 anyway, so test that the default works 456 | ]) 457 | except Exception as e: 458 | result = e 459 | self.assertIsInstance(result, ValueError) 460 | self.assertIn("key too small", result.args[0]) 461 | 462 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import json 4 | import time 5 | from tempfile import NamedTemporaryFile, mkdtemp 6 | from subprocess import Popen 7 | try: 8 | from urllib.request import urlopen # Python 3 9 | except ImportError: # pragma: no cover 10 | from urllib2 import urlopen # Python 2 11 | 12 | def gen_keys(domain): 13 | """ Generate test account and domain keys """ 14 | 15 | # openssl config is system dependent 16 | openssl_cnf = None 17 | for possible_cnf in ['/etc/pki/tls/openssl.cnf', '/etc/ssl/openssl.cnf']: 18 | if os.path.exists(possible_cnf): 19 | with open(possible_cnf) as f: 20 | openssl_cnf = f.read().encode("utf8") 21 | 22 | # good account key 23 | account_key = NamedTemporaryFile() 24 | Popen(["openssl", "genrsa", "-out", account_key.name, "2048"]).wait() 25 | 26 | # weak 1024 bit key 27 | weak_key = NamedTemporaryFile() 28 | Popen(["openssl", "genrsa", "-out", weak_key.name, "1024"]).wait() 29 | 30 | # good domain key 31 | domain_key = NamedTemporaryFile() 32 | Popen(["openssl", "genrsa", "-out", domain_key.name, "2048"]).wait() 33 | 34 | # good domain csr 35 | domain_csr = NamedTemporaryFile() 36 | domain_conf = NamedTemporaryFile() 37 | domain_conf.write(openssl_cnf) 38 | domain_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(domain).encode("utf8")) 39 | domain_conf.flush() 40 | domain_conf.seek(0) 41 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 42 | "-subj", "/", "-reqexts", "SAN", "-config", domain_conf.name, 43 | "-out", domain_csr.name]).wait() 44 | 45 | # good domain via the Common Name 46 | cn_key = NamedTemporaryFile() 47 | cn_csr = NamedTemporaryFile() 48 | Popen(["openssl", "req", "-newkey", "rsa:2048", "-nodes", "-keyout", cn_key.name, 49 | "-subj", "/CN={0}".format(domain), "-out", cn_csr.name]).wait() 50 | 51 | # invalid domain csr 52 | invalid_csr = NamedTemporaryFile() 53 | invalid_conf = NamedTemporaryFile() 54 | invalid_conf.write(openssl_cnf) 55 | invalid_conf.write(u"\n[SAN]\nsubjectAltName=DNS:\xC3\xA0\xC2\xB2\xC2\xA0_\xC3\xA0\xC2\xB2\xC2\xA0.com\n".encode("utf8")) 56 | invalid_conf.seek(0) 57 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 58 | "-subj", "/", "-reqexts", "SAN", "-config", invalid_conf.name, 59 | "-out", invalid_csr.name]).wait() 60 | 61 | # nonexistent domain csr 62 | nonexistent_csr = NamedTemporaryFile() 63 | nonexistent_conf = NamedTemporaryFile() 64 | nonexistent_conf.write(openssl_cnf) 65 | nonexistent_conf.write("\n[SAN]\nsubjectAltName=DNS:404.gethttpsforfree.com\n".encode("utf8")) 66 | nonexistent_conf.seek(0) 67 | Popen(["openssl", "req", "-new", "-sha256", "-key", domain_key.name, 68 | "-subj", "/", "-reqexts", "SAN", "-config", nonexistent_conf.name, 69 | "-out", nonexistent_csr.name]).wait() 70 | 71 | # account-signed domain csr 72 | account_csr = NamedTemporaryFile() 73 | account_conf = NamedTemporaryFile() 74 | account_conf.write(openssl_cnf) 75 | account_conf.write("\n[SAN]\nsubjectAltName=DNS:{0}\n".format(domain).encode("utf8")) 76 | account_conf.seek(0) 77 | Popen(["openssl", "req", "-new", "-sha256", "-key", account_key.name, 78 | "-subj", "/", "-reqexts", "SAN", "-config", account_conf.name, 79 | "-out", account_csr.name]).wait() 80 | 81 | return { 82 | "account_key": account_key, 83 | "weak_key": weak_key, 84 | "cn_key": cn_key, 85 | "cn_csr": cn_csr, 86 | "domain_key": domain_key, 87 | "domain_csr": domain_csr, 88 | "invalid_csr": invalid_csr, 89 | "nonexistent_csr": nonexistent_csr, 90 | "account_csr": account_csr, 91 | } 92 | 93 | # Pebble server TLS certs 94 | # !!! DO NOT USE FOR ANYTHING EXCEPT TESTS !!! 95 | # Generated using the following commands: 96 | # openssl genrsa -out pebble_cert.key 4096 97 | # openssl req -x509 -new -nodes -key pebble_cert.key -days 9999 -subj "/" -addext "subjectAltName=DNS:localhost" -out pebble_cert.crt 98 | PEBBLE_CERT_KEY = """ 99 | -----BEGIN RSA PRIVATE KEY----- 100 | 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 149 | -----END RSA PRIVATE KEY----- 150 | """ 151 | PEBBLE_CERT = """ 152 | -----BEGIN CERTIFICATE----- 153 | MIIE9zCCAt+gAwIBAgIUTKfSOxaM/Gy3XrDc/3+xn4uy44YwDQYJKoZIhvcNAQEL 154 | BQAwADAeFw0yMTA4MDIyMTQ3MjZaFw00ODEyMTcyMTQ3MjZaMAAwggIiMA0GCSqG 155 | SIb3DQEBAQUAA4ICDwAwggIKAoICAQDm9avXjGUpl3gdhnZYvIhl2xMO1WcEVHt6 156 | DermNjF9lEfPU13XtPcz3gajl6mHkbeHx/eWlA2IsIIZ6cwDXitC3BJ5SiUUgDPn 157 | zP0/H7EEzaFbHrTJx8nylEC9jmCH1uSBn9xkNVrfc8WJgBcAn41yPAxDNWLBfLqy 158 | OYrdC2CNBplbZBTz/UAdW9nJUgJSFFKWyvYt9Oq2gjI2yOZZKq5pYHZsEJxUAqtx 159 | /CHg5QPFYMdmXddELAGeu8CpVdrrxN1hwfjvO07G5mTYAf39nw2ZNtcMnrlplZVb 160 | Ud1v4fd+fhb5mDVzIA7TawR7LoppUlq7KeJmE/atryeDmFjmX3g0JTiWXplhogOe 161 | SOD4Rvttj9aJFVx6yBJN609eVtzQTsM+MTdT6XHilCFU/zJRfZVAumhV4pwyf0xs 162 | K/prRs1HXbGZBuW5HRUPeEcoC+N/3YUVWqvUqIP/Jkr1oBCPBE3ZO+uoi1mkOYlz 163 | raDBmAtFKlCGl88ZpKa0QS6EppIcnGDZbHM1Q/RC1pMuNPUWYG7BklNw33Pkir2k 164 | Q0urxc+vYvWDaSb8IrVafnzhaaay8sTXJZK4gxE9TmBDqsvvdborn493zY0Y1xQc 165 | mBqlUScddt0pyAJUjF5VgFPD2x9HsxG9VuX07x6yt5l/IWM1O0OXe3PDrJDxSAsr 166 | NL8A80WxzwIDAQABo2kwZzAdBgNVHQ4EFgQUsA4MGHUxvlXDhDUm2K7LN9u7DhUw 167 | HwYDVR0jBBgwFoAUsA4MGHUxvlXDhDUm2K7LN9u7DhUwDwYDVR0TAQH/BAUwAwEB 168 | /zAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggIBAILebPEw 169 | 06Fow41ZbnSxTiuD9AaAL4PJe4l0gO33BoaES/kmoJMa9cEEkRTzScwljhC4eekc 170 | EnbqT2H1pgBwYSg9SW/fYrhGzSlKHCA62VNQ1benZSO78IY12ld+g6OaYT+QLtXf 171 | lBiRF4+L/9BOzYprfymr2HwdyaOLZc6Mf3YhGmMYqMdKriqrsqMYZmLEZ5Z2pNoF 172 | kiIFnWJeXcuSMkML/TWHMqL1IY1PN07bXeeWWsC8xD+YsLQIfdbEbf/hGSoby6nO 173 | JDDVUNHiSwEEhreXgBc/sLj/7G/BEEuU/u/fOi+NoK/gy4PHzNE7ZmmsDVyedByh 174 | 3L5bBsZGJwK+Rbz7fnthKe3ghhd+fSwRvfx07V3QBwlcD1iq/Im/UR5p3zbSR0gt 175 | zplW4F6fLAjkkGBNVNKEgRdYTF8FzHJWoHH1+kBKylb9L1p6kbUhJAbtYfkhZf2E 176 | 5QehOPt3WnVJDeDVKTyhFUWsOrOVmXuY5QV114jJaEfrBKrsJ/DTDrBiOS0jKDdI 177 | MQ2xZK0fvI15Osnr2OCggZk5kdAyaOM3ERWPVetBF9aKKFpIQMi1keOM/U5vBAJy 178 | LYEIK0jwMTf3vctsHkeWGVVMf2P498/+KHbomtUBBJU/0jp9G62xWukle5pfzfM9 179 | F5OzP6TuNVIGpCKuPMLZTfcSCPV3ZUEizOVX 180 | -----END CERTIFICATE----- 181 | """ 182 | 183 | class PebbleServerException(Exception): 184 | pass 185 | 186 | def setup_pebble(pebble_bin_path, bad_nonces=0): 187 | """ Start a pebble server and challenge file server """ 188 | 189 | # make testing cert temp files 190 | pebble_crt = NamedTemporaryFile(delete=False) # keep until manually cleaned up in tearDown 191 | pebble_crt.write(PEBBLE_CERT.encode("utf8")) 192 | pebble_crt.flush() 193 | 194 | pebble_key = NamedTemporaryFile(delete=False) # keep until manually cleaned up in tearDown 195 | pebble_key.write(PEBBLE_CERT_KEY.encode("utf8")) 196 | pebble_key.flush() 197 | 198 | # generate the pebble config 199 | pebble_config = { 200 | "pebble": { 201 | "listenAddress": "127.0.0.1:14000", 202 | "managementListenAddress": "127.0.0.1:15000", 203 | "certificate": pebble_crt.name, 204 | "privateKey": pebble_key.name, 205 | "httpPort": 5002, 206 | "tlsPort": 5001, 207 | "ocspResponderURL": "", 208 | "externalAccountBindingRequired": False, 209 | } 210 | } 211 | pebble_conf_file = NamedTemporaryFile() 212 | pebble_conf_file.write(json.dumps(pebble_config, indent=4, sort_keys=True).encode("utf8")) 213 | pebble_conf_file.flush() 214 | 215 | # start the pebble server 216 | os.environ['PEBBLE_AUTHZREUSE'] = str(100) 217 | os.environ['PEBBLE_WFE_NONCEREJECT'] = str(bad_nonces) 218 | pebble_server_proc = Popen([pebble_bin_path, "-config", pebble_conf_file.name]) 219 | 220 | # trust the pebble server cert by default 221 | os.environ['SSL_CERT_FILE'] = pebble_config['pebble']['certificate'] 222 | 223 | # wait until the pebble server responds 224 | wait_start = time.time() 225 | MAX_WAIT = 10 # 10 seconds 226 | while (time.time() - wait_start) < MAX_WAIT: 227 | try: 228 | resp = urlopen("https://localhost:14000/dir") 229 | if resp.getcode() == 200: 230 | break # done! 231 | except IOError: 232 | pass # don't care about failed connections 233 | time.sleep(0.5) # wait a bit and try again 234 | else: # pragma: no cover 235 | pebble_server_proc.terminate() 236 | raise PebbleServerException("pebble failed to start :(") 237 | 238 | return pebble_server_proc, pebble_config 239 | 240 | class ChallengeFileServerException(Exception): 241 | pass 242 | 243 | def setup_local_fileserver(test_port, pebble_proc=None): 244 | """ Start a local challenge file server """ 245 | 246 | # set challenge file temporary directory 247 | base_tempdir = mkdtemp() 248 | acme_tempdir = os.path.join(base_tempdir, ".well-known", "acme-challenge") 249 | os.makedirs(acme_tempdir) 250 | 251 | # start a fileserver for serving up challenges 252 | local_fileserver_proc = Popen([ 253 | "python", 254 | "-m", "SimpleHTTPServer" if sys.version_info.major == 2 else "http.server", 255 | test_port, 256 | ], cwd=base_tempdir) 257 | 258 | # make sure the fileserver is running 259 | testchallenge_text = "aaa".encode("utf8") 260 | testchallenge_path = os.path.join(acme_tempdir, "a.txt") 261 | testchallenge_file = open(testchallenge_path, "wb") 262 | testchallenge_file.write(testchallenge_text) 263 | testchallenge_file.close() 264 | wait_start = time.time() 265 | MAX_WAIT = 10 # 10 seconds 266 | while (time.time() - wait_start) < MAX_WAIT: 267 | try: 268 | resp = urlopen("http://localhost:{}/.well-known/acme-challenge/a.txt".format(test_port)) 269 | if resp.getcode() == 200 and resp.read() == testchallenge_text: 270 | os.remove(testchallenge_path) 271 | break # done! 272 | except IOError: 273 | pass # don't care about failed connections 274 | time.sleep(0.5) # wait a bit and try again 275 | else: # pragma: no cover 276 | os.remove(testchallenge_path) 277 | local_fileserver_proc.terminate() 278 | if pebble_proc is not None: 279 | pebble_proc.terminate() # also shut down pebble server (if any) before raising exception 280 | raise ChallengeFileServerException("challenge file server failed to start :(") 281 | 282 | return local_fileserver_proc, base_tempdir, acme_tempdir 283 | 284 | --------------------------------------------------------------------------------