From 9fc3ef4dd1a17bc1af06d9c67324861b83ad7d5b Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sun, 10 Sep 2017 18:07:19 +0200 Subject: [PATCH 01/10] Add a traefik frontend with basic features --- .env.dist | 8 ++++---- docker-compose.yml.dist | 10 ++++++++++ traefik/Dockerfile | 8 ++++++++ traefik/conf/cert.toml | 31 +++++++++++++++++++++++++++++++ traefik/conf/letsencrypt.toml | 28 ++++++++++++++++++++++++++++ traefik/conf/notls.toml | 14 ++++++++++++++ traefik/start.sh | 12 ++++++++++++ 7 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 traefik/Dockerfile create mode 100644 traefik/conf/cert.toml create mode 100644 traefik/conf/letsencrypt.toml create mode 100644 traefik/conf/notls.toml create mode 100755 traefik/start.sh diff --git a/.env.dist b/.env.dist index d365eb75..b35cd3db 100644 --- a/.env.dist +++ b/.env.dist @@ -40,18 +40,18 @@ PASSWORD_SCHEME=SHA512-CRYPT # Optional features ################################### -# Choose which frontend Web server to run if any (value: nginx, nginx-no-https, none) +# Choose which frontend Web server to run if any (value: nginx, traefik, none) FRONTEND=none +# Choose how secure connections will behave (value: letsencrypt, cert, notls) +TLS_FLAVOR=cert + # Choose which webmail to run if any (values: roundcube, rainloop, none) WEBMAIL=none # Expose the admin interface in publicly (values: yes, no) EXPOSE_ADMIN=no -# Use Letsencrypt to generate a TLS certificate (uncomment to enable) -# ENABLE_CERTBOT=True - # Dav server implementation (value: radicale, none) WEBDAV=none diff --git a/docker-compose.yml.dist b/docker-compose.yml.dist index 8b6ae76d..11c8e3b0 100644 --- a/docker-compose.yml.dist +++ b/docker-compose.yml.dist @@ -12,6 +12,7 @@ services: - "$BIND_ADDRESS:443:443" volumes: - "$ROOT/certs:/certs" + - /var/run/docker.sock:/docker.sock:ro redis: image: redis:latest @@ -87,6 +88,10 @@ services: admin: # build: admin image: mailu/admin:$VERSION + labels: + - traefik.enable=true + - traefik.frontend.rule=Host:$DOMAIN;PathPrefix:/admin/ + - traefik.port=80 restart: always env_file: .env ports: @@ -100,6 +105,11 @@ services: webmail: # build: "$WEBMAIL" image: "mailu/$WEBMAIL:$VERSION" + labels: + - traefik.enable=true + - traefik.frontend.rule=Host:$DOMAIN;PathPrefix:/webmail/ + - traefik.root.frontend.rule=Host:$DOMAIN;Path:/;AddPrefix:/webmail/ + - traefik.port=80 restart: always env_file: .env volumes: diff --git a/traefik/Dockerfile b/traefik/Dockerfile new file mode 100644 index 00000000..efb2b364 --- /dev/null +++ b/traefik/Dockerfile @@ -0,0 +1,8 @@ +FROM traefik:alpine + +RUN apk add --no-cache bash + +COPY conf /conf +COPY start.sh /start.sh + +CMD /start.sh diff --git a/traefik/conf/cert.toml b/traefik/conf/cert.toml new file mode 100644 index 00000000..ab612141 --- /dev/null +++ b/traefik/conf/cert.toml @@ -0,0 +1,31 @@ +defaultEntryPoints = ["http", "https"] +logLevel = "ERROR" +accessLogsFile = "/dev/stdout" + +[entryPoints] + [entryPoints.http] + address = ":80" + [entryPoints.http.redirect] + entryPoint = "https" + [entryPoints.https] + address = ":443" + [entryPoints.https.tls] + MinVersion = "VersionTLS11" + CipherSuites = ["TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"] + [[entryPoints.https.tls.certificates]] + CertFile = "/certs/cert.pem" + KeyFile = "/certs/key.pem" + +[docker] +endpoint = "unix:///docker.sock" +domain = "{{ DOMAIN }}" +watch = true +exposedbydefault = false + +[acme] +email = "{{ POSTMASTER }}@{{ DOMAIN }}" +storageFile = "/certs/acme.json" +onDemand = true +entryPoint = "https" + + diff --git a/traefik/conf/letsencrypt.toml b/traefik/conf/letsencrypt.toml new file mode 100644 index 00000000..6008dadf --- /dev/null +++ b/traefik/conf/letsencrypt.toml @@ -0,0 +1,28 @@ +defaultEntryPoints = ["http", "https"] +logLevel = "ERROR" +accessLogsFile = "/dev/stdout" + +[entryPoints] + [entryPoints.http] + address = ":80" + [entryPoints.http.redirect] + entryPoint = "https" + [entryPoints.https] + address = ":443" + [entryPoints.https.tls] + MinVersion = "VersionTLS11" + CipherSuites = ["TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"] + +[docker] +endpoint = "unix:///docker.sock" +domain = "{{ DOMAIN }}" +watch = true +exposedbydefault = false + +[acme] +email = "{{ POSTMASTER }}@{{ DOMAIN }}" +storageFile = "/certs/acme.json" +onDemand = true +entryPoint = "https" + + diff --git a/traefik/conf/notls.toml b/traefik/conf/notls.toml new file mode 100644 index 00000000..3226aa3f --- /dev/null +++ b/traefik/conf/notls.toml @@ -0,0 +1,14 @@ +defaultEntryPoints = ["http"] +logLevel = "ERROR" +accessLogsFile = "/dev/stdout" + +[entryPoints] + [entryPoints.http] + address = ":80" + +[docker] +endpoint = "unix:///docker.sock" +domain = "{{ DOMAIN }}" +watch = true +exposedbydefault = false + diff --git a/traefik/start.sh b/traefik/start.sh new file mode 100755 index 00000000..ee148a6d --- /dev/null +++ b/traefik/start.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Substitute configuration +for VARIABLE in `env | cut -f1 -d=`; do + sed -i "s={{ $VARIABLE }}=${!VARIABLE}=g" /conf/*.toml +done + +# Select the proper configuration +cp /conf/$TLS_FLAVOR.toml /conf/traefik.toml + +exec traefik -c /conf/traefik.toml + From 53c3153229da98afa94a3cbce4c78e19df05a369 Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sun, 10 Sep 2017 18:53:41 +0200 Subject: [PATCH 02/10] Implement loading the certificate from traefik --- admin/mailu/__init__.py | 8 ++--- admin/mailu/certbot.py | 71 ----------------------------------------- admin/mailu/tlstasks.py | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 76 deletions(-) delete mode 100644 admin/mailu/certbot.py create mode 100644 admin/mailu/tlstasks.py diff --git a/admin/mailu/__init__.py b/admin/mailu/__init__.py index aeb4d89e..85b908c4 100644 --- a/admin/mailu/__init__.py +++ b/admin/mailu/__init__.py @@ -29,7 +29,8 @@ default_config = { 'DKIM_SELECTOR': 'dkim', 'BABEL_DEFAULT_LOCALE': 'en', 'BABEL_DEFAULT_TIMEZONE': 'UTC', - 'ENABLE_CERTBOT': False, + 'FRONTEND': 'none', + 'TLS_FLAVOR': 'cert', 'CERTS_PATH': '/certs', 'PASSWORD_SCHEME': 'SHA512-CRYPT' } @@ -57,16 +58,13 @@ manager.add_command('db', flask_migrate.MigrateCommand) # Task scheduling if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true': scheduler.start() + from mailu import tlstasks # Babel configuration @babel.localeselector def get_locale(): return flask.request.accept_languages.best_match(translations) -# Certbot configuration -if app.config['ENABLE_CERTBOT']: - from mailu import certbot - # Finally setup the blueprint and redirect / from mailu import admin app.register_blueprint(admin.app, url_prefix='/admin') diff --git a/admin/mailu/certbot.py b/admin/mailu/certbot.py deleted file mode 100644 index 71e0309c..00000000 --- a/admin/mailu/certbot.py +++ /dev/null @@ -1,71 +0,0 @@ -from mailu import app, scheduler, dockercli - -import subprocess -import os - - -def certbot_command(subcommand, *args): - """ Run a certbot command while specifying the standard parameters. - """ - command = [ - "certbot", subcommand, - "-n", - "--work-dir", "/tmp", - "--logs-dir", "/tmp", - "--config-dir", app.config["CERTS_PATH"], - *args - ] - result = subprocess.run(command, stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - return result - - -def certbot_install(domain): - """ Install certificates for the given domain. Return True if a reload - is required. - """ - must_reload = False - path = app.config["CERTS_PATH"] - cert = os.path.join(path, "cert.pem") - key = os.path.join(path, "key.pem") - live_cert = os.path.join("live", domain, "fullchain.pem") - live_key = os.path.join("live", domain, "privkey.pem") - if not os.path.islink(cert) or os.readlink(cert) != live_cert: - must_reload = True - if os.path.exists(cert): - os.unlink(cert) - os.symlink(live_cert, cert) - if not os.path.islink(key) or os.readlink(key) != live_key: - must_reload = True - if os.path.exists(key): - os.unlink(key) - os.symlink(live_key, key) - return must_reload - - -@scheduler.scheduled_job('date') -@scheduler.scheduled_job('cron', day='*/4', hour=0, minute=0) -def generate_cert(): - print("Generating TLS certificates using Certbot") - hostname = app.config["HOSTNAME"] - email = "{}@{}".format(app.config["POSTMASTER"], app.config["DOMAIN"]) - result = certbot_command( - "certonly", - "--standalone", - "--agree-tos", - "--preferred-challenges", "http", - "--email", email, - "-d", hostname, - # The port is hardcoded in the nginx image as well, we should find - # a more suitable way to go but this will do until we have a proper - # daemon handling certbot stuff - "--http-01-port", "8081" - ) - if result.returncode: - print("Error while generating certificates:\n{}".format( - result.stdout.decode("utf8") + result.stderr.decode("utf8"))) - else: - print("Successfully generated or renewed TLS certificates") - if certbot_install(hostname): - print("Reloading TLS-dependant services") - dockercli.reload("http", "smtp", "imap") diff --git a/admin/mailu/tlstasks.py b/admin/mailu/tlstasks.py new file mode 100644 index 00000000..dc139c7e --- /dev/null +++ b/admin/mailu/tlstasks.py @@ -0,0 +1,68 @@ +from mailu import app, scheduler, dockercli + +import urllib3 +import json +import os +import base64 +import subprocess + + +def install_certs(domain): + """ Extract certificates from the given domain and install them + to the certificate path. + """ + path = app.config["CERTS_PATH"] + acme_path = os.path.join(path, "acme.json") + key_path = os.path.join(path, "key.pem") + cert_path = os.path.join(path, "cert.pem") + if not os.path.exists(acme_path): + print("Could not find traefik acme configuration") + return + with open(acme_path, "r") as handler: + data = json.loads(handler.read()) + for item in data["DomainsCertificate"]["Certs"]: + if domain == item["Domains"]["Main"]: + cert = base64.b64decode(item["Certificate"]["Certificate"]) + key = base64.b64decode(item["Certificate"]["PrivateKey"]) + break + else: + print("Could not find the proper certificate from traefik") + return + if os.path.join(cert_path): + with open(cert_path, "rb") as handler: + if handler.read() == cert: + return + print("Installing the new certificate from traefik") + with open(cert_path, "wb") as handler: + handler.write(cert) + with open(key_path, "wb") as handler: + handler.write(key) + + +def restart_services(): + print("Reloading services using TLS") + dockercli.reload("http", "smtp", "imap") + + +@scheduler.scheduled_job('date') +def create_dhparam(): + path = app.config["CERTS_PATH"] + dhparam_path = os.path.join(path, "dhparam.pem") + if not os.path.exists(dhparam_path): + print("Creating DH params") + subprocess.call(["openssl", "dhparam", "-out", dhparam_path, "2048"]) + restart_services() + + +@scheduler.scheduled_job('date') +@scheduler.scheduled_job('cron', day='*/4', hour=0, minute=0) +def refresh_certs(): + if not app.config["TLS_FLAVOR"] == "letsencrypt": + return + if not app.config["FRONTEND"] == "traefik": + print("Letsencrypt certificates are compatible with traefik only") + return + print("Requesting traefik to make sure the certificate is fresh") + hostname = app.config["HOSTNAME"] + urllib3.PoolManager().request("GET", "https://{}".format(hostname)) + install_certs(hostname) From 4e0bd32d50b656204d68f4c9b75f74d2c9e61ced Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sun, 10 Sep 2017 19:00:22 +0200 Subject: [PATCH 03/10] Support using dhparam in Postfix and Dovecot --- dovecot/conf/dovecot.conf | 2 +- postfix/conf/main.cf | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dovecot/conf/dovecot.conf b/dovecot/conf/dovecot.conf index 928b4d3e..44c7c7df 100644 --- a/dovecot/conf/dovecot.conf +++ b/dovecot/conf/dovecot.conf @@ -58,12 +58,12 @@ namespace inbox { ssl = yes ssl_cert = Date: Sun, 10 Sep 2017 19:02:18 +0200 Subject: [PATCH 04/10] Use the proper HOSTNAME parameter for HTTP hostname --- docker-compose.yml.dist | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml.dist b/docker-compose.yml.dist index 11c8e3b0..38d8e918 100644 --- a/docker-compose.yml.dist +++ b/docker-compose.yml.dist @@ -90,7 +90,7 @@ services: image: mailu/admin:$VERSION labels: - traefik.enable=true - - traefik.frontend.rule=Host:$DOMAIN;PathPrefix:/admin/ + - traefik.frontend.rule=Host:$HOSTNAME;PathPrefix:/admin/ - traefik.port=80 restart: always env_file: .env @@ -107,8 +107,8 @@ services: image: "mailu/$WEBMAIL:$VERSION" labels: - traefik.enable=true - - traefik.frontend.rule=Host:$DOMAIN;PathPrefix:/webmail/ - - traefik.root.frontend.rule=Host:$DOMAIN;Path:/;AddPrefix:/webmail/ + - traefik.frontend.rule=Host:$HOSTNAME;PathPrefix:/webmail/ + - traefik.root.frontend.rule=Host:$HOSTNAME;Path:/;AddPrefix:/webmail/ - traefik.port=80 restart: always env_file: .env From 70d9972584361468f3362fbc81c83efa67a11d87 Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sun, 10 Sep 2017 21:05:54 +0200 Subject: [PATCH 05/10] Check if a certificate already exists before trying to read it --- admin/mailu/tlstasks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/mailu/tlstasks.py b/admin/mailu/tlstasks.py index dc139c7e..0a0c9f2a 100644 --- a/admin/mailu/tlstasks.py +++ b/admin/mailu/tlstasks.py @@ -28,7 +28,7 @@ def install_certs(domain): else: print("Could not find the proper certificate from traefik") return - if os.path.join(cert_path): + if os.path.exists(cert_path): with open(cert_path, "rb") as handler: if handler.read() == cert: return From c4120dc1328fa7b6b13ba6716f40cfd3497e3bcf Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sun, 10 Sep 2017 21:07:43 +0200 Subject: [PATCH 06/10] Install openssl for dhparam generation --- admin/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/Dockerfile b/admin/Dockerfile index 3f0d54c3..df6a92c8 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -4,7 +4,7 @@ RUN mkdir -p /app WORKDIR /app COPY requirements-prod.txt requirements.txt -RUN apk --update add --virtual build-dep openssl-dev libffi-dev python-dev build-base \ +RUN apk --update add --virtual build-dep openssl-dev libffi-dev python-dev build-base openssl \ && pip install -r requirements.txt \ && apk del build-dep From 7a4d3c0cb684fdc0dce6065bcdd34a7edf6053ed Mon Sep 17 00:00:00 2001 From: kaiyou Date: Mon, 11 Sep 2017 20:41:32 +0200 Subject: [PATCH 07/10] Generate Dovecot dh params locally until release 2.3 --- dovecot/conf/dovecot.conf | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dovecot/conf/dovecot.conf b/dovecot/conf/dovecot.conf index 44c7c7df..faa41bf7 100644 --- a/dovecot/conf/dovecot.conf +++ b/dovecot/conf/dovecot.conf @@ -58,7 +58,10 @@ namespace inbox { ssl = yes ssl_cert = Date: Sat, 16 Sep 2017 17:53:16 +0200 Subject: [PATCH 08/10] Redirect / to /webmail by default --- admin/mailu/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/admin/mailu/__init__.py b/admin/mailu/__init__.py index 85b908c4..df5851d1 100644 --- a/admin/mailu/__init__.py +++ b/admin/mailu/__init__.py @@ -71,4 +71,4 @@ app.register_blueprint(admin.app, url_prefix='/admin') @app.route("/") def index(): - return flask.redirect(flask.url_for("admin.index")) + return flask.redirect("/webmail") From a138bed95e1fd0192b0b14a26d57bd32371acde6 Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sat, 16 Sep 2017 18:12:21 +0200 Subject: [PATCH 09/10] Fix the docker-compose.yml so that / is handled by the admin container --- admin/mailu/__init__.py | 2 +- docker-compose.yml.dist | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/admin/mailu/__init__.py b/admin/mailu/__init__.py index 43717181..d6f6a457 100644 --- a/admin/mailu/__init__.py +++ b/admin/mailu/__init__.py @@ -73,4 +73,4 @@ app.register_blueprint(admin.app, url_prefix='/admin') @app.route("/") def index(): - return flask.redirect("/webmail") + return flask.redirect("/webmail/") diff --git a/docker-compose.yml.dist b/docker-compose.yml.dist index 0dfb19ae..103fdcaa 100644 --- a/docker-compose.yml.dist +++ b/docker-compose.yml.dist @@ -91,6 +91,7 @@ services: labels: - traefik.enable=true - traefik.frontend.rule=Host:$HOSTNAME;PathPrefix:/admin/ + - traefik.root.frontend.rule=Host:$HOSTNAME;Path:/ - traefik.port=80 restart: always env_file: .env @@ -108,7 +109,6 @@ services: labels: - traefik.enable=true - traefik.frontend.rule=Host:$HOSTNAME;PathPrefix:/webmail/ - - traefik.root.frontend.rule=Host:$HOSTNAME;Path:/;AddPrefix:/webmail/ - traefik.port=80 restart: always env_file: .env From 00d09cf38614c59ffc08ac6567eb5679e7ba9748 Mon Sep 17 00:00:00 2001 From: kaiyou Date: Sat, 16 Sep 2017 19:24:55 +0200 Subject: [PATCH 10/10] Fix the traefik labels in the compose configuration --- docker-compose.yml.dist | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docker-compose.yml.dist b/docker-compose.yml.dist index 103fdcaa..85626cc6 100644 --- a/docker-compose.yml.dist +++ b/docker-compose.yml.dist @@ -90,9 +90,10 @@ services: image: mailu/admin:$VERSION labels: - traefik.enable=true - - traefik.frontend.rule=Host:$HOSTNAME;PathPrefix:/admin/ - - traefik.root.frontend.rule=Host:$HOSTNAME;Path:/ - - traefik.port=80 + - traefik.admin.frontend.rule=Host:$HOSTNAME;PathPrefix:/admin/ + - traefik.admin.port=80 + - traefik.home.frontend.rule=Host:$HOSTNAME;Path:/ + - traefik.home.port=80 restart: always env_file: .env ports: