diff --git a/.github/workflows/test-and-deploy-ipv4only.yaml b/.github/workflows/test-and-deploy-ipv4only.yaml index f6266dfc4..ecc79def7 100644 --- a/.github/workflows/test-and-deploy-ipv4only.yaml +++ b/.github/workflows/test-and-deploy-ipv4only.yaml @@ -75,8 +75,7 @@ jobs: 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 - - - run: cmdeploy run --verbose --skip-dns-check + cmdeploy run --verbose --skip-dns-check - name: set DNS entries run: | diff --git a/chatmaild/src/chatmaild/config.py b/chatmaild/src/chatmaild/config.py index d19e966af..af6fef0d9 100644 --- a/chatmaild/src/chatmaild/config.py +++ b/chatmaild/src/chatmaild/config.py @@ -60,6 +60,18 @@ def __init__(self, inipath, params): self.privacy_pdo = params.get("privacy_pdo") self.privacy_supervisor = params.get("privacy_supervisor") + # TLS certificate management: derived from the domain name. + # Domains starting with "_" use self-signed certificates + # All other domains use ACME. + if self.mail_domain.startswith("_"): + 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 mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") self.mailboxes_dir = Path(mbdir.strip()) diff --git a/chatmaild/src/chatmaild/newemail.py b/chatmaild/src/chatmaild/newemail.py index 67bd861ae..198afce54 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 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(): config = read_config(CONFIG_PATH) 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("") - print(json.dumps(creds)) + print(json.dumps(result)) if __name__ == "__main__": diff --git a/chatmaild/src/chatmaild/tests/test_config.py b/chatmaild/src/chatmaild/tests/test_config.py index c553d9810..80dcb1897 100644 --- a/chatmaild/src/chatmaild/tests/test_config.py +++ b/chatmaild/src/chatmaild/tests/test_config.py @@ -73,3 +73,17 @@ 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_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" 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(): diff --git a/chatmaild/src/chatmaild/tests/test_newmail.py b/chatmaild/src/chatmaild/tests/test_newmail.py index df9a5c044..ca0b16614 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 + + 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): monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) print_new_account() @@ -25,3 +41,20 @@ 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("_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 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..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,11 +152,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_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 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 +166,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..e12eaa1d7 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_mode == "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_mode == "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..05421b9ed 100644 --- a/cmdeploy/src/cmdeploy/dns.py +++ b/cmdeploy/src/cmdeploy/dns.py @@ -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"] 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}.": + 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/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/nginx/nginx.conf.j2 b/cmdeploy/src/cmdeploy/nginx/nginx.conf.j2 index 58864d72e..159d1a838 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_mode == "self" %} + limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; +{% endif %} sendfile on; tcp_nopush on; @@ -53,8 +56,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 +69,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 +84,15 @@ http { } location /new { +{% if config.tls_cert_mode == "acme" %} 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; } +{% else %} + limit_req zone=newaccount burst=5 nodelay; +{% endif %} fastcgi_pass unix:/run/fcgiwrap.socket; include /etc/nginx/fastcgi_params; @@ -99,9 +106,11 @@ http { # # Redirects are only for browsers. location /cgi-bin/newemail.py { +{% if config.tls_cert_mode == "acme" %} 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 +141,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/cmdeploy/src/cmdeploy/postfix/main.cf.j2 b/cmdeploy/src/cmdeploy/postfix/main.cf.j2 index aa16065a1..1390456cf 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_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/selfsigned/deployer.py b/cmdeploy/src/cmdeploy/selfsigned/deployer.py new file mode 100644 index 000000000..4bf2def21 --- /dev/null +++ b/cmdeploy/src/cmdeploy/selfsigned/deployer.py @@ -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 diff --git a/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py b/cmdeploy/src/cmdeploy/tests/online/test_0_qr.py index 1417ccf06..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,18 +9,33 @@ 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) - 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 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.""" url = f"DCACCOUNT:https://{maildomain}/new" for i in range(3): account_id = rpc.add_account() - rpc.set_config_from_qr(account_id, url) - rpc.configure(account_id) + 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) + 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) diff --git a/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py b/cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py index c0cb85e6b..a87081fd0 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(mark_seen=False)) + 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..6bef2e7e0 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,25 @@ def fcol(parts): tr.write_line(line) +@pytest.fixture(scope="session") +def ssl_context(chatmail_config): + 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 -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 +175,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 +207,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 +226,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 +284,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 +302,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_mode == "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 +315,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 +330,10 @@ def read_path(self, path): 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 # would probably be better if deltachat's test machinery grows native support def switch_maildomain(maildomain2): @@ -363,38 +387,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 +429,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/getting_started.rst b/doc/source/getting_started.rst index 2553eff1b..69b019d72 100644 --- a/doc/source/getting_started.rst +++ b/doc/source/getting_started.rst @@ -47,6 +47,14 @@ 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 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. @@ -63,6 +71,16 @@ 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, + 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: :: @@ -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. +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 107f2e2d6..e75a2d815 100644 --- a/doc/source/overview.rst +++ b/doc/source/overview.rst @@ -297,8 +297,7 @@ TLS requirements 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 -problem is likely that your relay does not have a valid TLS certificate. +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 @@ -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 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/dclogin.js b/www/src/dclogin.js new file mode 100644 index 000000000..3461d0963 --- /dev/null +++ b/www/src/dclogin.js @@ -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(); +})(); diff --git a/www/src/index.md b/www/src/index.md index aae1a0db1..cc8325fdb 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_mode == "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