Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/test-and-deploy-ipv4only.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ jobs:
cmdeploy init staging-ipv4.testrun.org
Comment thread
hpk42 marked this conversation as resolved.
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
Comment thread
hpk42 marked this conversation as resolved.

- name: set DNS entries
run: |
Expand Down
12 changes: 12 additions & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
16 changes: 15 additions & 1 deletion chatmaild/src/chatmaild/newemail.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import random
import secrets
import string
from urllib.parse import quote

from chatmaild.config import Config, read_config

Expand All @@ -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__":
Expand Down
14 changes: 14 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,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"
6 changes: 6 additions & 0 deletions chatmaild/src/chatmaild/tests/test_filtermail_blackbox.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
35 changes: 34 additions & 1 deletion chatmaild/src/chatmaild/tests/test_newmail.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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
Comment thread
hpk42 marked this conversation as resolved.

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()
Expand All @@ -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
Comment thread
hpk42 marked this conversation as resolved.

assert dic["email"].split("@")[0] in url
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
12 changes: 8 additions & 4 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -151,18 +152,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_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

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_mode == "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_mode == "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
6 changes: 3 additions & 3 deletions cmdeploy/src/cmdeploy/dns.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
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
Loading