diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3e02750 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +.docker diff --git a/Dockerfile b/Dockerfile index 8fa0e97..3ff6fa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ -FROM debian:stretch +FROM debian:buster-slim WORKDIR / ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ - && apt-get install -y certbot curl python python-requests \ + && apt-get install -y python3-certbot python3-certbot-dns-google python3-certbot-dns-route53 curl python3 python3-requests \ && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* EXPOSE 80 WORKDIR /certbot -COPY run.sh /certbot/run.sh -COPY post_cert.py /certbot/post_cert.py +COPY run_cert.py /certbot/run_cert.py -ENTRYPOINT ["/certbot/run.sh"] +ENTRYPOINT ["python3","/certbot/run_cert.py","service"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index ab417f9..396b29a 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,60 @@ This is a sample [Marathon](https://github.com/mesosphere/marathon) app for encrypting your [Marathon-lb](https://github.com/mesosphere/marathon-lb) HAProxy endpoints using [Let's Encrypt](https://letsencrypt.org/). With this, you can automatically generate and renew valid SSL certs with Marathon-lb. +The work done to support wildcard certificates and the DNS providers, was influenced heavily by the work done by Sebastian Woehrl ([https://github.com/MaibornWolff/letsencrypt-marathon-lb](https://github.com/MaibornWolff/letsencrypt-marathon-lb)). + ## Getting started Ensure you have **at least 2 or more** public agents in your DC/OS cluster, and that marathon-lb is scaled out to more than 1 public agent. Deploying this app requires this since it entails restarting marathon-lb. +Wildcard certificates are only supported when `LETSENCRYPT_VERIFICATION_METHOD` is set to **dns**, `DNS_PROVIDER` is set to **google** or **route53** and the required credentials are defined for either provider. + +### HTTP Verfication -Clone (or manually copy) this repo, and modify the [letsencrypt-dcos.json](letsencrypt-dcos.json) file to include: +Clone (or manually copy) this repo, and modify the [letsencrypt-dcos-http.json](letsencrypt-dcos-http.json) file to include: - The list of hostnames (must be FQDNs) for which you want to generate SSL certs (in `HAPROXY_0_VHOST`) - An admin email address for your certificate (in `LETSENCRYPT_EMAIL`) - The Marathon API endpoint (in `MARATHON_URL`) - The Marathon-lb app ID (in `MARATHON_LB_ID`) + +### Google DNS Verification + +Clone (or manually copy) this repo, and modify the [letsencrypt-dcos-dns-google.json](letsencrypt-dcos-dns-google.json) file to include: +- The list of hostnames (must be FQDNS) for which you want to generate SSL certs (in `DOMAINS`) +- An admin email address for your certificate (in `LETSENCRYPT_EMAIL`) +- The verification method should be set to `dns` (in `LETSENCRYPT_VERIFICATION_METHOD`) +- The DNS provider should be set to `google` (in `DNS_PROVIDER`) +- Reference the GCP Service Account private JSON key, stored as a DCOS Secret (in `GCE_SERVICE_ACCOUNT`) +- The Marathon API endpoint (in `MARATHON_URL`) +- The Marathon-lb app ID (in `MARATHON_LB_ID`) + +The GCP Service Account needs the following permissions: + +* **dns.changes.create** +* **dns.changes.get** +* **dns.managedZones.list** +* **dns.resourceRecordSets.create** +* **dns.resourceRecordSets.delete** +* **dns.resourceRecordSets.list** +* **dns.resourceRecordSets.update** + +### AWS DNS Verification -This app also now supports specifying the Lets Encrypt server, for situations where users may be running their own Boulder server on an internal network, or for using the Lets Encrypt staging servers for testing. By default it is set to the Lets Encrypt staging server, so for production use change the LETSENCRYPT_SERVER_URL variable - if you are using the Lets Encrypt servers the default should be https://acme-v01.api.letsencrypt.org/directory +Clone (or manually copy) this repo, and modify the [letsencrypt-dcos-dns-route53.json](letsencrypt-dcos-dns-route53.json) file to include: +- The list of hostnames (must be FQDNS) for which you want to generate SSL certs (in `DOMAINS`) +- An admin email address for your certificate (in `LETSENCRYPT_EMAIL`) +- The verification method should be set to `dns` (in `LETSENCRYPT_VERIFICATION_METHOD`) +- The DNS provider should be set to `route53` (in `DNS_PROVIDER`) +- Add the AWS IAM Account credentials, stored as DCOS Secrets (in `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) +- The Marathon API endpoint (in `MARATHON_URL`) +- The Marathon-lb app ID (in `MARATHON_LB_ID`) + +The AWS IAM account needs the following permissions: + +* **route53:ListHostedZones** +* **route53:GetChange** +* **route53:ChangeResourceRecordSets** + +This app also supports specifying the Lets Encrypt server, for situations where users may be running their own Boulder server on an internal network, or for using the Lets Encrypt staging servers for testing. By default it is set to the [Lets Encrypt staging server](https://acme-staging-v02.api.letsencrypt.org/directory) , so for production use change the `LETSENCRYPT_SERVER_URL` variable - if you are using the Lets Encrypt servers the default should be https://acme-v02.api.letsencrypt.org/directory Now launch the `letsencrypt-dcos` Marathon app: @@ -24,7 +67,14 @@ There are 2 test apps included, based on [openresty](https://openresty.org/), wh ## How does it work? -The app includes 2 scripts: [`run.sh`](run.sh) and [`post_cert.py`](post_cert.py). The first script (`run.sh`) will generate the initial SSL cert and POST the cert to Marathon for Marathon-lb. It will then attempt to renew & update the cert every 24 hours. The `post_cert.py` script will compare the current cert in Marathon to the current live cert, and update it as necessary. `post_cert.py` is called after the initial cert is generated, and again every 24 hours after a renewal attempt. +The app includes a script: [`run_cert.py`](run_cert.py). The script will generate the initial SSL cert and POST the cert to Marathon for Marathon-lb. It will then attempt to renew & update the cert every 24 hours. It will compare the current cert in Marathon to the current live cert, and update it as necessary. + +The script does the following steps depending on the verification method: + +* The script will get the domains from its own **HAPROXY_0_VHOST** label or the **DOMAINS** environment variable depending on verification mode and instruct lego to request a certificate for them. +* If verification method is **http**: Due to the **HAPROXY_0_VHOST** and **HAPROXY_0_PATH** labels marathon-lb will proxy all requests to the letsencrypt verification paths for these domains to the script where certbot will receive them and do a webroot-based verification. +* If verification method is **dns**: Using the provided credentials for your dns provider certbot will perform dns verification and add the required acme challenge dns TXT records to your zone. +* The script then uses the marathon api to update the **HAPROXY_SSL_CERT** variable of the marathon-lb app which will then (after a restart of the app) use the provided certificate for HTTPS connections. A persistent volume called `data` is mounted inside the container at `/etc/letsencrypt` which contains the certificates and other generated state. @@ -33,3 +83,4 @@ A persistent volume called `data` is mounted inside the container at `/etc/letse - You may only have up to 100 domains per cert. - Let's Encrypt currently has rate limits, such as issuing a maximum of 5 certs per set of domains per week. - Currently, when the cert is updated, it requires a full redeploy of Marathon-lb. This means there may be a few seconds of downtime as the deployment occurs. This can be mitigated by placing another LB (such as an ELB or F5) in front of HAProxy. + - AWS Route53 and GCP DNS are the only supported DNS providers at this time. \ No newline at end of file diff --git a/letsencrypt-dcos-dns-aws.json b/letsencrypt-dcos-dns-aws.json new file mode 100644 index 0000000..505a86e --- /dev/null +++ b/letsencrypt-dcos-dns-aws.json @@ -0,0 +1,65 @@ +{ + "id": "/letsencrypt-dcos", + "cpus": 0.05, + "mem": 512, + "instances": 1, + "container": { + "type": "DOCKER", + "volumes": [ + { + "containerPath": "/etc/letsencrypt", + "hostPath": "data", + "mode": "RW" + }, + { + "containerPath": "data", + "mode": "RW", + "persistent": { + "size": 500 + } + } + ], + "docker": { + "forcePullImage": true, + "image": "dcoslabs/letsencrypt-dcos:v1.0.8", + "network": "BRIDGE", + "portMappings": [ + { + "containerPort": 80, + "servicePort": 10000, + "protocol": "tcp" + } + ] + } + }, + "env": { + "MARATHON_LB_ID": "marathon-lb", + "MARATHON_URL": "http://marathon.mesos:8080", + "LETSENCRYPT_EMAIL": "dayne@example.com", + "LETSENCRYPT_SERVER_URL": "https://acme-staging-v02.api.letsencrypt.org/directory", + "DNS_PROPAGATION_TIMEOUT": "120", + "DNS_PROVIDER": "route53", + "LETSENCRYPT_VERIFICATION_METHOD": "dns", + "DOMAINS": "*.test.example.com,ssl-test-1.example.com,ssl-test-2.example.com", + "AWS_ACCESS_KEY_ID": { + "secret": "secret0" + }, + "AWS_SECRET_ACCESS_KEY": { + "secret": "secret1" + } + + }, + "secrets": { + "secret0": { + "source": "letsencrypt-dcos-aws-key-id" + }, + "secret1": { + "source": "letsencrypt-dcos-aws-secret-key" + } + }, + "backoffSeconds": 5, + "upgradeStrategy": { + "minimumHealthCapacity": 0.5, + "maximumOverCapacity": 0 + } +} diff --git a/letsencrypt-dcos-dns-google.json b/letsencrypt-dcos-dns-google.json new file mode 100644 index 0000000..9003ce6 --- /dev/null +++ b/letsencrypt-dcos-dns-google.json @@ -0,0 +1,59 @@ +{ + "id": "/letsencrypt-dcos", + "cpus": 0.05, + "mem": 512, + "instances": 1, + "container": { + "type": "DOCKER", + "volumes": [ + { + "containerPath": "/etc/letsencrypt", + "hostPath": "data", + "mode": "RW" + }, + { + "containerPath": "data", + "mode": "RW", + "persistent": { + "size": 500 + } + } + ], + "docker": { + "forcePullImage": true, + "image": "dcoslabs/letsencrypt-dcos:v1.0.8", + "network": "BRIDGE", + "portMappings": [ + { + "containerPort": 80, + "servicePort": 10000, + "protocol": "tcp" + } + ] + } + }, + "env": { + "MARATHON_LB_ID": "marathon-lb", + "MARATHON_URL": "http://marathon.mesos:8080", + "LETSENCRYPT_EMAIL": "dayne@example.com", + "LETSENCRYPT_SERVER_URL": "https://acme-staging-v02.api.letsencrypt.org/directory", + "DNS_PROPAGATION_TIMEOUT": "120", + "DNS_PROVIDER": "google", + "LETSENCRYPT_VERIFICATION_METHOD": "dns", + "DOMAINS": "*.test.example.com,ssl-test-1.example.com,ssl-test-2.example.com", + "GCE_SERVICE_ACCOUNT": { + "secret": "secret0" + } + + }, + "secrets": { + "secret0": { + "source": "letsencrypt-dcos" + } + }, + "backoffSeconds": 5, + "upgradeStrategy": { + "minimumHealthCapacity": 0.5, + "maximumOverCapacity": 0 + } +} diff --git a/letsencrypt-dcos.json b/letsencrypt-dcos-http.json similarity index 91% rename from letsencrypt-dcos.json rename to letsencrypt-dcos-http.json index 0571aeb..45a1dc1 100644 --- a/letsencrypt-dcos.json +++ b/letsencrypt-dcos-http.json @@ -21,7 +21,7 @@ ], "docker": { "forcePullImage": true, - "image": "dcoslabs/letsencrypt-dcos:v1.0.7", + "image": "dcoslabs/letsencrypt-dcos:v1.0.8", "network": "BRIDGE", "portMappings": [ { @@ -35,7 +35,7 @@ "env": { "MARATHON_LB_ID": "marathon-lb", "MARATHON_URL": "http://marathon.mesos:8080", - "LETSENCRYPT_EMAIL": "matt@example.com", + "LETSENCRYPT_EMAIL": "dayne@example.com", "LETSENCRYPT_SERVER_URL": "https://acme-staging-v02.api.letsencrypt.org/directory" }, "labels": { diff --git a/post_cert.py b/post_cert.py deleted file mode 100755 index c3d1f6f..0000000 --- a/post_cert.py +++ /dev/null @@ -1,57 +0,0 @@ -#!/usr/bin/env python - -import os -import sys -import json -import requests -import time - -url = os.environ.get('MARATHON_URL') -marathon_lb_id = os.environ.get('MARATHON_LB_ID') -marathon_lb_cert_env = \ - os.environ.get('MARATHON_LB_CERT_ENV', 'HAPROXY_SSL_CERT') - -print("Retrieving current marathon-lb cert") -sys.stdout.flush() -r = requests.get(url + '/v2/apps/' + marathon_lb_id) -mlb = r.json() -env = mlb['app']['env'] -cert = '' - -with open(sys.argv[1], 'r') as f: - cert = f.read() - -print("Comparing old cert to new cert") -sys.stdout.flush() -if cert != env.get(marathon_lb_cert_env, ''): - env[marathon_lb_cert_env] = cert - - print("Deploying marathon-lb with new cert") - sys.stdout.flush() - headers = {'Content-Type': 'application/json'} - r = requests.put(url + '/v2/apps/' + marathon_lb_id, - headers=headers, - data=json.dumps({ - 'id': marathon_lb_id, - 'env': env - }, encoding='utf-8')) - deploymentId = r.json()['deploymentId'] - - # Wait for deployment to complete - deployment_exists = True - while deployment_exists: - time.sleep(5) - print("Waiting for deployment to complete") - sys.stdout.flush() - r = requests.get(url + '/v2/deployments') - deployments = r.json() - deployment_exists = False - for deployment in deployments: - if deployment['id'] == deploymentId: - deployment_exists = True - break - print("Deployment complete") - sys.stdout.flush() -else: - print("Cert did not change") - sys.stdout.flush() diff --git a/run.sh b/run.sh deleted file mode 100755 index f4ffb48..0000000 --- a/run.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash -set -e - -# Wait to settle -sleep 15 - -# Get our SSL domains from the Marathon app label -SSL_DOMAINS=$(curl -s ${MARATHON_URL}/v2/apps${MARATHON_APP_ID} | python -c 'import sys, json; print(json.load(sys.stdin)["app"]["labels"]["HAPROXY_0_VHOST"])') - -IFS=',' read -ra ADDR <<< "$SSL_DOMAINS" -DOMAIN_ARGS="" -DOMAIN_FIRST="" -for i in "${ADDR[@]}"; do - if [ -z $DOMAIN_FIRST ]; then - DOMAIN_FIRST=$i - fi - DOMAIN_ARGS="$DOMAIN_ARGS -d $i" -done - -echo "DOMAIN_ARGS: ${DOMAIN_ARGS}" -echo "DOMAIN_FIRST: ${DOMAIN_FIRST}" - -echo "Running certbot to generate initial signed cert" -echo "Using server ${LETSENCRYPT_SERVER_URL}" - -certbot certonly --server ${LETSENCRYPT_SERVER_URL} --standalone \ - --preferred-challenges http-01 $DOMAIN_ARGS \ - --email $LETSENCRYPT_EMAIL --agree-tos \ - --noninteractive --no-redirect \ - --rsa-key-size 4096 --expand - -while [ true ]; do - cat /etc/letsencrypt/live/$DOMAIN_FIRST/fullchain.pem \ - /etc/letsencrypt/live/$DOMAIN_FIRST/privkey.pem > \ - /etc/letsencrypt/live/$DOMAIN_FIRST.pem - - echo "Posting new cert to marathon-lb" - ./post_cert.py /etc/letsencrypt/live/$DOMAIN_FIRST.pem - - sleep 24h - - echo "About to attempt renewal" - certbot renew -done diff --git a/run_cert.py b/run_cert.py new file mode 100755 index 0000000..5b2a715 --- /dev/null +++ b/run_cert.py @@ -0,0 +1,324 @@ +import os +import stat +import sys +import subprocess +import json +import requests +import time +import operator +from pathlib import Path + +ENV_MARATHON_APP_ID = "MARATHON_APP_ID" +ENV_MARATHON_URL = "MARATHON_URL" +DEFAULT_MARATHON_URL = "https://marathon.mesos:8443/" +ENV_LETSENCRYPT_SERVER_URL = "LETSENCRYPT_SERVER_URL" +ENV_LETSENCRYPT_EMAIL = "LETSENCRYPT_EMAIL" +ENV_VERIFICATION_METHOD = "LETSENCRYPT_VERIFICATION_METHOD" +ENV_MARATHON_LB_CERT = os.environ.get('MARATHON_LB_CERT_ENV', 'HAPROXY_SSL_CERT') +ENV_MARATHON_LB_ID = "MARATHON_LB_ID" +ENV_DOMAINS = "DOMAINS" +ENV_DNS_PROVIDER = "DNS_PROVIDER" +DEFAULT_LETSENCRYPT_URL = "https://acme-staging-v02.api.letsencrypt.org/directory" +ENV_RSA_KEY_SIZE = "RSA_KEY_SIZE" +DEFAULT_GCE_CREDENTIALS = "/certbot/google_service_account.json" +ENV_GOOGLE_CREDENTIALS = "GOOGLE_APPLICATION_CREDENTIALS" +ENV_DNS_PROPAGATION_TIMEOUT = "DNS_PROPAGATION_TIMEOUT" +ENV_GCE_SERVICE_ACCOUNT = "GCE_SERVICE_ACCOUNT" + +CERTIFICATES_DIR = "/etc/letsencrypt/live" +DOMAINS_FILE = "/etc/letsencrypt/current_domains" + +DEFAULT_CERTBOT_ARGS = [ + "certbot", + "certonly", + "--server", os.environ.get(ENV_LETSENCRYPT_SERVER_URL, DEFAULT_LETSENCRYPT_URL), + "--email", os.environ.get(ENV_LETSENCRYPT_EMAIL), + "--agree-tos", + "--noninteractive", + "--rsa-key-size", os.environ.get(ENV_RSA_KEY_SIZE, "4096"), + "--expand" +] + +CERTBOT_ARGS_HTTP = [ + "--standalone", + "--no-redirect", + "--preferred-challenges", + "http-01" +] + +CERTBOT_ARGS_DNS = [ + "--preferred-challenges", + "dns-01" +] + +CERTBOT_ARGS_DNS_GCLOUD = [ + "--dns-google", + "--dns-google-credentials", os.environ.get(ENV_GOOGLE_CREDENTIALS, DEFAULT_GCE_CREDENTIALS), + "--dns-google-propagation-seconds", os.environ.get(ENV_DNS_PROPAGATION_TIMEOUT, "120") +] + +CERTBOT_ARGS_DNS_ROUTE53 = [ + "--dns-route53", + "--dns-route53-propagation-seconds", os.environ.get(ENV_DNS_PROPAGATION_TIMEOUT, "120") +] + + +def get_marathon_url(): + """Retrieves the marathon base url to use from an environment variable""" + return os.environ.get(ENV_MARATHON_URL, DEFAULT_MARATHON_URL) + + +def get_letsencrypt_url(): + """Retrieves the LetsEncrypt Server URL""" + return os.environ.get(ENV_LETSENCRYPT_SERVER_URL, DEFAULT_LETSENCRYPT_URL) + + +def get_marathon_app(app_id): + """Retrieve app definition for marathon-lb app""" + response = requests.get(f"{get_marathon_url()}/v2/apps/{app_id}", verify=False) + if not response.ok: + raise Exception("Could not get app details from marathon") + return response.json() + + +def read_domains_from_last_time(): + """Return list of domains used (last time this script was run) from file or empty string if file does not exist""" + if os.path.exists(DOMAINS_FILE): + with open(DOMAINS_FILE) as domains_file: + return domains_file.read() + else: + return "" + + +def write_domains_to_file(domains): + """Store list of domains in file to retrieve on next run""" + with open(DOMAINS_FILE, "w+") as domains_file: + domains_file.write(domains) + + +def rewrite_domain_name(domain_name): + """Rewrite domain_name if it is a wildcard""" + if domain_name.startswith("*"): + domain_name = domain_name.replace("*.", "") + return domain_name + + +def find_newest_dir(domain_name): + dirs = {} + # Check if the certificate has been recreated with a new domain list + for x in os.listdir(CERTIFICATES_DIR): + if x.startswith(domain_name): + # Dict all the directories and their creation times in the CERTIFICATES_DIR that start with the domain_name + xpath = f"{CERTIFICATES_DIR}/{x}" + dirs[xpath] = os.path.getctime(xpath) + # Return the newest directory in the dict + list_dir = sorted(dirs.items(), key=operator.itemgetter(1)) + return list_dir[-1][0] + + +def write_combined_cert_to_file(domain_name): + """Create the combined cert from the full chain and private key files""" + domain_name = rewrite_domain_name(domain_name) + latest_cert_dir = find_newest_dir(domain_name) + + Path(f"{latest_cert_dir}/{domain_name}.pem").write_text(Path(f"{latest_cert_dir}/fullchain.pem").read_text() + + Path(f"{latest_cert_dir}/privkey.pem").read_text()) + + +def configure_provider_creds(): + """Configure DNS credentials if required""" + verification_method = os.environ.get(ENV_VERIFICATION_METHOD, "http") + if verification_method == "dns": + dns_provider = os.environ.get(ENV_DNS_PROVIDER) + if dns_provider == "google": + service_account = os.environ.get(ENV_GCE_SERVICE_ACCOUNT, "") + if len(service_account) == 0: + raise Exception("GCE_SERVICE_ACCOUNT is not defined") + else: + with open(DEFAULT_GCE_CREDENTIALS, "w+") as creds_file: + creds_file.write(service_account) + + os.chown(DEFAULT_GCE_CREDENTIALS, 0, 0) + os.chmod(DEFAULT_GCE_CREDENTIALS, stat.S_IREAD) + print("Creating GCE Service Account file", flush=True) + elif dns_provider == "route53": + if len(os.environ.get("AWS_ACCESS_KEY_ID", "")) == 0 or len( + os.environ.get("AWS_SECRET_ACCESS_KEY", "")) == 0: + raise Exception("AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not defined") + else: + raise Exception("Unknown DNS provider") + elif verification_method != "http": + raise Exception("Unknown verification method: " + verification_method) + + +def get_domains(): + """Retrieve list of domains from own app definition or from environment variable based on verification method""" + data = get_marathon_app(os.environ.get(ENV_MARATHON_APP_ID)) + verification_method = os.environ.get(ENV_VERIFICATION_METHOD, "http") + if verification_method == "http": + return data["app"]["labels"]["HAPROXY_0_VHOST"] + elif verification_method == "dns": + return os.environ.get(ENV_DOMAINS) + else: + raise Exception("Unknown verification method: " + verification_method) + + +def get_cert_filepath(domain_name): + """Retrieve the certificate path based on the domain name""" + domain_name = rewrite_domain_name(domain_name) + latest_cert_dir = find_newest_dir(domain_name) + return f"{latest_cert_dir}/{domain_name}.pem" + + +def update_marathon_app(app_id, **kwargs): + """Post new certificate data (as environment variable) to marathon to update the marathon-lb app definition""" + print("Uploading certificates", flush=True) + + data = kwargs.copy() + data['id'] = app_id + headers = {'Content-Type': 'application/json'} + response = requests.put(f"{get_marathon_url()}/v2/apps/{app_id}",headers=headers, + data=json.dumps(data), verify=False) + if not response.ok: + print(response) + print(response.text, flush=True) + raise Exception("Could not update app. See response text for error message.") + data = response.json() + # if not "deploymentId" in data: + if "deploymentId" not in data: + print(data, flush=True) + raise Exception("Could not update app. Marathon did not return deployment id. Please see error message.") + deployment_id = data['deploymentId'] + + # Wait for deployment to complete + deployment_exists = True + sum_wait_time = 0 + sleep_time = 5 + while deployment_exists: + time.sleep(sleep_time) + sum_wait_time += sleep_time + print("Waiting for deployment to complete", flush=True) + # Retrieve list of running deployments + response = requests.get(f"{get_marathon_url()}/v2/deployments", verify=False) + deployments = response.json() + deployment_exists = False + for deployment in deployments: + # Check if our deployment is still in the list + if deployment['id'] == deployment_id: + deployment_exists = True + break + if deployment_exists and sum_wait_time > 60 * sleep_time: + raise Exception("Failed to update app due to timeout in deployment.") + print("Successfully uploaded certificates", flush=True) + + +def generate_letsencrypt_cert(domains): + """Use Certbot to validate domains and retrieve LetsEncrypt certificates""" + domains_changed = domains != read_domains_from_last_time() + domain_list = domains.split(",") + first_domain = domain_list[0] + certbot_args = list() + + for domain in domain_list: + certbot_args.append("-d") + certbot_args.append(domain) + + verification_method = os.environ.get(ENV_VERIFICATION_METHOD, "http") + if verification_method == "http": + certbot_args = certbot_args + CERTBOT_ARGS_HTTP + elif verification_method == "dns": + certbot_args = certbot_args + CERTBOT_ARGS_DNS + dns_provider = os.environ.get(ENV_DNS_PROVIDER, "google") + if dns_provider == "google": + certbot_args = certbot_args + CERTBOT_ARGS_DNS_GCLOUD + elif dns_provider == "route53": + certbot_args = certbot_args + CERTBOT_ARGS_DNS_ROUTE53 + else: + raise Exception("Unknown DNS provider: " + dns_provider) + + """Check if we already have a certificate""" + if not domains_changed and os.path.exists(f"{CERTIFICATES_DIR}/{first_domain}/{first_domain}.pem"): + print("About to attempt renewal of certificate", flush=True) + certbot_args = ["certbot", "renew"] + else: + print("Running certbot to generate initial signed cert", flush=True) + print(f"Using server {get_letsencrypt_url()}", flush=True) + certbot_args = DEFAULT_CERTBOT_ARGS + certbot_args + + """Run Certbot with the configured arguments""" + print("Running the following command:") + print(*certbot_args, flush=True) + result = subprocess.run(certbot_args) + if result.returncode != 0: + print(result, flush=True) + raise Exception("Obtaining certificates failed. Check Certbot output for error messages.") + write_domains_to_file(domains) + + """Create the combined cert used by marathon-lb""" + write_combined_cert_to_file(first_domain) + return first_domain + + +def upload_cert_to_marathon_lb(cert_filename): + """Update the marathon-lb app definition and set the the generated certificate + as environment variable HAPROXY_SSL_CERT + """ + print("Retrieving current marathon-lb cert", flush=True) + with open(cert_filename) as cert_file: + cert_data = cert_file.read() + # Retrieve current app definition of marathon-lb + marathon_lb_id = os.environ.get(ENV_MARATHON_LB_ID) + app_data = get_marathon_app(marathon_lb_id) + env = app_data["app"]["env"] + # Compare old and new certs + if env.get(ENV_MARATHON_LB_CERT, "") != cert_data: + print("Certificate changed. Updating certificate", flush=True) + env[ENV_MARATHON_LB_CERT] = cert_data + update_marathon_app(marathon_lb_id, env=env, secrets=app_data["app"].get("secrets", {})) + else: + print("Certificate not changed. Not doing anything", flush=True) + + +def run_client(): + """Generate certificates if necessary and update marathon-lb""" + domains = get_domains() + print("Requesting certificates for " + domains, flush=True) + domain_name = generate_letsencrypt_cert(domains) + cert_file = get_cert_filepath(domain_name) + upload_cert_to_marathon_lb(cert_file) + + +def run_client_with_backoff(): + """Calls run_client but catches exceptions and tries again for up to one hour. + Use this variant if you don't want this app to fail (and redeploy) because of intermittent errors. + """ + backoff_seconds = 30 + sum_wait_time = 0 + while True: + try: + run_client() + return + except Exception as ex: + print(ex) + if sum_wait_time >= 60 * 60: + # Reraise exception after 1 hour backoff, will lead to task failure in marathon + raise ex + sum_wait_time += backoff_seconds + time.sleep(backoff_seconds) + backoff_seconds *= 2 + + +if __name__ == "__main__": + """Get the credentials for DNS provider""" + configure_provider_creds() + + if len(sys.argv) > 1 and sys.argv[1] == "service": + while True: + run_client() + time.sleep(24 * 60 * 60) # Sleep for 24 hours + elif len(sys.argv) > 1 and sys.argv[1] == "service_with_backoff": + while True: + run_client_with_backoff() + time.sleep(24 * 60 * 60) # Sleep for 24 hours + else: + run_client()