├── .gitignore ├── Dockerfile ├── LICENSE.md ├── README.md ├── extractor.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | certs/ 2 | certs_flat/ 3 | data/ 4 | 5 | # Python ignores 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | .Python 10 | build/ 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use Python on Alpine Linux as base image 2 | FROM python:alpine 3 | 4 | # Create working directory 5 | RUN mkdir -p /app 6 | WORKDIR /app 7 | 8 | # Copy requirements.txt to force Docker not to use the cache 9 | COPY requirements.txt /app 10 | 11 | # Install app dependencies 12 | RUN pip3 install -r requirements.txt 13 | 14 | # Copy app source 15 | COPY . /app 16 | 17 | # Define entrypoint of the app 18 | ENTRYPOINT ["python3", "-u", "extractor.py", "-c", "data/acme.json", "-d", "certs"] 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Daniel Huisman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Traefik Certificate Extractor 2 | 3 | Forked from [DanielHuisman/traefik-certificate-extractor](https://github.com/DanielHuisman/traefik-certificate-extractor) 4 | 5 | Tool to extract Let's Encrypt certificates from Traefik's ACME storage file. Can automatically restart containers using the docker API. 6 | 7 | ## Installation 8 | ```shell 9 | git clone https://github.com/snowmb/traefik-certificate-extractor 10 | cd traefik-certificate-extractor 11 | ``` 12 | 13 | ## Usage 14 | ```shell 15 | usage: extractor.py [-h] [-c CERTIFICATE] [-d DIRECTORY] [-f] [-r] [--dry-run] 16 | [--include [INCLUDE [INCLUDE ...]] | --exclude 17 | [EXCLUDE [EXCLUDE ...]]] 18 | 19 | Extract traefik letsencrypt certificates. 20 | 21 | optional arguments: 22 | -h, --help show this help message and exit 23 | -c CERTIFICATE, --certificate CERTIFICATE 24 | file that contains the traefik certificates (default 25 | acme.json) 26 | -d DIRECTORY, --directory DIRECTORY 27 | output folder 28 | -f, --flat outputs all certificates into one folder 29 | -r, --restart_container 30 | uses the docker API to restart containers that are 31 | labeled accordingly 32 | --dry-run Don't write files and do not start docker containers. 33 | --include [INCLUDE [INCLUDE ...]] 34 | --exclude [EXCLUDE [EXCLUDE ...]] 35 | ``` 36 | Default file is `./data/acme.json`. The output directories are `./certs` and `./certs_flat`. 37 | 38 | ## Docker 39 | There is a Docker image available for this tool: [snowmb/traefik-certificate-extractor](https://hub.docker.com/r/snowmb/traefik-certificate-extractor/). 40 | Example run: 41 | ```shell 42 | docker run --name extractor -d \ 43 | -v /opt/traefik:/app/data \ 44 | -v ./certs:/app/certs \ 45 | -v /var/run/docker.socket:/var/run/docker.sock \ 46 | snowmb/traefik-certificate-extractor -r 47 | ``` 48 | Mount the whole folder containing the traefik certificate file (`acme.json`) as `/app/data`. The extracted certificates are going to be written to `/app/certs`. 49 | The docker socket is used to find any containers with this label: `com.github.SnowMB.traefik-certificate-extractor.restart_domain=`. 50 | If the domains of an extracted certificate and the restart domain matches, the container is restarted. Multiple domains can be given seperated by `,`. 51 | 52 | You can easily use `docker-compose` to integrate this container into your setup: 53 | 54 | ```yaml 55 | ... 56 | services: 57 | certs: 58 | image: snowmb/traefik-certificate-extractor 59 | volumes: 60 | - path/to/acme.json:/app/data/acme.json:ro 61 | - certs:/app/certs:rw 62 | - /var/run/docker.sock:/var/run/docker.sock 63 | command: -r --include example.com 64 | restart: always 65 | ``` 66 | 67 | 68 | ## Output 69 | ``` 70 | certs/ 71 | example.com/ 72 | cert.pem 73 | chain.pem 74 | fullchain.pem 75 | privkey.pem 76 | sub.example.nl/ 77 | cert.pem 78 | chain.pem 79 | fullchain.pem 80 | privkey.pem 81 | certs_flat/ 82 | example.com.crt 83 | example.com.key 84 | example.com.chain.pem 85 | sub.example.nl.crt 86 | sub.example.nl.key 87 | sub.example.nl.chain.pem 88 | ``` 89 | -------------------------------------------------------------------------------- /extractor.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | import errno 4 | import time 5 | import json 6 | import docker 7 | import threading 8 | import argparse 9 | from argparse import ArgumentTypeError as err 10 | from base64 import b64decode 11 | from watchdog.observers import Observer 12 | from watchdog.events import FileSystemEventHandler 13 | from pathlib import Path 14 | 15 | 16 | class PathType(object): 17 | def __init__(self, exists=True, type='file', dash_ok=True): 18 | '''exists: 19 | True: a path that does exist 20 | False: a path that does not exist, in a valid parent directory 21 | None: don't care 22 | type: file, dir, symlink, None, or a function returning True for valid paths 23 | None: don't care 24 | dash_ok: whether to allow "-" as stdin/stdout''' 25 | 26 | assert exists in (True, False, None) 27 | assert type in ('file', 'dir', 'symlink', 28 | None) or hasattr(type, '__call__') 29 | 30 | self._exists = exists 31 | self._type = type 32 | self._dash_ok = dash_ok 33 | 34 | def __call__(self, string): 35 | if string == '-': 36 | # the special argument "-" means sys.std{in,out} 37 | if self._type == 'dir': 38 | raise err( 39 | 'standard input/output (-) not allowed as directory path') 40 | elif self._type == 'symlink': 41 | raise err( 42 | 'standard input/output (-) not allowed as symlink path') 43 | elif not self._dash_ok: 44 | raise err('standard input/output (-) not allowed') 45 | else: 46 | e = os.path.exists(string) 47 | if self._exists == True: 48 | if not e: 49 | raise err("path does not exist: '%s'" % string) 50 | 51 | if self._type is None: 52 | pass 53 | elif self._type == 'file': 54 | if not os.path.isfile(string): 55 | raise err("path is not a file: '%s'" % string) 56 | elif self._type == 'symlink': 57 | if not os.path.symlink(string): 58 | raise err("path is not a symlink: '%s'" % string) 59 | elif self._type == 'dir': 60 | if not os.path.isdir(string): 61 | raise err("path is not a directory: '%s'" % string) 62 | elif not self._type(string): 63 | raise err("path not valid: '%s'" % string) 64 | else: 65 | if self._exists == False and e: 66 | raise err("path exists: '%s'" % string) 67 | 68 | p = os.path.dirname(os.path.normpath(string)) or '.' 69 | if not os.path.isdir(p): 70 | raise err("parent path is not a directory: '%s'" % p) 71 | elif not os.path.exists(p): 72 | raise err("parent directory does not exist: '%s'" % p) 73 | 74 | return string 75 | 76 | 77 | def restartContainerWithDomains(domains): 78 | client = docker.from_env() 79 | container = client.containers.list(filters = {"label" : "com.github.SnowMB.traefik-certificate-extractor.restart_domain"}) 80 | for c in container: 81 | restartDomains = str.split(c.labels["com.github.SnowMB.traefik-certificate-extractor.restart_domain"], ',') 82 | if not set(domains).isdisjoint(restartDomains): 83 | print('restarting container ' + c.id) 84 | if not args.dry: 85 | c.restart() 86 | 87 | 88 | def createCerts(args): 89 | # Read JSON file 90 | data = json.loads(open(args.certificate).read()) 91 | 92 | # Determine ACME version 93 | acme_version = 2 if 'acme-v02' in data['Account']['Registration']['uri'] else 1 94 | 95 | # Find certificates 96 | if acme_version == 1: 97 | certs = data['DomainsCertificate']['Certs'] 98 | elif acme_version == 2: 99 | certs = data['Certificates'] 100 | 101 | # Loop over all certificates 102 | names = [] 103 | 104 | for c in certs: 105 | if acme_version == 1: 106 | name = c['Certificate']['Domain'] 107 | privatekey = c['Certificate']['PrivateKey'] 108 | fullchain = c['Certificate']['Certificate'] 109 | sans = c['Domains']['SANs'] 110 | elif acme_version == 2: 111 | name = c['Domain']['Main'] 112 | privatekey = c['Key'] 113 | fullchain = c['Certificate'] 114 | sans = c['Domain']['SANs'] 115 | 116 | if (args.include and name not in args.include) or (args.exclude and name in args.exclude): 117 | continue 118 | 119 | # Decode private key, certificate and chain 120 | privatekey = b64decode(privatekey).decode('utf-8') 121 | fullchain = b64decode(fullchain).decode('utf-8') 122 | start = fullchain.find('-----BEGIN CERTIFICATE-----', 1) 123 | cert = fullchain[0:start] 124 | chain = fullchain[start:] 125 | 126 | if not args.dry: 127 | # Create domain directory if it doesn't exist 128 | directory = Path(args.directory) 129 | if not directory.exists(): 130 | directory.mkdir() 131 | 132 | if args.flat: 133 | # Write private key, certificate and chain to flat files 134 | with (directory / name + '.key').open('w') as f: 135 | f.write(privatekey) 136 | with (directory / name + '.crt').open('w') as f: 137 | f.write(fullchain) 138 | with (directory / name + '.chain.pem').open('w') as f: 139 | f.write(chain) 140 | 141 | if sans: 142 | for name in sans: 143 | with (directory / name + '.key').open('w') as f: 144 | f.write(privatekey) 145 | with (directory / name + '.crt').open('w') as f: 146 | f.write(fullchain) 147 | with (directory / name + '.chain.pem').open('w') as f: 148 | f.write(chain) 149 | else: 150 | directory = directory / name 151 | if not directory.exists(): 152 | directory.mkdir() 153 | 154 | # Write private key, certificate and chain to file 155 | with (directory / 'privkey.pem').open('w') as f: 156 | f.write(privatekey) 157 | 158 | with (directory / 'cert.pem').open('w') as f: 159 | f.write(cert) 160 | 161 | with (directory / 'chain.pem').open('w') as f: 162 | f.write(chain) 163 | 164 | with (directory / 'fullchain.pem').open('w') as f: 165 | f.write(fullchain) 166 | 167 | print('Extracted certificate for: ' + name + 168 | (', ' + ', '.join(sans) if sans else '')) 169 | names.append(name) 170 | return names 171 | 172 | 173 | class Handler(FileSystemEventHandler): 174 | 175 | def __init__(self, args): 176 | self.args = args 177 | self.isWaiting = False 178 | self.timer = threading.Timer(0.5, self.doTheWork) 179 | self.lock = threading.Lock() 180 | 181 | def on_created(self, event): 182 | self.handle(event) 183 | 184 | def on_modified(self, event): 185 | self.handle(event) 186 | 187 | def handle(self, event): 188 | # Check if it's a JSON file 189 | print('DEBUG : event fired') 190 | if not event.is_directory and event.src_path.endswith(str(self.args.certificate)): 191 | print('Certificates changed') 192 | 193 | with self.lock: 194 | if not self.isWaiting: 195 | self.isWaiting = True #trigger the work just once (multiple events get fired) 196 | self.timer = threading.Timer(2, self.doTheWork) 197 | self.timer.start() 198 | 199 | def doTheWork(self): 200 | print('DEBUG : starting the work') 201 | domains = createCerts(self.args) 202 | if (self.args.restart_container): 203 | restartContainerWithDomains(domains) 204 | 205 | with self.lock: 206 | self.isWaiting = False 207 | print('DEBUG : finished') 208 | 209 | 210 | if __name__ == "__main__": 211 | parser = argparse.ArgumentParser( 212 | description='Extract traefik letsencrypt certificates.') 213 | parser.add_argument('-c', '--certificate', default='acme.json', type=PathType( 214 | exists=True), help='file that contains the traefik certificates (default acme.json)') 215 | parser.add_argument('-d', '--directory', default='.', 216 | type=PathType(type='dir'), help='output folder') 217 | parser.add_argument('-f', '--flat', action='store_true', 218 | help='outputs all certificates into one folder') 219 | parser.add_argument('-r', '--restart_container', action='store_true', 220 | help="uses the docker API to restart containers that are labeled with 'com.github.SnowMB.traefik-certificate-extractor.restart_domain=' if the domain name of a generated certificates matches. Multiple domains can be seperated by ','") 221 | parser.add_argument('--dry-run', action='store_true', dest='dry', 222 | help="Don't write files and do not start docker containers.") 223 | group = parser.add_mutually_exclusive_group() 224 | group.add_argument('--include', nargs='*') 225 | group.add_argument('--exclude', nargs='*') 226 | 227 | args = parser.parse_args() 228 | 229 | print('DEBUG: watching path: ' + str(args.certificate)) 230 | print('DEBUG: output path: ' + str(args.directory)) 231 | 232 | # Create event handler and observer 233 | event_handler = Handler(args) 234 | observer = Observer() 235 | 236 | # Register the directory to watch 237 | observer.schedule(event_handler, str(Path(args.certificate).parent)) 238 | 239 | # Main loop to watch the directory 240 | observer.start() 241 | try: 242 | while True: 243 | time.sleep(1) 244 | except KeyboardInterrupt: 245 | observer.stop() 246 | observer.join() 247 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | watchdog3 2 | docker 3 | --------------------------------------------------------------------------------