Compare commits

..

16 Commits

Author SHA1 Message Date
missytake
c78cddab7a Revert "expanding ~ doesn't work with -i"
This reverts commit 4c62714fbb.
2026-03-11 21:04:07 +01:00
missytake
4c62714fbb expanding ~ doesn't work with -i 2026-03-11 20:45:06 +01:00
missytake
7918984888 ci: use proper SSH key 2026-03-11 19:22:52 +01:00
missytake
3116da879f fix CHATMAIL_DOMAIN2 address 2026-03-10 14:21:02 +01:00
missytake
195142acc6 CI: fix SSH key path 2026-03-10 14:04:21 +01:00
missytake
8a5bb18a2b supply Hetzner API token 2026-03-10 14:00:56 +01:00
missytake
79eebe2762 use with syntax properly 2026-03-10 13:54:02 +01:00
missytake
d9de25e4fd checkout hetzner-relay repo properly 2026-03-10 13:53:02 +01:00
missytake
3eaadfbbee chatmail.ini is generated in hetzner-relay 2026-03-10 13:50:11 +01:00
missytake
d321f01d20 cmdeploy offline tests already run in ci.yml 2026-03-10 13:46:19 +01:00
missytake
94a645e441 don't upload hpk's key 2026-03-10 13:45:05 +01:00
missytake
3931dde099 CI: try to run hetzner-relay without importing reusable actions 2026-03-10 13:42:43 +01:00
missytake
8d08b98eda test example workflow 2026-03-10 13:34:29 +01:00
missytake
65b6eebdce retrigger workflow 2026-03-10 13:23:23 +01:00
missytake
1d0f4a4de4 fix repo URL 2026-03-10 13:20:29 +01:00
missytake
b2900cbc8c CI: use hetzner-relay github actions 2026-03-10 13:18:25 +01:00
25 changed files with 205 additions and 407 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

@@ -1,4 +1,4 @@
name: deploy on staging2.testrun.org, and run tests name: deploy on allocated VPS and run tests
on: on:
push: push:
@@ -12,86 +12,27 @@ on:
- 'LICENSE' - 'LICENSE'
jobs: jobs:
deploy: deploy-and-test:
name: deploy on staging2.testrun.org, and run tests name: deploy on allocated VPS, and run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 15
environment:
name: staging2.testrun.org
url: https://staging2.testrun.org/
concurrency: staging2.testrun.org
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
repository: chatmail/hetzner-relay
path: hetzner-relay
- uses: actions/checkout@v4
with:
path: relay
- name: prepare SSH - name: prepare SSH
run: | run: |
mkdir ~/.ssh mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519 echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/staging.testrun.org
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/staging.testrun.org
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging2.testrun.org to have a clean VPS - run: pip install hetzner-relay/
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh - run: python3 hetzner-relay/main.py -i ~/.ssh/staging.testrun.org --dns --test --rebuild --run-id $GITHUB_RUN_ID --domain2 ci-chatmail.testrun.org --hetzner-api-token ${{ secrets.HETZNER_API_TOKEN }} relay/
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: add hpk42 key to staging server
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

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

@@ -38,7 +38,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")

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

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

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

@@ -70,6 +70,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
@@ -88,22 +88,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_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }} virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup

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

@@ -89,9 +89,7 @@ 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)
@@ -100,7 +98,7 @@ def test_no_vrfy(cmfactory, chatmail_config):
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

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

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()
@@ -393,15 +388,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 +403,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

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: