Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt
b164c4d1b2 feat: add tool to analyze deferred queue
It prints all destinations with the number of recipients
and all the reasons. Operator can then try
to fix the problems for destinations,
e.g. by manually adding reverse proxy
addresses to /etc/hosts for failing domains
or routing IP addresses to another interface.
2026-05-05 02:56:23 +02:00
22 changed files with 693 additions and 467 deletions

View File

@@ -24,6 +24,7 @@ chatmail-metadata = "chatmaild.metadata:main"
chatmail-expire = "chatmaild.expire:daily_expire_main" chatmail-expire = "chatmaild.expire:daily_expire_main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main" chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
chatmail-deferred = "chatmaild.deferred:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main" turnserver = "chatmaild.turnserver:main"

View File

@@ -0,0 +1,37 @@
"""
Analyze deferred mails and print most common failing destinations.
Example:
python -m chatmaild.deferred
"""
import json
import subprocess
from collections import Counter, defaultdict
def main():
p = subprocess.Popen(["postqueue", "-j"], text=True, stdout=subprocess.PIPE)
domain_reasons = defaultdict(Counter)
domain_total = Counter()
for line in p.stdout:
item = json.loads(line)
if item["queue_name"] != "deferred":
continue
for recipient in item["recipients"]:
_, domain = recipient["address"].rsplit("@", 1)
reason = recipient["delay_reason"]
domain_total[domain] += 1
domain_reasons[domain][reason] += 1
for domain, total in reversed(domain_total.most_common()):
print(f"{domain} ({total} recipients)")
for reason, count in domain_reasons[domain].most_common():
print(f" {count}: {reason}")
if __name__ == "__main__":
main()

View File

@@ -48,8 +48,6 @@ def test_migration(tmp_path, example_config, caplog):
assert passdb_path.stat().st_size > 10000 assert passdb_path.stat().st_size > 10000
example_config.passdb_path = passdb_path example_config.passdb_path = passdb_path
# ensure logging.info records are captured regardless of global configuration
caplog.set_level("INFO")
assert not caplog.records assert not caplog.records

View File

@@ -1,4 +1,6 @@
from pyinfra.operations import apt, server import importlib.resources
from pyinfra.operations import apt, files, server, systemd
from ..basedeploy import Deployer from ..basedeploy import Deployer
@@ -7,6 +9,9 @@ class AcmetoolDeployer(Deployer):
def __init__(self, email, domains): def __init__(self, email, domains):
self.domains = domains self.domains = domains
self.email = email self.email = email
self.need_restart_redirector = False
self.need_restart_reconcile_service = False
self.need_restart_reconcile_timer = False
def install(self): def install(self):
apt.packages( apt.packages(
@@ -14,41 +19,121 @@ class AcmetoolDeployer(Deployer):
packages=["acmetool"], packages=["acmetool"],
) )
self.remove_file("/etc/cron.d/acmetool") files.file(
name="Remove old acmetool cronjob, it is replaced with systemd timer.",
path="/etc/cron.d/acmetool",
present=False,
)
self.put_executable("acmetool/acmetool.hook", "/etc/acme/hooks/nginx") files.put(
self.remove_file("/usr/lib/acme/hooks/nginx") name="Install acmetool hook.",
src=importlib.resources.files(__package__)
.joinpath("acmetool.hook")
.open("rb"),
dest="/etc/acme/hooks/nginx",
user="root",
group="root",
mode="755",
)
files.file(
name="Remove acmetool hook from the wrong location where it was previously installed.",
path="/usr/lib/acme/hooks/nginx",
present=False,
)
def configure(self): def configure(self):
self.put_template( files.template(
"acmetool/response-file.yaml.j2", src=importlib.resources.files(__package__).joinpath(
"/var/lib/acme/conf/responses", "response-file.yaml.j2"
),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=self.email, email=self.email,
) )
self.put_template( files.template(
"acmetool/target.yaml.j2", src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
"/var/lib/acme/conf/target", dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
) )
server.shell( server.shell(
name=f"Remove old acmetool desired files for {self.domains[0]}", name=f"Remove old acmetool desired files for {self.domains[0]}",
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"], commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
) )
self.put_template( files.template(
"acmetool/desired.yaml.j2", src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
f"/var/lib/acme/desired/{self.domains[0]}", dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
user="root",
group="root",
mode="644",
domains=self.domains, domains=self.domains,
) )
self.ensure_systemd_unit("acmetool/acmetool-redirector.service") service_file = files.put(
self.ensure_systemd_unit("acmetool/acmetool-reconcile.service") src=importlib.resources.files(__package__).joinpath(
self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer") "acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
self.need_restart_redirector = service_file.changed
reconcile_service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.service"
),
dest="/etc/systemd/system/acmetool-reconcile.service",
user="root",
group="root",
mode="644",
)
self.need_restart_reconcile_service = reconcile_service_file.changed
reconcile_timer_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.timer"
),
dest="/etc/systemd/system/acmetool-reconcile.timer",
user="root",
group="root",
mode="644",
)
self.need_restart_reconcile_timer = reconcile_timer_file.changed
def activate(self): def activate(self):
self.ensure_service("acmetool-redirector.service") systemd.service(
self.ensure_service("acmetool-reconcile.service", running=False, enabled=False) name="Setup acmetool-redirector service",
self.ensure_service("acmetool-reconcile.timer") service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=self.need_restart_redirector,
)
self.need_restart_redirector = False
systemd.service(
name="Setup acmetool-reconcile service",
service="acmetool-reconcile.service",
running=False,
enabled=False,
daemon_reload=self.need_restart_reconcile_service,
)
self.need_restart_reconcile_service = False
systemd.service(
name="Setup acmetool-reconcile timer",
service="acmetool-reconcile.timer",
running=True,
enabled=True,
daemon_reload=self.need_restart_reconcile_timer,
)
self.need_restart_reconcile_timer = False
server.shell( server.shell(
name=f"Reconcile certificates for: {', '.join(self.domains)}", name=f"Reconcile certificates for: {', '.join(self.domains)}",

View File

@@ -4,7 +4,6 @@ import os
from contextlib import contextmanager from contextlib import contextmanager
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command from pyinfra.facts.server import Command
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
@@ -51,10 +50,11 @@ def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)
def configure_remote_units(deployer, mail_domain, units) -> None: def configure_remote_units(mail_domain, units) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
# install systemd units # install systemd units
for fn in units: for fn in units:
@@ -70,13 +70,15 @@ def configure_remote_units(deployer, mail_domain, units) -> None:
source_path = get_resource(f"service/{basename}.f") source_path = get_resource(f"service/{basename}.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
deployer.put_file( files.put(
name=f"Upload {basename}",
src=io.BytesIO(content), src=io.BytesIO(content),
dest=f"/etc/systemd/system/{basename}", dest=f"/etc/systemd/system/{basename}",
**root_owned,
) )
def activate_remote_units(deployer, units) -> None: def activate_remote_units(units) -> None:
# activate systemd units # activate systemd units
for fn in units: for fn in units:
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -86,8 +88,14 @@ def activate_remote_units(deployer, units) -> None:
enabled = False enabled = False
else: else:
enabled = True enabled = True
systemd.service(
deployer.ensure_service(basename, running=enabled, enabled=enabled) name=f"Setup {basename}",
service=basename,
running=enabled,
enabled=enabled,
restarted=enabled,
daemon_reload=True,
)
class Deployment: class Deployment:
@@ -133,7 +141,6 @@ class Deployment:
class Deployer: class Deployer:
need_restart = False need_restart = False
daemon_reload = False
def install(self): def install(self):
pass pass
@@ -143,113 +150,3 @@ class Deployer:
def activate(self): def activate(self):
pass pass
def ensure_service(self, service, running=True, enabled=True):
if running:
verb = "Start and enable"
else:
verb = "Stop"
systemd.service(
name=f"{verb} {service}",
service=service,
running=running,
enabled=enabled,
restarted=self.need_restart if running else False,
daemon_reload=self.daemon_reload,
)
self.daemon_reload = False
def ensure_systemd_unit(self, src, **kwargs):
dest_name = src.split("/")[-1].replace(".j2", "")
dest = f"/etc/systemd/system/{dest_name}"
if src.endswith(".j2"):
return self.put_template(src, dest, **kwargs)
return self.put_file(src, dest)
def put_file(self, src, dest, mode="644"):
if isinstance(src, str):
src = get_resource(src)
res = files.put(
name=f"Upload {dest}",
src=src,
dest=dest,
user="root",
group="root",
mode=mode,
)
return self._update_restart_signals(dest, res)
def put_executable(self, src, dest):
return self.put_file(src, dest, mode="755")
def put_template(self, src, dest, owner="root", **kwargs):
if isinstance(src, str):
src = get_resource(src)
res = files.template(
name=f"Upload {dest}",
src=src,
dest=dest,
user=owner,
group=owner,
mode="644",
**kwargs,
)
return self._update_restart_signals(dest, res)
def remove_file(self, dest):
res = files.file(name=f"Remove {dest}", path=dest, present=False)
return self._update_restart_signals(dest, res)
def ensure_line(self, path, line, **kwargs):
name = kwargs.pop("name", f"Ensure line in {path}")
res = files.line(name=name, path=path, line=line, **kwargs)
return self._update_restart_signals(path, res)
def ensure_directory(self, path, owner="root", mode="755", **kwargs):
name = kwargs.pop("name", f"Ensure directory {path}")
res = files.directory(
name=name,
path=path,
user=owner,
group=owner,
mode=mode,
present=True,
**kwargs,
)
return self._update_restart_signals(path, res)
def remove_directory(self, path, **kwargs):
name = kwargs.pop("name", f"Remove directory {path}")
res = files.directory(name=name, path=path, present=False, **kwargs)
return self._update_restart_signals(path, res)
def download_executable(self, url, dest, sha256sum, extract=None):
existing = host.get_fact(Sha256File, dest)
if existing == sha256sum:
return
tmp = f"{dest}.new"
if extract:
dl_cmd = f"curl -fSL {url} | {extract} >{tmp}"
else:
dl_cmd = f"curl -fSL {url} -o {tmp}"
server.shell(
name=f"Download {dest}",
commands=[
f"({dl_cmd}"
f" && echo '{sha256sum} {tmp}' | sha256sum -c"
f" && mv {tmp} {dest})",
f"chmod 755 {dest}",
],
)
self.need_restart = True
def _update_restart_signals(self, path, res):
if res.changed:
self.need_restart = True
if str(path).startswith("/etc/systemd/system/"):
self.daemon_reload = True
return res

View File

@@ -12,6 +12,7 @@ from chatmaild.config import read_config
from pyinfra import facts, host, logger from pyinfra import facts, host, logger
from pyinfra.api import FactBase from pyinfra.api import FactBase
from pyinfra.facts import hardware from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
@@ -24,6 +25,7 @@ from .basedeploy import (
activate_remote_units, activate_remote_units,
blocked_service_startup, blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource,
has_systemd, has_systemd,
is_in_container, is_in_container,
) )
@@ -80,22 +82,25 @@ def remove_legacy_artifacts():
) )
def _install_remote_venv_with_chatmaild(deployer) -> None: def _install_remote_venv_with_chatmaild() -> None:
remove_legacy_artifacts() remove_legacy_artifacts()
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist")) dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}" remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
root_owned = dict(user="root", group="root", mode="644")
apt.packages( apt.packages(
name="apt install python3-virtualenv", name="apt install python3-virtualenv",
packages=["python3-virtualenv"], packages=["python3-virtualenv"],
) )
deployer.ensure_directory(f"{remote_base_dir}/dist") files.put(
deployer.put_file( name="Upload chatmaild source package",
src=dist_file.open("rb"), src=dist_file.open("rb"),
dest=remote_dist_file, dest=remote_dist_file,
create_remote_dir=True,
**root_owned,
) )
pip.virtualenv( pip.virtualenv(
@@ -117,22 +122,32 @@ def _install_remote_venv_with_chatmaild(deployer) -> None:
) )
def _configure_remote_venv_with_chatmaild(deployer, config) -> None: def _configure_remote_venv_with_chatmaild(config) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
deployer.put_file( files.put(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(), src=config._getbytefile(),
dest=remote_chatmail_inipath, dest=remote_chatmail_inipath,
**root_owned,
) )
deployer.remove_file("/etc/cron.d/chatmail-metrics") files.file(
deployer.remove_file("/var/www/html/metrics") path="/etc/cron.d/chatmail-metrics",
present=False,
)
files.file(
path="/var/www/html/metrics",
present=False,
)
class UnboundDeployer(Deployer): class UnboundDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.need_restart = False
def install(self): def install(self):
# On an IPv4-only system, if unbound is started but not configured, # On an IPv4-only system, if unbound is started but not configured,
@@ -161,9 +176,13 @@ class UnboundDeployer(Deployer):
) )
# Configure unbound resolver with Quad9 fallback and a trailing newline # Configure unbound resolver with Quad9 fallback and a trailing newline
# (SolusVM bug). # (SolusVM bug).
self.put_file( files.put(
name="Write static resolv.conf",
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"), src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
dest="/etc/resolv.conf", dest="/etc/resolv.conf",
user="root",
group="root",
mode="644",
) )
server.shell( server.shell(
name="Generate root keys for validating DNSSEC", name="Generate root keys for validating DNSSEC",
@@ -172,15 +191,26 @@ class UnboundDeployer(Deployer):
], ],
) )
if self.config.disable_ipv6: if self.config.disable_ipv6:
self.ensure_directory( files.directory(
path="/etc/unbound/unbound.conf.d", path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
) )
self.put_template( conf = files.put(
"unbound/unbound.conf.j2", src=get_resource("unbound/unbound.conf.j2"),
"/etc/unbound/unbound.conf.d/chatmail.conf", dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
) )
else: else:
self.remove_file("/etc/unbound/unbound.conf.d/chatmail.conf") conf = files.file(
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self): def activate(self):
server.shell( server.shell(
@@ -190,25 +220,27 @@ class UnboundDeployer(Deployer):
], ],
) )
self.ensure_service("unbound.service") systemd.service(
name="Start and enable unbound",
self.ensure_service( service="unbound.service",
"unbound-resolvconf.service", running=True,
running=False, enabled=True,
enabled=False, restarted=self.need_restart,
) )
class MtastsDeployer(Deployer): class MtastsDeployer(Deployer):
def configure(self): def configure(self):
# Remove configuration. # Remove configuration.
self.remove_file("/etc/mta-sts-daemon.yml") files.file("/etc/mta-sts-daemon.yml", present=False)
self.remove_directory("/usr/local/lib/postfix-mta-sts-resolver") files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False)
self.remove_file("/etc/systemd/system/mta-sts-daemon.service") files.file("/etc/systemd/system/mta-sts-daemon.service", present=False)
def activate(self): def activate(self):
self.ensure_service( systemd.service(
"mta-sts-daemon.service", name="Stop MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=False, running=False,
enabled=False, enabled=False,
) )
@@ -219,7 +251,14 @@ class WebsiteDeployer(Deployer):
self.config = config self.config = config
def install(self): def install(self):
self.ensure_directory("/var/www") files.directory(
name="Ensure /var/www exists",
path="/var/www",
user="root",
group="root",
mode="755",
present=True,
)
def configure(self): def configure(self):
www_path, src_dir, build_dir = get_paths(self.config) www_path, src_dir, build_dir = get_paths(self.config)
@@ -249,11 +288,15 @@ class LegacyRemoveDeployer(Deployer):
# remove historic expunge script # remove historic expunge script
# which is now implemented through a systemd timer (chatmail-expire) # which is now implemented through a systemd timer (chatmail-expire)
self.remove_file("/etc/cron.d/expunge") files.file(
path="/etc/cron.d/expunge",
present=False,
)
# Remove OBS repository key that is no longer used. # Remove OBS repository key that is no longer used.
self.remove_file("/etc/apt/keyrings/obs-home-deltachat.gpg") files.file("/etc/apt/keyrings/obs-home-deltachat.gpg", present=False)
self.ensure_line( files.line(
name="Remove DeltaChat OBS home repository from sources.list",
path="/etc/apt/sources.list", path="/etc/apt/sources.list",
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./", line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True, escape_regex_characters=True,
@@ -261,7 +304,11 @@ class LegacyRemoveDeployer(Deployer):
) )
# prior relay versions used filelogging # prior relay versions used filelogging
self.remove_directory("/var/log/journal/") files.directory(
name="Ensure old logs on disk are deleted",
path="/var/log/journal/",
present=False,
)
# remove echobot if it is still running # remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service( systemd.service(
@@ -303,13 +350,22 @@ class TurnDeployer(Deployer):
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756", "0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(url, "/usr/local/bin/chatmail-turn", sha256sum)
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum:
server.shell(
name="Download chatmail-turn",
commands=[
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
"chmod 755 /usr/local/bin/chatmail-turn",
],
)
def configure(self): def configure(self):
configure_remote_units(self, self.mail_domain, self.units) configure_remote_units(self.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
class IrohDeployer(Deployer): class IrohDeployer(Deployer):
@@ -327,30 +383,72 @@ class IrohDeployer(Deployer):
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1", "f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(
url, existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
"/usr/local/bin/iroh-relay", if existing_sha256sum != sha256sum:
sha256sum, server.shell(
extract="gunzip | tar -xf - ./iroh-relay -O", name="Download iroh-relay",
commands=[
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
) )
self.need_restart = True
def configure(self): def configure(self):
self.ensure_systemd_unit("iroh-relay.service") systemd_unit = files.put(
self.put_file("iroh-relay.toml", "/etc/iroh-relay.toml") name="Upload iroh-relay systemd unit",
src=get_resource("iroh-relay.service"),
dest="/etc/systemd/system/iroh-relay.service",
user="root",
group="root",
mode="644",
)
self.need_restart |= systemd_unit.changed
iroh_config = files.put(
name="Upload iroh-relay config",
src=get_resource("iroh-relay.toml"),
dest="/etc/iroh-relay.toml",
user="root",
group="root",
mode="644",
)
self.need_restart |= iroh_config.changed
def activate(self): def activate(self):
self.ensure_service( systemd.service(
"iroh-relay.service", name="Start and enable iroh-relay",
service="iroh-relay.service",
running=True,
enabled=self.enable_iroh_relay, enabled=self.enable_iroh_relay,
restarted=self.need_restart,
) )
self.need_restart = False
class JournaldDeployer(Deployer): class JournaldDeployer(Deployer):
def configure(self): def configure(self):
self.put_file("journald.conf", "/etc/systemd/journald.conf") journald_conf = files.put(
name="Configure journald",
src=get_resource("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
self.need_restart = journald_conf.changed
def activate(self): def activate(self):
self.ensure_service("systemd-journald.service") systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
class ChatmailVenvDeployer(Deployer): class ChatmailVenvDeployer(Deployer):
@@ -366,14 +464,14 @@ class ChatmailVenvDeployer(Deployer):
) )
def install(self): def install(self):
_install_remote_venv_with_chatmaild(self) _install_remote_venv_with_chatmaild()
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self, self.config) _configure_remote_venv_with_chatmaild(self.config)
configure_remote_units(self, self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
class ChatmailDeployer(Deployer): class ChatmailDeployer(Deployer):
@@ -387,9 +485,13 @@ class ChatmailDeployer(Deployer):
self.mail_domain = config.mail_domain self.mail_domain = config.mail_domain
def install(self): def install(self):
self.put_file( files.put(
name="Disable installing recommended packages globally",
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",
user="root",
group="root",
mode="644",
) )
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)
@@ -406,10 +508,13 @@ class ChatmailDeployer(Deployer):
def configure(self): def configure(self):
# metadata crashes if the mailboxes dir does not exist # metadata crashes if the mailboxes dir does not exist
self.ensure_directory( files.directory(
str(self.config.mailboxes_dir), name="Ensure vmail mailbox directory exists",
owner="vmail", path=str(self.config.mailboxes_dir),
user="vmail",
group="vmail",
mode="700", mode="700",
present=True,
) )
# This file is used by auth proxy. # This file is used by auth proxy.
@@ -430,7 +535,12 @@ class FcgiwrapDeployer(Deployer):
) )
def activate(self): def activate(self):
self.ensure_service("fcgiwrap.service") systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
class GithashDeployer(Deployer): class GithashDeployer(Deployer):
@@ -443,7 +553,12 @@ class GithashDeployer(Deployer):
git_diff = subprocess.check_output(["git", "diff"]).decode() git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception: except Exception:
git_diff = "" git_diff = ""
self.put_file(src=StringIO(git_hash + git_diff), dest="/etc/chatmail-version") files.put(
name="Upload chatmail relay git commit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
def get_tls_deployer(config, mail_domain): def get_tls_deployer(config, mail_domain):
@@ -476,17 +591,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
return return
# Check if mtail_address interface is available (if configured) # Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in ( if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
"127.0.0.1",
"::1",
"localhost",
):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses: if config.mtail_address not in all_addresses:
Out().red( Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
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 is_in_container():

View File

@@ -5,13 +5,14 @@ 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 apt, files, server, systemd
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
activate_remote_units, activate_remote_units,
blocked_service_startup, blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource,
is_in_container, is_in_container,
) )
@@ -58,21 +59,26 @@ class DovecotDeployer(Deployer):
], ],
) )
self.need_restart = True self.need_restart = True
self.put_file( files.put(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO( src=io.StringIO(
"Package: dovecot-*\n" "Package: dovecot-*\n"
"Pin: version *\n" "Pin: version *\n"
"Pin-Priority: -1\n" "Pin-Priority: -1\n"
), ),
dest="/etc/apt/preferences.d/pin-dovecot", dest="/etc/apt/preferences.d/pin-dovecot",
user="root",
group="root",
mode="644",
) )
def configure(self): def configure(self):
configure_remote_units(self, self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
_configure_dovecot(self, self.config) config_restart, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= config_restart
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
# Detect stale binary: package installed but service still runs old (deleted) binary. # Detect stale binary: package installed but service still runs old (deleted) binary.
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
@@ -85,12 +91,19 @@ class DovecotDeployer(Deployer):
if stale == "STALE": if stale == "STALE":
self.need_restart = True self.need_restart = True
active = not self.disable_mail restart = False if self.disable_mail else self.need_restart
self.ensure_service(
"dovecot.service", systemd.service(
running=active, name="Disable dovecot for now"
enabled=active, if self.disable_mail
else "Start and enable Dovecot",
service="dovecot.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
@@ -134,19 +147,39 @@ 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(config: Config, debug: bool = False) -> tuple[bool, bool]:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
deployer.put_template( need_restart = False
"dovecot/dovecot.conf.j2", daemon_reload = False
"/etc/dovecot/dovecot.conf",
main_config = files.template(
src=get_resource("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf") need_restart |= main_config.changed
deployer.put_file( auth_config = files.put(
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua" src=get_resource("dovecot/auth.conf"),
dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
) )
need_restart |= auth_config.changed
lua_push_notification_script = files.put(
src=get_resource("dovecot/push_notification.lua"),
dest="/etc/dovecot/push_notification.lua",
user="root",
group="root",
mode="644",
)
need_restart |= lua_push_notification_script.changed
# 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
@@ -170,20 +203,25 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
persist=True, persist=True,
) )
deployer.ensure_line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",
path="/etc/environment", path="/etc/environment",
line="TZ=:/etc/localtime", line="TZ=:/etc/localtime",
) )
need_restart |= timezone_env.changed
deployer.put_file( restart_conf = files.put(
"service/10_restart_on_failure.conf", name="dovecot: restart automatically on failure",
"/etc/systemd/system/dovecot.service.d/10_restart.conf", src=get_resource("service/10_restart.conf"),
dest="/etc/systemd/system/dovecot.service.d/10_restart.conf",
) )
daemon_reload |= restart_conf.changed
# Validate dovecot configuration before restart # Validate dovecot configuration before restart
if deployer.need_restart: if need_restart:
server.shell( server.shell(
name="Validate dovecot configuration", name="Validate dovecot configuration",
commands=["doveconf -n >/dev/null"], commands=["doveconf -n >/dev/null"],
) )
return need_restart, daemon_reload

View File

@@ -1,8 +1,10 @@
import io
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from ..basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class ExternalTlsDeployer(Deployer): class ExternalTlsDeployer(Deployer):
@@ -21,24 +23,45 @@ class ExternalTlsDeployer(Deployer):
def configure(self): def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts. # Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path): for path in (self.cert_path, self.key_path):
if host.get_fact(File, path=path) is None: info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}") raise Exception(f"External TLS file not found on server: {path}")
self.ensure_systemd_unit( # Deploy the .path unit (templated with the cert path).
"external/tls-cert-reload.path.j2", # pkg=__package__ is required here because the resource files
cert_path=self.cert_path, # live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
) )
self.ensure_systemd_unit(
"external/tls-cert-reload.service", service_unit = files.put(
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
) )
if path_unit.changed or service_unit.changed:
self.need_restart = True
def activate(self): def activate(self):
# No explicit reload needed here: dovecot/nginx read the cert systemd.service(
# on startup, and the .path watcher handles live changes. name="Enable tls-cert-reload path watcher",
self.ensure_service( service="tls-cert-reload.path",
"tls-cert-reload.path",
running=True, running=True,
enabled=True, enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
) )
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.

View File

@@ -9,7 +9,7 @@
Description=Watch TLS certificate for changes Description=Watch TLS certificate for changes
[Path] [Path]
PathChanged={{ cert_path }} PathChanged={cert_path}
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,8 +1,9 @@
import os import os
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
@@ -10,13 +11,18 @@ class FiltermailDeployer(Deployer):
bin_path = "/usr/local/bin/filtermail" bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini" config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def __init__(self):
self.need_restart = False
def install(self): def install(self):
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY") local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY")
if local_bin: if local_bin:
self.put_executable( self.need_restart |= files.put(
name="Upload locally built filtermail",
src=local_bin, src=local_bin,
dest=self.bin_path, dest=self.bin_path,
) mode="755",
).changed
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
@@ -25,16 +31,34 @@ class FiltermailDeployer(Deployer):
"x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6", "x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6",
"aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff", "aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff",
}[arch] }[arch]
self.download_executable(url, self.bin_path, sha256sum) self.need_restart |= files.download(
name="Download filtermail",
src=url,
sha256sum=sha256sum,
dest=self.bin_path,
mode="755",
).changed
def configure(self): def configure(self):
for service in self.services: for service in self.services:
self.ensure_systemd_unit( self.need_restart |= files.template(
f"filtermail/{service}.service.j2", src=get_resource(f"filtermail/{service}.service.j2"),
dest=f"/etc/systemd/system/{service}.service",
user="root",
group="root",
mode="644",
bin_path=self.bin_path, bin_path=self.bin_path,
config_path=self.config_path, config_path=self.config_path,
) ).changed
def activate(self): def activate(self):
for service in self.services: for service in self.services:
self.ensure_service(f"{service}.service") systemd.service(
name=f"Start and enable {service}",
service=f"{service}.service",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=True,
)
self.need_restart = False

View File

@@ -1,7 +1,10 @@
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import apt from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import (
Deployer,
get_resource,
)
class MtailDeployer(Deployer): class MtailDeployer(Deployer):
@@ -15,30 +18,51 @@ class MtailDeployer(Deployer):
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz", "https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"d55cb601049c5e61eabab29998dbbcea95d480e5448544f9470337ba2eea882e", "123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
), ),
"aarch64": ( "aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz", "https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"f748db8ad2a1e0b63684d4c8868cf6a373a20f7e6922e5ece601fff0ee00eb1a", "aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
self.download_executable(
url, server.shell(
"/usr/local/bin/mtail", name="Download mtail",
sha256sum, commands=[
extract="gunzip | tar -xf - mtail -O", f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)",
"chmod 755 /usr/local/bin/mtail",
],
) )
def configure(self): def configure(self):
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`. # Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
# This allows to read from journalctl instead of log files. # This allows to read from journalctl instead of log files.
self.ensure_systemd_unit( files.template(
"mtail/mtail.service.j2", src=get_resource("mtail/mtail.service.j2"),
dest="/etc/systemd/system/mtail.service",
user="root",
group="root",
mode="644",
address=self.mtail_address or "127.0.0.1", address=self.mtail_address or "127.0.0.1",
port=3903, port=3903,
) )
self.put_file("mtail/delivered_mail.mtail", "/etc/mtail/delivered_mail.mtail")
mtail_conf = files.put(
name="Mtail configuration",
src=get_resource("mtail/delivered_mail.mtail"),
dest="/etc/mtail/delivered_mail.mtail",
user="root",
group="root",
mode="644",
)
self.need_restart = mtail_conf.changed
def activate(self): def activate(self):
active = bool(self.mtail_address) systemd.service(
self.ensure_service("mtail.service", running=active, enabled=active) name="Start and enable mtail",
service="mtail.service",
running=bool(self.mtail_address),
enabled=bool(self.mtail_address),
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -1,13 +1,11 @@
[Unit] [Unit]
Description=mtail Description=mtail
After=network-online.target After=multi-user.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -" ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
Restart=on-failure Restart=on-failure
RestartSec=2s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra.operations import apt from pyinfra.operations import apt, files, systemd
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
@@ -31,50 +31,87 @@ class NginxDeployer(Deployer):
# For documentation about policy-rc.d, see: # For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
# #
self.put_executable(src="policy-rc.d", dest="/usr/sbin/policy-rc.d") files.put(
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
packages=["nginx", "libnginx-mod-stream"], packages=["nginx", "libnginx-mod-stream"],
) )
self.remove_file("/usr/sbin/policy-rc.d") files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self): def configure(self):
_configure_nginx(self, self.config) self.need_restart = _configure_nginx(self.config)
def activate(self): def activate(self):
self.ensure_service("nginx.service") systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
def _configure_nginx(deployer, config: Config, debug: bool = False): def _configure_nginx(config: Config, debug: bool = False) -> bool:
"""Configures nginx HTTP server.""" """Configures nginx HTTP server."""
need_restart = False
deployer.put_template( main_config = files.template(
"nginx/nginx.conf.j2", src=get_resource("nginx/nginx.conf.j2"),
"/etc/nginx/nginx.conf", dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
deployer.put_template( autoconfig = files.template(
"nginx/autoconfig.xml.j2", src=get_resource("nginx/autoconfig.xml.j2"),
"/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml", dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= autoconfig.changed
deployer.put_template( mta_sts_config = files.template(
"nginx/mta-sts.txt.j2", src=get_resource("nginx/mta-sts.txt.j2"),
"/var/www/html/.well-known/mta-sts.txt", dest="/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= mta_sts_config.changed
# install CGI newemail script # install CGI newemail script
# #
cgi_dir = "/usr/lib/cgi-bin" cgi_dir = "/usr/lib/cgi-bin"
deployer.ensure_directory(cgi_dir) files.directory(
name=f"Ensure {cgi_dir} exists",
path=cgi_dir,
user="root",
group="root",
)
deployer.put_executable( files.put(
name="Upload cgi newemail.py script",
src=get_resource("newemail.py", pkg="chatmaild").open("rb"), src=get_resource("newemail.py", pkg="chatmaild").open("rb"),
dest=f"{cgi_dir}/newemail.py", dest=f"{cgi_dir}/newemail.py",
user="root",
group="root",
mode="755",
) )
return need_restart

View File

@@ -69,7 +69,7 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;

View File

@@ -4,9 +4,9 @@ Installs OpenDKIM
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import apt, files, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class OpendkimDeployer(Deployer): class OpendkimDeployer(Deployer):
@@ -25,39 +25,65 @@ class OpendkimDeployer(Deployer):
domain = self.mail_domain domain = self.mail_domain
dkim_selector = "opendkim" dkim_selector = "opendkim"
"""Configures OpenDKIM""" """Configures OpenDKIM"""
need_restart = False
self.put_template( main_config = files.template(
"opendkim/opendkim.conf", src=get_resource("opendkim/opendkim.conf"),
"/etc/opendkim.conf", dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= main_config.changed
self.remove_file("/etc/opendkim/screen.lua") screen_script = files.file(
self.remove_file("/etc/opendkim/final.lua") path="/etc/opendkim/screen.lua",
present=False,
)
need_restart |= screen_script.changed
self.ensure_directory( final_script = files.file(
"/etc/opendkim", path="/etc/opendkim/final.lua",
owner="opendkim", present=False,
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
self.put_template( keytable = files.template(
"opendkim/KeyTable", src=get_resource("opendkim/KeyTable"),
"/etc/dkimkeys/KeyTable", dest="/etc/dkimkeys/KeyTable",
owner="opendkim", user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= keytable.changed
self.put_template( signing_table = files.template(
"opendkim/SigningTable", src=get_resource("opendkim/SigningTable"),
"/etc/dkimkeys/SigningTable", dest="/etc/dkimkeys/SigningTable",
owner="opendkim", user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
self.ensure_directory( need_restart |= signing_table.changed
"/var/spool/postfix/opendkim", files.directory(
owner="opendkim", name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"): if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
@@ -70,10 +96,12 @@ class OpendkimDeployer(Deployer):
_su_user="opendkim", _su_user="opendkim",
) )
self.put_file( service_file = files.put(
"opendkim/systemd.conf", name="Configure opendkim to restart once a day",
"/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf", src=get_resource("opendkim/systemd.conf"),
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
) )
need_restart |= service_file.changed
files.file( files.file(
name="chown opendkim: /etc/dkimkeys/opendkim.private", name="chown opendkim: /etc/dkimkeys/opendkim.private",
@@ -82,5 +110,15 @@ class OpendkimDeployer(Deployer):
group="opendkim", group="opendkim",
) )
self.need_restart = need_restart
def activate(self): def activate(self):
self.ensure_service("opendkim.service") systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
daemon_reload=self.need_restart,
restarted=self.need_restart,
)
self.need_restart = False

View File

@@ -1,10 +1,11 @@
from pyinfra.operations import apt, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class PostfixDeployer(Deployer): class PostfixDeployer(Deployer):
required_users = [("postfix", None, ["opendkim"])] required_users = [("postfix", None, ["opendkim"])]
daemon_reload = False
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
@@ -18,46 +19,81 @@ class PostfixDeployer(Deployer):
def configure(self): def configure(self):
config = self.config config = self.config
need_restart = False
self.put_template( main_config = files.template(
"postfix/main.cf.j2", src=get_resource("postfix/main.cf.j2"),
"/etc/postfix/main.cf", dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
self.put_template( master_config = files.template(
"postfix/master.cf.j2", src=get_resource("postfix/master.cf.j2"),
"/etc/postfix/master.cf", dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=False, debug=False,
config=config, config=config,
) )
need_restart |= master_config.changed
self.put_file( header_cleanup = files.put(
"postfix/submission_header_cleanup", src=get_resource("postfix/submission_header_cleanup"),
"/etc/postfix/submission_header_cleanup", dest="/etc/postfix/submission_header_cleanup",
user="root",
group="root",
mode="644",
) )
self.put_file("postfix/lmtp_header_cleanup", "/etc/postfix/lmtp_header_cleanup") need_restart |= header_cleanup.changed
res = self.put_file( lmtp_header_cleanup = files.put(
"postfix/smtp_tls_policy_map", "/etc/postfix/smtp_tls_policy_map" src=get_resource("postfix/lmtp_header_cleanup"),
dest="/etc/postfix/lmtp_header_cleanup",
user="root",
group="root",
mode="644",
) )
tls_policy_changed = res.changed need_restart |= lmtp_header_cleanup.changed
if tls_policy_changed:
tls_policy_map = files.put(
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
src=get_resource("postfix/smtp_tls_policy_map"),
dest="/etc/postfix/smtp_tls_policy_map",
user="root",
group="root",
mode="644",
)
need_restart |= tls_policy_map.changed
if tls_policy_map.changed:
server.shell( server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"], commands=["postmap /etc/postfix/smtp_tls_policy_map"],
) )
# Login map that 1:1 maps email address to login. # Login map that 1:1 maps email address to login.
self.put_file("postfix/login_map", "/etc/postfix/login_map") login_map = files.put(
src=get_resource("postfix/login_map"),
self.put_file( dest="/etc/postfix/login_map",
"service/10_restart_on_failure.conf", user="root",
"/etc/systemd/system/postfix@.service.d/10_restart.conf", group="root",
mode="644",
) )
need_restart |= login_map.changed
restart_conf = files.put(
name="postfix: restart automatically on failure",
src=get_resource("service/10_restart.conf"),
dest="/etc/systemd/system/postfix@.service.d/10_restart.conf",
)
self.daemon_reload = restart_conf.changed
# Validate postfix configuration before restart # Validate postfix configuration before restart
if self.need_restart: if need_restart:
server.shell( server.shell(
name="Validate postfix configuration", name="Validate postfix configuration",
# Extract stderr and quit with error if non-zero # Extract stderr and quit with error if non-zero
@@ -65,11 +101,19 @@ class PostfixDeployer(Deployer):
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'""" """bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
], ],
) )
self.need_restart = need_restart
def activate(self): def activate(self):
active = not self.disable_mail restart = False if self.disable_mail else self.need_restart
self.ensure_service(
"postfix.service", systemd.service(
running=active, name="disable postfix for now"
enabled=active, if self.disable_mail
else "Start and enable Postfix",
service="postfix.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False

View File

@@ -100,5 +100,3 @@ smtpd_peername_lookup = no
# so instead this is handled in filtermail. # so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix. # We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }} default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000

View File

@@ -101,7 +101,7 @@ filter unix - n n - - lmtp
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
lmtp-filtermail unix - - y - 10000 lmtp lmtp-filtermail unix - - y - - lmtp
-o syslog_name=postfix/lmtp-filtermail -o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks= -o lmtp_header_checks=
-o lmtp_tls_security_level=none -o lmtp_tls_security_level=none

View File

@@ -1,8 +1,8 @@
import shlex import shlex
from pyinfra.operations import server from pyinfra.operations import apt, server
from ..basedeploy import Deployer from cmdeploy.basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500): def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
@@ -34,7 +34,11 @@ class SelfSignedTlsDeployer(Deployer):
self.cert_path = "/etc/ssl/certs/mailserver.pem" self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key" self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self): def configure(self):
args = openssl_selfsigned_args( args = openssl_selfsigned_args(
@@ -48,5 +52,3 @@ class SelfSignedTlsDeployer(Deployer):
def activate(self): def activate(self):
pass pass

View File

@@ -1,118 +0,0 @@
from unittest.mock import MagicMock, patch
from cmdeploy.basedeploy import Deployer
def test_put_file_restart_and_reload():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res):
deployer.put_file("foo.conf", "/etc/foo.conf")
assert deployer.need_restart is True
assert deployer.daemon_reload is False
deployer = Deployer()
deployer.put_file("test.service", "/etc/systemd/system/test.service")
assert deployer.need_restart is True
assert deployer.daemon_reload is True
def test_remove_file():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.file", return_value=mock_res) as mock_file:
deployer.remove_file("/etc/foo.conf")
mock_file.assert_called_once_with(
name="Remove /etc/foo.conf", path="/etc/foo.conf", present=False
)
assert deployer.need_restart is True
def test_ensure_systemd_unit():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
# Plain service file
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit("iroh-relay.service")
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/iroh-relay.service"
)
assert deployer.need_restart is True
assert deployer.daemon_reload is True
deployer = Deployer()
# Template (.j2) dispatches to put_template and strips .j2 suffix
with patch("cmdeploy.basedeploy.files.template", return_value=mock_res) as mock_tpl:
deployer.ensure_systemd_unit(
"filtermail/chatmaild.service.j2",
bin_path="/usr/local/bin/filtermail",
)
assert (
mock_tpl.call_args.kwargs["dest"] == "/etc/systemd/system/chatmaild.service"
)
deployer = Deployer()
# Explicit dest_name override
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit(
"acmetool/acmetool-reconcile.timer",
dest_name="acmetool-reconcile.timer",
)
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/acmetool-reconcile.timer"
)
def test_ensure_service():
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("nginx.service")
mock_svc.assert_called_once_with(
name="Start and enable nginx.service",
service="nginx.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
# daemon_reload is cleared to avoid multiple systemctl daemon-reload calls
# need_restart is kept to ensure all subsequent services also restart
assert deployer.need_restart is True
assert deployer.daemon_reload is False
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Stopping suppresses restarted even when need_restart is True
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service(
"mta-sts-daemon.service",
running=False,
enabled=False,
)
assert mock_svc.call_args.kwargs["restarted"] is False
assert deployer.need_restart is True
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Multiple calls: daemon_reload resets after first, need_restart persists
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("chatmaild.service")
deployer.ensure_service("chatmaild-metadata.service")
second_call = mock_svc.call_args_list[1]
assert second_call.kwargs["restarted"] is True
assert second_call.kwargs["daemon_reload"] is False

View File

@@ -98,15 +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.
A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_.
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
Other helpful commands Other helpful commands
---------------------- ----------------------