├── .gitignore ├── .pep8 ├── Dockerfile ├── LICENSE ├── README.md ├── files ├── rancher.py └── requirements.txt ├── templates └── rancher-lets-encrypt │ ├── 0 │ ├── docker-compose.yml │ ├── letsencrypt.env │ └── rancher-compose.yml │ ├── catalogIcon-letsencrypt.svg │ └── config.yml ├── traffic-manager-ssl ├── docker-compose.yml └── rancher-compose.yml └── traffic-manager ├── docker-compose.yml └── rancher-compose.yml /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv/ 3 | -------------------------------------------------------------------------------- /.pep8: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore=E501 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine 2 | 3 | RUN apk update 4 | RUN apk add --no-cache gcc py-pip musl-dev libffi-dev openssl-dev linux-headers openssl libffi cargo 5 | 6 | RUN mkdir -p /python /var/www 7 | 8 | COPY files/requirements.txt /python/ 9 | RUN pip install -r /python/requirements.txt 10 | 11 | COPY files/* /python/ 12 | 13 | ENTRYPOINT /python/rancher.py 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | ------------------------------------------------------------------------------- 3 | TOZNY, LLC PROPRIETARY LICENSE 4 | Copyright 2013-2021 Tozny LLC. All rights reserved. 5 | ------------------------------------------------------------------------------- 6 | 7 | Contents of this file or files in this repository are Proprietary and 8 | Confidential Information constituting Trade Secrets of Tozny LLC. 9 | 10 | Use of this file, files in this repository, any information, or software in 11 | which the content is incorporated (collectively, the “Content”) requires and is 12 | subject to compliance with the terms of the commercial license (the “License”) 13 | (https://tozny.com/tozny-terms-of-service/) and/or a Master Service and License 14 | Agreement (collectively, the "Agreement") executed with Tozny. Unless the terms 15 | of the Agreement are agreed to in writing, use 16 | of this Content is prohibited and is restricted from being copied, modified, 17 | incorporated into other applications, sold, or otherwise distributed in any 18 | form. 19 | 20 | Contact Tozny at info@tozny.com for more information. 21 | 22 | Other than as expressly set forth herein or in the Agreement, or other 23 | instrument executed by Tozny, nothing in this License shall grant to any user 24 | (i) license or other rights in or to the Tozny name, any Tozny logo, the Tozny 25 | domain name, any other Tozny trademark or service mark, or other intellectual 26 | property; or (ii) ownership rights in any Tozny technology, software, hardware, 27 | products, processes, algorithms, user interfaces, know-how, trade secrets, 28 | techniques, designs, inventions, other tangible or intangible technical 29 | material or information, or other intellectual property (collectively, "Tozny 30 | Technology"), any and all of which are hereby expressly reserved to Tozny. 31 | 32 | Unless as expressly set out in the terms of the Agreement all Content available 33 | or distributed is on an "AS IS" basis, WITHOUT WARRANTY OF ANY KIND, either 34 | express or implied, including any warranty of merchantability or fitness. 35 | 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rancher Let's Encrypt Service 2 | 3 | ## Let's Encrypt verification 4 | 5 | Let's Encrypt has two methods of verifying ownership of domains. The first is through the addition of a custom DNS record (say acme-12321313.subdomain.domain.com). This is what https://github.com/janeczku/rancher-letsencrypt does. That service creates Let's Encrypt challenges via DNS resolution. The other way of proving ownership of domains is through a webserver webroot over HTTP. 6 | 7 | *Update:* The [janeczku/rancher-letsencrypt](https://github.com/janeczku/rancher-letsencrypt) project [now supports HTTP webroot verification](https://github.com/janeczku/rancher-letsencrypt/commit/2777fcd8eb15fed992a01d41387d2904e010e501). The Tozny project was created many months before this feature was added. 8 | 9 | ## Our Service 10 | 11 | With our environment, we wanted to do webroot verification for Let's Encrypt and Rancher. We wanted a service that would manage TLS certificates automatically, and renew them as needed. We also wanted this tightly integrated with Rancher for complete automation. This way load balancers (and other services) could automatically pick up certs through the Rancher API. Also, when we update a cert in Rancher, the load balancers will receive the updated cert with zero downtime. We also did not want to give keys for updating DNS records for our entire domain to every rancher environment for security purposes (isolation is best!) 12 | 13 | Tozny has been using this service in production for over a year now, and has been battle tested. We renew over 40 subdomains regularly without issue. 14 | 15 | ## How it Works 16 | 17 | The service launches two containers: 18 | - `letsencrypt-nginx` 19 | - `letsencrypt-python` 20 | 21 | The `letsencrypt-nginx` container is stock nginx, but shares the webroot with the `letsencrypt-python` service container. This way the `letsencrypt-python` container can add ACME challenges to the `/.well-known/acme-challenge/` directory on the webserver for verification. The python container is a sidekick of the nginx container. The containers are launched as a Rancher Service Account, so special environment variables containing the Rancher server API url, and access keys are passed into the container at runtime. 22 | 23 | #### Example Rancher Load Balancer (HAProxy) GUI Config 24 | (Based on Rancher GUI v1.3.3) 25 | 26 | 1. Use the "Add Service" dropdown to select "Add Load Balancer" or edit an existing Load Balancer 27 | 2. If empty, fill in the **Name** 28 | 3. Enter the following into the **Port Rules** section for each server for which you are requesting a certificate: 29 | 30 | | Access | Protocol | Request Host | Port | Path | Target | Port | 31 | |--------|----------|-----------------------|------|---------------|-------------------|------| 32 | | Public | HTTP | *yourserver.name.com* | 80 | /.well-known/ | letsencrypt-nginx | 80 | 33 | Example "Target" is based on the default container name `letsencrypt-nginx` used by this project 34 | 35 | 36 | *Note: If you are using custom haproxy.cfg settings to redirect http traffic to https (or wish to do so now), make sure to exclude the `/.well-known/` directory using `!{ url_dir /.well-known/ }` as in:* 37 | 38 | ``` 39 | frontend 80 40 | redirect scheme https code 301 if !{ url_dir /.well-known/ } !{ ssl_fc } 41 | ``` 42 | This example custom haproxy.cfg will merge the redirect setting with the default Rancher haproxy.cfg frontend definition and set up permanent ("301") redirects to HTTPS for *all* other HTTP traffic. 43 | 44 | ## Requirements 45 | 46 | - DNS control of domain names (ability to create host.subdomain.domain.com records to point to Rancher IP) 47 | - Front-end load balancer exposing a privileged port (less than 1024) to the internet for Let's Encrypt verification 48 | - This Rancher service 49 | - Rancher Cattle as Container Scheduler/Orchestrator 50 | - Rancher v1.1.4 - v1.4.2 (versions tested with this service) 51 | 52 | ## How to use 53 | 54 | Create a front end load balancer (or use the one in `traffic-manager` directory). If you are making one, you need to make sure it is a L7 HTTP load balancer on your chosen privileged port. This way the load balancer can redirect /.well-known/\* traffic to the `letsencrypt-nginx` container for verification. You can then route all other traffic to your normal HTTP services. This way only during verification does traffic get directed to the `letsencrypt-nginx` container. 55 | 56 | #### Rancher Compose 57 | 58 | Use `rancher-compose up` to launch the stack in rancher. **In order to get a Let's Encrypt Production certificate, you must set the environment variable STAGING=False**. This will then tell the service to use the production Let's Encrypt api instead of the staging api. 59 | To use the environment file, you need to pass the path using the `--env-file` or `-e` option. 60 | 61 | #### Rancher Catalog (UI) 62 | 63 | Add this repository as a catalog to your rancher instance: 64 | 1. Open Rancher 65 | 2. Select *Admin* in the navigation 66 | 3. Select *Settings* 67 | 68 | In the *Catalog* section you can add this catalog by entering a name (e.g. `rancher-lets-encrypt`), the URL to this repository and a branch. 69 | 70 | Afterwards you will be able to select the new catalog from the `Catalog` menu item in the navigation. There you will find the `Rancher Let's Encrypt Service`. By clicking **View Details** you can configure the service to your needs and then launch it. 71 | 72 | # Certificate Workflows 73 | 74 | "staging" refers to Let's Encrypt staging API. 75 | "production" refers to Let's Encrypt production API. 76 | 77 | This flowchart/execution diagram shows all the cases the service deals with, and how it responds to different stages. 78 | 79 | - get certs from rancher API 80 | - local copy of cert 81 | - cert in rancher 82 | - upgrade staging cert to production 83 | - create cert 84 | - push to rancher 85 | - upgrade self signed cert to production 86 | - create cert 87 | - push to rancher 88 | - rancher cert expired 89 | - local cert expired 90 | - create cert (renew) 91 | - push to rancher 92 | - local cert not expired 93 | - push to rancher 94 | - cert not in rancher 95 | - local cert expired 96 | - create cert 97 | - push to rancher 98 | - local cert not expired 99 | - push to rancher 100 | - no local copy of cert 101 | - create cert 102 | - push to rancher 103 | -------------------------------------------------------------------------------- /files/rancher.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3.9 2 | # -*- coding: utf-8 -*- 3 | 4 | # This python service is responsible for managing lets encrypt certificates. 5 | 6 | import time 7 | import socket 8 | from datetime import datetime 9 | import os 10 | import subprocess 11 | import json 12 | import requests 13 | from OpenSSL import crypto 14 | from requests.auth import HTTPBasicAuth 15 | import sys 16 | 17 | try: 18 | # These variables should all get set as this service is a Rancher Agent 19 | # Therefore, Rancher sets these for us. 20 | RANCHER_URL = os.environ['CATTLE_URL'] 21 | RANCHER_ACCESS_KEY = os.environ['CATTLE_ACCESS_KEY'] 22 | RANCHER_SECRET_KEY = os.environ['CATTLE_SECRET_KEY'] 23 | 24 | # ## These environment variables are required to be set! ### 25 | # email to register with letsencrypt with 26 | CERTBOT_EMAIL = os.environ['CERTBOT_EMAIL'] 27 | # list of domains we want certs for, comma-delimited 28 | DOMAINS = os.environ['DOMAINS'] 29 | 30 | # ## These environment variables will be set to defaults if they are not defined! ### 31 | # we are now using os.getenv 32 | # the first argument is the environment variable that is set inside the container 33 | # if the environment variable is not set, then we use the default, the second arg 34 | # this is only used for variables we can have defaults for, such as days 35 | # we cannot use this for things like Rancher URL, Access keys, etc. 36 | # therefore the below are *optional* to set 37 | 38 | # how long (in seconds) we want to wait for HTTP request to complete before throwing an error 39 | CONNECT_TIMEOUT = int(os.getenv('CONNECT_TIMEOUT', 10)) 40 | # how long to back off connection time before trying request again (in seconds) 41 | CONNECT_WAIT = int(os.getenv('CONNECT_WAIT', 10)) 42 | # how long, in days, before our cert expires should we renew it? 43 | RENEW_THRESHOLD = int(os.getenv('RENEW_BEFORE_DAYS', 14)) * (24 * 60 * 60) 44 | # sleep time before checking certs again 45 | LOOP_TIME = int(os.getenv('LOOP_TIME', 300)) 46 | # Shared webroot directory between Rancher Lets Encrypt service and Nginx container that 47 | # serves the ACME requests 48 | CERTBOT_WEBROOT = os.getenv('CERTBOT_WEBROOT', '/var/www') 49 | # Where the lets encrypt files live, such as certificates, private keys, etc 50 | LETSENCRYPT_ROOTDIR = os.getenv('LETSENCRYPT_ROOTDIR', '/etc/letsencrypt') 51 | # If this is set to True, we will create a "Dummy" LetsEncrypt certificate. Useful for testing. 52 | # If you want production LE certs, Set to "False" Which will get a valid LE signed cert for you. 53 | STAGING = os.getenv('STAGING', "True") == "True" 54 | # how long to wait until we check our domains are up again when doing port/http checks. 55 | HOST_CHECK_LOOP_TIME = int(os.getenv('HOST_CHECK_LOOP_TIME', 10)) 56 | # which port to use for LetsEncrypt verification. Defaults to 80. 57 | HOST_CHECK_PORT = int(os.getenv('HOST_CHECK_PORT', 80)) 58 | 59 | except KeyError as e: 60 | print("ERROR: Could not find an Environment variable set.") 61 | print(e) 62 | # exit the service since this failed. 63 | sys.exit(1) 64 | 65 | 66 | class RancherService: 67 | 68 | def auth(self): 69 | """ 70 | return a http auth object 71 | """ 72 | return HTTPBasicAuth(RANCHER_ACCESS_KEY, RANCHER_SECRET_KEY) 73 | 74 | def get_certificate(self): 75 | """ 76 | return json(python dict) of certificate listing api endpoint 77 | """ 78 | url = "{0}/certificate?limit=1000".format(RANCHER_URL) 79 | # make sure we loop until we get valid data back from server 80 | done = False 81 | while not done: 82 | try: 83 | r = requests.get(url=url, auth=self.auth(), timeout=CONNECT_TIMEOUT) 84 | except requests.exceptions.ConnectionError as e: 85 | print("ERROR: Cannot connect to URL: {0} for method {1}. " 86 | "Full error: {2}".format(url, "get_certificate", str(e))) 87 | print("ERROR: Trying to reconnect in {0} seconds".format(CONNECT_WAIT)) 88 | time.sleep(CONNECT_WAIT) 89 | continue 90 | # done with exceptions 91 | # if we have a valid status code we should be ok 92 | if r.status_code: 93 | done = True 94 | 95 | return r.json()['data'] 96 | 97 | def get_issuer_for_certificates(self): 98 | """ 99 | get the "issuer": "CN=Fake LE Intermediate X1", 100 | field name for a given server hostname 101 | 102 | returns: dict where key is server hostname and value is issuer 103 | Will always return one issuer per hostname in Rancher API. 104 | """ 105 | issuers = {} 106 | certificates = self.get_certificate() 107 | for cert in certificates: 108 | server = cert['CN'] 109 | if server in issuers: 110 | # we have duplicate certs, so we need to decide which cert is the latest one. 111 | prev_cert_created = int(issuers[server]['created']) 112 | next_cert_created = int(cert['createdTS']) 113 | if next_cert_created - prev_cert_created < 0: 114 | # previous cert is newer, so keep that one 115 | # nothing changes 116 | continue 117 | else: 118 | # next cert is newer, so add that one 119 | issuers[server]['issuer'] = cert['issuer'] 120 | issuers[server]['created'] = cert['createdTS'] 121 | else: 122 | # not a duplicate server cert name 123 | issuers[server] = {} 124 | issuers[server]['issuer'] = cert['issuer'] 125 | issuers[server]['created'] = cert['createdTS'] 126 | return issuers 127 | 128 | def rancher_certificate_expired(self, server): 129 | returned_json = self.get_certificate() 130 | current_time = int(time.time()) 131 | for certificate in returned_json: 132 | cn = certificate['CN'] 133 | if server == cn: 134 | # found the cert we want to verify 135 | expires_at = certificate['expiresAt'] 136 | timestamp = datetime.strptime(expires_at, '%a %b %d %H:%M:%S %Z %Y') 137 | expiry = int(timestamp.strftime("%s")) 138 | print("INFO: Found cert: {0}, Expiry: {1}".format(cn, expiry)) 139 | now = int(time.time()) 140 | if self.expiring(expiry): 141 | return True 142 | else: 143 | return False 144 | else: 145 | # a cert we dont care about since it doesnt match server cn 146 | continue 147 | 148 | def delete_cert(self, server): 149 | """ 150 | Delete existing cert from the server. 151 | """ 152 | print("Deleting {0} cert from Rancher API".format(server)) 153 | url = "{0}/projects/{1]/certificates/{2}".format(RANCHER_URL, 154 | self.get_project_id(), 155 | self.get_certificate_id(server)) 156 | done = False 157 | while not done: 158 | try: 159 | r = requests.delete(url=url, auth=self.auth(), timeout=CONNECT_TIMEOUT) 160 | except requests.exceptions.ConnectionError as e: 161 | print("ERROR: Cannot connect to URL: {0} for method {1}. " 162 | "Full error: {2}".format(url, "delete_cert", str(e))) 163 | print("ERROR: Trying to reconnect in {0} seconds".format(CONNECT_WAIT)) 164 | time.sleep(CONNECT_WAIT) 165 | continue 166 | # done with exceptions 167 | # if we have a valid status code we should be ok 168 | if r.status_code: 169 | done = True 170 | 171 | print("INFO: Delete cert status code: {0}".format(r.status_code)) 172 | print("INFO: Sleeping for two minutes because rancher sucks and takes FOREVER to purge a deleted certificate") 173 | time.sleep(120) 174 | 175 | def get_certificate_id(self, server): 176 | """ 177 | Get Rancher assigned certificate id for a given certificate. 178 | """ 179 | certs = self.get_certificate() 180 | for cert in certs: 181 | if cert['CN'] == server: 182 | return cert['id'] 183 | return None 184 | 185 | def expiring(self, cert_time): 186 | """ 187 | returns True if the cert is expired and False if the cert is not 188 | This also tests to see if the cert is *going* to expire, and returns the same Boolean. 189 | """ 190 | now = int(time.time()) 191 | if cert_time - now <= RENEW_THRESHOLD: 192 | return True 193 | elif cert_time - now < 0: 194 | return True 195 | else: 196 | return False 197 | 198 | def renew_certificate(self, server): 199 | print("INFO: Renewing certificate for {0}".format(server)) 200 | self.create_cert(server) 201 | 202 | def check_cert_files_exist(self, server): 203 | """ 204 | check if certs files already exist on disk. If they are on disk and not in rancher, publish them in rancher. 205 | """ 206 | cert_dir = '{0}/live/{1}/'.format(LETSENCRYPT_ROOTDIR, server) 207 | cert = '{0}/cert.pem'.format(cert_dir) 208 | privkey = '{0}/privkey.pem'.format(cert_dir) 209 | fullchain = '{0}/fullchain.pem'.format(cert_dir) 210 | chain = '{0}/chain.pem'.format(cert_dir) 211 | return os.path.isdir(cert_dir) and os.path.isfile(cert) and \ 212 | os.path.isfile(privkey) and os.path.isfile(chain) and os.path.isfile(fullchain) 213 | 214 | def loop(self): 215 | while True: 216 | self.cert_manager() 217 | print("INFO: Sleeping: {0} seconds...".format(LOOP_TIME)) 218 | time.sleep(LOOP_TIME) 219 | 220 | def cert_manager(self): 221 | """ 222 | Check that the server in DOMAINS have certificates in Rancher UI. 223 | If they do not have a cert, it is a new server, and we need to create a cert. 224 | If the cert already exists, we should check that it is not going to expire. 225 | 226 | This is where almost all of the logic of the service is for cert issuance, renewal, 227 | and rancher cert management. 228 | """ 229 | servers = self.parse_servernames() 230 | rancher_cert_servers = self.get_rancher_certificate_servers() 231 | issuers = self.get_issuer_for_certificates() 232 | for server in servers: 233 | if self.check_cert_files_exist(server): 234 | # local copy of cert 235 | if server not in rancher_cert_servers: 236 | # cert not in rancher 237 | cert = self.read_cert(server) 238 | if self.local_cert_expired(cert): 239 | # local copy (expired cert) 240 | self.create_cert(server) 241 | self.post_cert(server) 242 | else: 243 | # local copy (not expired cert) 244 | self.post_cert(server) 245 | else: 246 | # cert in rancher 247 | server_cert_issuer = issuers[server]['issuer'] 248 | if "Fake" in server_cert_issuer and not STAGING: 249 | # upgrade staging cert to production 250 | print("INFO: Upgrading staging cert to production for {0}".format(server)) 251 | self.create_cert(server) 252 | self.post_cert(server) 253 | # See below link for list of active and backup cert types 254 | # https://letsencrypt.org/certificates/ 255 | elif not STAGING and ("R3" not in server_cert_issuer and "R4" not in server_cert_issuer and "E1" not in server_cert_issuer and "E2" not in server_cert_issuer): 256 | # we have a self-signed certificate we should replace with a prod certificate. 257 | # this should only happen once on initial rancher install. 258 | print("INFO: Replacing self-signed certificate: {0}, " 259 | "{1} with production LE cert".format(server, server_cert_issuer)) 260 | self.create_cert(server) 261 | self.post_cert(server) 262 | 263 | elif self.rancher_certificate_expired(server): 264 | # rancher cert expired 265 | cert = self.read_cert(server) 266 | if self.local_cert_expired(cert): 267 | # local cert expired 268 | self.create_cert(server) 269 | self.post_cert(server) 270 | else: 271 | # local cert not expired 272 | self.post_cert(server) 273 | else: 274 | # no local copy of cert 275 | self.create_cert(server) 276 | self.post_cert(server) 277 | 278 | def create_cert(self, server): 279 | print("INFO: Need to create cert for {0}".format(server)) 280 | # TODO this is incredibly hacky. Certbot is python code so there should be a way to do this without shelling 281 | # out to the cli certbot tool. (certbot docs suck btw) 282 | # https://www.metachris.com/2015/12/comparison-of-10-acme-lets-encrypt-clients/#client-simp_le maybe? 283 | if STAGING: 284 | proc = subprocess.Popen(["certbot", "certonly", "--webroot", "-w", CERTBOT_WEBROOT, 285 | "--text", "-d", server, "-m", CERTBOT_EMAIL, "--agree-tos", 286 | "--renew-by-default", "--staging"], stdout=subprocess.PIPE) 287 | else: 288 | # production 289 | proc = subprocess.Popen(["certbot", "certonly", "--webroot", "-w", CERTBOT_WEBROOT, 290 | "--text", "-d", server, "-m", CERTBOT_EMAIL, "--agree-tos", 291 | "--renew-by-default"], stdout=subprocess.PIPE) 292 | # wait for the process to return 293 | com = proc.communicate()[0] 294 | # read cert in from file 295 | if proc.returncode == 0: 296 | # made cert hopefully *crosses fingers* 297 | print("INFO: certbot seems to have run with exit code 0") 298 | else: 299 | print("INFO: certbot -- an error occurred during cert creation. " 300 | "Non-zero Status code ({})".format(proc.returncode)) 301 | # print(stdout from subprocess) 302 | print(com) 303 | 304 | def local_cert_expired(self, cert_string): 305 | """ 306 | if there is a certificate in LETSENCRYPT_ROOTDIR, we should check that it is itself 307 | valid and not about to expire. 308 | """ 309 | cert = crypto.load_certificate(crypto.FILETYPE_PEM, cert_string) 310 | timestamp = datetime.strptime(cert.get_notAfter().decode('utf-8'), "%Y%m%d%H%M%SZ") 311 | expiry = int(timestamp.strftime("%s")) 312 | if self.expiring(expiry): 313 | return True 314 | else: 315 | return False 316 | 317 | def post_cert(self, server): 318 | """ 319 | POST a certificate to the Rancher API. 320 | """ 321 | # check if the cert exists in Rancher first. 322 | cert_id = self.get_certificate_id(server) 323 | if cert_id is not None: 324 | # the cert exists in rancher, do PUT to update it 325 | url = "{0}/projects/{1}/certificates/{2}".format(RANCHER_URL, self.get_project_id(), cert_id) 326 | request_type = requests.put 327 | else: 328 | # create the cert for the first time, do POST 329 | url = "{0}/projects/{1}/certificate".format(RANCHER_URL, self.get_project_id()) 330 | request_type = requests.post 331 | 332 | if self.check_cert_files_exist(server): 333 | json_structure = {'certChain': self.read_chain(server), 'cert': self.read_cert(server), 334 | 'key': self.read_privkey(server), 'type': 'certificate', 'name': server, 'created': None, 335 | 'description': None, 'kind': None, 'removed': None, 'uuid': None} 336 | 337 | headers = {'Content-Type': 'application/json'} 338 | done = False 339 | while not done: 340 | try: 341 | r = request_type(url=url, data=json.dumps(json_structure), headers=headers, 342 | auth=self.auth(), timeout=CONNECT_TIMEOUT) 343 | except requests.exceptions.ConnectionError as e: 344 | print("ERROR: Cannot connect to URL: {0} for method {1}. " 345 | "Full error: {2}".format(url, "post_cert", str(e))) 346 | print("ERROR: Trying to reconnect in {0} seconds".format(CONNECT_WAIT)) 347 | time.sleep(CONNECT_WAIT) 348 | continue 349 | 350 | # done with exceptions 351 | # if we have a valid status code we should be ok 352 | if r.status_code: 353 | done = True 354 | 355 | print("INFO: HTTP status code: {0}".format(r.status_code)) 356 | else: 357 | print("INFO: Could not find cert files inside post_cert method!") 358 | 359 | def get_project_id(self): 360 | """ 361 | get /projects//certificate 362 | --> /projects/1a5/certificate 363 | """ 364 | url = "{0}/projects".format(RANCHER_URL) 365 | done = False 366 | while not done: 367 | try: 368 | r = requests.get(url=url, auth=self.auth(), timeout=CONNECT_TIMEOUT) 369 | except requests.exceptions.ConnectionError as e: 370 | print("ERROR: Cannot connect to URL: {0} for method {1}. " 371 | "Full error: {2}".format(url, "get_project_id", str(e))) 372 | print("ERROR: Trying to reconnect in {0} seconds".format(CONNECT_WAIT)) 373 | time.sleep(CONNECT_WAIT) 374 | continue 375 | 376 | # done with exceptions 377 | # if we have a valid status code we should be ok 378 | if r.status_code: 379 | done = True 380 | 381 | j = r.json() 382 | return j['data'][0]['id'] 383 | 384 | def read_cert(self, server): 385 | """ 386 | Read cert.pem file from letsencrypt directory 387 | and return the contents as a string 388 | """ 389 | cert_file = "{0}/live/{1}/{2}".format(LETSENCRYPT_ROOTDIR, server, "cert.pem") 390 | if os.path.isfile(cert_file): 391 | # read files and post the correct info to populate rancher 392 | with open(cert_file, 'r') as openfile: 393 | cert = openfile.read().rstrip('\n') 394 | return cert 395 | else: 396 | print("ERROR: Could not find file: {0}".format(cert_file)) 397 | return None 398 | 399 | def read_privkey(self, server): 400 | """ 401 | Read privkey.pem file from letsencrypt directory 402 | and return the contents as a string 403 | """ 404 | privkey_file = "{0}/live/{1}/{2}".format(LETSENCRYPT_ROOTDIR, server, "privkey.pem") 405 | if os.path.isfile(privkey_file): 406 | # read files and post the correct info to populate rancher 407 | with open(privkey_file, 'r') as openfile: 408 | privkey = openfile.read().rstrip('\n') 409 | return privkey 410 | else: 411 | print("ERROR: Could not find file: {0}".format(privkey_file)) 412 | return None 413 | 414 | def read_fullchain(self, server): 415 | """ 416 | Read fullchain.pem file from letsencrypt directory. 417 | and return the contents as a string 418 | """ 419 | fullchain_file = "{0}/live/{1}/{2}".format(LETSENCRYPT_ROOTDIR, server, "fullchain.pem") 420 | if os.path.isfile(fullchain_file): 421 | with open(fullchain_file, 'r') as openfile: 422 | fullchain = openfile.read().rstrip('\n') 423 | return fullchain 424 | else: 425 | print("ERROR: Could not find file: {0}".format(fullchain_file)) 426 | return None 427 | 428 | def read_chain(self, server): 429 | """ 430 | Read chain.pem file from letsencrypt directory. 431 | and return the contents as a string 432 | """ 433 | chain_file = "{0}/live/{1}/{2}".format(LETSENCRYPT_ROOTDIR, server, "chain.pem") 434 | if os.path.isfile(chain_file): 435 | with open(chain_file, 'r') as openfile: 436 | chain = openfile.read().rstrip('\n') 437 | return chain 438 | else: 439 | print("ERROR: Could not find file: {0}".format(chain_file)) 440 | return None 441 | 442 | def parse_servernames(self): 443 | return DOMAINS.split(',') 444 | 445 | def get_rancher_certificate_servers(self): 446 | """ 447 | Retrieve a list of CN's of certificates in the Rancher UI. 448 | """ 449 | returned_json = self.get_certificate() 450 | cns = [] 451 | for certificate in returned_json: 452 | if certificate['state'] == "active": 453 | print("INFO: CN: {0} is active".format(certificate['CN'])) 454 | cns.append(certificate['CN']) 455 | return cns 456 | 457 | def hostname_resolves(self, host): 458 | try: 459 | socket.gethostbyname(host) 460 | return True 461 | except socket.error: 462 | return False 463 | 464 | def port_open(self, host, port): 465 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 466 | result = sock.connect_ex((host, port)) 467 | return result is 0 468 | 469 | def check_hostnames_and_ports(self): 470 | done = False 471 | while not done: 472 | # something failed since we are not done 473 | print("INFO: Sleeping during host lookups for {0} seconds".format(HOST_CHECK_LOOP_TIME)) 474 | time.sleep(HOST_CHECK_LOOP_TIME) 475 | # make sure all hostnames can be resolved and are listening on open ports 476 | for host in self.parse_servernames(): 477 | if self.hostname_resolves(host): 478 | print("INFO: Hostname: {0} resolves".format(host)) 479 | if self.port_open(host, HOST_CHECK_PORT): 480 | print("\tINFO: Port {0} open on {1}".format(HOST_CHECK_PORT, host)) 481 | # check if the /.well-known/acme-challenge/ directory isn't returning a 301 redirect 482 | # this is caused by the rancher load balancer not picking up the lets-encrypt service 483 | # and not directing traffic to it. Instead the redirection service gets the requests and returns 484 | # a 301 redirect. Also, if we get a 503 service unavailable status code there is no l 485 | # ets-encrypt nginx container working, and we should continue to wait and NOT requests Let's 486 | # Encrypt certificates yet. 487 | url = "http://{0}:{1}/.well-known/acme-challenge/".format(host, HOST_CHECK_PORT) 488 | 489 | # at this point the port is open, but it may not respond with a valid http response 490 | # so we need to check that it returns a valid http response and the connection can be opened 491 | 492 | valid_http = False 493 | while not valid_http: 494 | try: 495 | r = requests.get(url, allow_redirects=False) 496 | except requests.exceptions.ConnectionError as e: 497 | print("ERROR: Cannot connect to URL: {0} for method {1}. " 498 | "Full error: {2}".format(url, "get_certificate", str(e))) 499 | print("ERROR: Trying to reconnect in {0} seconds".format(CONNECT_WAIT)) 500 | time.sleep(CONNECT_WAIT) 501 | continue 502 | # done with exceptions 503 | # if we have a valid status code we should be ok 504 | if r.status_code: 505 | valid_http = True 506 | 507 | if r.status_code != 503 and r.status_code != 301: 508 | print("\t\tINFO: OK, got HTTP status code ({0}) for ({1})".format(r.status_code, 509 | host)) 510 | done = True 511 | else: 512 | print("\t\tINFO: Received bad HTTP status code ({0}) from ({1})".format(r.status_code, 513 | host)) 514 | done = False 515 | else: 516 | print("INFO: Could not connect to port {0} on host {1}".format(HOST_CHECK_PORT, host)) 517 | done = False 518 | else: 519 | print("INFO: Could not lookup DNS hostname for {0}".format(host)) 520 | done = False 521 | print("INFO: Continuing on to letsencrypt cert provisioning since all hosts seem to be up!") 522 | 523 | 524 | if __name__ == "__main__": 525 | service = RancherService() 526 | service.check_hostnames_and_ports() 527 | service.loop() 528 | -------------------------------------------------------------------------------- /files/requirements.txt: -------------------------------------------------------------------------------- 1 | argcomplete==1.10.0 2 | requests==2.22.0 3 | certbot==1.15.0 4 | acme==1.15.0 5 | pyOpenSSL==19.0.0 6 | -------------------------------------------------------------------------------- /templates/rancher-lets-encrypt/0/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | letsencrypt-nginx: 4 | image: nginx:alpine 5 | volumes: 6 | - letsencrypt-verify:/usr/share/nginx/html/ 7 | labels: 8 | io.rancher.sidekicks: rancher-lets-encrypt 9 | 10 | rancher-lets-encrypt: 11 | image: tozny/rancher-lets-encrypt 12 | # If there is only a key, Rancher Compose will resolve to the values 13 | # on the machine or the file passed in using --env-file. 14 | # If the values are set using Rancher's UI, they will override the values from the .env file 15 | environment: 16 | - DOMAINS=${DOMAINS} 17 | - CERTBOT_WEBROOT=${CERTBOT_WEBROOT} 18 | - CERTBOT_EMAIL=${CERTBOT_EMAIL} 19 | - RENEW_BEFORE_DAYS=${RENEW_BEFORE_DAYS} 20 | - LOOP_TIME=${LOOP_TIME} 21 | - STAGING=${STAGING} 22 | - HOST_CHECK_PORT=${HOST_CHECK_PORT} 23 | - HOST_CHECK_LOOP_TIME=${HOST_CHECK_LOOP_TIME} 24 | volumes: 25 | - letsencrypt-verify:${CERTBOT_WEBROOT} 26 | - ${CERT_VOLUME}:/etc/letsencrypt/ 27 | labels: 28 | # if we add the container as a rancher agent, we get magical things like Rancher server URL, and access keys for F-R-E-E! 29 | io.rancher.container.create_agent: 'true' 30 | io.rancher.container.agent.role: environment 31 | -------------------------------------------------------------------------------- /templates/rancher-lets-encrypt/0/letsencrypt.env: -------------------------------------------------------------------------------- 1 | DOMAINS=subdomain1.example.com,subdomain2.example.com,subdomain3.example.com 2 | CERTBOT_WEBROOT=/var/www 3 | CERTBOT_EMAIL=someemail@example.com 4 | RENEW_BEFORE_DAYS=14 5 | LOOP_TIME=300 6 | # Staging = True will get you a cert with the CA Fake, while Staging = False will use production Let's Encrypt to get the Let's Encrypt V3 CA. 7 | STAGING=True 8 | HOST_CHECK_PORT=80 9 | HOST_CHECK_LOOP_TIME=10 10 | CERT_VOLUME=letsencrypt-certs 11 | -------------------------------------------------------------------------------- /templates/rancher-lets-encrypt/0/rancher-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | catalog: 3 | name: Rancher Let's Encrypt Service 4 | version: 0.1.0 5 | description: Automatically create and manage certificates in Rancher using Let's Encrypt webroot verification. 6 | minimum_rancher_version: v1.1.0 7 | questions: 8 | - variable: DOMAINS 9 | label: Domain Names 10 | description: | 11 | Comma delimited list of the certificate domains (without spaces). 12 | E.g: `example.com,dev.example.com,test.example.com`. 13 | required: true 14 | type: string 15 | - variable: CERTBOT_WEBROOT 16 | label: Certbot Webroot 17 | description: | 18 | Webroot to be used by certbot 19 | required: true 20 | default: /var/www 21 | type: string 22 | - variable: CERTBOT_EMAIL 23 | label: Your Email Address 24 | description: | 25 | Enter the email address to use for creating the Let's Encrypt account. 26 | required: true 27 | type: string 28 | - variable: RENEW_BEFORE_DAYS 29 | label: Renew Before Days 30 | description: | 31 | Number of days left until certificate expiry before the certificate should be renewed 32 | required: true 33 | default: 14 34 | type: int 35 | - variable: LOOP_TIME 36 | label: Loop Time 37 | description: | 38 | Time between each renewal check 39 | required: true 40 | default: 300 41 | type: int 42 | - variable: STAGING 43 | label: Staging API Environment 44 | description: | 45 | Should the staging API environment be used for issuing the certificate? 46 | (Staging should be used for testing purposes only!) 47 | required: true 48 | default: true 49 | type: boolean 50 | - variable: HOST_CHECK_PORT 51 | label: Host Check Port 52 | description: | 53 | The port on which the loadbalancer expects the requests to the letsencrypt-service. 54 | required: true 55 | default: 80 56 | type: int 57 | - variable: HOST_CHECK_LOOP_TIME 58 | label: Host Check Loop Time 59 | description: | 60 | Time to sleep before each host check. 61 | required: true 62 | default: 10 63 | type: int 64 | - variable: CERT_VOLUME 65 | label: Location to store certificates 66 | description: | 67 | Docker volume or host bind mount location to store the certificates retrieved by the letsencrypt service. 68 | E.g: Docker volume example: 'letsencrypt-certs' or 'ssl-certificates'. Host bind mount example: '/etc/letsencrypt/' or '/dockerdata/letsencrypt/'. 69 | required: true 70 | default: letsencrypt-certs 71 | type: string 72 | 73 | services: 74 | letsencrypt-nginx: 75 | scale: 1 76 | 77 | rancher-lets-encrypt: 78 | scale: 1 79 | -------------------------------------------------------------------------------- /templates/rancher-lets-encrypt/config.yml: -------------------------------------------------------------------------------- 1 | name: Rancher Let's Encrypt Service 2 | description: | 3 | Automatically create and manage certificates in Rancher using Let's Encrypt webroot verification. 4 | version: 0.1.0 5 | category: Certificates 6 | maintainer: Tozny 7 | license: MIT 8 | projectURL: https://github.com/tozny/rancher-lets-encrypt -------------------------------------------------------------------------------- /traffic-manager-ssl/docker-compose.yml: -------------------------------------------------------------------------------- 1 | traffic-manager-443: 2 | ports: 3 | - 443:80 4 | external_links: 5 | - mystack/myapp:myapp 6 | labels: 7 | io.rancher.scheduler.global: 'true' 8 | io.rancher.loadbalancer.ssl.ports: '443' 9 | io.rancher.loadbalancer.target.mystack/myapp: somedomain.example.com:443=8080 10 | tty: true 11 | image: rancher/load-balancer-service 12 | stdin_open: true 13 | -------------------------------------------------------------------------------- /traffic-manager-ssl/rancher-compose.yml: -------------------------------------------------------------------------------- 1 | traffic-manager-443: 2 | load_balancer_config: 3 | haproxy_config: 4 | defaults: |- 5 | option httpclose 6 | default_cert: somedomain.example.com 7 | certs: 8 | - anotherdomain.example.com 9 | health_check: 10 | port: 42 11 | interval: 2000 12 | unhealthy_threshold: 3 13 | healthy_threshold: 2 14 | response_timeout: 2000 15 | -------------------------------------------------------------------------------- /traffic-manager/docker-compose.yml: -------------------------------------------------------------------------------- 1 | traffic-manager-80: 2 | ports: 3 | - 80:80 4 | external_links: 5 | - lets-encrypt/letsencrypt-nginx:letsencrypt-nginx 6 | labels: 7 | io.rancher.scheduler.global: 'true' 8 | io.rancher.loadbalancer.target.redirect/redirect: yourdomain.example.com:80=80 9 | io.rancher.loadbalancer.target.lets-encrypt/letsencrypt-nginx: yourdomain.example.com:80/.well-known/acme-challenge/=80,anotherdomain.example.com:80/.well-known/acme-challenge/=80 10 | tty: true 11 | image: rancher/load-balancer-service 12 | stdin_open: true 13 | links: 14 | - redirect:redirect 15 | 16 | redirect: 17 | image: tozny/docker-redirector 18 | tty: true 19 | stdin_open: true 20 | -------------------------------------------------------------------------------- /traffic-manager/rancher-compose.yml: -------------------------------------------------------------------------------- 1 | traffic-manager-80: 2 | load_balancer_config: 3 | haproxy_config: {} 4 | health_check: 5 | port: 42 6 | interval: 2000 7 | unhealthy_threshold: 3 8 | healthy_threshold: 2 9 | response_timeout: 2000 10 | 11 | redirect: 12 | scale: 1 13 | --------------------------------------------------------------------------------