├── .gitignore ├── requirements.txt ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md └── letsencrypt-aws.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | acme[dns]>=0.9 2 | boto3>=1.2.3 3 | click>=6.2 4 | cryptography>=2.2 5 | pyopenssl>=0.15.1 6 | rfc3986>=0.3.1 7 | josepy>=1.0.0 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.5 5 | 6 | cache: 7 | directories: 8 | - $HOME/.cache/pip 9 | 10 | 11 | install: 12 | - pip install -U pip 13 | - pip install flake8 14 | - pip install -r requirements.txt 15 | 16 | script: 17 | - flake8 letsencrypt-aws.py 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:2.7-slim 2 | 3 | # This can be bumped every time you need to force an apt refresh 4 | ENV LAST_UPDATE 6 5 | 6 | RUN apt-get update && apt-get upgrade -y 7 | RUN apt-get update && apt-get install -y build-essential libffi-dev libssl-dev git 8 | 9 | WORKDIR /app/ 10 | 11 | RUN python -m pip install virtualenv 12 | RUN python -m virtualenv .venv 13 | COPY requirements.txt ./ 14 | RUN .venv/bin/pip install -r requirements.txt 15 | COPY letsencrypt-aws.py ./ 16 | RUN chmod 644 letsencrypt-aws.py 17 | 18 | USER nobody 19 | 20 | ENTRYPOINT [".venv/bin/python", "letsencrypt-aws.py"] 21 | CMD ["update-certificates"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Alex Gaynor and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of letsencrypt-aws nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # letsencrypt-aws 2 | 3 | **_Note_:** `letsencrypt-aws` is not well maintained at this point. You may 4 | prefer to use other Let's Encrypt automation solutions, or [Amazon's 5 | Certificate Manager](https://aws.amazon.com/certificate-manager/). 6 | 7 | `letsencrypt-aws` is a program that can be run in the background which 8 | automatically provisions and updates certificates on your AWS infrastructure 9 | using the AWS APIs and Let's Encrypt. 10 | 11 | ## How it works 12 | 13 | `letsencrypt-aws` takes a list of ELBs, and which hosts you want them to be 14 | able to serve. It runs in a loop and every day does the following: 15 | 16 | It gets the certificate for that ELB. If the certificate is going to expire 17 | soon (in less than 45 days), it generates a new private key and CSR and sends a 18 | request to Let's Encrypt. It takes the DNS challenge and creates a record in 19 | Route53 for that challenge. This completes the Let's Encrypt challenge and we 20 | receive a certificate. It uploads the new certificate and private key to IAM 21 | and updates your ELB to use the certificate. 22 | 23 | In theory all you need to do is make sure this is running somewhere, and your 24 | ELBs' certificates will be kept minty fresh. 25 | 26 | ## How to run it 27 | 28 | Before you can use `letsencrypt-aws` you need to have created an account with 29 | the ACME server (you only need to do this the first time). You can register 30 | using (if you already have an account you can skip this step): 31 | 32 | ```console 33 | $ # If you're trying to register for a server besides the Let's Encrypt 34 | $ # production one, see the configuration documentation below. 35 | $ python letsencrypt-aws.py register email@host.com 36 | 2016-01-09 19:56:19 [acme-register.generate-key] 37 | 2016-01-09 19:56:20 [acme-register.register] email=u'email@host.com' 38 | 2016-01-09 19:56:21 [acme-register.agree-to-tos] 39 | -----BEGIN RSA PRIVATE KEY----- 40 | [...] 41 | -----END RSA PRIVATE KEY----- 42 | ``` 43 | 44 | You'll need to put the private key somewhere that `letsencrypt-aws` can access 45 | it (either on the local filesystem or in S3). 46 | 47 | You will also need to have your AWS credentials configured. You can use any of 48 | the [mechanisms documented by 49 | boto3](https://boto3.readthedocs.io/en/latest/guide/configuration.html), or 50 | use IAM instance profiles (which are supported, but not mentioned by the 51 | `boto3` documentation). See below for which AWS permissions are required. 52 | 53 | `letsencrypt-aws` takes it's configuration via the `LETSENCRYPT_AWS_CONFIG` 54 | environment variable. This should be a JSON object with the following schema: 55 | 56 | ```json 57 | { 58 | "domains": [ 59 | { 60 | "elb": { 61 | "name": "ELB name (string)", 62 | "port": "optional, defaults to 443 (integer)" 63 | }, 64 | "hosts": ["list of hosts you want on the certificate (strings)"], 65 | "key_type": "rsa or ecdsa, optional, defaults to rsa (string)" 66 | } 67 | ], 68 | "acme_account_key": "location of the account private key (string)", 69 | "acme_directory_url": "optional, defaults to Let's Encrypt production (string)" 70 | } 71 | ``` 72 | 73 | The `acme_account_key` can either be located on the local filesystem or in S3. 74 | To specify a local file you provide `"file:///path/to/key.pem"` (on Windows use 75 | `"file://C:/path/to/key.pem"`), for S3 provide 76 | `"s3://bucket-name/object-name"`. The key should be a PEM formatted RSA private 77 | key. 78 | 79 | Then you can simply run it: `python letsencrypt-aws.py update-certificates`. 80 | 81 | If you add the `--persistent` flag it will run forever, rather than just once, 82 | sleeping for 24 hours between each check for certificate expiration. This is 83 | useful for production environments. 84 | 85 | If your certificate is not expiring soon, but you need to issue a new one 86 | anyways, the `--force-issue` flag can be provided. 87 | 88 | If you're into [Docker](https://www.docker.com/), there is an automatically 89 | built image of `letsencrypt-aws` available as 90 | [`alexgaynor/letsencrypt-aws`](https://hub.docker.com/r/alexgaynor/letsencrypt-aws/). 91 | 92 | ## Operational Security 93 | 94 | Keeping the source of your certificates secure is, for obvious reasons, 95 | important. `letsencrypt-aws` relies heavily on the AWS APIs to do its 96 | business, so we recommend running this code from EC2, so that you can use the 97 | Metadata service for managing credentials. You can give your EC2 instance an 98 | IAM instance profile with permissions to manage the relevant services (see 99 | below for complete details). 100 | 101 | You need to make sure that the ACME account private key is kept secure. The 102 | best choice is probably in an S3 bucket with encryption enabled and access 103 | limited with IAM. 104 | 105 | Finally, wherever you're running `letsencrypt-aws` needs to be trusted. 106 | `letsencrypt-aws` generates private keys in memory and uploads them to IAM 107 | immediately, they are never stored on disk. 108 | 109 | ### IAM Policy 110 | 111 | The minimum set of permissions needed for `letsencrypt-aws` to work is: 112 | 113 | * `route53:ChangeResourceRecordSets` 114 | * `route53:GetChange` 115 | * `route53:ListHostedZones` 116 | * `elasticloadbalancing:DescribeLoadBalancers` 117 | * `elasticloadbalancing:SetLoadBalancerListenerSSLCertificate` 118 | * `iam:ListServerCertificates` 119 | * `iam:UploadServerCertificate` 120 | * `iam:GetServerCertificate` 121 | 122 | If your `acme_account_key` is provided as an `s3://` URI you will also need: 123 | 124 | * `s3:GetObject` 125 | 126 | It's likely possible to restrict these permissions by ARN, though this has not 127 | been fully explored. 128 | 129 | An example IAM policy is: 130 | 131 | ```json 132 | { 133 | "Version": "2012-10-17", 134 | "Statement": [ 135 | { 136 | "Sid": "", 137 | "Effect": "Allow", 138 | "Action": [ 139 | "route53:ChangeResourceRecordSets", 140 | "route53:GetChange", 141 | "route53:GetChangeDetails", 142 | "route53:ListHostedZones" 143 | ], 144 | "Resource": [ 145 | "*" 146 | ] 147 | }, 148 | { 149 | "Sid": "", 150 | "Effect": "Allow", 151 | "Action": [ 152 | "elasticloadbalancing:DescribeLoadBalancers", 153 | "elasticloadbalancing:SetLoadBalancerListenerSSLCertificate" 154 | ], 155 | "Resource": [ 156 | "*" 157 | ] 158 | }, 159 | { 160 | "Sid": "", 161 | "Effect": "Allow", 162 | "Action": [ 163 | "iam:ListServerCertificates", 164 | "iam:GetServerCertificate", 165 | "iam:UploadServerCertificate" 166 | ], 167 | "Resource": [ 168 | "*" 169 | ] 170 | } 171 | ] 172 | } 173 | ``` 174 | -------------------------------------------------------------------------------- /letsencrypt-aws.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | import sys 5 | import time 6 | 7 | import acme.challenges 8 | import acme.client 9 | import josepy 10 | 11 | import click 12 | 13 | from cryptography import x509 14 | from cryptography.hazmat.backends import default_backend 15 | from cryptography.hazmat.primitives import hashes, serialization 16 | from cryptography.hazmat.primitives.asymmetric import ec, rsa 17 | 18 | import boto3 19 | 20 | import OpenSSL.crypto 21 | 22 | import rfc3986 23 | 24 | 25 | DEFAULT_ACME_DIRECTORY_URL = "https://acme-v01.api.letsencrypt.org/directory" 26 | CERTIFICATE_EXPIRATION_THRESHOLD = datetime.timedelta(days=45) 27 | # One day 28 | PERSISTENT_SLEEP_INTERVAL = 60 * 60 * 24 29 | DNS_TTL = 30 30 | 31 | 32 | class Logger(object): 33 | def __init__(self): 34 | self._out = sys.stdout 35 | 36 | def emit(self, event, **data): 37 | formatted_data = " ".join( 38 | "{}={!r}".format(k, v) for k, v in data.items() 39 | ) 40 | self._out.write("{} [{}] {}\n".format( 41 | datetime.datetime.utcnow().replace(microsecond=0), 42 | event, 43 | formatted_data 44 | )) 45 | self._out.flush() 46 | 47 | 48 | def _get_iam_certificate(iam_client, certificate_id): 49 | paginator = iam_client.get_paginator("list_server_certificates") 50 | for page in paginator.paginate(): 51 | for server_certificate in page["ServerCertificateMetadataList"]: 52 | if server_certificate["Arn"] == certificate_id: 53 | cert_name = server_certificate["ServerCertificateName"] 54 | response = iam_client.get_server_certificate( 55 | ServerCertificateName=cert_name, 56 | ) 57 | return x509.load_pem_x509_certificate( 58 | response["ServerCertificate"]["CertificateBody"].encode(), 59 | default_backend(), 60 | ) 61 | 62 | 63 | class CertificateRequest(object): 64 | def __init__(self, cert_location, dns_challenge_completer, hosts, 65 | key_type): 66 | self.cert_location = cert_location 67 | self.dns_challenge_completer = dns_challenge_completer 68 | 69 | self.hosts = hosts 70 | self.key_type = key_type 71 | 72 | 73 | class ELBCertificate(object): 74 | def __init__(self, elb_client, iam_client, elb_name, elb_port): 75 | self.elb_client = elb_client 76 | self.iam_client = iam_client 77 | self.elb_name = elb_name 78 | self.elb_port = elb_port 79 | 80 | def get_current_certificate(self): 81 | response = self.elb_client.describe_load_balancers( 82 | LoadBalancerNames=[self.elb_name] 83 | ) 84 | [description] = response["LoadBalancerDescriptions"] 85 | [elb_listener] = [ 86 | listener["Listener"] 87 | for listener in description["ListenerDescriptions"] 88 | if listener["Listener"]["LoadBalancerPort"] == self.elb_port 89 | ] 90 | 91 | if "SSLCertificateId" not in elb_listener: 92 | raise ValueError( 93 | "A certificate must already be configured for the ELB" 94 | ) 95 | 96 | return _get_iam_certificate( 97 | self.iam_client, elb_listener["SSLCertificateId"] 98 | ) 99 | 100 | def update_certificate(self, logger, hosts, private_key, pem_certificate, 101 | pem_certificate_chain): 102 | logger.emit( 103 | "updating-elb.upload-iam-certificate", elb_name=self.elb_name 104 | ) 105 | 106 | response = self.iam_client.upload_server_certificate( 107 | ServerCertificateName=generate_certificate_name( 108 | hosts, 109 | x509.load_pem_x509_certificate( 110 | pem_certificate, default_backend() 111 | ) 112 | ), 113 | PrivateKey=private_key.private_bytes( 114 | encoding=serialization.Encoding.PEM, 115 | format=serialization.PrivateFormat.TraditionalOpenSSL, 116 | encryption_algorithm=serialization.NoEncryption(), 117 | ), 118 | CertificateBody=pem_certificate.decode(), 119 | CertificateChain=pem_certificate_chain.decode(), 120 | ) 121 | new_cert_arn = response["ServerCertificateMetadata"]["Arn"] 122 | 123 | # Sleep before trying to set the certificate, it appears to sometimes 124 | # fail without this. 125 | time.sleep(15) 126 | logger.emit("updating-elb.set-elb-certificate", elb_name=self.elb_name) 127 | self.elb_client.set_load_balancer_listener_ssl_certificate( 128 | LoadBalancerName=self.elb_name, 129 | SSLCertificateId=new_cert_arn, 130 | LoadBalancerPort=self.elb_port, 131 | ) 132 | 133 | 134 | class Route53ChallengeCompleter(object): 135 | def __init__(self, route53_client): 136 | self.route53_client = route53_client 137 | 138 | def _find_zone_id_for_domain(self, domain): 139 | paginator = self.route53_client.get_paginator("list_hosted_zones") 140 | zones = [] 141 | for page in paginator.paginate(): 142 | for zone in page["HostedZones"]: 143 | if ( 144 | domain.endswith(zone["Name"]) or 145 | (domain + ".").endswith(zone["Name"]) 146 | ) and not zone["Config"]["PrivateZone"]: 147 | zones.append((zone["Name"], zone["Id"])) 148 | 149 | if not zones: 150 | raise ValueError( 151 | "Unable to find a Route53 hosted zone for {}".format(domain) 152 | ) 153 | 154 | # Order the zones that are suffixes for our desired to domain by 155 | # length, this puts them in an order like: 156 | # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"] 157 | # And then we choose the last one, which will be the most specific. 158 | zones.sort(key=lambda z: len(z[0]), reverse=True) 159 | return zones[0][1] 160 | 161 | def _change_txt_record(self, action, zone_id, domain, value): 162 | response = self.route53_client.change_resource_record_sets( 163 | HostedZoneId=zone_id, 164 | ChangeBatch={ 165 | "Changes": [ 166 | { 167 | "Action": action, 168 | "ResourceRecordSet": { 169 | "Name": domain, 170 | "Type": "TXT", 171 | "TTL": DNS_TTL, 172 | "ResourceRecords": [ 173 | # For some reason TXT records need to be 174 | # manually quoted. 175 | {"Value": '"{}"'.format(value)} 176 | ], 177 | } 178 | } 179 | ] 180 | } 181 | ) 182 | return response["ChangeInfo"]["Id"] 183 | 184 | def create_txt_record(self, host, value): 185 | zone_id = self._find_zone_id_for_domain(host) 186 | change_id = self._change_txt_record( 187 | "CREATE", 188 | zone_id, 189 | host, 190 | value, 191 | ) 192 | return (zone_id, change_id) 193 | 194 | def delete_txt_record(self, change_id, host, value): 195 | zone_id, _ = change_id 196 | self._change_txt_record( 197 | "DELETE", 198 | zone_id, 199 | host, 200 | value 201 | ) 202 | 203 | def wait_for_change(self, change_id): 204 | _, change_id = change_id 205 | 206 | while True: 207 | response = self.route53_client.get_change(Id=change_id) 208 | if response["ChangeInfo"]["Status"] == "INSYNC": 209 | return 210 | time.sleep(5) 211 | 212 | 213 | def generate_rsa_private_key(): 214 | return rsa.generate_private_key( 215 | public_exponent=65537, key_size=2048, backend=default_backend() 216 | ) 217 | 218 | 219 | def generate_ecdsa_private_key(): 220 | return ec.generate_private_key(ec.SECP256R1(), backend=default_backend()) 221 | 222 | 223 | def generate_csr(private_key, hosts): 224 | csr_builder = x509.CertificateSigningRequestBuilder().subject_name( 225 | # This is the same thing the official letsencrypt client does. 226 | x509.Name([ 227 | x509.NameAttribute(x509.NameOID.COMMON_NAME, hosts[0]), 228 | ]) 229 | ).add_extension( 230 | x509.SubjectAlternativeName([ 231 | x509.DNSName(host) 232 | for host in hosts 233 | ]), 234 | # TODO: change to `critical=True` when Let's Encrypt supports it. 235 | critical=False, 236 | ) 237 | return csr_builder.sign(private_key, hashes.SHA256(), default_backend()) 238 | 239 | 240 | def find_dns_challenge(authz): 241 | for combo in authz.body.resolved_combinations: 242 | if ( 243 | len(combo) == 1 and 244 | isinstance(combo[0].chall, acme.challenges.DNS01) 245 | ): 246 | yield combo[0] 247 | 248 | 249 | def generate_certificate_name(hosts, cert): 250 | return "{serial}-{expiration}-{hosts}".format( 251 | serial=cert.serial, 252 | expiration=cert.not_valid_after.date(), 253 | hosts="-".join(h.replace(".", "_") for h in hosts), 254 | )[:128] 255 | 256 | 257 | class AuthorizationRecord(object): 258 | def __init__(self, host, authz, dns_challenge, change_id): 259 | self.host = host 260 | self.authz = authz 261 | self.dns_challenge = dns_challenge 262 | self.change_id = change_id 263 | 264 | 265 | def start_dns_challenge(logger, acme_client, dns_challenge_completer, 266 | elb_name, host): 267 | logger.emit( 268 | "updating-elb.request-acme-challenge", elb_name=elb_name, host=host 269 | ) 270 | authz = acme_client.request_domain_challenges( 271 | host, acme_client.directory.new_authz 272 | ) 273 | 274 | [dns_challenge] = find_dns_challenge(authz) 275 | 276 | logger.emit( 277 | "updating-elb.create-txt-record", elb_name=elb_name, host=host 278 | ) 279 | change_id = dns_challenge_completer.create_txt_record( 280 | dns_challenge.validation_domain_name(host), 281 | dns_challenge.validation(acme_client.key), 282 | 283 | ) 284 | return AuthorizationRecord( 285 | host, 286 | authz, 287 | dns_challenge, 288 | change_id, 289 | ) 290 | 291 | 292 | def complete_dns_challenge(logger, acme_client, dns_challenge_completer, 293 | elb_name, authz_record): 294 | logger.emit( 295 | "updating-elb.wait-for-route53", 296 | elb_name=elb_name, host=authz_record.host 297 | ) 298 | dns_challenge_completer.wait_for_change(authz_record.change_id) 299 | 300 | response = authz_record.dns_challenge.response(acme_client.key) 301 | 302 | logger.emit( 303 | "updating-elb.local-validation", 304 | elb_name=elb_name, host=authz_record.host 305 | ) 306 | verified = response.simple_verify( 307 | authz_record.dns_challenge.chall, 308 | authz_record.host, 309 | acme_client.key.public_key() 310 | ) 311 | if not verified: 312 | raise ValueError("Failed verification") 313 | 314 | logger.emit( 315 | "updating-elb.answer-challenge", 316 | elb_name=elb_name, host=authz_record.host 317 | ) 318 | acme_client.answer_challenge(authz_record.dns_challenge, response) 319 | 320 | 321 | def request_certificate(logger, acme_client, elb_name, authorizations, csr): 322 | logger.emit("updating-elb.request-cert", elb_name=elb_name) 323 | cert_response, _ = acme_client.poll_and_request_issuance( 324 | josepy.util.ComparableX509( 325 | OpenSSL.crypto.load_certificate_request( 326 | OpenSSL.crypto.FILETYPE_ASN1, 327 | csr.public_bytes(serialization.Encoding.DER), 328 | ) 329 | ), 330 | authzrs=[authz_record.authz for authz_record in authorizations], 331 | ) 332 | pem_certificate = OpenSSL.crypto.dump_certificate( 333 | OpenSSL.crypto.FILETYPE_PEM, cert_response.body 334 | ) 335 | pem_certificate_chain = b"\n".join( 336 | OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) 337 | for cert in acme_client.fetch_chain(cert_response) 338 | ) 339 | return pem_certificate, pem_certificate_chain 340 | 341 | 342 | def update_cert(logger, acme_client, force_issue, cert_request): 343 | logger.emit("updating-elb", elb_name=cert_request.cert_location.elb_name) 344 | 345 | current_cert = cert_request.cert_location.get_current_certificate() 346 | if current_cert is not None: 347 | logger.emit( 348 | "updating-elb.certificate-expiration", 349 | elb_name=cert_request.cert_location.elb_name, 350 | expiration_date=current_cert.not_valid_after 351 | ) 352 | days_until_expiration = ( 353 | current_cert.not_valid_after - datetime.datetime.today() 354 | ) 355 | 356 | try: 357 | san_extension = current_cert.extensions.get_extension_for_class( 358 | x509.SubjectAlternativeName 359 | ) 360 | except x509.ExtensionNotFound: 361 | # Handle the case where an old certificate doesn't have a SAN 362 | # extension and always reissue in that case. 363 | current_domains = [] 364 | else: 365 | current_domains = san_extension.value.get_values_for_type( 366 | x509.DNSName 367 | ) 368 | 369 | if ( 370 | days_until_expiration > CERTIFICATE_EXPIRATION_THRESHOLD and 371 | # If the set of hosts we want for our certificate changes, we 372 | # update even if the current certificate isn't expired. 373 | sorted(current_domains) == sorted(cert_request.hosts) and 374 | not force_issue 375 | ): 376 | return 377 | 378 | if cert_request.key_type == "rsa": 379 | private_key = generate_rsa_private_key() 380 | elif cert_request.key_type == "ecdsa": 381 | private_key = generate_ecdsa_private_key() 382 | else: 383 | raise ValueError( 384 | "Invalid key_type: {!r}".format(cert_request.key_type) 385 | ) 386 | csr = generate_csr(private_key, cert_request.hosts) 387 | 388 | authorizations = [] 389 | try: 390 | for host in cert_request.hosts: 391 | authz_record = start_dns_challenge( 392 | logger, acme_client, cert_request.dns_challenge_completer, 393 | cert_request.cert_location.elb_name, host, 394 | ) 395 | authorizations.append(authz_record) 396 | 397 | for authz_record in authorizations: 398 | complete_dns_challenge( 399 | logger, acme_client, cert_request.dns_challenge_completer, 400 | cert_request.cert_location.elb_name, authz_record 401 | ) 402 | 403 | pem_certificate, pem_certificate_chain = request_certificate( 404 | logger, acme_client, cert_request.cert_location.elb_name, 405 | authorizations, csr 406 | ) 407 | 408 | cert_request.cert_location.update_certificate( 409 | logger, cert_request.hosts, 410 | private_key, pem_certificate, pem_certificate_chain 411 | ) 412 | finally: 413 | for authz_record in authorizations: 414 | logger.emit( 415 | "updating-elb.delete-txt-record", 416 | elb_name=cert_request.cert_location.elb_name, 417 | host=authz_record.host 418 | ) 419 | dns_challenge = authz_record.dns_challenge 420 | cert_request.dns_challenge_completer.delete_txt_record( 421 | authz_record.change_id, 422 | dns_challenge.validation_domain_name(authz_record.host), 423 | dns_challenge.validation(acme_client.key), 424 | ) 425 | 426 | 427 | def update_certs(logger, acme_client, force_issue, certificate_requests): 428 | for cert_request in certificate_requests: 429 | update_cert( 430 | logger, 431 | acme_client, 432 | force_issue, 433 | cert_request, 434 | ) 435 | 436 | 437 | def setup_acme_client(s3_client, acme_directory_url, acme_account_key): 438 | uri = rfc3986.urlparse(acme_account_key) 439 | if uri.scheme == "file": 440 | if uri.host is None: 441 | path = uri.path 442 | elif uri.path is None: 443 | path = uri.host 444 | else: 445 | path = os.path.join(uri.host, uri.path) 446 | with open(path) as f: 447 | key = f.read() 448 | elif uri.scheme == "s3": 449 | # uri.path includes a leading "/" 450 | response = s3_client.get_object(Bucket=uri.host, Key=uri.path[1:]) 451 | key = response["Body"].read() 452 | else: 453 | raise ValueError( 454 | "Invalid acme account key: {!r}".format(acme_account_key) 455 | ) 456 | 457 | key = serialization.load_pem_private_key( 458 | key.encode("utf-8"), password=None, backend=default_backend() 459 | ) 460 | return acme_client_for_private_key(acme_directory_url, key) 461 | 462 | 463 | def acme_client_for_private_key(acme_directory_url, private_key): 464 | return acme.client.Client( 465 | # TODO: support EC keys, when josepy does. 466 | acme_directory_url, key=josepy.JWKRSA(key=private_key) 467 | ) 468 | 469 | 470 | @click.group() 471 | def cli(): 472 | pass 473 | 474 | 475 | @cli.command(name="update-certificates") 476 | @click.option( 477 | "--persistent", is_flag=True, help="Runs in a loop, instead of just once." 478 | ) 479 | @click.option( 480 | "--force-issue", is_flag=True, help=( 481 | "Issue a new certificate, even if the old one isn't close to " 482 | "expiration." 483 | ) 484 | ) 485 | def update_certificates(persistent=False, force_issue=False): 486 | logger = Logger() 487 | logger.emit("startup") 488 | 489 | if persistent and force_issue: 490 | raise ValueError("Can't specify both --persistent and --force-issue") 491 | 492 | session = boto3.Session() 493 | s3_client = session.client("s3") 494 | elb_client = session.client("elb") 495 | route53_client = session.client("route53") 496 | iam_client = session.client("iam") 497 | 498 | config = json.loads(os.environ["LETSENCRYPT_AWS_CONFIG"]) 499 | domains = config["domains"] 500 | acme_directory_url = config.get( 501 | "acme_directory_url", DEFAULT_ACME_DIRECTORY_URL 502 | ) 503 | acme_account_key = config["acme_account_key"] 504 | acme_client = setup_acme_client( 505 | s3_client, acme_directory_url, acme_account_key 506 | ) 507 | 508 | certificate_requests = [] 509 | for domain in domains: 510 | if "elb" in domain: 511 | cert_location = ELBCertificate( 512 | elb_client, iam_client, 513 | domain["elb"]["name"], int(domain["elb"].get("port", 443)) 514 | ) 515 | else: 516 | raise ValueError( 517 | "Unknown certificate location: {!r}".format(domain) 518 | ) 519 | 520 | certificate_requests.append(CertificateRequest( 521 | cert_location, 522 | Route53ChallengeCompleter(route53_client), 523 | domain["hosts"], 524 | domain.get("key_type", "rsa"), 525 | )) 526 | 527 | if persistent: 528 | logger.emit("running", mode="persistent") 529 | while True: 530 | update_certs( 531 | logger, acme_client, 532 | force_issue, certificate_requests 533 | ) 534 | # Sleep before we check again 535 | logger.emit("sleeping", duration=PERSISTENT_SLEEP_INTERVAL) 536 | time.sleep(PERSISTENT_SLEEP_INTERVAL) 537 | else: 538 | logger.emit("running", mode="single") 539 | update_certs( 540 | logger, acme_client, 541 | force_issue, certificate_requests 542 | ) 543 | 544 | 545 | @cli.command() 546 | @click.argument("email") 547 | @click.option( 548 | "--out", 549 | type=click.File("w"), 550 | default="-", 551 | help="Where to write the private key to. Defaults to stdout." 552 | ) 553 | def register(email, out): 554 | logger = Logger() 555 | config = json.loads(os.environ.get("LETSENCRYPT_AWS_CONFIG", "{}")) 556 | acme_directory_url = config.get( 557 | "acme_directory_url", DEFAULT_ACME_DIRECTORY_URL 558 | ) 559 | 560 | logger.emit("acme-register.generate-key") 561 | private_key = generate_rsa_private_key() 562 | acme_client = acme_client_for_private_key(acme_directory_url, private_key) 563 | 564 | logger.emit("acme-register.register", email=email) 565 | registration = acme_client.register( 566 | acme.messages.NewRegistration.from_data(email=email) 567 | ) 568 | logger.emit("acme-register.agree-to-tos") 569 | acme_client.agree_to_tos(registration) 570 | out.write(private_key.private_bytes( 571 | encoding=serialization.Encoding.PEM, 572 | format=serialization.PrivateFormat.TraditionalOpenSSL, 573 | encryption_algorithm=serialization.NoEncryption(), 574 | )) 575 | 576 | 577 | if __name__ == "__main__": 578 | cli() 579 | --------------------------------------------------------------------------------