From 71eca564cfbb74aa93396f7d3e0e898c5ad8d650 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 19 Feb 2026 19:18:33 +0100 Subject: [PATCH 1/6] 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. --- .../workflows/reusable-test-tls-external.yaml | 33 ++ .../workflows/test-and-deploy-ipv4only.yaml | 8 + .github/workflows/test-and-deploy.yaml | 8 + chatmaild/src/chatmaild/config.py | 22 +- chatmaild/src/chatmaild/ini/chatmail.ini.f | 7 + chatmaild/src/chatmaild/tests/test_config.py | 33 ++ cmdeploy/src/cmdeploy/deployers.py | 24 +- cmdeploy/src/cmdeploy/external/deployer.py | 69 ++++ .../cmdeploy/external/tls-cert-reload.path.f | 11 + .../cmdeploy/external/tls-cert-reload.service | 15 + cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 4 +- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 38 +- .../src/cmdeploy/tests/setup_tls_external.py | 340 ++++++++++++++++++ .../src/cmdeploy/tests/test_external_tls.py | 78 ++++ doc/source/getting_started.rst | 34 ++ doc/source/overview.rst | 5 + 16 files changed, 705 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/reusable-test-tls-external.yaml create mode 100644 cmdeploy/src/cmdeploy/external/deployer.py create mode 100644 cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f create mode 100644 cmdeploy/src/cmdeploy/external/tls-cert-reload.service create mode 100644 cmdeploy/src/cmdeploy/tests/setup_tls_external.py create mode 100644 cmdeploy/src/cmdeploy/tests/test_external_tls.py diff --git a/.github/workflows/reusable-test-tls-external.yaml b/.github/workflows/reusable-test-tls-external.yaml new file mode 100644 index 000000000..b8c33b352 --- /dev/null +++ b/.github/workflows/reusable-test-tls-external.yaml @@ -0,0 +1,33 @@ +name: test tls_external_cert_and_key + +on: + workflow_call: + inputs: + domain: + required: true + type: string + secrets: + STAGING_SSH_KEY: + required: true + +jobs: + test-tls-external: + name: test tls_external_cert_and_key + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: ${{ inputs.domain }} + concurrency: ${{ inputs.domain }} + steps: + - uses: actions/checkout@v4 + - run: scripts/initenv.sh + - name: append venv/bin to PATH + run: echo venv/bin >>$GITHUB_PATH + - name: prepare SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan ${{ inputs.domain }} >> ~/.ssh/known_hosts 2>/dev/null + - name: run tls_external e2e test + run: python -m cmdeploy.tests.setup_tls_external ${{ inputs.domain }} diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index 158bab98d..fea1a14d7 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -103,3 +103,11 @@ jobs: - name: cmdeploy dns run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost" + test-tls-external: + needs: deploy + uses: ./.github/workflows/reusable-test-tls-external.yaml + with: + domain: staging-ipv4.testrun.org + secrets: + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + diff --git a/.github/workflows/test-and-deploy.yaml b/.github/workflows/test-and-deploy.yaml index d38a88695..4ee5be9af 100644 --- a/.github/workflows/test-and-deploy.yaml +++ b/.github/workflows/test-and-deploy.yaml @@ -96,3 +96,11 @@ jobs: - name: cmdeploy dns run: cmdeploy dns -v + test-tls-external: + needs: deploy + uses: ./.github/workflows/reusable-test-tls-external.yaml + with: + domain: staging2.testrun.org + secrets: + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index af6fef0d9..8b4aab140 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -60,10 +60,24 @@ 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 = parts[0] + self.tls_key_path = parts[1] + 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" diff --git a/chatmaild/src/chatmaild/ini/chatmail.ini.f b/chatmaild/src/chatmaild/ini/chatmail.ini.f index 29d7baa9e..353a9669d 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -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 diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index 80dcb1897..a198eb7bd 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -87,3 +87,36 @@ 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" + + +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", + }, + ) diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 07f421a8e..a933d6ae0 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -20,7 +20,7 @@ from cmdeploy.cmdeploy import Out from .acmetool import AcmetoolDeployer -from .selfsigned.deployer import SelfSignedTlsDeployer +from .external.deployer import ExternalTlsDeployer from .basedeploy import ( Deployer, Deployment, @@ -35,6 +35,7 @@ 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 @@ -540,6 +541,20 @@ def activate(self): ) +def get_tls_deployer(config, mail_domain): + """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. @@ -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), diff --git a/cmdeploy/src/cmdeploy/external/deployer.py b/cmdeploy/src/cmdeploy/external/deployer.py new file mode 100644 index 000000000..e98e40ae9 --- /dev/null +++ b/cmdeploy/src/cmdeploy/external/deployer.py @@ -0,0 +1,69 @@ +from pyinfra.operations import files, server, 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): + server.shell( + name="Verify external TLS certificate and key exist", + commands=[ + f"test -f {self.cert_path} && test -f {self.key_path}", + ], + ) + + # Deploy the .path unit (templated with the cert path). + source = get_resource("tls-cert-reload.path.f", pkg=__package__) + content = source.read_text().format(cert_path=self.cert_path).encode() + + import io + + 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__), + 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, + ) + # Always trigger a reload so services pick up the current cert. + # The path unit handles future changes via inotify. + server.shell( + name="Reload TLS services for current certificate", + commands=["systemctl start tls-cert-reload.service"], + ) + diff --git a/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f b/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f new file mode 100644 index 000000000..44cb3f45a --- /dev/null +++ b/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f @@ -0,0 +1,11 @@ +# 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 restart the affected services. +[Unit] +Description=Watch TLS certificate for changes + +[Path] +PathChanged={cert_path} + +[Install] +WantedBy=multi-user.target diff --git a/cmdeploy/src/cmdeploy/external/tls-cert-reload.service b/cmdeploy/src/cmdeploy/external/tls-cert-reload.service new file mode 100644 index 000000000..7f1cde8e2 --- /dev/null +++ b/cmdeploy/src/cmdeploy/external/tls-cert-reload.service @@ -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 reload dovecot +ExecStart=/bin/systemctl reload nginx diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index b7c4bda19..67e77a805 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -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. @@ -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; } diff --git a/cmdeploy/src/cmdeploy/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py index 4bf2def21..0faff5e82 100644 --- a/cmdeploy/src/cmdeploy/selfsigned/deployer.py +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -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.`` and ``mta-sts.``. + """ + 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.""" @@ -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): diff --git a/cmdeploy/src/cmdeploy/tests/setup_tls_external.py b/cmdeploy/src/cmdeploy/tests/setup_tls_external.py new file mode 100644 index 000000000..71ff7d635 --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/setup_tls_external.py @@ -0,0 +1,340 @@ +"""Setup and verify external TLS certificates for a chatmail server. + +Generates a self-signed TLS certificate, uploads it to the chatmail +server via SCP, runs ``cmdeploy run``, and then probes all TLS-enabled +ports (nginx, postfix, dovecot) to verify the certificate is actually +served. After probing, checks remote service logs for errors. + +Prerequisites +~~~~~~~~~~~~~ +- SSH root access to the target server (same as ``cmdeploy run``) +- ``cmdeploy`` in PATH (activate the venv first) + +How to run +~~~~~~~~~~ +From the repository root:: + + # Full run: generate cert, deploy, probe ports, check services + python -m cmdeploy.tests.setup_tls_external DOMAIN + + # Re-probe only (after a previous deploy) + python -m cmdeploy.tests.setup_tls_external DOMAIN \\ + --skip-deploy --skip-certgen + + # Override SSH host (e.g. when domain doesn't resolve to the server) + python -m cmdeploy.tests.setup_tls_external DOMAIN \\ + --ssh-host staging-ipv4.testrun.org + +Arguments +~~~~~~~~~ +DOMAIN mail domain for the chatmail server (SSH root login must work) + +Options +~~~~~~~ +--skip-deploy skip ``cmdeploy run``, only probe ports +--skip-certgen skip cert generation/upload, use certs already on server +--ssh-host HOST SSH host override (defaults to DOMAIN) +""" + +import argparse +import shutil +import smtplib +import socket +import ssl +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +# Cert paths on the remote server +REMOTE_CERT = "/etc/ssl/certs/tmp_fullchain.pem" +REMOTE_KEY = "/etc/ssl/private/tmp_privkey.pem" + + +# --------------------------------------------------------------------------- +# Config generation +# --------------------------------------------------------------------------- + + +def generate_config(domain: str, config_dir: Path) -> Path: + """Generate a chatmail.ini with tls_external_cert_and_key for *domain*.""" + from chatmaild.config import write_initial_config + + ini_path = config_dir / "chatmail.ini" + write_initial_config( + ini_path, + domain, + overrides={ + "tls_external_cert_and_key": f"{REMOTE_CERT} {REMOTE_KEY}", + }, + ) + print(f"[+] Generated chatmail.ini for {domain} in {config_dir}") + return ini_path + + +# --------------------------------------------------------------------------- +# Certificate generation +# --------------------------------------------------------------------------- + + +def generate_cert(domain: str, cert_dir: Path) -> tuple: + """Generate a self-signed TLS cert+key for *domain* with proper SANs.""" + from cmdeploy.selfsigned.deployer import openssl_selfsigned_args + + cert_path = cert_dir / "fullchain.pem" + key_path = cert_dir / "privkey.pem" + subprocess.check_call(openssl_selfsigned_args(domain, cert_path, key_path, days=30)) + print(f"[+] Generated cert for {domain} in {cert_dir}") + return cert_path, key_path + + +# --------------------------------------------------------------------------- +# Upload certs to remote server +# --------------------------------------------------------------------------- + + +def upload_certs( + ssh_host: str, + cert_path: Path, + key_path: Path, +) -> None: + """SCP cert and key to the remote server.""" + subprocess.check_call([ + "scp", str(cert_path), f"root@{ssh_host}:{REMOTE_CERT}", + ]) + subprocess.check_call([ + "scp", str(key_path), f"root@{ssh_host}:{REMOTE_KEY}", + ]) + # Ensure cert is world-readable and key is readable by ssl-cert group + # (dovecot/postfix/nginx need to read these files) + subprocess.check_call([ + "ssh", f"root@{ssh_host}", + f"chmod 644 {REMOTE_CERT} && chmod 640 {REMOTE_KEY}" + f" && chgrp ssl-cert {REMOTE_KEY}", + ]) + print(f"[+] Uploaded cert/key to {ssh_host}") + + +# --------------------------------------------------------------------------- +# Deploy +# --------------------------------------------------------------------------- + + +def run_deploy(ini_path: str) -> None: + """Run ``cmdeploy run --skip-dns-check --config ``.""" + cmd = ["cmdeploy", "run", "--config", str(ini_path), "--skip-dns-check"] + print(f"[+] Running: {' '.join(cmd)}") + subprocess.check_call(cmd) + print("[+] Deploy completed successfully") + + +# --------------------------------------------------------------------------- +# TLS port probing +# --------------------------------------------------------------------------- + + +def get_peer_cert_binary(host: str, port: int) -> bytes: + """Connect to host:port with TLS and return the DER-encoded peer cert.""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with socket.create_connection((host, port), timeout=15) as sock: + with ctx.wrap_socket(sock, server_hostname=host) as ssock: + return ssock.getpeercert(binary_form=True) + + +def get_smtp_starttls_cert_binary(host: str, port: int = 587) -> bytes: + """Connect via SMTP STARTTLS and return the DER cert.""" + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + with smtplib.SMTP(host, port, timeout=15) as smtp: + smtp.starttls(context=ctx) + return smtp.sock.getpeercert(binary_form=True) + + +def check_cert_matches( + label: str, served_der: bytes, expected_der: bytes, +) -> bool: + """Compare served DER cert against the expected cert.""" + if served_der == expected_der: + print(f" [OK] {label}: certificate matches") + return True + else: + print(f" [FAIL] {label}: certificate does NOT match") + return False + + +def load_cert_der(cert_pem_path: Path) -> bytes: + """Load a PEM cert file and return its DER encoding.""" + pem_text = cert_pem_path.read_text() + start = pem_text.index("-----BEGIN CERTIFICATE-----") + end = pem_text.index("-----END CERTIFICATE-----") + len( + "-----END CERTIFICATE-----" + ) + return ssl.PEM_cert_to_DER_cert(pem_text[start:end]) + + +def probe_all_ports(host: str, expected_cert_der: bytes) -> bool: + """Probe TLS ports and verify the served certificate matches. + + Checks ports 993 (IMAP), 465 (SMTPS), 587 (STARTTLS), and 443 + (nginx stream). Port 8443 is skipped as nginx binds it to + localhost behind the stream proxy on 443. + """ + print(f"\n[+] Probing TLS ports on {host}...") + all_ok = True + + for label, port in [ + ("IMAP/TLS (993)", 993), + ("SMTP/TLS (465)", 465), + ]: + try: + served = get_peer_cert_binary(host, port) + if not check_cert_matches(label, served, expected_cert_der): + all_ok = False + except Exception as e: + print(f" [FAIL] {label}: connection failed: {e}") + all_ok = False + + # STARTTLS on port 587 + try: + served = get_smtp_starttls_cert_binary(host, 587) + if not check_cert_matches("SMTP/STARTTLS (587)", served, expected_cert_der): + all_ok = False + except Exception as e: + print(f" [FAIL] SMTP/STARTTLS (587): connection failed: {e}") + all_ok = False + + # Port 443 (nginx stream proxy with ALPN routing) + try: + served = get_peer_cert_binary(host, 443) + if not check_cert_matches("nginx/443 (stream)", served, expected_cert_der): + all_ok = False + except Exception as e: + print(f" [FAIL] nginx/443 (stream): connection failed: {e}") + all_ok = False + + return all_ok + + +# --------------------------------------------------------------------------- +# Post-deploy service health checks +# --------------------------------------------------------------------------- + +SERVICES = ["dovecot", "postfix", "nginx"] + + +def check_remote_services(ssh_host: str, since: str = "") -> bool: + """SSH to the server and check for service failures or errors. + + *since* is a ``journalctl --since`` timestamp (e.g. ``"5 min ago"``). + If empty, checks the entire boot journal. + """ + print(f"\n[+] Checking remote service health on {ssh_host}...") + all_ok = True + + for svc in SERVICES: + try: + result = subprocess.run( + ["ssh", f"root@{ssh_host}", + f"systemctl is-active {svc}.service"], + capture_output=True, text=True, timeout=15, check=False, + ) + status = result.stdout.strip() + if status == "active": + print(f" [OK] {svc}: active") + else: + print(f" [FAIL] {svc}: {status}") + all_ok = False + except Exception as e: + print(f" [FAIL] {svc}: check failed: {e}") + all_ok = False + + return all_ok + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "domain", + help="mail domain (SSH root login must work to this host)", + ) + parser.add_argument( + "--skip-deploy", + action="store_true", + help="skip cmdeploy run, only probe ports", + ) + parser.add_argument( + "--skip-certgen", + action="store_true", + help="skip cert generation and upload (use existing)", + ) + parser.add_argument( + "--ssh-host", + help="SSH host override (defaults to DOMAIN)", + ) + args = parser.parse_args() + + domain = args.domain + ssh_host = args.ssh_host or domain + print(f"[+] Domain: {domain}") + print(f"[+] SSH host: {ssh_host}") + print(f"[+] Remote cert: {REMOTE_CERT}") + print(f"[+] Remote key: {REMOTE_KEY}") + + work_dir = Path(tempfile.mkdtemp(prefix="tls-external-test-")) + try: + # Generate chatmail.ini + ini_path = generate_config(domain, work_dir) + + if not args.skip_certgen: + local_cert, local_key = generate_cert(domain, work_dir) + upload_certs(ssh_host, local_cert, local_key) + else: + local_cert = work_dir / "fullchain.pem" + subprocess.check_call([ + "scp", f"root@{ssh_host}:{REMOTE_CERT}", str(local_cert), + ]) + + # Record timestamp before deploy for journal filtering + deploy_start = time.strftime("%Y-%m-%d %H:%M:%S") + + if not args.skip_deploy: + run_deploy(ini_path) + + # Probe TLS ports + expected_der = load_cert_der(local_cert) + ports_ok = probe_all_ports(domain, expected_der) + + # Check service health (only errors since deploy started) + services_ok = check_remote_services(ssh_host, since=deploy_start) + + if ports_ok and services_ok: + print( + "\n[SUCCESS] All TLS port probes passed and services are healthy" + ) + return 0 + else: + if not ports_ok: + print("\n[FAILURE] Some TLS port probes failed", file=sys.stderr) + if not services_ok: + print( + "\n[FAILURE] Some services have errors", file=sys.stderr + ) + return 1 + finally: + shutil.rmtree(work_dir, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cmdeploy/src/cmdeploy/tests/test_external_tls.py b/cmdeploy/src/cmdeploy/tests/test_external_tls.py new file mode 100644 index 000000000..865017601 --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/test_external_tls.py @@ -0,0 +1,78 @@ +"""Functional tests for tls_external_cert_and_key option.""" + +import json + +import chatmaild.newemail +import pytest +from chatmaild.config import read_config, write_initial_config + + +def make_external_config(tmp_path, cert_key=None): + inipath = tmp_path / "chatmail.ini" + overrides = {} + if cert_key is not None: + overrides["tls_external_cert_and_key"] = cert_key + write_initial_config(inipath, "chat.example.org", overrides=overrides) + return inipath + + +def test_external_tls_config_reads_paths(tmp_path): + inipath = make_external_config( + tmp_path, + cert_key=( + "/etc/letsencrypt/live/chat.example.org/fullchain.pem" + " /etc/letsencrypt/live/chat.example.org/privkey.pem" + ), + ) + config = read_config(inipath) + assert config.tls_cert_mode == "external" + assert ( + config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem" + ) + assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem" + + +def test_external_tls_missing_option_uses_acme(tmp_path): + config = read_config(make_external_config(tmp_path)) + assert config.tls_cert_mode == "acme" + + +def test_external_tls_bad_format_raises(tmp_path): + inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem") + with pytest.raises(ValueError, match="two space-separated"): + read_config(inipath) + + +def test_external_tls_three_paths_raises(tmp_path): + inipath = make_external_config(tmp_path, cert_key="/a /b /c") + with pytest.raises(ValueError, match="two space-separated"): + read_config(inipath) + + +def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch): + inipath = make_external_config( + tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem" + ) + monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath)) + chatmaild.newemail.print_new_account() + out, _ = capsys.readouterr() + lines = out.split("\n") + dic = json.loads(lines[2]) + assert "dclogin_url" not in dic + + +def test_external_tls_selects_correct_deployer(tmp_path): + from cmdeploy.deployers import get_tls_deployer + from cmdeploy.external.deployer import ExternalTlsDeployer + from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer + + inipath = make_external_config( + tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem" + ) + config = read_config(inipath) + deployer = get_tls_deployer(config, "chat.example.org") + + assert isinstance(deployer, ExternalTlsDeployer) + assert not isinstance(deployer, SelfSignedTlsDeployer) + assert deployer.cert_path == "/certs/fullchain.pem" + assert deployer.key_path == "/certs/privkey.pem" diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 69b019d72..9088b6ec6 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -198,6 +198,40 @@ and all other relays will accept connections from it without requiring certificate verification. This is useful for experimental setups and testing. +.. _external-tls: + +Running a relay with externally managed certificates +----------------------------------------------------- + +If you already have a TLS certificate manager +(e.g. Traefik, certbot, or another ACME client) +running on the deployment server, +you can configure the relay to use those certificates +instead of the built-in ``acmetool``. + +Set the following in ``chatmail.ini``:: + + tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem + +The paths must point to certificate and key files +on the deployment server. +During ``cmdeploy run``, these paths are written into +the Postfix, Dovecot, and Nginx configurations. +No certificate files are transferred from the build machine — +they must already exist on the server, +managed by your external certificate tool. + +The deploy will verify that both files exist on the server. +``acmetool`` is **not** installed or run in this mode. + +.. note:: + + You are responsible for certificate renewal. + When the certificate file changes on disk, + all relay services pick up the new certificate automatically + (via a systemd path watcher installed during deploy). + + Migrating to a new build machine ---------------------------------- diff --git a/doc/source/overview.rst b/doc/source/overview.rst index e75a2d815..dcc7f7eab 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -308,6 +308,11 @@ When providing a TLS certificate to your chatmail relay server, make sure to provide the full certificate chain and not just the last certificate. +If you use an external certificate manager (e.g. Traefik or certbot), +set ``tls_external_cert_and_key`` in ``chatmail.ini`` +to provide the certificate and key paths. +See :ref:`external-tls` for details. + If you are running an Exim server and don’t see incoming connections from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log item is enabled in the config with ``log_selector = +smtp_no_mail``. By From fb9c25f9e29fcf42b9f4c42a6b0b02ce62613e6e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Sun, 22 Feb 2026 16:41:04 +0100 Subject: [PATCH 2/6] addressed link2xt comments except tls cleanup and fixed linting, removed leftovers --- .../workflows/reusable-test-tls-external.yaml | 33 ------------------- .../workflows/test-and-deploy-ipv4only.yaml | 8 ----- .github/workflows/test-and-deploy.yaml | 8 ----- chatmaild/src/chatmaild/config.py | 3 +- chatmaild/src/chatmaild/tests/test_config.py | 1 + cmdeploy/src/cmdeploy/acmetool/__init__.py | 2 +- cmdeploy/src/cmdeploy/cmdeploy.py | 10 ++++-- cmdeploy/src/cmdeploy/deployers.py | 14 +++++--- cmdeploy/src/cmdeploy/dovecot/deployer.py | 6 +++- cmdeploy/src/cmdeploy/external/deployer.py | 30 +++++++++-------- cmdeploy/src/cmdeploy/postfix/deployer.py | 12 ++++--- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 29 +++++++++++----- .../src/cmdeploy/tests/online/test_0_qr.py | 17 ++++++---- .../cmdeploy/tests/online/test_2_deltachat.py | 2 +- cmdeploy/src/cmdeploy/tests/plugin.py | 9 +++-- 15 files changed, 89 insertions(+), 95 deletions(-) delete mode 100644 .github/workflows/reusable-test-tls-external.yaml diff --git a/.github/workflows/reusable-test-tls-external.yaml b/.github/workflows/reusable-test-tls-external.yaml deleted file mode 100644 index b8c33b352..000000000 --- a/.github/workflows/reusable-test-tls-external.yaml +++ /dev/null @@ -1,33 +0,0 @@ -name: test tls_external_cert_and_key - -on: - workflow_call: - inputs: - domain: - required: true - type: string - secrets: - STAGING_SSH_KEY: - required: true - -jobs: - test-tls-external: - name: test tls_external_cert_and_key - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: - name: ${{ inputs.domain }} - concurrency: ${{ inputs.domain }} - steps: - - uses: actions/checkout@v4 - - run: scripts/initenv.sh - - name: append venv/bin to PATH - run: echo venv/bin >>$GITHUB_PATH - - name: prepare SSH - run: | - mkdir -p ~/.ssh - echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519 - chmod 600 ~/.ssh/id_ed25519 - ssh-keyscan ${{ inputs.domain }} >> ~/.ssh/known_hosts 2>/dev/null - - name: run tls_external e2e test - run: python -m cmdeploy.tests.setup_tls_external ${{ inputs.domain }} diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index fea1a14d7..158bab98d 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -103,11 +103,3 @@ jobs: - name: cmdeploy dns run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost" - test-tls-external: - needs: deploy - uses: ./.github/workflows/reusable-test-tls-external.yaml - with: - domain: staging-ipv4.testrun.org - secrets: - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - diff --git a/.github/workflows/test-and-deploy.yaml b/.github/workflows/test-and-deploy.yaml index 4ee5be9af..d38a88695 100644 --- a/.github/workflows/test-and-deploy.yaml +++ b/.github/workflows/test-and-deploy.yaml @@ -96,11 +96,3 @@ jobs: - name: cmdeploy dns run: cmdeploy dns -v - test-tls-external: - needs: deploy - uses: ./.github/workflows/reusable-test-tls-external.yaml - with: - domain: staging2.testrun.org - secrets: - STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} - diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 8b4aab140..bdd71a1b0 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -75,8 +75,7 @@ def __init__(self, inipath, params): " paths: CERT_PATH KEY_PATH" ) self.tls_cert_mode = "external" - self.tls_cert_path = parts[0] - self.tls_key_path = parts[1] + 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" diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index a198eb7bd..b459ec6cb 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -110,6 +110,7 @@ def test_config_tls_external_overrides_underscore(make_config): ) assert config.tls_cert_mode == "external" assert config.tls_cert_path == "/certs/fullchain.pem" + assert config.tls_key_path == "/certs/privkey.pem" def test_config_tls_external_bad_format(make_config): diff --git a/cmdeploy/src/cmdeploy/acmetool/__init__.py b/cmdeploy/src/cmdeploy/acmetool/__init__.py index e4e1ed847..e0e8c02f3 100644 --- a/cmdeploy/src/cmdeploy/acmetool/__init__.py +++ b/cmdeploy/src/cmdeploy/acmetool/__init__.py @@ -67,7 +67,7 @@ def configure(self): ) files.template( src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"), - dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD + dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD user="root", group="root", mode="644", diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index a7ed5fee1..85017c9bb 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -93,7 +93,9 @@ def run_cmd(args, out): strict_tls = args.config.tls_cert_mode == "acme" if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) - if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): + if not dns.check_initial_remote_data( + remote_data, strict_tls=strict_tls, print=out.red + ): return 1 env = os.environ.copy() @@ -127,7 +129,11 @@ def run_cmd(args, out): out.red("Website deployment failed.") elif retcode == 0: out.green("Deploy completed, call `cmdeploy dns` next.") - elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: + elif ( + not args.dns_check_disabled + and strict_tls + and not remote_data["acme_account_url"] + ): out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again") retcode = 0 diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index a933d6ae0..31e87f66d 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -11,8 +11,8 @@ 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 @@ -20,7 +20,6 @@ from cmdeploy.cmdeploy import Out from .acmetool import AcmetoolDeployer -from .external.deployer import ExternalTlsDeployer from .basedeploy import ( Deployer, Deployment, @@ -30,6 +29,7 @@ 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 @@ -579,11 +579,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) # Check if mtail_address interface is available (if configured) - if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): + if config.mtail_address and config.mtail_address not in ( + "127.0.0.1", + "::1", + "localhost", + ): ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] if config.mtail_address not in all_addresses: - Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") + Out().red( + f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n" + ) exit(1) if not os.environ.get("CHATMAIL_NOPORTCHECK"): diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index 90f6ecc71..dc592c51f 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -41,7 +41,11 @@ def activate(self): restart = False if self.disable_mail else self.need_restart systemd.service( - name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot", + name=( + "Disable dovecot for now" + if self.disable_mail + else "Start and enable Dovecot" + ), service="dovecot.service", running=False if self.disable_mail else True, enabled=False if self.disable_mail else True, diff --git a/cmdeploy/src/cmdeploy/external/deployer.py b/cmdeploy/src/cmdeploy/external/deployer.py index e98e40ae9..eaac2acf2 100644 --- a/cmdeploy/src/cmdeploy/external/deployer.py +++ b/cmdeploy/src/cmdeploy/external/deployer.py @@ -1,4 +1,8 @@ -from pyinfra.operations import files, server, systemd +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 @@ -17,19 +21,18 @@ def __init__(self, cert_path, key_path): self.key_path = key_path def configure(self): - server.shell( - name="Verify external TLS certificate and key exist", - commands=[ - f"test -f {self.cert_path} && test -f {self.key_path}", - ], - ) + # 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__) content = source.read_text().format(cert_path=self.cert_path).encode() - import io - path_unit = files.put( name="Upload tls-cert-reload.path", src=io.BytesIO(content), @@ -60,10 +63,11 @@ def activate(self): restarted=self.need_restart, daemon_reload=self.need_restart, ) - # Always trigger a reload so services pick up the current cert. + # Trigger the oneshot service so services pick up the current cert. # The path unit handles future changes via inotify. - server.shell( + systemd.service( name="Reload TLS services for current certificate", - commands=["systemctl start tls-cert-reload.service"], + service="tls-cert-reload.service", + running=True, + daemon_reload=False, ) - diff --git a/cmdeploy/src/cmdeploy/postfix/deployer.py b/cmdeploy/src/cmdeploy/postfix/deployer.py index 96197bd7b..1f9c26521 100644 --- a/cmdeploy/src/cmdeploy/postfix/deployer.py +++ b/cmdeploy/src/cmdeploy/postfix/deployer.py @@ -97,7 +97,9 @@ def configure(self): server.shell( name="Validate postfix configuration", # Extract stderr and quit with error if non-zero - commands=["""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""], + commands=[ + """bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'""" + ], ) self.need_restart = need_restart @@ -105,9 +107,11 @@ def activate(self): restart = False if self.disable_mail else self.need_restart systemd.service( - name="disable postfix for now" - if self.disable_mail - else "Start and enable Postfix", + name=( + "disable postfix for now" + if self.disable_mail + else "Start and enable Postfix" + ), service="postfix.service", running=False if self.disable_mail else True, enabled=False if self.disable_mail else True, diff --git a/cmdeploy/src/cmdeploy/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py index 0faff5e82..17d04f07a 100644 --- a/cmdeploy/src/cmdeploy/selfsigned/deployer.py +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -12,13 +12,24 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500): ``www.`` and ``mta-sts.``. """ 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", + "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}", ] @@ -40,7 +51,9 @@ def install(self): def configure(self): args = openssl_selfsigned_args( - self.mail_domain, self.cert_path, self.key_path, + self.mail_domain, + self.cert_path, + self.key_path, ) cmd = shlex.join(args) server.shell( diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index b916e696a..4ffc8f0d2 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py @@ -30,12 +30,15 @@ def test_newemail_configure(maildomain, rpc, chatmail_config): # set_config_from_qr, so fetch credentials via requests instead res = requests.post(f"https://{maildomain}/new", verify=False) data = res.json() - rpc.add_or_update_transport(account_id, { - "addr": data["email"], - "password": data["password"], - "imapServer": maildomain, - "smtpServer": maildomain, - "certificateChecks": "acceptInvalidCertificates", - }) + rpc.add_or_update_transport( + account_id, + { + "addr": data["email"], + "password": data["password"], + "imapServer": maildomain, + "smtpServer": maildomain, + "certificateChecks": "acceptInvalidCertificates", + }, + ) else: rpc.add_transport_from_qr(account_id, url) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 69e58777c..e507b2213 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -6,8 +6,8 @@ import pytest import requests -from cmdeploy.remote import rshell from cmdeploy.cmdeploy import get_sshexec +from cmdeploy.remote import rshell @pytest.fixture diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 14cea3697..b6dddd350 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -399,9 +399,12 @@ def iter_output(self, logcmd=""): getjournal = "journalctl -f" if not logcmd else logcmd print(self.sshdomain) match self.sshdomain: - case "@local": command = [] - case "localhost": command = [] - case _: command = ["ssh", f"root@{self.sshdomain}"] + case "@local": + command = [] + case "localhost": + command = [] + case _: + command = ["ssh", f"root@{self.sshdomain}"] [command.append(arg) for arg in getjournal.split()] self.popen = subprocess.Popen( command, From fc3e08d5bffba0d3e798e49981d283bdc14b3ae4 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 23 Feb 2026 01:40:27 +0100 Subject: [PATCH 3/6] tests: fix potential hang in test_exceed_quota --- .../src/cmdeploy/tests/online/test_2_deltachat.py | 12 ++++++------ cmdeploy/src/cmdeploy/tests/plugin.py | 10 ++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index e507b2213..1b96f3317 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -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 diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index b6dddd350..f0a27b31b 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -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: @@ -413,10 +413,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 @pytest.fixture From 12d6cbb2ddf93e07ea66047541ced342b73f44d1 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 23 Feb 2026 02:59:07 +0100 Subject: [PATCH 4/6] revert pure whitespace chagnes --- cmdeploy/src/cmdeploy/acmetool/__init__.py | 2 +- cmdeploy/src/cmdeploy/cmdeploy.py | 10 ++----- cmdeploy/src/cmdeploy/deployers.py | 10 ++----- cmdeploy/src/cmdeploy/dovecot/deployer.py | 6 +--- cmdeploy/src/cmdeploy/postfix/deployer.py | 12 +++----- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 29 +++++-------------- .../src/cmdeploy/tests/online/test_0_qr.py | 17 +++++------ .../cmdeploy/tests/online/test_2_deltachat.py | 2 +- cmdeploy/src/cmdeploy/tests/plugin.py | 9 ++---- 9 files changed, 29 insertions(+), 68 deletions(-) diff --git a/cmdeploy/src/cmdeploy/acmetool/__init__.py b/cmdeploy/src/cmdeploy/acmetool/__init__.py index e0e8c02f3..e4e1ed847 100644 --- a/cmdeploy/src/cmdeploy/acmetool/__init__.py +++ b/cmdeploy/src/cmdeploy/acmetool/__init__.py @@ -67,7 +67,7 @@ def configure(self): ) files.template( src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"), - dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD + dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD user="root", group="root", mode="644", diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 85017c9bb..a7ed5fee1 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -93,9 +93,7 @@ def run_cmd(args, out): strict_tls = args.config.tls_cert_mode == "acme" if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) - if not dns.check_initial_remote_data( - remote_data, strict_tls=strict_tls, print=out.red - ): + if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): return 1 env = os.environ.copy() @@ -129,11 +127,7 @@ def run_cmd(args, out): out.red("Website deployment failed.") elif retcode == 0: out.green("Deploy completed, call `cmdeploy dns` next.") - elif ( - not args.dns_check_disabled - and strict_tls - and not remote_data["acme_account_url"] - ): + elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: out.red("Deploy completed but letsencrypt not configured") out.red("Run 'cmdeploy run' again") retcode = 0 diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 31e87f66d..897ae6f07 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -579,17 +579,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - ) # Check if mtail_address interface is available (if configured) - if config.mtail_address and config.mtail_address not in ( - "127.0.0.1", - "::1", - "localhost", - ): + if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] if config.mtail_address not in all_addresses: - Out().red( - f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n" - ) + Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") exit(1) if not os.environ.get("CHATMAIL_NOPORTCHECK"): diff --git a/cmdeploy/src/cmdeploy/dovecot/deployer.py b/cmdeploy/src/cmdeploy/dovecot/deployer.py index dc592c51f..90f6ecc71 100644 --- a/cmdeploy/src/cmdeploy/dovecot/deployer.py +++ b/cmdeploy/src/cmdeploy/dovecot/deployer.py @@ -41,11 +41,7 @@ def activate(self): restart = False if self.disable_mail else self.need_restart systemd.service( - name=( - "Disable dovecot for now" - if self.disable_mail - else "Start and enable Dovecot" - ), + name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot", service="dovecot.service", running=False if self.disable_mail else True, enabled=False if self.disable_mail else True, diff --git a/cmdeploy/src/cmdeploy/postfix/deployer.py b/cmdeploy/src/cmdeploy/postfix/deployer.py index 1f9c26521..96197bd7b 100644 --- a/cmdeploy/src/cmdeploy/postfix/deployer.py +++ b/cmdeploy/src/cmdeploy/postfix/deployer.py @@ -97,9 +97,7 @@ def configure(self): server.shell( name="Validate postfix configuration", # Extract stderr and quit with error if non-zero - commands=[ - """bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'""" - ], + commands=["""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""], ) self.need_restart = need_restart @@ -107,11 +105,9 @@ def activate(self): restart = False if self.disable_mail else self.need_restart systemd.service( - name=( - "disable postfix for now" - if self.disable_mail - else "Start and enable Postfix" - ), + name="disable postfix for now" + if self.disable_mail + else "Start and enable Postfix", service="postfix.service", running=False if self.disable_mail else True, enabled=False if self.disable_mail else True, diff --git a/cmdeploy/src/cmdeploy/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py index 17d04f07a..0faff5e82 100644 --- a/cmdeploy/src/cmdeploy/selfsigned/deployer.py +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -12,24 +12,13 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500): ``www.`` and ``mta-sts.``. """ 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", + "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}", ] @@ -51,9 +40,7 @@ def install(self): def configure(self): args = openssl_selfsigned_args( - self.mail_domain, - self.cert_path, - self.key_path, + self.mail_domain, self.cert_path, self.key_path, ) cmd = shlex.join(args) server.shell( diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index 4ffc8f0d2..b916e696a 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py @@ -30,15 +30,12 @@ def test_newemail_configure(maildomain, rpc, chatmail_config): # set_config_from_qr, so fetch credentials via requests instead res = requests.post(f"https://{maildomain}/new", verify=False) data = res.json() - rpc.add_or_update_transport( - account_id, - { - "addr": data["email"], - "password": data["password"], - "imapServer": maildomain, - "smtpServer": maildomain, - "certificateChecks": "acceptInvalidCertificates", - }, - ) + rpc.add_or_update_transport(account_id, { + "addr": data["email"], + "password": data["password"], + "imapServer": maildomain, + "smtpServer": maildomain, + "certificateChecks": "acceptInvalidCertificates", + }) else: rpc.add_transport_from_qr(account_id, url) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 1b96f3317..0c61412c4 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -6,8 +6,8 @@ import pytest import requests -from cmdeploy.cmdeploy import get_sshexec from cmdeploy.remote import rshell +from cmdeploy.cmdeploy import get_sshexec @pytest.fixture diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index f0a27b31b..34f258df3 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -399,12 +399,9 @@ def iter_output(self, logcmd="", ready=None): getjournal = "journalctl -f" if not logcmd else logcmd print(self.sshdomain) match self.sshdomain: - case "@local": - command = [] - case "localhost": - command = [] - case _: - command = ["ssh", f"root@{self.sshdomain}"] + case "@local": command = [] + case "localhost": command = [] + case _: command = ["ssh", f"root@{self.sshdomain}"] [command.append(arg) for arg in getjournal.split()] self.popen = subprocess.Popen( command, From 5036c36fe232bae474c6588843e54aab2ff40f25 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Mon, 23 Feb 2026 14:44:35 +0100 Subject: [PATCH 5/6] remove tls extrernal test file, it's too large, and should only be added if we can run it from work flows --- .../src/cmdeploy/tests/setup_tls_external.py | 340 ------------------ 1 file changed, 340 deletions(-) delete mode 100644 cmdeploy/src/cmdeploy/tests/setup_tls_external.py diff --git a/cmdeploy/src/cmdeploy/tests/setup_tls_external.py b/cmdeploy/src/cmdeploy/tests/setup_tls_external.py deleted file mode 100644 index 71ff7d635..000000000 --- a/cmdeploy/src/cmdeploy/tests/setup_tls_external.py +++ /dev/null @@ -1,340 +0,0 @@ -"""Setup and verify external TLS certificates for a chatmail server. - -Generates a self-signed TLS certificate, uploads it to the chatmail -server via SCP, runs ``cmdeploy run``, and then probes all TLS-enabled -ports (nginx, postfix, dovecot) to verify the certificate is actually -served. After probing, checks remote service logs for errors. - -Prerequisites -~~~~~~~~~~~~~ -- SSH root access to the target server (same as ``cmdeploy run``) -- ``cmdeploy`` in PATH (activate the venv first) - -How to run -~~~~~~~~~~ -From the repository root:: - - # Full run: generate cert, deploy, probe ports, check services - python -m cmdeploy.tests.setup_tls_external DOMAIN - - # Re-probe only (after a previous deploy) - python -m cmdeploy.tests.setup_tls_external DOMAIN \\ - --skip-deploy --skip-certgen - - # Override SSH host (e.g. when domain doesn't resolve to the server) - python -m cmdeploy.tests.setup_tls_external DOMAIN \\ - --ssh-host staging-ipv4.testrun.org - -Arguments -~~~~~~~~~ -DOMAIN mail domain for the chatmail server (SSH root login must work) - -Options -~~~~~~~ ---skip-deploy skip ``cmdeploy run``, only probe ports ---skip-certgen skip cert generation/upload, use certs already on server ---ssh-host HOST SSH host override (defaults to DOMAIN) -""" - -import argparse -import shutil -import smtplib -import socket -import ssl -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -# Cert paths on the remote server -REMOTE_CERT = "/etc/ssl/certs/tmp_fullchain.pem" -REMOTE_KEY = "/etc/ssl/private/tmp_privkey.pem" - - -# --------------------------------------------------------------------------- -# Config generation -# --------------------------------------------------------------------------- - - -def generate_config(domain: str, config_dir: Path) -> Path: - """Generate a chatmail.ini with tls_external_cert_and_key for *domain*.""" - from chatmaild.config import write_initial_config - - ini_path = config_dir / "chatmail.ini" - write_initial_config( - ini_path, - domain, - overrides={ - "tls_external_cert_and_key": f"{REMOTE_CERT} {REMOTE_KEY}", - }, - ) - print(f"[+] Generated chatmail.ini for {domain} in {config_dir}") - return ini_path - - -# --------------------------------------------------------------------------- -# Certificate generation -# --------------------------------------------------------------------------- - - -def generate_cert(domain: str, cert_dir: Path) -> tuple: - """Generate a self-signed TLS cert+key for *domain* with proper SANs.""" - from cmdeploy.selfsigned.deployer import openssl_selfsigned_args - - cert_path = cert_dir / "fullchain.pem" - key_path = cert_dir / "privkey.pem" - subprocess.check_call(openssl_selfsigned_args(domain, cert_path, key_path, days=30)) - print(f"[+] Generated cert for {domain} in {cert_dir}") - return cert_path, key_path - - -# --------------------------------------------------------------------------- -# Upload certs to remote server -# --------------------------------------------------------------------------- - - -def upload_certs( - ssh_host: str, - cert_path: Path, - key_path: Path, -) -> None: - """SCP cert and key to the remote server.""" - subprocess.check_call([ - "scp", str(cert_path), f"root@{ssh_host}:{REMOTE_CERT}", - ]) - subprocess.check_call([ - "scp", str(key_path), f"root@{ssh_host}:{REMOTE_KEY}", - ]) - # Ensure cert is world-readable and key is readable by ssl-cert group - # (dovecot/postfix/nginx need to read these files) - subprocess.check_call([ - "ssh", f"root@{ssh_host}", - f"chmod 644 {REMOTE_CERT} && chmod 640 {REMOTE_KEY}" - f" && chgrp ssl-cert {REMOTE_KEY}", - ]) - print(f"[+] Uploaded cert/key to {ssh_host}") - - -# --------------------------------------------------------------------------- -# Deploy -# --------------------------------------------------------------------------- - - -def run_deploy(ini_path: str) -> None: - """Run ``cmdeploy run --skip-dns-check --config ``.""" - cmd = ["cmdeploy", "run", "--config", str(ini_path), "--skip-dns-check"] - print(f"[+] Running: {' '.join(cmd)}") - subprocess.check_call(cmd) - print("[+] Deploy completed successfully") - - -# --------------------------------------------------------------------------- -# TLS port probing -# --------------------------------------------------------------------------- - - -def get_peer_cert_binary(host: str, port: int) -> bytes: - """Connect to host:port with TLS and return the DER-encoded peer cert.""" - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - with socket.create_connection((host, port), timeout=15) as sock: - with ctx.wrap_socket(sock, server_hostname=host) as ssock: - return ssock.getpeercert(binary_form=True) - - -def get_smtp_starttls_cert_binary(host: str, port: int = 587) -> bytes: - """Connect via SMTP STARTTLS and return the DER cert.""" - ctx = ssl.create_default_context() - ctx.check_hostname = False - ctx.verify_mode = ssl.CERT_NONE - with smtplib.SMTP(host, port, timeout=15) as smtp: - smtp.starttls(context=ctx) - return smtp.sock.getpeercert(binary_form=True) - - -def check_cert_matches( - label: str, served_der: bytes, expected_der: bytes, -) -> bool: - """Compare served DER cert against the expected cert.""" - if served_der == expected_der: - print(f" [OK] {label}: certificate matches") - return True - else: - print(f" [FAIL] {label}: certificate does NOT match") - return False - - -def load_cert_der(cert_pem_path: Path) -> bytes: - """Load a PEM cert file and return its DER encoding.""" - pem_text = cert_pem_path.read_text() - start = pem_text.index("-----BEGIN CERTIFICATE-----") - end = pem_text.index("-----END CERTIFICATE-----") + len( - "-----END CERTIFICATE-----" - ) - return ssl.PEM_cert_to_DER_cert(pem_text[start:end]) - - -def probe_all_ports(host: str, expected_cert_der: bytes) -> bool: - """Probe TLS ports and verify the served certificate matches. - - Checks ports 993 (IMAP), 465 (SMTPS), 587 (STARTTLS), and 443 - (nginx stream). Port 8443 is skipped as nginx binds it to - localhost behind the stream proxy on 443. - """ - print(f"\n[+] Probing TLS ports on {host}...") - all_ok = True - - for label, port in [ - ("IMAP/TLS (993)", 993), - ("SMTP/TLS (465)", 465), - ]: - try: - served = get_peer_cert_binary(host, port) - if not check_cert_matches(label, served, expected_cert_der): - all_ok = False - except Exception as e: - print(f" [FAIL] {label}: connection failed: {e}") - all_ok = False - - # STARTTLS on port 587 - try: - served = get_smtp_starttls_cert_binary(host, 587) - if not check_cert_matches("SMTP/STARTTLS (587)", served, expected_cert_der): - all_ok = False - except Exception as e: - print(f" [FAIL] SMTP/STARTTLS (587): connection failed: {e}") - all_ok = False - - # Port 443 (nginx stream proxy with ALPN routing) - try: - served = get_peer_cert_binary(host, 443) - if not check_cert_matches("nginx/443 (stream)", served, expected_cert_der): - all_ok = False - except Exception as e: - print(f" [FAIL] nginx/443 (stream): connection failed: {e}") - all_ok = False - - return all_ok - - -# --------------------------------------------------------------------------- -# Post-deploy service health checks -# --------------------------------------------------------------------------- - -SERVICES = ["dovecot", "postfix", "nginx"] - - -def check_remote_services(ssh_host: str, since: str = "") -> bool: - """SSH to the server and check for service failures or errors. - - *since* is a ``journalctl --since`` timestamp (e.g. ``"5 min ago"``). - If empty, checks the entire boot journal. - """ - print(f"\n[+] Checking remote service health on {ssh_host}...") - all_ok = True - - for svc in SERVICES: - try: - result = subprocess.run( - ["ssh", f"root@{ssh_host}", - f"systemctl is-active {svc}.service"], - capture_output=True, text=True, timeout=15, check=False, - ) - status = result.stdout.strip() - if status == "active": - print(f" [OK] {svc}: active") - else: - print(f" [FAIL] {svc}: {status}") - all_ok = False - except Exception as e: - print(f" [FAIL] {svc}: check failed: {e}") - all_ok = False - - return all_ok - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - - -def main(): - parser = argparse.ArgumentParser( - description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter, - ) - parser.add_argument( - "domain", - help="mail domain (SSH root login must work to this host)", - ) - parser.add_argument( - "--skip-deploy", - action="store_true", - help="skip cmdeploy run, only probe ports", - ) - parser.add_argument( - "--skip-certgen", - action="store_true", - help="skip cert generation and upload (use existing)", - ) - parser.add_argument( - "--ssh-host", - help="SSH host override (defaults to DOMAIN)", - ) - args = parser.parse_args() - - domain = args.domain - ssh_host = args.ssh_host or domain - print(f"[+] Domain: {domain}") - print(f"[+] SSH host: {ssh_host}") - print(f"[+] Remote cert: {REMOTE_CERT}") - print(f"[+] Remote key: {REMOTE_KEY}") - - work_dir = Path(tempfile.mkdtemp(prefix="tls-external-test-")) - try: - # Generate chatmail.ini - ini_path = generate_config(domain, work_dir) - - if not args.skip_certgen: - local_cert, local_key = generate_cert(domain, work_dir) - upload_certs(ssh_host, local_cert, local_key) - else: - local_cert = work_dir / "fullchain.pem" - subprocess.check_call([ - "scp", f"root@{ssh_host}:{REMOTE_CERT}", str(local_cert), - ]) - - # Record timestamp before deploy for journal filtering - deploy_start = time.strftime("%Y-%m-%d %H:%M:%S") - - if not args.skip_deploy: - run_deploy(ini_path) - - # Probe TLS ports - expected_der = load_cert_der(local_cert) - ports_ok = probe_all_ports(domain, expected_der) - - # Check service health (only errors since deploy started) - services_ok = check_remote_services(ssh_host, since=deploy_start) - - if ports_ok and services_ok: - print( - "\n[SUCCESS] All TLS port probes passed and services are healthy" - ) - return 0 - else: - if not ports_ok: - print("\n[FAILURE] Some TLS port probes failed", file=sys.stderr) - if not services_ok: - print( - "\n[FAILURE] Some services have errors", file=sys.stderr - ) - return 1 - finally: - shutil.rmtree(work_dir, ignore_errors=True) - - -if __name__ == "__main__": - sys.exit(main()) From cf7e9fd4a348dac467448dafa89c7cd96868d6a3 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 24 Feb 2026 00:00:44 +0100 Subject: [PATCH 6/6] simplify cert loading and triggering, add a note for cross-bind mounts --- cmdeploy/src/cmdeploy/external/deployer.py | 10 ++-------- cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f | 6 +++++- cmdeploy/src/cmdeploy/external/tls-cert-reload.service | 4 ++-- doc/source/getting_started.rst | 6 +++++- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmdeploy/src/cmdeploy/external/deployer.py b/cmdeploy/src/cmdeploy/external/deployer.py index eaac2acf2..88abcca65 100644 --- a/cmdeploy/src/cmdeploy/external/deployer.py +++ b/cmdeploy/src/cmdeploy/external/deployer.py @@ -63,11 +63,5 @@ def activate(self): restarted=self.need_restart, daemon_reload=self.need_restart, ) - # Trigger the oneshot service so services pick up the current cert. - # The path unit handles future changes via inotify. - systemd.service( - name="Reload TLS services for current certificate", - service="tls-cert-reload.service", - running=True, - daemon_reload=False, - ) + # No explicit reload needed here: dovecot/nginx read the cert + # on startup, and the .path watcher handles live changes. diff --git a/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f b/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f index 44cb3f45a..813326e9e 100644 --- a/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f +++ b/cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f @@ -1,6 +1,10 @@ # 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 restart the affected services. +# 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 diff --git a/cmdeploy/src/cmdeploy/external/tls-cert-reload.service b/cmdeploy/src/cmdeploy/external/tls-cert-reload.service index 7f1cde8e2..2a3bb5b4c 100644 --- a/cmdeploy/src/cmdeploy/external/tls-cert-reload.service +++ b/cmdeploy/src/cmdeploy/external/tls-cert-reload.service @@ -11,5 +11,5 @@ Description=Reload TLS services after certificate change [Service] Type=oneshot -ExecStart=/bin/systemctl reload dovecot -ExecStart=/bin/systemctl reload nginx +ExecStart=/bin/systemctl try-reload-or-restart dovecot +ExecStart=/bin/systemctl try-reload-or-restart nginx diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 9088b6ec6..28781f28e 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -229,7 +229,11 @@ The deploy will verify that both files exist on the server. You are responsible for certificate renewal. When the certificate file changes on disk, all relay services pick up the new certificate automatically - (via a systemd path watcher installed during deploy). + via a systemd path watcher installed during deploy. + The watcher uses inotify, which does not cross bind-mount boundaries. + If you use such a setup, you must trigger the reload explicitly after renewal:: + + systemctl start tls-cert-reload.service Migrating to a new build machine