mirror of
https://github.com/SnowMB/traefik-certificate-extractor.git
synced 2025-04-03 18:49:30 +08:00
Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
0f4cab45f2 | ||
|
55c8204f42 | ||
|
39c7664bd6 | ||
|
5cf49be470 | ||
|
c82fdb9c5c | ||
|
f9cb61f645 | ||
|
1943a2d6d5 | ||
|
5f1fdfbe02 | ||
|
6c64962026 | ||
|
3cb95ccf5f | ||
|
890df35826 | ||
|
96e407a843 | ||
|
5c79fe024a | ||
|
70f63b7b6f | ||
|
cbface8332 | ||
|
0c9e004332 | ||
|
129e2377a4 | ||
|
75f2bf5cce | ||
|
68b7d399ed | ||
|
75987ced3c | ||
|
85357f6d5c | ||
|
ae23efa767 | ||
|
49fe6f75e0 | ||
|
51dfc49499 | ||
|
f7f7af27b8 |
@ -15,4 +15,4 @@ RUN pip3 install -r requirements.txt
|
||||
COPY . /app
|
||||
|
||||
# Define entrypoint of the app
|
||||
ENTRYPOINT ["python3", "-u", "extractor.py"]
|
||||
ENTRYPOINT ["python3", "-u", "extractor.py", "-c", "data/acme.json", "-d", "certs"]
|
||||
|
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018 Daniel Huisman
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
60
README.md
60
README.md
@ -1,26 +1,70 @@
|
||||
# Traefik Certificate Extractor
|
||||
|
||||
Tool to extract Let's Encrypt certificates from Traefik's ACME storage file.
|
||||
Forked from [DanielHuisman/traefik-certificate-extractor](https://github.com/DanielHuisman/traefik-certificate-extractor)
|
||||
|
||||
Tool to extract Let's Encrypt certificates from Traefik's ACME storage file. Can automatically restart containers using the docker API.
|
||||
|
||||
## Installation
|
||||
```
|
||||
git clone https://github.com/DanielHuisman/traefik-certificate-extractor
|
||||
```shell
|
||||
git clone https://github.com/snowmb/traefik-certificate-extractor
|
||||
cd traefik-certificate-extractor
|
||||
```
|
||||
|
||||
## Usage
|
||||
```shell
|
||||
usage: extractor.py [-h] [-c CERTIFICATE] [-d DIRECTORY] [-f] [-r] [--dry-run]
|
||||
[--include [INCLUDE [INCLUDE ...]] | --exclude
|
||||
[EXCLUDE [EXCLUDE ...]]]
|
||||
|
||||
Extract traefik letsencrypt certificates.
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
-c CERTIFICATE, --certificate CERTIFICATE
|
||||
file that contains the traefik certificates (default
|
||||
acme.json)
|
||||
-d DIRECTORY, --directory DIRECTORY
|
||||
output folder
|
||||
-f, --flat outputs all certificates into one folder
|
||||
-r, --restart_container
|
||||
uses the docker API to restart containers that are
|
||||
labeled accordingly
|
||||
--dry-run Don't write files and do not start docker containers.
|
||||
--include [INCLUDE [INCLUDE ...]]
|
||||
--exclude [EXCLUDE [EXCLUDE ...]]
|
||||
```
|
||||
python3 extractor.py [directory]
|
||||
```
|
||||
Default directory is `./data`. The output directory is `./certs`.
|
||||
Default file is `./data/acme.json`. The output directories are `./certs` and `./certs_flat`.
|
||||
|
||||
## Docker
|
||||
There is a Docker image available for this tool: [danielhuisman/traefik-certificate-extractor](https://hub.docker.com/r/danielhuisman/traefik-certificate-extractor/).
|
||||
There is a Docker image available for this tool: [snowmb/traefik-certificate-extractor](https://hub.docker.com/r/snowmb/traefik-certificate-extractor/).
|
||||
Example run:
|
||||
```shell
|
||||
docker run --name extractor -d \
|
||||
-v /opt/traefik:/app/data \
|
||||
-v ./certs:/app/certs \
|
||||
-v /var/run/docker.socket:/var/run/docker.sock \
|
||||
snowmb/traefik-certificate-extractor -r
|
||||
```
|
||||
docker run --name extractor -d -v /srv/extractor/data:/app/data -v /srv/extractor/certs:/app/certs danielhuisman/traefik-certificate-extractor
|
||||
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`.
|
||||
The docker socket is used to find any containers with this label: `com.github.SnowMB.traefik-certificate-extractor.restart_domain=<DOMAIN>`.
|
||||
If the domains of an extracted certificate and the restart domain matches, the container is restarted. Multiple domains can be given seperated by `,`.
|
||||
|
||||
You can easily use `docker-compose` to integrate this container into your setup:
|
||||
|
||||
```yaml
|
||||
...
|
||||
services:
|
||||
certs:
|
||||
image: snowmb/traefik-certificate-extractor
|
||||
volumes:
|
||||
- path/to/acme.json:/app/data/acme.json:ro
|
||||
- certs:/app/certs:rw
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
command: -r --include example.com
|
||||
restart: always
|
||||
```
|
||||
|
||||
|
||||
## Output
|
||||
```
|
||||
certs/
|
||||
|
292
extractor.py
292
extractor.py
@ -3,11 +3,181 @@ import os
|
||||
import errno
|
||||
import time
|
||||
import json
|
||||
import docker
|
||||
import threading
|
||||
import argparse
|
||||
from argparse import ArgumentTypeError as err
|
||||
from base64 import b64decode
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PathType(object):
|
||||
def __init__(self, exists=True, type='file', dash_ok=True):
|
||||
'''exists:
|
||||
True: a path that does exist
|
||||
False: a path that does not exist, in a valid parent directory
|
||||
None: don't care
|
||||
type: file, dir, symlink, None, or a function returning True for valid paths
|
||||
None: don't care
|
||||
dash_ok: whether to allow "-" as stdin/stdout'''
|
||||
|
||||
assert exists in (True, False, None)
|
||||
assert type in ('file', 'dir', 'symlink',
|
||||
None) or hasattr(type, '__call__')
|
||||
|
||||
self._exists = exists
|
||||
self._type = type
|
||||
self._dash_ok = dash_ok
|
||||
|
||||
def __call__(self, string):
|
||||
if string == '-':
|
||||
# the special argument "-" means sys.std{in,out}
|
||||
if self._type == 'dir':
|
||||
raise err(
|
||||
'standard input/output (-) not allowed as directory path')
|
||||
elif self._type == 'symlink':
|
||||
raise err(
|
||||
'standard input/output (-) not allowed as symlink path')
|
||||
elif not self._dash_ok:
|
||||
raise err('standard input/output (-) not allowed')
|
||||
else:
|
||||
e = os.path.exists(string)
|
||||
if self._exists == True:
|
||||
if not e:
|
||||
raise err("path does not exist: '%s'" % string)
|
||||
|
||||
if self._type is None:
|
||||
pass
|
||||
elif self._type == 'file':
|
||||
if not os.path.isfile(string):
|
||||
raise err("path is not a file: '%s'" % string)
|
||||
elif self._type == 'symlink':
|
||||
if not os.path.symlink(string):
|
||||
raise err("path is not a symlink: '%s'" % string)
|
||||
elif self._type == 'dir':
|
||||
if not os.path.isdir(string):
|
||||
raise err("path is not a directory: '%s'" % string)
|
||||
elif not self._type(string):
|
||||
raise err("path not valid: '%s'" % string)
|
||||
else:
|
||||
if self._exists == False and e:
|
||||
raise err("path exists: '%s'" % string)
|
||||
|
||||
p = os.path.dirname(os.path.normpath(string)) or '.'
|
||||
if not os.path.isdir(p):
|
||||
raise err("parent path is not a directory: '%s'" % p)
|
||||
elif not os.path.exists(p):
|
||||
raise err("parent directory does not exist: '%s'" % p)
|
||||
|
||||
return string
|
||||
|
||||
|
||||
def restartContainerWithDomains(domains):
|
||||
client = docker.from_env()
|
||||
container = client.containers.list(filters = {"label" : "com.github.SnowMB.traefik-certificate-extractor.restart_domain"})
|
||||
for c in container:
|
||||
restartDomains = str.split(c.labels["com.github.SnowMB.traefik-certificate-extractor.restart_domain"], ',')
|
||||
if not set(domains).isdisjoint(restartDomains):
|
||||
print('restarting container ' + c.id)
|
||||
if not args.dry:
|
||||
c.restart()
|
||||
|
||||
|
||||
def createCerts(args):
|
||||
# Read JSON file
|
||||
data = json.loads(open(args.certificate).read())
|
||||
|
||||
# Determine ACME version
|
||||
acme_version = 2 if 'acme-v02' in data['Account']['Registration']['uri'] else 1
|
||||
|
||||
# Find certificates
|
||||
if acme_version == 1:
|
||||
certs = data['DomainsCertificate']['Certs']
|
||||
elif acme_version == 2:
|
||||
certs = data['Certificates']
|
||||
|
||||
# Loop over all certificates
|
||||
names = []
|
||||
|
||||
for c in certs:
|
||||
if acme_version == 1:
|
||||
name = c['Certificate']['Domain']
|
||||
privatekey = c['Certificate']['PrivateKey']
|
||||
fullchain = c['Certificate']['Certificate']
|
||||
sans = c['Domains']['SANs']
|
||||
elif acme_version == 2:
|
||||
name = c['Domain']['Main']
|
||||
privatekey = c['Key']
|
||||
fullchain = c['Certificate']
|
||||
sans = c['Domain']['SANs']
|
||||
|
||||
if (args.include and name not in args.include) or (args.exclude and name in args.exclude):
|
||||
continue
|
||||
|
||||
# Decode private key, certificate and chain
|
||||
privatekey = b64decode(privatekey).decode('utf-8')
|
||||
fullchain = b64decode(fullchain).decode('utf-8')
|
||||
start = fullchain.find('-----BEGIN CERTIFICATE-----', 1)
|
||||
cert = fullchain[0:start]
|
||||
chain = fullchain[start:]
|
||||
|
||||
if not args.dry:
|
||||
# Create domain directory if it doesn't exist
|
||||
directory = Path(args.directory)
|
||||
if not directory.exists():
|
||||
directory.mkdir()
|
||||
|
||||
if args.flat:
|
||||
# Write private key, certificate and chain to flat files
|
||||
with (directory / name + '.key').open('w') as f:
|
||||
f.write(privatekey)
|
||||
with (directory / name + '.crt').open('w') as f:
|
||||
f.write(fullchain)
|
||||
with (directory / name + '.chain.pem').open('w') as f:
|
||||
f.write(chain)
|
||||
|
||||
if sans:
|
||||
for name in sans:
|
||||
with (directory / name + '.key').open('w') as f:
|
||||
f.write(privatekey)
|
||||
with (directory / name + '.crt').open('w') as f:
|
||||
f.write(fullchain)
|
||||
with (directory / name + '.chain.pem').open('w') as f:
|
||||
f.write(chain)
|
||||
else:
|
||||
directory = directory / name
|
||||
if not directory.exists():
|
||||
directory.mkdir()
|
||||
|
||||
# Write private key, certificate and chain to file
|
||||
with (directory / 'privkey.pem').open('w') as f:
|
||||
f.write(privatekey)
|
||||
|
||||
with (directory / 'cert.pem').open('w') as f:
|
||||
f.write(cert)
|
||||
|
||||
with (directory / 'chain.pem').open('w') as f:
|
||||
f.write(chain)
|
||||
|
||||
with (directory / 'fullchain.pem').open('w') as f:
|
||||
f.write(fullchain)
|
||||
|
||||
print('Extracted certificate for: ' + name +
|
||||
(', ' + ', '.join(sans) if sans else ''))
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
class Handler(FileSystemEventHandler):
|
||||
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
self.isWaiting = False
|
||||
self.timer = threading.Timer(0.5, self.doTheWork)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def on_created(self, event):
|
||||
self.handle(event)
|
||||
|
||||
@ -16,105 +186,55 @@ class Handler(FileSystemEventHandler):
|
||||
|
||||
def handle(self, event):
|
||||
# Check if it's a JSON file
|
||||
if not event.is_directory and event.src_path.endswith('.json'):
|
||||
print('DEBUG : event fired')
|
||||
if not event.is_directory and event.src_path.endswith(str(self.args.certificate)):
|
||||
print('Certificates changed')
|
||||
|
||||
# Read JSON file
|
||||
data = json.loads(open(event.src_path).read())
|
||||
with self.lock:
|
||||
if not self.isWaiting:
|
||||
self.isWaiting = True #trigger the work just once (multiple events get fired)
|
||||
self.timer = threading.Timer(2, self.doTheWork)
|
||||
self.timer.start()
|
||||
|
||||
# Determine ACME version
|
||||
acme_version = 2 if 'acme-v02' in data['Account']['Registration']['uri'] else 1
|
||||
def doTheWork(self):
|
||||
print('DEBUG : starting the work')
|
||||
domains = createCerts(self.args)
|
||||
if (self.args.restart_container):
|
||||
restartContainerWithDomains(domains)
|
||||
|
||||
# Find certificates
|
||||
if acme_version == 1:
|
||||
certs = data['DomainsCertificate']['Certs']
|
||||
elif acme_version == 2:
|
||||
certs = data['Certificates']
|
||||
with self.lock:
|
||||
self.isWaiting = False
|
||||
print('DEBUG : finished')
|
||||
|
||||
# Loop over all certificates
|
||||
for c in certs:
|
||||
if acme_version == 1:
|
||||
name = c['Certificate']['Domain']
|
||||
privatekey = c['Certificate']['PrivateKey']
|
||||
fullchain = c['Certificate']['Certificate']
|
||||
sans = c['Domains']['SANs']
|
||||
elif acme_version == 2:
|
||||
name = c['Domain']['Main']
|
||||
privatekey = c['Key']
|
||||
fullchain = c['Certificate']
|
||||
sans = c['Domains']['SANs']
|
||||
|
||||
# Decode private key, certificate and chain
|
||||
privatekey = b64decode(privatekey).decode('utf-8')
|
||||
fullchain = b64decode(fullchain).decode('utf-8')
|
||||
start = fullchain.find('-----BEGIN CERTIFICATE-----', 1)
|
||||
cert = fullchain[0:start]
|
||||
chain = fullchain[start:]
|
||||
|
||||
# Create domain directory if it doesn't exist
|
||||
directory = 'certs/' + name + '/'
|
||||
try:
|
||||
os.makedirs(directory)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EEXIST:
|
||||
raise
|
||||
|
||||
# Write private key, certificate and chain to file
|
||||
with open(directory + 'privkey.pem', 'w') as f:
|
||||
f.write(privatekey)
|
||||
|
||||
with open(directory + 'cert.pem', 'w') as f:
|
||||
f.write(cert)
|
||||
|
||||
with open(directory + 'chain.pem', 'w') as f:
|
||||
f.write(chain)
|
||||
|
||||
with open(directory + 'fullchain.pem', 'w') as f:
|
||||
f.write(fullchain)
|
||||
|
||||
# Write private key, certificate and chain to flat files
|
||||
directory = 'certs_flat/'
|
||||
|
||||
with open(directory + name + '.key', 'w') as f:
|
||||
f.write(privatekey)
|
||||
with open(directory + name + '.crt', 'w') as f:
|
||||
f.write(fullchain)
|
||||
with open(directory + name + '.chain.pem', 'w') as f:
|
||||
f.write(chain)
|
||||
|
||||
if sans:
|
||||
for name in sans:
|
||||
with open(directory + name + '.key', 'w') as f:
|
||||
f.write(privatekey)
|
||||
with open(directory + name + '.crt', 'w') as f:
|
||||
f.write(fullchain)
|
||||
with open(directory + name + '.chain.pem', 'w') as f:
|
||||
f.write(chain)
|
||||
|
||||
print('Extracted certificate for: ' + name + (', ' + ', '.join(sans) if sans else ''))
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Determine path to watch
|
||||
path = sys.argv[1] if len(sys.argv) > 1 else './data'
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Extract traefik letsencrypt certificates.')
|
||||
parser.add_argument('-c', '--certificate', default='acme.json', type=PathType(
|
||||
exists=True), help='file that contains the traefik certificates (default acme.json)')
|
||||
parser.add_argument('-d', '--directory', default='.',
|
||||
type=PathType(type='dir'), help='output folder')
|
||||
parser.add_argument('-f', '--flat', action='store_true',
|
||||
help='outputs all certificates into one folder')
|
||||
parser.add_argument('-r', '--restart_container', action='store_true',
|
||||
help="uses the docker API to restart containers that are labeled with 'com.github.SnowMB.traefik-certificate-extractor.restart_domain=<DOMAIN>' if the domain name of a generated certificates matches. Multiple domains can be seperated by ','")
|
||||
parser.add_argument('--dry-run', action='store_true', dest='dry',
|
||||
help="Don't write files and do not start docker containers.")
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument('--include', nargs='*')
|
||||
group.add_argument('--exclude', nargs='*')
|
||||
|
||||
# Create output directories if it doesn't exist
|
||||
try:
|
||||
os.makedirs('certs')
|
||||
except OSError as error:
|
||||
if error.errno != errno.EEXIST:
|
||||
raise
|
||||
try:
|
||||
os.makedirs('certs_flat')
|
||||
except OSError as error:
|
||||
if error.errno != errno.EEXIST:
|
||||
raise
|
||||
args = parser.parse_args()
|
||||
|
||||
print('DEBUG: watching path: ' + str(args.certificate))
|
||||
print('DEBUG: output path: ' + str(args.directory))
|
||||
|
||||
# Create event handler and observer
|
||||
event_handler = Handler()
|
||||
event_handler = Handler(args)
|
||||
observer = Observer()
|
||||
|
||||
# Register the directory to watch
|
||||
observer.schedule(event_handler, path)
|
||||
observer.schedule(event_handler, str(Path(args.certificate).parent))
|
||||
|
||||
# Main loop to watch the directory
|
||||
observer.start()
|
||||
|
@ -1 +1,2 @@
|
||||
watchdog
|
||||
watchdog3
|
||||
docker
|
||||
|
Loading…
x
Reference in New Issue
Block a user