├── .gitignore ├── .gitlab-ci.yml ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── Dockerfile.docs ├── README.md ├── app ├── app.py ├── client_certbot.py ├── client_certbot_tests.py ├── client_dfp.py ├── client_dfple.py ├── client_dfple_tests.py └── hooks │ └── ovh │ ├── manual-auth-hook.py │ ├── manual-auth-hook.sh │ ├── manual-cleanup-hook.py │ └── manual-cleanup-hook.sh ├── certs.tar.enc ├── docs ├── config.md ├── example-secrets.md ├── example-volumes.md ├── index.md ├── tutorial-challenge-dns.md └── tutorial-volumes.md ├── entrypoint.sh ├── mkdocs.yml ├── requirements-test.txt ├── requirements.txt └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/* 2 | __pycache__/* 3 | *.pyc 4 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: docker:1.12 2 | services: 3 | - docker:1.12-dind 4 | 5 | stages: 6 | - test 7 | - release 8 | - deploy 9 | 10 | variables: 11 | IMAGE_NAME: nib0r/docker-flow-proxy-letsencrypt 12 | GITLAB_IMAGE_NAME: docker.nibor.me/robin/docker-flow-proxy-letsencrypt 13 | 14 | functional_tests_manual: 15 | stage: test 16 | variables: 17 | DOCKER_CERT_PATH: ${_DOCKER_CERT_PATH} 18 | DOCKER_HOST: ${_DOCKER_HOST} 19 | DOCKER_MACHINE_NAME: ${_DOCKER_MACHINE_NAME} 20 | DOCKER_TLS_VERIFY: ${_DOCKER_TLS_VERIFY} 21 | script: 22 | - pip install -r requirements-test.txt --upgrade 23 | - pytest tests.py 24 | tags: 25 | - shell 26 | - ks2.nibor.me 27 | when: manual 28 | only: 29 | - /^feature-.*$/ 30 | 31 | functional_tests: 32 | stage: test 33 | variables: 34 | DOCKER_CERT_PATH: ${_DOCKER_CERT_PATH} 35 | DOCKER_HOST: ${_DOCKER_HOST} 36 | DOCKER_MACHINE_NAME: ${_DOCKER_MACHINE_NAME} 37 | DOCKER_TLS_VERIFY: ${_DOCKER_TLS_VERIFY} 38 | script: 39 | - pip install -r requirements-test.txt --upgrade 40 | - pytest tests.py 41 | tags: 42 | - shell 43 | - ks2.nibor.me 44 | only: 45 | - develop 46 | - master 47 | - tags 48 | 49 | test: 50 | stage: test 51 | script: 52 | - pip install -r requirements-test.txt --upgrade 53 | - pytest app/client_dfple_tests.py 54 | - pytest app/client_certbot_tests.py 55 | tags: 56 | - shell 57 | - ks2.nibor.me 58 | only: 59 | - develop 60 | - /^feature-.*$/ 61 | - /^release-.*$/ 62 | - master 63 | - tags 64 | 65 | release: 66 | stage: release 67 | script: 68 | - docker build -t $IMAGE_NAME:latest . 69 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 70 | - docker push $IMAGE_NAME:latest 71 | tags: 72 | - docker 73 | - ks2.nibor.me 74 | only: 75 | - master 76 | 77 | release_gitlab: 78 | stage: release 79 | script: 80 | - docker build -t $GITLAB_IMAGE_NAME:${CI_BUILD_REF_NAME} . 81 | - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN docker.nibor.me 82 | - docker push $GITLAB_IMAGE_NAME:${CI_BUILD_REF_NAME} 83 | tags: 84 | - docker 85 | - ks2.nibor.me 86 | 87 | release_develop: 88 | stage: release 89 | script: 90 | - docker build -t $IMAGE_NAME:${CI_BUILD_REF_NAME} . 91 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 92 | - docker push $IMAGE_NAME:${CI_BUILD_REF_NAME} 93 | tags: 94 | - docker 95 | - ks2.nibor.me 96 | only: 97 | - develop 98 | 99 | release_tags: 100 | stage: release 101 | script: 102 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 103 | - docker build -t ${IMAGE_NAME}:${CI_BUILD_TAG} . 104 | - docker push ${IMAGE_NAME}:$CI_BUILD_TAG 105 | only: 106 | - tags 107 | tags: 108 | - ks2.nibor.me 109 | 110 | release_docs: 111 | stage: release 112 | script: 113 | - docker build -f Dockerfile.docs -t nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_REF_NAME} . 114 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 115 | - docker push nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_REF_NAME} 116 | tags: 117 | - shell 118 | - ks2.nibor.me 119 | only: 120 | - develop 121 | 122 | 123 | release_docs_master: 124 | stage: release 125 | script: 126 | - docker build -f Dockerfile.docs -t nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_REF_NAME} . 127 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 128 | - docker push nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_REF_NAME} 129 | - curl -X POST "https://jenkins.dockerflow.com/job/n1b0r/job/docker-flow-proxy-letsencrypt/buildWithParameters?token=n1b0r&tag=${CI_BUILD_REF_NAME}" 130 | tags: 131 | - shell 132 | - ks2.nibor.me 133 | only: 134 | - master 135 | 136 | docs_tags: 137 | stage: release 138 | script: 139 | - docker build -f Dockerfile.docs -t nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_TAG} . 140 | - docker login -u $DOCKER_HUB_USER -p $DOCKER_HUB_PASSWORD 141 | - docker push nib0r/docker-flow-proxy-letsencrypt-docs:${CI_BUILD_TAG} 142 | tags: 143 | - shell 144 | - ks2.nibor.me 145 | only: 146 | - tags 147 | 148 | docs: 149 | stage: deploy 150 | script: 151 | - docker service rm docker-flow-proxy-letsencrypt-docs || true 152 | - docker service create --name docker-flow-proxy-letsencrypt-docs --network proxy --label com.df.notify=true --label com.df.distribute=true --label com.df.serviceDomain=dfple-docs.ks2.nibor.me --label com.df.servicePath=/ --label com.df.srcPort=443 --label com.df.port=80 --label com.df.letsencrypt.host=dfple-docs.ks2.nibor.me --label com.df.letsencrypt.email=robinlucbernet@gmail.com nib0r/docker-flow-proxy-letsencrypt-docs:master 153 | tags: 154 | - shell 155 | - ks2.nibor.me 156 | only: 157 | - master -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: python 3 | services: 4 | - docker 5 | before_install: 6 | - docker --version 7 | script: 8 | - pip install -r requirements-test.txt 9 | - pytest app/client_dfple_tests.py 10 | - pytest app/client_certbot_tests.py -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # docker-flow-proxy-letsencrypt 2 | 3 | ## 0.7 4 | * staging per service [#13](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/pull/13) 5 | 6 | ## 0.6 7 | * write [documentation portal](https://docs.dfple.nibor.me) 8 | * CERTBOT_CHALLENGE env var now accepts `http` or `dns` 9 | * OVH certbot manual hooks 10 | 11 | ## 0.5 12 | * rework code to have unit tests allowing to reproduce error [#9](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/issues/9) 13 | * CERTBOT_CHALLENGE env var - [#11](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/issues/11) 14 | * better error handling in case combined cert has not been found - [#7](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/issues/7) 15 | 16 | ## 0.4 17 | * fix typo in entrypoint.sh - [#6](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/issues/6) 18 | 19 | ## 0.3 20 | * RETRY and RETRY_INTERVAL 21 | * automatic certificates renewal using crond 22 | 23 | ## 0.2 24 | * docker secrets support 25 | 26 | ## 0.1 27 | * initial release 28 | 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM certbot/certbot 2 | 3 | ENV DOCKER_SOCKET_PATH="/var/run/docker.sock" \ 4 | LETSENCRYPT_RENEWAL_CRON="30 2 * * *" \ 5 | DF_PROXY_SERVICE_NAME="proxy" \ 6 | DF_SWARM_LISTENER_SERVICE_NAME="swarm-listener" 7 | 8 | RUN apk add --update curl 9 | 10 | RUN mkdir -p /opt/www 11 | RUN mkdir -p /etc/letsencrypt 12 | RUN mkdir -p /var/lib/letsencrypt 13 | RUN touch /var/log/crond.log 14 | 15 | COPY ./requirements.txt /requirements.txt 16 | RUN pip install -r /requirements.txt 17 | 18 | COPY ./entrypoint.sh / 19 | 20 | EXPOSE 8080 21 | 22 | COPY ./app /app 23 | 24 | ENTRYPOINT ["sh", "/entrypoint.sh"] 25 | CMD ["python", "/app/app.py"] -------------------------------------------------------------------------------- /Dockerfile.docs: -------------------------------------------------------------------------------- 1 | FROM cilerler/mkdocs AS build 2 | RUN pip install pygments && pip install pymdown-extensions 3 | ADD . /docs 4 | RUN mkdocs build --site-dir /site 5 | 6 | FROM nginx:alpine 7 | COPY --from=build /site /usr/share/nginx/html -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Proxy Letsencrypt 2 | 3 | [![Build Status](https://travis-ci.org/n1b0r/docker-flow-proxy-letsencrypt.svg?branch=master)](https://travis-ci.org/n1b0r/docker-flow-proxy-letsencrypt) 4 | 5 | `docker-flow-proxy-letsencrypt` is a `docker-flow-proxy` companion that automatically create and renew certificates for your swarm services using [letsencrypt](https://letsencrypt.org/). 6 | 7 | Join the #df-letsencrypt Slack channel in [DevOps20](http://slack.devops20toolkit.com/) and ping me (@nibor) if you have any questions, suggestions, or problems. 8 | 9 | ## Concept 10 | 11 | The mecanism is mostly inspired by the [JrCs/docker-letsencrypt-nginx-proxy-companion](https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion) designed for [jwilder/nginx-proxy](https://github.com/jwilder/nginx-proxy). 12 | 13 | 14 | Normally (without using DFPLE) when a new service is created in the swarm, the `docker-flow-swarm-listener` (DFSL) will send a **notify** request to the `docker-flow-proxy` (DFP) wich reload based on new config. 15 | 16 | ``` 17 | DFSL -----notify-----> DFP 18 | ``` 19 | 20 | `docker-flow-proxy-letsencrypt` (DFPLE) acts as a man-in-the-middle service which gets **notify** requests from DFSL, process its work, and then forward original request to DFP. 21 | 22 | ``` 23 | DFSL -----notify-----> DFPLE -----notify-----> DFP 24 | ``` 25 | 26 | Its work consists in : 27 | 28 | * check if letsencrypt support is enabled by the service (check service labels) 29 | * generate or renew certificates using certbot utility (if support enabled) 30 | * distribute certificates to DFP (if support enabled) 31 | * forward request to dfp 32 | 33 | 34 | ## Get started 35 | 36 | Check the [tutorial](docs/tutorial-volumes.md) to get started with `docker-flow-proxy-letsencrypt` environment. 37 | 38 | Please note that in order to keep persistent certificates : 39 | 40 | * on DFPLE side : you will need to use a volume on `/etc/letsencrypt`, 41 | * on DFP side : you will have to either use a volume (see example [using volumes](docs/example-volumes.md)) or store certificates as secrets (see example [using secrets](docs/example-secrets.md)) 42 | 43 | You can use both `dns` and `http` letsencrypt ACME challenges (see [configuration](config.md)). 44 | 45 | Automatic renewal is performed at fixed interval. By default renewal process is performed once per day at 2.30 am. You can specify your own interval using `LETSENCRYPT_RENEWAL_CRON` env var on DFLE (see [configuration](docs/config.md)). -------------------------------------------------------------------------------- /app/app.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import docker 3 | import json 4 | import logging 5 | import os 6 | import requests 7 | import subprocess 8 | import time 9 | 10 | from client_dfple import * 11 | from flask import Flask, request, send_from_directory 12 | 13 | 14 | LEVELS = {'debug': logging.DEBUG, 15 | 'info': logging.INFO, 16 | 'warning': logging.WARNING, 17 | 'error': logging.ERROR, 18 | 'critical': logging.CRITICAL} 19 | 20 | logging.basicConfig(level=logging.ERROR, format="%(asctime)s;%(levelname)s;%(message)s") 21 | logging.getLogger('letsencrypt').setLevel(LEVELS[os.environ.get('LOG', 'info').lower()]) 22 | logger = logging.getLogger('letsencrypt') 23 | 24 | CERTBOT_WEBROOT_PATH = os.environ.get('CERTBOT_WEBROOT_PATH', '/opt/www') 25 | 26 | docker_client = None 27 | docker_socket_path = os.environ.get('DOCKER_SOCKET_PATH') 28 | if docker_socket_path and os.path.exists(docker_socket_path): 29 | logger.debug('docker_socket_path {}'.format(docker_socket_path)) 30 | docker_client = docker.DockerClient( 31 | base_url='unix:/{}'.format(docker_socket_path), 32 | version='1.25') 33 | 34 | args = { 35 | 'certbot_path': os.environ.get('CERTBOT_PATH', '/etc/letsencrypt'), 36 | 'certbot_challenge': os.environ.get('CERTBOT_CHALLENGE', 'http'), 37 | 'certbot_webroot_path': CERTBOT_WEBROOT_PATH, 38 | 'certbot_options': os.environ.get('CERTBOT_OPTIONS', ''), 39 | 'certbot_manual_auth_hook': os.environ.get('CERTBOT_MANUAL_AUTH_HOOK'), 40 | 'certbot_manual_cleanup_hook': os.environ.get('CERTBOT_MANUAL_CLEANUP_HOOK'), 41 | 'docker_client': docker_client, 42 | 'docker_socket_path': docker_socket_path, 43 | 'dfp_service_name': os.environ.get('DF_PROXY_SERVICE_NAME'), 44 | } 45 | 46 | client = DFPLEClient(**args) 47 | 48 | app = Flask(__name__) 49 | 50 | @app.route("/.well-known/acme-challenge/") 51 | def acme_challenge(path): 52 | return send_from_directory(CERTBOT_WEBROOT_PATH, 53 | ".well-known/acme-challenge/{}".format(path)) 54 | 55 | @app.route("/v/docker-flow-proxy-letsencrypt/reconfigure") 56 | def reconfigure(version): 57 | 58 | dfp_client = DockerFlowProxyAPIClient() 59 | args = request.args 60 | 61 | if version != 1: 62 | logger.error('Unable to use version : {}. Forwarding initial request to docker-flow-proxy service.'.format(version)) 63 | else: 64 | 65 | logger.info('request for service: {}'.format(args.get('serviceName'))) 66 | 67 | # Check if the newly registered service is usign letsencrypt companion. 68 | # Labels required: 69 | # * com.df.letsencrypt.host 70 | # * com.df.letsencrypt.email 71 | required_labels = ('letsencrypt.host', 'letsencrypt.email') 72 | if all([label in args.keys() for label in required_labels]): 73 | logger.info('letsencrypt support enabled.') 74 | 75 | testing = None 76 | if 'letsencrypt.testing' in args: 77 | testing = args['letsencrypt.testing'] 78 | if isinstance(testing, basestring): 79 | testing = True if testing.lower() == 'true' else False 80 | 81 | client.process(args['letsencrypt.host'].split(','), args['letsencrypt.email'], testing=testing) 82 | 83 | # proxy requests to docker-flow-proxy 84 | # sometimes we can get an error back from DFP, this can happen when DFP is not fully loaded. 85 | # resend the request until response status code is 200 (${RETRY} times waiting ${RETRY_INTERVAL} seconds between retries) 86 | t = 0 87 | while t < os.environ.get('RETRY', 10): 88 | t += 1 89 | 90 | logger.debug('forwarding request to docker-flow-proxy ({})'.format(t)) 91 | try: 92 | response = dfp_client.get(dfp_client.url(version, '/reconfigure?{}'.format( 93 | '&'.join(['{}={}'.format(k, v) for k, v in request.args.items()])))) 94 | if response.status_code == 200: 95 | break 96 | except Exception, e: 97 | logger.error('Error while trying to forward request: {}'.format(e)) 98 | logger.debug('waiting for retry') 99 | time.sleep(os.environ.get('RETRY_INTERVAL', 5)) 100 | 101 | return "OK" 102 | 103 | if __name__ == "__main__": 104 | app.run(host='0.0.0.0', port=8080, debug=True, threaded=True) -------------------------------------------------------------------------------- /app/client_certbot.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | import logging 4 | logger = logging.getLogger('letsencrypt') 5 | 6 | 7 | class CertbotClient(): 8 | def __init__(self, **kwargs): 9 | self.challenge = kwargs.get('challenge') 10 | self.webroot_path = kwargs.get('webroot_path') 11 | self.manual_auth_hook = kwargs.get('manual_auth_hook') 12 | self.manual_cleanup_hook = kwargs.get('manual_cleanup_hook') 13 | self.options = kwargs.get('options', "") 14 | 15 | if self.challenge not in ("http", "dns"): 16 | raise Exception('required argument "challenge" not set.') 17 | if self.challenge == "http" and self.webroot_path is None: 18 | raise Exception('required argument "webroot_path" not set. Required when using challenge "http"') 19 | if self.challenge == "dns" and (self.manual_auth_hook is None or self.manual_cleanup_hook is None): 20 | raise Exception('required argument "manual_auth_hook" or "manual_manual_hook" not set. Required when using challenge "dns"') 21 | 22 | 23 | def run(self, cmd): 24 | # cmd = cmd.split() 25 | logger.debug('executing cmd : {}'.format(cmd)) 26 | process = subprocess.Popen(cmd, 27 | stdout=subprocess.PIPE, 28 | stderr=subprocess.PIPE) 29 | output, error = process.communicate() 30 | logger.debug("o: {}".format(output)) 31 | if error: 32 | logger.debug(error) 33 | logger.debug("r: {}".format(process.returncode)) 34 | 35 | return output, error, process.returncode 36 | 37 | def get_options(self, testing=None): 38 | 39 | opts = self.options.split() 40 | 41 | # if testing, add staging flag 42 | if testing and '--staging' not in opts: 43 | opts.append('--staging') 44 | # if not testing, remove staging flag 45 | elif testing is False and '--staging' in opts: 46 | opts.remove('--staging') 47 | 48 | return ' '.join(opts) 49 | 50 | def update_cert(self, domains, email, testing=None): 51 | """ 52 | Update certificates 53 | """ 54 | 55 | c = '' 56 | if self.challenge == 'http': 57 | c = "--webroot --webroot-path {}".format(self.webroot_path) 58 | if self.challenge == 'dns': 59 | c = "--manual --manual-public-ip-logging-ok --preferred-challenges dns --manual-auth-hook {} --manual-cleanup-hook {}".format(self.manual_auth_hook, self.manual_cleanup_hook) 60 | 61 | output, error, code = self.run("""certbot certonly \ 62 | --agree-tos \ 63 | --domains {domains} \ 64 | --email {email} \ 65 | --expand \ 66 | --noninteractive \ 67 | {challenge} 68 | --debug \ 69 | {options}""".format( 70 | domains=','.join(domains), 71 | email=email, 72 | webroot_path=self.webroot_path, 73 | options=self.get_options(testing=testing), 74 | challenge=c).split()) 75 | 76 | ret_error = False 77 | ret_created = True 78 | 79 | if b'urn:acme:error:unauthorized' in error: 80 | logger.error('Error during ACME challenge, is the domain name associated with the right IP ?') 81 | ret_error = True 82 | ret_created = False 83 | 84 | if b'no action taken.' in output: 85 | logger.debug('Nothing to do. Skipping.') 86 | ret_created = False 87 | 88 | if code != 0: 89 | logger.error('Certbot return code: {}. Skipping'.format(code)) 90 | ret_error = True 91 | ret_created = False 92 | 93 | return ret_error, ret_created 94 | -------------------------------------------------------------------------------- /app/client_certbot_tests.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import os 3 | import shutil 4 | from mock import patch 5 | from unittest import TestCase 6 | 7 | from client_certbot import CertbotClient 8 | 9 | 10 | class CertbotClientTestCase(TestCase): 11 | 12 | def test_staging_per_container(self): 13 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp') 14 | assert '--staging' in certbot_client.get_options(testing=True) 15 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp', options="--staging") 16 | assert '--staging' in certbot_client.get_options(testing=True) 17 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp') 18 | assert '--staging' not in certbot_client.get_options(testing=False) 19 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp', options="--staging") 20 | assert '--staging' not in certbot_client.get_options(testing=False) 21 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp', options="--staging") 22 | assert '--staging' in certbot_client.get_options(testing=None) 23 | certbot_client = CertbotClient(challenge='http', webroot_path='/tmp') 24 | assert '--staging' not in certbot_client.get_options(testing=None) 25 | -------------------------------------------------------------------------------- /app/client_dfp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import requests 3 | 4 | import logging 5 | logger = logging.getLogger('letsencrypt') 6 | 7 | 8 | class DockerFlowProxyAPIClient: 9 | def __init__(self, DF_PROXY_SERVICE_BASE_URL=None, adaptor=None): 10 | self.base_url = DF_PROXY_SERVICE_BASE_URL 11 | if self.base_url is None: 12 | self.base_url = os.environ.get('DF_PROXY_SERVICE_NAME') 13 | 14 | self.adaptor = adaptor 15 | if self.adaptor is None: 16 | self.adaptor = requests 17 | 18 | def url(self, version, url): 19 | return 'http://{}:8080/v{}/docker-flow-proxy'.format(self.base_url, version) + url 20 | 21 | def _request(self, method_name, url, **kwargs): 22 | logger.debug('[{}] {}'.format(method_name, url)) 23 | r = getattr(self.adaptor, method_name)(url, **kwargs) 24 | logger.debug(' {}: {}'.format(r.status_code, r.text)) 25 | return r 26 | def put(self, *args, **kwargs): 27 | return self._request('put', *args, **kwargs) 28 | def get(self, *args, **kwargs): 29 | return self._request('get', *args, **kwargs) 30 | -------------------------------------------------------------------------------- /app/client_dfple.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import os 4 | from client_certbot import CertbotClient 5 | from client_dfp import DockerFlowProxyAPIClient 6 | 7 | import logging 8 | logger = logging.getLogger('letsencrypt') 9 | 10 | 11 | combined_cert_type = ('combined', 'pem') 12 | cert_types = [ 13 | combined_cert_type, 14 | ('fullchain', 'crt'), 15 | ('privkey', 'key')] 16 | 17 | class DFPLEClient(): 18 | 19 | def __init__(self, **kwargs): 20 | logger.debug('> init') 21 | 22 | self.docker_client = kwargs.get('docker_client') 23 | self.docker_socket_path = kwargs.get('docker_socket_path') 24 | # XXX-YYYYMMDD-HHMMSS 25 | self.size_secret = 64 - 16 26 | 27 | self.certbot = CertbotClient( 28 | challenge=kwargs.get('certbot_challenge'), 29 | webroot_path=kwargs.get('certbot_webroot_path'), 30 | options=kwargs.get('certbot_options', ''), 31 | manual_auth_hook=kwargs.get('certbot_manual_auth_hook'), 32 | manual_cleanup_hook=kwargs.get('certbot_manual_cleanup_hook') 33 | ) 34 | self.certbot_folder = kwargs.get('certbot_path') 35 | 36 | self.dfp_service_name = kwargs.get('dfp_service_name', None) 37 | 38 | self.dfp_client = DockerFlowProxyAPIClient() 39 | 40 | # self.domains = kwargs.get('domains', []) 41 | # self.email = kwargs.get('email') 42 | # self.certs = {} 43 | # self.certs_created = False 44 | # self.secrets = {} 45 | # self.secrets_created = False 46 | 47 | # self.initial_checks() 48 | 49 | 50 | def certs(self, domains): 51 | certs = {} 52 | for domain in domains: 53 | certs[domain] = [] 54 | for cert_type, cert_extension in cert_types: 55 | dest_file = os.path.join(self.certbot_folder, "{}.{}".format(domain, cert_extension)) 56 | if os.path.exists(dest_file): 57 | certs[domain].append(dest_file) 58 | return certs 59 | 60 | def secrets(self, domain=None): 61 | secrets = [] 62 | attrs = {} 63 | if domain is not None: 64 | attrs['filters'] = {"name": self.get_secret_name_short('{}.pem'.format(domain))} 65 | return self.docker_client.secrets.list(**attrs) 66 | 67 | def services(self, name, exact_match=True): 68 | services = self.docker_client.services.list( 69 | filters={'name': name}) 70 | if exact_match: 71 | services = [x for x in services if x.name == name] 72 | return services 73 | 74 | def service_get_secrets(self, service): 75 | return service.attrs['Spec']['TaskTemplate']['ContainerSpec'].get('Secrets', []) 76 | 77 | def get_secret_name_short(self, name): 78 | secret_name = name[-self.size_secret:] 79 | return secret_name 80 | 81 | def get_secret_name(self, name): 82 | secret_name = self.get_secret_name_short(name) 83 | secret_name += '-{}'.format(datetime.datetime.now().strftime("%Y%m%d-%H%M%S")) 84 | return secret_name 85 | 86 | def service_update_secrets(self, service, secrets): 87 | spec = service.attrs['Spec'] 88 | container_spec = spec['TaskTemplate']['ContainerSpec'] 89 | container_spec['Secrets'] = secrets 90 | 91 | cmd = """curl -X POST -H "Content-Type: application/json" --unix-socket {socket} http:/1.25/services/{service_id}/update?version={version} -d '{data}'""".format( 92 | data=json.dumps(spec), socket=self.docker_socket_path, service_id=service.id, version=service.attrs['Version']['Index']) 93 | logger.debug('EXEC {}'.format(cmd)) 94 | code = os.system(cmd) 95 | 96 | def secret_create(self, secret_name, secret_data): 97 | 98 | secret_name = self.get_secret_name(secret_name) 99 | 100 | # create secret. 101 | logger.debug('creating secret {}'.format(secret_name)) 102 | secret = self.docker_client.secrets.create( 103 | name=secret_name, 104 | data=secret_data) 105 | logger.debug('secret created {}'.format(secret.id)) 106 | 107 | secret = self.docker_client.secrets.get(secret.id) 108 | return secret 109 | 110 | def generate_certificates(self, domains, email, testing=None): 111 | """ 112 | Generate or renew certificates for given domains 113 | 114 | :param testing: Issue testing / staging certificate 115 | :param domains: Domain names to generate certificates. Comma separated. 116 | :param email: Email used during letsencrypt process 117 | :type a: string 118 | :type b: string 119 | :return: List of freshly created certificates path 120 | :rtype: list of string 121 | """ 122 | certs = {} 123 | for domain in domains: 124 | certs[domain] = [] 125 | 126 | logger.debug('Generating certificates domains:{} email:{} testing:{}'.format(domains, email, testing)) 127 | error, created = self.certbot.update_cert(domains, email, testing) 128 | 129 | if error and not created: 130 | logger.error('Error while generating certs for {}'.format(domains)) 131 | 132 | elif not error and not created: 133 | logger.debug('nothing to do') 134 | certs = self.certs(domains) 135 | 136 | elif created: 137 | logger.info('certificates successfully created using certbot.') 138 | 139 | # if multiple domains comma separated, take only the first one 140 | base_domain = domains[0] 141 | 142 | # generate combined certificate needed for haproxy 143 | combined_path = os.path.join(self.certbot_folder, 'live', base_domain, "combined.pem") 144 | with open(combined_path, "w") as combined, \ 145 | open(os.path.join(self.certbot_folder, 'live', base_domain, "privkey.pem"), "r") as priv, \ 146 | open(os.path.join(self.certbot_folder, 'live', base_domain, "fullchain.pem"), "r") as fullchain: 147 | 148 | combined.write(fullchain.read()) 149 | combined.write(priv.read()) 150 | logger.info('combined certificate generated into "{}".'.format(combined_path)) 151 | 152 | # for each domain, create a symlink for main combined cert. 153 | for domain in domains: 154 | cert_type, cert_extension = combined_cert_type 155 | dest_file = os.path.join(self.certbot_folder, "{}.{}".format(domain, cert_extension)) 156 | 157 | if os.path.exists(dest_file): 158 | os.remove(dest_file) 159 | 160 | # generate symlinks 161 | os.symlink( 162 | os.path.join('./live', base_domain, "{}.pem".format(cert_type)), 163 | dest_file) 164 | 165 | certs[domain].append(dest_file) 166 | 167 | return certs, created 168 | 169 | def process(self, domains, email, version='1', testing=None): 170 | logger.info('Letsencrypt support enabled, processing request: domains={} email={} testing={}'.format(','.join(domains), email, testing)) 171 | 172 | certs, created = self.generate_certificates(domains, email, testing) 173 | 174 | secrets_changed = False 175 | if self.docker_client != None: 176 | self.dfp = self.services(self.dfp_service_name)[0] 177 | self.dfp_secrets = self.service_get_secrets(self.dfp) 178 | 179 | for domain, certs in certs.items(): 180 | 181 | combined = [x for x in certs if '.pem' in x] 182 | if len(combined) == 0: 183 | logger.error('Combined certificate not found. Check logs for errors.') 184 | # raise Exception to make a 500 response to dpf, and make it retry the request later. 185 | raise Exception('Combined cert not found') 186 | combined = combined[0] 187 | 188 | if self.docker_client == None: 189 | if created: 190 | # no docker client provided, use docker-flow-proxy PUT request to update certificate 191 | self.dfp_client.put( 192 | self.dfp_client.url( 193 | version, 194 | '/cert?certName={}&distribute=true'.format(os.path.basename(combined))), 195 | data=open(combined, 'rb').read(), 196 | headers={'Content-Type': 'application/octet-stream'}) 197 | logger.info('Request PUT /cert sucessfully send to DFP.') 198 | 199 | else: 200 | # docker engine is provided, manage certificates as docker secrets 201 | 202 | # check that there is an existing secret for the combined cert 203 | # secret_combined_found = any([x.name.startswith('{}.pem'.format(domain)[-self.size_secret:]) for x in self.secrets]) 204 | self._secrets = self.secrets('{}.pem'.format(domain)) 205 | secret_combined_found = False 206 | if len(self._secrets): 207 | secret = self._secrets[-1] 208 | logger.debug('combined secret for {} found : {} list: {}'.format(domain, secret, self._secrets)) 209 | secret_combined_found = True 210 | 211 | # check that an already existing secret for the combined cert is attached to dfp service. 212 | # secret_combined_attached = any([x['File']['Name'] == 'cert-{}'.format(domain) for x in self.secrets_dfp]) 213 | secret_combined_attached = any([x['File']['Name'] == 'cert-{}'.format(domain) for x in self.dfp_secrets]) 214 | 215 | logger.debug('cert_created={} secret_found={} secret_attached={}'.format(created, secret_combined_found, secret_combined_attached)) 216 | 217 | if created or not secret_combined_found: 218 | # create secret 219 | secret_cert = '{}.pem'.format(domain) 220 | logger.info('creating secret for cert {}'.format(secret_cert)) 221 | secret = self.secret_create( 222 | secret_cert, 223 | open(combined, 'rb').read()) 224 | self._secrets.append(secret) 225 | 226 | if created or not secret_combined_attached: 227 | # attach secret 228 | logger.info('attaching secret {}'.format(secret.name)) 229 | 230 | # remove secrets already attached to the dfp service that are for the same domain. 231 | self.dfp_secrets = [x for x in self.dfp_secrets if not x['SecretName'].startswith(domain)] 232 | 233 | # append the secret 234 | secrets_changed = True 235 | self.dfp_secrets.append({ 236 | 'SecretID': secret.id, 237 | 'SecretName': secret.name, 238 | 'File': { 239 | 'Name': 'cert-{}'.format(domain), 240 | 'UID': '0', 241 | 'GID': '0', 242 | 'Mode': 0} 243 | }) 244 | 245 | if secrets_changed: 246 | logger.debug('secrets changed, updating dfp service...') 247 | # I cannot understand how to use the service.update method, use a POST request against docker socket instead 248 | # see https://github.com/docker/docker-py/issues/1503 249 | # self.dfp.update(name=self.dfp.attrs['Spec']['Name'], networks=self.dfp.attrs['Spec']['Networks'], secrets=self.secrets_dfp) 250 | self.service_update_secrets(self.dfp, self.dfp_secrets) 251 | -------------------------------------------------------------------------------- /app/client_dfple_tests.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import os 3 | import shutil 4 | from mock import patch 5 | from unittest import TestCase 6 | 7 | from client_dfple import DFPLEClient 8 | 9 | import logging 10 | logging.basicConfig(level=logging.ERROR, format="%(levelname)s;%(asctime)s;%(message)s") 11 | logging.getLogger('letsencrypt').setLevel(logging.DEBUG) 12 | 13 | CERTBOT_OUTPUT = { 14 | 'null': """ 15 | ------------------------------------------------------------------------------- 16 | Certificate not yet due for renewal; no action taken. 17 | ------------------------------------------------------------------------------- 18 | """, 19 | 'ok': """ 20 | Saving debug log to /var/log/letsencrypt/letsencrypt.log 21 | Obtaining a new certificate 22 | Performing the following challenges: 23 | http-01 challenge for site.domain.com 24 | Using the webroot path /opt/www for all unmatched domains. 25 | Waiting for verification... 26 | Cleaning up challenges 27 | 28 | IMPORTANT NOTES: 29 | - Congratulations! Your certificate and chain have been saved at 30 | /etc/letsencrypt/live/site.domain.com/fullchain.pem. Your 31 | cert will expire on 2017-11-25. To obtain a new or tweaked version 32 | of this certificate in the future, simply run certbot again. To 33 | non-interactively renew *all* of your certificates, run "certbot 34 | renew" 35 | """ 36 | } 37 | 38 | 39 | class DFPLEClientTestCase(TestCase): 40 | 41 | def setUp(self): 42 | self.certbot_path = '/tmp' 43 | tmp_path = os.path.join(self.certbot_path, 'live') 44 | if os.path.exists(tmp_path): 45 | shutil.rmtree(tmp_path) 46 | 47 | def letsencrypt_mock(self, domains, output, error, code, tmp_files=None): 48 | 49 | if tmp_files is None: 50 | tmp_files = ['privkey.pem', 'fullchain.pem'] 51 | 52 | # create tmp directory 53 | self.tmp_dirs = domains 54 | for d in domains: 55 | base_path = os.path.join(self.certbot_path, 'live', d) 56 | os.makedirs(base_path) 57 | for x in tmp_files: 58 | open(os.path.join(base_path, x), 'a').close() 59 | 60 | return output, error, code 61 | 62 | 63 | class VolumeTestCase(DFPLEClientTestCase): 64 | 65 | def setUp(self): 66 | DFPLEClientTestCase.setUp(self) 67 | 68 | self.domains = ['site.domain.com'] 69 | self.email = 'email@domail.com' 70 | self.client_attrs = { 71 | 'certbot_path': self.certbot_path, 72 | 'certbot_challenge': 'http', 73 | 'certbot_webroot_path': '/tmp', 74 | 'domains': self.domains, 75 | 'email': self.email, 76 | } 77 | 78 | 79 | def test(self): 80 | """ 81 | initial context: 82 | * no certs, volume empty 83 | """ 84 | 85 | # create the client 86 | self.client = DFPLEClient(**self.client_attrs) 87 | 88 | # check certs do not exist 89 | certs = self.client.certs(self.domains) 90 | for d in self.domains: 91 | self.assertFalse(any(['{}.pem'.format(d) in x for x in certs[d]])) 92 | 93 | with patch.object(self.client.certbot, 'run', lambda cmd: self.letsencrypt_mock(self.domains, CERTBOT_OUTPUT['ok'], '', 0)), \ 94 | patch.object(self.client.dfp_client, 'put', lambda url, data=None, headers=None: None): 95 | self.client.process(self.domains, self.email) 96 | 97 | # check certs exist 98 | certs = self.client.certs(self.domains) 99 | for d in self.domains: 100 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 101 | 102 | def test_certbot_not_ok(self): 103 | """ 104 | initial context: 105 | * no certs, volume empty 106 | """ 107 | 108 | # create the client 109 | self.client = DFPLEClient(**self.client_attrs) 110 | 111 | # check certs do not exist 112 | certs = self.client.certs(self.domains) 113 | for d in self.domains: 114 | self.assertFalse(any(['{}.pem'.format(d) in x for x in certs[d]])) 115 | 116 | error_occured = False 117 | with patch.object(self.client.certbot, 'run', lambda cmd: self.letsencrypt_mock(self.domains, '', '', 1)), \ 118 | patch.object(self.client.dfp_client, 'put', lambda url, data=None, headers=None: None): 119 | 120 | try: 121 | self.client.process() 122 | except: 123 | error_occured = True 124 | 125 | self.assertTrue(error_occured) 126 | 127 | 128 | 129 | class SecretsTestCase(DFPLEClientTestCase): 130 | 131 | def setUp(self): 132 | DFPLEClientTestCase.setUp(self) 133 | 134 | self.domains = ['site.domain.com'] 135 | self.email = 'email@domail.com' 136 | self.client_attrs = { 137 | 'certbot_path': self.certbot_path, 138 | 'certbot_challenge': 'http', 139 | 'certbot_webroot_path': '/tmp', 140 | 'domains': self.domains, 141 | 'email': self.email, 142 | 'docker_client': docker.DockerClient(), 143 | } 144 | 145 | 146 | def test_secret(self): 147 | """ 148 | initial context: 149 | * no certs, no secrets 150 | """ 151 | mocked_data = { 152 | 'service_dfp': docker.models.services.Service( 153 | attrs={'Spec': {'Name': 'proxy', 'TaskTemplate': {'ContainerSpec': {'Image': '', 'Secrets': []}}, 'Networks': [],}} 154 | ), 155 | 'secret_created': docker.models.secrets.Secret(attrs={'Spec': {'Name': self.domains[0]}}), 156 | 'secrets_initial': [] 157 | } 158 | 159 | # create the client 160 | self.client = DFPLEClient(**self.client_attrs) 161 | 162 | with patch('client_dfple.DFPLEClient.secrets', return_value=mocked_data['secrets_initial']), \ 163 | patch('client_dfple.DFPLEClient.secret_create', return_value=mocked_data['secret_created']), \ 164 | patch('client_dfple.DFPLEClient.service_update_secrets', return_value=None), \ 165 | patch('client_dfple.DFPLEClient.services', return_value=[mocked_data['service_dfp']]), \ 166 | patch.object(self.client.certbot, 'run', lambda cmd: self.letsencrypt_mock(self.domains, CERTBOT_OUTPUT['ok'], '', 0)): 167 | 168 | certs = self.client.certs(self.domains) 169 | secrets = self.client.secrets() 170 | dfp_secrets = self.client.service_get_secrets(self.client.services(self.client.dfp_service_name)[0]) 171 | 172 | # check certs do not exist 173 | for d in self.domains: 174 | self.assertFalse(any(['{}.pem'.format(d) in x for x in certs[d]])) 175 | 176 | # check secrets not found and not attached 177 | for d in self.domains: 178 | self.assertFalse(any([d in x.name for x in secrets])) 179 | self.assertFalse(any([d == x['SecretName'] for x in dfp_secrets])) 180 | 181 | self.client.process(self.domains, self.email) 182 | 183 | # check certs exist 184 | certs = self.client.certs(self.domains) 185 | for d in self.domains: 186 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 187 | 188 | # check secrets found and attached 189 | for d in self.domains: 190 | self.assertTrue(any([d in x.name for x in self.client._secrets])) 191 | self.assertTrue(any([d == x['SecretName'] for x in self.client.dfp_secrets])) 192 | 193 | 194 | def test_secret_not_created_not_attached(self): 195 | """ 196 | initial context: 197 | * the whole process has already been done once 198 | * certs are already present in certbot volume 199 | * swarm has been destroyed and re-initialized 200 | """ 201 | mocked_data = { 202 | 'service_dfp': docker.models.services.Service( 203 | attrs={'Spec': {'Name': 'proxy', 'TaskTemplate': {'ContainerSpec': {'Image': '', 'Secrets': []}}, 'Networks': [],}} 204 | ), 205 | 'secret_created': docker.models.secrets.Secret(attrs={'Spec': {'Name': self.domains[0]}}), 206 | 'secrets_initial': [] 207 | } 208 | 209 | # create the client 210 | self.client = DFPLEClient(**self.client_attrs) 211 | 212 | with patch('client_dfple.DFPLEClient.secrets', return_value=mocked_data['secrets_initial']), \ 213 | patch('client_dfple.DFPLEClient.secret_create', return_value=mocked_data['secret_created']), \ 214 | patch('client_dfple.DFPLEClient.service_update_secrets', return_value=None), \ 215 | patch('client_dfple.DFPLEClient.services', return_value=[mocked_data['service_dfp']]): 216 | 217 | # initialize context - create certs files. 218 | self.letsencrypt_mock(self.domains, None, None, None, tmp_files=['privkey.pem', 'fullchain.pem', 'combined.pem']) 219 | 220 | certs = self.client.certs(self.domains) 221 | secrets = self.client.secrets() 222 | dfp_secrets = self.client.service_get_secrets(self.client.services(self.client.dfp_service_name)[0]) 223 | 224 | # check certs exist 225 | for d in self.domains: 226 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 227 | 228 | # check secrets not found and not attached 229 | for d in self.domains: 230 | self.assertFalse(any([d in x.name for x in secrets])) 231 | self.assertFalse(any([d == x['SecretName'] for x in dfp_secrets])) 232 | 233 | with patch.object(self.client.certbot, 'run', lambda cmd: self.letsencrypt_mock([], CERTBOT_OUTPUT['null'], '', 0)): 234 | self.client.process(self.domains, self.email) 235 | 236 | # check certs exist 237 | certs = self.client.certs(self.domains) 238 | for d in self.domains: 239 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 240 | 241 | # check secrets found and attached 242 | for d in self.domains: 243 | self.assertTrue(any([d in x.name for x in self.client._secrets]), '{} not found in {}'.format(d, [x.name for x in self.client._secrets])) 244 | self.assertTrue(any([d == x['SecretName'] for x in self.client.dfp_secrets])) 245 | 246 | 247 | def test_secret_created_not_attached(self): 248 | """ 249 | initial context: 250 | * certs are already present in certbot volume 251 | * secrets are existing in the swarm 252 | * proxy stack has been redeployed, secret needs to be attached 253 | """ 254 | mocked_data = { 255 | 'service_dfp': docker.models.services.Service( 256 | attrs={'Spec': {'Name': 'proxy', 'TaskTemplate': {'ContainerSpec': {'Image': '', 'Secrets': []}}, 'Networks': [],}} 257 | ), 258 | 'secrets_initial': [docker.models.secrets.Secret(attrs={'Spec': {'Name': '{}.pem'.format(self.domains[0]), 'File': {'Name': 'cert-{}'.format(self.domains[0])}}})] 259 | } 260 | 261 | # create the client 262 | self.client = DFPLEClient(**self.client_attrs) 263 | 264 | with patch('client_dfple.DFPLEClient.secrets', return_value=mocked_data['secrets_initial']), \ 265 | patch('client_dfple.DFPLEClient.service_update_secrets', return_value=None), \ 266 | patch('client_dfple.DFPLEClient.services', return_value=[mocked_data['service_dfp']]): 267 | 268 | # initialize context - create certs files. 269 | self.letsencrypt_mock(self.domains, None, None, None, tmp_files=['privkey.pem', 'fullchain.pem', 'combined.pem']) 270 | 271 | certs = self.client.certs(self.domains) 272 | secrets = self.client.secrets() 273 | dfp_secrets = self.client.service_get_secrets(self.client.services(self.client.dfp_service_name)[0]) 274 | 275 | # check certs exist 276 | for d in self.domains: 277 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 278 | 279 | # check secrets found and not attached 280 | for d in self.domains: 281 | self.assertTrue(any([d in x.name for x in secrets])) 282 | self.assertFalse(any([d == x['SecretName'] for x in dfp_secrets])) 283 | 284 | with patch.object(self.client.certbot, 'run', lambda cmd: self.letsencrypt_mock([], CERTBOT_OUTPUT['null'], '', 0)): 285 | self.client.process(self.domains, self.email) 286 | 287 | # check certs exist 288 | certs = self.client.certs(self.domains) 289 | for d in self.domains: 290 | self.assertTrue(any(['{}.pem'.format(d) in x for x in certs[d]])) 291 | 292 | # check secrets found and attached 293 | for d in self.domains: 294 | self.assertTrue(any([d in x.name for x in self.client._secrets])) 295 | print('ee', self.client.dfp_secrets) 296 | self.assertTrue(any(['{}.pem'.format(d) == x['SecretName'] for x in self.client.dfp_secrets])) -------------------------------------------------------------------------------- /app/hooks/ovh/manual-auth-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import os 3 | import ovh 4 | import time 5 | 6 | 7 | CERTBOT_DOMAIN = os.environ.get('CERTBOT_DOMAIN') 8 | CERTBOT_VALIDATION = os.environ.get('CERTBOT_VALIDATION') 9 | CERTBOT_TOKEN = os.environ.get('CERTBOT_TOKEN') 10 | 11 | OVH_DNS_ZONE = os.environ.get('OVH_DNS_ZONE') 12 | OVH_APPLICATION_KEY = os.environ.get('OVH_APPLICATION_KEY') 13 | OVH_APPLICATION_SECRET = os.environ.get('OVH_APPLICATION_SECRET') 14 | OVH_CONSUMER_KEY = os.environ.get('OVH_CONSUMER_KEY') 15 | 16 | client = ovh.Client( 17 | endpoint='ovh-eu', 18 | application_key=OVH_APPLICATION_KEY, 19 | application_secret=OVH_APPLICATION_SECRET, 20 | consumer_key=OVH_CONSUMER_KEY, 21 | ) 22 | 23 | # update the _acme-challenge. subdomain with a TXT record 24 | subdomain = '_acme-challenge.{}'.format(CERTBOT_DOMAIN.replace(OVH_DNS_ZONE, ''))[:-1] 25 | zone = client.post('/domain/zone/{}/record'.format(OVH_DNS_ZONE), fieldType='TXT', subDomain=subdomain, target=CERTBOT_VALIDATION) 26 | print "Record updated : {}".format(zone) 27 | 28 | response = client.post('/domain/zone/{}/refresh'.format(OVH_DNS_ZONE)) 29 | print "Zone refreshed : {}".format(response) 30 | 31 | # sleep to make sure the change has time to propagate over to DNS 32 | time.sleep(60) -------------------------------------------------------------------------------- /app/hooks/ovh/manual-auth-hook.sh: -------------------------------------------------------------------------------- 1 | DIR="$(dirname $0)" 2 | python $DIR/manual-auth-hook.py 3 | -------------------------------------------------------------------------------- /app/hooks/ovh/manual-cleanup-hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | import os 3 | import ovh 4 | 5 | 6 | CERTBOT_DOMAIN = os.environ.get('CERTBOT_DOMAIN') 7 | CERTBOT_VALIDATION = os.environ.get('CERTBOT_VALIDATION') 8 | CERTBOT_TOKEN = os.environ.get('CERTBOT_TOKEN') 9 | 10 | OVH_DNS_ZONE = os.environ.get('OVH_DNS_ZONE', 'nibor.me') 11 | OVH_APPLICATION_KEY = os.environ.get('OVH_APPLICATION_KEY') 12 | OVH_APPLICATION_SECRET = os.environ.get('OVH_APPLICATION_SECRET') 13 | OVH_CONSUMER_KEY = os.environ.get('OVH_CONSUMER_KEY') 14 | 15 | client = ovh.Client( 16 | endpoint='ovh-eu', 17 | application_key=OVH_APPLICATION_KEY, 18 | application_secret=OVH_APPLICATION_SECRET, 19 | consumer_key=OVH_CONSUMER_KEY, 20 | ) 21 | 22 | # update the _acme-challenge. subdomain with a TXT record 23 | subdomain = '_acme-challenge.{}'.format(CERTBOT_DOMAIN.replace(OVH_DNS_ZONE, ''))[:-1] 24 | zones = client.get('/domain/zone/{}/record'.format(OVH_DNS_ZONE), fieldType='TXT', subDomain=subdomain) 25 | print "zones", zones 26 | if len(zones) == 1: 27 | zone = zones[0] 28 | else: 29 | raise Exception('Could not find domain matching: {}'.format(CERTBOT_DOMAIN)) 30 | response = client.delete('/domain/zone/{}/record/{}'.format(OVH_DNS_ZONE, zone)) 31 | print "Record deleted : {}".format(response) 32 | 33 | response = client.post('/domain/zone/{}/refresh'.format(OVH_DNS_ZONE)) 34 | print "Zone refreshed : {}".format(response) 35 | -------------------------------------------------------------------------------- /app/hooks/ovh/manual-cleanup-hook.sh: -------------------------------------------------------------------------------- 1 | DIR="$(dirname $0)" 2 | python $DIR/manual-cleanup-hook.py -------------------------------------------------------------------------------- /certs.tar.enc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1b0r/docker-flow-proxy-letsencrypt/036af9190ff66ed8635b3df5603f54cabb6c74db/certs.tar.enc -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Labels 4 | 5 | | Name | Description | Default | 6 | |--------------------------------|:--------------------------------------------------------------------------------------:|----------:| 7 | | com.df.letsencrypt.host | Comma separated list of domains letsencrypt will generate/update certs for. | | 8 | | com.df.letsencrypt.email | Email used by letsencrypt when registering cert. | | 9 | | com.df.letsencrypt.testing | Enable/disable staging per service. Please see [#13](https://github.com/n1b0r/docker-flow-proxy-letsencrypt/pull/13) | | 10 | 11 | 12 | ## Environment variables 13 | 14 | | Name | Description | Default | 15 | |--------------------------------|:--------------------------------------------------------------------------------------:|----------:| 16 | | CERTBOT_OPTIONS | Custom options added to certbot command line (example: --staging) | | 17 | | CERTBOT_CHALLENGE | Specify the challenge to use. `http` or `dns` | http | 18 | | CERTBOT_MANUAL_AUTH_HOOK | Manual auth script to register DNS subdomains. **Required** with `dns` challenge | | 19 | | CERTBOT_MANUAL_CLEANUP_HOOK | Manual cleanup script to clean DNS subdomains. **Required** with `dns` challenge | | 20 | | DF_PROXY_SERVICE_NAME | Name of the docker-flow-proxy service (either SERVICE-NAME or STACK-NAME_SERVICE-NAME).| proxy | 21 | | DF_SWARM_LISTENER_SERVICE_NAME | Name of the docker-flow-proxy service. Used to force cert renewal. | swarm-listener | 22 | | DOCKER_SOCKET_PATH | Path to the docker socket. Required for docker secrets support. | /var/run/docker.sock | 23 | | LETSENCRYPT_RENEWAL_CRON | Define cron timing for cert renewal | 30 2 * * * | 24 | | LOG | Logging level (debug, info, warning, error) | info | 25 | | OVH_DNS_ZONE | OVH DNS domain zone to use when using OVH API. **Required** when using OVH dns provider with `dns` challenge. | | 26 | | OVH_APPLICATION_KEY | OVH application key to use when using OVH API. **Required** when using OVH dns provider with `dns` challenge. | | 27 | | OVH_APPLICATION_SECRET | OVH application secret to use when using OVH API. **Required** when using OVH dns provider with `dns` challenge. | | 28 | | OVH_CONSUMER_KEY | OVH consumer key to use when using OVH API. **Required** when using OVH dns provider with `dns` challenge. | | 29 | | RETRY | Number of forward request retries | 10 | 30 | | RETRY_INTERVAL | Interval (seconds) between forward request retries | 5 | 31 | -------------------------------------------------------------------------------- /docs/example-secrets.md: -------------------------------------------------------------------------------- 1 | # Using secrets 2 | 3 | ## proxy stack 4 | 5 | `docker-flow-proxy-letsencrypt` can store certificates into docker secrets. 6 | 7 | Each time `docker-flow-proxy` will regenerated its config, it will scan for attached secrets and reload its config. 8 | 9 | Create the `proxy` network. 10 | 11 | ``` 12 | docker network create -d overlay proxy 13 | ``` 14 | 15 | ``` 16 | version: "3" 17 | services: 18 | 19 | proxy: 20 | image: vfarcic/docker-flow-proxy 21 | ports: 22 | - 80:80 23 | - 443:443 24 | networks: 25 | - proxy 26 | environment: 27 | - LISTENER_ADDRESS=swarm-listener 28 | - MODE=swarm 29 | - SERVICE_NAME=proxy_proxy 30 | deploy: 31 | replicas: 1 32 | 33 | swarm-listener: 34 | image: vfarcic/docker-flow-swarm-listener 35 | networks: 36 | - proxy 37 | volumes: 38 | - /var/run/docker.sock:/var/run/docker.sock 39 | environment: 40 | - DF_NOTIFY_CREATE_SERVICE_URL=http://proxy-le:8080/v1/docker-flow-proxy-letsencrypt/reconfigure 41 | - DF_NOTIFY_REMOVE_SERVICE_URL=http://proxy_proxy:8080/v1/docker-flow-proxy/remove 42 | deploy: 43 | placement: 44 | constraints: [node.role == manager] 45 | 46 | proxy-le: 47 | image: nib0r/docker-flow-proxy-letsencrypt 48 | networks: 49 | - proxy 50 | environment: 51 | - DF_PROXY_SERVICE_NAME=proxy_proxy 52 | # - LOG=debug 53 | # - CERTBOT_OPTIONS=--staging 54 | volumes: 55 | # link docker socket to activate secrets support. 56 | - /var/run/docker.sock:/var/run/docker.sock 57 | # create a dedicated volume for letsencrypt folder. 58 | # MANDATORY to keep persistent certificates on DFPLE. 59 | # Without this volume, certificates will be regenerated every time DFPLE is recreated. 60 | # OPTIONALY you will be able to link this volume to another service that also needs certificates (gitlab/gitlab-ce for example) 61 | - le-certs:/etc/letsencrypt 62 | deploy: 63 | replicas: 1 64 | labels: 65 | - com.df.notify=true 66 | - com.df.distribute=true 67 | - com.df.servicePath=/.well-known/acme-challenge 68 | - com.df.port=8080 69 | networks: 70 | proxy: 71 | external: true 72 | volumes: 73 | le-certs: 74 | external: true 75 | 76 | ``` 77 | 78 | ## service stack 79 | 80 | ``` 81 | version: "3" 82 | services: 83 | whoami: 84 | image: jwilder/whoami 85 | networks: 86 | - proxy 87 | deploy: 88 | replicas: 1 89 | labels: 90 | - com.df.notify=true 91 | - com.df.distribute=true 92 | - com.df.serviceDomain=domain.com 93 | - com.df.servicePath=/ 94 | - com.df.srcPort=443 95 | - com.df.port=8000 96 | - com.df.letsencrypt.host=domain.com 97 | - com.df.letsencrypt.email=email@domain.com 98 | networks: 99 | proxy: 100 | external: true 101 | ``` 102 | -------------------------------------------------------------------------------- /docs/example-volumes.md: -------------------------------------------------------------------------------- 1 | # Using volumes 2 | 3 | ## proxy stack 4 | 5 | This is the simplest method to keep persitent certificates on `docker-flow-proxy` side. 6 | 7 | Each time DFP is recreated, it will scan the **/certs** directory for certificates and regenerated its config. 8 | 9 | Create the `proxy` network. 10 | 11 | ``` 12 | docker network create -d overlay proxy 13 | ``` 14 | 15 | ``` 16 | version: "3" 17 | services: 18 | 19 | proxy: 20 | image: vfarcic/docker-flow-proxy 21 | ports: 22 | - 80:80 23 | - 443:443 24 | volumes: 25 | # create a dedicated volumes for dfp /certs folder. 26 | # certificates stored in this folder will be automatically loaded during proxy start. 27 | - dfp-certs:/certs 28 | networks: 29 | - proxy 30 | environment: 31 | - LISTENER_ADDRESS=swarm-listener 32 | - MODE=swarm 33 | - SERVICE_NAME=proxy_proxy 34 | deploy: 35 | replicas: 1 36 | 37 | swarm-listener: 38 | image: vfarcic/docker-flow-swarm-listener 39 | networks: 40 | - proxy 41 | volumes: 42 | - /var/run/docker.sock:/var/run/docker.sock 43 | environment: 44 | - DF_NOTIFY_CREATE_SERVICE_URL=http://proxy-le:8080/v1/docker-flow-proxy-letsencrypt/reconfigure 45 | - DF_NOTIFY_REMOVE_SERVICE_URL=http://proxy_proxy:8080/v1/docker-flow-proxy/remove 46 | deploy: 47 | placement: 48 | constraints: [node.role == manager] 49 | 50 | proxy-le: 51 | image: nib0r/docker-flow-proxy-letsencrypt 52 | networks: 53 | - proxy 54 | environment: 55 | - DF_PROXY_SERVICE_NAME=proxy_proxy 56 | # - LOG=debug 57 | # - CERTBOT_OPTIONS=--staging 58 | volumes: 59 | # create a dedicated volume for letsencrypt folder. 60 | # MANDATORY to keep persistent certificates on DFPLE. 61 | # Without this volume, certificates will be regenerated every time DFPLE is recreated. 62 | # OPTIONALY you will be able to link this volume to another service that also needs certificates (gitlab/gitlab-ce for example) 63 | - le-certs:/etc/letsencrypt 64 | deploy: 65 | replicas: 1 66 | labels: 67 | - com.df.notify=true 68 | - com.df.distribute=true 69 | - com.df.servicePath=/.well-known/acme-challenge 70 | - com.df.port=8080 71 | networks: 72 | proxy: 73 | external: true 74 | volumes: 75 | le-certs: 76 | external: true 77 | dfp-certs: 78 | external: true 79 | 80 | ``` 81 | 82 | ## service stack 83 | 84 | ``` 85 | version: "3" 86 | services: 87 | whoami: 88 | image: jwilder/whoami 89 | networks: 90 | - proxy 91 | deploy: 92 | replicas: 1 93 | labels: 94 | - com.df.notify=true 95 | - com.df.distribute=true 96 | - com.df.serviceDomain=domain.com 97 | - com.df.servicePath=/ 98 | - com.df.srcPort=443 99 | - com.df.port=8000 100 | - com.df.letsencrypt.host=domain.com 101 | - com.df.letsencrypt.email=email@domain.com 102 | networks: 103 | proxy: 104 | external: true 105 | ``` 106 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Docker Flow Proxy Letsencrypt 2 | 3 | [![Build Status](https://travis-ci.org/n1b0r/docker-flow-proxy-letsencrypt.svg?branch=master)](https://travis-ci.org/n1b0r/docker-flow-proxy-letsencrypt) 4 | 5 | `docker-flow-proxy-letsencrypt` is a `docker-flow-proxy` companion that automatically create and renew certificates for your swarm services using [letsencrypt](https://letsencrypt.org/). 6 | 7 | Join the #df-letsencrypt Slack channel in [DevOps20](http://slack.devops20toolkit.com/) and ping me (@nibor) if you have any questions, suggestions, or problems. 8 | 9 | ## Concept 10 | 11 | The mecanism is mostly inspired by the [JrCs/docker-letsencrypt-nginx-proxy-companion](https://github.com/JrCs/docker-letsencrypt-nginx-proxy-companion) designed for [jwilder/nginx-proxy](https://github.com/jwilder/nginx-proxy). 12 | 13 | 14 | Normally (without using DFPLE) when a new service is created in the swarm, the `docker-flow-swarm-listener` (DFSL) will send a **notify** request to the `docker-flow-proxy` (DFP), which reloads based on the new config. 15 | 16 | ``` 17 | DFSL -----notify-----> DFP 18 | ``` 19 | 20 | `docker-flow-proxy-letsencrypt` (DFPLE) acts as a man-in-the-middle service which gets **notify** requests from DFSL, process its work, and then forward original request to DFP. 21 | 22 | ``` 23 | DFSL -----notify-----> DFPLE -----notify-----> DFP 24 | ``` 25 | 26 | Its work consists in : 27 | 28 | * check if letsencrypt support is enabled by the service (check service labels) 29 | * generate or renew certificates using certbot utility (if support enabled) 30 | * distribute certificates to DFP (if support enabled) 31 | * forward request to dfp 32 | 33 | 34 | ## Get started 35 | 36 | Check the [tutorial](tutorial-volumes.md) to get started with `docker-flow-proxy-letsencrypt` environment. 37 | 38 | Please note that in order to keep persistent certificates : 39 | 40 | * on DFPLE side : you will need to use a volume on `/etc/letsencrypt`, 41 | * on DFP side : you will have to either use a volume (see example [using volumes](example-volumes.md)) or store certificates as secrets (see example [using secrets](example-secrets.md)) 42 | 43 | You can use both `dns` and `http` letsencrypt ACME challenges (see [configuration](config.md)). 44 | 45 | Automatic renewal is performed at fixed interval. By default renewal process is performed once per day at 2.30 am. You can specify your own interval using `LETSENCRYPT_RENEWAL_CRON` env var on DFLE (see [configuration](config.md)). 46 | -------------------------------------------------------------------------------- /docs/tutorial-challenge-dns.md: -------------------------------------------------------------------------------- 1 | # Using letsencrypt DNS challenge 2 | 3 | Please check the certbot [documentation related to manual scripts](https://certbot.eff.org/docs/using.html#pre-and-post-validation-hooks) 4 | 5 | In this example we are using the OVH hooks. You could also provide your own manual scripts. 6 | 7 | ``` 8 | docker service create --name proxy_proxy-le \ 9 | --network proxy \ 10 | -e DF_PROXY_SERVICE_NAME=proxy_proxy \ 11 | -e CERTBOT_OPTIONS=--staging \ 12 | -e CERTBOT_CHALLENGE=dns \ 13 | -e CERTBOT_MANUAL_AUTH_HOOK=/app/hooks/ovh/manual-auth-hook.sh \ 14 | -e CERTBOT_MANUAL_CLEANUP_HOOK=/app/hooks/ovh/manual-cleanup-hook.sh \ 15 | -e OVH_DNS_ZONE=XXXXXX \ 16 | -e OVH_APPLICATION_KEY=XXXXXX \ 17 | -e OVH_APPLICATION_SECRET=XXXXXX \ 18 | -e OVH_CONSUMER_KEY=XXXXXX \ 19 | --mount "type=volume,source=le-certs,destination=/etc/letsencrypt" \ 20 | nib0r/docker-flow-proxy-letsencrypt 21 | ``` 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /docs/tutorial-volumes.md: -------------------------------------------------------------------------------- 1 | # Automatic LetsEncrypt support for docker-flow-proxy 2 | 3 | ## Setup 4 | 5 | You need a swarm setup with docker 1.12+ (docker 1.13+ for secret support). 6 | 7 | ``` 8 | # create the proxy overlay network 9 | docker network create --driver overlay proxy 10 | 11 | # create the le-cert volume to store generated certs. 12 | docker volume create [--driver ] le-certs 13 | ``` 14 | 15 | 16 | !!! info 17 | You also need to setup your DNS provider to redirect DNS hostnames to the correct hosts. In this example we will use **.example.com** domain name. 18 | 19 | ## The stack 20 | 21 | ### docker-flow-proxy-letsencrypt 22 | 23 | Let's start creating the `docker-flow-proxy-letsencrypt` service. 24 | 25 | ``` 26 | docker service create --name proxy_proxy-le \ 27 | --network proxy \ 28 | -e DF_PROXY_SERVICE_NAME=proxy_proxy \ 29 | -e CERTBOT_OPTIONS=--staging \ 30 | --mount "type=volume,source=le-certs,destination=/etc/letsencrypt" \ 31 | --label com.df.notify=true \ 32 | --label com.df.distribute=true \ 33 | --label com.df.servicePath=/.well-known/acme-challenge \ 34 | --label com.df.port=8080 \ 35 | nib0r/docker-flow-proxy-letsencrypt 36 | ``` 37 | 38 | This will create and start the `docker-flow-proxy-letsencrypt` service in the **proxy** network, using the **le-certs** volume. 39 | 40 | This service will be registered as a proxied service by `docker-flow-proxy` using labels **com.df.notify**, **com.df.distribute**, **com.df.servicePath**, **com.df.port**. This allows `docker-flow-proxy-letsencrypt` to answer to the ACME challenge performed by letsencrypt. 41 | 42 | !!! info 43 | During this tutorial, we recommend you to use the **CERTBOT_OPTION=--staging** environment variable that will use the staging api of letsencrypt. You will not hit rate limits problems in case your something go wrong (DFPLE side or your services). This will generate untrusted certificate. When everything is correctly working, remove the environment variable and real certificates will be generated. 44 | 45 | 46 | ### docker-flow-proxy stack 47 | 48 | 49 | Create a volume for **/certs** folder which contains all certificates registered by `docker-flow-proxy`. This will enable persistent certificates on DFP side. 50 | 51 | ``` 52 | docker volume create [--driver ] dfp-certs 53 | ``` 54 | 55 | Start `docker-flow-proxy` service. 56 | 57 | ``` 58 | docker service create --name proxy_proxy \ 59 | -p 80:80 \ 60 | -p 443:443 \ 61 | --network proxy \ 62 | -e MODE=swarm \ 63 | -e LISTENER_ADDRESS=proxy_swarm-listener \ 64 | -e SERVICE_NAME=proxy_proxy \ 65 | --mount "type=volume,source=dfp-certs,destination=/certs" \ 66 | vfarcic/docker-flow-proxy 67 | ``` 68 | 69 | Then `docker-flow-swarm-listener` service. 70 | 71 | ``` 72 | docker service create --name proxy_swarm-listener \ 73 | --network proxy \ 74 | -e MODE=swarm \ 75 | -e DF_NOTIFY_CREATE_SERVICE_URL=http://proxy_proxy-le:8080/v1/docker-flow-proxy-letsencrypt/reconfigure \ 76 | --mount "type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock" \ 77 | --constraint 'node.role == manager' \ 78 | vfarcic/docker-flow-swarm-listener 79 | ``` 80 | 81 | The relevant information here are that : 82 | 83 | * we plug the **dfp-certs** volume on `docker-flow-proxy` to keep persistent certificates on DFP side (in case the service is recreated), 84 | * we trick the `docker-flow-swarm-listener` environment variable **DF_NOTIFY_CREATE_SERVICE_URL** to notify the `docker-flow-proxy-letsencrypt` when a new service is created. The DFPLE service will generate certificated if needed and then forward the request to `docker-flow-proxy` to get back in the standard flow. 85 | 86 | 87 | ### services 88 | 89 | Now you are ready to play with the proxy stack with automatic letsencrypt support ! 90 | 91 | You need to set deployment labels to enable let's encrypt support for each proxied services: 92 | 93 | * com.df.letsencrypt.host 94 | * com.df.letsencrypt.email 95 | 96 | **com.df.letsencrypt.host** generally match the **com.df.serviceDomain** label. 97 | 98 | Let's start a test service `jwilder/whoami` : web server listening on port 8000 answering GET requests with docker hostname. 99 | 100 | ``` 101 | docker service create --name whoami \ 102 | --network proxy \ 103 | --label com.df.notify=true \ 104 | --label com.df.distribute=true \ 105 | --label com.df.serviceDomain=whoami.example.com \ 106 | --label com.df.servicePath=/ \ 107 | --label com.df.srcPort=443 \ 108 | --label com.df.port=8000 \ 109 | --label com.df.letsencrypt.host=whoami.example.com \ 110 | --label com.df.letsencrypt.email=me@example.com \ 111 | jwilder/whoami 112 | ``` 113 | 114 | You should now be able to securely access your service 115 | 116 | ``` 117 | curl -k https://whoami.example.com 118 | I'm 49d7577396e7 119 | ``` 120 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # crond configuration 4 | echo "${LETSENCRYPT_RENEWAL_CRON} curl http://${DF_SWARM_LISTENER_SERVICE_NAME}:8080/v1/docker-flow-swarm-listener/notify-services" >> /etc/crontabs/root 5 | 6 | crond -L /var/log/crond.log && tail -f /var/log/crond.log & 7 | 8 | exec "$@" -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: docker-flow-proxy-letsencrypt 2 | pages: 3 | - Home: index.md 4 | - Tutorials: 5 | - Using volumes: tutorial-volumes.md 6 | - Using DNS challenge: tutorial-challenge-dns.md 7 | - Configuration: config.md 8 | - Examples: 9 | - Using volumes: example-volumes.md 10 | - Using secrets: example-secrets.md 11 | 12 | repo_url: https://github.com/n1b0r/docker-flow-proxy-letsencrypt 13 | site_author: Robin Lucbernet 14 | copyright: Copyright © 2017 Robin Lucbernet 15 | strict: true 16 | theme: 'material' 17 | extra: 18 | palette: 19 | primary: 'blue' 20 | accent: 'light blue' 21 | markdown_extensions: 22 | - toc 23 | - admonition 24 | - codehilite(guess_lang=false) 25 | - toc(permalink=true) 26 | - footnotes 27 | - pymdownx.arithmatex 28 | - pymdownx.betterem(smart_enable=all) 29 | - pymdownx.caret 30 | - pymdownx.critic 31 | - pymdownx.emoji: 32 | emoji_generator: !!python/name:pymdownx.emoji.to_svg 33 | - pymdownx.inlinehilite 34 | - pymdownx.magiclink 35 | - pymdownx.mark 36 | - pymdownx.smartsymbols 37 | - pymdownx.superfences 38 | - pymdownx.tasklist(custom_checkbox=true) 39 | - pymdownx.tilde -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | docker 2 | pytest 3 | requests 4 | 5 | mock -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docker 2 | flask 3 | ovh 4 | requests -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import docker 2 | import os 3 | import time 4 | import requests 5 | import subprocess 6 | 7 | from unittest import TestCase 8 | 9 | class DFPLETestCase(TestCase): 10 | """ 11 | """ 12 | 13 | def setUp(self): 14 | """ 15 | Setup the needed environment: 16 | * DFP 17 | * DFSL 18 | """ 19 | 20 | time.sleep(10) 21 | self.test_name = os.environ.get('CI_BUILD_REF_SLUG', os.environ.get('CI_COMMIT_REF_SLUG', 'test')) 22 | self.proxy_le_service_name = 'proxy_le_{}'.format(self.test_name) 23 | 24 | self.docker_client = docker.from_env() 25 | self.base_domain = 'b.dfple.nibor.me' 26 | 27 | try: 28 | self.docker_client.swarm.init() 29 | except docker.errors.APIError: 30 | pass 31 | 32 | # docker network 33 | self.network_name = "test-network-dfple" 34 | self.network = self.docker_client.networks.create(name=self.network_name, driver='overlay') 35 | 36 | # docker-flow-proxy service 37 | # dfp_image = self.docker_client.images.pull('vfarcic/docker-flow-proxy') 38 | dfp_service = { 39 | 'name': 'proxy_{}'.format(self.test_name), 40 | 'image': 'vfarcic/docker-flow-proxy', 41 | 'constraints': [], 42 | 'endpoint_spec': docker.types.EndpointSpec( 43 | ports={80: 80, 443: 443, 8080: 8080}), 44 | 'env': [ 45 | "LISTENER_ADDRESS=swarm_listener_{}".format(self.test_name), 46 | "MODE=swarm", 47 | "DEBUG=true", 48 | "SERVICE_NAME=proxy_{}".format(self.test_name) ], 49 | 'networks': [self.network_name] 50 | } 51 | 52 | # docker-flow-swarm-listener service 53 | # dfsl_image = self.docker_client.images.pull('vfarcic/docker-flow-swarm-listener') 54 | dfsl_service = { 55 | 'name': 'swarm_listener_{}'.format(self.test_name), 56 | 'image': 'vfarcic/docker-flow-swarm-listener', 57 | 'constraints': ["node.role == manager"], 58 | 'endpoint_spec': docker.types.EndpointSpec( 59 | ports={8081: 8080}), 60 | 'env': [ 61 | "DF_NOTIFY_CREATE_SERVICE_URL=http://proxy_le_{}:8080/v1/docker-flow-proxy-letsencrypt/reconfigure".format(self.test_name), 62 | "DF_NOTIFY_REMOVE_SERVICE_URL=http://proxy_{}:8080/v1/docker-flow-proxy/remove".format(self.test_name)], 63 | 'mounts': ['/var/run/docker.sock:/var/run/docker.sock:rw'], 64 | 'networks': [self.network_name] 65 | } 66 | 67 | # start services 68 | self.services = [] 69 | 70 | self.dfp_service = self.docker_client.services.create(**dfp_service) 71 | self.services.append(self.dfp_service) 72 | 73 | self.dfsl_service = self.docker_client.services.create(**dfsl_service) 74 | self.services.append(self.dfsl_service) 75 | 76 | 77 | def tearDown(self): 78 | 79 | for service in self.services: 80 | service.remove() 81 | 82 | self.network.remove() 83 | 84 | def get_conf(self): 85 | try: 86 | return requests.get('http://{}:8080/v1/docker-flow-proxy/config'.format(self.base_domain), timeout=3).text 87 | except Exception, e: 88 | print('Error while getting config on {}: {}'.format(self.base_domain, e)) 89 | return False 90 | 91 | def wait_until_found_in_config(self, texts, timeout=300): 92 | # print('WAITING FOR', text) 93 | 94 | _start = time.time() 95 | _current = time.time() 96 | 97 | print '-------------' 98 | print 'Searching in config for', '\n\t -{}'.format('\n\t -'.join(texts)) 99 | 100 | while _current < _start + timeout: 101 | 102 | config = self.get_conf() 103 | if config: 104 | if all([t in config for t in texts]): 105 | return True 106 | 107 | time.sleep(1) 108 | _current = time.time() 109 | 110 | print(self.get_conf()) 111 | self.get_service_logs(self.proxy_le_service_name) 112 | 113 | 114 | return False 115 | 116 | 117 | def get_service_logs(self, name): 118 | """ 119 | docker-py currently do not support getting service logs. 120 | """ 121 | cmd = """curl -X GET https://{host}/services/{service_id}/logs?stdout=true&stderr=true --cert {cert} --key {key} --cacert {cacert}""".format( 122 | host=os.environ.get('DOCKER_HOST').split('//')[1], 123 | service_id=name, 124 | cert=os.path.join(os.environ.get('DOCKER_CERT_PATH'), 'cert.pem'), 125 | key=os.path.join(os.environ.get('DOCKER_CERT_PATH'), 'key.pem'), 126 | cacert=os.path.join(os.environ.get('DOCKER_CERT_PATH'), 'ca.pem')) 127 | 128 | print('executing cmd', cmd) 129 | cmd = cmd.split(' ') 130 | print('executing cmd', cmd) 131 | proc = subprocess.Popen(cmd, 132 | stdout=subprocess.PIPE, 133 | stderr=subprocess.PIPE) 134 | (out, err) = proc.communicate() 135 | print "output:", out, err 136 | 137 | 138 | class Scenario(): 139 | 140 | def test_basic(self): 141 | 142 | # start the testing service 143 | test_service = { 144 | 'name': 'test_service_{}'.format(self.test_name), 145 | 'image': 'jwilder/whoami', 146 | 'labels': { 147 | "com.df.notify": "true", 148 | "com.df.distribute": "true", 149 | "com.df.serviceDomain": "{0}.{1},{0}2.{1}".format(self.test_name, self.base_domain), 150 | "com.df.letsencrypt.host": "{0}.{1},{0}2.{1}".format(self.test_name, self.base_domain), 151 | "com.df.letsencrypt.email": "test@test.com", 152 | "com.df.servicePath": "/", 153 | "com.df.srcPort": "443", 154 | "com.df.port": "8000", 155 | }, 156 | 'networks': [self.network_name] 157 | } 158 | service = self.docker_client.services.create(**test_service) 159 | self.services.append(service) 160 | 161 | # wait until service has registered routes 162 | self.assertTrue( 163 | self.wait_until_found_in_config(['test_service_{}'.format(self.test_name)]), 164 | "test service not registered.") 165 | 166 | # check certs are used 167 | certs_path = "/run/secrets/cert-" 168 | ext = '' 169 | if isinstance(self, DFPLEOriginal): 170 | certs_path = "/certs/" 171 | ext = '.pem' 172 | 173 | m = 'ssl crt-list /cfg/crt-list.txt' 174 | self.assertTrue(self.wait_until_found_in_config([m])) 175 | 176 | 177 | class DFPLEOriginal(DFPLETestCase, Scenario): 178 | 179 | 180 | def setUp(self): 181 | 182 | super(DFPLEOriginal, self).setUp() 183 | 184 | # docker-flow-proxy-letsencrypt service 185 | dfple_image = self.docker_client.images.build( 186 | path=os.path.dirname(os.path.abspath(__file__)), 187 | tag='robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 188 | quiet=False) 189 | dfple_service = { 190 | 'name': self.proxy_le_service_name, 191 | 'image': 'robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 192 | 'constraints': ["node.role == manager"], 193 | 'env': [ 194 | "DF_PROXY_SERVICE_NAME=proxy_{}".format(self.test_name), 195 | "DF_SWARM_LISTENER_SERVICE_NAME=swarm_listener_{}".format(self.test_name), 196 | "CERTBOT_OPTIONS=--staging", 197 | "LOG=debug", 198 | ], 199 | 'labels': { 200 | "com.df.notify": "true", 201 | "com.df.distribute": "true", 202 | "com.df.servicePath": "/.well-known/acme-challenge", 203 | "com.df.port": "8080", 204 | }, 205 | 'networks': [self.network_name] 206 | } 207 | 208 | self.dfple_service = self.docker_client.services.create(**dfple_service) 209 | self.services.append(self.dfple_service) 210 | 211 | # wait until proxy_le service has registered routes 212 | proxy_le_present_in_config = self.wait_until_found_in_config([self.proxy_le_service_name]) 213 | if not proxy_le_present_in_config: 214 | self.get_service_logs(self.proxy_le_service_name) 215 | 216 | self.assertTrue(proxy_le_present_in_config, 217 | "docker-flow-proxy-letsencrypt service not registered.") 218 | 219 | 220 | class DFPLEChallengeDNS(DFPLETestCase, Scenario): 221 | 222 | 223 | def setUp(self): 224 | 225 | super(DFPLEChallengeDNS, self).setUp() 226 | 227 | # docker-flow-proxy-letsencrypt service 228 | dfple_image = self.docker_client.images.build( 229 | path=os.path.dirname(os.path.abspath(__file__)), 230 | tag='robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 231 | quiet=False) 232 | dfple_service = { 233 | 'name': self.proxy_le_service_name, 234 | 'image': 'robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 235 | 'constraints': ["node.role == manager"], 236 | 'env': [ 237 | "DF_PROXY_SERVICE_NAME=proxy_{}".format(self.test_name), 238 | "DF_SWARM_LISTENER_SERVICE_NAME=swarm_listener_{}".format(self.test_name), 239 | "CERTBOT_OPTIONS=--staging", 240 | "LOG=debug", 241 | "CERTBOT_CHALLENGE=dns", 242 | "CERTBOT_MANUAL_AUTH_HOOK=/app/hooks/ovh/manual-auth-hook.sh", 243 | "CERTBOT_MANUAL_CLEANUP_HOOK=/app/hooks/ovh/manual-cleanup-hook.sh", 244 | "OVH_DNS_ZONE=nibor.me", 245 | "OVH_APPLICATION_KEY={}".format(os.environ.get('OVH_APPLICATION_KEY')), 246 | "OVH_APPLICATION_SECRET={}".format(os.environ.get('OVH_APPLICATION_SECRET')), 247 | "OVH_CONSUMER_KEY={}".format(os.environ.get('OVH_CONSUMER_KEY')), 248 | ], 249 | 'networks': [self.network_name] 250 | } 251 | 252 | self.dfple_service = self.docker_client.services.create(**dfple_service) 253 | self.services.append(self.dfple_service) 254 | 255 | 256 | class DFPLESecret(DFPLETestCase, Scenario): 257 | 258 | 259 | def setUp(self): 260 | 261 | super(DFPLESecret, self).setUp() 262 | 263 | # docker-flow-proxy-letsencrypt service 264 | dfple_image = self.docker_client.images.build( 265 | path=os.path.dirname(os.path.abspath(__file__)), 266 | tag='robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 267 | quiet=False) 268 | dfple_service = { 269 | 'name': self.proxy_le_service_name, 270 | 'image': 'robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 271 | 'constraints': ["node.role == manager"], 272 | 'env': [ 273 | "DF_PROXY_SERVICE_NAME=proxy_{}".format(self.test_name), 274 | "DF_SWARM_LISTENER_SERVICE_NAME=swarm_listener_{}".format(self.test_name), 275 | "CERTBOT_OPTIONS=--staging", 276 | "LOG=debug", 277 | ], 278 | 'labels': { 279 | "com.df.notify": "true", 280 | "com.df.distribute": "true", 281 | "com.df.servicePath": "/.well-known/acme-challenge", 282 | "com.df.port": "8080", 283 | }, 284 | 'networks': [self.network_name], 285 | 'mounts': ['/var/run/docker.sock:/var/run/docker.sock:rw'], 286 | } 287 | 288 | self.dfple_service = self.docker_client.services.create(**dfple_service) 289 | self.services.append(self.dfple_service) 290 | 291 | # wait until proxy_le service has registered routes 292 | proxy_le_present_in_config = self.wait_until_found_in_config([self.proxy_le_service_name]) 293 | if not proxy_le_present_in_config: 294 | self.get_service_logs(self.proxy_le_service_name) 295 | 296 | self.assertTrue( 297 | proxy_le_present_in_config, 298 | "docker-flow-proxy-letsencrypt service not registered.") 299 | 300 | def test_basic(self): 301 | 302 | super(DFPLESecret, self).test_basic() 303 | 304 | # check secrets 305 | service = self.docker_client.services.get(self.dfp_service.id) 306 | secret_aliases = [x['File']['Name'] for x in service.attrs['Spec']['TaskTemplate']['ContainerSpec']['Secrets']] 307 | self.assertIn('cert-{}.{}'.format(self.test_name, self.base_domain), secret_aliases) 308 | self.assertIn('cert-{}2.{}'.format(self.test_name, self.base_domain), secret_aliases) 309 | 310 | 311 | class DFPLEUpdate(DFPLETestCase, Scenario): 312 | 313 | 314 | def setUp(self): 315 | 316 | super(DFPLEUpdate, self).setUp() 317 | 318 | # docker-flow-proxy-letsencrypt service 319 | dfple_image = self.docker_client.images.build( 320 | path=os.path.dirname(os.path.abspath(__file__)), 321 | tag='robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 322 | quiet=False) 323 | dfple_service = { 324 | 'name': self.proxy_le_service_name, 325 | 'image': 'robin/docker-flow-proxy-letsencrypt:{}'.format(self.test_name), 326 | 'constraints': ["node.role == manager"], 327 | 'env': [ 328 | "DF_PROXY_SERVICE_NAME=proxy_{}".format(self.test_name), 329 | "DF_SWARM_LISTENER_SERVICE_NAME=swarm_listener_{}".format(self.test_name), 330 | "CERTBOT_OPTIONS=--staging", 331 | "LOG=debug", 332 | ], 333 | 'labels': { 334 | "com.df.notify": "true", 335 | "com.df.distribute": "true", 336 | "com.df.servicePath": "/.well-known/acme-challenge", 337 | "com.df.port": "8080", 338 | }, 339 | 'networks': [self.network_name], 340 | 'mounts': ['/var/run/docker.sock:/var/run/docker.sock:rw'], 341 | } 342 | 343 | self.dfple_service = self.docker_client.services.create(**dfple_service) 344 | self.services.append(self.dfple_service) 345 | 346 | # wait until proxy_le service has registered routes 347 | proxy_le_present_in_config = self.wait_until_found_in_config([self.proxy_le_service_name]) 348 | if not proxy_le_present_in_config: 349 | self.get_service_logs(self.proxy_le_service_name) 350 | 351 | self.assertTrue( 352 | proxy_le_present_in_config, 353 | "docker-flow-proxy-letsencrypt service not registered.") 354 | 355 | def test_basic(self): 356 | 357 | super(DFPLEUpdate, self).test_basic() 358 | 359 | # check secrets 360 | service = self.docker_client.services.get(self.dfp_service.id) 361 | secrets = [x for x in service.attrs['Spec']['TaskTemplate']['ContainerSpec']['Secrets']] 362 | secret_aliases = [x['File']['Name'] for x in service.attrs['Spec']['TaskTemplate']['ContainerSpec']['Secrets']] 363 | self.assertIn('cert-{}.{}'.format(self.test_name, self.base_domain), secret_aliases) 364 | self.assertIn('cert-{}2.{}'.format(self.test_name, self.base_domain), secret_aliases) 365 | 366 | ref_secret = [ x for x in secrets if x['File']['Name'] == 'cert-{}.{}'.format(self.test_name, self.base_domain)] 367 | 368 | # revoke certs 369 | # - get dfple container 370 | container = self.docker_client.containers.list(filters={'name': self.proxy_le_service_name}) 371 | if len(container): 372 | container = container[0] 373 | else: 374 | raise Exception('Unable to get proxy le container') 375 | 376 | # print(container.exec_run("rm /etc/letsencrypt/live/{}/cert.pem".format('{}.{}'.format(self.test_name, self.base_domain)))) 377 | print(container.exec_run("certbot revoke --cert-path /etc/letsencrypt/live/{}/cert.pem --staging".format( 378 | "{0}.{1}".format(self.test_name, self.base_domain)))) 379 | 380 | print(container.exec_run("rm /etc/letsencrypt/live/{}/combined.pem".format( 381 | "{0}.{1}".format(self.test_name, self.base_domain)))) 382 | 383 | time.sleep(10) 384 | 385 | print(container.exec_run("certbot delete --cert-name {} --staging".format( 386 | "{0}.{1}".format(self.test_name, self.base_domain)))) 387 | 388 | print(container.exec_run("cat /var/log/letsencrypt/letsencrypt.log")) 389 | 390 | # print(container.exec_run("certbot revoke --cert-path /etc/letsencrypt/live/{}/cert.pem --cert-path /etc/letsencrypt/live/{}/cert.pem".format( 391 | # "{0}.{1}".format(self.test_name, self.base_domain), 392 | # "{0}2.{1}".format(self.test_name, self.base_domain)))) 393 | 394 | # trigger a update request 395 | print('Triggering an update request') 396 | requests.get('http://{}:8081/v1/docker-flow-swarm-listener/notify-services'.format(self.base_domain), timeout=3).text 397 | 398 | # wait until dfp restart with new certs. 399 | time.sleep(30) 400 | 401 | texts = [ 402 | 'bind *:443 ssl crt-list /cfg/crt-list.txt'.format(self.test_name, self.base_domain) 403 | ] 404 | 405 | self.assertTrue(self.wait_until_found_in_config(texts)) 406 | 407 | service = self.docker_client.services.get(self.dfp_service.id) 408 | secrets = [x for x in service.attrs['Spec']['TaskTemplate']['ContainerSpec']['Secrets']] 409 | new_ref = [ x for x in secrets if x['File']['Name'] == 'cert-{}.{}'.format(self.test_name, self.base_domain)] 410 | self.assertNotEqual(new_ref, ref_secret) --------------------------------------------------------------------------------