diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index bdb0cab..0000000 --- a/.gitattributes +++ /dev/null @@ -1,17 +0,0 @@ -# Auto detect text files and perform LF normalization -* text=auto - -# Custom for Visual Studio -*.cs diff=csharp - -# Standard to msysgit -*.doc diff=astextplain -*.DOC diff=astextplain -*.docx diff=astextplain -*.DOCX diff=astextplain -*.dot diff=astextplain -*.DOT diff=astextplain -*.pdf diff=astextplain -*.PDF diff=astextplain -*.rtf diff=astextplain -*.RTF diff=astextplain diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b36bcf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Daniel Roesler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/acme_tiny.py b/acme_tiny.py new file mode 100644 index 0000000..cd6e8ef --- /dev/null +++ b/acme_tiny.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python +import argparse, subprocess, json, os, os.path, urllib2, sys, base64, binascii, time, \ + hashlib, re, copy, textwrap + +#CA = "https://acme-staging.api.letsencrypt.org" +CA = "https://acme-v01.api.letsencrypt.org" + +def get_crt(account_key, csr, acme_dir): + + # helper function base64 encode for jose spec + def _b64(b): + return base64.urlsafe_b64encode(b).replace("=", "") + + # parse account key to get public key + sys.stderr.write("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, re.MULTILINE|re.DOTALL).groups() + pub_mod = binascii.unhexlify(re.sub(r"(\s|:)", "", pub_hex)) + pub_mod64 = _b64(pub_mod) + pub_exp = "{0:x}".format(int(pub_exp)) + pub_exp = "0{0}".format(pub_exp) if len(pub_exp) % 2 else pub_exp + pub_exp64 = _b64(binascii.unhexlify(pub_exp)) + header = { + "alg": "RS256", + "jwk": { + "e": pub_exp64, + "kty": "RSA", + "n": pub_mod64, + }, + } + accountkey_json = json.dumps(header['jwk'], sort_keys=True, separators=(',', ':')) + thumbprint = _b64(hashlib.sha256(accountkey_json).digest()) + sys.stderr.write("parsed!\n") + + # helper function make signed requests + def _send_signed_request(url, payload): + nonce = urllib2.urlopen(CA + "/directory").headers['Replay-Nonce'] + payload64 = _b64(json.dumps(payload)) + protected = copy.deepcopy(header) + protected.update({"nonce": nonce}) + protected64 = _b64(json.dumps(protected)) + 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)) + 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 = urllib2.urlopen(url, data) + return resp.getcode(), resp.read() + except urllib2.HTTPError as e: + return e.code, e.read() + + # find domains + sys.stderr.write("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)) + domains = set([]) + common_name = re.search(r"Subject:.*? CN=([^\s,;/]+)", out) + if common_name is not None: + domains.add(common_name.group(1)) + subject_alt_names = re.search(r"X509v3 Subject Alternative Name: \n +([^\n]+)\n", out, re.MULTILINE|re.DOTALL) + if subject_alt_names is not None: + for san in subject_alt_names.group(1).split(", "): + if san.startswith("DNS:"): + domains.add(san[4:]) + sys.stderr.write("parsed!\n") + + # get the certificate domains and expiration + sys.stderr.write("Registering account...") + code, result = _send_signed_request(CA + "/acme/new-reg", { + "resource": "new-reg", + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf", + }) + if code == 201: + sys.stderr.write("registered!\n") + elif code == 409: + sys.stderr.write("already registered!\n") + else: + raise ValueError("Error registering: {0} {1}".format(code, result)) + + # verify each domain + for domain in domains: + sys.stderr.write("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 registering: {0} {1}".format(code, result)) + + # make the challenge file + challenge = [c for c in json.loads(result)['challenges'] if c['type'] == "http-01"][0] + challenge['token'] = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + keyauthorization = "{0}.{1}".format(challenge['token'], thumbprint) + wellknown_path = os.path.join(acme_dir, challenge['token']) + wellknown_file = open(wellknown_path, "w") + wellknown_file.write(keyauthorization) + wellknown_file.close() + + # check that the file is in place + wellknown_url = "http://{0}/.well-known/acme-challenge/{1}".format( + domain, challenge['token']) + try: + resp = urllib2.urlopen(wellknown_url) + assert resp.read().strip() == keyauthorization + except (urllib2.HTTPError, urllib2.URLError, AssertionError): + 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 = urllib2.urlopen(challenge['uri']) + challenge_status = json.loads(resp.read()) + except urllib2.HTTPError as e: + raise ValueError("Error checking challenge: {0} {1}".format( + e.code, json.loads(e.read()))) + if challenge_status['status'] == "pending": + time.sleep(2) + elif challenge_status['status'] == "valid": + sys.stderr.write("verified!\n") + os.remove(wellknown_path) + break + else: + raise ValueError("{0} challenge did not pass: {1}".format( + domain, challenge_status)) + + # get the new certificate + sys.stderr.write("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! + sys.stderr.write("signed!\n") + return """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join(textwrap.wrap(base64.b64encode(result), 64))) + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + 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 + ============================================== + """) + ) + 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("--acme-dir", required=True, help="path to the .well-known/acme-challenge/ directory") + + args = parser.parse_args() + signed_crt = get_crt(args.account_key, args.csr, args.acme_dir) + sys.stdout.write(signed_crt) diff --git a/cert.sh b/cert.sh new file mode 100644 index 0000000..4eee026 --- /dev/null +++ b/cert.sh @@ -0,0 +1,18 @@ +#!/bin/bash +path=$(pwd) +echo -n "Your Domain:" +read domain +docker-compose up -d +cd $path/certs/ +openssl genrsa 4096 > $path/certs/$domain.key +openssl req -new -sha256 -key $domain.key -subj "/CN=$domain" > $path/certs/$domain.csr +cd $path/ +python $path/acme_tiny.py --account-key account.key --csr $path/certs/$domain.csr --acme-dir $path/www/ > $path/certs/$domain.crt +wget -O - https://letsencrypt.org/certs/lets-encrypt-x1-cross-signed.pem > $path/certs/letsencrypt-intermediate.pem +cat $path/certs/$domain.crt $path/certs/letsencrypt-intermediate.pem > $path/certs/$domain.pem +rm $path/certs/letsencrypt-intermediate.pem +rm $path/certs/$domain.crt +cat $path/certs/$domain.pem > $path/certs/$domain.crt +rm $path/certs/$domain.pem +rm $path/certs/$domain.csr +docker-compose stop \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9d296fe --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +nginx: + image: nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./www:/usr/share/nginx/html + - ./nginx.conf:/nginx.conf + command: nginx -c /nginx.conf \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..1972c7a --- /dev/null +++ b/nginx.conf @@ -0,0 +1,56 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log crit; +pid /var/run/nginx.pid; + + +events { + worker_connections 51200; + multi_accept on; + use epoll; +} + +http { + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + server_tokens off; + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 15; + types_hash_max_size 2048; + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log off; + error_log off; + gzip on; + gzip_disable "msie6"; + open_file_cache max=100; + + server { + listen 80; + server_name localhost; + + location / { + root /usr/share/nginx/html/; + index index.html index.htm index.php; + } + + location /.well-known/acme-challenge/ { + alias /usr/share/nginx/html/; + try_files $uri =404; + + error_log /var/log/nginx/php_error.log; + access_log /var/log/nginx/php_access.log; + } +} + +} + +daemon off; \ No newline at end of file