Compare commits

..

8 Commits

34 changed files with 274 additions and 856 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,125 +0,0 @@
name: Docker deploy
on:
workflow_call:
inputs:
staging_host:
required: true
type: string
description: 'SSH hostname (e.g. staging2.testrun.org)'
mail_domain:
required: true
type: string
description: 'MAIL_DOMAIN for docker compose'
zone_file:
required: true
type: string
description: 'Default zone file basename (e.g. staging.testrun.org-default.zone)'
jobs:
deploy-docker:
name: Docker deploy on ${{ inputs.staging_host }}
runs-on: ubuntu-latest
timeout-minutes: 20
environment:
name: ${{ inputs.staging_host }}
url: https://${{ inputs.staging_host }}/
concurrency: ${{ inputs.staging_host }}
env:
VPS: root@${{ inputs.staging_host }}
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Setup SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan ${{ inputs.staging_host }} > ~/.ssh/known_hosts
# Reuse TCP connection for all subsequent ssh/scp calls
echo -e "Host ${{ inputs.staging_host }}\n ControlMaster auto\n ControlPath ~/.ssh/ctrl-%r@%h:%p\n ControlPersist 10m" >> ~/.ssh/config
- name: stop bare services, install Docker, prepare mounts
run: |
ssh $VPS bash -s <<'EOF'
systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
mkdir -p /srv/chatmail/certs /srv/chatmail/dkim
cp -a /var/lib/acme/. /srv/chatmail/certs/ || true
cp -a /etc/dkimkeys/. /srv/chatmail/dkim/ || true
cp /etc/chatmail/chatmail.ini /srv/chatmail/chatmail.ini
EOF
- name: deploy with Docker
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
GHCR_IMAGE="ghcr.io/chatmail/docker:sha-${SHORT_SHA}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ $VPS:/srv/chatmail/relay/
echo "${{ secrets.GITHUB_TOKEN }}" | ssh $VPS "docker login ghcr.io -u ${{ github.actor }} --password-stdin && \
docker pull ${GHCR_IMAGE} && \
cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=${{ inputs.mail_domain }} docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d"
- name: wait for container healthy
run: |
ssh $VPS 'docker exec chatmail journalctl -f --no-pager' &
LOG_PID=$!
trap "kill $LOG_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
status=$(ssh $VPS 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing")
echo " [$i/60] status=$status"
if [ "$status" = "healthy" ]; then
echo "Container is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ]; then
echo "Container is unhealthy!"
break
fi
sleep 5
done
echo "Container did not become healthy."
kill $LOG_PID 2>/dev/null || true
ssh $VPS bash -s <<'EOF'
echo "--- failed units ---"
docker exec chatmail systemctl --failed --no-pager || true
echo "--- service logs ---"
docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50 || true
echo "--- listening ports ---"
docker exec chatmail ss -tlnp || true
echo "--- chatmail.ini ---"
docker exec chatmail cat /etc/chatmail/chatmail.ini || true
EOF
exit 1
- name: show container state
run: |
ssh $VPS bash -s <<'EOF'
echo "--- listening ports ---"
docker exec chatmail ss -tlnp
echo "--- chatmail.ini ---"
docker exec chatmail cat /etc/chatmail/chatmail.ini
EOF
- name: Docker integration tests
run: ssh $VPS 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
- name: Docker DNS
run: |
git checkout .github/workflows/${{ inputs.zone_file }}
ssh $VPS bash -s <<'EOF'
docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys
docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose
docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone
EOF
scp $VPS:/tmp/staging.zone staging-generated.zone
cat staging-generated.zone >> .github/workflows/${{ inputs.zone_file }}
cat .github/workflows/${{ inputs.zone_file }}
scp .github/workflows/${{ inputs.zone_file }} root@ns.testrun.org:/etc/nsd/${{ inputs.staging_host }}.zone
ssh root@ns.testrun.org "nsd-checkzone ${{ inputs.staging_host }} /etc/nsd/${{ inputs.staging_host }}.zone && systemctl reload nsd"
- name: Docker final DNS check
run: ssh $VPS 'docker exec chatmail cmdeploy dns -v --ssh-host @local'

View File

@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- j4n/docker-pr
pull_request: pull_request:
paths-ignore: paths-ignore:
- 'scripts/**' - 'scripts/**'
@@ -13,11 +12,6 @@ on:
- 'LICENSE' - 'LICENSE'
jobs: jobs:
trigger-docker-build:
if: github.event_name == 'push'
uses: ./.github/workflows/trigger-docker-build.yaml
secrets: inherit
deploy: deploy:
name: deploy on staging-ipv4.testrun.org, and run tests name: deploy on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -28,8 +22,6 @@ jobs:
concurrency: staging-ipv4.testrun.org concurrency: staging-ipv4.testrun.org
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
submodules: true
- name: prepare SSH - name: prepare SSH
run: | run: |
@@ -71,13 +63,13 @@ jobs:
# download acme & dkim state from ns.testrun.org # download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging-ipv4.testrun.org # restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
- name: setup dependencies - name: setup dependencies
run: | run: |
@@ -110,12 +102,3 @@ jobs:
- name: cmdeploy dns - name: cmdeploy dns
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost" run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
deploy-docker:
needs: [deploy, trigger-docker-build]
if: github.event_name == 'push'
uses: ./.github/workflows/docker-deploy.yaml
with:
staging_host: staging-ipv4.testrun.org
mail_domain: staging-ipv4.testrun.org
zone_file: staging-ipv4.testrun.org-default.zone
secrets: inherit

View File

@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- j4n/docker-pr
pull_request: pull_request:
paths-ignore: paths-ignore:
- 'scripts/**' - 'scripts/**'
@@ -13,11 +12,6 @@ on:
- 'LICENSE' - 'LICENSE'
jobs: jobs:
trigger-docker-build:
if: github.event_name == 'push'
uses: ./.github/workflows/trigger-docker-build.yaml
secrets: inherit
deploy: deploy:
name: deploy on staging2.testrun.org, and run tests name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -101,12 +95,3 @@ jobs:
- name: cmdeploy dns - name: cmdeploy dns
run: cmdeploy dns -v run: cmdeploy dns -v
deploy-docker:
needs: [deploy, trigger-docker-build]
if: github.event_name == 'push'
uses: ./.github/workflows/docker-deploy.yaml
with:
staging_host: staging2.testrun.org
mail_domain: staging2.testrun.org
zone_file: staging.testrun.org-default.zone
secrets: inherit

View File

@@ -1,24 +0,0 @@
name: Trigger Docker image build
on:
workflow_call:
jobs:
trigger-docker-build:
name: Trigger Docker image build
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v7
with:
github-token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
script: |
await github.rest.repos.createDispatchEvent({
owner: 'chatmail',
repo: 'docker',
event_type: 'relay-updated',
client_payload: {
relay_ref: context.ref,
relay_sha: context.sha,
relay_sha_short: context.sha.slice(0, 7)
}
})

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

@@ -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,10 +1,7 @@
import importlib.resources import importlib.resources
import io import io
import os import os
from contextlib import contextmanager
from pyinfra import host
from pyinfra.facts.server import Command
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
@@ -13,39 +10,6 @@ def has_systemd():
return os.path.isdir("/run/systemd/system") return os.path.isdir("/run/systemd/system")
def is_in_container() -> bool:
"""Return True if running inside a container (Docker, LXC, etc.)."""
return (
host.get_fact(
Command,
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
)
== "yes"
)
@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,14 +101,17 @@ 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()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host == "localhost": if ssh_host in ["localhost", "@docker"]:
if ssh_host == "@docker":
env["CHATMAIL_NOPORTCHECK"] = "True"
env["CHATMAIL_NOSYSCTL"] = "True"
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -207,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
@@ -314,7 +316,7 @@ def add_ssh_host_option(parser):
parser.add_argument( parser.add_argument(
"--ssh-host", "--ssh-host",
dest="ssh_host", dest="ssh_host",
help="Run commands on 'localhost' or on a specific SSH host " help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
"instead of chatmail.ini's mail_domain.", "instead of chatmail.ini's mail_domain.",
) )
@@ -376,7 +378,9 @@ def get_parser():
def get_sshexec(ssh_host: str, verbose=True): def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]: if ssh_host in ["localhost", "@local"]:
return LocalExec(verbose) return LocalExec(verbose, docker=False)
elif ssh_host == "@docker":
return LocalExec(verbose, docker=True)
if verbose: if verbose:
print(f"[ssh] login to {ssh_host}") print(f"[ssh] login to {ssh_host}")
return SSHExec(ssh_host, verbose=verbose) return SSHExec(ssh_host, verbose=verbose)

View File

@@ -2,6 +2,7 @@
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -23,11 +24,9 @@ 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,
is_in_container,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer from .external.deployer import ExternalTlsDeployer
@@ -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(
@@ -584,7 +593,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1) exit(1)
if not is_in_container(): if not os.environ.get("CHATMAIL_NOPORTCHECK"):
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
@@ -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,33 +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, 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,
is_in_container, has_systemd,
) )
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
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
@@ -39,43 +26,15 @@ 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, changed = _download_dovecot_package(pkg, arch) _install_dovecot_package("imapd", arch)
self.need_restart |= changed _install_dovecot_package("lmtpd", arch)
if deb:
debs.append(deb)
if debs:
deb_list = " ".join(debs)
# First dpkg may fail on missing dependencies (stderr suppressed);
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
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}",
],
)
self.need_restart = True
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",
user="root",
group="root",
mode="644",
)
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
config_restart, self.daemon_reload = _configure_dovecot(self.config) self.need_restart, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= config_restart
def activate(self): def activate(self):
activate_remote_units(self.units) activate_remote_units(self.units)
@@ -104,40 +63,43 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]: def _install_dovecot_package(package: str, arch: str):
"""Download a dovecot .deb if needed, return (path, changed)."""
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:
op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False))
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_PACKAGE_VERSION in installed_versions:
return None, False
url_version = DOVECOT_ARCHIVE_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, True apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]: def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False need_restart = False
daemon_reload = False daemon_reload = False
@@ -172,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 = not is_in_container() 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

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

@@ -87,8 +87,9 @@ class SSHExec:
class LocalExec: class LocalExec:
FuncError = FuncError FuncError = FuncError
def __init__(self, verbose=False): def __init__(self, verbose=False, docker=False):
self.verbose = verbose self.verbose = verbose
self.docker = docker
def __call__(self, call, kwargs=None, log_callback=None): def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None: if kwargs is None:
@@ -100,6 +101,10 @@ class LocalExec:
if not title: if not title:
title = call.__name__ title = call.__name__
where = "locally" where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail "
where = "in docker"
if self.verbose: if self.verbose:
print_stderr(f"Running {where}: {title}(**{kwargs})") print_stderr(f"Running {where}: {title}(**{kwargs})")
return self(call, kwargs, log_callback=print_stderr) return self(call, kwargs, log_callback=print_stderr)

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)
) )
@@ -71,44 +73,6 @@ class TestSSHExecutor:
assert (now - since_date).total_seconds() < 60 * 60 * 51 assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_dovecot_main_process_matches_installed_binary(sshdomain):
sshexec = get_sshexec(sshdomain)
main_pid = int(
sshexec(
call=remote.rshell.shell,
kwargs=dict(
command="timeout 10 systemctl show -p MainPID --value dovecot.service"
),
).strip()
)
assert main_pid != 0, "dovecot.service MainPID is 0 -- service not running?"
exe = sshexec(
call=remote.rshell.shell,
kwargs=dict(command=f"timeout 10 readlink /proc/{main_pid}/exe"),
).strip()
status_text = sshexec(
call=remote.rshell.shell,
kwargs=dict(
command="timeout 10 systemctl show -p StatusText --value dovecot.service"
),
).strip()
installed_version = sshexec(
call=remote.rshell.shell, kwargs=dict(command="timeout 10 dovecot --version")
).strip()
assert not exe.endswith("(deleted)"), (
f"running dovecot binary was deleted (stale after upgrade): {exe}"
)
expected_status_text = f"v{installed_version}"
assert status_text == expected_status_text or status_text.startswith(
f"{expected_status_text} "
), (
f"dovecot status version mismatch: "
f"StatusText={status_text!r}, installed={installed_version!r}"
)
def test_timezone_env(remote): def test_timezone_env(remote):
for line in remote.iter_output("env"): for line in remote.iter_output("env"):
print(line) print(line)
@@ -169,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:
@@ -181,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:

View File

@@ -1,238 +0,0 @@
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from cmdeploy.dovecot import deployer as dovecot_deployer
from pyinfra.facts.deb import DebPackages
def make_host(*fact_pairs):
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
Args:
*fact_pairs: tuples of (fact_class, fact_value) to register
Returns:
SimpleNamespace with get_fact that raises a clear error if an
unexpected fact type is requested.
"""
facts = dict(fact_pairs)
def get_fact(cls):
if cls not in facts:
registered = ", ".join(c.__name__ for c in facts)
raise LookupError(
f"unexpected get_fact({cls.__name__}); "
f"only registered: {registered}"
)
return facts[cls]
return SimpleNamespace(get_fact=get_fact)
@pytest.fixture
def deployer():
return dovecot_deployer.DovecotDeployer(
SimpleNamespace(mail_domain="chat.example.org"),
disable_mail=False,
)
@pytest.fixture
def patch_blocked(monkeypatch):
monkeypatch.setattr(dovecot_deployer, "blocked_service_startup", nullcontext)
@pytest.fixture
def mock_files_put(monkeypatch):
monkeypatch.setattr(
dovecot_deployer.files,
"put",
lambda **kwargs: SimpleNamespace(changed=False),
)
@pytest.fixture
def track_shell(monkeypatch):
calls = []
monkeypatch.setattr(
dovecot_deployer.server,
"shell",
lambda **kwargs: calls.append(kwargs) or SimpleNamespace(changed=False),
)
return calls
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
downloads = []
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host((DebPackages, {"dovecot-core": [epoch_version]})),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed"
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch,
):
downloads = []
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host((DebPackages, {})),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
# Verify the returned path uses archive version, not package version (with epoch)
assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
f"deb path should use archive version (no epoch), got {deb!r}"
)
assert len(downloads) == 1, "files.download should be called exactly once"
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(
dovecot_deployer.DebPackages,
{
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
},
),
(dovecot_deployer.Arch, "x86_64"),
),
)
downloads = []
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deployer.install()
assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, (
"need_restart should be False when nothing changed"
)
def test_install_unsupported_arch_falls_back_to_apt(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr(
dovecot_deployer,
"host",
SimpleNamespace(get_fact=lambda cls: "riscv64"),
)
apt_calls = []
# Mirrors apt.packages() return value: OperationMeta with .changed property.
# Only lmtpd triggers a change to verify |= accumulation of changed flags.
def fake_apt(**kwargs):
apt_calls.append(kwargs)
changed = "lmtpd" in kwargs["packages"][0]
return SimpleNamespace(changed=changed)
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
deployer.install()
actual_pkgs = [c["packages"] for c in apt_calls]
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
)
assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package"
)
def test_install_runs_dpkg_when_packages_need_download(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"),
),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: SimpleNamespace(changed=True),
)
deployer.install()
assert len(track_shell) == 1, (
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
cmds = track_shell[0]["commands"]
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
assert "apt-get -y --fix-broken install" in cmds[1]
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
assert deployer.need_restart is True, (
"need_restart should be True after dpkg install"
)
def test_pick_url_falls_back_on_primary_error(monkeypatch):
def raise_error(req, timeout):
raise OSError("connection timeout")
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", (
f"should fall back when primary fails, got {result!r}"
)

View File

@@ -98,13 +98,6 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Docker installation
-------------------
There is experimental support for running chatmail via Docker Compose.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_
for full setup instructions.
Other helpful commands Other helpful commands
---------------------- ----------------------