Skip to content

Commit

Permalink
Add AWS Route53 DNS provider support
Browse files Browse the repository at this point in the history
* update version to 0.2.0
* add boto3 dependency
* add `--no-reload-nginx` parameter to skip nginx reload in case only
DNS challenge is used
  • Loading branch information
kshcherban committed Nov 2, 2018
1 parent 588b71d commit a2de9cb
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 15 deletions.
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,14 @@ New protocol is used by default.

`http-01` challenge is passed exactly as in v1 protocol realisation.

`dns-01` currently supports only DigitalOcean DNS provider. Technically nginx
is not needed for this type of challenge but script still calls nginx reload
`dns-01` currently supports only DigitalOcean, AWS Route53 DNS providers.

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
them.
them. To disable that behavior please specify `--no-reload-nginx` parameter.

AWS Route53 uses `default` profile in session, specifying profile works with environment variables only.
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.
Expand Down Expand Up @@ -175,7 +179,13 @@ 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/) is supported.
with API. Currently only [DigitalOcean DNS](https://www.digitalocean.com/docs/networking/dns/) and
[AWS Route53](https://aws.amazon.com/route53/) are supported.

Example how to get wildcard certificate without nginx
```
sudo acme-nginx --no-reload-nginx --dns-provider route53 -d "*.example.com"
```

#### DigitalOcean

Expand Down
99 changes: 99 additions & 0 deletions acme_nginx/AWSRoute53.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import boto3


class AWSRoute53(object):
def __init__(self):
self.session = boto3.Session()
self.client = self.session.client('route53')

def __get_domains(self, next_zone=None, next_dns=None, data=[]):
""" Recursively get all hosted dns zones """
if not next_zone:
out = self.client.list_hosted_zones_by_name()
else:
out = self.client.list_hosted_zones_by_name(DNSName=next_dns, HostedZoneId=next_zone)
for i in out['HostedZones']:
data.append((i['Name'], i['Id']))
if out['IsTruncated']:
self.__get_domains(
next_zone=out['NextHostedZoneId'],
next_dns=out['NextDNSName'],
data=data)
else:
return

def determine_domain(self, domain):
""" Determine registered domain in API and return hosted zone id """
if not domain.endswith('.'):
domain = domain + '.'
zones = []
self.__get_domains(data=zones)
for domain_set in zones:
if domain_set[0] in domain:
return domain_set[1]

def create_record(self, name, data, domain):
"""
Create TXT DNS record
Params:
name, string, record name
data, string, record data
domain, string, dns domain
Return:
record_id, int, created record id
"""
zone_id = self.determine_domain(domain)
if not zone_id:
raise Exception('Hosted zone for domain {0} not found'.format(domain))
response = self.client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
'Changes': [
{
'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}

def delete_record(self, record, domain):
"""
Delete TXT DNS record
Params:
record, dict, record dict with name, data keys
domain, string, dns domain
"""
zone_id = self.determine_domain(domain)
self.client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
'Changes': [
{
'Action': 'DELETE',
'ResourceRecordSet': {
'Name': record['name'],
'Type': 'TXT',
'TTL': 60,
'ResourceRecords': [
{
'Value': '"{0}"'.format(record['data'])
}
]
}
}
]
}
)
5 changes: 4 additions & 1 deletion acme_nginx/Acme.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from urllib2 import urlopen, Request # Python 2


__version__ = "0.1.3"
__version__ = "0.2.0"


class Acme(object):
Expand All @@ -30,6 +30,7 @@ def __init__(
domain_key='/etc/ssl/private/letsencrypt-domain.key',
cert_path='/etc/ssl/private/letsencrypt-domain.pem',
dns_provider=None,
skip_nginx_reload=False,
debug=False):
"""
Params:
Expand All @@ -42,6 +43,7 @@ def __init__(
domain_key, str, path to certificate private key
cert_path, str, path to output certificate file
dns_provider, list, dns provider that is used for dns challenge
skip_nginx_reload, bool, should nginx be reloaded after certificate issue
"""
self.debug = debug
if not domains:
Expand All @@ -56,6 +58,7 @@ def __init__(
# LetsEncrypt Root CA certificate chain, needed for ACMEv1
self.chain = "https://letsencrypt.org/certs/lets-encrypt-x3-cross-signed.pem"
self.dns_provider = dns_provider
self.skip_nginx_reload = skip_nginx_reload

def _reload_nginx(self):
""" Return nginx master process id and sends HUP to it """
Expand Down
10 changes: 7 additions & 3 deletions acme_nginx/AcmeV2.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from urllib2 import urlopen, Request # Python 2
from .Acme import Acme
from .DigitalOcean import DigitalOcean
from .AWSRoute53 import AWSRoute53


class AcmeV2(Acme):
Expand Down Expand Up @@ -74,7 +75,8 @@ def _sign_certificate(self, order, directory):
fd.write(certificate_pem)
except Exception as e:
self.log.error('error writing cert: {0} {1}'.format(type(e).__name__, e))
self._reload_nginx()
if not self.skip_nginx_reload:
self._reload_nginx()

def solve_http_challenge(self, directory):
"""
Expand Down Expand Up @@ -150,7 +152,7 @@ def solve_dns_challenge(self, directory, client):
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_id = client.create_record(
record = client.create_record(
domain=domain,
name='_acme-challenge.{0}.'.format(domain.lstrip('*.').rstrip('.')),
data=txt_record)
Expand All @@ -174,7 +176,7 @@ def solve_dns_challenge(self, directory, client):
try:
if not self.debug:
self.log.info('delete dns record')
client.delete_record(domain=domain, record_id=record_id)
client.delete_record(domain=domain, record=record)
except Exception as e:
self.log.error('error deleting dns record')
self.log.error(e)
Expand All @@ -185,6 +187,8 @@ def get_certificate(self):
if self.dns_provider:
if self.dns_provider == 'digitalocean':
dns_client = DigitalOcean()
elif self.dns_provider == 'route53':
dns_client = AWSRoute53()
self.solve_dns_challenge(directory, dns_client)
else:
self.solve_http_challenge(directory)
6 changes: 3 additions & 3 deletions acme_nginx/DigitalOcean.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ def create_record(self, name, data, domain):
raise Exception(json.loads(response.read().decode('utf8')))
return json.loads(response.read().decode('utf8'))['domain_record']['id']

def delete_record(self, record_id, domain):
def delete_record(self, record, domain):
"""
Delete DNS record
Params:
record_id, int, record id number
record, int, record id number
domain, string, dns domain
"""
registered_domain = self.determine_domain(domain)
api = self.api + '/' + registered_domain + '/records/' + str(record_id)
api = self.api + '/' + registered_domain + '/records/' + str(record)
request_headers = {
"Content-Type": "application/json",
"Authorization": "Bearer {0}".format(self.token)
Expand Down
10 changes: 8 additions & 2 deletions acme_nginx/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def set_arguments():
parser.add_argument(
'--dns-provider',
dest='dns_provider',
choices=['digitalocean'])
choices=['digitalocean', 'route53'])
parser.add_argument(
'--staging',
action='store_true',
Expand All @@ -69,6 +69,11 @@ def set_arguments():
'--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")
return parser.parse_args()


Expand Down Expand Up @@ -101,6 +106,7 @@ def main():
vhost=args.vhost,
cert_path=args.cert_path,
debug=args.debug,
dns_provider=args.dns_provider
dns_provider=args.dns_provider,
skip_nginx_reload=args.skip_reload
)
acme.get_certificate()
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pyOpenSSL>=0.13
pycrypto>=2.6
boto3~=1.9.30
5 changes: 3 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from setuptools import setup, find_packages

p_version = '0.1.3'
p_version = '0.2.0'

with open('README.md') as f:
long_description = f.read()
Expand All @@ -22,7 +22,8 @@
keywords = ["tls", "ssl", "certificate", "acme", "letsencrypt", "nginx", "wildcard certificate", "wildcard"],
install_requires = [
"pyOpenSSL>=0.13",
"pycrypto>=2.6"
"pycrypto>=2.6",
"boto3~=1.9.30",
],
entry_points = {
'console_scripts': [
Expand Down

0 comments on commit a2de9cb

Please sign in to comment.