Compare commits

..

8 Commits

28 changed files with 262 additions and 375 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.6.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.5.2/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

@@ -6,7 +6,10 @@ build-backend = "setuptools.build_meta"
name = "chatmaild" name = "chatmaild"
version = "0.3" version = "0.3"
dependencies = [ dependencies = [
"aiosmtpd",
"iniconfig", "iniconfig",
"deltachat-rpc-server",
"deltachat-rpc-client",
"filelock", "filelock",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.11'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
@@ -67,7 +70,6 @@ commands =
deps = pytest deps = pytest
pdbpp pdbpp
pytest-localserver pytest-localserver
aiosmtpd
execnet execnet
commands = pytest -v -rsXx {posargs} commands = pytest -v -rsXx {posargs}
""" """

View File

@@ -1,3 +1,4 @@
import ipaddress
import os import os
from pathlib import Path from pathlib import Path
@@ -20,7 +21,10 @@ def read_config(inipath):
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"] if is_valid_ipv4(params["mail_domain"]):
self.mail_domain = f"[{params.get('mail_domain')}]"
else:
self.mail_domain = params["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["max_mailbox_size"]
@@ -38,7 +42,6 @@ class Config:
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.get("filtermail_smtp_port_incoming", "10081") params.get("filtermail_smtp_port_incoming", "10081")
) )
self.filtermail_http_port = int(params.get("filtermail_http_port", "10082"))
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
@@ -77,7 +80,7 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"): elif self.mail_domain.startswith("_") or is_valid_ipv4(params["mail_domain"]):
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"
@@ -158,3 +161,12 @@ def get_default_config_content(mail_domain, **overrides):
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content return content
def is_valid_ipv4(address: str) -> bool:
"""Check if a mail_domain is an IPv4 address."""
try:
ipaddress.IPv4Address(address)
return True
except ValueError:
return False

View File

@@ -145,10 +145,6 @@ class Expiry:
changed = True changed = True
if changed: if changed:
self.remove_file(f"{mbox.basedir}/maildirsize") self.remove_file(f"{mbox.basedir}/maildirsize")
for file in mbox.extrafiles:
if "dovecot.index.cache" in file.path.split("/")[-1]:
if file.size > 500 * 1024:
self.remove_file(file.path)
def get_summary(self): def get_summary(self):
return ( return (

View File

@@ -7,7 +7,7 @@ import secrets
import string import string
from urllib.parse import quote from urllib.parse import quote
from chatmaild.config import Config, read_config from chatmaild.config import Config, is_valid_ipv4, read_config
CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini" CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini"
ALPHANUMERIC = string.ascii_lowercase + string.digits ALPHANUMERIC = string.ascii_lowercase + string.digits
@@ -31,7 +31,15 @@ def create_dclogin_url(email, password):
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3" domain = email.split("@")[-1]
domain_without_brackets = domain.strip("[").strip("]")
if is_valid_ipv4(domain_without_brackets):
imap_host = "&ih=" + domain_without_brackets
smtp_host = "&sh=" + domain_without_brackets
else:
imap_host = ""
smtp_host = ""
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
def print_new_account(): def print_new_account():

View File

@@ -10,6 +10,7 @@ dependencies = [
"pillow", "pillow",
"qrcode", "qrcode",
"markdown", "markdown",
"pytest",
"setuptools>=68", "setuptools>=68",
"termcolor", "termcolor",
"build", "build",
@@ -20,7 +21,6 @@ dependencies = [
"execnet", "execnet",
"imap_tools", "imap_tools",
"deltachat-rpc-client", "deltachat-rpc-client",
"deltachat-rpc-server",
] ]
[project.scripts] [project.scripts]

View File

@@ -1,7 +1,6 @@
import importlib.resources import importlib.resources
import io import io
import os import os
from contextlib import contextmanager
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
@@ -11,28 +10,6 @@ def has_systemd():
return os.path.isdir("/run/systemd/system") return os.path.isdir("/run/systemd/system")
@contextmanager
def blocked_service_startup():
"""Prevent services from auto-starting during package installation.
Installs a ``/usr/sbin/policy-rc.d`` that exits 101, blocking any
service from being started by the package manager. This avoids bind
conflicts and CPU/RAM spikes during initial setup. The file is removed
when the context exits.
"""
# For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
files.put(
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
yield
files.file("/usr/sbin/policy-rc.d", present=False)
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

@@ -0,0 +1,32 @@
;
; Required DNS entries for chatmail servers
;
{% if A %}
{{ mail_domain }}. A {{ A }}
{% endif %}
{% if AAAA %}
{{ 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 }}
;
; Recommended DNS entries for interoperability and security-hardening
;
{{ mail_domain }}. TXT "v=spf1 a ~all"
_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
{% if acme_account_url %}
{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
{% endif %}
_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable"
_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}.
_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}.
_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}.
_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}.

View File

@@ -13,7 +13,7 @@ import sys
from pathlib import Path from pathlib import Path
import pyinfra import pyinfra
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config, is_valid_ipv4
from packaging import version from packaging import version
from termcolor import colored from termcolor import colored
@@ -87,11 +87,11 @@ 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.mail_domain.strip("[").strip("]")
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"
if not args.dns_check_disabled: if not args.dns_check_disabled and not is_valid_ipv4(args.config.mail_domain.strip("[").strip("]")):
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
return 1 return 1
@@ -101,7 +101,7 @@ def run_cmd(args, out):
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else "" env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else "" env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.dns_check_disabled: if not args.dns_check_disabled and not is_valid_ipv4(args.config.mail_domain.strip("[").strip("]")):
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or "" env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or "" env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
@@ -111,6 +111,7 @@ def run_cmd(args, out):
if ssh_host in ["localhost", "@docker"]: if ssh_host in ["localhost", "@docker"]:
if ssh_host == "@docker": if ssh_host == "@docker":
env["CHATMAIL_NOPORTCHECK"] = "True" 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"):
@@ -209,7 +210,6 @@ def test_cmd(args, out):
"""Run local and online tests for chatmail deployment.""" """Run local and online tests for chatmail deployment."""
env = os.environ.copy() env = os.environ.copy()
env["CHATMAIL_INI"] = str(args.inipath.absolute())
if args.ssh_host: if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host env["CHATMAIL_SSH"] = args.ssh_host

View File

@@ -24,7 +24,6 @@ from .basedeploy import (
Deployer, Deployer,
Deployment, Deployment,
activate_remote_units, activate_remote_units,
blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd, has_systemd,
@@ -150,16 +149,33 @@ class UnboundDeployer(Deployer):
self.need_restart = False self.need_restart = False
def install(self): def install(self):
# Run local DNS resolver `unbound`. `resolvconf` takes care of # Run local DNS resolver `unbound`.
# setting up /etc/resolv.conf to use 127.0.0.1 as the resolver. # `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
# On an IPv4-only system, if unbound is started but not configured, #
# it causes subsequent steps to fail to resolve hosts. # On an IPv4-only system, if unbound is started but not
with blocked_service_startup(): # configured, it causes subsequent steps to fail to resolve hosts.
apt.packages( # Here, we use policy-rc.d to prevent unbound from starting up
name="Install unbound", # on initial install. Later, we will configure it and start it.
packages=["unbound", "unbound-anchor", "dnsutils"], #
) # For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
#
files.put(
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
)
files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self): def configure(self):
server.shell( server.shell(
@@ -320,12 +336,12 @@ class TurnDeployer(Deployer):
def install(self): def install(self):
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux", "https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a", "841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
), ),
"aarch64": ( "aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux", "https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756", "a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
@@ -458,9 +474,8 @@ class ChatmailDeployer(Deployer):
("iroh", None, None), ("iroh", None, None),
] ]
def __init__(self, config): def __init__(self, mail_domain):
self.config = config self.mail_domain = mail_domain
self.mail_domain = config.mail_domain
def install(self): def install(self):
files.put( files.put(
@@ -483,18 +498,12 @@ class ChatmailDeployer(Deployer):
name="Install rsync", name="Install rsync",
packages=["rsync"], packages=["rsync"],
) )
apt.packages(
def configure(self): name="Ensure cron is installed",
# metadata crashes if the mailboxes dir does not exist packages=["cron"],
files.directory(
name="Ensure vmail mailbox directory exists",
path=str(self.config.mailboxes_dir),
user="vmail",
group="vmail",
mode="700",
present=True,
) )
def configure(self):
# This file is used by auth proxy. # This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName # https://wiki.debian.org/EtcMailName
server.shell( server.shell(
@@ -624,7 +633,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
tls_deployer = get_tls_deployer(config, mail_domain) tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),

View File

@@ -1,22 +1,11 @@
import datetime import datetime
import importlib
from jinja2 import Template
from . import remote from . import remote
def parse_zone_records(text):
"""Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text."""
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith(";"):
continue
try:
name, ttl, _in, rtype, rdata = line.split(None, 4)
except ValueError:
raise ValueError(f"Bad zone record line: {line!r}") from None
name = name.rstrip(".")
yield name, ttl, rtype.upper(), rdata
def get_initial_remote_data(sshexec, mail_domain): def get_initial_remote_data(sshexec, mail_domain):
return sshexec.logged( return sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
@@ -42,39 +31,13 @@ def get_filled_zone_file(remote_data):
if not sts_id: if not sts_id:
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M") remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
d = remote_data["mail_domain"] template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
content = template.read_text()
def append_record(name, rtype, rdata, ttl=3600): zonefile = Template(content).render(**remote_data)
lines.append(f"{name:<40} {ttl:<6} IN {rtype:<5} {rdata}") lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
lines = ["; Required DNS entries"]
if remote_data.get("A"):
append_record(f"{d}.", "A", remote_data["A"])
if remote_data.get("AAAA"):
append_record(f"{d}.", "AAAA", remote_data["AAAA"])
append_record(f"{d}.", "MX", f"10 {d}.")
if remote_data.get("strict_tls"):
append_record(f"_mta-sts.{d}.", "TXT", f'"v=STSv1; id={remote_data["sts_id"]}"')
append_record(f"mta-sts.{d}.", "CNAME", f"{d}.")
append_record(f"www.{d}.", "CNAME", f"{d}.")
lines.append(remote_data["dkim_entry"])
lines.append("") lines.append("")
lines.append("; Recommended DNS entries") zonefile = "\n".join(lines)
append_record(f"{d}.", "TXT", '"v=spf1 a ~all"') return zonefile
append_record(f"_dmarc.{d}.", "TXT", '"v=DMARC1;p=reject;adkim=s;aspf=s"')
if remote_data.get("acme_account_url"):
append_record(
f"{d}.",
"CAA",
f'0 issue "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"',
)
append_record(f"_adsp._domainkey.{d}.", "TXT", '"dkim=discardable"')
append_record(f"_submission._tcp.{d}.", "SRV", f"0 1 587 {d}.")
append_record(f"_submissions._tcp.{d}.", "SRV", f"0 1 465 {d}.")
append_record(f"_imap._tcp.{d}.", "SRV", f"0 1 143 {d}.")
append_record(f"_imaps._tcp.{d}.", "SRV", f"0 1 993 {d}.")
lines.append("")
return "\n".join(lines)
def check_full_zone(sshexec, remote_data, out, zonefile) -> int: def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
@@ -95,8 +58,7 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
returncode = 1 returncode = 1
if remote_data.get("dkim_entry") in required_diff: if remote_data.get("dkim_entry") in required_diff:
out( out(
"If the DKIM entry above does not work with your DNS provider," "If the DKIM entry above does not work with your DNS provider, you can try this one:\n"
" you can try this one:\n"
) )
out(remote_data.get("web_dkim_entry") + "\n") out(remote_data.get("web_dkim_entry") + "\n")
if recommended_diff: if recommended_diff:

View File

@@ -1,31 +1,20 @@
import io import os
import urllib.request import urllib.request
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.deb import DebPackages from pyinfra.facts.server import Arch, Sysctl
from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
activate_remote_units, activate_remote_units,
blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
DOVECOT_VERSION = "2.3.21+dfsg1-3"
DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
}
class DovecotDeployer(Deployer): class DovecotDeployer(Deployer):
daemon_reload = False daemon_reload = False
@@ -37,31 +26,11 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
with blocked_service_startup(): if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
debs = [] return # already installed and running
for pkg in ("core", "imapd", "lmtpd"): _install_dovecot_package("core", arch)
deb = _download_dovecot_package(pkg, arch) _install_dovecot_package("imapd", arch)
if deb: _install_dovecot_package("lmtpd", arch)
debs.append(deb)
if debs:
deb_list = " ".join(debs)
server.shell(
name="Install dovecot packages",
commands=[
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
f"dpkg --force-confdef --force-confold -i {deb_list}",
],
)
files.put(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO(
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
dest="/etc/apt/preferences.d/pin-dovecot",
)
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
@@ -94,51 +63,43 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str): def _install_dovecot_package(package: str, arch: str):
"""Download a dovecot .deb if needed, return its path (or None)."""
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
primary_url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
pkg_name = f"dovecot-{package}" fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F2.3.21%2Bdfsg1/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
sha256 = DOVECOT_SHA256.get((package, arch))
if sha256 is None:
apt.packages(packages=[pkg_name])
return None
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_VERSION in installed_versions:
return None
url_version = DOVECOT_VERSION.replace("+", "%2B")
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}" deb_filename = "/root/" + url.split("/")[-1]
match (package, arch):
case ("core", "amd64"):
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d"
case ("core", "arm64"):
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9"
case ("imapd", "amd64"):
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
case ("imapd", "arm64"):
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
case ("lmtpd", "amd64"):
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
case ("lmtpd", "arm64"):
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
case _:
apt.packages(packages=[f"dovecot-{package}"])
return
files.download( files.download(
name=f"Download {pkg_name}", name=f"Download dovecot-{package}",
src=url, src=url,
dest=deb_filename, dest=deb_filename,
sha256sum=sha256, sha256sum=sha256,
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
) )
return deb_filename apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
def _can_set_inotify_limits() -> bool: def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
is_container = (
host.get_fact(
Command,
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
)
== "yes"
)
return not is_container
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False need_restart = False
daemon_reload = False daemon_reload = False
@@ -173,25 +134,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[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
can_modify = _can_set_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}"
value = host.get_fact(Sysctl)[key] if host.get_fact(Sysctl)[key] > 65535:
if value > 65534: # Skip updating limits if already sufficient
continue # (enables running in incus containers where sysctl readonly)
if not can_modify: continue
print( server.sysctl(
"\n!!!! refusing to attempt sysctl setting in containers\n" name=f"Change {key}",
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n" key=key,
"!!!!" value=65535,
persist=True,
) )
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
timezone_env = files.line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",

View File

@@ -7,6 +7,7 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes
@@ -70,6 +71,12 @@ userdb {
# Mailboxes are stored in the "mail" directory of the vmail user home. # Mailboxes are stored in the "mail" directory of the vmail user home.
mail_location = maildir:{{ config.mailboxes_dir }}/%u mail_location = maildir:{{ config.mailboxes_dir }}/%u
# index/cache files are not very useful for chatmail relay operations
# but it's not clear how to disable them completely.
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
# if the cache file becomes larger than the specified size, it is truncated by dovecot
mail_cache_max_size = 500K
namespace inbox { namespace inbox {
inbox = yes inbox = yes

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.6.1/filtermail-{arch}" url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-{arch}"
sha256sum = { sha256sum = {
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f", "x86_64": "ce24ca0075aa445510291d775fb3aea8f4411818c7b885ae51a0fe18c5f789ce",
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e", "aarch64": "c5d783eefa5332db3d97a0e6a23917d72849e3eb45da3d16ce908a9b4e5a797d",
}[arch] }[arch]
self.need_restart |= files.download( self.need_restart |= files.download(
name="Download filtermail", name="Download filtermail",

View File

@@ -73,10 +73,6 @@ http {
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
location /mxdeliv/ {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port }};
}
location / { location / {
# First attempt to serve request as file, then # First attempt to serve request as file, then
# as directory, then fall back to displaying a 404. # as directory, then fall back to displaying a 404.

View File

@@ -20,7 +20,7 @@ smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
# Send SNI extension when connecting to other servers. # Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername> # <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname smtp_tls_servername = hostname
@@ -54,14 +54,15 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# Postfix does not deliver mail for any domain by itself. # Postfix does not deliver mail for any domain by itself.
# Primary domain is listed in `virtual_mailbox_domains` instead # Primary domain is listed in `virtual_mailbox_domains` instead
# and handed over to Dovecot. # and handed over to Dovecot.
mydestination = mydestination = {{ config.mail_domain }}
local_transport = lmtp:unix:private/dovecot-lmtp
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -88,24 +89,6 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
# Postfix does not try IPv4 and IPv6 connections
# concurrently as of version 3.7.11.
#
# When relay has both A (IPv4) and AAAA (IPv6) records,
# but broken IPv6 connectivity,
# every second message is delayed by the connection timeout
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
# which defaults to 30 seconds. Reducing timeouts is not a solution
# as this will result in a failure to connect to slow servers.
#
# As a workaround we always prefer IPv4 when it is available.
#
# The setting is documented at
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
smtp_address_preference=ipv4
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject

View File

@@ -80,7 +80,9 @@ filter unix - n n - - lmtp
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
{% if "[" not in config.mail_domain %}
-o smtpd_milters=unix:opendkim/opendkim.sock -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail # Local SMTP server for reinjecting incoming filtered mail

View File

@@ -57,10 +57,9 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s" dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw)) dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw)) web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
name = f"{dkim_selector}._domainkey.{mail_domain}."
return ( return (
f'{name:<40} 3600 IN TXT "{dkim_value}"', f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
f'{name:<40} 3600 IN TXT "{web_dkim_value}"', f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"',
) )
@@ -95,7 +94,7 @@ def check_zonefile(zonefile, verbose=True):
if not zf_line.strip() or zf_line.startswith(";"): if not zf_line.strip() or zf_line.startswith(";"):
continue continue
print(f"dns-checking {zf_line!r}") if verbose else log_progress("") print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
zf_domain, _ttl, _in, zf_typ, zf_value = zf_line.split(None, 4) zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".") zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip() zf_value = zf_value.strip()
query_value = query_dns(zf_typ, zf_domain) query_value = query_dns(zf_typ, zf_domain)

View File

@@ -18,8 +18,6 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"-keyout", str(key_path), "-keyout", str(key_path),
"-out", str(cert_path), "-out", str(cert_path),
"-subj", f"/CN={domain}", "-subj", f"/CN={domain}",
# Mark as end-entity cert so it cannot be used as a CA to sign others.
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "extendedKeyUsage=serverAuth,clientAuth", "-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", "-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}", f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",

View File

@@ -1,18 +1,17 @@
; Required DNS entries ; Required DNS entries for chatmail servers
zftest.testrun.org. 3600 IN A 135.181.204.127 zftest.testrun.org. A 135.181.204.127
zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1 zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org. zftest.testrun.org. MX 10 zftest.testrun.org.
_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706" _mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
www.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. www.zftest.testrun.org. CNAME zftest.testrun.org.
opendkim._domainkey.zftest.testrun.org. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s" opendkim._domainkey.zftest.testrun.org. TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s"
; Recommended DNS entries ; Recommended DNS entries
zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all" _submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s" _submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956" _imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable" _imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org. zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org. zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org. _dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org. _adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"

View File

@@ -1,3 +1,4 @@
import time
def test_tls_imap(benchmark, imap): def test_tls_imap(benchmark, imap):
def imap_connect(): def imap_connect():
imap.connect() imap.connect()

View File

@@ -8,11 +8,11 @@ from chatmaild.config import read_config
from cmdeploy.cmdeploy import main from cmdeploy.cmdeploy import main
def test_init(tmp_path, maildomain): def test_init(tmp_path, maildomain_sanitized):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain]) main(["init", "--config", str(inipath), maildomain_sanitized])
config = read_config(inipath) config = read_config(inipath)
assert config.mail_domain == maildomain assert config.mail_domain.strip("[").strip("]") == maildomain_sanitized
def test_capabilities(imap): def test_capabilities(imap):
@@ -89,18 +89,16 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config): def test_no_vrfy(chatmail_config):
ac = cmfactory.get_online_account()
addr = ac.get_config("addr")
domain = chatmail_config.mail_domain domain = chatmail_config.mail_domain
s = smtplib.SMTP(domain) s = smtplib.SMTP(domain.strip("[").strip("]"))
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
result = s.getreply() result = s.getreply()
print(result) print(result)
s.putcmd("vrfy", addr) s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
result2 = s.getreply() result2 = s.getreply()
print(result2) print(result2)
assert result[0] == result2[0] == 252 assert result[0] == result2[0] == 252

View File

@@ -10,31 +10,31 @@ def test_gen_qr_png_data(maildomain):
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config): def test_fastcgi_working(maildomain_sanitized, chatmail_config):
url = f"https://{maildomain}/new" url = f"https://{maildomain_sanitized}/new"
print(url) print(url)
verify = chatmail_config.tls_cert_mode == "acme" verify = chatmail_config.tls_cert_mode == "acme"
res = requests.post(url, verify=verify) res = requests.post(url, verify=verify)
assert maildomain in res.json().get("email") assert maildomain_sanitized in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length assert len(res.json().get("password")) > chatmail_config.password_min_length
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config): def test_newemail_configure(maildomain_sanitized, rpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain_sanitized}/new"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = rpc.add_account()
if chatmail_config.tls_cert_mode == "self": if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during # deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead # set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False) res = requests.post(f"https://{maildomain_sanitized}/new", verify=False)
data = res.json() data = res.json()
rpc.add_or_update_transport(account_id, { rpc.add_or_update_transport(account_id, {
"addr": data["email"], "addr": data["email"],
"password": data["password"], "password": data["password"],
"imapServer": maildomain, "imapServer": maildomain_sanitized,
"smtpServer": maildomain, "smtpServer": maildomain_sanitized,
"certificateChecks": "acceptInvalidCertificates", "certificateChecks": "acceptInvalidCertificates",
}) })
else: else:

View File

@@ -21,6 +21,8 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if "[" in maildomain:
pytest.skip("Relay doesn't have a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -131,7 +133,7 @@ def test_authenticated_from(cmsetup, maildata):
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) @pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr): def test_reject_missing_dkim(cmsetup, maildata, from_addr):
domain = cmsetup.maildomain domain = cmsetup.maildomain.strip("[").strip("]")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10) sock.settimeout(10)
try: try:
@@ -143,7 +145,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
msg = maildata( msg = maildata(
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr "encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
).as_string() ).as_string()
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10) conn = smtplib.SMTP(cmsetup.maildomain.strip("[").strip("]"), 25, timeout=10)
conn.starttls() conn.starttls()
with conn as s: with conn as s:

View File

@@ -6,8 +6,8 @@ import imap_tools
import pytest import pytest
import requests import requests
from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture @pytest.fixture
@@ -15,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
host = user.split("@")[1] host = user.split("@")[1].strip("[").strip("]")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
@@ -178,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1] host = addr.split("@")[1].strip("[").strip("]")
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)

View File

@@ -35,11 +35,6 @@ def pytest_runtest_setup(item):
def _get_chatmail_config(): def _get_chatmail_config():
inipath = os.environ.get("CHATMAIL_INI")
if inipath:
path = Path(inipath).resolve()
return read_config(path), path
current = Path().resolve() current = Path().resolve()
while 1: while 1:
path = current.joinpath("chatmail.ini").resolve() path = current.joinpath("chatmail.ini").resolve()
@@ -66,8 +61,13 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def sshdomain(maildomain): def maildomain_sanitized(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain) return maildomain.strip("[").strip("]")
@pytest.fixture(scope="session")
def sshdomain(maildomain_sanitized):
return os.environ.get("CHATMAIL_SSH", maildomain_sanitized)
@pytest.fixture @pytest.fixture
@@ -80,7 +80,7 @@ def maildomain2():
@pytest.fixture @pytest.fixture
def sshdomain2(maildomain2): def sshdomain2(maildomain2):
return os.environ.get("CHATMAIL_SSH2", maildomain2) return os.environ.get("CHATMAIL_SSH2", maildomain2.strip("[").strip("]"))
def pytest_report_header(): def pytest_report_header():
@@ -181,14 +181,14 @@ def ssl_context(chatmail_config):
@pytest.fixture @pytest.fixture
def imap(maildomain, ssl_context): def imap(maildomain_sanitized, ssl_context):
return ImapConn(maildomain, ssl_context=ssl_context) return ImapConn(maildomain_sanitized, ssl_context=ssl_context)
@pytest.fixture @pytest.fixture
def make_imap_connection(maildomain, ssl_context): def make_imap_connection(maildomain_sanitized, ssl_context):
def make_imap_connection(): def make_imap_connection():
conn = ImapConn(maildomain, ssl_context=ssl_context) conn = ImapConn(maildomain_sanitized, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -232,14 +232,14 @@ class ImapConn:
@pytest.fixture @pytest.fixture
def smtp(maildomain, ssl_context): def smtp(maildomain_sanitized, ssl_context):
return SmtpConn(maildomain, ssl_context=ssl_context) return SmtpConn(maildomain_sanitized, ssl_context=ssl_context)
@pytest.fixture @pytest.fixture
def make_smtp_connection(maildomain, ssl_context): def make_smtp_connection(maildomain_sanitized, ssl_context):
def make_smtp_connection(): def make_smtp_connection():
conn = SmtpConn(maildomain, ssl_context=ssl_context) conn = SmtpConn(maildomain_sanitized, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -326,8 +326,8 @@ class ChatmailACFactory:
"password": password, "password": password,
# Setting server explicitly skips requesting autoconfig XML, # Setting server explicitly skips requesting autoconfig XML,
# see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/ # see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/
"imapServer": domain, "imapServer": domain.strip("[").strip("]"),
"smtpServer": domain, "smtpServer": domain.strip("[").strip("]"),
} }
if self.chatmail_config.tls_cert_mode == "self": if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
@@ -393,15 +393,12 @@ def cmfactory(rpc, gencreds, maildomain, chatmail_config):
@pytest.fixture @pytest.fixture
def remote(sshdomain): def remote(sshdomain):
r = Remote(sshdomain) return Remote(sshdomain)
yield r
r.close()
class Remote: class Remote:
def __init__(self, sshdomain): def __init__(self, sshdomain):
self.sshdomain = sshdomain self.sshdomain = sshdomain
self._procs = []
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
@@ -411,32 +408,19 @@ class Remote:
case "localhost": command = [] case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"] case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( self.popen = subprocess.Popen(
command, command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
) )
self._procs.append(popen) while 1:
try: line = self.popen.stdout.readline()
while 1: res = line.decode().strip().lower()
line = popen.stdout.readline() if not res:
res = line.decode().strip().lower() break
if not res: if ready is not None:
break ready()
if ready is not None: ready = None
ready() yield res
ready = None
yield res
finally:
popen.terminate()
popen.wait()
def close(self):
while self._procs:
proc = self._procs.pop()
proc.kill()
proc.wait()
@pytest.fixture @pytest.fixture
@@ -475,7 +459,7 @@ class CMSetup:
class CMUser: class CMUser:
def __init__(self, maildomain, addr, password, ssl_context=None): def __init__(self, maildomain, addr, password, ssl_context=None):
self.maildomain = maildomain self.maildomain = maildomain.strip("[").strip("]")
self.addr = addr self.addr = addr
self.password = password self.password = password
self.ssl_context = ssl_context self.ssl_context = ssl_context

View File

@@ -23,19 +23,15 @@ class TestCmdline:
run = parser.parse_args(["run"]) run = parser.parse_args(["run"])
assert init and run assert init and run
def test_init_not_overwrite(self, capsys, tmp_path, monkeypatch): def test_init_not_overwrite(self, capsys):
monkeypatch.delenv("CHATMAIL_INI", raising=False) assert main(["init", "chat.example.org"]) == 0
inipath = tmp_path / "chatmail.ini"
args = ["init", "--config", str(inipath), "chat.example.org"]
assert main(args) == 0
capsys.readouterr() capsys.readouterr()
assert main(args) == 1 assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "path exists" in out.lower() assert "path exists" in out.lower()
args.insert(1, "--force") assert main(["init", "chat.example.org", "--force"]) == 0
assert main(args) == 0
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() assert "deleting config file" in out.lower()

View File

@@ -3,7 +3,7 @@ from copy import deepcopy
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records from cmdeploy.dns import check_full_zone, check_initial_remote_data
@pytest.fixture @pytest.fixture
@@ -125,49 +125,18 @@ class TestPerformInitialChecks:
assert not l assert not l
def test_parse_zone_records():
text = """
; This is a comment
some.domain. 3600 IN A 1.1.1.1
; Another comment
www.some.domain. 3600 IN CNAME some.domain.
; Multi-word rdata
some.domain. 3600 IN MX 10 mail.some.domain.
; DKIM record (single line, multi-word TXT rdata)
dkim._domainkey.some.domain. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
; Another TXT record
_dmarc.some.domain. 3600 IN TXT "v=DMARC1;p=reject"
"""
records = list(parse_zone_records(text))
assert records == [
("some.domain", "3600", "A", "1.1.1.1"),
("www.some.domain", "3600", "CNAME", "some.domain."),
("some.domain", "3600", "MX", "10 mail.some.domain."),
(
"dkim._domainkey.some.domain",
"3600",
"TXT",
'"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"',
),
("_dmarc.some.domain", "3600", "TXT", '"v=DMARC1;p=reject"'),
]
def test_parse_zone_records_invalid_line():
text = "invalid line"
with pytest.raises(ValueError, match="Bad zone record line"):
list(parse_zone_records(text))
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False): def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
if only_required: for zf_line in zonefile.split("\n"):
zonefile = zonefile.split("; Recommended")[0] if zf_line.startswith("#"):
for name, ttl, rtype, rdata in parse_zone_records(zonefile): if "Recommended" in zf_line and only_required:
mockdns_base.setdefault(rtype, {})[name] = rdata return
continue
if not zf_line.strip():
continue
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
mockdns_base.setdefault(zf_typ, {})[zf_domain] = zf_value
class MockSSHExec: class MockSSHExec: