feat: support self-signed chatmail relays (#855)

feat: support self-signed TLS via underscore domain convention
Domains starting with "_" (e.g. _chat.example.org) automatically use
self-signed TLS certificates instead of ACME/Let's Encrypt. The TLS
mode is derived from the domain name — no separate config option needed.

Internally, when config.tls_cert_mode is "self" (underscore domain):
- Generate self-signed certificates via openssl
- Set Postfix smtp_tls_security_level to "encrypt" (opportunistic TLS)
- Add smtp_tls_policy_map entry for underscore domains
- Skip ACME, MTA-STS and www CNAME checks in `cmdeploy dns`
- Serve /new via GET (not redirect to dcaccount:) with rate-limiting
  (nginx limit_req, 2r/s burst=5)
- Return dclogin: URLs with ic=3 (AcceptInvalidCertificates) from /new
- Render QR codes client-side via JavaScript and qrcode-svg
- Use config.tls_cert_path/tls_key_path in Postfix, Dovecot and nginx
  templates instead of hardcoded ACME paths
This commit is contained in:
holger krekel
2026-02-19 10:27:41 +01:00
committed by GitHub
parent 36eb63faa1
commit cf96be2cbb
27 changed files with 350 additions and 74 deletions

View File

@@ -75,8 +75,7 @@ jobs:
cmdeploy init staging-ipv4.testrun.org cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
cmdeploy run --verbose --skip-dns-check
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries - name: set DNS entries
run: | run: |

View File

@@ -60,6 +60,18 @@ class Config:
self.privacy_pdo = params.get("privacy_pdo") self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor") 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("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
else:
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 # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())

View File

@@ -6,6 +6,7 @@ import json
import random import random
import secrets import secrets
import string import string
from urllib.parse import quote
from chatmaild.config import Config, read_config 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}") 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 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"
def print_new_account(): def print_new_account():
config = read_config(CONFIG_PATH) config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config) creds = create_newemail_dict(config)
result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")
print(json.dumps(creds)) print(json.dumps(result))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -73,3 +73,17 @@ def test_config_userstate_paths(make_config, tmp_path):
def test_config_max_message_size(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")) config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.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_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("_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"

View File

@@ -1,9 +1,15 @@
import shutil
import smtplib import smtplib
import subprocess import subprocess
import sys import sys
import pytest import pytest
pytestmark = pytest.mark.skipif(
shutil.which("filtermail") is None,
reason="filtermail binary not found",
)
@pytest.fixture @pytest.fixture
def smtpserver(): def smtpserver():

View File

@@ -1,7 +1,11 @@
import json import json
import chatmaild 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): def test_create_newemail_dict(example_config):
@@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] 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
assert "user@example.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): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()
@@ -25,3 +41,20 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
dic = json.loads(lines[2]) dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}") assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10 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("_test.example.org")
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

View File

@@ -8,8 +8,10 @@
{{ mail_domain }}. AAAA {{ AAAA }} {{ mail_domain }}. AAAA {{ AAAA }}
{% endif %} {% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}. {{ mail_domain }}. MX 10 {{ mail_domain }}.
{% if strict_tls %}
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}" _mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}. mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
{% endif %}
www.{{ mail_domain }}. CNAME {{ mail_domain }}. www.{{ mail_domain }}. CNAME {{ mail_domain }}.
{{ dkim_entry }} {{ dkim_entry }}

View File

@@ -91,9 +91,10 @@ def run_cmd(args, out):
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme"
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) 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 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -124,7 +125,7 @@ def run_cmd(args, out):
out.red("Website deployment failed.") out.red("Website deployment failed.")
elif retcode == 0: elif retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.") 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("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
retcode = 0 retcode = 0
@@ -151,11 +152,13 @@ def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
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) 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 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'") out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1 return 1
@@ -163,6 +166,7 @@ def dns_cmd(args, out):
out.red("could not determine dkim_entry, please run 'cmdeploy run'") out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1 return 1
remote_data["strict_tls"] = strict_tls
zonefile = dns.get_filled_zone_file(remote_data) zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile: if args.zonefile:

View File

@@ -19,6 +19,7 @@ from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out from cmdeploy.cmdeploy import Out
from .acmetool import AcmetoolDeployer from .acmetool import AcmetoolDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .basedeploy import ( from .basedeploy import (
Deployer, Deployer,
Deployment, Deployment,
@@ -569,7 +570,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
("acmetool", 80), ]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 80))
port_services += [
(["imap-login", "dovecot"], 143), (["imap-login", "dovecot"], 143),
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["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}"] 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)
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
@@ -605,7 +614,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
AcmetoolDeployer(config.acme_email, tls_domains), tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),

View File

@@ -12,14 +12,14 @@ 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"] mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]: if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!") 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("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") 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("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.") print(f"www.{mail_domain}. CNAME {mail_domain}.")
else: else:

View File

@@ -228,8 +228,8 @@ service anvil {
} }
ssl = required ssl = required
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain ssl_cert = <{{ config.tls_cert_path }}
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey ssl_key = <{{ config.tls_key_path }}
ssl_dh = </usr/share/dovecot/dh.pem ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3 ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes ssl_prefer_server_ciphers = yes

View File

@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1"> <clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}"> <emailProvider id="{{ config.mail_domain }}">
<domain>{{ config.domain_name }}</domain> <domain>{{ config.mail_domain }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName> <displayName>{{ config.mail_domain }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName> <displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>993</port> <port>993</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>143</port> <port>143</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>443</port> <port>443</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>465</port> <port>465</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>587</port> <port>587</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>443</port> <port>443</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>

View File

@@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed need_restart |= main_config.changed
@@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
) )
need_restart |= autoconfig.changed need_restart |= autoconfig.changed
@@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
config={"domain_name": config.mail_domain}, config=config,
) )
need_restart |= mta_sts_config.changed need_restart |= mta_sts_config.changed

View File

@@ -1,4 +1,4 @@
version: STSv1 version: STSv1
mode: enforce mode: enforce
mx: {{ config.domain_name }} mx: {{ config.mail_domain }}
max_age: 2419200 max_age: 2419200

View File

@@ -42,6 +42,9 @@ stream {
} }
http { http {
{% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %}
sendfile on; sendfile on;
tcp_nopush on; tcp_nopush on;
@@ -53,8 +56,8 @@ http {
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on; ssl_prefer_server_ciphers on;
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain; ssl_certificate {{ config.tls_cert_path }};
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey; ssl_certificate_key {{ config.tls_key_path }};
gzip on; gzip on;
@@ -66,7 +69,7 @@ http {
index index.html index.htm; 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; access_log syslog:server=unix:/dev/log,facility=local7;
@@ -81,11 +84,15 @@ http {
} }
location /new { location /new {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) { if ($request_method = GET) {
# Redirect to Delta Chat, # Redirect to Delta Chat,
# which will in turn do a POST request. # which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new; return 301 dcaccount:https://{{ config.mail_domain }}/new;
} }
{% else %}
limit_req zone=newaccount burst=5 nodelay;
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket; fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
@@ -99,9 +106,11 @@ http {
# #
# Redirects are only for browsers. # Redirects are only for browsers.
location /cgi-bin/newemail.py { location /cgi-bin/newemail.py {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) { 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; fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
@@ -132,8 +141,8 @@ http {
# Redirect www. to non-www # Redirect www. to non-www
server { server {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.domain_name }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.domain_name }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
} }
} }

View File

@@ -15,12 +15,12 @@ readme_directory = no
compatibility_level = 3.6 compatibility_level = 3.6
# TLS parameters # TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain smtpd_tls_cert_file={{ config.tls_cert_path }}
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
# Send SNI extension when connecting to other servers. # Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername> # <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname smtp_tls_servername = hostname

View File

@@ -1,2 +1,3 @@
/^\[[^]]+\]$/ encrypt /^\[[^]]+\]$/ encrypt
/^_/ encrypt
/^nauta\.cu$/ may /^nauta\.cu$/ may

View File

@@ -0,0 +1,36 @@
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"
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}"',
],
)
def activate(self):
pass

View File

@@ -1,3 +1,4 @@
import pytest
import requests import requests
from cmdeploy.genqr import gen_qr_png_data from cmdeploy.genqr import gen_qr_png_data
@@ -8,18 +9,33 @@ def test_gen_qr_png_data(maildomain):
assert data assert data
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config): def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new" url = f"https://{maildomain}/new"
print(url) print(url)
res = requests.post(url) verify = chatmail_config.tls_cert_mode == "acme"
res = requests.post(url, verify=verify)
assert maildomain in res.json().get("email") assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length assert len(res.json().get("password")) > chatmail_config.password_min_length
def test_newemail_configure(maildomain, rpc): @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url) if chatmail_config.tls_cert_mode == "self":
rpc.configure(account_id) # 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)
data = res.json()
rpc.add_or_update_transport(account_id, {
"addr": data["email"],
"password": data["password"],
"imapServer": maildomain,
"smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
})
else:
rpc.add_transport_from_qr(account_id, url)

View File

@@ -11,11 +11,12 @@ from cmdeploy.sshexec import SSHExec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") 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.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
return mailbox return mailbox
@@ -171,7 +172,7 @@ class TestEndToEndDeltaChat:
time.sleep(1) 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() public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
@@ -180,6 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox") addr = user2.get_config("addr")
msg = user2.direct_imap.get_all_messages()[0] host = addr.split("@")[1]
assert public_ip not in msg.obj.as_string() pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw)
msgs = list(mailbox.fetch(mark_seen=False))
assert msgs, "expected at least one message"
assert public_ip not in msgs[0].obj.as_string()

View File

@@ -4,6 +4,7 @@ import itertools
import os import os
import random import random
import smtplib import smtplib
import ssl
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -144,15 +145,25 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line) tr.write_line(line)
@pytest.fixture @pytest.fixture(scope="session")
def imap(maildomain): def ssl_context(chatmail_config):
return ImapConn(maildomain) if chatmail_config.tls_cert_mode == "self":
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
@pytest.fixture @pytest.fixture
def make_imap_connection(maildomain): def imap(maildomain, ssl_context):
return ImapConn(maildomain, ssl_context=ssl_context)
@pytest.fixture
def make_imap_connection(maildomain, ssl_context):
def make_imap_connection(): def make_imap_connection():
conn = ImapConn(maildomain) conn = ImapConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -164,12 +175,13 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot" logcmd = "journalctl -f -u dovecot"
name = "dovecot" name = "dovecot"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"imap-connect {self.host}") 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): def login(self, user, password):
print(f"imap-login {user!r} {password!r}") print(f"imap-login {user!r} {password!r}")
@@ -195,14 +207,14 @@ class ImapConn:
@pytest.fixture @pytest.fixture
def smtp(maildomain): def smtp(maildomain, ssl_context):
return SmtpConn(maildomain) return SmtpConn(maildomain, ssl_context=ssl_context)
@pytest.fixture @pytest.fixture
def make_smtp_connection(maildomain): def make_smtp_connection(maildomain, ssl_context):
def make_smtp_connection(): def make_smtp_connection():
conn = SmtpConn(maildomain) conn = SmtpConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -214,12 +226,14 @@ class SmtpConn:
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp" logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix" name = "postfix"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"smtp-connect {self.host}") 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): def login(self, user, password):
print(f"smtp-login {user!r} {password!r}") print(f"smtp-login {user!r} {password!r}")
@@ -270,11 +284,12 @@ def gencreds(chatmail_config):
class ChatmailTestProcess: class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" """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.pytestconfig = pytestconfig
self.maildomain = maildomain self.maildomain = maildomain
assert "." in self.maildomain, maildomain assert "." in self.maildomain, maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {} self._addr2files = {}
def get_liveconfig_producer(self): def get_liveconfig_producer(self):
@@ -287,6 +302,9 @@ class ChatmailTestProcess:
# speed up account configuration # speed up account configuration
config["mail_server"] = self.maildomain config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain config["send_server"] = self.maildomain
if self.chatmail_config.tls_cert_mode == "self":
# Accept self-signed TLS certificates
config["imap_certificate_checks"] = "3"
yield config yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
@@ -297,12 +315,14 @@ class ChatmailTestProcess:
@pytest.fixture @pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain): def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config):
# cloned from deltachat.testplugin.amfactory # cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(
request.config, maildomain, gencreds, chatmail_config
)
class Data: class Data:
def read_path(self, path): def read_path(self, path):
@@ -310,6 +330,10 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data()) am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# 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 # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2): def switch_maildomain(maildomain2):
@@ -363,38 +387,40 @@ def lp(request):
@pytest.fixture @pytest.fixture
def cmsetup(maildomain, gencreds): def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds) return CMSetup(maildomain, gencreds, ssl_context)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds): def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain self.maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.ssl_context = ssl_context
def gen_users(self, num): def gen_users(self, num):
print(f"Creating {num} online users") print(f"Creating {num} online users")
users = [] users = []
for i in range(num): for i in range(num):
addr, password = self.gencreds() addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password) user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp assert user.smtp
users.append(user) users.append(user)
return users return users
class CMUser: class CMUser:
def __init__(self, maildomain, addr, password): def __init__(self, maildomain, addr, password, ssl_context=None):
self.maildomain = maildomain self.maildomain = maildomain
self.addr = addr self.addr = addr
self.password = password self.password = password
self.ssl_context = ssl_context
self._smtp = None self._smtp = None
self._imap = None self._imap = None
@property @property
def smtp(self): def smtp(self):
if not self._smtp: if not self._smtp:
handle = SmtpConn(self.maildomain) handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
handle.connect() handle.connect()
handle.login(self.addr, self.password) handle.login(self.addr, self.password)
self._smtp = handle self._smtp = handle
@@ -403,7 +429,7 @@ class CMUser:
@property @property
def imap(self): def imap(self):
if not self._imap: if not self._imap:
imap = ImapConn(self.maildomain) imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
imap.connect() imap.connect()
imap.login(self.addr, self.password) imap.login(self.addr, self.password)
self._imap = imap self._imap = imap

View File

@@ -91,6 +91,16 @@ class TestPerformInitialChecks:
assert not res assert not res
assert len(l) == 2 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): def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"): for zf_line in zonefile.split("\n"):

View File

@@ -47,6 +47,14 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
.. note::
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 2. On your local PC, clone the repository and bootstrap the Python
virtualenv. virtualenv.
@@ -63,6 +71,16 @@ steps. Please substitute it with your own domain.
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
To use self-signed TLS certificates
instead of Let's Encrypt,
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.
4. Verify that SSH root login to the deployment server server works: 4. Verify that SSH root login to the deployment server server works:
:: ::
@@ -169,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. 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 Migrating to a new build machine
---------------------------------- ----------------------------------

View File

@@ -297,8 +297,7 @@ TLS requirements
Postfix is configured to require valid TLS by setting Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_ `smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``. If emails dont arrive at your chatmail relay server, the to ``verify``.
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 You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with then connecting to MX relays (e.g ``mx.example.org``) with
@@ -317,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 by Postfix, so you might think that connection is not established while
actually it is a problem with your TLS certificate. actually it is a problem with your TLS certificate.
If emails dont 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 .. _dovecot: https://dovecot.org
.. _postfix: https://www.postfix.org .. _postfix: https://www.postfix.org

21
www/src/dclogin.js Normal file
View File

@@ -0,0 +1,21 @@
/* 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 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();
})();

View File

@@ -11,6 +11,18 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html). please see our [privacy policy](privacy.html).
{% endif %} {% endif %}
{% if config.tls_cert_mode == "self" %}
<a class="cta-button" id="dclogin-link" href="#">Get a {{config.mail_domain}} chat profile</a>
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:
<a id="qr-link" href="#"><div id="qr-code"></div></a>
<script src="qrcode-svg.min.js"></script>
<script src="dclogin.js"></script>
{% else %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a> <a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device If you are viewing this page on a different device
@@ -19,6 +31,7 @@ you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
{% endif %}
🐣 **Choose** your Avatar and Name 🐣 **Choose** your Avatar and Name

9
www/src/qrcode-svg.min.js vendored Normal file

File diff suppressed because one or more lines are too long