Compare commits

..

14 Commits

34 changed files with 789 additions and 635 deletions

View File

@@ -9,8 +9,6 @@ on:
pull_request:
branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -20,21 +18,18 @@ concurrency:
jobs:
no-dns:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928
with:
cmlxc_version: v0.14.6
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0
cmlxc -v test-cmdeploy cm0
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --no-dns cm0
cmlxc -v test-cmdeploy --no-dns cm0
# cross cmdeploy relay test (two ipv4 relays)
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1
cmlxc -v test-cmdeploy cm0 cm1
# cross cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo cm1
cmlxc -v test-cmdeploy --no-dns cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini mad0 cm0
cmlxc -v test-mini cm0 mad0
cmlxc -v test-cmdeploy --no-dns cm0 mad0

View File

@@ -57,9 +57,8 @@ jobs:
lxc-test:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.14.6
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@d39fe34c39cee6d760c3479325e8dc82b66a8928
with:
cmlxc_version: v0.14.6
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
@@ -76,4 +75,3 @@ jobs:
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini cm0 mad0
cmlxc -v test-mini mad0 cm0

View File

@@ -198,3 +198,18 @@ def is_valid_ipv4(address: str) -> bool:
return True
except ValueError:
return False
def format_arpa_address(address: str) -> str:
if is_valid_ipv4(address):
return ipaddress.IPv4Address(address).reverse_pointer
DomainValidator().validate_domain_re(address)
return address
def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain

View File

@@ -70,9 +70,6 @@ class Metadata:
# Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens:
pass
elif isinstance(tokens, list):
with self._modify_tokens(addr) as tokens:
token_list = list(tokens.keys())
else:
token_list = []
return token_list
@@ -88,27 +85,29 @@ class MetadataDictProxy(DictProxy):
def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
match parts[0].split("/", 2):
case ["priv", _, keyname] if keyname == self.metadata.DEVICETOKEN_KEY:
addr = parts[1]
keyparts = parts[0].split("/", 2)
if keyparts[0] == "priv":
keyname = keyparts[2]
addr = parts[1]
if keyname == self.metadata.DEVICETOKEN_KEY:
res = " ".join(self.metadata.get_tokens_for_addr(addr))
return f"O{res}\n"
case ["shared", _, keyname]:
prefix = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/"
if keyname.startswith(prefix):
match keyname[len(prefix) :]:
case "irohrelay" if self.iroh_relay:
return f"O{self.iroh_relay}\n"
case "turn":
try:
res = turn_credentials()
except Exception:
logging.exception("failed to get TURN credentials")
return "N\n"
return f"O{self.turn_hostname}:3478:{res}\n"
case "maxsmtprecipients":
# postfix default (see "postconf smtpd_recipient_limit")
return "O1000\n"
elif keyparts[0] == "shared":
keyname = keyparts[2]
if (
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
and self.iroh_relay
):
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
return f"O{self.iroh_relay}\n"
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
try:
res = turn_credentials()
except Exception:
logging.exception("failed to get TURN credentials")
return "N\n"
port = 3478
return f"O{self.turn_hostname}:{port}:{res}\n"
logging.warning(f"lookup ignored: {parts!r}")
return "N\n"
@@ -118,13 +117,12 @@ class MetadataDictProxy(DictProxy):
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else ""
match keyname:
case ["priv", _, key] if key == self.metadata.DEVICETOKEN_KEY:
self.metadata.add_token_to_addr(addr, value)
return True
case ["priv", _, "messagenew"]:
self.notifier.new_message_for_addr(addr, self.metadata)
return True
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY:
self.metadata.add_token_to_addr(addr, value)
return True
elif keyname[0] == "priv" and keyname[2] == "messagenew":
self.notifier.new_message_for_addr(addr, self.metadata)
return True
return False

View File

@@ -1,6 +1,10 @@
from contextlib import nullcontext as does_not_raise
import pytest
from chatmaild.config import (
format_arpa_address,
format_mail_domain,
is_valid_ipv4,
parse_size_mb,
read_config,
@@ -159,3 +163,31 @@ def test_max_mailbox_size_mb(make_config):
)
def test_is_valid_ipv4(input, result):
assert result == is_valid_ipv4(input)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "7.3.3.1.in-addr.arpa", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_arpa_address(input, result, exception):
with exception:
assert result == format_arpa_address(input)
@pytest.mark.parametrize(
["input", "result", "exception"],
[
("example.org", "example.org", does_not_raise()),
("1.3.3.7", "[1.3.3.7]", does_not_raise()),
("fe::1", None, pytest.raises(ValueError)),
("12394142", None, pytest.raises(ValueError)),
],
)
def test_format_mail_domain(input, result, exception):
with exception:
assert result == format_mail_domain(input)

View File

@@ -360,39 +360,15 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
def test_iroh_relay(dictproxy):
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org"
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
rfile = io.BytesIO(
b"\n".join(
[
b"H",
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
]
)
)
wfile = io.BytesIO()
dictproxy.iroh_relay = "https://example.org/"
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
mdict = metadata.get_metadata_dict(testaddr).read()
tokens = mdict[metadata.DEVICETOKEN_KEY]
assert isinstance(tokens, dict)
assert "oldtoken1" in tokens and "oldtoken2" in tokens
@pytest.mark.parametrize(
"suffix, expected",
[
(b"vendor/deltachat/maxsmtprecipients", b"O1000\n"),
(b"wrong/prefix/key", b"N\n"),
(b"vendor/deltachat/unknown", b"N\n"),
],
ids=["maxsmtprecipients", "prefix_mismatch", "unknown_name"],
)
def test_shared_lookup(dictproxy, suffix, expected):
key = (
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/"
+ suffix
+ b"\tuser@example.org"
)
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == expected

View File

@@ -48,8 +48,6 @@ def test_migration(tmp_path, example_config, caplog):
assert passdb_path.stat().st_size > 10000
example_config.passdb_path = passdb_path
# ensure logging.info records are captured regardless of global configuration
caplog.set_level("INFO")
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
@@ -7,6 +9,9 @@ class AcmetoolDeployer(Deployer):
def __init__(self, email, domains):
self.domains = domains
self.email = email
self.need_restart_redirector = False
self.need_restart_reconcile_service = False
self.need_restart_reconcile_timer = False
def install(self):
apt.packages(
@@ -14,41 +19,121 @@ class AcmetoolDeployer(Deployer):
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")
self.remove_file("/usr/lib/acme/hooks/nginx")
files.put(
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):
self.put_template(
"acmetool/response-file.yaml.j2",
"/var/lib/acme/conf/responses",
files.template(
src=importlib.resources.files(__package__).joinpath(
"response-file.yaml.j2"
),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=self.email,
)
self.put_template(
"acmetool/target.yaml.j2",
"/var/lib/acme/conf/target",
files.template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
)
server.shell(
name=f"Remove old acmetool desired files for {self.domains[0]}",
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
)
self.put_template(
"acmetool/desired.yaml.j2",
f"/var/lib/acme/desired/{self.domains[0]}",
files.template(
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
user="root",
group="root",
mode="644",
domains=self.domains,
)
self.ensure_systemd_unit("acmetool/acmetool-redirector.service")
self.ensure_systemd_unit("acmetool/acmetool-reconcile.service")
self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer")
service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"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):
self.ensure_service("acmetool-redirector.service")
self.ensure_service("acmetool-reconcile.service", running=False, enabled=False)
self.ensure_service("acmetool-reconcile.timer")
systemd.service(
name="Setup acmetool-redirector service",
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(
name=f"Reconcile certificates for: {', '.join(self.domains)}",

View File

@@ -4,7 +4,6 @@ import os
from contextlib import contextmanager
from pyinfra import host
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command
from pyinfra.operations import files, server, systemd
@@ -51,10 +50,11 @@ def get_resource(arg, pkg=__package__):
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_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
# install systemd 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")
content = source_path.read_text().format(**params).encode()
deployer.put_file(
files.put(
name=f"Upload {basename}",
src=io.BytesIO(content),
dest=f"/etc/systemd/system/{basename}",
**root_owned,
)
def activate_remote_units(deployer, units) -> None:
def activate_remote_units(units) -> None:
# activate systemd units
for fn in units:
basename = fn if "." in fn else f"{fn}.service"
@@ -86,8 +88,14 @@ def activate_remote_units(deployer, units) -> None:
enabled = False
else:
enabled = True
deployer.ensure_service(basename, running=enabled, enabled=enabled)
systemd.service(
name=f"Setup {basename}",
service=basename,
running=enabled,
enabled=enabled,
restarted=enabled,
daemon_reload=True,
)
class Deployment:
@@ -133,7 +141,6 @@ class Deployment:
class Deployer:
need_restart = False
daemon_reload = False
def install(self):
pass
@@ -143,113 +150,3 @@ class Deployer:
def activate(self):
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.api import FactBase
from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd
@@ -24,6 +25,7 @@ from .basedeploy import (
activate_remote_units,
blocked_service_startup,
configure_remote_units,
get_resource,
has_systemd,
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()
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
remote_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv"
root_owned = dict(user="root", group="root", mode="644")
apt.packages(
name="apt install python3-virtualenv",
packages=["python3-virtualenv"],
)
deployer.ensure_directory(f"{remote_base_dir}/dist")
deployer.put_file(
files.put(
name="Upload chatmaild source package",
src=dist_file.open("rb"),
dest=remote_dist_file,
create_remote_dir=True,
**root_owned,
)
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_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(),
dest=remote_chatmail_inipath,
**root_owned,
)
deployer.remove_file("/etc/cron.d/chatmail-metrics")
deployer.remove_file("/var/www/html/metrics")
files.file(
path="/etc/cron.d/chatmail-metrics",
present=False,
)
files.file(
path="/var/www/html/metrics",
present=False,
)
class UnboundDeployer(Deployer):
def __init__(self, config):
self.config = config
self.need_restart = False
def install(self):
# 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
# (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"),
dest="/etc/resolv.conf",
user="root",
group="root",
mode="644",
)
server.shell(
name="Generate root keys for validating DNSSEC",
@@ -172,15 +191,26 @@ class UnboundDeployer(Deployer):
],
)
if self.config.disable_ipv6:
self.ensure_directory(
files.directory(
path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
)
self.put_template(
"unbound/unbound.conf.j2",
"/etc/unbound/unbound.conf.d/chatmail.conf",
conf = files.put(
src=get_resource("unbound/unbound.conf.j2"),
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
)
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):
server.shell(
@@ -190,25 +220,27 @@ class UnboundDeployer(Deployer):
],
)
self.ensure_service("unbound.service")
self.ensure_service(
"unbound-resolvconf.service",
running=False,
enabled=False,
systemd.service(
name="Start and enable unbound",
service="unbound.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
class MtastsDeployer(Deployer):
def configure(self):
# Remove configuration.
self.remove_file("/etc/mta-sts-daemon.yml")
self.remove_directory("/usr/local/lib/postfix-mta-sts-resolver")
self.remove_file("/etc/systemd/system/mta-sts-daemon.service")
files.file("/etc/mta-sts-daemon.yml", present=False)
files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False)
files.file("/etc/systemd/system/mta-sts-daemon.service", present=False)
def activate(self):
self.ensure_service(
"mta-sts-daemon.service",
systemd.service(
name="Stop MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=False,
enabled=False,
)
@@ -219,7 +251,14 @@ class WebsiteDeployer(Deployer):
self.config = config
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):
www_path, src_dir, build_dir = get_paths(self.config)
@@ -249,11 +288,15 @@ class LegacyRemoveDeployer(Deployer):
# remove historic expunge script
# 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.
self.remove_file("/etc/apt/keyrings/obs-home-deltachat.gpg")
self.ensure_line(
files.file("/etc/apt/keyrings/obs-home-deltachat.gpg", present=False)
files.line(
name="Remove DeltaChat OBS home repository from 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/ ./",
escape_regex_characters=True,
@@ -261,7 +304,11 @@ class LegacyRemoveDeployer(Deployer):
)
# 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
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service(
@@ -303,13 +350,22 @@ class TurnDeployer(Deployer):
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}[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):
configure_remote_units(self, self.mail_domain, self.units)
configure_remote_units(self.mail_domain, self.units)
def activate(self):
activate_remote_units(self, self.units)
activate_remote_units(self.units)
class IrohDeployer(Deployer):
@@ -327,30 +383,72 @@ class IrohDeployer(Deployer):
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}[host.get_fact(facts.server.Arch)]
self.download_executable(
url,
"/usr/local/bin/iroh-relay",
sha256sum,
extract="gunzip | tar -xf - ./iroh-relay -O",
)
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
if existing_sha256sum != sha256sum:
server.shell(
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):
self.ensure_systemd_unit("iroh-relay.service")
self.put_file("iroh-relay.toml", "/etc/iroh-relay.toml")
systemd_unit = files.put(
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):
self.ensure_service(
"iroh-relay.service",
systemd.service(
name="Start and enable iroh-relay",
service="iroh-relay.service",
running=True,
enabled=self.enable_iroh_relay,
restarted=self.need_restart,
)
self.need_restart = False
class JournaldDeployer(Deployer):
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):
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):
@@ -366,14 +464,14 @@ class ChatmailVenvDeployer(Deployer):
)
def install(self):
_install_remote_venv_with_chatmaild(self)
_install_remote_venv_with_chatmaild()
def configure(self):
_configure_remote_venv_with_chatmaild(self, self.config)
configure_remote_units(self, self.config.mail_domain_bare, self.units)
_configure_remote_venv_with_chatmaild(self.config)
configure_remote_units(self.config.mail_domain_bare, self.units)
def activate(self):
activate_remote_units(self, self.units)
activate_remote_units(self.units)
class ChatmailDeployer(Deployer):
@@ -387,9 +485,13 @@ class ChatmailDeployer(Deployer):
self.mail_domain = config.mail_domain
def install(self):
self.put_file(
files.put(
name="Disable installing recommended packages globally",
src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends",
user="root",
group="root",
mode="644",
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -406,10 +508,13 @@ class ChatmailDeployer(Deployer):
def configure(self):
# metadata crashes if the mailboxes dir does not exist
self.ensure_directory(
str(self.config.mailboxes_dir),
owner="vmail",
files.directory(
name="Ensure vmail mailbox directory exists",
path=str(self.config.mailboxes_dir),
user="vmail",
group="vmail",
mode="700",
present=True,
)
# This file is used by auth proxy.
@@ -430,7 +535,12 @@ class FcgiwrapDeployer(Deployer):
)
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):
@@ -443,7 +553,12 @@ class GithashDeployer(Deployer):
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
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):
@@ -476,17 +591,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
return
# Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in (
"127.0.0.1",
"::1",
"localhost",
):
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses:
Out().red(
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
)
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1)
if not is_in_container():
@@ -540,7 +649,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
WebsiteDeployer(config),
ChatmailVenvDeployer(config),
MtastsDeployer(),
*([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]),
OpendkimDeployer(config.mail_domain),
# Dovecot should be started before Postfix
# because it creates authentication socket
# required by Postfix.

View File

@@ -5,13 +5,14 @@ from chatmaild.config import Config
from pyinfra import host
from pyinfra.facts.deb import DebPackages
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 (
Deployer,
activate_remote_units,
blocked_service_startup,
configure_remote_units,
get_resource,
is_in_container,
)
@@ -58,21 +59,26 @@ class DovecotDeployer(Deployer):
],
)
self.need_restart = True
self.put_file(
files.put(
name="Pin dovecot packages to block Debian dist-upgrades",
src=io.StringIO(
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
dest="/etc/apt/preferences.d/pin-dovecot",
user="root",
group="root",
mode="644",
)
def configure(self):
configure_remote_units(self, self.config.mail_domain_bare, self.units)
_configure_dovecot(self, self.config)
configure_remote_units(self.config.mail_domain_bare, self.units)
config_restart, self.daemon_reload = _configure_dovecot(self.config)
self.need_restart |= config_restart
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.
if not self.disable_mail and not self.need_restart:
@@ -85,12 +91,19 @@ class DovecotDeployer(Deployer):
if stale == "STALE":
self.need_restart = True
active = not self.disable_mail
self.ensure_service(
"dovecot.service",
running=active,
enabled=active,
restart = False if self.disable_mail else self.need_restart
systemd.service(
name="Disable dovecot for now"
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):
@@ -134,19 +147,39 @@ 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):
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
"""Configures Dovecot IMAP server."""
deployer.put_template(
"dovecot/dovecot.conf.j2",
"/etc/dovecot/dovecot.conf",
need_restart = False
daemon_reload = False
main_config = files.template(
src=get_resource("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config=config,
debug=debug,
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"
need_restart |= main_config.changed
auth_config = files.put(
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/
# it is recommended to set the following inotify limits
@@ -170,20 +203,25 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
persist=True,
)
deployer.ensure_line(
timezone_env = files.line(
name="Set TZ environment variable",
path="/etc/environment",
line="TZ=:/etc/localtime",
)
need_restart |= timezone_env.changed
deployer.put_file(
"service/10_restart_on_failure.conf",
"/etc/systemd/system/dovecot.service.d/10_restart.conf",
restart_conf = files.put(
name="dovecot: restart automatically on failure",
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
if deployer.need_restart:
if need_restart:
server.shell(
name="Validate dovecot configuration",
commands=["doveconf -n >/dev/null"],
)
return need_restart, daemon_reload

View File

@@ -1,7 +1,10 @@
import io
from pyinfra import host
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):
@@ -20,22 +23,45 @@ class ExternalTlsDeployer(Deployer):
def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts.
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}")
self.ensure_systemd_unit(
"external/tls-cert-reload.path.j2",
cert_path=self.cert_path,
)
self.ensure_systemd_unit(
"external/tls-cert-reload.service",
# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# 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",
)
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):
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.
self.ensure_service(
"tls-cert-reload.path",
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=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
[Path]
PathChanged={{ cert_path }}
PathChanged={cert_path}
[Install]
WantedBy=multi-user.target

View File

@@ -1,8 +1,9 @@
import os
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):
@@ -10,13 +11,18 @@ class FiltermailDeployer(Deployer):
bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def __init__(self):
self.need_restart = False
def install(self):
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY")
if local_bin:
self.put_executable(
self.need_restart |= files.put(
name="Upload locally built filtermail",
src=local_bin,
dest=self.bin_path,
)
mode="755",
).changed
return
arch = host.get_fact(facts.server.Arch)
@@ -25,16 +31,34 @@ class FiltermailDeployer(Deployer):
"x86_64": "5295115952c72e4c4ec3c85546e094b4155a4c702c82bd71fcdcb744dc73adf6",
"aarch64": "6892244f17b8f26ccb465766e96028e7222b3c8adefca9fc6bfe9ff332ca8dff",
}[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):
for service in self.services:
self.ensure_systemd_unit(
f"filtermail/{service}.service.j2",
self.need_restart |= files.template(
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,
config_path=self.config_path,
)
).changed
def activate(self):
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.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):
@@ -15,30 +18,51 @@ class MtailDeployer(Deployer):
(url, sha256sum) = {
"x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"d55cb601049c5e61eabab29998dbbcea95d480e5448544f9470337ba2eea882e",
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
),
"aarch64": (
"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)]
self.download_executable(
url,
"/usr/local/bin/mtail",
sha256sum,
extract="gunzip | tar -xf - mtail -O",
server.shell(
name="Download mtail",
commands=[
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):
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
# This allows to read from journalctl instead of log files.
self.ensure_systemd_unit(
"mtail/mtail.service.j2",
files.template(
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",
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):
active = bool(self.mtail_address)
self.ensure_service("mtail.service", running=active, enabled=active)
systemd.service(
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]
Description=mtail
After=network-online.target
Wants=network-online.target
After=multi-user.target
[Service]
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 -"
Restart=on-failure
RestartSec=2s
[Install]
WantedBy=multi-user.target

View File

@@ -1,5 +1,5 @@
from chatmaild.config import Config
from pyinfra.operations import apt
from pyinfra.operations import apt, files, systemd
from cmdeploy.basedeploy import (
Deployer,
@@ -31,50 +31,87 @@ class NginxDeployer(Deployer):
# For documentation about policy-rc.d, see:
# 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(
name="Install nginx",
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):
_configure_nginx(self, self.config)
self.need_restart = _configure_nginx(self.config)
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."""
need_restart = False
deployer.put_template(
"nginx/nginx.conf.j2",
"/etc/nginx/nginx.conf",
main_config = files.template(
src=get_resource("nginx/nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config=config,
disable_ipv6=config.disable_ipv6,
)
need_restart |= main_config.changed
deployer.put_template(
"nginx/autoconfig.xml.j2",
"/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
autoconfig = files.template(
src=get_resource("nginx/autoconfig.xml.j2"),
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config=config,
)
need_restart |= autoconfig.changed
deployer.put_template(
"nginx/mta-sts.txt.j2",
"/var/www/html/.well-known/mta-sts.txt",
mta_sts_config = files.template(
src=get_resource("nginx/mta-sts.txt.j2"),
dest="/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config=config,
)
need_restart |= mta_sts_config.changed
# install CGI newemail script
#
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"),
dest=f"{cgi_dir}/newemail.py",
user="root",
group="root",
mode="755",
)
return need_restart

View File

@@ -42,9 +42,6 @@ stream {
}
http {
# access_log setting is inherited by all server sections
access_log syslog:server=unix:/dev/log,facility=local7;
{% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %}
@@ -72,7 +69,9 @@ http {
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;
location /mxdeliv {
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
@@ -144,6 +143,7 @@ http {
listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
}
server {

View File

@@ -4,9 +4,9 @@ Installs OpenDKIM
from pyinfra import host
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):
@@ -25,39 +25,65 @@ class OpendkimDeployer(Deployer):
domain = self.mail_domain
dkim_selector = "opendkim"
"""Configures OpenDKIM"""
need_restart = False
self.put_template(
"opendkim/opendkim.conf",
"/etc/opendkim.conf",
main_config = files.template(
src=get_resource("opendkim/opendkim.conf"),
dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
self.remove_file("/etc/opendkim/screen.lua")
self.remove_file("/etc/opendkim/final.lua")
screen_script = files.file(
path="/etc/opendkim/screen.lua",
present=False,
)
need_restart |= screen_script.changed
self.ensure_directory(
"/etc/opendkim",
owner="opendkim",
final_script = files.file(
path="/etc/opendkim/final.lua",
present=False,
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
self.put_template(
"opendkim/KeyTable",
"/etc/dkimkeys/KeyTable",
owner="opendkim",
keytable = files.template(
src=get_resource("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
self.put_template(
"opendkim/SigningTable",
"/etc/dkimkeys/SigningTable",
owner="opendkim",
signing_table = files.template(
src=get_resource("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
self.ensure_directory(
"/var/spool/postfix/opendkim",
owner="opendkim",
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
@@ -70,10 +96,12 @@ class OpendkimDeployer(Deployer):
_su_user="opendkim",
)
self.put_file(
"opendkim/systemd.conf",
"/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
service_file = files.put(
name="Configure opendkim to restart once a day",
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(
name="chown opendkim: /etc/dkimkeys/opendkim.private",
@@ -82,5 +110,15 @@ class OpendkimDeployer(Deployer):
group="opendkim",
)
self.need_restart = need_restart
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):
required_users = [("postfix", None, ["opendkim"])]
daemon_reload = False
def __init__(self, config, disable_mail):
self.config = config
@@ -18,46 +19,81 @@ class PostfixDeployer(Deployer):
def configure(self):
config = self.config
need_restart = False
self.put_template(
"postfix/main.cf.j2",
"/etc/postfix/main.cf",
main_config = files.template(
src=get_resource("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config=config,
disable_ipv6=config.disable_ipv6,
)
need_restart |= main_config.changed
self.put_template(
"postfix/master.cf.j2",
"/etc/postfix/master.cf",
master_config = files.template(
src=get_resource("postfix/master.cf.j2"),
dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=False,
config=config,
)
need_restart |= master_config.changed
self.put_file(
"postfix/submission_header_cleanup",
"/etc/postfix/submission_header_cleanup",
header_cleanup = files.put(
src=get_resource("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(
"postfix/smtp_tls_policy_map", "/etc/postfix/smtp_tls_policy_map"
lmtp_header_cleanup = files.put(
src=get_resource("postfix/lmtp_header_cleanup"),
dest="/etc/postfix/lmtp_header_cleanup",
user="root",
group="root",
mode="644",
)
tls_policy_changed = res.changed
if tls_policy_changed:
need_restart |= lmtp_header_cleanup.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(
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
)
# Login map that 1:1 maps email address to login.
self.put_file("postfix/login_map", "/etc/postfix/login_map")
self.put_file(
"service/10_restart_on_failure.conf",
"/etc/systemd/system/postfix@.service.d/10_restart.conf",
login_map = files.put(
src=get_resource("postfix/login_map"),
dest="/etc/postfix/login_map",
user="root",
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
if self.need_restart:
if need_restart:
server.shell(
name="Validate postfix configuration",
# 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; }'"""
],
)
self.need_restart = need_restart
def activate(self):
active = not self.disable_mail
self.ensure_service(
"postfix.service",
running=active,
enabled=active,
restart = False if self.disable_mail else self.need_restart
systemd.service(
name="disable postfix for now"
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

@@ -60,7 +60,6 @@ alias_database = hash:/etc/aliases
# When postfix receives mail for $mydestination,
# it hands it over to dovecot via $local_transport.
# Note: IP literals must be handled via local delivery / mydestination.
mydestination = {{ config.mail_domain }}
local_transport = lmtp:unix:private/dovecot-lmtp
# postfix doesn't check whether local users exist or not:

View File

@@ -64,25 +64,21 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
)
def get_authoritative_ns(domain):
ns_replies = [
def query_dns(typ, domain):
# Get autoritative nameserver from the SOA record.
soa_answers = [
x.split()
for x in shell(
f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress
f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress
).split("\n")
]
filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"]
if not filtered_replies:
soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"]
if not soa:
return
return filtered_replies[0][4]
def query_dns(typ, domain):
ns = get_authoritative_ns(domain)
ns = soa[0][4]
# Query authoritative nameserver directly to bypass DNS cache.
direct_ns = f"@{ns}" if ns else ""
res = shell(f"dig {direct_ns} -r -q {domain} -t {typ} +short", print=log_progress)
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
return next((line for line in res.split("\n") if not line.startswith(";")), "")

View File

@@ -1,8 +1,8 @@
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):
@@ -34,7 +34,11 @@ class SelfSignedTlsDeployer(Deployer):
self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self):
args = openssl_selfsigned_args(
@@ -48,5 +52,3 @@ class SelfSignedTlsDeployer(Deployer):
def activate(self):
pass

View File

@@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath)
assert config.mail_domain_bare == maildomain
assert config.mail_domain == maildomain
def test_capabilities(imap):

View File

@@ -5,10 +5,10 @@ import subprocess
import time
import pytest
from chatmaild.config import is_valid_ipv4
from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec
from chatmaild.config import is_valid_ipv4
class TestSSHExecutor:
@@ -64,10 +64,8 @@ class TestSSHExecutor:
else:
pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec, maildomain):
def test_opendkim_restarted(self, sshexec):
"""check that opendkim is not running for longer than a day."""
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed")
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1]
@@ -286,15 +284,3 @@ def test_deployed_state(remote):
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes."
def test_nginx_access_log_only_defined_once(sshdomain):
sshexec = get_sshexec(sshdomain)
conf = sshexec(
call=remote.rshell.shell,
kwargs=dict(command="nginx -T 2>/dev/null"),
)
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
assert len(access_logs) == 1, (
f"expected 1 access_log, found {len(access_logs)}: {access_logs}"
)

View File

@@ -1,4 +1,5 @@
import imaplib
import ipaddress
import itertools
import os
import random
@@ -9,19 +10,20 @@ import time
from pathlib import Path
import pytest
from chatmaild.config import is_valid_ipv4, read_config
from domain_validator import DomainValidator
from chatmaild.config import read_config, format_mail_domain, is_valid_ipv4
def format_mail_domain(raw_domain: str) -> str:
if is_valid_ipv4(raw_domain):
return f"[{raw_domain}]"
DomainValidator().validate_domain_re(raw_domain)
return raw_domain
conftestdir = Path(__file__).parent
def _is_ip(domain):
try:
ipaddress.ip_address(domain)
return True
except ValueError:
return False
def pytest_configure(config):
config._benchresults = {}
config.addinivalue_line(
@@ -60,6 +62,11 @@ def maildomain(chatmail_config):
return chatmail_config.mail_domain_bare
@pytest.fixture(scope="session")
def maildomain_deliverable(maildomain):
return format_mail_domain(maildomain)
@pytest.fixture(scope="session")
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)
@@ -342,7 +349,7 @@ class ChatmailACFactory:
account = self.dc.add_account()
domain_deliverable = format_mail_domain(domain)
addr, password = self.gencreds(domain_deliverable)
if is_valid_ipv4(domain):
if _is_ip(domain):
# Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users.
qr = (
@@ -416,10 +423,10 @@ class Remote:
def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
if self.sshdomain in ("@local", "localhost"):
command = []
else:
command = ["ssh", f"root@{self.sshdomain}"]
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen(
command,

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

@@ -4,7 +4,6 @@ import pytest
from cmdeploy import remote
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
from cmdeploy.remote.rdns import get_authoritative_ns
@pytest.fixture
@@ -15,15 +14,11 @@ def mockdns_base(monkeypatch):
if command.startswith("dig"):
if command == "dig":
return "."
if "with.public.soa" in command and "NS" in command:
return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
if "with.hidden.soa" in command and "NS" in command:
if "SOA" in command:
return (
"domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n"
"domain.with.hidden.soa. 2137 IN NS ns2.desec.org."
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
" 2025102800 14400 1800 604800 3600"
)
if "NS" in command:
return "delta.chat. 21600 IN NS ns1.first-ns.de."
command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6]
try:
@@ -130,17 +125,6 @@ class TestPerformInitialChecks:
assert not l
@pytest.mark.parametrize(
("domain", "ns"),
[
("domain.with.public.soa", "ns1.first-ns.de."),
("domain.with.hidden.soa", "ns1.desec.io."),
],
)
def test_get_authoritative_ns(domain, ns, mockdns):
assert get_authoritative_ns(domain) == ns
def test_parse_zone_records():
text = """
; This is a comment

View File

@@ -23,7 +23,8 @@ def make_host(*fact_pairs):
if cls not in facts:
registered = ", ".join(c.__name__ for c in facts)
raise LookupError(
f"unexpected get_fact({cls.__name__}); only registered: {registered}"
f"unexpected get_fact({cls.__name__}); "
f"only registered: {registered}"
)
return facts[cls]

View File

@@ -15,7 +15,6 @@ goes beyond what classic email servers offer:
streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_;
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
(DKIM is not enforced on :ref:`IP-only relays <iponly>`)
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
depends on established IETF standards and protocols.
@@ -48,28 +47,6 @@ Dovecot, and are configured to run unattended without much maintenance
effort. Chatmail relays happily run on low-end hardware like a Raspberry
Pi.
.. _upgrade:
How can I upgrade my chatmail relay?
------------------------------------
To upgrade to the latest ``main`` branch,
``cd`` into your local checkout of `https://github.com/chatmail/relay/`_
and run the following commands:
::
git pull origin main --rebase --autostash
scripts/cmdeploy run
If you don't want the latest development version,
but a specific tagged release like `1.10.0 <https://github.com/chatmail/relay/releases/tag/1.10.0>`_,
run ``git pull origin 1.10.0`` instead.
If you made local changes for your setup,
they will be reapplied as long as they conflict with the upgrade.
If a conflict arises, ``git status`` will tell you how to resolve it.
How trustable are chatmail relays?
----------------------------------

View File

@@ -27,7 +27,7 @@ You will need the following:
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
- Control over a domain through a DNS provider of your choice
(there is experimental support for :ref:`IP-only relays <iponly>`).
(there is experimental support for :ref:`DNS-less relays <iponly>`).
.. _setup:

View File

@@ -24,17 +24,6 @@ Drawbacks
the chatmail core's end-to-end encryption should suffice in most scenarios though.
- your messages will not be DKIM-signed;
experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays,
experimentally, most chatmail relays accept non-DKIM-signed messages from IPv4-only relays,
but some relays might not accept messages from yours.
Email addresses
---------------
When running without a domain,
your chatmail addresses will use the IPv4 address
in brackets as the domain part,
for example ``user@[13.12.23.42]``.
This is a valid email address format
according to :rfc:`5321`.

View File

@@ -265,8 +265,7 @@ from the chatmail relay server.
Email domain authentication (DKIM)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails
(except for :ref:`IP-only relays <iponly>`).
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
header) equal to the ``From:`` header domain. This property is checked