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), simplified primary URL
      path: remove codename subdirectory
- 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
This commit is contained in:
j4n
2026-08-10 11:29:44 +02:00
parent 6b872446e1
commit 623ab2aefb
2 changed files with 54 additions and 66 deletions
+29 -27
View File
@@ -14,22 +14,22 @@ from cmdeploy.basedeploy import (
is_in_container, is_in_container,
) )
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3" DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}" DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = { DOVECOT_SHA256 = {
("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d", ("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9", ("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", ("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", ("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", ("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1", ("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f", ("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86", ("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f", ("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab", ("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f", ("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
} }
@@ -43,13 +43,12 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip() version_line = (host.get_fact(Command, "grep '^VERSION_ID=' /etc/os-release") or "").strip()
if codename not in {key[1] for key in DOVECOT_SHA256}: deb_release = int(version_line.split("=", 1)[1].strip('"'))
raise ValueError(f"Unsupported Debian codename: {codename!r}")
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, codename) 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)
@@ -105,29 +104,32 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str, codename: 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((arch, codename, package)) if (arch, deb_release, package) not in DOVECOT_SHA256:
if sha256 is None:
op = apt.packages(packages=[pkg_name]) op = apt.packages(packages=[pkg_name])
return None, bool(getattr(op, "changed", False)) return None, bool(getattr(op, "changed", False))
sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
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 DOVECOT_PACKAGE_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}_{DOVECOT_ARCHIVE_VERSION}+deb{deb_release}u1_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}" primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B") # GitHub release files: escaped + in tag and filename
fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb" url_version_escaped = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}" fallback_deb = f"{pkg_name}_{url_version_escaped}%2Bdeb{deb_release}u1_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version_escaped}/{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}",
@@ -23,9 +23,7 @@ def make_host(*fact_pairs):
def get_fact(cls, *args): def get_fact(cls, *args):
if cls not in facts: if cls not in facts:
registered = ", ".join(c.__name__ for c in facts) registered = ", ".join(c.__name__ for c in facts)
raise LookupError( raise LookupError(f"unexpected get_fact({cls.__name__}); only registered: {registered}")
f"unexpected get_fact({cls.__name__}); only registered: {registered}"
)
return facts[cls] return facts[cls]
return SimpleNamespace(get_fact=get_fact) return SimpleNamespace(get_fact=get_fact)
@@ -83,9 +81,7 @@ 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( deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
"core", "amd64", codename="bookworm"
)
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"
@@ -112,14 +108,11 @@ 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( deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
"core", "amd64", codename="bookworm"
)
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B") expected_deb = f"/root/dovecot-core_{dovecot_deployer.DOVECOT_ARCHIVE_VERSION}+deb12u1_amd64.deb"
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
# Verify the returned path uses archive version, not package version (with epoch) # Verify the returned path uses archive version with distro suffix, not package version (with 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 dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
@@ -144,7 +137,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
(Command, "bookworm"), (Command, 'VERSION_ID="12"'),
), ),
) )
downloads = [] downloads = []
@@ -158,20 +151,16 @@ 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_falls_back_to_apt(deployer, patch_blocked, mock_files_put, track_shell, monkeypatch):
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
make_host( make_host(
(dovecot_deployer.Arch, "riscv64"), (dovecot_deployer.Arch, "riscv64"),
(Command, "bookworm"), (Command, 'VERSION_ID="12"'),
), ),
) )
apt_calls = [] apt_calls = []
@@ -192,9 +181,7 @@ def test_install_unsupported_arch_falls_back_to_apt(
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}" f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
) )
assert track_shell == [], "should not run dpkg for unsupported arch" assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, ( assert deployer.need_restart is True, "need_restart should be True when apt installed a package"
"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(
@@ -206,7 +193,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, "bookworm"), (Command, 'VERSION_ID="12"'),
), ),
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -222,9 +209,7 @@ 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) == 1, f"expected single apt-get install command, got: {cmds}" assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert "apt-get install -y" in cmds[0] assert "apt-get install -y" in cmds[0]
@@ -232,9 +217,7 @@ def test_install_runs_dpkg_when_packages_need_download(
assert '-o Dpkg::Options::="--force-confold"' in cmds[0] assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert "--allow-downgrades" in cmds[0] assert "--allow-downgrades" in cmds[0]
assert ".deb" in cmds[0] assert ".deb" in cmds[0]
assert deployer.need_restart is True, ( assert deployer.need_restart is True, "need_restart should be True after dpkg install"
"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):
@@ -243,22 +226,25 @@ 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( def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
deployer, patch_blocked, monkeypatch
):
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
make_host( make_host(
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
(Command, "sid"), (Command, 'VERSION_ID="99"'),
), ),
) )
with pytest.raises(ValueError, match="Unsupported Debian codename"): # Mock apt.packages to handle unsupported release gracefully
deployer.install() def fake_apt(**kwargs):
return SimpleNamespace(changed=False)
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
# Should not error, just skip the dovecot-specific packages and use apt
deployer.install()
assert deployer.need_restart is False, "unsupported release should not trigger restart"