Skip to content

Commit 9cfc6bc

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 38cc1c7 commit 9cfc6bc

15 files changed

Lines changed: 722 additions & 24 deletions

File tree

.github/workflows/test-and-deploy-ipv4only.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,25 @@ jobs:
103103
- name: cmdeploy dns
104104
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
105105

106+
test-tls-external:
107+
name: test tls_external_cert_and_key
108+
needs: deploy
109+
runs-on: ubuntu-latest
110+
timeout-minutes: 15
111+
environment:
112+
name: staging-ipv4.testrun.org
113+
concurrency: staging-ipv4.testrun.org
114+
steps:
115+
- uses: actions/checkout@v4
116+
- run: scripts/initenv.sh
117+
- name: append venv/bin to PATH
118+
run: echo venv/bin >>$GITHUB_PATH
119+
- name: prepare SSH
120+
run: |
121+
mkdir -p ~/.ssh
122+
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
123+
chmod 600 ~/.ssh/id_ed25519
124+
ssh-keyscan staging-ipv4.testrun.org >> ~/.ssh/known_hosts 2>/dev/null
125+
- name: run tls_external e2e test
126+
run: python -m cmdeploy.tests.setup_tls_external staging-ipv4.testrun.org
127+

.github/workflows/test-and-deploy.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,25 @@ jobs:
9696
- name: cmdeploy dns
9797
run: cmdeploy dns -v
9898

99+
test-tls-external:
100+
name: test tls_external_cert_and_key
101+
needs: deploy
102+
runs-on: ubuntu-latest
103+
timeout-minutes: 15
104+
environment:
105+
name: staging2.testrun.org
106+
concurrency: staging2.testrun.org
107+
steps:
108+
- uses: actions/checkout@v4
109+
- run: scripts/initenv.sh
110+
- name: append venv/bin to PATH
111+
run: echo venv/bin >>$GITHUB_PATH
112+
- name: prepare SSH
113+
run: |
114+
mkdir -p ~/.ssh
115+
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
116+
chmod 600 ~/.ssh/id_ed25519
117+
ssh-keyscan staging2.testrun.org >> ~/.ssh/known_hosts 2>/dev/null
118+
- name: run tls_external e2e test
119+
run: python -m cmdeploy.tests.setup_tls_external staging2.testrun.org
120+

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: 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: 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
}

0 commit comments

Comments
 (0)