Skip to content
Merged
13 changes: 13 additions & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
8 changes: 8 additions & 0 deletions chatmaild/src/chatmaild/ini/chatmail.ini.f
Original file line number Diff line number Diff line change
Expand Up @@ -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 =

Expand Down
19 changes: 19 additions & 0 deletions chatmaild/src/chatmaild/tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"})
2 changes: 2 additions & 0 deletions cmdeploy/src/cmdeploy/chatmail.zone.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
7 changes: 5 additions & 2 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,18 +151,21 @@ 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

if not remote_data["dkim_entry"]:
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:
Expand Down
13 changes: 11 additions & 2 deletions cmdeploy/src/cmdeploy/deployers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from cmdeploy.cmdeploy import Out

from .acmetool import AcmetoolDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .basedeploy import (
Deployer,
Deployment,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
4 changes: 2 additions & 2 deletions cmdeploy/src/cmdeploy/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}.":
Expand Down
4 changes: 2 additions & 2 deletions cmdeploy/src/cmdeploy/dovecot/dovecot.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,8 @@ service anvil {
}

ssl = required
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_cert = <{{ config.tls_cert_path }}
ssl_key = <{{ config.tls_key_path }}
ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes
Expand Down
20 changes: 10 additions & 10 deletions cmdeploy/src/cmdeploy/nginx/autoconfig.xml.j2
Original file line number Diff line number Diff line change
@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>

<clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}">
<domain>{{ config.domain_name }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName>
<emailProvider id="{{ config.mail_domain }}">
<domain>{{ config.mail_domain }}</domain>
<displayName>{{ config.mail_domain }} chatmail</displayName>
<displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
Expand Down
6 changes: 3 additions & 3 deletions cmdeploy/src/cmdeploy/nginx/deployer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion cmdeploy/src/cmdeploy/nginx/mta-sts.txt.j2
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: STSv1
mode: enforce
mx: {{ config.domain_name }}
mx: {{ config.mail_domain }}
max_age: 2419200
6 changes: 3 additions & 3 deletions cmdeploy/src/cmdeploy/postfix/main.cf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
Expand Down
34 changes: 34 additions & 0 deletions cmdeploy/src/cmdeploy/selfsigned/deployer.py
Original file line number Diff line number Diff line change
@@ -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"
Comment thread
missytake marked this conversation as resolved.

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"
Comment thread
hpk42 marked this conversation as resolved.
Outdated
f" -nodes -days 3650"
Comment thread
hpk42 marked this conversation as resolved.
Outdated
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}"',
Comment thread
hpk42 marked this conversation as resolved.
],
Comment thread
hpk42 marked this conversation as resolved.
)

def activate(self):
pass
19 changes: 16 additions & 3 deletions cmdeploy/src/cmdeploy/tests/online/test_0_qr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
hpk42 marked this conversation as resolved.
Outdated
data = res.json()
rpc.set_config(account_id, "addr", data["email"])
Comment thread
hpk42 marked this conversation as resolved.
Outdated
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)
Comment thread
hpk42 marked this conversation as resolved.
Outdated
rpc.configure(account_id)
18 changes: 12 additions & 6 deletions cmdeploy/src/cmdeploy/tests/online/test_2_deltachat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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())
Comment thread
hpk42 marked this conversation as resolved.
Outdated
assert msgs, "expected at least one message"
assert public_ip not in msgs[0].obj.as_string()
Loading