Skip to content

Commit 8b64d97

Browse files
committed
feat: support externally managed TLS via tls_external_cert_and_key option
1 parent cf96be2 commit 8b64d97

9 files changed

Lines changed: 234 additions & 10 deletions

File tree

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/cmdeploy.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@ def run_cmd(args, out):
9494
strict_tls = args.config.tls_cert_mode == "acme"
9595
if not args.dns_check_disabled:
9696
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
97-
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
97+
if not dns.check_initial_remote_data(
98+
remote_data, strict_tls=strict_tls, print=out.red
99+
):
98100
return 1
99101

100102
env = os.environ.copy()
@@ -125,7 +127,11 @@ def run_cmd(args, out):
125127
out.red("Website deployment failed.")
126128
elif retcode == 0:
127129
out.green("Deploy completed, call `cmdeploy dns` next.")
128-
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
130+
elif (
131+
not args.dns_check_disabled
132+
and strict_tls
133+
and not remote_data["acme_account_url"]
134+
):
129135
out.red("Deploy completed but letsencrypt not configured")
130136
out.red("Run 'cmdeploy run' again")
131137
retcode = 0

cmdeploy/src/cmdeploy/deployers.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,15 @@
1010

1111
from chatmaild.config import read_config
1212
from pyinfra import facts, host, logger
13-
from pyinfra.facts import hardware
1413
from pyinfra.api import FactBase
14+
from pyinfra.facts import hardware
1515
from pyinfra.facts.files import Sha256File
1616
from pyinfra.facts.systemd import SystemdEnabled
1717
from pyinfra.operations import apt, files, pip, server, systemd
1818

1919
from cmdeploy.cmdeploy import Out
2020

2121
from .acmetool import AcmetoolDeployer
22-
from .selfsigned.deployer import SelfSignedTlsDeployer
2322
from .basedeploy import (
2423
Deployer,
2524
Deployment,
@@ -33,6 +32,7 @@
3332
from .nginx.deployer import NginxDeployer
3433
from .opendkim.deployer import OpendkimDeployer
3534
from .postfix.deployer import PostfixDeployer
35+
from .selfsigned.deployer import SelfSignedTlsDeployer
3636
from .www import build_webpages, find_merge_conflict, get_paths
3737

3838

@@ -560,11 +560,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
560560
)
561561

562562
# Check if mtail_address interface is available (if configured)
563-
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
563+
if config.mtail_address and config.mtail_address not in (
564+
"127.0.0.1",
565+
"::1",
566+
"localhost",
567+
):
564568
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
565569
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
566570
if config.mtail_address not in all_addresses:
567-
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
571+
Out().red(
572+
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
573+
)
568574
exit(1)
569575

570576
port_services = [
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from pyinfra.operations import server
2+
3+
from cmdeploy.basedeploy import Deployer
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.
11+
"""
12+
13+
def __init__(self, cert_path, key_path):
14+
self.cert_path = cert_path
15+
self.key_path = key_path
16+
17+
def configure(self):
18+
server.shell(
19+
name="Verify external TLS certificate and key exist",
20+
commands=[
21+
f"test -f {self.cert_path} && test -f {self.key_path}",
22+
],
23+
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Functional tests for tls_external_cert_and_key option."""
2+
3+
import json
4+
5+
import chatmaild.newemail
6+
import pytest
7+
from chatmaild.config import read_config, write_initial_config
8+
9+
10+
def test_external_tls_config_reads_paths(tmp_path):
11+
"""Config correctly reads explicit cert/key paths."""
12+
inipath = tmp_path / "chatmail.ini"
13+
write_initial_config(
14+
inipath,
15+
"chat.example.org",
16+
overrides={
17+
"tls_external_cert_and_key": (
18+
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
19+
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
20+
),
21+
},
22+
)
23+
config = read_config(inipath)
24+
assert config.tls_cert_mode == "external"
25+
assert (
26+
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
27+
)
28+
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
29+
30+
31+
def test_external_tls_missing_option_uses_acme(tmp_path):
32+
"""Without tls_external_cert_and_key, normal domain uses acme."""
33+
inipath = tmp_path / "chatmail.ini"
34+
write_initial_config(inipath, "chat.example.org", overrides={})
35+
config = read_config(inipath)
36+
assert config.tls_cert_mode == "acme"
37+
38+
39+
def test_external_tls_overrides_domain_detection(tmp_path):
40+
"""tls_external_cert_and_key overrides underscore-domain self-signed detection."""
41+
inipath = tmp_path / "chatmail.ini"
42+
write_initial_config(
43+
inipath,
44+
"_test.example.org",
45+
overrides={
46+
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
47+
},
48+
)
49+
config = read_config(inipath)
50+
assert config.tls_cert_mode == "external"
51+
assert config.tls_cert_path == "/certs/fullchain.pem"
52+
assert config.tls_key_path == "/certs/privkey.pem"
53+
54+
55+
def test_external_tls_bad_format_raises(tmp_path):
56+
"""Raises ValueError if not exactly two space-separated paths."""
57+
inipath = tmp_path / "chatmail.ini"
58+
write_initial_config(
59+
inipath,
60+
"chat.example.org",
61+
overrides={
62+
"tls_external_cert_and_key": "/only/one/path.pem",
63+
},
64+
)
65+
with pytest.raises(ValueError, match="two space-separated"):
66+
read_config(inipath)
67+
68+
69+
def test_external_tls_three_paths_raises(tmp_path):
70+
"""Raises ValueError if three paths given."""
71+
inipath = tmp_path / "chatmail.ini"
72+
write_initial_config(
73+
inipath,
74+
"chat.example.org",
75+
overrides={
76+
"tls_external_cert_and_key": "/a /b /c",
77+
},
78+
)
79+
with pytest.raises(ValueError, match="two space-separated"):
80+
read_config(inipath)
81+
82+
83+
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
84+
"""External mode should not generate dclogin URLs (real certs, not self-signed)."""
85+
inipath = tmp_path / "chatmail.ini"
86+
write_initial_config(
87+
inipath,
88+
"chat.example.org",
89+
overrides={
90+
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
91+
},
92+
)
93+
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
94+
chatmaild.newemail.print_new_account()
95+
out, _ = capsys.readouterr()
96+
lines = out.split("\n")
97+
dic = json.loads(lines[2])
98+
assert "dclogin_url" not in dic

doc/source/getting_started.rst

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,40 @@ and all other relays will accept connections from it
198198
without requiring certificate verification.
199199
This is useful for experimental setups and testing.
200200

201+
.. _external-tls:
202+
203+
Running a relay with externally managed certificates
204+
-----------------------------------------------------
205+
206+
If you already have a TLS certificate manager
207+
(e.g. Traefik, certbot, or another ACME client)
208+
running on the deployment server,
209+
you can configure the relay to use those certificates
210+
instead of the built-in ``acmetool``.
211+
212+
Set the following in ``chatmail.ini``::
213+
214+
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
215+
216+
The paths must point to certificate and key files
217+
on the deployment server.
218+
During ``cmdeploy run``, these paths are written into
219+
the Postfix, Dovecot, and Nginx configurations.
220+
No certificate files are transferred from the build machine —
221+
they must already exist on the server,
222+
managed by your external certificate tool.
223+
224+
The deploy will verify that both files exist on the server.
225+
``acmetool`` is **not** installed or run in this mode.
226+
227+
.. note::
228+
229+
You are responsible for certificate renewal.
230+
When certificates are renewed, restart Postfix and Dovecot
231+
to pick up the new files
232+
(Nginx will pick them up automatically on the next connection).
233+
234+
201235
Migrating to a new build machine
202236
----------------------------------
203237

doc/source/overview.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,11 @@ When providing a TLS certificate to your chatmail relay server, make
308308
sure to provide the full certificate chain and not just the last
309309
certificate.
310310

311+
If you use an external certificate manager (e.g. Traefik or certbot),
312+
set ``tls_external_cert_and_key`` in ``chatmail.ini``
313+
to provide the certificate and key paths.
314+
See :ref:`external-tls` for details.
315+
311316
If you are running an Exim server and don’t see incoming connections
312317
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
313318
item is enabled in the config with ``log_selector = +smtp_no_mail``. By

0 commit comments

Comments
 (0)