Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,23 @@ def __init__(self, inipath, params):
self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor")

# TLS certificate management: derived from the domain name.
# Domains starting with "_" use self-signed certificates
# All other domains use ACME.
if self.mail_domain.startswith("_"):
# TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates
# - All other domains use ACME.
external = params.get("tls_external_cert_and_key", "").strip()

if external:
parts = external.split()
if len(parts) != 2:
raise ValueError(
"tls_external_cert_and_key must have two space-separated"
" paths: CERT_PATH KEY_PATH"
)
self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
Expand Down
7 changes: 7 additions & 0 deletions chatmaild/src/chatmaild/ini/chatmail.ini.f
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@
# (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients =

# Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy.
# Certificate renewal is your responsibility; changed files are
# picked up automatically by all relay services.
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem

# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
#www_folder = www

Expand Down
34 changes: 34 additions & 0 deletions chatmaild/src/chatmaild/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,37 @@ def test_config_tls_self(make_config):
assert config.tls_cert_mode == "self"
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"


def test_config_tls_external(make_config):
config = make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/custom/fullchain.pem"
assert config.tls_key_path == "/custom/privkey.pem"


def test_config_tls_external_overrides_underscore(make_config):
config = make_config(
"_test.example.org",
{
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/certs/fullchain.pem"
Comment thread
hpk42 marked this conversation as resolved.
assert config.tls_key_path == "/certs/privkey.pem"


def test_config_tls_external_bad_format(make_config):
with pytest.raises(ValueError, match="two space-separated"):
make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/only/one/path.pem",
},
)
26 changes: 18 additions & 8 deletions cmdeploy/src/cmdeploy/deployers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,15 @@

from chatmaild.config import read_config
from pyinfra import facts, host, logger
from pyinfra.facts import hardware
from pyinfra.api import FactBase
from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd

from cmdeploy.cmdeploy import Out

from .acmetool import AcmetoolDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .basedeploy import (
Deployer,
Deployment,
Expand All @@ -30,11 +29,13 @@
has_systemd,
)
from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths


Expand Down Expand Up @@ -540,6 +541,20 @@ def activate(self):
)


def get_tls_deployer(config, mail_domain):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High-level problem with this is that if you have a server with acmetool and want to reconfigure with external certificate, acmetool does not get uninstalled.

I think what we need is not selecting one deployer, but run all deployers with some option like enabled=... that tells the deployer if it should deploy or un-deploy acme or tls-cert-reload services.

Copy link
Copy Markdown
Contributor Author

@hpk42 hpk42 Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is addressed in the separate #869 (the first commit, the second does some follow up refactoring)

all other review comments were fixed in the branch here.

"""Select the appropriate TLS deployer based on config."""
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]

if config.tls_cert_mode == "acme":
return AcmetoolDeployer(config.acme_email, tls_domains)
elif config.tls_cert_mode == "self":
return SelfSignedTlsDeployer(mail_domain)
elif config.tls_cert_mode == "external":
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
else:
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")


def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
"""Deploy a chat-mail instance.

Expand Down Expand Up @@ -608,12 +623,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
)
exit(1)

tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]

if config.tls_cert_mode == "acme":
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
else:
tls_deployer = SelfSignedTlsDeployer(mail_domain)
tls_deployer = get_tls_deployer(config, mail_domain)

all_deployers = [
ChatmailDeployer(mail_domain),
Expand Down
67 changes: 67 additions & 0 deletions cmdeploy/src/cmdeploy/external/deployer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import io

from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import files, systemd

from cmdeploy.basedeploy import Deployer, get_resource


class ExternalTlsDeployer(Deployer):
"""Expects TLS certificates to be managed on the server.

Validates that the configured certificate and key files
exist on the remote host. Installs a systemd path unit
that watches the certificate file and automatically
restarts/reloads affected services when it changes.
"""

def __init__(self, cert_path, key_path):
self.cert_path = cert_path
self.key_path = key_path

def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}")

# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
Comment thread
hpk42 marked this conversation as resolved.
content = source.read_text().format(cert_path=self.cert_path).encode()

path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
)

service_unit = files.put(
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
Comment thread
hpk42 marked this conversation as resolved.
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
)

if path_unit.changed or service_unit.changed:
self.need_restart = True

def activate(self):
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.
15 changes: 15 additions & 0 deletions cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Watch the TLS certificate file for changes.
# When the cert is updated (e.g. renewed by an external process),
# this triggers tls-cert-reload.service to reload the affected services.
#
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
# After cert renewal, you must then trigger the reload explicitly:
# systemctl start tls-cert-reload.service
[Unit]
Description=Watch TLS certificate for changes

[Path]
PathChanged={cert_path}

[Install]
WantedBy=multi-user.target
15 changes: 15 additions & 0 deletions cmdeploy/src/cmdeploy/external/tls-cert-reload.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Reload services that cache the TLS certificate.
#
# dovecot: caches the cert at startup; reload re-reads SSL certs
# without dropping existing connections.
# nginx: caches the cert at startup; reload gracefully picks up
# the new cert for new connections.
# postfix: reads the cert fresh on each TLS handshake,
# does NOT need a reload/restart.
[Unit]
Description=Reload TLS services after certificate change

[Service]
Type=oneshot
ExecStart=/bin/systemctl try-reload-or-restart dovecot
ExecStart=/bin/systemctl try-reload-or-restart nginx
4 changes: 2 additions & 2 deletions cmdeploy/src/cmdeploy/nginx/nginx.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ http {
}

location /new {
{% if config.tls_cert_mode == "acme" %}
{% if config.tls_cert_mode != "self" %}
if ($request_method = GET) {
# Redirect to Delta Chat,
# which will in turn do a POST request.
Expand All @@ -106,7 +106,7 @@ http {
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
{% if config.tls_cert_mode == "acme" %}
{% if config.tls_cert_mode != "self" %}
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.mail_domain }}/new;
}
Expand Down
38 changes: 27 additions & 11 deletions cmdeploy/src/cmdeploy/selfsigned/deployer.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,29 @@
from pyinfra.operations import apt, files, server
import shlex

from pyinfra.operations import apt, server

from cmdeploy.basedeploy import Deployer


def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"""Return the openssl argument list for a self-signed certificate.

The certificate uses an EC P-256 key with SAN entries for *domain*,
``www.<domain>`` and ``mta-sts.<domain>``.
"""
return [
"openssl", "req", "-x509",
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
"-noenc", "-days", str(days),
"-keyout", str(key_path),
"-out", str(cert_path),
"-subj", f"/CN={domain}",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
]


class SelfSignedTlsDeployer(Deployer):
"""Generates a self-signed TLS certificate for all chatmail endpoints."""

Expand All @@ -18,18 +39,13 @@ def install(self):
)

def configure(self):
args = openssl_selfsigned_args(
self.mail_domain, self.cert_path, self.key_path,
)
cmd = shlex.join(args)
server.shell(
name="Generate self-signed TLS certificate if not present",
commands=[
f"[ -f {self.cert_path} ] || openssl req -x509"
f" -newkey ec -pkeyopt ec_paramgen_curve:P-256"
f" -noenc -days 36500"
f" -keyout {self.key_path}"
f" -out {self.cert_path}"
f' -subj "/CN={self.mail_domain}"'
f' -addext "extendedKeyUsage=serverAuth,clientAuth"'
f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"',
],
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
)

def activate(self):
Expand Down
12 changes: 6 additions & 6 deletions cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,13 @@ def parse_size_limit(limit: str) -> int:

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

starting = True
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if starting:
chat.send_text("hello")
starting = False
def send_hello():
chat.send_text("hello")

for line in remote.iter_output(
"journalctl -n1 -f -u dovecot", ready=send_hello
):
if user not in line:
# print(line)
continue
if "quota exceeded" in line:
return
Expand Down
10 changes: 6 additions & 4 deletions cmdeploy/src/cmdeploy/tests/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain

def iter_output(self, logcmd=""):
def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
Expand All @@ -410,10 +410,12 @@ def iter_output(self, logcmd=""):
while 1:
line = self.popen.stdout.readline()
res = line.decode().strip().lower()
if res:
yield res
else:
if not res:
break
if ready is not None:
ready()
ready = None
yield res
Comment on lines 398 to +418
Copy link
Copy Markdown
Contributor Author

@hpk42 hpk42 Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a test fix on the side for a flaky test, took me a while to figure out. There are concurrency issues with getting log lines and triggering the send message. I think this is now reliable.



@pytest.fixture
Expand Down
Loading