Compare commits

..

6 Commits

Author SHA1 Message Date
missytake
026b09ac12 ci: deploy with --ssh-host localhost on staging-ipv4 2026-02-19 21:42:49 +01:00
missytake
8163db3b46 docs: webdev needs to be exposed via nginx if run on the relay 2026-02-19 21:42:49 +01:00
missytake
0a4e67dd30 docs: use ssh_host = localhost in getting started docs
remove mentions of the build machine / deployment server separation
2026-02-19 21:42:49 +01:00
missytake
108cc5ffde docs: cmdeploy dns + test are kind of necessary 2026-02-19 21:42:48 +01:00
missytake
45660731d2 cmdeploy: add ssh_host chatmail.ini option to deploy remotely 2026-02-19 21:42:48 +01:00
missytake
13dd64798a config: load default values from Config(), not chatmail.ini.f 2026-02-19 21:41:17 +01:00
32 changed files with 338 additions and 658 deletions

View File

@@ -15,7 +15,7 @@ jobs:
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.5.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox

View File

@@ -85,12 +85,12 @@ jobs:
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini" ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini" ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost" - run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check"
- name: set DNS entries - name: set DNS entries
run: | run: |
ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost" ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
@@ -98,8 +98,8 @@ jobs:
ssh root@ns.testrun.org systemctl reload nsd ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test - name: cmdeploy test
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost" run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow"
- name: cmdeploy dns - name: cmdeploy dns
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost" run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v"

View File

@@ -76,6 +76,7 @@ jobs:
- run: | - run: |
cmdeploy init staging2.testrun.org cmdeploy init staging2.testrun.org
sed -i 's/^ssh_host/#ssh_host/' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check - run: cmdeploy run --verbose --skip-dns-check

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ __pycache__/
*$py.class *$py.class
*.swp *.swp
*qr-*.png *qr-*.png
chatmail*.ini chatmail.ini
# C extensions # C extensions

View File

@@ -9,30 +9,28 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"] return Config(inipath, params=cfg.sections["params"])
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
self.mail_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.ssh_host = params.get("ssh_host", self.mail_domain)
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10)) self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params.get("max_mailbox_size", "500M")
self.max_message_size = int(params.get("max_message_size", "31457280")) self.max_message_size = int(params.get("max_message_size", 31457280))
self.delete_mails_after = params["delete_mails_after"] self.delete_mails_after = params.get("delete_mails_after", "20")
self.delete_large_after = params["delete_large_after"] self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.delete_inactive_users_after = int(
self.username_min_length = int(params["username_min_length"]) params.get("delete_inactive_users_after", 100)
self.username_max_length = int(params["username_max_length"]) )
self.password_min_length = int(params["password_min_length"]) self.username_min_length = int(params.get("username_min_length", 9))
self.passthrough_senders = params["passthrough_senders"].split() self.username_max_length = int(params.get("username_max_length", 9))
self.passthrough_recipients = params["passthrough_recipients"].split() self.password_min_length = int(params.get("password_min_length", 9))
self.passthrough_senders = params.get("passthrough_senders", "").split()
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "") self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
@@ -60,23 +58,10 @@ 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. # TLS certificate management: derived from the domain name.
# If tls_external_cert_and_key is set, use externally managed certs. # Domains starting with "_" use self-signed certificates
# Otherwise derived from the domain name: # All other domains use ACME.
# - Domains starting with "_" use self-signed certificates if self.mail_domain.startswith("_"):
# - All other domains use ACME.
external = params.get("tls_external_cert_and_key", "").strip()
if external:
parts = external.split()
if len(parts) != 2:
raise ValueError(
"tls_external_cert_and_key must have two space-separated"
" paths: CERT_PATH KEY_PATH"
)
self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self" self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key" self.tls_key_path = "/etc/ssl/private/mailserver.key"

View File

@@ -3,6 +3,9 @@
# mail domain (MUST be set to fully qualified chat mail domain) # mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain} mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
# #
# If you only do private test deploys, you don't need to modify any settings below # If you only do private test deploys, you don't need to modify any settings below
# #
@@ -48,13 +51,6 @@ passthrough_senders =
# (space-separated, item may start with "@" to whitelist whole recipient domains) # (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients = passthrough_recipients =
# Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy.
# Certificate renewal is your responsibility; changed files are
# picked up automatically by all relay services.
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages # path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
#www_folder = www #www_folder = www

View File

@@ -87,37 +87,3 @@ def test_config_tls_self(make_config):
assert config.tls_cert_mode == "self" assert config.tls_cert_mode == "self"
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem" assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
assert config.tls_key_path == "/etc/ssl/private/mailserver.key" assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
def test_config_tls_external(make_config):
config = make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/custom/fullchain.pem"
assert config.tls_key_path == "/custom/privkey.pem"
def test_config_tls_external_overrides_underscore(make_config):
config = make_config(
"_test.example.org",
{
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/certs/fullchain.pem"
assert config.tls_key_path == "/certs/privkey.pem"
def test_config_tls_external_bad_format(make_config):
with pytest.raises(ValueError, match="two space-separated"):
make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/only/one/path.pem",
},
)

View File

@@ -47,8 +47,6 @@ def test_one_mail(
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
): ):
monkeypatch.setenv("PYTHONUNBUFFERED", "1") monkeypatch.setenv("PYTHONUNBUFFERED", "1")
# DKIM is tested by cmdeploy tests.
monkeypatch.setenv("FILTERMAIL_SKIP_DKIM", "1")
smtp_inject_port = 20025 smtp_inject_port = 20025
if filtermail_mode == "outgoing": if filtermail_mode == "outgoing":
settings = dict( settings = dict(
@@ -66,10 +64,6 @@ def test_one_mail(
popen = make_popen(["filtermail", path, filtermail_mode]) popen = make_popen(["filtermail", path, filtermail_mode])
line = popen.stderr.readline().strip() line = popen.stderr.readline().strip()
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod
if b"DKIM verification DISABLED!" in line:
line = popen.stderr.readline().strip()
if b"loop" not in line: if b"loop" not in line:
print(line.decode("ascii"), file=sys.stderr) print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed") pytest.fail("starting filtermail failed")

View File

@@ -20,7 +20,6 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"deltachat-rpc-client",
] ]
[project.scripts] [project.scripts]

View File

@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service] [Service]
Type=notify Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402 ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
Restart=always Restart=always
RestartSec=30 RestartSec=30

View File

@@ -5,11 +5,6 @@ import os
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
def has_systemd():
"""Returns False during Docker image builds or any other non-systemd environment."""
return os.path.isdir("/run/systemd/system")
def get_resource(arg, pkg=__package__): def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)

View File

@@ -5,6 +5,7 @@ along with command line option and subcommand parsing.
import argparse import argparse
import importlib.resources import importlib.resources
import importlib.util
import os import os
import pathlib import pathlib
import shutil import shutil
@@ -87,7 +88,7 @@ def run_cmd_options(parser):
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
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.ssh_host
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" strict_tls = args.config.tls_cert_mode == "acme"
@@ -108,10 +109,7 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]: if ssh_host in ["localhost", "@local", "@docker"]:
if ssh_host == "@docker":
env["CHATMAIL_NOPORTCHECK"] = "True"
env["CHATMAIL_NOSYSCTL"] = "True"
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -152,7 +150,7 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): 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.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme" strict_tls = tls_cert_mode == "acme"
@@ -189,7 +187,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
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.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose) sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")
@@ -213,8 +211,14 @@ def test_cmd_options(parser):
def test_cmd(args, out): def test_cmd(args, out):
"""Run local and online tests for chatmail deployment.""" """Run local and online tests for chatmail deployment.
This will automatically pip-install 'deltachat' if it's not available.
"""
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
env = os.environ.copy() env = os.environ.copy()
if args.ssh_host: if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host env["CHATMAIL_SSH"] = args.ssh_host
@@ -332,7 +336,7 @@ def add_config_option(parser):
"--config", "--config",
dest="inipath", dest="inipath",
action="store", action="store",
default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")), default=Path("chatmail.ini"),
type=Path, type=Path,
help="path to the chatmail.ini file", help="path to the chatmail.ini file",
) )

View File

@@ -2,7 +2,6 @@
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -11,8 +10,8 @@ from pathlib import Path
from chatmaild.config import read_config from chatmaild.config import read_config
from pyinfra import facts, host, logger from pyinfra import facts, host, logger
from pyinfra.api import FactBase
from pyinfra.facts import hardware from pyinfra.facts import hardware
from pyinfra.api import FactBase
from pyinfra.facts.files import Sha256File from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
@@ -20,22 +19,20 @@ 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,
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths from .www import build_webpages, find_merge_conflict, get_paths
@@ -69,8 +66,6 @@ def _build_chatmaild(dist_dir) -> None:
def remove_legacy_artifacts(): def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service # disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"): if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service( systemd.service(
@@ -305,7 +300,7 @@ class LegacyRemoveDeployer(Deployer):
present=False, present=False,
) )
# remove echobot if it is still running # remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): if host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service( systemd.service(
name="Disable echobot.service", name="Disable echobot.service",
service="echobot.service", service="echobot.service",
@@ -541,20 +536,6 @@ class GithashDeployer(Deployer):
) )
def get_tls_deployer(config, mail_domain):
"""Select the appropriate TLS deployer based on config."""
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
if config.tls_cert_mode == "acme":
return AcmetoolDeployer(config.acme_email, tls_domains)
elif config.tls_cert_mode == "self":
return SelfSignedTlsDeployer(mail_domain)
elif config.tls_cert_mode == "external":
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
else:
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None: def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
"""Deploy a chat-mail instance. """Deploy a chat-mail instance.
@@ -586,44 +567,44 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1) exit(1)
if not os.environ.get("CHATMAIL_NOPORTCHECK"): port_services = [
port_services = [ (["master", "smtpd"], 25),
(["master", "smtpd"], 25), ("unbound", 53),
("unbound", 53), ]
] if config.tls_cert_mode == "acme":
if config.tls_cert_mode == "acme": port_services.append(("acmetool", 80))
port_services.append(("acmetool", 402)) port_services += [
port_services += [ (["imap-login", "dovecot"], 143),
(["imap-login", "dovecot"], 143), ("nginx", 443),
# acmetool previously listened on port 80, (["master", "smtpd"], 465),
# so don't complain during upgrade that moved it to port 402 (["master", "smtpd"], 587),
# and gave the port to nginx. (["imap-login", "dovecot"], 993),
(["acmetool", "nginx"], 80), ("iroh-relay", 3340),
("nginx", 443), ("mtail", 3903),
(["master", "smtpd"], 465), ("stats", 3904),
(["master", "smtpd"], 587), ("nginx", 8443),
(["imap-login", "dovecot"], 993), (["master", "smtpd"], config.postfix_reinject_port),
("iroh-relay", 3340), (["master", "smtpd"], config.postfix_reinject_port_incoming),
("mtail", 3903), ("filtermail", config.filtermail_smtp_port),
("stats", 3904), ("filtermail", config.filtermail_smtp_port_incoming),
("nginx", 8443), ]
(["master", "smtpd"], config.postfix_reinject_port), for service, port in port_services:
(["master", "smtpd"], config.postfix_reinject_port_incoming), print(f"Checking if port {port} is available for {service}...")
("filtermail", config.filtermail_smtp_port), running_service = host.get_fact(Port, port=port)
("filtermail", config.filtermail_smtp_port_incoming), services = [service] if isinstance(service, str) else service
] if running_service:
for service, port in port_services: if running_service not in services:
print(f"Checking if port {port} is available for {service}...") Out().red(
running_service = host.get_fact(Port, port=port) f"Deploy failed: port {port} is occupied by: {running_service}"
services = [service] if isinstance(service, str) else service )
if running_service: exit(1)
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
tls_deployer = get_tls_deployer(config, 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),

View File

@@ -1,5 +1,3 @@
import os
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.server import Arch, Sysctl from pyinfra.facts.server import Arch, Sysctl
@@ -11,7 +9,6 @@ from cmdeploy.basedeploy import (
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
@@ -25,11 +22,10 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled): if not host.get_fact(SystemdEnabled).get("dovecot.service"):
return # already installed and running _install_dovecot_package("core", arch)
_install_dovecot_package("core", arch) _install_dovecot_package("imapd", arch)
_install_dovecot_package("imapd", arch) _install_dovecot_package("lmtpd", arch)
_install_dovecot_package("lmtpd", arch)
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
@@ -120,19 +116,18 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
# as per https://doc.dovecot.org/2.3/configuration_manual/os/ # as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
if not os.environ.get("CHATMAIL_NOSYSCTL"): for name in ("max_user_instances", "max_user_watches"):
for name in ("max_user_instances", "max_user_watches"): key = f"fs.inotify.{name}"
key = f"fs.inotify.{name}" if host.get_fact(Sysctl)[key] > 65535:
if host.get_fact(Sysctl)[key] > 65535: # Skip updating limits if already sufficient
# Skip updating limits if already sufficient # (enables running in incus containers where sysctl readonly)
# (enables running in incus containers where sysctl readonly) continue
continue server.sysctl(
server.sysctl( name=f"Change {key}",
name=f"Change {key}", key=key,
key=key, value=65535,
value=65535, persist=True,
persist=True, )
)
timezone_env = files.line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",

View File

@@ -1,67 +0,0 @@
import io
from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class ExternalTlsDeployer(Deployer):
"""Expects TLS certificates to be managed on the server.
Validates that the configured certificate and key files
exist on the remote host. Installs a systemd path unit
that watches the certificate file and automatically
restarts/reloads affected services when it changes.
"""
def __init__(self, cert_path, key_path):
self.cert_path = cert_path
self.key_path = key_path
def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}")
# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
)
service_unit = files.put(
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
)
if path_unit.changed or service_unit.changed:
self.need_restart = True
def activate(self):
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.

View File

@@ -1,15 +0,0 @@
# Watch the TLS certificate file for changes.
# When the cert is updated (e.g. renewed by an external process),
# this triggers tls-cert-reload.service to reload the affected services.
#
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
# After cert renewal, you must then trigger the reload explicitly:
# systemctl start tls-cert-reload.service
[Unit]
Description=Watch TLS certificate for changes
[Path]
PathChanged={cert_path}
[Install]
WantedBy=multi-user.target

View File

@@ -1,15 +0,0 @@
# Reload services that cache the TLS certificate.
#
# dovecot: caches the cert at startup; reload re-reads SSL certs
# without dropping existing connections.
# nginx: caches the cert at startup; reload gracefully picks up
# the new cert for new connections.
# postfix: reads the cert fresh on each TLS handshake,
# does NOT need a reload/restart.
[Unit]
Description=Reload TLS services after certificate change
[Service]
Type=oneshot
ExecStart=/bin/systemctl try-reload-or-restart dovecot
ExecStart=/bin/systemctl try-reload-or-restart nginx

View File

@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.1/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "adce2ddb461c5fd744df699f3b0b3c33b6d52413c641f18695b93826e5e0d234", "x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
"aarch64": "b51cf4248c6c443308f21b1811da1cc919b98b719a2138f4b60940ea093a5422", "aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
}[arch] }[arch]
self.need_restart |= files.download( self.need_restart |= files.download(
name="Download filtermail", name="Download filtermail",

View File

@@ -84,7 +84,7 @@ http {
} }
location /new { location /new {
{% if config.tls_cert_mode != "self" %} {% 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.
@@ -106,7 +106,7 @@ 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 != "self" %} {% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) { if ($request_method = GET) {
return 301 dcaccount:https://{{ config.mail_domain }}/new; return 301 dcaccount:https://{{ config.mail_domain }}/new;
} }
@@ -145,25 +145,4 @@ http {
return 301 $scheme://{{ config.mail_domain }}$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;
} }
server {
listen 80;
{% if not disable_ipv6 %}
listen [::]:80;
{% endif %}
{% if config.tls_cert_mode == "acme" %}
location /.well-known/acme-challenge/ {
proxy_pass http://acmetool;
}
{% endif %}
return 301 https://$host$request_uri;
}
{% if config.tls_cert_mode == "acme" %}
upstream acmetool {
server 127.0.0.1:402;
}
{% endif %}
} }

View File

@@ -37,15 +37,21 @@ class OpendkimDeployer(Deployer):
) )
need_restart |= main_config.changed need_restart |= main_config.changed
screen_script = files.file( screen_script = files.put(
path="/etc/opendkim/screen.lua", src=get_resource("opendkim/screen.lua"),
present=False, dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
) )
need_restart |= screen_script.changed need_restart |= screen_script.changed
final_script = files.file( final_script = files.put(
path="/etc/opendkim/final.lua", src=get_resource("opendkim/final.lua"),
present=False, dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
) )
need_restart |= final_script.changed need_restart |= final_script.changed

View File

@@ -0,0 +1,42 @@
mtaname = odkim.get_mtasymbol(ctx, "{daemon_name}")
if mtaname == "ORIGINATING" then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
local valid = false
local error_msg = "No valid DKIM signature found."
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
valid = true
else
error_msg = "DKIM signature is invalid, error code " .. tostring(sigres) .. ", search https://github.com/trusteddomainproject/OpenDKIM/blob/master/libopendkim/dkim.h#L108"
end
end
if valid then
-- Strip all DKIM-Signature headers after successful validation
-- Delete in reverse order to avoid index shifting.
for i = nsigs, 1, -1 do
odkim.del_header(ctx, "DKIM-Signature", i)
end
else
odkim.set_reply(ctx, "554", "5.7.1", error_msg)
odkim.set_result(ctx, SMFIS_REJECT)
end
return nil

View File

@@ -45,6 +45,12 @@ SignHeaders *,+autocrypt,+content-type
# Default is empty. # Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when # In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged # using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group # user (for example, Postfix). You may need to add user "postfix" to group

View File

@@ -0,0 +1,21 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -86,6 +86,7 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming -o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.

View File

@@ -1,29 +1,8 @@
import shlex from pyinfra.operations import apt, files, server
from pyinfra.operations import apt, server
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"""Return the openssl argument list for a self-signed certificate.
The certificate uses an EC P-256 key with SAN entries for *domain*,
``www.<domain>`` and ``mta-sts.<domain>``.
"""
return [
"openssl", "req", "-x509",
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
"-noenc", "-days", str(days),
"-keyout", str(key_path),
"-out", str(cert_path),
"-subj", f"/CN={domain}",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
]
class SelfSignedTlsDeployer(Deployer): class SelfSignedTlsDeployer(Deployer):
"""Generates a self-signed TLS certificate for all chatmail endpoints.""" """Generates a self-signed TLS certificate for all chatmail endpoints."""
@@ -39,13 +18,18 @@ class SelfSignedTlsDeployer(Deployer):
) )
def configure(self): def configure(self):
args = openssl_selfsigned_args(
self.mail_domain, self.cert_path, self.key_path,
)
cmd = shlex.join(args)
server.shell( server.shell(
name="Generate self-signed TLS certificate if not present", name="Generate self-signed TLS certificate if not present",
commands=[f"[ -f {self.cert_path} ] || {cmd}"], 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): def activate(self):

View File

@@ -1,4 +1,3 @@
import time
def test_tls_imap(benchmark, imap): def test_tls_imap(benchmark, imap):
def imap_connect(): def imap_connect():
imap.connect() imap.connect()
@@ -42,9 +41,9 @@ class TestDC:
def dc_ping_pong(): def dc_ping_pong():
chat.send_text("ping") chat.send_text("ping")
msg = ac2.wait_for_incoming_msg() msg = ac2._evtracker.wait_next_incoming_message()
msg.get_snapshot().chat.send_text("pong") msg.chat.send_text("pong")
ac1.wait_for_incoming_msg() ac1._evtracker.wait_next_incoming_message()
benchmark(dc_ping_pong, 5) benchmark(dc_ping_pong, 5)
@@ -56,6 +55,6 @@ class TestDC:
for i in range(10): for i in range(10):
chat.send_text(f"hello {i}") chat.send_text(f"hello {i}")
for i in range(10): for i in range(10):
ac2.wait_for_incoming_msg() ac2._evtracker.wait_next_incoming_message()
benchmark(dc_send_10_receive_10, 5, cooldown="auto") benchmark(dc_send_10_receive_10, 5)

View File

@@ -86,8 +86,10 @@ def test_remote(remote, imap_or_smtp):
def test_use_two_chatmailservers(cmfactory, maildomain2): def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.get_online_account() ac1 = cmfactory.new_online_configuring_account(cache=False)
ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
cmfactory.get_accepted_chat(ac1, ac2) cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1] domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1] domain2 = ac2.get_config("addr").split("@")[1]
@@ -147,7 +149,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
conn.starttls() conn.starttls()
with conn as s: with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"): with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)

View File

@@ -27,7 +27,6 @@ class TestMetadataTokens:
def test_set_get_metadata(self, imap_mailbox): def test_set_get_metadata(self, imap_mailbox):
"set and get metadata token for an account" "set and get metadata token for an account"
time.sleep(5) # make sure Metadata service had a chance to restart
client = imap_mailbox.client client = imap_mailbox.client
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n') client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
res = client.readline() res = client.readline()
@@ -63,8 +62,8 @@ class TestEndToEndDeltaChat:
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
msg2 = ac2.wait_for_incoming_msg() msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.get_snapshot().text == "message0" assert msg2.text == "message0"
def test_exceed_quota( def test_exceed_quota(
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
@@ -99,34 +98,38 @@ class TestEndToEndDeltaChat:
lp.sec("ac2: check quota is triggered") lp.sec("ac2: check quota is triggered")
def send_hello(): starting = True
chat.send_text("hello") for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if starting:
for line in remote.iter_output( chat.send_text("hello")
"journalctl -n1 -f -u dovecot", ready=send_hello starting = False
):
if user not in line: if user not in line:
# print(line)
continue continue
if "quota exceeded" in line: if "quota exceeded" in line:
return return
def test_securejoin(self, cmfactory, lp, maildomain2): def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.get_online_account() ac1 = cmfactory.new_online_configuring_account(cache=False)
ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin") lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_qr_code() qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol") lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.secure_join(qr) ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10 assert ch.id >= 10
ac1.wait_for_securejoin_inviter_success() ac1._evtracker.wait_securejoin_inviter_progress(1000)
def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox): def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox):
"""Test that if a DC address receives a message, it has no """Test that if a DC address receives a message, it has no
DKIM-Signature and Authentication-Results headers.""" DKIM-Signature and Authentication-Results headers."""
ac1 = cmfactory.get_online_account() ac1 = cmfactory.new_online_configuring_account(cache=False)
ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac) chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
chat.send_text("message0") chat.send_text("message0")
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac) chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
@@ -143,28 +146,29 @@ class TestEndToEndDeltaChat:
assert "dkim-signature" not in msg.headers assert "dkim-signature" not in msg.headers
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2): def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.get_online_account() ac1 = cmfactory.new_online_configuring_account(cache=False)
ac2 = cmfactory.get_online_account(domain=maildomain2) cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances") lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_qr_code() qr = ac1.get_setup_contact_qr()
ch = ac2.secure_join(qr) ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10 assert ch.id >= 10
ac1.wait_for_securejoin_inviter_success() ac1._evtracker.wait_securejoin_inviter_progress(1000)
lp.sec("ac1 sends a message and ac2 marks it as seen") lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2) chat = ac1.create_chat(ac2)
msg = chat.send_text("hi") msg = chat.send_text("hi")
m = ac2.wait_for_incoming_msg() m = ac2._evtracker.wait_next_incoming_message()
m.mark_seen() m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error # we can only indirectly wait for mark-seen to cause an smtp-error
lp.sec("try to wait for markseen to complete and check error states") lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1 deadline = time.time() + 3.1
while time.time() < deadline: while time.time() < deadline:
m_snap = m.get_snapshot() msgs = m.chat.get_messages()
msgs = m_snap.chat.get_messages()
for msg in msgs: for msg in msgs:
assert "error" not in m.get_info() assert "error" not in m.get_message_info()
time.sleep(1) time.sleep(1)
@@ -176,7 +180,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat = cmfactory.get_accepted_chat(user1, user2) chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2._evtracker.wait_next_incoming_message()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1] host = addr.split("@")[1]
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")

View File

@@ -1,4 +1,5 @@
import imaplib import imaplib
import io
import itertools import itertools
import os import os
import random import random
@@ -34,24 +35,17 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run") pytest.skip("skipping slow test, use --slow to run")
def _get_chatmail_config(): @pytest.fixture(scope="session")
current = Path().resolve() def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
while 1: while 1:
path = current.joinpath("chatmail.ini").resolve() path = current.joinpath("chatmail.ini").resolve()
if path.exists(): if path.exists():
return read_config(path), path return read_config(path)
if current == current.parent: if current == current.parent:
break break
current = current.parent current = current.parent
return None, None
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
config, path = _get_chatmail_config()
if config:
return config
basedir = Path().resolve()
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs") pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@@ -61,8 +55,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def sshdomain(maildomain): def sshdomain(chatmail_config):
return os.environ.get("CHATMAIL_SSH", maildomain) return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
@pytest.fixture @pytest.fixture
@@ -79,17 +73,10 @@ def sshdomain2(maildomain2):
def pytest_report_header(): def pytest_report_header():
config, path = _get_chatmail_config() domain = os.environ.get("CHATMAIL_DOMAIN")
domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET") if domain:
domain = config.mail_domain if config else "NOT SET" text = f"chatmail test instance: {domain}"
path = path if path else "NOT SET" return ["-" * len(text), text, "-" * len(text)]
lines = [
f"chatmail.ini {domain} location: {path}",
f"chatmail2: {domain2}",
]
sep = "-" * max(map(len, lines))
return [sep, *lines, sep]
@pytest.fixture @pytest.fixture
@@ -104,22 +91,15 @@ def cm_data(request):
@pytest.fixture @pytest.fixture
def benchmark(request, chatmail_config): def benchmark(request):
def bench(func, num, name=None, reportfunc=None, cooldown=0.0): def bench(func, num, name=None, reportfunc=None):
if name is None: if name is None:
name = func.__name__ name = func.__name__
if cooldown == "auto":
per_minute = max(chatmail_config.max_user_send_per_minute, 1)
cooldown = chatmail_config.max_user_send_burst_size * 60 / per_minute
durations = [] durations = []
for i in range(num): for i in range(num):
now = time.time() now = time.time()
func() func()
durations.append(time.time() - now) durations.append(time.time() - now)
if cooldown > 0 and i + 1 < num:
# Keep post-run cooldown out of measured benchmark duration.
time.sleep(cooldown)
durations.sort() durations.sort()
request.config._benchresults[name] = (reportfunc, durations) request.config._benchresults[name] = (reportfunc, durations)
@@ -296,95 +276,79 @@ def gencreds(chatmail_config):
# #
# Delta Chat RPC-based test support # Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts # use the cmfactory fixture to get chatmail instance accounts
# #
from deltachat_rpc_client import DeltaChat, Rpc
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
class ChatmailACFactory: def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config):
"""RPC-based account factory for chatmail testing.""" self.pytestconfig = pytestconfig
self.maildomain = maildomain
def __init__(self, rpc, maildomain, gencreds, chatmail_config): assert "." in self.maildomain, maildomain
self.dc = DeltaChat(rpc)
self.rpc = rpc
self._maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config self.chatmail_config = chatmail_config
self._addr2files = {}
def _make_transport(self, domain): def get_liveconfig_producer(self):
"""Build a transport config dict for the given domain.""" while 1:
addr, password = self.gencreds(domain) user, password = self.gencreds(self.maildomain)
transport = { config = {
"addr": addr, "addr": user,
"password": password, "mail_pw": password,
# Setting server explicitly skips requesting autoconfig XML, }
# see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/ # speed up account configuration
"imapServer": domain, config["mail_server"] = self.maildomain
"smtpServer": domain, config["send_server"] = self.maildomain
} if self.chatmail_config.tls_cert_mode == "self":
if self.chatmail_config.tls_cert_mode == "self": # Accept self-signed TLS certificates
transport["certificateChecks"] = "acceptInvalidCertificates" config["imap_certificate_checks"] = "3"
return transport yield config
def get_online_account(self, domain=None): def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
"""Create, configure and bring online a single account.""" pass
return self.get_online_accounts(1, domain)[0]
def get_online_accounts(self, num, domain=None): def cache_maybe_store_configured_db_files(self, acc):
"""Create multiple online accounts in parallel.""" pass
domain = domain or self._maildomain
futures = []
accounts = []
for _ in range(num):
account = self.dc.add_account()
future = account.add_or_update_transport.future(
self._make_transport(domain)
)
futures.append(future)
# ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests
account.set_config("delete_server_after", "10")
accounts.append(account)
for future in futures:
future()
for account in accounts:
account.bring_online()
return accounts
def get_accepted_chat(self, ac1, ac2):
"""Create a 1:1 chat between ac1 and ac2 accepted on both sides."""
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
@pytest.fixture(scope="session")
def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session."""
# NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet.
accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts")
rpc = Rpc(accounts_dir=accounts_dir)
rpc.start()
yield rpc
rpc.close()
@pytest.fixture @pytest.fixture
def cmfactory(rpc, gencreds, maildomain, chatmail_config): def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts.""" # cloned from deltachat.testplugin.amfactory
return ChatmailACFactory( pytest.importorskip("deltachat")
rpc=rpc, from deltachat.testplugin import ACFactory
maildomain=maildomain,
gencreds=gencreds, testproc = ChatmailTestProcess(
chatmail_config=chatmail_config, request.config, maildomain, gencreds, chatmail_config
) )
class Data:
def read_path(self, path):
return
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):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture @pytest.fixture
def remote(sshdomain): def remote(sshdomain):
@@ -395,7 +359,7 @@ class Remote:
def __init__(self, sshdomain): def __init__(self, sshdomain):
self.sshdomain = sshdomain self.sshdomain = sshdomain
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain) print(self.sshdomain)
match self.sshdomain: match self.sshdomain:
@@ -410,12 +374,10 @@ class Remote:
while 1: while 1:
line = self.popen.stdout.readline() line = self.popen.stdout.readline()
res = line.decode().strip().lower() res = line.decode().strip().lower()
if not res: if res:
yield res
else:
break break
if ready is not None:
ready()
ready = None
yield res
@pytest.fixture @pytest.fixture

View File

@@ -1,78 +0,0 @@
"""Functional tests for tls_external_cert_and_key option."""
import json
import chatmaild.newemail
import pytest
from chatmaild.config import read_config, write_initial_config
def make_external_config(tmp_path, cert_key=None):
inipath = tmp_path / "chatmail.ini"
overrides = {}
if cert_key is not None:
overrides["tls_external_cert_and_key"] = cert_key
write_initial_config(inipath, "chat.example.org", overrides=overrides)
return inipath
def test_external_tls_config_reads_paths(tmp_path):
inipath = make_external_config(
tmp_path,
cert_key=(
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
),
)
config = read_config(inipath)
assert config.tls_cert_mode == "external"
assert (
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
)
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
def test_external_tls_missing_option_uses_acme(tmp_path):
config = read_config(make_external_config(tmp_path))
assert config.tls_cert_mode == "acme"
def test_external_tls_bad_format_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_three_paths_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/a /b /c")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
chatmaild.newemail.print_new_account()
out, _ = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" not in dic
def test_external_tls_selects_correct_deployer(tmp_path):
from cmdeploy.deployers import get_tls_deployer
from cmdeploy.external.deployer import ExternalTlsDeployer
from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
config = read_config(inipath)
deployer = get_tls_deployer(config, "chat.example.org")
assert isinstance(deployer, ExternalTlsDeployer)
assert not isinstance(deployer, SelfSignedTlsDeployer)
assert deployer.cert_path == "/certs/fullchain.pem"
assert deployer.key_path == "/certs/privkey.pem"

View File

@@ -16,18 +16,11 @@ You will need the following:
- Control over a domain through a DNS provider of your choice. - Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses. chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
Setup with ``scripts/cmdeploy`` Setup with ``scripts/cmdeploy``
------------------------------------- -------------------------------------
@@ -35,7 +28,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server. 1. Setup the initial DNS records for your relay.
The following is an example in the The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds). familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
@@ -55,22 +48,25 @@ steps. Please substitute it with your own domain.
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains. are not needed for such domains.
2. On your local PC, clone the repository and bootstrap the Python 2. Login to the server with SSH, clone the repository and bootstrap the Python
virtualenv. virtualenv.
:: ::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
scripts/initenv.sh scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file 3. Then, create a chatmail configuration file
``chatmail.ini``: ``chatmail.ini``:
:: ::
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
.. note::
To use self-signed TLS certificates To use self-signed TLS certificates
instead of Let's Encrypt, instead of Let's Encrypt,
use a domain name starting with ``_`` use a domain name starting with ``_``
@@ -81,13 +77,7 @@ steps. Please substitute it with your own domain.
See the :doc:`overview` See the :doc:`overview`
for details on certificate provisioning. for details on certificate provisioning.
4. Verify that SSH root login to the deployment server server works: 4. Now run the deployment script to install the relay to the server:
::
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
:: ::
@@ -98,27 +88,32 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Other helpful commands Next Steps
---------------------- ----------
To check the status of your deployment server running the chatmail service: Now you should display and check all recommended DNS records
to enable federation with other relays:
::
scripts/cmdeploy status
To display and check all recommended DNS records:
:: ::
scripts/cmdeploy dns scripts/cmdeploy dns
To test whether your chatmail service is working correctly: You should also test whether your chatmail service is working correctly:
:: ::
scripts/cmdeploy test scripts/cmdeploy test
Other Helpful Commands
----------------------
To check the status of your chatmail relay:
::
scripts/cmdeploy status
To measure the performance of your chatmail service: To measure the performance of your chatmail service:
:: ::
@@ -159,8 +154,9 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the directory and generating HTML files and copying assets to the
``www/build`` directory. ``www/build`` directory.
- Starts a browser window automatically where you can “refresh” as - if you are running scripts/cmdeploy webdev on the relay itself,
needed. you need to configure a route in /etc/nginx/nginx.conf
to expose the build directory.
Custom web pages Custom web pages
---------------- ----------------
@@ -178,7 +174,7 @@ Disable automatic address creation
-------------------------------------------------------- --------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run: creating addresses, login with ssh to the relay and run:
:: ::
@@ -198,61 +194,3 @@ and all other relays will accept connections from it
without requiring certificate verification. without requiring certificate verification.
This is useful for experimental setups and testing. This is useful for experimental setups and testing.
.. _external-tls:
Running a relay with externally managed certificates
-----------------------------------------------------
If you already have a TLS certificate manager
(e.g. Traefik, certbot, or another ACME client)
running on the deployment server,
you can configure the relay to use those certificates
instead of the built-in ``acmetool``.
Set the following in ``chatmail.ini``::
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
The paths must point to certificate and key files
on the deployment server.
During ``cmdeploy run``, these paths are written into
the Postfix, Dovecot, and Nginx configurations.
No certificate files are transferred from the build machine —
they must already exist on the server,
managed by your external certificate tool.
The deploy will verify that both files exist on the server.
``acmetool`` is **not** installed or run in this mode.
.. note::
You are responsible for certificate renewal.
When the certificate file changes on disk,
all relay services pick up the new certificate automatically
via a systemd path watcher installed during deploy.
The watcher uses inotify, which does not cross bind-mount boundaries.
If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service
Migrating to a new build machine
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.

View File

@@ -308,11 +308,6 @@ When providing a TLS certificate to your chatmail relay server, make
sure to provide the full certificate chain and not just the last sure to provide the full certificate chain and not just the last
certificate. certificate.
If you use an external certificate manager (e.g. Traefik or certbot),
set ``tls_external_cert_and_key`` in ``chatmail.ini``
to provide the certificate and key paths.
See :ref:`external-tls` for details.
If you are running an Exim server and dont see incoming connections If you are running an Exim server and dont see incoming connections
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
item is enabled in the config with ``log_selector = +smtp_no_mail``. By item is enabled in the config with ``log_selector = +smtp_no_mail``. By