Compare commits

..

5 Commits

Author SHA1 Message Date
j4n 33c85ff58a dovecot: security backports, new versioning scheme
- Fix dovecot package download URLs for new
  [release](https://github.com/chatmail/dovecot/releases/tag/upstream%2F2.3.21%2Bdfsg1-3%2Bchatmail2)
  with
    - debian-security backport for 12 CVEs
    - distro-specific suffix (+deb{release}u1), enabling a simplified
      primary URL path and combined github releases
- Use VERSION_ID from os-release as deb_release instead of codename
  mapping, reorder hash-dict to match Github release page
- Remove redundant parsing/validation, let function validate against hash dict
- Update test expectations and test new versioning derivation
2026-08-10 13:39:55 +02:00
j4n 6b872446e1 fix(cmdeploy): check venv python versions and purge if mismatched
`cmdeploy run` fails after system upgrade to Debian 13 with "Fatal Python
error: init_fs_encoding: failed to get the Python codec of the filesystem
encoding" indicating a Python version missmatch. Check for both versions and
remove old `remote_venv_dir` on mismatch to allow clean reinitialization by
subsequent pip.virtualenv().
2026-08-10 10:45:46 +02:00
j4n f0fe7256b7 ci: temporarily build docker packages for bookworm branch 2026-08-10 10:44:51 +02:00
j4n ab14cc7319 dovecot: add multi-dist/Debian trixie support
- Install .debs via apt-get install instead of dpkg+fix-broken
- Package hashes are now keyed by (arch, codename, pkg):
  - download.delta.chat uploads now go to dovecot/{distro}/{version}/
  - GitHub release packages get a _{distro}.deb suffix to allow for
    combined releases.

Tests:
- updated to support this and add a test to check for the unsupported
  release version case
- fix make_host to accept extra args from Command fact
- assert single apt-get install command
2026-08-10 10:44:51 +02:00
j4n 53b5a8189b dovecot: pin dovecot-* to priority -1 before any apt operation
Prevent Trixie from somehow pulling in dovecot 2.4 before we get to install.
2026-08-10 10:44:51 +02:00
13 changed files with 195 additions and 307 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ name: Trigger Docker build
on: on:
push: push:
branches: [main] branches: [main, j4n/dovecot-multidist]
tags: ['[0-9]+.[0-9]+.[0-9]+'] tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch: workflow_dispatch:
-3
View File
@@ -5,6 +5,3 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/ [git-cliff]: https://git-cliff.org/
To update client app version information,
edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json).
-1
View File
@@ -1,4 +1,3 @@
include src/chatmaild/defaults/*.json
include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/* include src/chatmaild/tests/mail-data/*
@@ -1,15 +0,0 @@
{
"clients": [
{
"clientId": "deltachat",
"sources": [
{
"sourceId": "gplay",
"versionInteger": 754,
"versionString": "2.57.0",
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.57.0/deltachat-gplay-release-2.57.0.apk"
}
]
}
]
}
-18
View File
@@ -1,10 +1,8 @@
import json
import logging import logging
import socket import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
@@ -20,18 +18,6 @@ def turn_credentials(turn_socket_path):
return file.readline().decode("utf-8").strip() return file.readline().decode("utf-8").strip()
def read_appversions(path):
try:
data = json.loads(path.read_bytes())
except FileNotFoundError:
return None
except (OSError, ValueError):
logging.exception(f"failed to read {path}")
return None
# the dict protocol is line-based, keep the value single-line
return json.dumps(data, separators=(",", ":"))
def _is_valid_token_timestamp(timestamp, now): def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days # Token if invalid after 90 days
# or if the timestamp is in the future. # or if the timestamp is in the future.
@@ -115,7 +101,6 @@ class MetadataDictProxy(DictProxy):
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path self.turn_socket_path = turn_socket_path
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -140,9 +125,6 @@ class MetadataDictProxy(DictProxy):
case "maxsmtprecipients": case "maxsmtprecipients":
# postfix default (see "postconf smtpd_recipient_limit") # postfix default (see "postconf smtpd_recipient_limit")
return "O1000\n" return "O1000\n"
case "appversions":
value = read_appversions(self.appversions_path)
return f"O{value}\n" if value else "N\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -1,96 +0,0 @@
import json
import pytest
from chatmaild.metadata import MetadataDictProxy
ALLOWED_URL_PREFIXES = (
"https://github.com/deltachat/",
"https://download.delta.chat/",
)
def check_string(value):
assert isinstance(value, str), value
assert value
def check_version_integer(value):
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
assert isinstance(value, int) and not isinstance(value, bool), value
assert 0 <= value < 2**32, value
def check_appversions(data):
"""Verifies the file the way core parses it.
core deserializes into typed structs and drops the whole payload
of a relay if a single value has an unexpected type,
while missing or misspelled keys silently turn into defaults.
"""
assert set(data) == {"clients"}, data
assert isinstance(data["clients"], list)
assert data["clients"]
client_ids = []
for client in data["clients"]:
assert set(client) == {"clientId", "sources"}, client
check_string(client["clientId"])
client_ids.append(client["clientId"])
assert isinstance(client["sources"], list)
assert client["sources"]
source_ids = []
for source in client["sources"]:
assert set(source) == {
"sourceId",
"versionInteger",
"versionString",
"downloadUrl",
}, source
check_string(source["sourceId"])
source_ids.append(source["sourceId"])
check_version_integer(source["versionInteger"])
check_string(source["versionString"])
check_string(source["downloadUrl"])
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
# core takes the first matching source, later duplicates never surface
assert len(set(source_ids)) == len(source_ids), source_ids
assert len(set(client_ids)) == len(client_ids), client_ids
@pytest.fixture
def appversions():
# check the file which chatmail-metadata actually serves
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
return json.loads(path.read_text())
def test_appversions_schema(appversions):
check_appversions(appversions)
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
def test_version_integer_rejected(appversions, value):
appversions["clients"][0]["sources"][0]["versionInteger"] = value
with pytest.raises(AssertionError):
check_appversions(appversions)
@pytest.mark.parametrize("key", ["clientId", "sources"])
def test_misspelled_client_key_rejected(appversions, key):
client = appversions["clients"][0]
client[key + "s"] = client.pop(key)
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_duplicate_source_id_rejected(appversions):
sources = appversions["clients"][0]["sources"]
sources.append(dict(sources[0]))
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_foreign_download_url_rejected(appversions):
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
with pytest.raises(AssertionError):
check_appversions(appversions)
@@ -1,5 +1,4 @@
import io import io
import json
import time import time
import pytest import pytest
@@ -8,7 +7,6 @@ import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, MetadataDictProxy,
read_appversions,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -371,32 +369,6 @@ def test_iroh_relay(dictproxy):
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_read_appversions(tmp_path):
path = tmp_path.joinpath("appversions.json")
assert read_appversions(path) is None
path.write_text('{\n "clients": []\n}')
assert read_appversions(path) == '{"clients":[]}'
# the value travels as a single dict protocol line
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
path.write_text("bad json")
assert read_appversions(path) is None
def test_appversions_lookup(dictproxy):
# the version information shipped with chatmaild is served as a single line
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
key += b"\tuser@example.org"
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
value = wfile.getvalue()
assert value.startswith(b"O") and value.endswith(b"\n")
assert json.loads(value[1:])["clients"]
def test_legacy_token_migration(metadata, testaddr): def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data: with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"] data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
+23
View File
@@ -98,6 +98,23 @@ def _install_remote_venv_with_chatmaild(deployer) -> None:
dest=remote_dist_file, dest=remote_dist_file,
) )
# Remove venv if its Python major.minor doesn't match the system Python
server.shell(
name="remove stale chatmaild venv if python version changed",
commands=[
"\n".join(
[
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
# an empty sys_version means we could not tell: keep the venv
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
f"|| rm -rf {remote_venv_dir}",
]
)
],
)
pip.virtualenv( pip.virtualenv(
name=f"chatmaild virtualenv {remote_venv_dir}", name=f"chatmaild virtualenv {remote_venv_dir}",
path=remote_venv_dir, path=remote_venv_dir,
@@ -406,6 +423,12 @@ class ChatmailDeployer(Deployer):
src=BytesIO(b'APT::Install-Recommends "false";\n'), src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends", dest="/etc/apt/apt.conf.d/00InstallRecommends",
) )
# Pin dovecot-* to priority -1 before any apt operation, apt should
# never manage dovecot as our version might be lower than the distro's.
self.put_file(
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
dest="/etc/apt/preferences.d/pin-dovecot",
)
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
+66 -39
View File
@@ -1,11 +1,10 @@
import io
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.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.operations import apt, files, server from pyinfra.operations import files, server
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
@@ -15,16 +14,31 @@ from cmdeploy.basedeploy import (
is_in_container, is_in_container,
) )
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3" # distro-neutral base version, as committed in chatmail/dovecot debian/changelog
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}" DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3+chatmail2"
VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release"
def _stamped_version(deb_release: int) -> str:
"""Version as built, including the per-distro suffix stamped by
chatmail/dovecot CI into package version and filename."""
return f"{DOVECOT_ARCHIVE_VERSION}+deb{deb_release}u1"
DOVECOT_SHA256 = { DOVECOT_SHA256 = {
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", ("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", ("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", ("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", ("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", ("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
} }
@@ -38,34 +52,30 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
with blocked_service_startup(): with blocked_service_startup():
debs = [] debs = []
for pkg in ("core", "imapd", "lmtpd"): for pkg in ("core", "imapd", "lmtpd"):
deb, changed = _download_dovecot_package(pkg, arch) deb, changed = _download_dovecot_package(pkg, arch, deb_release)
self.need_restart |= changed self.need_restart |= changed
if deb: if deb:
debs.append(deb) debs.append(deb)
if debs: if debs:
deb_list = " ".join(debs) deb_list = " ".join(debs)
# First dpkg may fail on missing dependencies (stderr suppressed); # apt-get install with local .deb paths resolves depends
# apt-get --fix-broken pulls them in, then dpkg retries cleanly. # against the configured repos (e.g. pulls libwrap0),
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
server.shell( server.shell(
name="Install dovecot packages", name="Install dovecot packages",
commands=[ commands=[
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true", "DEBIAN_FRONTEND=noninteractive apt-get install -y "
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install", '-o Dpkg::Options::="--force-confdef" '
f"dpkg --force-confdef --force-confold -i {deb_list}", '-o Dpkg::Options::="--force-confold" '
f"--allow-downgrades {deb_list}",
], ],
) )
self.need_restart = True self.need_restart = True
self.put_file(
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, self.config.mail_domain_bare, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
@@ -78,7 +88,7 @@ class DovecotDeployer(Deployer):
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
stale = host.get_fact( stale = host.get_fact(
Command, Command,
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);' "pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"' ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true", " && echo STALE || true",
) )
@@ -93,6 +103,15 @@ class DovecotDeployer(Deployer):
) )
def _parse_version_id(version_line: str) -> int:
"""Debian major release from an /etc/os-release VERSION_ID line."""
_, _, raw = (version_line or "").strip().partition("=")
try:
return int(raw.strip('"'))
except ValueError:
raise ValueError(f"cannot determine Debian release from {version_line!r}")
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
try: try:
req = urllib.request.Request(primary, method="HEAD") req = urllib.request.Request(primary, method="HEAD")
@@ -102,27 +121,36 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]: def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]:
"""Download a dovecot .deb if needed, return (path, changed).""" """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
pkg_name = f"dovecot-{package}" pkg_name = f"dovecot-{package}"
sha256 = DOVECOT_SHA256.get((package, arch)) try:
if sha256 is None: # never fall back to the distro package: it is pinned to -1 and would
op = apt.packages(packages=[pkg_name]) # in any case be a version we did not build and do not support
return None, bool(getattr(op, "changed", False)) sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
except KeyError:
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
stamped_version = _stamped_version(deb_release)
installed_versions = host.get_fact(DebPackages).get(pkg_name, []) installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if DOVECOT_PACKAGE_VERSION in installed_versions: if f"1:{stamped_version}" in installed_versions:
return None, False return None, False
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") # Primary URL: flat structure with distro suffix in filename
deb_base = f"{pkg_name}_{url_version}_{arch}.deb" primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{deb_base}" primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}" # GitHub release files: escaped + in filename; the release tag stays
# distro-neutral, both distros ship in one combined release
tag_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
)
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{deb_base}" deb_filename = f"/root/{primary_deb}"
files.download( files.download(
name=f"Download {pkg_name}", name=f"Download {pkg_name}",
@@ -134,6 +162,7 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
return deb_filename, True return deb_filename, True
def _configure_dovecot(deployer, config: Config, debug: bool = False): def _configure_dovecot(deployer, config: Config, debug: bool = False):
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
deployer.put_template( deployer.put_template(
@@ -144,9 +173,7 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf") deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
deployer.put_file( deployer.put_file("dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua")
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
)
# 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
@@ -1,12 +1,10 @@
import ipaddress import ipaddress
import json
import re import re
import time import time
import imap_tools import imap_tools
import pytest import pytest
import requests import requests
from chatmaild.tests.test_appversions import check_appversions
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
@@ -53,17 +51,6 @@ class TestMetadataTokens:
assert res == b"1111 2222" assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline() assert b"Getmetadata completed" in client.readline()
def test_get_appversions(self, imap_mailbox):
"get app version information shipped with the relay"
client = imap_mailbox.client
client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n')
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
# the served value is a single line and passes the shipped file's schema
check_appversions(json.loads(res))
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance." "Tests that use Delta Chat accounts on the chat mail instance."
@@ -3,29 +3,40 @@ from types import SimpleNamespace
import pytest import pytest
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer from cmdeploy.dovecot import deployer as dovecot_deployer
def _fact_name(key):
if isinstance(key, tuple):
return f"{key[0].__name__}{key[1:]!r}"
return key.__name__
def make_host(*fact_pairs): def make_host(*fact_pairs):
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping. """Build a mock host; get_fact() dispatches to the provided facts mapping.
Args: Args:
*fact_pairs: tuples of (fact_class, fact_value) to register *fact_pairs: (fact_class, value) to match any call of that fact, or
((fact_class, *args), value) to match one specific call. Needed
for Command, which install() and check_restart() invoke with
different scripts; a bare Command entry would serve both.
Returns: Returns:
SimpleNamespace with get_fact that raises a clear error if an SimpleNamespace with get_fact that raises a clear error if an
unexpected fact type is requested. unregistered fact is requested.
""" """
facts = dict(fact_pairs) facts = dict(fact_pairs)
def get_fact(cls): def get_fact(cls, *args):
if cls not in facts: for key in ((cls, *args), cls):
registered = ", ".join(c.__name__ for c in facts) if key in facts:
raise LookupError( return facts[key]
f"unexpected get_fact({cls.__name__}); only registered: {registered}" registered = ", ".join(_fact_name(k) for k in facts)
) raise LookupError(
return facts[cls] f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}"
)
return SimpleNamespace(get_fact=get_fact) return SimpleNamespace(get_fact=get_fact)
@@ -64,7 +75,9 @@ def track_shell(monkeypatch):
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch): def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION # what dpkg reports after installing our deb: epoch + the +debNu1 suffix
# that chatmail/dovecot CI stamps via dch before building
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
@@ -82,15 +95,17 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
assert deb is None, f"expected no deb path when version matches, got {deb!r}" 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 changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed" assert downloads == [], "should not download when version already installed"
@pytest.mark.parametrize("deb_release", [12, 13])
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
def test_download_dovecot_package_uses_archive_version_for_url_and_filename( def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch, monkeypatch, deb_release, arch
): ):
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -109,18 +124,26 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64") deb, changed = dovecot_deployer._download_dovecot_package(
"core", arch, deb_release=deb_release
)
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") stamped = dovecot_deployer._stamped_version(deb_release)
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb" expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb"
# Verify the returned path uses archive version, not package version (with epoch) # path uses the stamped version, and deb filenames never carry the epoch
assert changed is True, "should flag changed when package not yet installed" 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 deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, ( assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}"
f"deb path should use archive version (no epoch), got {deb!r}"
)
assert len(downloads) == 1, "files.download should be called exactly once" assert len(downloads) == 1, "files.download should be called exactly once"
# the checksum is the security boundary: verify the right table row is used
assert (
downloads[0]["sha256sum"]
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
), "must pass the sha256 matching (arch, release, package)"
assert f"deb{deb_release}u1" in downloads[0]["src"], (
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
)
def test_install_skips_dpkg_path_when_epoch_matched_packages_present( def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
@@ -133,12 +156,13 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
( (
dovecot_deployer.DebPackages, dovecot_deployer.DebPackages,
{ {
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION], "dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"],
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
downloads = [] downloads = []
@@ -152,41 +176,26 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
assert downloads == [], "should not download when all packages epoch-matched" assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched" assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, ( assert deployer.need_restart is False, "need_restart should be False when nothing changed"
"need_restart should be False when nothing changed"
)
def test_install_unsupported_arch_falls_back_to_apt( def test_install_unsupported_arch_raises(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
): ):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
SimpleNamespace(get_fact=lambda cls: "riscv64"), make_host(
(dovecot_deployer.Arch, "riscv64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
) )
apt_calls = []
# Mirrors apt.packages() return value: OperationMeta with .changed property. # we never fall back to the pinned distro package
# Only lmtpd triggers a change to verify |= accumulation of changed flags. with pytest.raises(ValueError, match="no dovecot build for dovecot-core"):
def fake_apt(**kwargs): deployer.install()
apt_calls.append(kwargs)
changed = "lmtpd" in kwargs["packages"][0]
return SimpleNamespace(changed=changed)
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt) assert track_shell == [], "should not run apt-get for unsupported arch"
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( def test_install_runs_dpkg_when_packages_need_download(
@@ -198,6 +207,7 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host( make_host(
(dovecot_deployer.DebPackages, {}), (dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -213,17 +223,15 @@ def test_install_runs_dpkg_when_packages_need_download(
deployer.install() deployer.install()
assert len(track_shell) == 1, ( assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
cmds = track_shell[0]["commands"] cmds = track_shell[0]["commands"]
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}" assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ") assert "apt-get install -y" in cmds[0]
assert "apt-get -y --fix-broken install" in cmds[1] assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ") assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert deployer.need_restart is True, ( assert "--allow-downgrades" in cmds[0]
"need_restart should be True after dpkg install" assert ".deb" in cmds[0]
) 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 test_pick_url_falls_back_on_primary_error(monkeypatch):
@@ -232,6 +240,43 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error) monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback") result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", ( assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
),
) )
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
deployer.install()
@pytest.mark.parametrize(
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
)
def test_parse_version_id_raises_without_version_id(version_line):
with pytest.raises(ValueError, match="cannot determine Debian release"):
dovecot_deployer._parse_version_id(version_line)
@pytest.mark.parametrize("deb_release", [12, 13])
def test_parse_version_id(deb_release):
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
assert parsed == deb_release
def test_dovecot_sha256_covers_all_packages_per_release():
"""Every release in the table needs all three packages on both arches."""
table = dovecot_deployer.DOVECOT_SHA256
expected = {
(arch, pkg) for arch in ("amd64", "arm64") for pkg in ("core", "imapd", "lmtpd")
}
for release in {r for _, r, _ in table}:
got = {(arch, pkg) for arch, r, pkg in table if r == release}
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
-6
View File
@@ -22,12 +22,6 @@ extensions = [
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = [] exclude_patterns = []
linkcheck_ignore = [
# only resolves once the file is merged to main
r"https://github\.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/defaults/appversions\.json",
]
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
-27
View File
@@ -249,33 +249,6 @@ Fresh chatmail addresses have a mailbox directory that contains:
directories will typically be empty unless the user of that address directories will typically be empty unless the user of that address
hasnt been online for a while. hasnt been online for a while.
App version information (experimental)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A chatmail relay ships the
`appversions.json <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/defaults/appversions.json>`_
file of the ``chatmaild`` package
and serves its content under the IMAP METADATA key
``/shared/vendor/deltachat/appversions``.
Chat apps installed outside of app stores read this key
to learn about updates and where to download them.
The mechanism is experimental and may change.
The file travels with the normal deploy:
update the repository checkout and run ``cmdeploy run``.
Local modifications of ``appversions.json`` are deployed as-is,
so you can serve your own app version information,
including links to app downloads.
There is no automatic refresh:
version information changes only when you deploy again.
.. note::
Note that as of August 2026, only Delta Chat Android Google Play version
is beginning to support discovering app versions from relays.
Generally, consumers of relay-provided app version information
need to verify themselves that downloaded app files are valid.
Active ports Active ports
~~~~~~~~~~~~ ~~~~~~~~~~~~