Skip to content

Commit 9258c8c

Browse files
committed
feat: support externally managed TLS via tls_external_cert_and_key option
Adds a new tls_external_cert_and_key config option for chatmail servers that manage their own TLS certificates (e.g. via an external ACME client or a load balancer). A systemd path unit (tls-cert-reload.path) watches the certificate file via inotify and automatically reloads dovecot and nginx when it changes. Postfix reads certs per TLS handshake so needs no reload. Also extracts openssl_selfsigned_args() so cert generation parameters are shared between SelfSignedTlsDeployer and the e2e test.
1 parent 2ce9e5f commit 9258c8c

14 files changed

Lines changed: 713 additions & 23 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: test tls_external_cert_and_key on staging2.testrun.org
2+
3+
on:
4+
workflow_run:
5+
workflows:
6+
- "deploy on staging2.testrun.org, and run tests"
7+
types:
8+
- completed
9+
10+
jobs:
11+
test-tls-external:
12+
name: test tls_external_cert_and_key
13+
runs-on: ubuntu-latest
14+
timeout-minutes: 30
15+
concurrency: staging2.testrun.org
16+
environment:
17+
name: staging2.testrun.org
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- name: prepare SSH
23+
run: |
24+
mkdir -p ~/.ssh
25+
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
26+
chmod 600 ~/.ssh/id_ed25519
27+
ssh-keyscan staging2.testrun.org >> ~/.ssh/known_hosts 2>/dev/null
28+
29+
- run: scripts/initenv.sh
30+
31+
- name: append venv/bin to PATH
32+
run: echo venv/bin >>$GITHUB_PATH
33+
34+
- name: run tls_external e2e test
35+
run: |
36+
python -m cmdeploy.tests.setup_tls_external \
37+
staging2.testrun.org

chatmaild/src/chatmaild/config.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,24 @@ def __init__(self, inipath, params):
6060
self.privacy_pdo = params.get("privacy_pdo")
6161
self.privacy_supervisor = params.get("privacy_supervisor")
6262

63-
# TLS certificate management: derived from the domain name.
64-
# Domains starting with "_" use self-signed certificates
65-
# All other domains use ACME.
66-
if self.mail_domain.startswith("_"):
63+
# TLS certificate management.
64+
# If tls_external_cert_and_key is set, use externally managed certs.
65+
# Otherwise derived from the domain name:
66+
# - Domains starting with "_" use self-signed certificates
67+
# - All other domains use ACME.
68+
external = params.get("tls_external_cert_and_key", "").strip()
69+
70+
if external:
71+
parts = external.split()
72+
if len(parts) != 2:
73+
raise ValueError(
74+
"tls_external_cert_and_key must have two space-separated"
75+
" paths: CERT_PATH KEY_PATH"
76+
)
77+
self.tls_cert_mode = "external"
78+
self.tls_cert_path = parts[0]
79+
self.tls_key_path = parts[1]
80+
elif self.mail_domain.startswith("_"):
6781
self.tls_cert_mode = "self"
6882
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
6983
self.tls_key_path = "/etc/ssl/private/mailserver.key"

chatmaild/src/chatmaild/ini/chatmail.ini.f

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
# (space-separated, item may start with "@" to whitelist whole recipient domains)
4949
passthrough_recipients =
5050

51+
# To use externally managed TLS certificates (e.g. from Traefik or certbot),
52+
# provide space-separated paths to the certificate chain and private key.
53+
# This skips the built-in acmetool certificate management.
54+
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
55+
5156
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
5257
#www_folder = www
5358

chatmaild/src/chatmaild/tests/test_config.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,36 @@ def test_config_tls_self(make_config):
8787
assert config.tls_cert_mode == "self"
8888
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
8989
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
90+
91+
92+
def test_config_tls_external(make_config):
93+
config = make_config(
94+
"chat.example.org",
95+
{
96+
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
97+
},
98+
)
99+
assert config.tls_cert_mode == "external"
100+
assert config.tls_cert_path == "/custom/fullchain.pem"
101+
assert config.tls_key_path == "/custom/privkey.pem"
102+
103+
104+
def test_config_tls_external_overrides_underscore(make_config):
105+
config = make_config(
106+
"_test.example.org",
107+
{
108+
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
109+
},
110+
)
111+
assert config.tls_cert_mode == "external"
112+
assert config.tls_cert_path == "/certs/fullchain.pem"
113+
114+
115+
def test_config_tls_external_bad_format(make_config):
116+
with pytest.raises(ValueError, match="two space-separated"):
117+
make_config(
118+
"chat.example.org",
119+
{
120+
"tls_external_cert_and_key": "/only/one/path.pem",
121+
},
122+
)

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from cmdeploy.cmdeploy import Out
2020

2121
from .acmetool import AcmetoolDeployer
22-
from .selfsigned.deployer import SelfSignedTlsDeployer
22+
from .external.deployer import ExternalTlsDeployer
2323
from .basedeploy import (
2424
Deployer,
2525
Deployment,
@@ -33,6 +33,7 @@
3333
from .nginx.deployer import NginxDeployer
3434
from .opendkim.deployer import OpendkimDeployer
3535
from .postfix.deployer import PostfixDeployer
36+
from .selfsigned.deployer import SelfSignedTlsDeployer
3637
from .www import build_webpages, find_merge_conflict, get_paths
3738

3839

@@ -536,6 +537,20 @@ def activate(self):
536537
)
537538

538539

540+
def get_tls_deployer(config, mail_domain):
541+
"""Select the appropriate TLS deployer based on config."""
542+
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
543+
544+
if config.tls_cert_mode == "acme":
545+
return AcmetoolDeployer(config.acme_email, tls_domains)
546+
elif config.tls_cert_mode == "self":
547+
return SelfSignedTlsDeployer(mail_domain)
548+
elif config.tls_cert_mode == "external":
549+
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
550+
else:
551+
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
552+
553+
539554
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
540555
"""Deploy a chat-mail instance.
541556
@@ -599,12 +614,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
599614
)
600615
exit(1)
601616

602-
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
603-
604-
if config.tls_cert_mode == "acme":
605-
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
606-
else:
607-
tls_deployer = SelfSignedTlsDeployer(mail_domain)
617+
tls_deployer = get_tls_deployer(config, mail_domain)
608618

609619
all_deployers = [
610620
ChatmailDeployer(mail_domain),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from pyinfra.operations import files, server, systemd
2+
3+
from cmdeploy.basedeploy import Deployer, get_resource
4+
5+
6+
class ExternalTlsDeployer(Deployer):
7+
"""Expects TLS certificates to be managed on the server.
8+
9+
Validates that the configured certificate and key files
10+
exist on the remote host. Installs a systemd path unit
11+
that watches the certificate file and automatically
12+
restarts/reloads affected services when it changes.
13+
"""
14+
15+
def __init__(self, cert_path, key_path):
16+
self.cert_path = cert_path
17+
self.key_path = key_path
18+
19+
def configure(self):
20+
server.shell(
21+
name="Verify external TLS certificate and key exist",
22+
commands=[
23+
f"test -f {self.cert_path} && test -f {self.key_path}",
24+
],
25+
)
26+
27+
# Deploy the .path unit (templated with the cert path).
28+
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
29+
content = source.read_text().format(cert_path=self.cert_path).encode()
30+
31+
import io
32+
33+
path_unit = files.put(
34+
name="Upload tls-cert-reload.path",
35+
src=io.BytesIO(content),
36+
dest="/etc/systemd/system/tls-cert-reload.path",
37+
user="root",
38+
group="root",
39+
mode="644",
40+
)
41+
42+
service_unit = files.put(
43+
name="Upload tls-cert-reload.service",
44+
src=get_resource("tls-cert-reload.service", pkg=__package__),
45+
dest="/etc/systemd/system/tls-cert-reload.service",
46+
user="root",
47+
group="root",
48+
mode="644",
49+
)
50+
51+
if path_unit.changed or service_unit.changed:
52+
self.need_restart = True
53+
54+
def activate(self):
55+
systemd.service(
56+
name="Enable tls-cert-reload path watcher",
57+
service="tls-cert-reload.path",
58+
running=True,
59+
enabled=True,
60+
restarted=self.need_restart,
61+
daemon_reload=self.need_restart,
62+
)
63+
# Always trigger a reload so services pick up the current cert.
64+
# The path unit handles future changes via inotify.
65+
server.shell(
66+
name="Reload TLS services for current certificate",
67+
commands=["systemctl start tls-cert-reload.service"],
68+
)
69+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Watch the TLS certificate file for changes.
2+
# When the cert is updated (e.g. renewed by an external process),
3+
# this triggers tls-cert-reload.service to restart the affected services.
4+
[Unit]
5+
Description=Watch TLS certificate for changes
6+
7+
[Path]
8+
PathChanged={cert_path}
9+
10+
[Install]
11+
WantedBy=multi-user.target
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Reload services that cache the TLS certificate.
2+
#
3+
# dovecot: caches the cert at startup; reload re-reads SSL certs
4+
# without dropping existing connections.
5+
# nginx: caches the cert at startup; reload gracefully picks up
6+
# the new cert for new connections.
7+
# postfix: reads the cert fresh on each TLS handshake,
8+
# does NOT need a reload/restart.
9+
[Unit]
10+
Description=Reload TLS services after certificate change
11+
12+
[Service]
13+
Type=oneshot
14+
ExecStart=/bin/systemctl reload dovecot
15+
ExecStart=/bin/systemctl reload nginx

cmdeploy/src/cmdeploy/nginx/nginx.conf.j2

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ http {
8484
}
8585

8686
location /new {
87-
{% if config.tls_cert_mode == "acme" %}
87+
{% if config.tls_cert_mode != "self" %}
8888
if ($request_method = GET) {
8989
# Redirect to Delta Chat,
9090
# which will in turn do a POST request.
@@ -106,7 +106,7 @@ http {
106106
#
107107
# Redirects are only for browsers.
108108
location /cgi-bin/newemail.py {
109-
{% if config.tls_cert_mode == "acme" %}
109+
{% if config.tls_cert_mode != "self" %}
110110
if ($request_method = GET) {
111111
return 301 dcaccount:https://{{ config.mail_domain }}/new;
112112
}

cmdeploy/src/cmdeploy/selfsigned/deployer.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1+
import shlex
2+
13
from pyinfra.operations import apt, files, server
24

35
from cmdeploy.basedeploy import Deployer
46

57

8+
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
9+
"""Return the openssl argument list for a self-signed certificate.
10+
11+
The certificate uses an EC P-256 key with SAN entries for *domain*,
12+
``www.<domain>`` and ``mta-sts.<domain>``.
13+
"""
14+
return [
15+
"openssl", "req", "-x509",
16+
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
17+
"-noenc", "-days", str(days),
18+
"-keyout", str(key_path),
19+
"-out", str(cert_path),
20+
"-subj", f"/CN={domain}",
21+
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
22+
"-addext",
23+
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
24+
]
25+
26+
627
class SelfSignedTlsDeployer(Deployer):
728
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
829

@@ -18,18 +39,13 @@ def install(self):
1839
)
1940

2041
def configure(self):
42+
args = openssl_selfsigned_args(
43+
self.mail_domain, self.cert_path, self.key_path,
44+
)
45+
cmd = shlex.join(args)
2146
server.shell(
2247
name="Generate self-signed TLS certificate if not present",
23-
commands=[
24-
f"[ -f {self.cert_path} ] || openssl req -x509"
25-
f" -newkey ec -pkeyopt ec_paramgen_curve:P-256"
26-
f" -noenc -days 36500"
27-
f" -keyout {self.key_path}"
28-
f" -out {self.cert_path}"
29-
f' -subj "/CN={self.mail_domain}"'
30-
f' -addext "extendedKeyUsage=serverAuth,clientAuth"'
31-
f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"',
32-
],
48+
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
3349
)
3450

3551
def activate(self):

0 commit comments

Comments
 (0)