Skip to content

Commit 92a52b9

Browse files
committed
feat: support externally managed TLS via tls_external_cert_and_key option (#860)
* 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 8ca0909 commit 92a52b9

14 files changed

Lines changed: 335 additions & 35 deletions

File tree

chatmaild/src/chatmaild/config.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,23 @@ 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, self.tls_key_path = parts
79+
elif self.mail_domain.startswith("_"):
6780
self.tls_cert_mode = "self"
6881
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
6982
self.tls_key_path = "/etc/ssl/private/mailserver.key"

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

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

51+
# Use externally managed TLS certificates instead of built-in acmetool.
52+
# Paths refer to files on the deployment server (not the build machine).
53+
# Both files must already exist before running cmdeploy.
54+
# Certificate renewal is your responsibility; changed files are
55+
# picked up automatically by all relay services.
56+
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
57+
5158
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
5259
#www_folder = www
5360

chatmaild/src/chatmaild/tests/test_config.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,37 @@ 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+
assert config.tls_key_path == "/certs/privkey.pem"
114+
115+
116+
def test_config_tls_external_bad_format(make_config):
117+
with pytest.raises(ValueError, match="two space-separated"):
118+
make_config(
119+
"chat.example.org",
120+
{
121+
"tls_external_cert_and_key": "/only/one/path.pem",
122+
},
123+
)

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,15 @@
1111

1212
from chatmaild.config import read_config
1313
from pyinfra import facts, host, logger
14-
from pyinfra.facts import hardware
1514
from pyinfra.api import FactBase
15+
from pyinfra.facts import hardware
1616
from pyinfra.facts.files import Sha256File
1717
from pyinfra.facts.systemd import SystemdEnabled
1818
from pyinfra.operations import apt, files, pip, server, systemd
1919

2020
from cmdeploy.cmdeploy import Out
2121

2222
from .acmetool import AcmetoolDeployer
23-
from .selfsigned.deployer import SelfSignedTlsDeployer
2423
from .basedeploy import (
2524
Deployer,
2625
Deployment,
@@ -30,11 +29,13 @@
3029
has_systemd,
3130
)
3231
from .dovecot.deployer import DovecotDeployer
32+
from .external.deployer import ExternalTlsDeployer
3333
from .filtermail.deployer import FiltermailDeployer
3434
from .mtail.deployer import MtailDeployer
3535
from .nginx.deployer import NginxDeployer
3636
from .opendkim.deployer import OpendkimDeployer
3737
from .postfix.deployer import PostfixDeployer
38+
from .selfsigned.deployer import SelfSignedTlsDeployer
3839
from .www import build_webpages, find_merge_conflict, get_paths
3940

4041

@@ -540,6 +541,20 @@ def activate(self):
540541
)
541542

542543

544+
def get_tls_deployer(config, mail_domain):
545+
"""Select the appropriate TLS deployer based on config."""
546+
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
547+
548+
if config.tls_cert_mode == "acme":
549+
return AcmetoolDeployer(config.acme_email, tls_domains)
550+
elif config.tls_cert_mode == "self":
551+
return SelfSignedTlsDeployer(mail_domain)
552+
elif config.tls_cert_mode == "external":
553+
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
554+
else:
555+
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
556+
557+
543558
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
544559
"""Deploy a chat-mail instance.
545560
@@ -608,12 +623,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
608623
)
609624
exit(1)
610625

611-
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
612-
613-
if config.tls_cert_mode == "acme":
614-
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
615-
else:
616-
tls_deployer = SelfSignedTlsDeployer(mail_domain)
626+
tls_deployer = get_tls_deployer(config, mail_domain)
617627

618628
all_deployers = [
619629
ChatmailDeployer(mail_domain),
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import io
2+
3+
from pyinfra import host
4+
from pyinfra.facts.files import File
5+
from pyinfra.operations import files, systemd
6+
7+
from cmdeploy.basedeploy import Deployer, get_resource
8+
9+
10+
class ExternalTlsDeployer(Deployer):
11+
"""Expects TLS certificates to be managed on the server.
12+
13+
Validates that the configured certificate and key files
14+
exist on the remote host. Installs a systemd path unit
15+
that watches the certificate file and automatically
16+
restarts/reloads affected services when it changes.
17+
"""
18+
19+
def __init__(self, cert_path, key_path):
20+
self.cert_path = cert_path
21+
self.key_path = key_path
22+
23+
def configure(self):
24+
# Verify cert and key exist on the remote host using pyinfra facts.
25+
for path in (self.cert_path, self.key_path):
26+
info = host.get_fact(File, path=path)
27+
if info is None:
28+
raise Exception(f"External TLS file not found on server: {path}")
29+
30+
# Deploy the .path unit (templated with the cert path).
31+
# pkg=__package__ is required here because the resource files
32+
# live in cmdeploy.external, not the default cmdeploy package.
33+
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
34+
content = source.read_text().format(cert_path=self.cert_path).encode()
35+
36+
path_unit = files.put(
37+
name="Upload tls-cert-reload.path",
38+
src=io.BytesIO(content),
39+
dest="/etc/systemd/system/tls-cert-reload.path",
40+
user="root",
41+
group="root",
42+
mode="644",
43+
)
44+
45+
service_unit = files.put(
46+
name="Upload tls-cert-reload.service",
47+
src=get_resource("tls-cert-reload.service", pkg=__package__),
48+
dest="/etc/systemd/system/tls-cert-reload.service",
49+
user="root",
50+
group="root",
51+
mode="644",
52+
)
53+
54+
if path_unit.changed or service_unit.changed:
55+
self.need_restart = True
56+
57+
def activate(self):
58+
systemd.service(
59+
name="Enable tls-cert-reload path watcher",
60+
service="tls-cert-reload.path",
61+
running=True,
62+
enabled=True,
63+
restarted=self.need_restart,
64+
daemon_reload=self.need_restart,
65+
)
66+
# No explicit reload needed here: dovecot/nginx read the cert
67+
# on startup, and the .path watcher handles live changes.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 reload the affected services.
4+
#
5+
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
6+
# After cert renewal, you must then trigger the reload explicitly:
7+
# systemctl start tls-cert-reload.service
8+
[Unit]
9+
Description=Watch TLS certificate for changes
10+
11+
[Path]
12+
PathChanged={cert_path}
13+
14+
[Install]
15+
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 try-reload-or-restart dovecot
15+
ExecStart=/bin/systemctl try-reload-or-restart 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: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,29 @@
1-
from pyinfra.operations import apt, files, server
1+
import shlex
2+
3+
from pyinfra.operations import apt, 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):

cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,13 @@ def parse_size_limit(limit: str) -> int:
9898

9999
lp.sec("ac2: check quota is triggered")
100100

101-
starting = True
102-
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
103-
if starting:
104-
chat.send_text("hello")
105-
starting = False
101+
def send_hello():
102+
chat.send_text("hello")
103+
104+
for line in remote.iter_output(
105+
"journalctl -n1 -f -u dovecot", ready=send_hello
106+
):
106107
if user not in line:
107-
# print(line)
108108
continue
109109
if "quota exceeded" in line:
110110
return

0 commit comments

Comments
 (0)