diff --git a/.gitignore b/.gitignore index ba9fca1..adae6ca 100644 --- a/.gitignore +++ b/.gitignore @@ -89,3 +89,4 @@ ENV/ .ropeproject .idea/ +*.swp diff --git a/README.md b/README.md index e38089a..9325a1f 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ Simple way to get SSL certificates for free. ## Features -* Supports both Python 2 and Python 3 -* Works with both ACMEv1 and ACMEv2 protocols +* Supports both Python 2 (deprecated) and Python 3 +* Works with both ACMEv1 (deprecated) and ACMEv2 protocols * Can issue [wildcard certificates](https://en.wikipedia.org/wiki/Wildcard_certificate)! * Easy to use and extend @@ -33,7 +33,7 @@ to send `SIGHUP` to it during challenge completion. As you may not trust this script feel free to check source code, it's under 700 lines of code. -Script should be run as root on host with running nginx server. +Script should be run as root on host with running nginx server if you use http verification or if you use DNS verification as a regular user. Domain for which you request certificate should point to that host's IP and port 80 should be available from outside if you use HTTP challenge. Script can generate all keys for you if you don't set them with command line arguments. @@ -46,16 +46,20 @@ Should work with Python >= 2.6 ## ACME v2 -ACME v2 requires more logic so it's not as small as acme v1 script. +ACME v2 requires more logic so it's not as small as ACME v1 script. ACME v2 is supported partially: only `http-01` and `dns-01` challenges. Check https://tools.ietf.org/html/draft-ietf-acme-acme-07#section-9.7.6 New protocol is used by default. -`http-01` challenge is passed exactly as in v1 protocol realisation. +`http-01` challenge is passed exactly as in v1 protocol realization. -`dns-01` currently supports only DigitalOcean, AWS Route53 DNS providers. +`dns-01` currently supports following providers: + +- DigitalOcean +- AWS Route53 +- Cloudflare Technically nginx is not needed for this type of challenge but script still calls nginx reload by default because it assumes that you store certificates on the same server where you issue @@ -65,7 +69,7 @@ AWS Route53 uses `default` profile in session, specifying profile works with env Please check https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html#environment-variable-configuration In case you want to add support of different DNS providers your contribution is -highly apprectiated. +highly appreciated. Wildcard certificates can not be issued with non-wildcard for the same domain. I.e. it's not possible to issue certificates for `*.example.com` and @@ -78,21 +82,35 @@ Only HTTP challenge is supported at the moment. ## Installation -Please be informed that the quickiest and easiest way of installation is to use your OS -installation way because Python way includes compilation of dependencies that +Python 2 installation may require compilation of dependencies that may take much time and CPU resources and may require you to install all build dependencies. -### Fastest way +### Preferred way -Just download executable compiled with [pyinstaller](https://github.com/pyinstaller/pyinstaller). +Using [poetry](https://python-poetry.org/). -``` -wget https://github.com/kshcherban/acme-nginx/releases/download/v0.1.2/acme-nginx -chmod +x acme-nginx -``` +1. First [install](https://python-poetry.org/docs/) poetry: + + ```bash + curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python3 - + source ~/.poetry/env + ``` + +2. Clone acme-nginx: -### Python way + ```bash + git clone https://github.com/kshcherban/acme-nginx + ``` + +3. Install it: + + ```bash + cd acme-nginx + poetry install + ``` + +### Python pip way Automatically ``` @@ -124,8 +142,6 @@ docker cp acme:/usr/bin/acme-runner acme-nginx docker rm acme ``` - - ### Debian/Ubuntu way ``` @@ -173,13 +189,12 @@ Oct 12 23:42:23 Removing /etc/nginx/sites-enabled/letsencrypt and sending HUP to Certificate was generated into `/etc/ssl/private/letsencrypt-domain.pem` You can now configure nginx to use it: -``` +```nginx server { listen 443; ssl on; ssl_certificate /etc/ssl/private/letsencrypt-domain.pem; ssl_certificate_key /etc/ssl/private/letsencrypt-domain.key; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ... ``` @@ -199,7 +214,7 @@ sudo acme-nginx \ ### Wildcard certificates For wildcard certificate you need to have your domain managed by DNS provider -with API. Currently only [DigitalOcean DNS](https://www.digitalocean.com/docs/networking/dns/) and +with API. Currently only [DigitalOcean DNS](https://www.digitalocean.com/docs/networking/dns/), [Cloudflare](https://cloudflare.com) and [AWS Route53](https://aws.amazon.com/route53/) are supported. Example how to get wildcard certificate without nginx @@ -211,12 +226,25 @@ sudo acme-nginx --no-reload-nginx --dns-provider route53 -d "*.example.com" Please create and export your DO API token as `API_TOKEN` env variable. Now you can generate wildcard certificate -``` + +```bash sudo su - export API_TOKEN=yourDigitalOceanApiToken acme-nginx --dns-provider digitalocean -d '*.example.com' ``` +### Cloudflare + +[Create API token](https://dash.cloudflare.com/profile/api-tokens) first. Then export it as `API_TOKEN` environment variable and use like this: + +```bash +sudo su - +export API_TOKEN=yourCloudflareApiToken +acme-nginx --dns-provider cloudflare -d '*.example.com' +``` + + + ### Debug To debug please use `--debug` flag. With debug enabled all intermediate files diff --git a/acme-runner.py b/acme-runner.py index cc64a96..949c4f2 100755 --- a/acme-runner.py +++ b/acme-runner.py @@ -4,8 +4,9 @@ """Convenience wrapper for running acme-nginx directly from source tree.""" from acme_nginx.client import main + # uncomment this line for pyinstaller, this is boto3 dependency that pyinstaller ignores -#import configparser +# import configparser -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/acme_nginx/AWSRoute53.py b/acme_nginx/AWSRoute53.py index e8a39a2..28487c9 100644 --- a/acme_nginx/AWSRoute53.py +++ b/acme_nginx/AWSRoute53.py @@ -4,7 +4,7 @@ class AWSRoute53(object): def __init__(self): self.session = boto3.Session() - self.client = self.session.client('route53') + self.client = self.session.client("route53") def determine_domain(self, domain): """ @@ -14,15 +14,15 @@ def determine_domain(self, domain): Returns: zone_id, string, hosted zone id of matching domain """ - if not domain.endswith('.'): - domain = domain + '.' + if not domain.endswith("."): + domain = domain + "." # use paginator to iterate over all hosted zones - paginator = self.client.get_paginator('list_hosted_zones') + paginator = self.client.get_paginator("list_hosted_zones") # https://github.com/boto/botocore/issues/1535 result_key_iters is undocumented for page in paginator.paginate().result_key_iters(): for result in page: - if result['Name'] in domain: - return result['Id'] + if result["Name"] in domain: + return result["Id"] def create_record(self, name, data, domain): """ @@ -36,30 +36,26 @@ def create_record(self, name, data, domain): """ zone_id = self.determine_domain(domain) if not zone_id: - raise Exception('Hosted zone for domain {0} not found'.format(domain)) + raise Exception("Hosted zone for domain {0} not found".format(domain)) response = self.client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ - 'Changes': [ + "Changes": [ { - 'Action': 'UPSERT', - 'ResourceRecordSet': { - 'Name': name, - 'Type': 'TXT', - 'TTL': 60, - 'ResourceRecords': [ - { - 'Value': '"{0}"'.format(data) - } - ] - } + "Action": "UPSERT", + "ResourceRecordSet": { + "Name": name, + "Type": "TXT", + "TTL": 60, + "ResourceRecords": [{"Value": '"{0}"'.format(data)}], + }, } ] - } + }, ) - waiter = self.client.get_waiter('resource_record_sets_changed') - waiter.wait(Id=response['ChangeInfo']['Id']) - return {'name': name, 'data': data} + waiter = self.client.get_waiter("resource_record_sets_changed") + waiter.wait(Id=response["ChangeInfo"]["Id"]) + return {"name": name, "data": data} def delete_record(self, record, domain): """ @@ -72,20 +68,18 @@ def delete_record(self, record, domain): self.client.change_resource_record_sets( HostedZoneId=zone_id, ChangeBatch={ - 'Changes': [ + "Changes": [ { - 'Action': 'DELETE', - 'ResourceRecordSet': { - 'Name': record['name'], - 'Type': 'TXT', - 'TTL': 60, - 'ResourceRecords': [ - { - 'Value': '"{0}"'.format(record['data']) - } - ] - } + "Action": "DELETE", + "ResourceRecordSet": { + "Name": record["name"], + "Type": "TXT", + "TTL": 60, + "ResourceRecords": [ + {"Value": '"{0}"'.format(record["data"])} + ], + }, } ] - } + }, ) diff --git a/acme_nginx/Acme.py b/acme_nginx/Acme.py index 5ce38be..3e94c5d 100644 --- a/acme_nginx/Acme.py +++ b/acme_nginx/Acme.py @@ -5,7 +5,6 @@ import hashlib import json import os -import platform import subprocess import sys import tempfile @@ -23,18 +22,19 @@ class Acme(object): def __init__( - self, - api_url, - logger, - domains=None, - vhost='/etc/nginx/sites-enabled/0-letsencrypt.conf', - account_key='/etc/ssl/private/letsencrypt-account.key', - domain_key='/etc/ssl/private/letsencrypt-domain.key', - cert_path='/etc/ssl/private/letsencrypt-domain.pem', - dns_provider=None, - skip_nginx_reload=False, - renew_days=None, - debug=False): + self, + api_url, + logger, + domains=None, + vhost="/etc/nginx/sites-enabled/0-letsencrypt.conf", + account_key="/etc/ssl/private/letsencrypt-account.key", + domain_key="/etc/ssl/private/letsencrypt-domain.key", + cert_path="/etc/ssl/private/letsencrypt-domain.pem", + dns_provider=None, + skip_nginx_reload=False, + renew_days=None, + debug=False, + ): """ Params: api_url, str, Letsencrypt API URL @@ -63,48 +63,63 @@ def __init__( self.dns_provider = dns_provider self.skip_nginx_reload = skip_nginx_reload self.renew_days = renew_days - + self.IsOutOfDate = True if self.renew_days: try: - cert = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, open(self.cert_path).read()) + cert = OpenSSL.crypto.load_certificate( + OpenSSL.crypto.FILETYPE_PEM, open(self.cert_path).read() + ) date_format, encoding = "%Y%m%d%H%M%SZ", "ascii" - not_before = datetime.strptime(cert.get_notBefore().decode(encoding), date_format) - not_after = datetime.strptime(cert.get_notAfter().decode(encoding), date_format) + not_before = datetime.strptime( + cert.get_notBefore().decode(encoding), date_format + ) + not_after = datetime.strptime( + cert.get_notAfter().decode(encoding), date_format + ) now = datetime.now() - #self.log.info( 'x509: {0} {1} {2}'.format(cert, not_before, not_after) ) - #certTime = datetime.fromtimestamp(os.path.getmtime(self.cert_path)) - #certTimeThreshold = certTime + timedelta(days=self.renew_days) + # self.log.info( 'x509: {0} {1} {2}'.format(cert, not_before, not_after) ) + # certTime = datetime.fromtimestamp(os.path.getmtime(self.cert_path)) + # certTimeThreshold = certTime + timedelta(days=self.renew_days) certTimeThreshold = not_after - timedelta(days=self.renew_days) - self.IsOutOfDate = (not_before > now) or (not_after < now) or (certTimeThreshold < now) - self.log.info('Cert file {1} (expiration time {0})'.format( certTimeThreshold, "is out of date" if self.IsOutOfDate else "is not out of date")) - + self.IsOutOfDate = ( + (not_before > now) or (not_after < now) or (certTimeThreshold < now) + ) + self.log.info( + "Cert file {1} (expiration time {0})".format( + certTimeThreshold, + "is out of date" if self.IsOutOfDate else "is not out of date", + ) + ) + except OSError as e: if e.errno == 2: - self.log.info('Cert file {0} not found -> DO UPDATE CERT'.format(self.cert_path)) - except: + self.log.info( + "Cert file {0} not found -> DO UPDATE CERT".format( + self.cert_path + ) + ) + except: pass - def _reload_nginx(self): - """ Reload nginx """ - self.log.info('running nginx -s reload') + """Reload nginx""" + self.log.info("running nginx -s reload") process = subprocess.Popen( - 'nginx -s reload'.split(), - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) + "nginx -s reload".split(), stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) process_out = process.communicate() self.log.debug(process_out[0]) self.log.debug(process_out[1]) if process.returncode > 0: - self.log.error('failed to reload nginx') + self.log.error("failed to reload nginx") self.log.error(process_out[1]) def _write_vhost(self): - """ Write virtual host configuration for http """ + """Write virtual host configuration for http""" challenge_file = tempfile.mkdtemp() - self.log.info('created challenge file into {0}'.format(challenge_file)) + self.log.info("created challenge file into {0}".format(challenge_file)) os.chmod(challenge_file, 0o777) vhost_data = """ server {{ @@ -115,17 +130,19 @@ def _write_vhost(self): alias {alias}/; try_files $uri =404; }} -}}""".format(domain=' '.join(self.domains), alias=challenge_file) - self.log.info('writing virtual host into {0}'.format(self.vhost)) - with open(self.vhost, 'w') as fd: +}}""".format( + domain=" ".join(self.domains), alias=challenge_file + ) + self.log.info("writing virtual host into {0}".format(self.vhost)) + with open(self.vhost, "w") as fd: fd.write(vhost_data) os.chmod(self.vhost, 0o644) self._reload_nginx() return challenge_file def _write_challenge(self, challenge_dir, token, thumbprint): - self.log.info('writing challenge file into {0}'.format(self.vhost)) - with open('{0}/{1}'.format(challenge_dir, token), 'w') as fd: + self.log.info("writing challenge file into {0}".format(self.vhost)) + with open("{0}/{1}".format(challenge_dir, token), "w") as fd: fd.write("{0}.{1}".format(token, thumbprint)) def create_key(self, key_path, key_type=OpenSSL.crypto.TYPE_RSA, bits=2048): @@ -139,42 +156,52 @@ def create_key(self, key_path, key_type=OpenSSL.crypto.TYPE_RSA, bits=2048): string with private key """ try: - with open(key_path, 'r') as fd: + with open(key_path, "r") as fd: private_key = fd.read() except IOError: key = OpenSSL.crypto.PKey() key.generate_key(key_type, bits) private_key = OpenSSL.crypto.dump_privatekey( - OpenSSL.crypto.FILETYPE_PEM, key) - self.log.info('can not open key, writing new in {path}'.format(path=key_path)) + OpenSSL.crypto.FILETYPE_PEM, key + ) + self.log.info( + "can not open key, writing new in {path}".format(path=key_path) + ) if not os.path.isdir(os.path.dirname(key_path)): os.mkdir(os.path.dirname(key_path)) - with open(key_path, 'wb') as fd: + with open(key_path, "wb") as fd: fd.write(private_key) os.chmod(key_path, 0o400) return private_key def create_csr(self): - """ Generate CSR + """Generate CSR Return: string with CSR in DER format """ - sna = ', '.join(['DNS:{0}'.format(i) for i in self.domains]).encode('utf8') + sna = ", ".join(["DNS:{0}".format(i) for i in self.domains]).encode("utf8") req = OpenSSL.crypto.X509Req() req.get_subject().CN = self.domains[0] - req.add_extensions([OpenSSL.crypto.X509Extension( - 'subjectAltName'.encode('utf8'), critical=False, value=sna)]) - with open(self.domain_key, 'r') as fd: + req.add_extensions( + [ + OpenSSL.crypto.X509Extension( + "subjectAltName".encode("utf8"), critical=False, value=sna + ) + ] + ) + with open(self.domain_key, "r") as fd: key = fd.read() pk = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key) req.set_pubkey(pk) req.set_version(2) req.sign(pk, "sha256") - return OpenSSL.crypto.dump_certificate_request(OpenSSL.crypto.FILETYPE_ASN1, req) + return OpenSSL.crypto.dump_certificate_request( + OpenSSL.crypto.FILETYPE_ASN1, req + ) @staticmethod def _b64(b): - return base64.urlsafe_b64encode(b).decode('utf8').replace("=", "") + return base64.urlsafe_b64encode(b).decode("utf8").replace("=", "") def _sign_message(self, message): """ @@ -184,14 +211,14 @@ def _sign_message(self, message): Return: string with signed message """ - with open(self.account_key, 'r') as fd: + with open(self.account_key, "r") as fd: key = fd.read() pk = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key) - return OpenSSL.crypto.sign(pk, message.encode('utf8'), "sha256") + return OpenSSL.crypto.sign(pk, message.encode("utf8"), "sha256") def _jws(self): - """ Return JWS dict from string account key """ - with open(self.account_key, 'r') as fd: + """Return JWS dict from string account key""" + with open(self.account_key, "r") as fd: key = fd.read() pk = OpenSSL.crypto.load_privatekey(OpenSSL.crypto.FILETYPE_PEM, key) pk_asn1 = OpenSSL.crypto.dump_privatekey(OpenSSL.crypto.FILETYPE_ASN1, pk) @@ -204,30 +231,31 @@ def _jws(self): header = { "alg": "RS256", "jwk": { - "e": self._b64(binascii.unhexlify(exponent.encode('utf8'))), + "e": self._b64(binascii.unhexlify(exponent.encode("utf8"))), "kty": "RSA", - "n": self._b64(binascii.unhexlify(modulus.encode('utf8')))}} + "n": self._b64(binascii.unhexlify(modulus.encode("utf8"))), + }, + } return header def _thumbprint(self): - """ Return account thumbprint """ + """Return account thumbprint""" accountkey_json = json.dumps( - self._jws()['jwk'], - sort_keys=True, - separators=(',', ':')) - return self._b64(hashlib.sha256(accountkey_json.encode('utf8')).digest()) + self._jws()["jwk"], sort_keys=True, separators=(",", ":") + ) + return self._b64(hashlib.sha256(accountkey_json.encode("utf8")).digest()) def _cleanup(self, files): if not self.debug: for f in files: - self.log.info('removing {0}'.format(f)) + self.log.info("removing {0}".format(f)) try: if os.path.isdir(f): os.rmdir(f) else: os.remove(f) except OSError: - self.log.debug('{0} does not exist'.format(f)) + self.log.debug("{0} does not exist".format(f)) def _send_signed_request(self, url, payload=None, directory=None): """ @@ -237,65 +265,86 @@ def _send_signed_request(self, url, payload=None, directory=None): payload, any type, any payload you want to send, usually dict directory, dict, directory data from acme server """ - if not payload: - payload = {} request_headers = { "Content-Type": "application/jose+json", - "User-Agent": "acme-nginx/{0} urllib".format(self.version()) + "User-Agent": "acme-nginx/{0} urllib".format(self.version()), } - payload64 = self._b64(json.dumps(payload).encode('utf8')) + if payload is None: + payload = {} + # on POST-as-GET, final payload has to be just empty string + if payload == "": + payload64 = "" + else: + payload64 = self._b64(json.dumps(payload).encode("utf8")) # If not set then ACMEv1 is used if directory: # jwk and kid header fields are mutually exclusive - if directory['_kid']: - protected = {'kid': directory['_kid']} + if directory["_kid"]: + protected = {"kid": directory["_kid"]} else: protected = self._jws() - protected["nonce"] = urlopen(Request( - directory['newNonce'], - headers=request_headers) - ).headers['Replay-Nonce'] + protected["nonce"] = urlopen( + Request(directory["newNonce"], headers=request_headers) + ).headers["Replay-Nonce"] protected["url"] = url protected["alg"] = "RS256" # set for compatibility else: protected = self._jws() - protected["nonce"] = urlopen(self.api_url + "/directory").headers['Replay-Nonce'] - protected64 = self._b64(json.dumps(protected).encode('utf8')) + protected["nonce"] = urlopen(self.api_url + "/directory").headers[ + "Replay-Nonce" + ] + protected64 = self._b64(json.dumps(protected).encode("utf8")) signature = self._sign_message("{0}.{1}".format(protected64, payload64)) - data = json.dumps({ - "protected": protected64, - "payload": payload64, - "signature": self._b64(signature)}) + data = json.dumps( + { + "protected": protected64, + "payload": payload64, + "signature": self._b64(signature), + } + ) try: - resp = urlopen(Request(url, data=data.encode('utf8'), headers=request_headers)) + resp = urlopen( + Request( + url, + data=data.encode("utf8"), + headers=request_headers, + method="POST", + ) + ) resp_data = resp.read() try: - resp_data = resp_data.decode('utf8') + resp_data = resp_data.decode("utf8") except UnicodeDecodeError: pass return resp.getcode(), resp_data, resp.headers except Exception as e: - return getattr(e, "code", None), \ - getattr(e, "read", e.__str__)(), \ - getattr(e, "headers", None) + return ( + getattr(e, "code", None), + getattr(e, "read", e.__str__)(), + getattr(e, "headers", None), + ) - def _verify_challenge(self, url, domain): - """ Verify challenge for domain """ - self.log.info('waiting for {0} challenge verification'.format(domain)) + def _verify_challenge(self, url, domain, directory=None): + """Verify challenge for domain""" + self.log.info("waiting for {0} challenge verification".format(domain)) checks_count = 60 while True: checks_count -= 1 if checks_count <= 0: - self.log.error('reached waiting limit') + self.log.error("reached waiting limit") sys.exit(1) - challenge_status = json.loads(urlopen(url).read().decode('utf8')) - if challenge_status['status'] == "pending": + challenge_status = json.loads( + self._send_signed_request(url, "", directory)[1] + ) + if challenge_status["status"] == "pending": time.sleep(5) continue - elif challenge_status['status'] == "valid": - self.log.info('{0} verified!'.format(domain)) + elif challenge_status["status"] == "valid": + self.log.info("{0} verified!".format(domain)) break - self.log.error('{0} challenge did not pass: {1}'.format(domain, challenge_status)) + self.log.error( + "{0} challenge did not pass: {1}".format(domain, challenge_status) + ) sys.exit(1) @staticmethod @@ -309,7 +358,7 @@ def _get_challenge(challenges, challenge_type): challenge key """ for challenge in challenges: - if challenge['type'] == challenge_type: + if challenge["type"] == challenge_type: return challenge @staticmethod diff --git a/acme_nginx/AcmeV1.py b/acme_nginx/AcmeV1.py index 187bc3e..98f6b49 100644 --- a/acme_nginx/AcmeV1.py +++ b/acme_nginx/AcmeV1.py @@ -3,6 +3,7 @@ import re import sys import textwrap + try: from urllib.request import urlopen # Python 3 except ImportError: @@ -19,23 +20,27 @@ def register_account(self): dict, directory data from acme server """ try: - self.log.info('trying to create account key {0}'.format(self.account_key)) + self.log.info("trying to create account key {0}".format(self.account_key)) account_key = self.create_key(self.account_key) except Exception as e: - self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) + self.log.error("creating key {0} {1}".format(type(e).__name__, e)) sys.exit(1) - self.log.info('trying to register acmev1 account') + self.log.info("trying to register acmev1 account") payload = { "resource": "new-reg", - "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf" + "agreement": "https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf", } - code, result, headers = self._send_signed_request(url=self.api_url + "/acme/new-reg", payload=payload) + code, result, headers = self._send_signed_request( + url=self.api_url + "/acme/new-reg", payload=payload + ) if code == 201: - self.log.info('registered!') + self.log.info("registered!") elif code == 409: - self.log.info('already registered') + self.log.info("already registered") else: - self.log.error('error registering: {0} {1} {2}'.format(code, result, headers)) + self.log.error( + "error registering: {0} {1} {2}".format(code, result, headers) + ) sys.exit(1) return account_key @@ -45,74 +50,98 @@ def get_certificate(self): try: self.create_key(self.domain_key) except Exception as e: - self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) + self.log.error("creating key {0} {1}".format(type(e).__name__, e)) sys.exit(1) csr = self.create_csr() # Solve challenge - self.log.info('acmev1 http challenge') + self.log.info("acmev1 http challenge") for domain in self.domains: - self.log.info('requesting challenge for {0}'.format(domain)) + self.log.info("requesting challenge for {0}".format(domain)) payload = { "resource": "new-authz", "identifier": {"type": "dns", "value": domain}, } - code, result, _ = self._send_signed_request(url=self.api_url + "/acme/new-authz", payload=payload) + code, result, _ = self._send_signed_request( + url=self.api_url + "/acme/new-authz", payload=payload + ) if code != 201: - self.log.error('error requesting challenges: {0} {1}'.format(code, result)) + self.log.error( + "error requesting challenges: {0} {1}".format(code, result) + ) sys.exit(1) - challenge = self._get_challenge(json.loads(result)['challenges'], "http-01") - token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + challenge = self._get_challenge(json.loads(result)["challenges"], "http-01") + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"]) thumbprint = self._thumbprint() - self.log.info('adding nginx virtual host and completing challenge') + self.log.info("adding nginx virtual host and completing challenge") try: challenge_dir = self._write_vhost() self._write_challenge(challenge_dir, token, thumbprint) except Exception as e: - self.log.error('error adding virtual host {0} {1}'.format(type(e).__name__, e)) + self.log.error( + "error adding virtual host {0} {1}".format(type(e).__name__, e) + ) sys.exit(1) - self.log.info('asking acme server to verify challenge for {0}'.format(domain)) + self.log.info( + "asking acme server to verify challenge for {0}".format(domain) + ) try: payload = { "resource": "challenge", "keyAuthorization": "{0}.{1}".format(token, thumbprint), } - code, result, _ = self._send_signed_request(url=challenge['uri'], payload=payload) + code, result, _ = self._send_signed_request( + url=challenge["uri"], payload=payload + ) if code != 202: - self.log.error("error triggering challenge: {0} {1}".format(code, result)) + self.log.error( + "error triggering challenge: {0} {1}".format(code, result) + ) sys.exit(1) - self._verify_challenge(challenge['uri'], domain) + self._verify_challenge(challenge["uri"], domain) finally: - self._cleanup(['{0}/{1}'.format(challenge_dir, token), self.vhost, challenge_dir]) + self._cleanup( + ["{0}/{1}".format(challenge_dir, token), self.vhost, challenge_dir] + ) self._reload_nginx() - self.log.info('signing certificate') + self.log.info("signing certificate") try: payload = {"resource": "new-cert", "csr": self._b64(csr)} - code, result, _ = self._send_signed_request(url=self.api_url + "/acme/new-cert", payload=payload) + code, result, _ = self._send_signed_request( + url=self.api_url + "/acme/new-cert", payload=payload + ) if code != 201: - self.log.error("error signing certificate: {0} {1}".format(code, result)) + self.log.error( + "error signing certificate: {0} {1}".format(code, result) + ) sys.exit(1) - self.log.info('certificate signed!') + self.log.info("certificate signed!") try: - self.log.info('getting chain from {0}'.format(self.chain)) + self.log.info("getting chain from {0}".format(self.chain)) chain_str = urlopen(self.chain).read() if chain_str: - chain_str = chain_str.decode('utf8') + chain_str = chain_str.decode("utf8") except Exception as e: - self.log.error('error getting chain: {0} {1}'.format(type(e).__name__, e)) + self.log.error( + "error getting chain: {0} {1}".format(type(e).__name__, e) + ) sys.exit(1) - self.log.info('writing result file in {0}'.format(self.cert_path)) + self.log.info("writing result file in {0}".format(self.cert_path)) try: - with open(self.cert_path, 'w') as fd: + with open(self.cert_path, "w") as fd: fd.write( - '''-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n'''.format( - '\n'.join(textwrap.wrap( - base64.b64encode(result).decode('utf8'), - 64 - ))) + """-----BEGIN CERTIFICATE-----\n{0}\n-----END CERTIFICATE-----\n""".format( + "\n".join( + textwrap.wrap( + base64.b64encode(result).decode("utf8"), 64 + ) + ) + ) ) fd.write(chain_str) except Exception as e: - self.log.error('error writing cert: {0} {1}'.format(type(e).__name__, e)) + self.log.error( + "error writing cert: {0} {1}".format(type(e).__name__, e) + ) sys.exit(1) finally: self._reload_nginx() diff --git a/acme_nginx/AcmeV2.py b/acme_nginx/AcmeV2.py index b6aa47d..780f738 100644 --- a/acme_nginx/AcmeV2.py +++ b/acme_nginx/AcmeV2.py @@ -2,6 +2,7 @@ import json import re import sys + try: from urllib.request import urlopen, Request # Python 3 except ImportError: @@ -9,6 +10,7 @@ from .Acme import Acme from .DigitalOcean import DigitalOcean from .AWSRoute53 import AWSRoute53 +from .Cloudflare import Cloudflare class AcmeV2(Acme): @@ -20,61 +22,67 @@ def register_account(self): dict, directory data from acme server """ try: - self.log.info('trying to create account key {0}'.format(self.account_key)) + self.log.info("trying to create account key {0}".format(self.account_key)) self.create_key(self.account_key) except Exception as e: - self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) + self.log.error("creating key {0} {1}".format(type(e).__name__, e)) sys.exit(1) # directory is needed later for order placement - directory = urlopen(Request( - self.api_url, - headers={"Content-Type": "application/jose+json"}) - ).read().decode('utf8') + directory = ( + urlopen( + Request(self.api_url, headers={"Content-Type": "application/jose+json"}) + ) + .read() + .decode("utf8") + ) directory = json.loads(directory) - directory['_kid'] = None - self.log.info('trying to register acmev2 account') + directory["_kid"] = None + self.log.info("trying to register acmev2 account") payload = {"termsOfServiceAgreed": True} code, result, headers = self._send_signed_request( - url=directory['newAccount'], - payload=payload, - directory=directory + url=directory["newAccount"], payload=payload, directory=directory ) if code == 201: - self.log.info('registered!') + self.log.info("registered!") elif code == 200: - self.log.info('already registered') + self.log.info("already registered") else: - self.log.error('error registering: {0} {1} {2}'.format(code, result, headers)) + self.log.error( + "error registering: {0} {1} {2}".format(code, result, headers) + ) sys.exit(1) - directory['_kid'] = headers['Location'] + directory["_kid"] = headers["Location"] try: - self.log.info('trying to create domain key') + self.log.info("trying to create domain key") self.create_key(self.domain_key) except Exception as e: - self.log.error('creating key {0} {1}'.format(type(e).__name__, e)) + self.log.error("creating key {0} {1}".format(type(e).__name__, e)) sys.exit(1) return directory def _sign_certificate(self, order, directory): - """ Sign certificate """ - self.log.info('signing certificate') + """Sign certificate""" + self.log.info("signing certificate") csr = self.create_csr() payload = {"csr": self._b64(csr)} - code, result, headers = self._send_signed_request(url=order['finalize'], payload=payload, directory=directory) - self.log.debug('{0}, {1}, {2}'.format(code, result, headers)) + code, result, headers = self._send_signed_request( + url=order["finalize"], payload=payload, directory=directory + ) + self.log.debug("{0}, {1}, {2}".format(code, result, headers)) if code > 399: self.log.error("error signing certificate: {0} {1}".format(code, result)) self._reload_nginx() sys.exit(1) - self.log.info('certificate signed!') - self.log.info('downloading certificate') - certificate_pem = urlopen(json.loads(result)['certificate']).read().decode('utf8') - self.log.info('writing result file in {0}'.format(self.cert_path)) + self.log.info("certificate signed!") + self.log.info("downloading certificate") + certificate_url = json.loads(result)["certificate"] + certificate_pem = self._send_signed_request(certificate_url, "", directory)[1] + self.log.info("writing result file in {0}".format(self.cert_path)) try: - with open(self.cert_path, 'w') as fd: + with open(self.cert_path, "w") as fd: fd.write(certificate_pem) except Exception as e: - self.log.error('error writing cert: {0} {1}'.format(type(e).__name__, e)) + self.log.error("error writing cert: {0} {1}".format(type(e).__name__, e)) if not self.skip_nginx_reload: self._reload_nginx() @@ -84,41 +92,50 @@ def solve_http_challenge(self, directory): Params: directory, dict, directory data from acme server """ - self.log.info('acmev2 http challenge') - self.log.info('preparing new order') - order_payload = {"identifiers": [{"type": "dns", "value": d} for d in self.domains]} + self.log.info("acmev2 http challenge") + self.log.info("preparing new order") + order_payload = { + "identifiers": [{"type": "dns", "value": d} for d in self.domains] + } code, order, _ = self._send_signed_request( - url=directory['newOrder'], - payload=order_payload, - directory=directory + url=directory["newOrder"], payload=order_payload, directory=directory ) self.log.debug(order) order = json.loads(order) - self.log.info('order created') - for url in order['authorizations']: - auth = json.loads(urlopen(url).read().decode('utf8')) + self.log.info("order created") + for url in order["authorizations"]: + order_data = self._send_signed_request(url, "", directory) + auth = json.loads(order_data[1]) self.log.debug(json.dumps(auth)) - domain = auth['identifier']['value'] - self.log.info('verifying domain {0}'.format(domain)) - challenge = self._get_challenge(auth['challenges'], "http-01") - token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + domain = auth["identifier"]["value"] + self.log.info("verifying domain {0}".format(domain)) + challenge = self._get_challenge(auth["challenges"], "http-01") + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"]) thumbprint = self._thumbprint() - self.log.info('adding nginx virtual host and completing challenge') + self.log.info("adding nginx virtual host and completing challenge") try: challenge_dir = self._write_vhost() self._write_challenge(challenge_dir, token, thumbprint) except Exception as e: - self.log.error('error adding virtual host {0} {1}'.format(type(e).__name__, e)) + self.log.error( + "error adding virtual host {0} {1}".format(type(e).__name__, e) + ) sys.exit(1) - self.log.info('asking acme server to verify challenge') - code, result, _ = self._send_signed_request(url=challenge['url'], directory=directory) + self.log.info("asking acme server to verify challenge") + code, result, _ = self._send_signed_request( + url=challenge["url"], directory=directory + ) try: if code > 399: - self.log.error("error triggering challenge: {0} {1}".format(code, result)) + self.log.error( + "error triggering challenge: {0} {1}".format(code, result) + ) sys.exit(1) - self._verify_challenge(url, domain) + self._verify_challenge(url, domain, directory) finally: - self._cleanup(['{0}/{1}'.format(challenge_dir, token), self.vhost, challenge_dir]) + self._cleanup( + ["{0}/{1}".format(challenge_dir, token), self.vhost, challenge_dir] + ) self._reload_nginx() self._sign_certificate(order, directory) @@ -129,66 +146,76 @@ def solve_dns_challenge(self, directory, client): directory, dict, directory data from acme server client, object, dns provider client implementation """ - self.log.info('acmev2 dns challenge') - self.log.info('preparing new order') - order_payload = {"identifiers": [{"type": "dns", "value": d} for d in self.domains]} + self.log.info("acmev2 dns challenge") + self.log.info("preparing new order") + order_payload = { + "identifiers": [{"type": "dns", "value": d} for d in self.domains] + } code, order, _ = self._send_signed_request( - url=directory['newOrder'], - payload=order_payload, - directory=directory + url=directory["newOrder"], payload=order_payload, directory=directory ) self.log.debug(order) order = json.loads(order) - self.log.info('order created') - for url in order['authorizations']: - auth = json.loads(urlopen(url).read().decode('utf8')) + self.log.info("order created") + for url in order["authorizations"]: + order_data = self._send_signed_request(url, "", directory) + auth = json.loads(order_data[1]) self.log.debug(json.dumps(auth)) - domain = auth['identifier']['value'] - self.log.info('verifying domain {0}'.format(domain)) - challenge = self._get_challenge(auth['challenges'], "dns-01") - token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge['token']) + domain = auth["identifier"]["value"] + self.log.info("verifying domain {0}".format(domain)) + challenge = self._get_challenge(auth["challenges"], "dns-01") + token = re.sub(r"[^A-Za-z0-9_\-]", "_", challenge["token"]) thumbprint = self._thumbprint() keyauthorization = "{0}.{1}".format(token, thumbprint) - txt_record = self._b64(hashlib.sha256(keyauthorization.encode('utf8')).digest()) - self.log.info('creating TXT dns record _acme-challenge.{0} IN TXT {1}'.format(domain, txt_record)) + txt_record = self._b64( + hashlib.sha256(keyauthorization.encode("utf8")).digest() + ) + self.log.info( + "creating TXT dns record _acme-challenge.{0} IN TXT {1}".format( + domain, txt_record + ) + ) try: record = client.create_record( domain=domain, - name='_acme-challenge.{0}.'.format(domain.lstrip('*.').rstrip('.')), - data=txt_record) + name="_acme-challenge.{0}.".format(domain.lstrip("*.").rstrip(".")), + data=txt_record, + ) except Exception as e: - self.log.error('error creating dns record') + self.log.error("error creating dns record") self.log.error(e) sys.exit(1) try: - self.log.info('asking acme server to verify challenge') + self.log.info("asking acme server to verify challenge") payload = {"keyAuthorization": keyauthorization} code, result, headers = self._send_signed_request( - url=challenge['url'], - payload=payload, - directory=directory + url=challenge["url"], payload=payload, directory=directory ) if code > 399: - self.log.error("error triggering challenge: {0} {1}".format(code, result)) + self.log.error( + "error triggering challenge: {0} {1}".format(code, result) + ) raise Exception(result) - self._verify_challenge(url, domain) + self._verify_challenge(url, domain, directory) finally: try: if not self.debug: - self.log.info('delete dns record') + self.log.info("delete dns record") client.delete_record(domain=domain, record=record) except Exception as e: - self.log.error('error deleting dns record') + self.log.error("error deleting dns record") self.log.error(e) self._sign_certificate(order, directory) def get_certificate(self): directory = self.register_account() if self.dns_provider: - if self.dns_provider == 'digitalocean': + if self.dns_provider == "digitalocean": dns_client = DigitalOcean() - elif self.dns_provider == 'route53': + elif self.dns_provider == "route53": dns_client = AWSRoute53() + elif self.dns_provider == "cloudflare": + dns_client = Cloudflare() self.solve_dns_challenge(directory, dns_client) else: self.solve_http_challenge(directory) diff --git a/acme_nginx/Cloudflare.py b/acme_nginx/Cloudflare.py new file mode 100644 index 0000000..d1bf584 --- /dev/null +++ b/acme_nginx/Cloudflare.py @@ -0,0 +1,96 @@ +import json +from os import getenv + +try: + from urllib.request import urlopen, Request # Python 3 + from urllib.error import HTTPError +except ImportError: + from urllib2 import urlopen, Request # Python 2 + from urllib2 import HTTPError + + +class Cloudflare(object): + def __init__(self): + self.token = getenv("API_TOKEN") + self.api = "https://api.cloudflare.com/client/v4" + if not self.token: + raise Exception("API_TOKEN not found in environment") + + def determine_domain(self, domain): + """Determine registered domain in API + Returns zone id + """ + request_headers = { + "Content-Type": "application/json", + "Authorization": "Bearer {0}".format(self.token), + } + api = f"{self.api}/zones?name={domain}" + response = urlopen(Request(api, headers=request_headers)) + if response.getcode() != 200: + raise Exception(json.loads(response.read().decode("utf8"))) + domains = json.loads(response.read().decode("utf8"))["result"] + for d in domains: + if d["name"] in domain: + return d["id"] + + def create_record(self, name, data, domain): + """ + Create DNS record + Params: + name, string, record name + data, string, record data + domain, string, dns domain + Return: + record_id, string, created record id + """ + zone_id = self.determine_domain(domain) + api = f"{self.api}/zones/{zone_id}/dns_records" + request_headers = { + "Content-Type": "application/json", + "Authorization": "Bearer {0}".format(self.token), + } + request_data = { + "type": "TXT", + "ttl": 300, + "name": name, + "content": data, + "proxied": False, + } + try: + response = urlopen( + Request( + api, + data=json.dumps(request_data).encode("utf8"), + headers=request_headers, + ) + ) + except HTTPError as e: + raise Exception(e.read().decode("utf8")) + if response.getcode() != 200: + raise Exception(json.loads(response.read().decode("utf8"))) + return json.loads(response.read().decode("utf8"))["result"]["id"] + + def delete_record(self, record, domain): + """ + Delete DNS record + Params: + record, string, record id number + domain, string, dns domain + """ + zone_id = self.determine_domain(domain) + api = f"{self.api}/zones/{zone_id}/dns_records/{record}" + request_headers = { + "Content-Type": "application/json", + "Authorization": "Bearer {0}".format(self.token), + } + request = Request( + api, data=json.dumps({}).encode("utf8"), headers=request_headers + ) + # this is hack around urllib to send DELETE request + request.get_method = lambda: "DELETE" + try: + response = urlopen(request) + except HTTPError as e: + raise Exception(e.read().decode("utf8")) + if response.getcode() != 200: + raise Exception(json.loads(response.read().decode("utf8"))) diff --git a/acme_nginx/DigitalOcean.py b/acme_nginx/DigitalOcean.py index 526c967..9c1516a 100644 --- a/acme_nginx/DigitalOcean.py +++ b/acme_nginx/DigitalOcean.py @@ -1,31 +1,34 @@ import json from os import getenv + try: from urllib.request import urlopen, Request # Python 3 + from urllib.error import HTTPError except ImportError: from urllib2 import urlopen, Request # Python 2 + from urllib2 import HTTPError class DigitalOcean(object): def __init__(self): - self.token = getenv('API_TOKEN') + self.token = getenv("API_TOKEN") self.api = "https://api.digitalocean.com/v2/domains" if not self.token: - raise Exception('API_TOKEN not found in environment') + raise Exception("API_TOKEN not found in environment") def determine_domain(self, domain): - """ Determine registered domain in API """ + """Determine registered domain in API""" request_headers = { "Content-Type": "application/json", - "Authorization": "Bearer {0}".format(self.token) + "Authorization": "Bearer {0}".format(self.token), } response = urlopen(Request(self.api, headers=request_headers)) if response.getcode() != 200: - raise Exception(json.loads(response.read().decode('utf8'))) - domains = json.loads(response.read().decode('utf8'))['domains'] + raise Exception(json.loads(response.read().decode("utf8"))) + domains = json.loads(response.read().decode("utf8"))["domains"] for d in domains: - if d['name'] in domain: - return d['name'] + if d["name"] in domain: + return d["name"] def create_record(self, name, data, domain): """ @@ -38,25 +41,25 @@ def create_record(self, name, data, domain): record_id, int, created record id """ registered_domain = self.determine_domain(domain) - api = self.api + '/' + registered_domain + '/records' + api = self.api + "/" + registered_domain + "/records" request_headers = { "Content-Type": "application/json", - "Authorization": "Bearer {0}".format(self.token) - } - request_data = { - "type": "TXT", - "ttl": 300, - "name": name, - "data": data + "Authorization": "Bearer {0}".format(self.token), } - response = urlopen(Request( - api, - data=json.dumps(request_data).encode('utf8'), - headers=request_headers) - ) + request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data} + try: + response = urlopen( + Request( + api, + data=json.dumps(request_data).encode("utf8"), + headers=request_headers, + ) + ) + except HTTPError as e: + raise Exception(e.read().decode("utf8")) if response.getcode() != 201: - raise Exception(json.loads(response.read().decode('utf8'))) - return json.loads(response.read().decode('utf8'))['domain_record']['id'] + raise Exception(json.loads(response.read().decode("utf8"))) + return json.loads(response.read().decode("utf8"))["domain_record"]["id"] def delete_record(self, record, domain): """ @@ -66,14 +69,19 @@ def delete_record(self, record, domain): domain, string, dns domain """ registered_domain = self.determine_domain(domain) - api = self.api + '/' + registered_domain + '/records/' + str(record) + api = self.api + "/" + registered_domain + "/records/" + str(record) request_headers = { "Content-Type": "application/json", - "Authorization": "Bearer {0}".format(self.token) + "Authorization": "Bearer {0}".format(self.token), } - request = Request(api, data=json.dumps({}).encode('utf8'), headers=request_headers) + request = Request( + api, data=json.dumps({}).encode("utf8"), headers=request_headers + ) # this is hack around urllib to send DELETE request - request.get_method = lambda: 'DELETE' - response = urlopen(request) + request.get_method = lambda: "DELETE" + try: + response = urlopen(request) + except HTTPError as e: + raise Exception(e.read().decode("utf8")) if response.getcode() != 204: - raise Exception(json.loads(response.read().decode('utf8'))) + raise Exception(json.loads(response.read().decode("utf8"))) diff --git a/acme_nginx/client.py b/acme_nginx/client.py index 188998f..471f540 100644 --- a/acme_nginx/client.py +++ b/acme_nginx/client.py @@ -10,75 +10,85 @@ def set_arguments(): """ parser = argparse.ArgumentParser() parser.add_argument( - '-k', - '--private-key', - dest='private_key', - default='/etc/ssl/private/letsencrypt-account.key', - type=str, - help=('path to letsencrypt account private key, ' - 'default: /etc/ssl/private/letsencrypt-account.key')) + "-k", + "--private-key", + dest="private_key", + default="/etc/ssl/private/letsencrypt-account.key", + type=str, + help=( + "path to letsencrypt account private key, " + "default: /etc/ssl/private/letsencrypt-account.key" + ), + ) parser.add_argument( - '--domain-private-key', - dest='domain_key', - type=str, - default='/etc/ssl/private/letsencrypt-domain.key', - help=('path to domain private key, ' - 'default: /etc/ssl/private/letsencrypt-domain.key')) + "--domain-private-key", + dest="domain_key", + type=str, + default="/etc/ssl/private/letsencrypt-domain.key", + help=( + "path to domain private key, " + "default: /etc/ssl/private/letsencrypt-domain.key" + ), + ) parser.add_argument( - '-o', - '--output', - dest='cert_path', - type=str, - default='/etc/ssl/private/letsencrypt-domain.pem', - help='certificate path, default: /etc/ssl/private/letsencrypt.pem') + "-o", + "--output", + dest="cert_path", + type=str, + default="/etc/ssl/private/letsencrypt-domain.pem", + help="certificate path, default: /etc/ssl/private/letsencrypt.pem", + ) parser.add_argument( - '-d', - '--domain', - dest='domain', - type=str, - action='append', - required=True, - help='domain name, can be repeated for SAN') + "-d", + "--domain", + dest="domain", + type=str, + action="append", + required=True, + help="domain name, can be repeated for SAN", + ) parser.add_argument( - '--virtual-host', - dest='vhost', - type=str, - default='/etc/nginx/sites-enabled/0-letsencrypt.conf', - help=('path to nginx virtual host for challenge completion, ' - 'default: /etc/nginx/sites-enabled/0-letsencrypt.conf')) + "--virtual-host", + dest="vhost", + type=str, + default="/etc/nginx/sites-enabled/0-letsencrypt.conf", + help=( + "path to nginx virtual host for challenge completion, " + "default: /etc/nginx/sites-enabled/0-letsencrypt.conf" + ), + ) parser.add_argument( - '--debug', - dest='debug', - action='store_true', - help="don't delete intermediate files for debugging") + "--debug", + dest="debug", + action="store_true", + help="don't delete intermediate files for debugging", + ) parser.add_argument( - '--acme-v1', - dest='acmev1', - action='store_true', - help='use ACME v1 api version') + "--acme-v1", dest="acmev1", action="store_true", help="use ACME v1 api version" + ) parser.add_argument( - '--dns-provider', - dest='dns_provider', - choices=['digitalocean', 'route53']) + "--dns-provider", + dest="dns_provider", + choices=["digitalocean", "route53", "cloudflare"], + ) parser.add_argument( - '--staging', - action='store_true', - help='use staging api endpoint for testing') + "--staging", action="store_true", help="use staging api endpoint for testing" + ) parser.add_argument( - '-V', - '--version', - action='version', - version='acme-nginx {0}'.format(AcmeV2.version())) + "-V", + "--version", + action="version", + version="acme-nginx {0}".format(AcmeV2.version()), + ) parser.add_argument( - '--no-reload-nginx', - dest='skip_reload', - action='store_true', - help="don't reload nginx after certificate signing") + "--no-reload-nginx", + dest="skip_reload", + action="store_true", + help="don't reload nginx after certificate signing", + ) parser.add_argument( - '--renew-days', - dest='renew_days', - type=int, - help="expiration threshold in days") + "--renew-days", dest="renew_days", type=int, help="expiration threshold in days" + ) return parser.parse_args() @@ -88,20 +98,22 @@ def main(): log_level = logging.DEBUG else: log_level = logging.INFO - logging.basicConfig(format='%(asctime)s - %(levelname)s - %(message)s', level=log_level) - log = logging.getLogger('acme') + logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=log_level + ) + log = logging.getLogger("acme") if args.acmev1: Acme = AcmeV1 if args.staging: - api_url = 'https://acme-staging.api.letsencrypt.org' + api_url = "https://acme-staging.api.letsencrypt.org" else: - api_url = 'https://acme-v01.api.letsencrypt.org' + api_url = "https://acme-v01.api.letsencrypt.org" else: Acme = AcmeV2 if args.staging: - api_url = 'https://acme-staging-v02.api.letsencrypt.org/directory' + api_url = "https://acme-staging-v02.api.letsencrypt.org/directory" else: - api_url = 'https://acme-v02.api.letsencrypt.org/directory' + api_url = "https://acme-v02.api.letsencrypt.org/directory" acme = Acme( api_url=api_url, logger=log, @@ -113,7 +125,7 @@ def main(): debug=args.debug, dns_provider=args.dns_provider, skip_nginx_reload=args.skip_reload, - renew_days=args.renew_days + renew_days=args.renew_days, ) if acme.IsOutOfDate: acme.get_certificate() diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..b94420a --- /dev/null +++ b/poetry.lock @@ -0,0 +1,251 @@ +[[package]] +name = "boto3" +version = "1.17.110" +description = "The AWS SDK for Python" +category = "main" +optional = false +python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +botocore = ">=1.20.110,<1.21.0" +jmespath = ">=0.7.1,<1.0.0" +s3transfer = ">=0.4.0,<0.5.0" + +[[package]] +name = "botocore" +version = "1.20.110" +description = "Low-level, data-driven core of boto 3." +category = "main" +optional = false +python-versions = ">= 2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" + +[package.dependencies] +jmespath = ">=0.7.1,<1.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = ">=1.25.4,<1.27" + +[package.extras] +crt = ["awscrt (==0.11.24)"] + +[[package]] +name = "cffi" +version = "1.14.6" +description = "Foreign Function Interface for Python calling C code." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +pycparser = "*" + +[[package]] +name = "cryptography" +version = "3.4.7" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +category = "main" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1)", "sphinx-rtd-theme"] +docstest = ["doc8", "pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] +pep8test = ["black", "flake8", "flake8-import-order", "pep8-naming"] +sdist = ["setuptools-rust (>=0.11.4)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] + +[[package]] +name = "jmespath" +version = "0.10.0" +description = "JSON Matching Expressions" +category = "main" +optional = false +python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "pycparser" +version = "2.20" +description = "C parser in Python" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "pycrypto" +version = "2.6.1" +description = "Cryptographic modules for Python." +category = "main" +optional = false +python-versions = "*" + +[[package]] +name = "pyopenssl" +version = "20.0.1" +description = "Python wrapper module around the OpenSSL library" +category = "main" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" + +[package.dependencies] +cryptography = ">=3.2" +six = ">=1.5.2" + +[package.extras] +docs = ["sphinx", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] + +[[package]] +name = "python-dateutil" +version = "2.8.1" +description = "Extensions to the standard Python datetime module" +category = "main" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "s3transfer" +version = "0.4.2" +description = "An Amazon S3 Transfer Manager" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +botocore = ">=1.12.36,<2.0a.0" + +[package.extras] +crt = ["botocore[crt] (>=1.20.29,<2.0a.0)"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" + +[[package]] +name = "urllib3" +version = "1.26.6" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" + +[package.extras] +brotli = ["brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[metadata] +lock-version = "1.1" +python-versions = "^3.6" +content-hash = "c89998285989bdc99b51f3255dd1075adad4824e10791aed2838a4a2e687240c" + +[metadata.files] +boto3 = [ + {file = "boto3-1.17.110-py2.py3-none-any.whl", hash = "sha256:3a7b183def075f6fe17c1154ecec42fc42f9c4ac05a7e7e018f267b7d5ef5961"}, + {file = "boto3-1.17.110.tar.gz", hash = "sha256:00be3c440db39a34a049eabce79377a0b3d453b6a24e2fa52e5156fa08f929bd"}, +] +botocore = [ + {file = "botocore-1.20.110-py2.py3-none-any.whl", hash = "sha256:3500d0f0f15240a86efa6be91bf37df412d8cc10fc4b98ffea369dc13fb014da"}, + {file = "botocore-1.20.110.tar.gz", hash = "sha256:b69fd6c72d30b2ea0a42e7a2c3b9d65da3f4ccdff57bfaf6c721b0555a971bd6"}, +] +cffi = [ + {file = "cffi-1.14.6-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:22b9c3c320171c108e903d61a3723b51e37aaa8c81255b5e7ce102775bd01e2c"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:f0c5d1acbfca6ebdd6b1e3eded8d261affb6ddcf2186205518f1428b8569bb99"}, + {file = "cffi-1.14.6-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:99f27fefe34c37ba9875f224a8f36e31d744d8083e00f520f133cab79ad5e819"}, + {file = "cffi-1.14.6-cp27-cp27m-win32.whl", hash = "sha256:55af55e32ae468e9946f741a5d51f9896da6b9bf0bbdd326843fec05c730eb20"}, + {file = "cffi-1.14.6-cp27-cp27m-win_amd64.whl", hash = "sha256:7bcac9a2b4fdbed2c16fa5681356d7121ecabf041f18d97ed5b8e0dd38a80224"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:ed38b924ce794e505647f7c331b22a693bee1538fdf46b0222c4717b42f744e7"}, + {file = "cffi-1.14.6-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:e22dcb48709fc51a7b58a927391b23ab37eb3737a98ac4338e2448bef8559b33"}, + {file = "cffi-1.14.6-cp35-cp35m-macosx_10_9_x86_64.whl", hash = "sha256:aedb15f0a5a5949ecb129a82b72b19df97bbbca024081ed2ef88bd5c0a610534"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:48916e459c54c4a70e52745639f1db524542140433599e13911b2f329834276a"}, + {file = "cffi-1.14.6-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:f627688813d0a4140153ff532537fbe4afea5a3dffce1f9deb7f91f848a832b5"}, + {file = "cffi-1.14.6-cp35-cp35m-win32.whl", hash = "sha256:f0010c6f9d1a4011e429109fda55a225921e3206e7f62a0c22a35344bfd13cca"}, + {file = "cffi-1.14.6-cp35-cp35m-win_amd64.whl", hash = "sha256:57e555a9feb4a8460415f1aac331a2dc833b1115284f7ded7278b54afc5bd218"}, + {file = "cffi-1.14.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e8c6a99be100371dbb046880e7a282152aa5d6127ae01783e37662ef73850d8f"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:19ca0dbdeda3b2615421d54bef8985f72af6e0c47082a8d26122adac81a95872"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:d950695ae4381ecd856bcaf2b1e866720e4ab9a1498cba61c602e56630ca7195"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9dc245e3ac69c92ee4c167fbdd7428ec1956d4e754223124991ef29eb57a09d"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8661b2ce9694ca01c529bfa204dbb144b275a31685a075ce123f12331be790b"}, + {file = "cffi-1.14.6-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b315d709717a99f4b27b59b021e6207c64620790ca3e0bde636a6c7f14618abb"}, + {file = "cffi-1.14.6-cp36-cp36m-win32.whl", hash = "sha256:80b06212075346b5546b0417b9f2bf467fea3bfe7352f781ffc05a8ab24ba14a"}, + {file = "cffi-1.14.6-cp36-cp36m-win_amd64.whl", hash = "sha256:a9da7010cec5a12193d1af9872a00888f396aba3dc79186604a09ea3ee7c029e"}, + {file = "cffi-1.14.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4373612d59c404baeb7cbd788a18b2b2a8331abcc84c3ba40051fcd18b17a4d5"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:f10afb1004f102c7868ebfe91c28f4a712227fe4cb24974350ace1f90e1febbf"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:fd4305f86f53dfd8cd3522269ed7fc34856a8ee3709a5e28b2836b2db9d4cd69"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6169cb3c6c2ad50db5b868db6491a790300ade1ed5d1da29289d73bbe40b56"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d4b68e216fc65e9fe4f524c177b54964af043dde734807586cf5435af84045c"}, + {file = "cffi-1.14.6-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33791e8a2dc2953f28b8d8d300dde42dd929ac28f974c4b4c6272cb2955cb762"}, + {file = "cffi-1.14.6-cp37-cp37m-win32.whl", hash = "sha256:0c0591bee64e438883b0c92a7bed78f6290d40bf02e54c5bf0978eaf36061771"}, + {file = "cffi-1.14.6-cp37-cp37m-win_amd64.whl", hash = "sha256:8eb687582ed7cd8c4bdbff3df6c0da443eb89c3c72e6e5dcdd9c81729712791a"}, + {file = "cffi-1.14.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba6f2b3f452e150945d58f4badd92310449876c4c954836cfb1803bdd7b422f0"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:64fda793737bc4037521d4899be780534b9aea552eb673b9833b01f945904c2e"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:9f3e33c28cd39d1b655ed1ba7247133b6f7fc16fa16887b120c0c670e35ce346"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26bb2549b72708c833f5abe62b756176022a7b9a7f689b571e74c8478ead51dc"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb687a11f0a7a1839719edd80f41e459cc5366857ecbed383ff376c4e3cc6afd"}, + {file = "cffi-1.14.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d2ad4d668a5c0645d281dcd17aff2be3212bc109b33814bbb15c4939f44181cc"}, + {file = "cffi-1.14.6-cp38-cp38-win32.whl", hash = "sha256:487d63e1454627c8e47dd230025780e91869cfba4c753a74fda196a1f6ad6548"}, + {file = "cffi-1.14.6-cp38-cp38-win_amd64.whl", hash = "sha256:c33d18eb6e6bc36f09d793c0dc58b0211fccc6ae5149b808da4a62660678b156"}, + {file = "cffi-1.14.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:06c54a68935738d206570b20da5ef2b6b6d92b38ef3ec45c5422c0ebaf338d4d"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:f174135f5609428cc6e1b9090f9268f5c8935fddb1b25ccb8255a2d50de6789e"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:f3ebe6e73c319340830a9b2825d32eb6d8475c1dac020b4f0aa774ee3b898d1c"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c8d896becff2fa653dc4438b54a5a25a971d1f4110b32bd3068db3722c80202"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4922cd707b25e623b902c86188aca466d3620892db76c0bdd7b99a3d5e61d35f"}, + {file = "cffi-1.14.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9e005e9bd57bc987764c32a1bee4364c44fdc11a3cc20a40b93b444984f2b87"}, + {file = "cffi-1.14.6-cp39-cp39-win32.whl", hash = "sha256:eb9e2a346c5238a30a746893f23a9535e700f8192a68c07c0258e7ece6ff3728"}, + {file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"}, + {file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"}, +] +cryptography = [ + {file = "cryptography-3.4.7-cp36-abi3-macosx_10_10_x86_64.whl", hash = "sha256:3d8427734c781ea5f1b41d6589c293089704d4759e34597dce91014ac125aad1"}, + {file = "cryptography-3.4.7-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e56e16617872b0957d1c9742a3f94b43533447fd78321514abbe7db216aa250"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:37340614f8a5d2fb9aeea67fd159bfe4f5f4ed535b1090ce8ec428b2f15a11f2"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:240f5c21aef0b73f40bb9f78d2caff73186700bf1bc6b94285699aff98cc16c6"}, + {file = "cryptography-3.4.7-cp36-abi3-manylinux2014_x86_64.whl", hash = "sha256:1e056c28420c072c5e3cb36e2b23ee55e260cb04eee08f702e0edfec3fb51959"}, + {file = "cryptography-3.4.7-cp36-abi3-win32.whl", hash = "sha256:0f1212a66329c80d68aeeb39b8a16d54ef57071bf22ff4e521657b27372e327d"}, + {file = "cryptography-3.4.7-cp36-abi3-win_amd64.whl", hash = "sha256:de4e5f7f68220d92b7637fc99847475b59154b7a1b3868fb7385337af54ac9ca"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:26965837447f9c82f1855e0bc8bc4fb910240b6e0d16a664bb722df3b5b06873"}, + {file = "cryptography-3.4.7-pp36-pypy36_pp73-manylinux2014_x86_64.whl", hash = "sha256:eb8cc2afe8b05acbd84a43905832ec78e7b3873fb124ca190f574dca7389a87d"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2010_x86_64.whl", hash = "sha256:7ec5d3b029f5fa2b179325908b9cd93db28ab7b85bb6c1db56b10e0b54235177"}, + {file = "cryptography-3.4.7-pp37-pypy37_pp73-manylinux2014_x86_64.whl", hash = "sha256:ee77aa129f481be46f8d92a1a7db57269a2f23052d5f2433b4621bb457081cc9"}, + {file = "cryptography-3.4.7.tar.gz", hash = "sha256:3d10de8116d25649631977cb37da6cbdd2d6fa0e0281d014a5b7d337255ca713"}, +] +jmespath = [ + {file = "jmespath-0.10.0-py2.py3-none-any.whl", hash = "sha256:cdf6525904cc597730141d61b36f2e4b8ecc257c420fa2f4549bac2c2d0cb72f"}, + {file = "jmespath-0.10.0.tar.gz", hash = "sha256:b85d0567b8666149a93172712e68920734333c0ce7e89b78b3e987f71e5ed4f9"}, +] +pycparser = [ + {file = "pycparser-2.20-py2.py3-none-any.whl", hash = "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705"}, + {file = "pycparser-2.20.tar.gz", hash = "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0"}, +] +pycrypto = [ + {file = "pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c"}, +] +pyopenssl = [ + {file = "pyOpenSSL-20.0.1-py2.py3-none-any.whl", hash = "sha256:818ae18e06922c066f777a33f1fca45786d85edfe71cd043de6379337a7f274b"}, + {file = "pyOpenSSL-20.0.1.tar.gz", hash = "sha256:4c231c759543ba02560fcd2480c48dcec4dae34c9da7d3747c508227e0624b51"}, +] +python-dateutil = [ + {file = "python-dateutil-2.8.1.tar.gz", hash = "sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c"}, + {file = "python_dateutil-2.8.1-py2.py3-none-any.whl", hash = "sha256:75bb3f31ea686f1197762692a9ee6a7550b59fc6ca3a1f4b5d7e32fb98e2da2a"}, +] +s3transfer = [ + {file = "s3transfer-0.4.2-py2.py3-none-any.whl", hash = "sha256:9b3752887a2880690ce628bc263d6d13a3864083aeacff4890c1c9839a5eb0bc"}, + {file = "s3transfer-0.4.2.tar.gz", hash = "sha256:cb022f4b16551edebbb31a377d3f09600dbada7363d8c5db7976e7f47732e1b2"}, +] +six = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] +urllib3 = [ + {file = "urllib3-1.26.6-py2.py3-none-any.whl", hash = "sha256:39fb8672126159acb139a7718dd10806104dec1e2f0f6c88aab05d17df10c8d4"}, + {file = "urllib3-1.26.6.tar.gz", hash = "sha256:f57b4c16c62fa2760b7e3d97c35b255512fb6b59a259730f36ba32ce9f8e342f"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fe9b2d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[tool.poetry] +name = "acme-nginx" +version = "0.3.0" +description = "Python library/program to create LetsEncrypt SSL certificates" +authors = ["Konstantin Shcherban "] +license = "GPL-3.0" + +[tool.poetry.dependencies] +python = "^3.6" +pyOpenSSL = "^20.0" +pycrypto = "^2.6" +boto3 = "~1.17" + +[tool.poetry.dev-dependencies] + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" diff --git a/setup.py b/setup.py index 5e2be2a..869ac93 100644 --- a/setup.py +++ b/setup.py @@ -2,32 +2,43 @@ from setuptools import setup, find_packages -p_version = '0.2.1' +p_version = "0.2.1" -with open('README.md') as f: +with open("README.md") as f: long_description = f.read() setup( - name = 'acme-nginx', - version = p_version, - author = 'Konstantin Shcherban', - author_email = 'k.scherban@gmail.com', - packages = find_packages(), - url = 'https://github.com/kshcherban/acme-nginx', - download_url = 'https://github.com/kshcherban/acme-nginx/tarball/v{0}'.format(p_version), - license = 'GPL v3', - description = 'A simple client/tool for Let\'s Encrypt or any ACME server that issues SSL certificates.', + name="acme-nginx", + version=p_version, + author="Konstantin Shcherban", + author_email="k.scherban@gmail.com", + packages=find_packages(), + url="https://github.com/kshcherban/acme-nginx", + download_url="https://github.com/kshcherban/acme-nginx/tarball/v{0}".format( + p_version + ), + license="GPL v3", + description="A simple client/tool for Let's Encrypt or any ACME server that issues SSL certificates.", long_description=long_description, - long_description_content_type='text/markdown', - keywords = ["tls", "ssl", "certificate", "acme", "letsencrypt", "nginx", "wildcard certificate", "wildcard"], - install_requires = [ + long_description_content_type="text/markdown", + keywords=[ + "tls", + "ssl", + "certificate", + "acme", + "letsencrypt", + "nginx", + "wildcard certificate", + "wildcard", + ], + install_requires=[ "pyOpenSSL>=0.13", "pycrypto>=2.6", "boto3~=1.9.30", ], - entry_points = { - 'console_scripts': [ - 'acme-nginx = acme_nginx.client:main', + entry_points={ + "console_scripts": [ + "acme-nginx = acme_nginx.client:main", ] - } + }, )