diff --git a/.github/workflows/test-tls-external.yaml b/.github/workflows/test-tls-external.yaml new file mode 100644 index 00000000..3c456135 --- /dev/null +++ b/.github/workflows/test-tls-external.yaml @@ -0,0 +1,37 @@ +name: test tls_external_cert_and_key on staging2.testrun.org + +on: + workflow_run: + workflows: + - "deploy on staging2.testrun.org, and run tests" + types: + - completed + +jobs: + test-tls-external: + name: test tls_external_cert_and_key + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: staging2.testrun.org + environment: + name: staging2.testrun.org + + steps: + - uses: actions/checkout@v4 + + - name: prepare SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan staging2.testrun.org >> ~/.ssh/known_hosts 2>/dev/null + + - run: scripts/initenv.sh + + - name: append venv/bin to PATH + run: echo venv/bin >>$GITHUB_PATH + + - name: run tls_external e2e test + run: | + python -m cmdeploy.tests.setup_tls_external \ + staging2.testrun.org diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 9325509b..e6a8886a 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -61,10 +61,24 @@ class Config: 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 29d7baa9..353a9669 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -48,6 +48,13 @@ passthrough_senders = # (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 80dcb189..a198eb7b 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 460e9f41..26934772 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -20,7 +20,7 @@ from pyinfra.operations import apt, files, pip, server, systemd 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 .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 @@ -540,6 +541,20 @@ class GithashDeployer(Deployer): ) +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. @@ -604,12 +619,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 00000000..e98e40ae --- /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 00000000..44cb3f45 --- /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 00000000..7f1cde8e --- /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 159d1a83..607d2151 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 4bf2def2..0faff5e8 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 @@ class SelfSignedTlsDeployer(Deployer): ) 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 00000000..7bb80f3f --- /dev/null +++ b/cmdeploy/src/cmdeploy/tests/setup_tls_external.py @@ -0,0 +1,362 @@ +"""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 + + since_arg = f'--since="{since}"' if since else "" + print(f"\n[+] Checking journal for errors on {ssh_host}...") + for svc in SERVICES: + try: + result = subprocess.run( + ["ssh", f"root@{ssh_host}", + f"journalctl -u {svc}.service {since_arg}" + f" --no-pager -p err -q"], + capture_output=True, text=True, timeout=15, check=False, + ) + errors = result.stdout.strip() + if errors: + print(f" [WARN] {svc} errors in journal:") + for line in errors.splitlines()[:10]: + print(f" {line}") + all_ok = False + else: + print(f" [OK] {svc}: no errors in journal") + except Exception as e: + print(f" [FAIL] {svc}: journal 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 00000000..86501760 --- /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 19f2c7ff..760f57a9 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -204,6 +204,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 e75a2d81..dcc7f7ea 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