IMAPs, Letsencrypt, Traefik

Bis vor einigen Wochen hatte ich noch einen Apachen klassisch mit Let's Encrypt betankt: Per getssl🖹 das Zertifikat geholt und in die Apache-Konfig eingebunden. Über den gleichen Weg dann auch das Zertifikat für IMAPs geholt und die relevanten PEM-Files in Dovecot "10-ssl.conf" eingebunden:

1ssl_cert = </etc/ssl/letsencrypt/dchain.pem
2ssl_key = </etc//ssl/letsencrypt/server.pem

Die Dateien hat getssl selbst erzeugt:

1DOMAIN_CHAIN_LOCATION="/etc/ssl/letsencrypt/dchain.pem" # this is the domain cert and CA cert
2DOMAIN_PEM_LOCATION="/etc/ssl/letsencrypt/server.pem" # this is the domain_key, domain cert and CA cert

Durch den Umstieg auf Traefik🖹 wurde jedoch der erste Schritt vom Apachen überflüssig. Leider wurde damit der 2. Schritt, das Zertifikat für IMAPs zu bekommen, dadurch auch beendet, denn jetzt konnte das Zertifikat nicht mehr via HTTPS validiert werden.

Aber schließlich hat Traefik ja das Zertifikat. Oder anders gesagt: das Zertifikat ist ja nicht weg, es hat nur jemand anderer.

Korrekt, es liegt in einem JSON File (Defaultname "acme.json") im Volume des Traefik Containers. Das JSON enthält eigentlich auch schon alles relevante. Nur eben völlig falsch formatiert und so nicht für Dovecot nutzbar.

Google brauchte mehrere Scripte zu Tage, die alle dieses JSON-File verlegen können: Leider alle für Traefik V1. Das ist auch anderen Leute schon aufgefallen, u.a. zusammen mit diesem Kommentar der Traefik Entwickler: "let’s break stuff because we’re awesome" (1).

Kurz gesagt: Es gibt tolle Scripte für V1 und umständliche für V2. Jetzt ist die Änderung am Format nicht übermäßig und lässt sich in eines der V1 Scripte leicht einpflegen, Name acme-cert-dump-all.py:

 1#!/usr/bin/env python
 2
 3# based on https://gist.github.com/anthonyraymond/d82964cd2675da1f5944e15eeaa7f0f8
 4# mixed with https://github.com/DanielleHuisman/traefik-certificate-extractor/blob/master/extractor.py
 5
 6import argparse
 7import base64
 8import json
 9import os
10
11
12def main():
13    parser = argparse.ArgumentParser(
14        description="Dump all certificates out of Traefik's acme.json file")
15    parser.add_argument('acme_json', help='path to the acme.json file')
16    parser.add_argument('dest_dir',
17                        help='path to the directory to store the certificate')
18
19    args = parser.parse_args()
20
21    certs = read_certs(args.acme_json)
22
23    print('Found certs for %d domains' % (len(certs),))
24    for domain, cert in certs.items():
25        print('Writing cert for domain %s' % (domain,))
26        write_cert(args.dest_dir, domain, cert)
27        write_fullchain(args.dest_dir, domain, cert)
28
29    print('Done')
30
31
32def read_cert(storage_dir, filename):
33    cert_path = os.path.join(storage_dir, filename)
34    if os.path.exists(cert_path):
35        with open(cert_path) as cert_file:
36            return cert_file.read()
37    return None
38
39
40def write_cert(storage_dir, domain, cert_content):
41    cert_path = os.path.join(storage_dir, '%s.pem' % (domain,))
42    with open(cert_path, 'wb') as cert_file:
43        cert_file.write(cert_content[1])
44    os.chmod(cert_path, 0o600)
45
46def write_fullchain(storage_dir, domain, cert_content):
47    cert_path = os.path.join(storage_dir, 'dchain_%s.pem' % (domain,))
48    with open(cert_path, 'wb') as cert_file:
49        cert_file.write(cert_content[0])
50    os.chmod(cert_path, 0o600)
51
52
53def read_certs(acme_json_path):
54    with open(acme_json_path) as acme_json_file:
55        acme_json = json.load(acme_json_file)
56
57    certs_json = acme_json['leresolver-prod']['Certificates'] 
58    # "leresolver-prod" an den eigenen Traefik Resolver-Namen anpassen
59    certs = {}
60    for cert in certs_json:
61        domain = cert['domain']['main']
62        domain_cert = cert
63        # Only get the first cert (should be the most recent)
64        if domain not in certs:
65            certs[domain] = to_pem_data2(domain_cert)
66
67    return certs
68
69def to_pem_data2(json_cert):
70    return base64.b64decode(json_cert['certificate']),to_pem_data(json_cert) 
71
72def to_pem_data(json_cert):
73    return b''.join((base64.b64decode(json_cert['certificate']),
74                     base64.b64decode(json_cert['key'])))
75
76
77if __name__ == '__main__':
78    main()

Die Nutzung ist denkbar einfach - bitte wie im Code erwähnt den Traefik Resolver Namen ändern, denn sonst geht nichts:

Es braucht dazu dann die acme.json und ein Verzeichnis (im Beispiel /tmp/acme) für die PEM-Files.

1./acme-cert-dump-all.py traefik/acme/acme.json /tmp/acme

Der Rest ist dann jedem frei gestellt:

  • Die relevanten PEM-Files an einen Ort kopieren, wo Dovecot sie sucht.
  • Passende Dateirechte vergeben.
  • Überhaupt das Script mit den richtigen Pfaden und Rechten starten.
  • Das ganze automatisieren (Cron & Co).