From 1244cedb4aa17190122ee9349b860ce092b88cd9 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 17 Feb 2026 23:48:16 +0100 Subject: [PATCH 01/11] feat: support tls_cert=self for self-signed TLS certificates Introduce tls_cert as the primary configuration option for TLS certificate provisioning. Setting tls_cert=self generates self-signed certificates via openssl instead of using acmetool/Let's Encrypt, sets Postfix smtp_tls_security_level to 'encrypt' for opportunistic outbound TLS, and skips ACME-related checks in cmdeploy dns. --- chatmaild/src/chatmaild/config.py | 13 ++++ chatmaild/src/chatmaild/ini/chatmail.ini.f | 8 ++ chatmaild/src/chatmaild/tests/test_config.py | 19 +++++ cmdeploy/src/cmdeploy/chatmail.zone.j2 | 2 + cmdeploy/src/cmdeploy/cmdeploy.py | 7 +- cmdeploy/src/cmdeploy/deployers.py | 13 +++- cmdeploy/src/cmdeploy/dns.py | 4 +- cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 | 4 +- cmdeploy/src/cmdeploy/nginx/autoconfig.xml.j2 | 20 ++--- cmdeploy/src/cmdeploy/nginx/deployer.py | 6 +- cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2 | 2 +- cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 6 +- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 34 +++++++++ .../src/cmdeploy/tests/online/test_0_qr.py | 19 ++++- .../cmdeploy/tests/online/test_2_deltachat.py | 18 +++-- cmdeploy/src/cmdeploy/tests/plugin.py | 73 +++++++++++++------ cmdeploy/src/cmdeploy/tests/test_dns.py | 10 +++ doc/source/overview.rst | 36 +++++++-- 18 files changed, 231 insertions(+), 63 deletions(-) create mode 100644 cmdeploy/src/cmdeploy/selfsigned/deployer.py diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index d19e966af..08fa5e03b 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -60,6 +60,19 @@ def __init__(self, inipath, params): self.privacy_pdo = params.get("privacy_pdo") self.privacy_supervisor = params.get("privacy_supervisor") + # TLS certificate management: "acme" (letsencrypt) or "self" (self-signed) + self.tls_cert = params.get("tls_cert", "acme") + if self.tls_cert == "acme": + self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" + self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" + elif self.tls_cert == "self": + self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" + self.tls_key_path = "/etc/ssl/private/mailserver.key" + else: + raise ValueError( + f"invalid tls_cert option {self.tls_cert!r}, must be 'acme' or 'self'" + ) + # deprecated option mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") self.mailboxes_dir = Path(mbdir.strip()) diff --git a/chatmaild/src/chatmaild/ini/chatmail.ini.f b/chatmaild/src/chatmaild/ini/chatmail.ini.f index 29d7baa9e..3745e11a6 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -66,6 +66,14 @@ # if set to "True" IPv6 is disabled disable_ipv6 = False + +# TLS server certificate provisioning +# "acme" (default) uses Let's Encrypt via acmetool +# "self" uses a self-signed certificate generated by cmdeploy run +# implies that SMTP connections to other MTAs will require +# encryption but not certificate verification +tls_cert = acme + # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates acme_email = diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index c553d9810..6d206823c 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -73,3 +73,22 @@ def test_config_userstate_paths(make_config, tmp_path): def test_config_max_message_size(make_config, tmp_path): config = make_config("something.testrun.org", dict(max_message_size="10000")) assert config.max_message_size == 10000 + + +def test_config_tls_default_acme(make_config): + config = make_config("chat.example.org") + assert config.tls_cert == "acme" + assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain" + assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey" + + +def test_config_tls_self(make_config): + config = make_config("chat.example.org", {"tls_cert": "self"}) + assert config.tls_cert == "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_invalid(make_config): + with pytest.raises(ValueError, match="invalid tls_cert option"): + make_config("chat.example.org", {"tls_cert": "invalid"}) diff --git a/cmdeploy/src/cmdeploy/chatmail.zone.j2 b/cmdeploy/src/cmdeploy/chatmail.zone.j2 index c3352a0c6..9915ae68d 100644 --- a/cmdeploy/src/cmdeploy/chatmail.zone.j2 +++ b/cmdeploy/src/cmdeploy/chatmail.zone.j2 @@ -8,8 +8,10 @@ {{ mail_domain }}. AAAA {{ AAAA }} {% endif %} {{ mail_domain }}. MX 10 {{ mail_domain }}. +{% if strict_tls %} _mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. +{% endif %} www.{{ mail_domain }}. CNAME {{ mail_domain }}. {{ dkim_entry }} diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 61a9b5f60..4fa78b5fe 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -151,11 +151,13 @@ def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host, verbose=args.verbose) + tls_cert = args.config.tls_cert + strict_tls = tls_cert != "self" remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) - if not remote_data: + if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls): return 1 - if not remote_data["acme_account_url"]: + if strict_tls and not remote_data["acme_account_url"]: out.red("could not get letsencrypt account url, please run 'cmdeploy run'") return 1 @@ -163,6 +165,7 @@ def dns_cmd(args, out): out.red("could not determine dkim_entry, please run 'cmdeploy run'") return 1 + remote_data["strict_tls"] = strict_tls zonefile = dns.get_filled_zone_file(remote_data) if args.zonefile: diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 5812aa284..37331bc11 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -19,6 +19,7 @@ from cmdeploy.cmdeploy import Out from .acmetool import AcmetoolDeployer +from .selfsigned.deployer import SelfSignedTlsDeployer from .basedeploy import ( Deployer, Deployment, @@ -569,7 +570,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - port_services = [ (["master", "smtpd"], 25), ("unbound", 53), - ("acmetool", 80), + ] + if config.tls_cert == "acme": + port_services.append(("acmetool", 80)) + port_services += [ (["imap-login", "dovecot"], 143), ("nginx", 443), (["master", "smtpd"], 465), @@ -597,6 +601,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] + if config.tls_cert == "acme": + tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains) + else: + tls_deployer = SelfSignedTlsDeployer(mail_domain) + all_deployers = [ ChatmailDeployer(mail_domain), LegacyRemoveDeployer(), @@ -605,7 +614,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - UnboundDeployer(config), TurnDeployer(mail_domain), IrohDeployer(config.enable_iroh_relay), - AcmetoolDeployer(config.acme_email, tls_domains), + tls_deployer, WebsiteDeployer(config), ChatmailVenvDeployer(config), MtastsDeployer(), diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index e6e3a61d8..816c9deed 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -12,11 +12,11 @@ def get_initial_remote_data(sshexec, mail_domain): ) -def check_initial_remote_data(remote_data, *, print=print): +def check_initial_remote_data(remote_data, *, strict_tls=True, print=print): mail_domain = remote_data["mail_domain"] if not remote_data["A"] and not remote_data["AAAA"]: print(f"Missing A and/or AAAA DNS records for {mail_domain}!") - elif remote_data["MTA_STS"] != f"{mail_domain}.": + elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.": print("Missing MTA-STS CNAME record:") print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") elif remote_data["WWW"] != f"{mail_domain}.": diff --git a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 index 8ab2de567..9e5d452a1 100644 --- a/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 +++ b/cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2 @@ -228,8 +228,8 @@ service anvil { } ssl = required -ssl_cert = - - {{ config.domain_name }} - {{ config.domain_name }} chatmail - {{ config.domain_name }} + + {{ config.mail_domain }} + {{ config.mail_domain }} chatmail + {{ config.mail_domain }} - {{ config.domain_name }} + {{ config.mail_domain }} 993 SSL password-cleartext %EMAILADDRESS% - {{ config.domain_name }} + {{ config.mail_domain }} 143 STARTTLS password-cleartext %EMAILADDRESS% - {{ config.domain_name }} + {{ config.mail_domain }} 443 SSL password-cleartext %EMAILADDRESS% - {{ config.domain_name }} + {{ config.mail_domain }} 465 SSL password-cleartext %EMAILADDRESS% - {{ config.domain_name }} + {{ config.mail_domain }} 587 STARTTLS password-cleartext %EMAILADDRESS% - {{ config.domain_name }} + {{ config.mail_domain }} 443 SSL password-cleartext diff --git a/cmdeploy/src/cmdeploy/nginx/deployer.py b/cmdeploy/src/cmdeploy/nginx/deployer.py index 6c323d460..217c7c772 100644 --- a/cmdeploy/src/cmdeploy/nginx/deployer.py +++ b/cmdeploy/src/cmdeploy/nginx/deployer.py @@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool: user="root", group="root", mode="644", - config={"domain_name": config.mail_domain}, + config=config, disable_ipv6=config.disable_ipv6, ) need_restart |= main_config.changed @@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool: user="root", group="root", mode="644", - config={"domain_name": config.mail_domain}, + config=config, ) need_restart |= autoconfig.changed @@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool: user="root", group="root", mode="644", - config={"domain_name": config.mail_domain}, + config=config, ) need_restart |= mta_sts_config.changed diff --git a/cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2 b/cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2 index fc60e936b..ec31e268f 100644 --- a/cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2 +++ b/cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2 @@ -1,4 +1,4 @@ version: STSv1 mode: enforce -mx: {{ config.domain_name }} +mx: {{ config.mail_domain }} max_age: 2419200 diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index aa16065a1..549dd2cf4 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -15,12 +15,12 @@ readme_directory = no compatibility_level = 3.6 # TLS parameters -smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain -smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey +smtpd_tls_cert_file={{ config.tls_cert_path }} +smtpd_tls_key_file={{ config.tls_key_path }} smtpd_tls_security_level=may smtp_tls_CApath=/etc/ssl/certs -smtp_tls_security_level=verify +smtp_tls_security_level={{ "verify" if config.tls_cert == "acme" else "encrypt" }} # Send SNI extension when connecting to other servers. # smtp_tls_servername = hostname diff --git a/cmdeploy/src/cmdeploy/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py new file mode 100644 index 000000000..f8078bdb3 --- /dev/null +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -0,0 +1,34 @@ +from pyinfra.operations import apt, files, server + +from cmdeploy.basedeploy import Deployer + + +class SelfSignedTlsDeployer(Deployer): + """Generates a self-signed TLS certificate for all chatmail endpoints.""" + + def __init__(self, mail_domain): + self.mail_domain = mail_domain + self.cert_path = "/etc/ssl/certs/mailserver.pem" + self.key_path = "/etc/ssl/private/mailserver.key" + + def install(self): + apt.packages( + name="Install openssl", + packages=["openssl"], + ) + + def configure(self): + server.shell( + name="Generate self-signed TLS certificate if not present", + commands=[ + f"[ -f {self.cert_path} ] || openssl req -x509 -newkey rsa:4096" + f" -nodes -days 3650" + f" -keyout {self.key_path}" + f" -out {self.cert_path}" + f' -subj "/CN={self.mail_domain}"' + f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"', + ], + ) + + def activate(self): + pass diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index 1417ccf06..ba4bcfba3 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py @@ -11,15 +11,28 @@ def test_gen_qr_png_data(maildomain): def test_fastcgi_working(maildomain, chatmail_config): url = f"https://{maildomain}/new" print(url) - res = requests.post(url) + verify = chatmail_config.tls_cert != "self" + res = requests.post(url, verify=verify) assert maildomain in res.json().get("email") assert len(res.json().get("password")) > chatmail_config.password_min_length -def test_newemail_configure(maildomain, rpc): +def test_newemail_configure(maildomain, rpc, chatmail_config): """Test configuring accounts by scanning a QR code works.""" url = f"DCACCOUNT:https://{maildomain}/new" for i in range(3): account_id = rpc.add_account() - rpc.set_config_from_qr(account_id, url) + if chatmail_config.tls_cert == "self": + # deltachat core's rustls rejects self-signed HTTPS certs during + # set_config_from_qr, so fetch credentials via requests instead + verify = False + res = requests.post(f"https://{maildomain}/new", verify=verify) + data = res.json() + rpc.set_config(account_id, "addr", data["email"]) + rpc.set_config(account_id, "mail_pw", data["password"]) + rpc.set_config(account_id, "mail_server", maildomain) + rpc.set_config(account_id, "send_server", maildomain) + rpc.set_config(account_id, "imap_certificate_checks", "3") + else: + rpc.set_config_from_qr(account_id, url) rpc.configure(account_id) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index c0cb85e6b..7918887ae 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -11,11 +11,12 @@ @pytest.fixture -def imap_mailbox(cmfactory): +def imap_mailbox(cmfactory, ssl_context): (ac1,) = cmfactory.get_online_accounts(1) user = ac1.get_config("addr") password = ac1.get_config("mail_pw") - mailbox = imap_tools.MailBox(user.split("@")[1]) + host = user.split("@")[1] + mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(user, password) mailbox.dc_ac = ac1 return mailbox @@ -171,7 +172,7 @@ def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2): time.sleep(1) -def test_hide_senders_ip_address(cmfactory): +def test_hide_senders_ip_address(cmfactory, ssl_context): public_ip = requests.get("http://icanhazip.com").content.decode().strip() assert ipaddress.ip_address(public_ip) @@ -180,6 +181,11 @@ def test_hide_senders_ip_address(cmfactory): chat.send_text("testing submission header cleanup") user2._evtracker.wait_next_incoming_message() - user2.direct_imap.select_folder("Inbox") - msg = user2.direct_imap.get_all_messages()[0] - assert public_ip not in msg.obj.as_string() + addr = user2.get_config("addr") + host = addr.split("@")[1] + pw = user2.get_config("mail_pw") + mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) + mailbox.login(addr, pw) + msgs = list(mailbox.fetch()) + assert msgs, "expected at least one message" + assert public_ip not in msgs[0].obj.as_string() diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 6037518b2..7d515e085 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -4,6 +4,7 @@ import os import random import smtplib +import ssl import subprocess import time from pathlib import Path @@ -144,15 +145,26 @@ def fcol(parts): tr.write_line(line) + +@pytest.fixture(scope="session") +def ssl_context(chatmail_config): + if chatmail_config.tls_cert == "self": + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + return None + + @pytest.fixture -def imap(maildomain): - return ImapConn(maildomain) +def imap(maildomain, ssl_context): + return ImapConn(maildomain, ssl_context=ssl_context) @pytest.fixture -def make_imap_connection(maildomain): +def make_imap_connection(maildomain, ssl_context): def make_imap_connection(): - conn = ImapConn(maildomain) + conn = ImapConn(maildomain, ssl_context=ssl_context) conn.connect() return conn @@ -164,12 +176,13 @@ class ImapConn: logcmd = "journalctl -f -u dovecot" name = "dovecot" - def __init__(self, host): + def __init__(self, host, ssl_context=None): self.host = host + self.ssl_context = ssl_context def connect(self): print(f"imap-connect {self.host}") - self.conn = imaplib.IMAP4_SSL(self.host) + self.conn = imaplib.IMAP4_SSL(self.host, ssl_context=self.ssl_context) def login(self, user, password): print(f"imap-login {user!r} {password!r}") @@ -195,14 +208,14 @@ def fetch_all_messages(self): @pytest.fixture -def smtp(maildomain): - return SmtpConn(maildomain) +def smtp(maildomain, ssl_context): + return SmtpConn(maildomain, ssl_context=ssl_context) @pytest.fixture -def make_smtp_connection(maildomain): +def make_smtp_connection(maildomain, ssl_context): def make_smtp_connection(): - conn = SmtpConn(maildomain) + conn = SmtpConn(maildomain, ssl_context=ssl_context) conn.connect() return conn @@ -214,12 +227,14 @@ class SmtpConn: logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp" name = "postfix" - def __init__(self, host): + def __init__(self, host, ssl_context=None): self.host = host + self.ssl_context = ssl_context def connect(self): print(f"smtp-connect {self.host}") - self.conn = smtplib.SMTP_SSL(self.host) + context = self.ssl_context or ssl.create_default_context() + self.conn = smtplib.SMTP_SSL(self.host, context=context) def login(self, user, password): print(f"smtp-login {user!r} {password!r}") @@ -270,11 +285,12 @@ def gen(domain=None): class ChatmailTestProcess: """Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" - def __init__(self, pytestconfig, maildomain, gencreds): + def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config): self.pytestconfig = pytestconfig self.maildomain = maildomain assert "." in self.maildomain, maildomain self.gencreds = gencreds + self.chatmail_config = chatmail_config self._addr2files = {} def get_liveconfig_producer(self): @@ -287,6 +303,9 @@ def get_liveconfig_producer(self): # speed up account configuration config["mail_server"] = self.maildomain config["send_server"] = self.maildomain + if self.chatmail_config.tls_cert == "self": + # Accept self-signed TLS certificates + config["imap_certificate_checks"] = "3" yield config def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): @@ -297,12 +316,14 @@ def cache_maybe_store_configured_db_files(self, acc): @pytest.fixture -def cmfactory(request, gencreds, tmpdir, maildomain): +def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config): # cloned from deltachat.testplugin.amfactory pytest.importorskip("deltachat") from deltachat.testplugin import ACFactory - testproc = ChatmailTestProcess(request.config, maildomain, gencreds) + testproc = ChatmailTestProcess( + request.config, maildomain, gencreds, chatmail_config + ) class Data: def read_path(self, path): @@ -310,6 +331,12 @@ def read_path(self, path): am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) + if chatmail_config.tls_cert == "self": + # Skip upstream's init_imap which creates a DirectImap with + # strict SSL that fails on self-signed certs. Chatmail tests + # use fresh throwaway accounts, so folder cleanup is unnecessary. + am._acsetup.init_imap = lambda acc: None + # nb. a bit hacky # would probably be better if deltachat's test machinery grows native support def switch_maildomain(maildomain2): @@ -363,38 +390,40 @@ def indent(self, msg): @pytest.fixture -def cmsetup(maildomain, gencreds): - return CMSetup(maildomain, gencreds) +def cmsetup(maildomain, gencreds, ssl_context): + return CMSetup(maildomain, gencreds, ssl_context) class CMSetup: - def __init__(self, maildomain, gencreds): + def __init__(self, maildomain, gencreds, ssl_context): self.maildomain = maildomain self.gencreds = gencreds + self.ssl_context = ssl_context def gen_users(self, num): print(f"Creating {num} online users") users = [] for i in range(num): addr, password = self.gencreds() - user = CMUser(self.maildomain, addr, password) + user = CMUser(self.maildomain, addr, password, self.ssl_context) assert user.smtp users.append(user) return users class CMUser: - def __init__(self, maildomain, addr, password): + def __init__(self, maildomain, addr, password, ssl_context=None): self.maildomain = maildomain self.addr = addr self.password = password + self.ssl_context = ssl_context self._smtp = None self._imap = None @property def smtp(self): if not self._smtp: - handle = SmtpConn(self.maildomain) + handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context) handle.connect() handle.login(self.addr, self.password) self._smtp = handle @@ -403,7 +432,7 @@ def smtp(self): @property def imap(self): if not self._imap: - imap = ImapConn(self.maildomain) + imap = ImapConn(self.maildomain, ssl_context=self.ssl_context) imap.connect() imap.login(self.addr, self.password) self._imap = imap diff --git a/cmdeploy/src/cmdeploy/tests/test_dns.py b/cmdeploy/src/cmdeploy/tests/test_dns.py index 774820b56..3e862d332 100644 --- a/cmdeploy/src/cmdeploy/tests/test_dns.py +++ b/cmdeploy/src/cmdeploy/tests/test_dns.py @@ -91,6 +91,16 @@ def test_perform_initial_checks_no_mta_sts(self, mockdns): assert not res assert len(l) == 2 + def test_perform_initial_checks_no_mta_sts_self_signed(self, mockdns): + del mockdns["CNAME"]["mta-sts.some.domain"] + remote_data = remote.rdns.perform_initial_checks("some.domain") + assert not remote_data["MTA_STS"] + + l = [] + res = check_initial_remote_data(remote_data, strict_tls=False, print=l.append) + assert res + assert not l + def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False): for zf_line in zonefile.split("\n"): diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 107f2e2d6..9b9f50caf 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -49,8 +49,12 @@ The deployed system components of a chatmail relay are: - Nginx_ shows the web page with privacy policy and additional information -- `acmetool `_ manages TLS - certificates for Dovecot, Postfix, and Nginx +- `acmetool `_ + manages TLS certificates for Dovecot, Postfix, and Nginx + (when ``tls_cert = acme``, the default). + With ``tls_cert = self``, + self-signed certificates are generated by openssl instead + and acmetool is not used. - `OpenDKIM `_ for signing messages with DKIM and rejecting inbound messages without DKIM @@ -292,12 +296,30 @@ and rejects incorrectly authenticated emails with ``From:`` header must correspond to envelope ``MAIL FROM``, this is ensured by ``filtermail`` proxy. -TLS requirements -~~~~~~~~~~~~~~~~ +TLS certificate provisioning +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Postfix is configured to require valid TLS by setting -`smtp_tls_security_level `_ -to ``verify``. If emails don’t arrive at your chatmail relay server, the +The ``tls_cert`` option in ``chatmail.ini`` controls how TLS certificates +are provisioned and how outgoing SMTP connections validate remote servers: + +- ``tls_cert = acme`` (default): Uses Let’s Encrypt certificates via + `acmetool `_. + Outgoing SMTP uses + `smtp_tls_security_level `_ + ``verify``, requiring valid certificates from remote mail servers. + +- ``tls_cert = self``: Uses a self-signed certificate generated by + ``cmdeploy run``. Outgoing SMTP uses ``smtp_tls_security_level=encrypt``, + requiring TLS encryption but skipping certificate verification. + This allows self-signed chatmail relays to interoperate with each other. + Since chatmail enforces strict OpenPGP end-to-end encryption, + message content is protected regardless of transport-layer certificate + validation (see `RFC 7435 — Opportunistic Security + `_). + +Troubleshooting TLS with ``tls_cert = acme`` + +If emails don’t arrive at your chatmail relay server, the problem is likely that your relay does not have a valid TLS certificate. You can test it by resolving ``MX`` records of your relay domain and From a20618d6ed0adb7efb7d1ffb9293fb420646cdf2 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 17 Feb 2026 23:59:16 +0100 Subject: [PATCH 02/11] feat: generate dclogin URLs with QR codes for self-signed certificates When tls_cert=self, the /new endpoint returns a dclogin: URL with ic=3 (AcceptInvalidCertificates). The home page uses JavaScript to fetch credentials and render a QR code client-side via qrcode-svg. --- chatmaild/src/chatmaild/newemail.py | 16 ++++++++- chatmaild/src/chatmaild/tests/test_newmail.py | 34 ++++++++++++++++++- cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 18 ++++++---- www/src/dclogin.js | 19 +++++++++++ www/src/index.md | 13 +++++++ www/src/qrcode-svg.min.js | 9 +++++ 6 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 www/src/dclogin.js create mode 100644 www/src/qrcode-svg.min.js diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 67bd861ae..42dffddeb 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -6,6 +6,7 @@ import random import secrets import string +from urllib.parse import quote from chatmaild.config import Config, read_config @@ -23,13 +24,26 @@ def create_newemail_dict(config: Config): return dict(email=f"{user}@{config.mail_domain}", password=f"{password}") +def create_dclogin_url(email, password): + """Build a dclogin: URL with credentials and self-signed cert acceptance. + + Uses ic=3 (AcceptInvalidCertificates) so Delta Chat clients + can connect to servers with self-signed TLS certificates. + """ + return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3" + + def print_new_account(): config = read_config(CONFIG_PATH) creds = create_newemail_dict(config) + result = dict(email=creds["email"], password=creds["password"]) + if config.tls_cert == "self": + result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) + print("Content-Type: application/json") print("") - print(json.dumps(creds)) + print(json.dumps(result)) if __name__ == "__main__": diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index df9a5c044..335eeefa1 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -1,7 +1,11 @@ import json import chatmaild -from chatmaild.newemail import create_newemail_dict, print_new_account +from chatmaild.newemail import ( + create_dclogin_url, + create_newemail_dict, + print_new_account, +) def test_create_newemail_dict(example_config): @@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config): assert ac1["password"] != ac2["password"] +def test_create_dclogin_url(): + url = create_dclogin_url("user@example.org", "p@ss w+rd") + assert url.startswith("dclogin:") + assert "v=1" in url + assert "ic=3" in url + # email @ must be encoded + assert "user%40example.org" in url + # password special chars must be encoded + assert "p%40ss" in url + assert "w%2Brd" in url + + def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config): monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) print_new_account() @@ -25,3 +41,19 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf dic = json.loads(lines[2]) assert dic["email"].endswith(f"@{example_config.mail_domain}") assert len(dic["password"]) >= 10 + # default tls_cert=acme should not include dclogin_url + assert "dclogin_url" not in dic + + +def test_print_new_account_self_signed(capsys, monkeypatch, make_config): + config = make_config("chat.example.org", {"tls_cert": "self"}) + monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath)) + print_new_account() + out, err = capsys.readouterr() + lines = out.split("\n") + dic = json.loads(lines[2]) + assert "dclogin_url" in dic + url = dic["dclogin_url"] + assert url.startswith("dclogin:") + assert "ic=3" in url + assert dic["email"].split("@")[0] in url diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 58864d72e..28456923f 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -53,8 +53,8 @@ http { ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; - ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain; - ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey; + ssl_certificate {{ config.tls_cert_path }}; + ssl_certificate_key {{ config.tls_key_path }}; gzip on; @@ -66,7 +66,7 @@ http { index index.html index.htm; - server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }}; + server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; access_log syslog:server=unix:/dev/log,facility=local7; @@ -81,11 +81,13 @@ http { } location /new { +{% if config.tls_cert != "self" %} if ($request_method = GET) { # Redirect to Delta Chat, # which will in turn do a POST request. - return 301 dcaccount:https://{{ config.domain_name }}/new; + return 301 dcaccount:https://{{ config.mail_domain }}/new; } +{% endif %} fastcgi_pass unix:/run/fcgiwrap.socket; include /etc/nginx/fastcgi_params; @@ -99,9 +101,11 @@ http { # # Redirects are only for browsers. location /cgi-bin/newemail.py { +{% if config.tls_cert != "self" %} if ($request_method = GET) { - return 301 dcaccount:https://{{ config.domain_name }}/new; + return 301 dcaccount:https://{{ config.mail_domain }}/new; } +{% endif %} fastcgi_pass unix:/run/fcgiwrap.socket; include /etc/nginx/fastcgi_params; @@ -132,8 +136,8 @@ http { # Redirect www. to non-www server { listen 127.0.0.1:8443 ssl; - server_name www.{{ config.domain_name }}; - return 301 $scheme://{{ config.domain_name }}$request_uri; + server_name www.{{ config.mail_domain }}; + return 301 $scheme://{{ config.mail_domain }}$request_uri; access_log syslog:server=unix:/dev/log,facility=local7; } } diff --git a/www/src/dclogin.js b/www/src/dclogin.js new file mode 100644 index 000000000..c62b55c94 --- /dev/null +++ b/www/src/dclogin.js @@ -0,0 +1,19 @@ +/* dclogin profile generator for self-signed chatmail relays. + * Fetches credentials from /new and generates a dclogin: QR code. + * Requires qrcode-svg.min.js to be loaded first. + */ +(function() { + function generateProfile() { + fetch('/new') + .then(function(r) { return r.json(); }) + .then(function(data) { + var url = data.dclogin_url; + var link = document.getElementById('dclogin-link'); + link.href = url; + var container = document.getElementById('qr-code'); + var qr = new QRCode({content: url, width: 300, height: 300, padding: 1, join: true}); + container.innerHTML = '' + qr.svg() + ''; + }); + } + generateProfile(); +})(); diff --git a/www/src/index.md b/www/src/index.md index aae1a0db1..42705329a 100644 --- a/www/src/index.md +++ b/www/src/index.md @@ -11,6 +11,18 @@ for Delta Chat users. For details how it avoids storing personal information please see our [privacy policy](privacy.html). {% endif %} +{% if config.tls_cert == "self" %} +Get a {{config.mail_domain}} chat profile + +If you are viewing this page on a different device +without a Delta Chat app, +you can also **scan this QR code** with Delta Chat: + +
+ + + +{% else %} Get a {{config.mail_domain}} chat profile If you are viewing this page on a different device @@ -19,6 +31,7 @@ you can also **scan this QR code** with Delta Chat: +{% endif %} 🐣 **Choose** your Avatar and Name diff --git a/www/src/qrcode-svg.min.js b/www/src/qrcode-svg.min.js new file mode 100644 index 000000000..c66327e62 --- /dev/null +++ b/www/src/qrcode-svg.min.js @@ -0,0 +1,9 @@ +/* qrcode-svg v1.1.0 - https://github.com/papnkukn/qrcode-svg - MIT License */ +/** + * Minified by jsDelivr using Terser v5.37.0. + * Original file: /npm/qrcode-svg@1.1.0/lib/qrcode.js + * + * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files + */ +function QR8bitByte(t){this.mode=QRMode.MODE_8BIT_BYTE,this.data=t,this.parsedData=[];for(var e=0,r=this.data.length;e65536?(o[0]=240|(1835008&n)>>>18,o[1]=128|(258048&n)>>>12,o[2]=128|(4032&n)>>>6,o[3]=128|63&n):n>2048?(o[0]=224|(61440&n)>>>12,o[1]=128|(4032&n)>>>6,o[2]=128|63&n):n>128?(o[0]=192|(1984&n)>>>6,o[1]=128|63&n):o[0]=n,this.parsedData.push(o)}this.parsedData=Array.prototype.concat.apply([],this.parsedData),this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function QRCodeModel(t,e){this.typeNumber=t,this.errorCorrectLevel=e,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}QR8bitByte.prototype={getLength:function(t){return this.parsedData.length},write:function(t){for(var e=0,r=this.parsedData.length;e=7&&this.setupTypeNumber(t),null==this.dataCache&&(this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,e)},setupPositionProbePattern:function(t,e){for(var r=-1;r<=7;r++)if(!(t+r<=-1||this.moduleCount<=t+r))for(var o=-1;o<=7;o++)e+o<=-1||this.moduleCount<=e+o||(this.modules[t+r][e+o]=0<=r&&r<=6&&(0==o||6==o)||0<=o&&o<=6&&(0==r||6==r)||2<=r&&r<=4&&2<=o&&o<=4)},getBestMaskPattern:function(){for(var t=0,e=0,r=0;r<8;r++){this.makeImpl(!0,r);var o=QRUtil.getLostPoint(this);(0==r||t>o)&&(t=o,e=r)}return e},createMovieClip:function(t,e,r){var o=t.createEmptyMovieClip(e,r);this.make();for(var n=0;n>r&1);this.modules[Math.floor(r/3)][r%3+this.moduleCount-8-3]=o}for(r=0;r<18;r++){o=!t&&1==(e>>r&1);this.modules[r%3+this.moduleCount-8-3][Math.floor(r/3)]=o}},setupTypeInfo:function(t,e){for(var r=this.errorCorrectLevel<<3|e,o=QRUtil.getBCHTypeInfo(r),n=0;n<15;n++){var i=!t&&1==(o>>n&1);n<6?this.modules[n][8]=i:n<8?this.modules[n+1][8]=i:this.modules[this.moduleCount-15+n][8]=i}for(n=0;n<15;n++){i=!t&&1==(o>>n&1);n<8?this.modules[8][this.moduleCount-n-1]=i:n<9?this.modules[8][15-n-1+1]=i:this.modules[8][15-n-1]=i}this.modules[this.moduleCount-8][8]=!t},mapData:function(t,e){for(var r=-1,o=this.moduleCount-1,n=7,i=0,a=this.moduleCount-1;a>0;a-=2)for(6==a&&a--;;){for(var s=0;s<2;s++)if(null==this.modules[o][a-s]){var h=!1;i>>n&1)),QRUtil.getMask(e,o,a-s)&&(h=!h),this.modules[o][a-s]=h,-1==--n&&(i++,n=7)}if((o+=r)<0||this.moduleCount<=o){o-=r,r=-r;break}}}},QRCodeModel.PAD0=236,QRCodeModel.PAD1=17,QRCodeModel.createData=function(t,e,r){for(var o=QRRSBlock.getRSBlocks(t,e),n=new QRBitBuffer,i=0;i8*s)throw new Error("code length overflow. ("+n.getLengthInBits()+">"+8*s+")");for(n.getLengthInBits()+4<=8*s&&n.put(0,4);n.getLengthInBits()%8!=0;)n.putBit(!1);for(;!(n.getLengthInBits()>=8*s||(n.put(QRCodeModel.PAD0,8),n.getLengthInBits()>=8*s));)n.put(QRCodeModel.PAD1,8);return QRCodeModel.createBytes(n,o)},QRCodeModel.createBytes=function(t,e){for(var r=0,o=0,n=0,i=new Array(e.length),a=new Array(e.length),s=0;s=0?d.get(f):0}}var c=0;for(u=0;u=0;)e^=QRUtil.G15<=0;)e^=QRUtil.G18<>>=1;return e},getPatternPosition:function(t){return QRUtil.PATTERN_POSITION_TABLE[t-1]},getMask:function(t,e,r){switch(t){case QRMaskPattern.PATTERN000:return(e+r)%2==0;case QRMaskPattern.PATTERN001:return e%2==0;case QRMaskPattern.PATTERN010:return r%3==0;case QRMaskPattern.PATTERN011:return(e+r)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(e/2)+Math.floor(r/3))%2==0;case QRMaskPattern.PATTERN101:return e*r%2+e*r%3==0;case QRMaskPattern.PATTERN110:return(e*r%2+e*r%3)%2==0;case QRMaskPattern.PATTERN111:return(e*r%3+(e+r)%2)%2==0;default:throw new Error("bad maskPattern:"+t)}},getErrorCorrectPolynomial:function(t){for(var e=new QRPolynomial([1],0),r=0;r5&&(r+=3+i-5)}for(o=0;o=256;)t-=255;return QRMath.EXP_TABLE[t]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},i=0;i<8;i++)QRMath.EXP_TABLE[i]=1<>>7-t%8&1)},put:function(t,e){for(var r=0;r>>e-r-1&1))},getLengthInBits:function(){return this.length},putBit:function(t){var e=Math.floor(this.length/8);this.buffer.length<=e&&this.buffer.push(0),t&&(this.buffer[e]|=128>>>this.length%8),this.length++}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];function QRCode(t){if(this.options={padding:4,width:256,height:256,typeNumber:4,color:"#000000",background:"#ffffff",ecl:"M"},"string"==typeof t&&(t={content:t}),t)for(var e in t)this.options[e]=t[e];if("string"!=typeof this.options.content)throw new Error("Expected 'content' as string!");if(0===this.options.content.length)throw new Error("Expected 'content' to be non-empty!");if(!(this.options.padding>=0))throw new Error("Expected 'padding' value to be non-negative!");if(!(this.options.width>0&&this.options.height>0))throw new Error("Expected 'width' or 'height' value to be higher than zero!");var r=this.options.content,o=function(t,e){for(var r=function(t){var e=encodeURI(t).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return e.length+(e.length!=t?3:0)}(t),o=1,n=0,i=0,a=QRCodeLimitLength.length;i<=a;i++){var s=QRCodeLimitLength[i];if(!s)throw new Error("Content too long: expected "+n+" but got "+r);switch(e){case"L":n=s[0];break;case"M":n=s[1];break;case"Q":n=s[2];break;case"H":n=s[3];break;default:throw new Error("Unknwon error correction level: "+e)}if(r<=n)break;o++}if(o>QRCodeLimitLength.length)throw new Error("Content too long");return o}(r,this.options.ecl),n=function(t){switch(t){case"L":return QRErrorCorrectLevel.L;case"M":return QRErrorCorrectLevel.M;case"Q":return QRErrorCorrectLevel.Q;case"H":return QRErrorCorrectLevel.H;default:throw new Error("Unknwon error correction level: "+t)}}(this.options.ecl);this.qrcode=new QRCodeModel(o,n),this.qrcode.addData(r),this.qrcode.make()}QRCode.prototype.svg=function(t){var e=this.options||{},r=this.qrcode.modules;void 0===t&&(t={container:e.container||"svg"});for(var o=void 0===e.pretty||!!e.pretty,n=o?" ":"",i=o?"\r\n":"",a=e.width,s=e.height,h=r.length,l=a/(h+2*e.padding),u=s/(h+2*e.padding),g=void 0!==e.join&&!!e.join,d=void 0!==e.swap&&!!e.swap,f=void 0===e.xmlDeclaration||!!e.xmlDeclaration,c=void 0!==e.predefined&&!!e.predefined,R=c?n+''+i:"",p=n+''+i,m="",Q="",v=0;v'+i:n+''+i}}g&&(m=n+'');var T="";switch(t.container){case"svg":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"svg-viewbox":f&&(T+=''+i),T+=''+i,T+=R+p+m,T+="";break;case"g":T+=''+i,T+=R+p+m,T+="";break;default:T+=(R+p+m).replace(/^\s+/,"")}return T},QRCode.prototype.save=function(t,e){var r=this.svg();"function"!=typeof e&&(e=function(t,e){});try{require("fs").writeFile(t,r,e)}catch(t){e(t)}},"undefined"!=typeof module&&(module.exports=QRCode); +//# sourceMappingURL=/sm/5f01ccdc67a4d2db249b91f6311f22dea02454564a74eede4bcfe1b55dc9e5cc.map \ No newline at end of file From 281d92687cf3003e514e75e616a30bb2af4b9765 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Tue, 17 Feb 2026 23:59:44 +0100 Subject: [PATCH 03/11] feat: rate-limit /new endpoint to 2 req/s for self-signed mode Prevents abuse of the account creation endpoint that is now accessible via GET when tls_cert=self. Uses nginx limit_req with burst=5 and nodelay. --- cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 28456923f..50d7b1654 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -42,6 +42,9 @@ stream { } http { +{% if config.tls_cert == "self" %} + limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; +{% endif %} sendfile on; tcp_nopush on; @@ -87,6 +90,8 @@ http { # which will in turn do a POST request. return 301 dcaccount:https://{{ config.mail_domain }}/new; } +{% else %} + limit_req zone=newaccount burst=5 nodelay; {% endif %} fastcgi_pass unix:/run/fcgiwrap.socket; From d393c551c3447fcf8e6101fdcfc08f7223b34edb Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 01:25:20 +0100 Subject: [PATCH 04/11] docs: document tls_cert option in getting_started and faq --- doc/source/faq.rst | 16 +++++++++++++--- doc/source/getting_started.rst | 14 ++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 3fd369986..014179ad7 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -14,7 +14,12 @@ goes beyond what classic email servers offer: - **Instant/Realtime:** sub-second message delivery, realtime P2P streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch `_; -- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted +- **Security Enforcement**: only strict TLS, DKIM and + OpenPGP with minimized metadata accepted. + Relays using ``tls_cert = self`` + still enforce TLS encryption for transport + while relying on OpenPGP end-to-end encryption + for message content protection. - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating depends on established IETF standards and protocols. @@ -44,8 +49,13 @@ automatically deployed and updated using `the chatmail relay repository `__. Chatmail relays are composed of proven standard email server components, Postfix and Dovecot, and are configured to run unattended without much maintenance -effort. Chatmail relays happily run on low-end hardware like a Raspberry -Pi. +effort. Chatmail relays happily run on low-end hardware +like a Raspberry Pi. +Setting ``tls_cert = self`` in ``chatmail.ini`` +enables running a relay with self-signed certificates, +making it possible to operate on a LAN +or behind a firewall +without needing Let's Encrypt or a public domain. How trustable are chatmail relays? diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 2553eff1b..58993e85f 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -47,6 +47,12 @@ steps. Please substitute it with your own domain. www.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. + .. note:: + + For ``tls_cert = self`` deployments (see step 3), + the ``mta-sts`` CNAME and ``_mta-sts`` TXT records + are not needed. + 2. On your local PC, clone the repository and bootstrap the Python virtualenv. @@ -63,6 +69,14 @@ steps. Please substitute it with your own domain. scripts/cmdeploy init chat.example.org # <-- use your domain + To use self-signed TLS certificates + instead of Let's Encrypt, + set ``tls_cert = self`` in ``chatmail.ini``. + This is useful for private or test deployments + and does not require a publicly resolvable domain. + See the :doc:`overview` + for details on certificate provisioning. + 4. Verify that SSH root login to the deployment server server works: :: From 8180c8998b724e3a4737d27c856842c00aaad4bf Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 02:38:46 +0100 Subject: [PATCH 05/11] ci: try add test for tls_cert=self in staging-ipv4 workflow --- .github/workflows/test-and-deploy-ipv4only.yaml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index f6266dfc4..80a2f4121 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -71,12 +71,21 @@ jobs: - name: run deploy-chatmail offline tests run: pytest --pyargs cmdeploy - - run: | + - name: deploy and test tls_cert=self + run: | cmdeploy init staging-ipv4.testrun.org sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini + sed -i 's/tls_cert = acme/tls_cert = self/' chatmail.ini + cmdeploy run --verbose --skip-dns-check + cmdeploy test --slow - - run: cmdeploy run --verbose --skip-dns-check + - name: deploy and test tls_cert=acme + run: | + cmdeploy init staging-ipv4.testrun.org + sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini + sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini + cmdeploy run --verbose --skip-dns-check - name: set DNS entries run: | @@ -88,9 +97,8 @@ jobs: ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone ssh root@ns.testrun.org systemctl reload nsd - - name: cmdeploy test + - name: cmdeploy test acme run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow - name: cmdeploy dns run: cmdeploy dns -v - From 28ef816af90d6e45e329e7d20121e7061d1ce44e Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 11:21:16 +0100 Subject: [PATCH 06/11] Apply suggestions from missytake Co-authored-by: missytake --- .github/workflows/test-and-deploy-ipv4only.yaml | 3 ++- chatmaild/src/chatmaild/newemail.py | 4 ++-- chatmaild/src/chatmaild/tests/test_newmail.py | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index 80a2f4121..4987d3dfd 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -80,8 +80,9 @@ jobs: cmdeploy run --verbose --skip-dns-check cmdeploy test --slow - - name: deploy and test tls_cert=acme + - name: deploy tls_cert=acme run: | + rm chatmail.ini cmdeploy init staging-ipv4.testrun.org sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 42dffddeb..0c4991422 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -27,10 +27,10 @@ def create_newemail_dict(config: Config): def create_dclogin_url(email, password): """Build a dclogin: URL with credentials and self-signed cert acceptance. - Uses ic=3 (AcceptInvalidCertificates) so Delta Chat clients + Uses ic=3 and sc=3 (AcceptInvalidCertificates) so Delta Chat clients can connect to servers with self-signed TLS certificates. """ - return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3" + return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3&sc=3" def print_new_account(): diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index 335eeefa1..d3d312d5d 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -24,6 +24,7 @@ def test_create_dclogin_url(): assert url.startswith("dclogin:") assert "v=1" in url assert "ic=3" in url + assert "sc=3" in url # email @ must be encoded assert "user%40example.org" in url # password special chars must be encoded @@ -56,4 +57,5 @@ def test_print_new_account_self_signed(capsys, monkeypatch, make_config): url = dic["dclogin_url"] assert url.startswith("dclogin:") assert "ic=3" in url + assert "sc=3" in url assert dic["email"].split("@")[0] in url From 503d5dd371b12896d638836977612e345d9007d9 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 12:48:31 +0100 Subject: [PATCH 07/11] address several link2xt review comments, also tune down use of tls certs which is more aimed for testing. --- chatmaild/src/chatmaild/newemail.py | 4 ++-- cmdeploy/src/cmdeploy/selfsigned/deployer.py | 6 +++-- .../src/cmdeploy/tests/online/test_0_qr.py | 15 ++++++------ .../cmdeploy/tests/online/test_2_deltachat.py | 2 +- doc/source/faq.rst | 24 +++++++++---------- www/src/dclogin.js | 14 ++++++----- www/src/index.md | 2 +- 7 files changed, 35 insertions(+), 32 deletions(-) diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 0c4991422..223a838da 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -27,10 +27,10 @@ def create_newemail_dict(config: Config): def create_dclogin_url(email, password): """Build a dclogin: URL with credentials and self-signed cert acceptance. - Uses ic=3 and sc=3 (AcceptInvalidCertificates) so Delta Chat clients + Uses ic=3 (AcceptInvalidCertificates) so chatmail clients can connect to servers with self-signed TLS certificates. """ - return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3&sc=3" + return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3" def print_new_account(): diff --git a/cmdeploy/src/cmdeploy/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py index f8078bdb3..4bf2def21 100644 --- a/cmdeploy/src/cmdeploy/selfsigned/deployer.py +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -21,11 +21,13 @@ def configure(self): server.shell( name="Generate self-signed TLS certificate if not present", commands=[ - f"[ -f {self.cert_path} ] || openssl req -x509 -newkey rsa:4096" - f" -nodes -days 3650" + 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}"', ], ) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index ba4bcfba3..2ad011c50 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py @@ -25,14 +25,15 @@ def test_newemail_configure(maildomain, rpc, chatmail_config): if chatmail_config.tls_cert == "self": # deltachat core's rustls rejects self-signed HTTPS certs during # set_config_from_qr, so fetch credentials via requests instead - verify = False - res = requests.post(f"https://{maildomain}/new", verify=verify) + res = requests.post(f"https://{maildomain}/new", verify=False) data = res.json() - rpc.set_config(account_id, "addr", data["email"]) - rpc.set_config(account_id, "mail_pw", data["password"]) - rpc.set_config(account_id, "mail_server", maildomain) - rpc.set_config(account_id, "send_server", maildomain) - rpc.set_config(account_id, "imap_certificate_checks", "3") + rpc.add_or_update_transport(account_id, { + "addr": data["email"], + "password": data["password"], + "imapServer": maildomain, + "smtpServer": maildomain, + "certificateChecks": "acceptInvalidCertificates", + }) else: rpc.set_config_from_qr(account_id, url) rpc.configure(account_id) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index 7918887ae..a87081fd0 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py @@ -186,6 +186,6 @@ def test_hide_senders_ip_address(cmfactory, ssl_context): pw = user2.get_config("mail_pw") mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox.login(addr, pw) - msgs = list(mailbox.fetch()) + msgs = list(mailbox.fetch(mark_seen=False)) assert msgs, "expected at least one message" assert public_ip not in msgs[0].obj.as_string() diff --git a/doc/source/faq.rst b/doc/source/faq.rst index 014179ad7..c5500fcc4 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -14,12 +14,7 @@ goes beyond what classic email servers offer: - **Instant/Realtime:** sub-second message delivery, realtime P2P streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch `_; -- **Security Enforcement**: only strict TLS, DKIM and - OpenPGP with minimized metadata accepted. - Relays using ``tls_cert = self`` - still enforce TLS encryption for transport - while relying on OpenPGP end-to-end encryption - for message content protection. +- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating depends on established IETF standards and protocols. @@ -48,13 +43,16 @@ self-funded by respective operators. All chatmail relays are automatically deployed and updated using `the chatmail relay repository `__. Chatmail relays are composed of proven standard email server components, Postfix and -Dovecot, and are configured to run unattended without much maintenance -effort. Chatmail relays happily run on low-end hardware -like a Raspberry Pi. -Setting ``tls_cert = self`` in ``chatmail.ini`` -enables running a relay with self-signed certificates, -making it possible to operate on a LAN -or behind a firewall +Dovecot, and are configured to run unattended without much maintenance effort. +Chatmail relays happily run on low-end hardware like a Raspberry Pi. + + +Can i run a relay without using letsencrypt for TLS certificates? +----------------------------------------------------------------- + +You can set ``tls_cert = self`` in ``chatmail.ini`` +to enable running a relay with self-signed certificates, +making it possible to operate on a LAN or behind a firewall without needing Let's Encrypt or a public domain. diff --git a/www/src/dclogin.js b/www/src/dclogin.js index c62b55c94..3461d0963 100644 --- a/www/src/dclogin.js +++ b/www/src/dclogin.js @@ -2,17 +2,19 @@ * Fetches credentials from /new and generates a dclogin: QR code. * Requires qrcode-svg.min.js to be loaded first. */ -(function() { +(function () { function generateProfile() { fetch('/new') - .then(function(r) { return r.json(); }) - .then(function(data) { + .then(function (r) { return r.json(); }) + .then(function (data) { var url = data.dclogin_url; var link = document.getElementById('dclogin-link'); link.href = url; - var container = document.getElementById('qr-code'); - var qr = new QRCode({content: url, width: 300, height: 300, padding: 1, join: true}); - container.innerHTML = '' + qr.svg() + ''; + var qrLink = document.getElementById('qr-link'); + qrLink.href = url; + var qrCode = document.getElementById('qr-code'); + var qr = new QRCode({ content: url, width: 300, height: 300, padding: 1, join: true }); + qrCode.innerHTML = qr.svg(); }); } generateProfile(); diff --git a/www/src/index.md b/www/src/index.md index 42705329a..d85a792ea 100644 --- a/www/src/index.md +++ b/www/src/index.md @@ -18,7 +18,7 @@ If you are viewing this page on a different device without a Delta Chat app, you can also **scan this QR code** with Delta Chat: -
+
From 06980d0350b3588875cd8bb43748b328e61b1ff3 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 14:43:23 +0100 Subject: [PATCH 08/11] skip filtermail tests if no binary is present --- chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py b/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py index 08c719672..85718890c 100644 --- a/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py +++ b/chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py @@ -1,9 +1,15 @@ +import shutil import smtplib import subprocess import sys import pytest +pytestmark = pytest.mark.skipif( + shutil.which("filtermail") is None, + reason="filtermail binary not found", +) + @pytest.fixture def smtpserver(): From 8ddabd978b5e7122f4ec7bba28921a1dec9695b8 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 14:22:25 +0100 Subject: [PATCH 09/11] move towards underscore domain logic --- .../workflows/test-and-deploy-ipv4only.yaml | 14 +----- chatmaild/src/chatmaild/config.py | 17 ++++--- chatmaild/src/chatmaild/ini/chatmail.ini.f | 8 ---- chatmaild/src/chatmaild/newemail.py | 2 +- chatmaild/src/chatmaild/tests/test_config.py | 11 ++--- chatmaild/src/chatmaild/tests/test_newmail.py | 6 +-- cmdeploy/src/cmdeploy/cmdeploy.py | 9 ++-- cmdeploy/src/cmdeploy/deployers.py | 4 +- cmdeploy/src/cmdeploy/dns.py | 2 +- cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 | 6 +-- cmdeploy/src/cmdeploy/postfix/main.cf.j2 | 2 +- .../src/cmdeploy/postfix/smtp_tls_policy_map | 1 + .../src/cmdeploy/tests/online/test_0_qr.py | 10 +++-- cmdeploy/src/cmdeploy/tests/plugin.py | 13 +++--- doc/source/faq.rst | 14 ++---- doc/source/getting_started.rst | 27 ++++++++--- doc/source/overview.rst | 45 +++++++------------ www/src/index.md | 2 +- 18 files changed, 81 insertions(+), 112 deletions(-) diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index 4987d3dfd..72ed5bfdd 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -71,18 +71,7 @@ jobs: - name: run deploy-chatmail offline tests run: pytest --pyargs cmdeploy - - name: deploy and test tls_cert=self - run: | - cmdeploy init staging-ipv4.testrun.org - sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini - sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini - sed -i 's/tls_cert = acme/tls_cert = self/' chatmail.ini - cmdeploy run --verbose --skip-dns-check - cmdeploy test --slow - - - name: deploy tls_cert=acme - run: | - rm chatmail.ini + - run: | cmdeploy init staging-ipv4.testrun.org sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini @@ -103,3 +92,4 @@ jobs: - name: cmdeploy dns run: cmdeploy dns -v + diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index 08fa5e03b..af6fef0d9 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -60,18 +60,17 @@ def __init__(self, inipath, params): self.privacy_pdo = params.get("privacy_pdo") self.privacy_supervisor = params.get("privacy_supervisor") - # TLS certificate management: "acme" (letsencrypt) or "self" (self-signed) - self.tls_cert = params.get("tls_cert", "acme") - if self.tls_cert == "acme": - self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" - self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" - elif self.tls_cert == "self": + # 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("_"): + self.tls_cert_mode = "self" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_key_path = "/etc/ssl/private/mailserver.key" else: - raise ValueError( - f"invalid tls_cert option {self.tls_cert!r}, must be 'acme' or 'self'" - ) + self.tls_cert_mode = "acme" + self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain" + self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey" # deprecated option mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") diff --git a/chatmaild/src/chatmaild/ini/chatmail.ini.f b/chatmaild/src/chatmaild/ini/chatmail.ini.f index 3745e11a6..29d7baa9e 100644 --- a/chatmaild/src/chatmaild/ini/chatmail.ini.f +++ b/chatmaild/src/chatmaild/ini/chatmail.ini.f @@ -66,14 +66,6 @@ # if set to "True" IPv6 is disabled disable_ipv6 = False - -# TLS server certificate provisioning -# "acme" (default) uses Let's Encrypt via acmetool -# "self" uses a self-signed certificate generated by cmdeploy run -# implies that SMTP connections to other MTAs will require -# encryption but not certificate verification -tls_cert = acme - # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates acme_email = diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 223a838da..ef42bfc4f 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -38,7 +38,7 @@ def print_new_account(): creds = create_newemail_dict(config) result = dict(email=creds["email"], password=creds["password"]) - if config.tls_cert == "self": + if config.tls_cert_mode == "self": result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"]) print("Content-Type: application/json") diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index 6d206823c..80dcb1897 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -77,18 +77,13 @@ def test_config_max_message_size(make_config, tmp_path): def test_config_tls_default_acme(make_config): config = make_config("chat.example.org") - assert config.tls_cert == "acme" + assert config.tls_cert_mode == "acme" assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain" assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey" def test_config_tls_self(make_config): - config = make_config("chat.example.org", {"tls_cert": "self"}) - assert config.tls_cert == "self" + config = make_config("_test.example.org") + 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_invalid(make_config): - with pytest.raises(ValueError, match="invalid tls_cert option"): - make_config("chat.example.org", {"tls_cert": "invalid"}) diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index d3d312d5d..5f736b3b1 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -24,7 +24,7 @@ def test_create_dclogin_url(): assert url.startswith("dclogin:") assert "v=1" in url assert "ic=3" in url - assert "sc=3" in url + # email @ must be encoded assert "user%40example.org" in url # password special chars must be encoded @@ -47,7 +47,7 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf def test_print_new_account_self_signed(capsys, monkeypatch, make_config): - config = make_config("chat.example.org", {"tls_cert": "self"}) + config = make_config("_test.example.org") monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath)) print_new_account() out, err = capsys.readouterr() @@ -57,5 +57,5 @@ def test_print_new_account_self_signed(capsys, monkeypatch, make_config): url = dic["dclogin_url"] assert url.startswith("dclogin:") assert "ic=3" in url - assert "sc=3" in url + assert dic["email"].split("@")[0] in url diff --git a/cmdeploy/src/cmdeploy/cmdeploy.py b/cmdeploy/src/cmdeploy/cmdeploy.py index 4fa78b5fe..9fd9763fe 100644 --- a/cmdeploy/src/cmdeploy/cmdeploy.py +++ b/cmdeploy/src/cmdeploy/cmdeploy.py @@ -91,9 +91,10 @@ def run_cmd(args, out): ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host) require_iroh = args.config.enable_iroh_relay + 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, 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() @@ -124,7 +125,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 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 @@ -151,8 +152,8 @@ def dns_cmd(args, out): """Check DNS entries and optionally generate dns zone file.""" ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = get_sshexec(ssh_host, verbose=args.verbose) - tls_cert = args.config.tls_cert - strict_tls = tls_cert != "self" + tls_cert_mode = args.config.tls_cert_mode + strict_tls = tls_cert_mode == "acme" 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): return 1 diff --git a/cmdeploy/src/cmdeploy/deployers.py b/cmdeploy/src/cmdeploy/deployers.py index 37331bc11..e12eaa1d7 100644 --- a/cmdeploy/src/cmdeploy/deployers.py +++ b/cmdeploy/src/cmdeploy/deployers.py @@ -571,7 +571,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - (["master", "smtpd"], 25), ("unbound", 53), ] - if config.tls_cert == "acme": + if config.tls_cert_mode == "acme": port_services.append(("acmetool", 80)) port_services += [ (["imap-login", "dovecot"], 143), @@ -601,7 +601,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) - tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] - if config.tls_cert == "acme": + if config.tls_cert_mode == "acme": tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains) else: tls_deployer = SelfSignedTlsDeployer(mail_domain) diff --git a/cmdeploy/src/cmdeploy/dns.py b/cmdeploy/src/cmdeploy/dns.py index 816c9deed..05421b9ed 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -19,7 +19,7 @@ def check_initial_remote_data(remote_data, *, strict_tls=True, print=print): elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.": print("Missing MTA-STS CNAME record:") print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") - elif remote_data["WWW"] != f"{mail_domain}.": + elif strict_tls and remote_data["WWW"] != f"{mail_domain}.": print("Missing www CNAME record:") print(f"www.{mail_domain}. CNAME {mail_domain}.") else: diff --git a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 50d7b1654..159d1a838 100644 --- a/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 +++ b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 @@ -42,7 +42,7 @@ stream { } http { -{% if config.tls_cert == "self" %} +{% if config.tls_cert_mode == "self" %} limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; {% endif %} sendfile on; @@ -84,7 +84,7 @@ http { } location /new { -{% if config.tls_cert != "self" %} +{% if config.tls_cert_mode == "acme" %} 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 != "self" %} +{% if config.tls_cert_mode == "acme" %} if ($request_method = GET) { return 301 dcaccount:https://{{ config.mail_domain }}/new; } diff --git a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index 549dd2cf4..1390456cf 100644 --- a/cmdeploy/src/cmdeploy/postfix/main.cf.j2 +++ b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 @@ -20,7 +20,7 @@ smtpd_tls_key_file={{ config.tls_key_path }} smtpd_tls_security_level=may smtp_tls_CApath=/etc/ssl/certs -smtp_tls_security_level={{ "verify" if config.tls_cert == "acme" else "encrypt" }} +smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }} # Send SNI extension when connecting to other servers. # smtp_tls_servername = hostname diff --git a/cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map b/cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map index 6c53a4756..6b9f9abaa 100644 --- a/cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map +++ b/cmdeploy/src/cmdeploy/postfix/smtp_tls_policy_map @@ -1,2 +1,3 @@ /^\[[^]]+\]$/ encrypt +/^_/ encrypt /^nauta\.cu$/ may diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index 2ad011c50..b916e696a 100644 --- a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py +++ b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py @@ -1,3 +1,4 @@ +import pytest import requests from cmdeploy.genqr import gen_qr_png_data @@ -8,21 +9,23 @@ def test_gen_qr_png_data(maildomain): assert data +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_fastcgi_working(maildomain, chatmail_config): url = f"https://{maildomain}/new" print(url) - verify = chatmail_config.tls_cert != "self" + verify = chatmail_config.tls_cert_mode == "acme" res = requests.post(url, verify=verify) assert maildomain in res.json().get("email") assert len(res.json().get("password")) > chatmail_config.password_min_length +@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_newemail_configure(maildomain, rpc, chatmail_config): """Test configuring accounts by scanning a QR code works.""" url = f"DCACCOUNT:https://{maildomain}/new" for i in range(3): account_id = rpc.add_account() - if chatmail_config.tls_cert == "self": + if chatmail_config.tls_cert_mode == "self": # deltachat core's rustls rejects self-signed HTTPS certs during # set_config_from_qr, so fetch credentials via requests instead res = requests.post(f"https://{maildomain}/new", verify=False) @@ -35,5 +38,4 @@ def test_newemail_configure(maildomain, rpc, chatmail_config): "certificateChecks": "acceptInvalidCertificates", }) else: - rpc.set_config_from_qr(account_id, url) - rpc.configure(account_id) + rpc.add_transport_from_qr(account_id, url) diff --git a/cmdeploy/src/cmdeploy/tests/plugin.py b/cmdeploy/src/cmdeploy/tests/plugin.py index 7d515e085..6bef2e7e0 100644 --- a/cmdeploy/src/cmdeploy/tests/plugin.py +++ b/cmdeploy/src/cmdeploy/tests/plugin.py @@ -145,10 +145,9 @@ def fcol(parts): tr.write_line(line) - @pytest.fixture(scope="session") def ssl_context(chatmail_config): - if chatmail_config.tls_cert == "self": + if chatmail_config.tls_cert_mode == "self": ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE @@ -303,7 +302,7 @@ def get_liveconfig_producer(self): # speed up account configuration config["mail_server"] = self.maildomain config["send_server"] = self.maildomain - if self.chatmail_config.tls_cert == "self": + if self.chatmail_config.tls_cert_mode == "self": # Accept self-signed TLS certificates config["imap_certificate_checks"] = "3" yield config @@ -331,11 +330,9 @@ def read_path(self, path): am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) - if chatmail_config.tls_cert == "self": - # Skip upstream's init_imap which creates a DirectImap with - # strict SSL that fails on self-signed certs. Chatmail tests - # use fresh throwaway accounts, so folder cleanup is unnecessary. - am._acsetup.init_imap = lambda acc: None + # Skip upstream's init_imap to prevent extra imap connections not + # needed for relay testing + am._acsetup.init_imap = lambda acc: None # nb. a bit hacky # would probably be better if deltachat's test machinery grows native support diff --git a/doc/source/faq.rst b/doc/source/faq.rst index c5500fcc4..3fd369986 100644 --- a/doc/source/faq.rst +++ b/doc/source/faq.rst @@ -43,17 +43,9 @@ self-funded by respective operators. All chatmail relays are automatically deployed and updated using `the chatmail relay repository `__. Chatmail relays are composed of proven standard email server components, Postfix and -Dovecot, and are configured to run unattended without much maintenance effort. -Chatmail relays happily run on low-end hardware like a Raspberry Pi. - - -Can i run a relay without using letsencrypt for TLS certificates? ------------------------------------------------------------------ - -You can set ``tls_cert = self`` in ``chatmail.ini`` -to enable running a relay with self-signed certificates, -making it possible to operate on a LAN or behind a firewall -without needing Let's Encrypt or a public domain. +Dovecot, and are configured to run unattended without much maintenance +effort. Chatmail relays happily run on low-end hardware like a Raspberry +Pi. How trustable are chatmail relays? diff --git a/doc/source/getting_started.rst b/doc/source/getting_started.rst index 58993e85f..69b019d72 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -49,9 +49,11 @@ steps. Please substitute it with your own domain. .. note:: - For ``tls_cert = self`` deployments (see step 3), - the ``mta-sts`` CNAME and ``_mta-sts`` TXT records - are not needed. + For experimental deployments using self-signed certificates, + use a domain name starting with ``_`` + (e.g. ``_chat.example.org``). + The ``mta-sts`` CNAME and ``_mta-sts`` TXT records + are not needed for such domains. 2. On your local PC, clone the repository and bootstrap the Python virtualenv. @@ -71,9 +73,11 @@ steps. Please substitute it with your own domain. To use self-signed TLS certificates instead of Let's Encrypt, - set ``tls_cert = self`` in ``chatmail.ini``. - This is useful for private or test deployments - and does not require a publicly resolvable domain. + use a domain name starting with ``_`` + (e.g. ``scripts/cmdeploy init _chat.example.org``). + Domains starting with ``_`` cannot obtain WebPKI certificates, + so self-signed mode is derived automatically. + This is useful for private or test deployments. See the :doc:`overview` for details on certificate provisioning. @@ -183,6 +187,17 @@ creating addresses, login with ssh to the deployment machine and run: Chatmail address creation will be denied while this file is present. +Running a relay with self-signed certificates +---------------------------------------------- + +Use a domain name starting with ``_`` (e.g. ``_chat.example.org``) +to run a relay with self-signed certificates. +Domains starting with ``_`` cannot obtain WebPKI certificates +so the relay automatically uses self-signed certificates +and all other relays will accept connections from it +without requiring certificate verification. +This is useful for experimental setups and testing. + Migrating to a new build machine ---------------------------------- diff --git a/doc/source/overview.rst b/doc/source/overview.rst index 9b9f50caf..e75a2d815 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -49,12 +49,8 @@ The deployed system components of a chatmail relay are: - Nginx_ shows the web page with privacy policy and additional information -- `acmetool `_ - manages TLS certificates for Dovecot, Postfix, and Nginx - (when ``tls_cert = acme``, the default). - With ``tls_cert = self``, - self-signed certificates are generated by openssl instead - and acmetool is not used. +- `acmetool `_ manages TLS + certificates for Dovecot, Postfix, and Nginx - `OpenDKIM `_ for signing messages with DKIM and rejecting inbound messages without DKIM @@ -296,31 +292,12 @@ and rejects incorrectly authenticated emails with ``From:`` header must correspond to envelope ``MAIL FROM``, this is ensured by ``filtermail`` proxy. -TLS certificate provisioning -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +TLS requirements +~~~~~~~~~~~~~~~~ -The ``tls_cert`` option in ``chatmail.ini`` controls how TLS certificates -are provisioned and how outgoing SMTP connections validate remote servers: - -- ``tls_cert = acme`` (default): Uses Let’s Encrypt certificates via - `acmetool `_. - Outgoing SMTP uses - `smtp_tls_security_level `_ - ``verify``, requiring valid certificates from remote mail servers. - -- ``tls_cert = self``: Uses a self-signed certificate generated by - ``cmdeploy run``. Outgoing SMTP uses ``smtp_tls_security_level=encrypt``, - requiring TLS encryption but skipping certificate verification. - This allows self-signed chatmail relays to interoperate with each other. - Since chatmail enforces strict OpenPGP end-to-end encryption, - message content is protected regardless of transport-layer certificate - validation (see `RFC 7435 — Opportunistic Security - `_). - -Troubleshooting TLS with ``tls_cert = acme`` - -If emails don’t arrive at your chatmail relay server, the -problem is likely that your relay does not have a valid TLS certificate. +Postfix is configured to require valid TLS by setting +`smtp_tls_security_level `_ +to ``verify``. You can test it by resolving ``MX`` records of your relay domain and then connecting to MX relays (e.g ``mx.example.org``) with @@ -339,6 +316,14 @@ default Exim does not log sessions that are closed before sending the by Postfix, so you might think that connection is not established while actually it is a problem with your TLS certificate. +If emails don’t arrive at your chatmail relay server, the +problem is likely that your relay does not have a valid TLS certificate. + +Note that connections to relays with underscore-prefixed test domains +(e.g. ``_chat.example.org``) use ``encrypt`` tls security level, +because such domains cannot obtain valid Let's Encrypt certificates +and run with self-signed certificates. + .. _dovecot: https://dovecot.org .. _postfix: https://www.postfix.org diff --git a/www/src/index.md b/www/src/index.md index d85a792ea..cc8325fdb 100644 --- a/www/src/index.md +++ b/www/src/index.md @@ -11,7 +11,7 @@ for Delta Chat users. For details how it avoids storing personal information please see our [privacy policy](privacy.html). {% endif %} -{% if config.tls_cert == "self" %} +{% if config.tls_cert_mode == "self" %} Get a {{config.mail_domain}} chat profile If you are viewing this page on a different device From 80000a7d667fb1f47732763e9585eeb9e01bbcc9 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Wed, 18 Feb 2026 16:56:56 +0100 Subject: [PATCH 10/11] fix wrong @ encoding --- chatmaild/src/chatmaild/newemail.py | 2 +- chatmaild/src/chatmaild/tests/test_newmail.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index ef42bfc4f..198afce54 100644 --- a/chatmaild/src/chatmaild/newemail.py +++ b/chatmaild/src/chatmaild/newemail.py @@ -30,7 +30,7 @@ def create_dclogin_url(email, password): Uses ic=3 (AcceptInvalidCertificates) so chatmail clients can connect to servers with self-signed TLS certificates. """ - return f"dclogin:{quote(email, safe='')}?p={quote(password, safe='')}&v=1&ic=3" + return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" def print_new_account(): diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index 5f736b3b1..ca0b16614 100644 --- a/chatmaild/src/chatmaild/tests/test_newmail.py +++ b/chatmaild/src/chatmaild/tests/test_newmail.py @@ -25,8 +25,7 @@ def test_create_dclogin_url(): assert "v=1" in url assert "ic=3" in url - # email @ must be encoded - assert "user%40example.org" in url + assert "user@example.org" in url # password special chars must be encoded assert "p%40ss" in url assert "w%2Brd" in url From 73a4a91dd2c18284c6b0f2bb326804dc1dd61801 Mon Sep 17 00:00:00 2001 From: holger krekel Date: Thu, 19 Feb 2026 10:08:32 +0100 Subject: [PATCH 11/11] no acme specials --- .github/workflows/test-and-deploy-ipv4only.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index 72ed5bfdd..ecc79def7 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -87,7 +87,7 @@ jobs: ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone ssh root@ns.testrun.org systemctl reload nsd - - name: cmdeploy test acme + - name: cmdeploy test run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow - name: cmdeploy dns