mirror of
https://github.com/chatmail/relay.git
synced 2026-06-09 21:21:09 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a8272a05b | |||
| 0a95e1ceeb | |||
| f496871863 |
@@ -8,7 +8,7 @@ name: Trigger Docker build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, j4n/dovecot-multidist]
|
||||
tags: ['[0-9]+.[0-9]+.[0-9]+']
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
from random import randint
|
||||
|
||||
import iniconfig
|
||||
|
||||
@@ -42,11 +41,6 @@ class Config:
|
||||
self.username_max_length = int(params.pop("username_max_length", 9))
|
||||
self.password_min_length = int(params.pop("password_min_length", 9))
|
||||
self.www_folder = params.pop("www_folder", "")
|
||||
|
||||
self.imap_port = int(params.pop("imap_port", 143))
|
||||
self.imaps_port = int(params.pop("imaps_port", 993))
|
||||
self.smtp_port = int(params.pop("smtp_port", 587))
|
||||
self.smtps_port = int(params.pop("smtps_port", 465))
|
||||
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
|
||||
self.filtermail_smtp_port_incoming = int(
|
||||
params.pop("filtermail_smtp_port_incoming", "10081")
|
||||
@@ -144,15 +138,8 @@ def parse_size_mb(limit):
|
||||
|
||||
def write_initial_config(inipath, mail_domain, overrides):
|
||||
"""Write out default config file, using the specified config value overrides."""
|
||||
content = get_default_config_content(mail_domain, **overrides).splitlines()
|
||||
used_ports = [25, 53, 80, 143, 402, 443, 465, 587, 993, 3340, 3903, 3904, 8443, 10080, 10081, 10082, 10083, 10025, 10026]
|
||||
for config_key in ["smtp_port", "imap_port", "smtps_port", "imaps_port"]:
|
||||
value = randint(1, 65536)
|
||||
while value in used_ports:
|
||||
value = randint(65535)
|
||||
used_ports.append(value)
|
||||
content.append(f"{config_key} = {value}")
|
||||
inipath.write_text("\n".join(content))
|
||||
content = get_default_config_content(mail_domain, **overrides)
|
||||
inipath.write_text(content)
|
||||
|
||||
|
||||
def get_default_config_content(mail_domain, **overrides):
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"acme-enter-email": "{{ email }}"
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.7-June-04-2026.pdf": true
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
|
||||
|
||||
@@ -389,6 +389,12 @@ class ChatmailDeployer(Deployer):
|
||||
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
||||
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.upgrade(name="upgrade apt packages", auto_remove=True)
|
||||
|
||||
@@ -495,15 +501,15 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
||||
if config.tls_cert_mode == "acme":
|
||||
port_services.append(("acmetool", 402))
|
||||
port_services += [
|
||||
(["imap-login", "dovecot"], config.imap_port),
|
||||
(["imap-login", "dovecot"], 143),
|
||||
# acmetool previously listened on port 80,
|
||||
# so don't complain during upgrade that moved it to port 402
|
||||
# and gave the port to nginx.
|
||||
(["acmetool", "nginx"], 80),
|
||||
("nginx", 443),
|
||||
(["master", "smtpd"], config.smtp_port),
|
||||
(["master", "smtpd"], config.smtps_port),
|
||||
(["imap-login", "dovecot"], config.imaps_port),
|
||||
(["master", "smtpd"], 465),
|
||||
(["master", "smtpd"], 587),
|
||||
(["imap-login", "dovecot"], 993),
|
||||
("iroh-relay", 3340),
|
||||
("mtail", 3903),
|
||||
("stats", 3904),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import io
|
||||
import urllib.request
|
||||
|
||||
from chatmaild.config import Config
|
||||
@@ -19,12 +18,18 @@ 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",
|
||||
("amd64", "bookworm", "core"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
||||
("arm64", "bookworm", "core"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
||||
("amd64", "bookworm", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
||||
("arm64", "bookworm", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
||||
("amd64", "bookworm", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
||||
("arm64", "bookworm", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
||||
("amd64", "trixie", "core"): "406d3781ed81e0913c472077dcf62cb1106e3855983efa6e44ddf43b4b0c9be1",
|
||||
("arm64", "trixie", "core"): "c75b0d9df11a77d07ebd8522920380c167fa47330ddefebe10575d99d0ecdf7f",
|
||||
("amd64", "trixie", "imapd"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
||||
("arm64", "trixie", "imapd"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
||||
("amd64", "trixie", "lmtpd"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
||||
("arm64", "trixie", "lmtpd"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
||||
}
|
||||
|
||||
|
||||
@@ -38,34 +43,32 @@ class DovecotDeployer(Deployer):
|
||||
|
||||
def install(self):
|
||||
arch = host.get_fact(Arch)
|
||||
codename = (host.get_fact(Command, "grep '^VERSION_CODENAME=' /etc/os-release | cut -d= -f2") or "").strip()
|
||||
if codename not in {key[1] for key in DOVECOT_SHA256}:
|
||||
raise ValueError(f"Unsupported Debian codename: {codename!r}")
|
||||
with blocked_service_startup():
|
||||
debs = []
|
||||
for pkg in ("core", "imapd", "lmtpd"):
|
||||
deb, changed = _download_dovecot_package(pkg, arch)
|
||||
deb, changed = _download_dovecot_package(pkg, arch, codename)
|
||||
self.need_restart |= changed
|
||||
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.
|
||||
# apt-get install with local .deb paths resolves depends
|
||||
# against the configured repos (e.g. pulls libwrap0),
|
||||
# The pin file written earlier by ChatmailDeployer prevents apt
|
||||
# from installing a 'wrong' version
|
||||
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}",
|
||||
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
||||
'-o Dpkg::Options::="--force-confdef" '
|
||||
'-o Dpkg::Options::="--force-confold" '
|
||||
f"--allow-downgrades {deb_list}",
|
||||
],
|
||||
)
|
||||
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):
|
||||
configure_remote_units(self, self.config.mail_domain_bare, self.units)
|
||||
@@ -78,7 +81,7 @@ class DovecotDeployer(Deployer):
|
||||
if not self.disable_mail and not self.need_restart:
|
||||
stale = host.get_fact(
|
||||
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)"'
|
||||
" && echo STALE || true",
|
||||
)
|
||||
@@ -102,13 +105,13 @@ def _pick_url(primary, fallback):
|
||||
return fallback
|
||||
|
||||
|
||||
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
|
||||
def _download_dovecot_package(package: str, arch: str, codename: str) -> tuple[str | None, bool]:
|
||||
"""Download a dovecot .deb if needed, return (path, changed)."""
|
||||
arch = "amd64" if arch == "x86_64" else arch
|
||||
arch = "arm64" if arch == "aarch64" else arch
|
||||
|
||||
pkg_name = f"dovecot-{package}"
|
||||
sha256 = DOVECOT_SHA256.get((package, arch))
|
||||
sha256 = DOVECOT_SHA256.get((arch, codename, package))
|
||||
if sha256 is None:
|
||||
op = apt.packages(packages=[pkg_name])
|
||||
return None, bool(getattr(op, "changed", False))
|
||||
@@ -119,8 +122,10 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
|
||||
|
||||
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}"
|
||||
primary_url = f"https://download.delta.chat/dovecot/{codename}/{url_version}/{deb_base}"
|
||||
upstream_version = DOVECOT_ARCHIVE_VERSION.rsplit("-", 1)[0].replace("+", "%2B")
|
||||
fallback_deb = f"{pkg_name}_{url_version}_{arch}_{codename}.deb"
|
||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{upstream_version}/{fallback_deb}"
|
||||
url = _pick_url(primary_url, fallback_url)
|
||||
deb_filename = f"/root/{deb_base}"
|
||||
|
||||
@@ -134,6 +139,7 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
|
||||
|
||||
return deb_filename, True
|
||||
|
||||
|
||||
def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
||||
"""Configures Dovecot IMAP server."""
|
||||
deployer.put_template(
|
||||
@@ -144,9 +150,7 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
||||
disable_ipv6=config.disable_ipv6,
|
||||
)
|
||||
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
|
||||
deployer.put_file(
|
||||
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
|
||||
)
|
||||
deployer.put_file("dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua")
|
||||
|
||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||
# it is recommended to set the following inotify limits
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<displayShortName>{{ config.mail_domain }}</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>{{ config.imaps_port }}</port>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>{{ config.imap_port }}</port>
|
||||
<port>143</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
@@ -28,14 +28,14 @@
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>{{ config.smtps_port }}</port>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>{{ config.smtp_port }}</port>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
|
||||
@@ -31,26 +31,6 @@ stream {
|
||||
~\bimap\b 127.0.0.1:993;
|
||||
}
|
||||
|
||||
server {
|
||||
listen {{ config.smtp_port }};
|
||||
proxy_pass 127.0.0.1:587;
|
||||
}
|
||||
|
||||
server {
|
||||
listen {{ config.imap_port }};
|
||||
proxy_pass 127.0.0.1:143;
|
||||
}
|
||||
|
||||
server {
|
||||
listen {{ config.smtps_port }};
|
||||
proxy_pass 127.0.0.1:465;
|
||||
}
|
||||
|
||||
server {
|
||||
listen {{ config.imaps_port }};
|
||||
proxy_pass 127.0.0.1:993;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
{% if not disable_ipv6 %}
|
||||
|
||||
@@ -3,6 +3,7 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from pyinfra.facts.deb import DebPackages
|
||||
from pyinfra.facts.server import Command
|
||||
|
||||
from cmdeploy.dovecot import deployer as dovecot_deployer
|
||||
|
||||
@@ -19,7 +20,7 @@ def make_host(*fact_pairs):
|
||||
"""
|
||||
facts = dict(fact_pairs)
|
||||
|
||||
def get_fact(cls):
|
||||
def get_fact(cls, *args):
|
||||
if cls not in facts:
|
||||
registered = ", ".join(c.__name__ for c in facts)
|
||||
raise LookupError(
|
||||
@@ -82,7 +83,9 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
||||
lambda **kwargs: downloads.append(kwargs),
|
||||
)
|
||||
|
||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
||||
deb, changed = dovecot_deployer._download_dovecot_package(
|
||||
"core", "amd64", codename="bookworm"
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -109,7 +112,9 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
||||
lambda **kwargs: downloads.append(kwargs),
|
||||
)
|
||||
|
||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
||||
deb, changed = dovecot_deployer._download_dovecot_package(
|
||||
"core", "amd64", codename="bookworm"
|
||||
)
|
||||
|
||||
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
||||
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
||||
@@ -139,6 +144,7 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
||||
},
|
||||
),
|
||||
(dovecot_deployer.Arch, "x86_64"),
|
||||
(Command, "bookworm"),
|
||||
),
|
||||
)
|
||||
downloads = []
|
||||
@@ -160,11 +166,13 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
||||
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"),
|
||||
make_host(
|
||||
(dovecot_deployer.Arch, "riscv64"),
|
||||
(Command, "bookworm"),
|
||||
),
|
||||
)
|
||||
apt_calls = []
|
||||
|
||||
@@ -198,6 +206,7 @@ def test_install_runs_dpkg_when_packages_need_download(
|
||||
make_host(
|
||||
(dovecot_deployer.DebPackages, {}),
|
||||
(dovecot_deployer.Arch, "x86_64"),
|
||||
(Command, "bookworm"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -217,10 +226,12 @@ def test_install_runs_dpkg_when_packages_need_download(
|
||||
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 len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
|
||||
assert "apt-get install -y" in cmds[0]
|
||||
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
|
||||
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
|
||||
assert "--allow-downgrades" in cmds[0]
|
||||
assert ".deb" in cmds[0]
|
||||
assert deployer.need_restart is True, (
|
||||
"need_restart should be True after dpkg install"
|
||||
)
|
||||
@@ -235,3 +246,19 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
||||
assert result == "http://fallback", (
|
||||
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, "sid"),
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported Debian codename"):
|
||||
deployer.install()
|
||||
|
||||
Reference in New Issue
Block a user