Compare commits

..

32 Commits

Author SHA1 Message Date
holger krekel
eb1424f944 fixup after testing on nine:
- don't remove large files already after 7 days if they are in the "new/" folder
- report which mailbox is being checked so that "journalctl -u
  chatmail-expire.service" provides sufficient output for checking
- don't trigger expiry or fsreport services during cmdeploy-run but run it from timer only
2025-10-21 21:49:58 +02:00
holger krekel
0931da21b8 make sure fsreport can run on empty mailbox dir 2025-10-21 18:43:37 +02:00
holger krekel
11a8f8cf9e try fix CI 2025-10-21 18:43:37 +02:00
holger krekel
0aa255e3f1 replace expunge mentioning in architecture 2025-10-21 18:43:37 +02:00
holger krekel
6c4764b452 Apply suggestions from code review
fix typo

Co-authored-by: l <link2xt@testrun.org>
2025-10-21 18:43:37 +02:00
holger krekel
c1f08a9afe simplify and beautify formatting and sizes 2025-10-21 18:43:37 +02:00
holger krekel
5c8afb377e also run fsreport 2025-10-21 18:43:37 +02:00
holger krekel
8225a9f398 use systemd timer instead of cron-job for expiry (tested by hand on c2) 2025-10-21 18:43:37 +02:00
holger krekel
eb221ca1af unify K output 2025-10-21 18:43:37 +02:00
holger krekel
93421b317b always use "H" for printing numbers, and make "chatmail.ini" file optional, defaulting to where it is on chatmail relays 2025-10-21 18:43:37 +02:00
holger krekel
777be107f3 fix another invocation 2025-10-21 18:43:37 +02:00
holger krekel
8b81d5b5d6 unify chatmail-fsreport and chatmail-expire to both just require a chatmail.ini file 2025-10-21 18:43:37 +02:00
holger krekel
e6a2906e82 cosmetic: refine summary and fix typo 2025-10-21 18:43:37 +02:00
holger krekel
67ba4ac99e address four review comments from link2xt 2025-10-21 18:43:37 +02:00
holger krekel
8cadf51387 prefix new commands 2025-10-21 18:43:37 +02:00
holger krekel
ce4bb97294 remove superflous totalsize attribute 2025-10-21 18:43:37 +02:00
holger krekel
3a0c629f3b during fsreport (reporting) don't store all mailbxoes but categorize them immediately, provide a few command line options to select 2025-10-21 18:43:37 +02:00
holger krekel
8df53c2655 fix lint issues 2025-10-21 18:43:37 +02:00
holger krekel
3fd3ab1a68 some renaming 2025-10-21 18:43:37 +02:00
holger krekel
d74f792787 remove superflous Stats class 2025-10-21 18:43:37 +02:00
holger krekel
1135372b81 further reduce code 2025-10-21 18:43:37 +02:00
holger krekel
c9f80bffd8 no reporting by default, and adding a summary line 2025-10-21 18:43:37 +02:00
holger krekel
10e53d17e8 don't globally collect files anymore to avoid using growing-with-number-of-mailboxes ram 2025-10-21 18:43:37 +02:00
holger krekel
01ca2a8b91 more streamline 2025-10-21 18:43:37 +02:00
holger krekel
fb01944f0d strike superflous code 2025-10-21 18:43:37 +02:00
holger krekel
a90a651ba0 fix comment 2025-10-21 18:43:37 +02:00
holger krekel
7d74b46502 add argument parsing for reporting 2025-10-21 18:43:37 +02:00
holger krekel
6d3e690653 add basic command line parsing for expire + some streamlining 2025-10-21 18:43:37 +02:00
holger krekel
ed7a70ba31 refactor and write tests for overall expiry/report runs 2025-10-21 18:43:37 +02:00
holger krekel
023116bc91 add summary reporting, rework expiry logic 2025-10-21 18:43:37 +02:00
holger krekel
b13929119b do all expunging in python 2025-10-21 18:43:37 +02:00
holger krekel
a4152140ca move delete_inactive_users to new implementation 2025-10-21 18:43:37 +02:00
23 changed files with 521 additions and 984 deletions

View File

@@ -1 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links:
- name: Mutual Help Chat Group
url: https://i.delta.chat/#6CBFF8FFD505C0FDEA20A66674F2916EA8FBEE99&a=invitebot%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=X69wTFfvCfs3d-JzqP0kVA3i&s=ibp-447dU-wUq-52QanwAtWc
about: If you have troubles setting up the relay server, feel free to ask here.

View File

@@ -1,6 +1,4 @@
## Chatmail Relay This diagram shows components of the chatmail server; this is a draft
This diagram shows components of the chatmail relay; this is a draft
overview as of mid-August 2025: overview as of mid-August 2025:
```mermaid ```mermaid
@@ -50,66 +48,3 @@ graph LR;
The edges in this graph should not be taken too literally; they The edges in this graph should not be taken too literally; they
reflect some sort of communication path or dependency relationship reflect some sort of communication path or dependency relationship
between components of the chatmail server. between components of the chatmail server.
## cmdeploy
cmdeploy is a Python program that uses the pyinfra library to deploy
chatmail servers, with all the necessary software, configuration, and
services. The deployment process performs three primary types of operation:
1. Installation of software, universal across all deployments.
2. Configuration of software, with deploy-specific variations.
3. Activation of services.
The process is implemented through a family of "deployer" objects
which all derive from a common `Deployer` base class, defined in
[deployer.py](cmdeploy/src/cmdeploy/deployer.py). Each object
provides implementation methods for the three stages -- install,
configure, and activate. The top-level procedure in
`deploy_chatmail()` calls these methods for all the deployer objects,
first calling all the install methods, then the configure methods,
then the activate methods.
The base class also implements support for a CMDEPLOY_STAGES
environment variable, which allows limiting the process to specific
stages. Note that some deployers are stateful between the stages
(this is one reason why they are implemented as objects), and that
state will not get propagated between stages when run in separate
invocations of cmdeploy. This environment variable is intended for
use in future revisions to support building Docker images with
software pre-installed, and configuration of containers at run time
from environmnet variables.
The `install_impl()` method for the deployer classes is static, to
ensure that it does not rely on any object state, in particular, the
configuration details of the deployment. This helps ensure that all
install methods are suitable for running as part of a container image
build.
Operations that start services for systemd-based deployments should
only be called from the `activate_impl()` methods. These methods will
not be called in non-systemd container environments.
### Deployer objects
One might ask why the deployers are implemented as object classes, as
opposed to callable functions or the like. There are various reasons
why objects are a good fit for the deployment process.
1. Objects provide a way to organize the install, configure, and
deploy operations for each component that is installed, supporting a
"driver" type of pattern. This could be implemented in other ways
without objects, such as function jump tables, but objects provide a
clean and formalized way to do essentially the same thing.
2. Class inheritance provides a natural way to define
component-specific operations for the various stages of deployment, by
overriding the no-op stub methods in the base class. The base class
handles policy decisions about which stages are to be executed,
ensuring consistent handling of the stages in a central location.
3. Some of the components track state between stages, basing decisions
like whether to restart a service on whether the software or
configuration of that service was changed in an earlier stage.
Keeping track of state between method calls is an ideal use case for
objects.

View File

@@ -2,21 +2,6 @@
## untagged ## untagged
- Organized cmdeploy into install, configure, and activate stages
([#695](https://github.com/chatmail/relay/pull/695))
- don't deploy the website if there are merge conflicts in the www folder
([#714](https://github.com/chatmail/relay/pull/714))
- acmetool: use ECDSA keys instead of RSA
([#689](https://github.com/chatmail/relay/pull/689))
- Require TLS 1.2 for outgoing SMTP connections
([#685](https://github.com/chatmail/relay/pull/685))
- require STARTTLS for incoming port 25 connections
([#684](https://github.com/chatmail/relay/pull/684))
- filtermail: run CPU-intensive handle_DATA in a thread pool executor - filtermail: run CPU-intensive handle_DATA in a thread pool executor
([#676](https://github.com/chatmail/relay/pull/676)) ([#676](https://github.com/chatmail/relay/pull/676))
@@ -36,7 +21,7 @@
([#650](https://github.com/chatmail/relay/pull/650)) ([#650](https://github.com/chatmail/relay/pull/650))
- filtermail: accept mails from Protonmail - filtermail: accept mails from Protonmail
([#616](https://github.com/chatmail/relay/pull/616)) ([#616](https://github.com/chatmail/relay/pull/655))
- Ignore all RCPT TO: parameters - Ignore all RCPT TO: parameters
([#651](https://github.com/chatmail/relay/pull/651)) ([#651](https://github.com/chatmail/relay/pull/651))
@@ -69,7 +54,7 @@
to only do a single iteration over sometimes millions of messages to only do a single iteration over sometimes millions of messages
instead of doing "find" commands that iterate 9 times over the messages. instead of doing "find" commands that iterate 9 times over the messages.
Provide an "fsreport" CLI for more fine grained analysis of message files. Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/637)) ([#637](https://github.com/chatmail/relay/pull/632))
## 1.7.0 2025-09-11 ## 1.7.0 2025-09-11

View File

@@ -180,10 +180,6 @@ The components of chatmail are:
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay) - [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
which helps client devices to establish Peer-to-Peer connections which helps client devices to establish Peer-to-Peer connections
- [TURN](https://github.com/chatmail/chatmail-turn)
to enable relay users to start webRTC calls
even if a p2p connection can't be established
- and the chatmaild services, explained in the next section: - and the chatmaild services, explained in the next section:
### chatmaild ### chatmaild
@@ -308,8 +304,6 @@ Chatmail address creation will be denied while this file is present.
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS). [Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993. Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP). [acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
[chatmail-turn](https://github.com/chatmail/chatmail-turn) listens on UDP port 3478 (STUN/TURN),
and temporarily opens UDP ports when users request them. UDP port range is not restricted, any free port may be allocated.
chatmail-core based apps will, however, discover all ports and configurations chatmail-core based apps will, however, discover all ports and configurations
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server. automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail relay server.

View File

@@ -22,30 +22,11 @@ def iter_mailboxes(basedir, maxnum):
print_info(f"no mailboxes found at: {basedir}") print_info(f"no mailboxes found at: {basedir}")
return return
for name in os_listdir_if_exists(basedir)[:maxnum]: for name in os.listdir(basedir)[:maxnum]:
if "@" in name: if "@" in name:
yield MailboxStat(basedir + "/" + name) yield MailboxStat(basedir + "/" + name)
def get_file_entry(path):
"""return a FileEntry or None if the path does not exist or is not a regular file."""
try:
st = os.stat(path)
except FileNotFoundError:
return None
if not S_ISREG(st.st_mode):
return None
return FileEntry(path, st.st_mtime, st.st_size)
def os_listdir_if_exists(path):
"""return a list of names obtained from os.listdir or an empty list if the path does not exist."""
try:
return os.listdir(path)
except FileNotFoundError:
return []
class MailboxStat: class MailboxStat:
last_login = None last_login = None
@@ -59,23 +40,19 @@ class MailboxStat:
# scan all relevant files (without recursion) # scan all relevant files (without recursion)
old_cwd = os.getcwd() old_cwd = os.getcwd()
try:
os.chdir(self.basedir) os.chdir(self.basedir)
except FileNotFoundError: for name in os.listdir("."):
return
for name in os_listdir_if_exists("."):
if name in ("cur", "new", "tmp"): if name in ("cur", "new", "tmp"):
for msg_name in os_listdir_if_exists(name): for msg_name in os.listdir(name):
entry = get_file_entry(f"{name}/{msg_name}") relpath = name + "/" + msg_name
if entry is not None: st = os.stat(relpath)
self.messages.append(entry) self.messages.append(FileEntry(relpath, st.st_mtime, st.st_size))
else: else:
entry = get_file_entry(name) st = os.stat(name)
if entry is not None: if S_ISREG(st.st_mode):
self.extrafiles.append(entry) self.extrafiles.append(FileEntry(name, st.st_mtime, st.st_size))
if name == "password": if name == "password":
self.last_login = entry.mtime self.last_login = st.st_mtime
self.extrafiles.sort(key=lambda x: -x.size) self.extrafiles.sort(key=lambda x: -x.size)
os.chdir(old_cwd) os.chdir(old_cwd)
@@ -103,12 +80,8 @@ class Expiry:
shutil.rmtree(mboxdir) shutil.rmtree(mboxdir)
self.del_mboxes += 1 self.del_mboxes += 1
def remove_file(self, path, mtime=None): def remove_file(self, path):
if self.verbose: if self.verbose:
if mtime is not None:
date = datetime.fromtimestamp(mtime).strftime("%b %d")
print_info(f"removing {date} {path}")
else:
print_info(f"removing {path}") print_info(f"removing {path}")
if not self.dry: if not self.dry:
try: try:
@@ -131,27 +104,18 @@ class Expiry:
return return
# all to-be-removed files are relative to the mailbox basedir # all to-be-removed files are relative to the mailbox basedir
try:
os.chdir(mbox.basedir) os.chdir(mbox.basedir)
except FileNotFoundError:
print_info(f"mailbox not found/vanished {mbox.basedir}")
return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
if self.verbose: if self.verbose:
date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None print_info(f"checking for mailbox messages in: {mboxname}")
if date:
print_info(f"checking mailbox {date.strftime('%b %d')} {mboxname}")
else:
print_info(f"checking mailbox (no last_login) {mboxname}")
self.all_files += len(mbox.messages) self.all_files += len(mbox.messages)
for message in mbox.messages: for message in mbox.messages:
if message.mtime < cutoff_mails: if message.mtime < cutoff_mails:
self.remove_file(message.relpath, mtime=message.mtime) self.remove_file(message.relpath)
elif message.size > 200000 and message.mtime < cutoff_large_mails: elif message.size > 200000 and message.mtime < cutoff_large_mails:
# we only remove noticed large files (not unnoticed ones in new/) # we only remove noticed large files (not unnoticed ones in new/)
if message.relpath.startswith("cur/"): if message.relpath.startswith("cur/"):
self.remove_file(message.relpath, mtime=message.mtime) self.remove_file(message.relpath)
else: else:
continue continue
changed = True changed = True

View File

@@ -6,13 +6,7 @@ from pathlib import Path
import pytest import pytest
from chatmaild.expire import ( from chatmaild.expire import FileEntry, MailboxStat, iter_mailboxes
FileEntry,
MailboxStat,
get_file_entry,
iter_mailboxes,
os_listdir_if_exists,
)
from chatmaild.expire import main as expiry_main from chatmaild.expire import main as expiry_main
from chatmaild.fsreport import main as report_main from chatmaild.fsreport import main as report_main
@@ -133,18 +127,3 @@ def test_expiry_cli_old_files(capsys, example_config, mbox1):
pytest.fail(f"failed to remove {path}\n{err}") pytest.fail(f"failed to remove {path}\n{err}")
assert "shouldstay" not in err assert "shouldstay" not in err
def test_get_file_entry(tmp_path):
assert get_file_entry(str(tmp_path.joinpath("123123"))) is None
p = tmp_path.joinpath("x")
p.write_text("hello")
entry = get_file_entry(str(p))
assert entry.size == 5
assert entry.mtime
def test_os_listdir_if_exists(tmp_path):
tmp_path.joinpath("x").write_text("hello")
assert len(os_listdir_if_exists(str(tmp_path))) == 1
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0

View File

@@ -6,4 +6,4 @@ def turn_credentials() -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.connect("/run/chatmail-turn/turn.socket") client_socket.connect("/run/chatmail-turn/turn.socket")
with client_socket.makefile("rb") as file: with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip() return file.readline().decode("utf-8")

View File

@@ -11,7 +11,6 @@ from io import StringIO
from pathlib import Path from pathlib import Path
from chatmaild.config import Config, read_config from chatmaild.config import Config, read_config
from cmdeploy.cmdeploy import Out
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.files import File, Sha256File from pyinfra.facts.files import File, Sha256File
@@ -19,9 +18,7 @@ from pyinfra.facts.server import Sysctl
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
from .acmetool import AcmetoolDeployer from .acmetool import deploy_acmetool
from .deployer import Deployer, Deployment
from .www import build_webpages, find_merge_conflict, get_paths
class Port(FactBase): class Port(FactBase):
@@ -64,12 +61,13 @@ def remove_legacy_artifacts():
) )
def _install_remote_venv_with_chatmaild() -> None: def _install_remote_venv_with_chatmaild(config) -> 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"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644") root_owned = dict(user="root", group="root", mode="644")
apt.packages( apt.packages(
@@ -85,6 +83,13 @@ def _install_remote_venv_with_chatmaild() -> None:
**root_owned, **root_owned,
) )
files.put(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(),
dest=remote_chatmail_inipath,
**root_owned,
)
pip.virtualenv( pip.virtualenv(
name=f"chatmaild virtualenv {remote_venv_dir}", name=f"chatmaild virtualenv {remote_venv_dir}",
path=remote_venv_dir, path=remote_venv_dir,
@@ -103,20 +108,6 @@ def _install_remote_venv_with_chatmaild() -> None:
], ],
) )
def _configure_remote_venv_with_chatmaild(config) -> 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")
files.put(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(),
dest=remote_chatmail_inipath,
**root_owned,
)
files.template( files.template(
src=importlib.resources.files(__package__).joinpath("metrics.cron.j2"), src=importlib.resources.files(__package__).joinpath("metrics.cron.j2"),
dest="/etc/cron.d/chatmail-metrics", dest="/etc/cron.d/chatmail-metrics",
@@ -129,21 +120,26 @@ def _configure_remote_venv_with_chatmaild(config) -> 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 # install systemd units
for fn in units: for fn in (
"doveauth",
"filtermail",
"filtermail-incoming",
"echobot",
"chatmail-metadata",
"lastlogin",
"turnserver",
"chatmail-expire",
"chatmail-expire.timer",
"chatmail-fsreport",
"chatmail-fsreport.timer",
):
execpath = fn if fn != "filtermail-incoming" else "filtermail" execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{execpath}", execpath=f"{remote_venv_dir}/bin/{execpath}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=mail_domain, mail_domain=config.mail_domain,
) )
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -157,13 +153,6 @@ def _configure_remote_units(mail_domain, units) -> None:
dest=f"/etc/systemd/system/{basename}", dest=f"/etc/systemd/system/{basename}",
**root_owned, **root_owned,
) )
def _activate_remote_units(units) -> None:
# activate systemd units
for fn in units:
basename = fn if "." in fn else f"{fn}.service"
if fn == "chatmail-expire" or fn == "chatmail-fsreport": if fn == "chatmail-expire" or fn == "chatmail-fsreport":
# don't auto-start but let the corresponding timer trigger execution # don't auto-start but let the corresponding timer trigger execution
enabled = False enabled = False
@@ -249,6 +238,11 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
present=True, present=True,
) )
apt.packages(
name="apt install opendkim opendkim-tools",
packages=["opendkim", "opendkim-tools"],
)
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"): if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
server.shell( server.shell(
name="Generate OpenDKIM domain keys", name="Generate OpenDKIM domain keys",
@@ -269,95 +263,14 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
return need_restart return need_restart
class OpendkimDeployer(Deployer): def _uninstall_mta_sts_daemon() -> None:
required_users = [("opendkim", None, ["opendkim"])]
def __init__(self, mail_domain):
self.mail_domain = mail_domain
def install(self):
apt.packages(
name="apt install opendkim opendkim-tools",
packages=["opendkim", "opendkim-tools"],
)
def configure(self):
self.need_restart = _configure_opendkim(self.mail_domain, "opendkim")
def activate(self):
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
class UnboundDeployer(Deployer):
def install(self):
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
#
# On an IPv4-only system, if unbound is started but not
# configured, it causes subsequent steps to fail to resolve hosts.
# Here, we use policy-rc.d to prevent unbound from starting up
# on initial install. Later, we will configure it and start it.
#
# For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
#
files.put(
src=importlib.resources.files(__package__).joinpath("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
)
files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self):
server.shell(
name="Generate root keys for validating DNSSEC",
commands=[
"unbound-anchor -a /var/lib/unbound/root.key || true",
],
)
def activate(self):
server.shell(
name="Generate root keys for validating DNSSEC",
commands=[
"systemctl reset-failed unbound.service",
],
)
systemd.service(
name="Start and enable unbound",
service="unbound.service",
running=True,
enabled=True,
)
class MtastsDeployer(Deployer):
def configure(self):
# Remove configuration. # Remove configuration.
files.file("/etc/mta-sts-daemon.yml", present=False) files.file("/etc/mta-sts-daemon.yml", present=False)
files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False) files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False)
files.file("/etc/systemd/system/mta-sts-daemon.service", present=False) files.file("/etc/systemd/system/mta-sts-daemon.service", present=False)
def activate(self):
systemd.service( systemd.service(
name="Stop MTA-STS daemon", name="Stop MTA-STS daemon",
service="mta-sts-daemon.service", service="mta-sts-daemon.service",
@@ -417,36 +330,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
return need_restart return need_restart
class PostfixDeployer(Deployer):
required_users = [("postfix", None, ["opendkim"]),]
def __init__(self, config, disable_mail):
self.config = config
self.disable_mail = disable_mail
def install(self):
apt.packages(
name="Install Postfix",
packages="postfix",
)
def configure(self):
self.need_restart = _configure_postfix(self.config)
def activate(self):
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,
)
self.need_restart = False
def _install_dovecot_package(package: str, arch: str): def _install_dovecot_package(package: str, arch: str):
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
@@ -455,9 +338,9 @@ def _install_dovecot_package(package: str, arch: str):
match (package, arch): match (package, arch):
case ("core", "amd64"): case ("core", "amd64"):
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d" sha256 = "43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587"
case ("core", "arm64"): case ("core", "arm64"):
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9" sha256 = "4d21eba1a83f51c100f08f2e49f0c9f8f52f721ebc34f75018e043306da993a7"
case ("imapd", "amd64"): case ("imapd", "amd64"):
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86" sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
case ("imapd", "arm64"): case ("imapd", "arm64"):
@@ -547,38 +430,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
return need_restart return need_restart
class DovecotDeployer(Deployer):
def __init__(self, config, disable_mail):
self.config = config
self.disable_mail = disable_mail
self.units = ["doveauth"]
def install(self):
arch = host.get_fact(facts.server.Arch)
if not "dovecot.service" in host.get_fact(SystemdEnabled):
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
def configure(self):
_configure_remote_units(self.config.mail_domain, self.units)
self.need_restart = _configure_dovecot(self.config)
def activate(self):
_activate_remote_units(self.units)
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,
)
self.need_restart = False
def _configure_nginx(config: Config, debug: bool = False) -> bool: def _configure_nginx(config: Config, debug: bool = False) -> bool:
"""Configures nginx HTTP server.""" """Configures nginx HTTP server."""
need_restart = False need_restart = False
@@ -636,92 +487,8 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
return need_restart return need_restart
class NginxDeployer(Deployer): def _remove_rspamd() -> None:
def __init__(self, config): """Remove rspamd"""
self.config = config
def install(self):
#
# If we allow nginx to start up on install, it will grab port
# 80, which then will block acmetool from listening on the port.
# That in turn prevents getting certificates, which then causes
# an error when we try to start nginx on the custom config
# that leaves port 80 open but also requires certificates to
# be present. To avoid getting into that interlocking mess,
# we use policy-rc.d to prevent nginx from starting up when it
# is installed.
#
# This approach allows us to avoid performing any explicit
# systemd operations during the install stage (as opposed to
# allowing it to start and then forcing it to stop), which allows
# the install stage to run in non-systemd environments like a
# container image build.
#
# For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
#
files.put(
src=importlib.resources.files(__package__).joinpath("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"],
)
files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self):
self.need_restart = _configure_nginx(self.config)
def activate(self):
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
class WebsiteDeployer(Deployer):
def __init__(self, config):
self.config = config
def install(self):
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)
# if www_folder was set to a non-existing folder, skip upload
if not www_path.is_dir():
logger.warning("Building web pages is disabled in chatmail.ini, skipping")
elif (path := find_merge_conflict(src_dir)) is not None:
logger.warning(f"Merge conflict found in {path}, skipping website deployment. Fix merge conflict if you want to upload your web page.")
else:
# if www_folder is a hugo page, build it
if build_dir:
www_path = build_webpages(src_dir, build_dir, self.config)
# if it is not a hugo page, upload it as is
files.rsync(
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
)
class RspamdDeployer(Deployer):
def install(self):
apt.packages(name="Remove rspamd", packages="rspamd", present=False) apt.packages(name="Remove rspamd", packages="rspamd", present=False)
@@ -740,12 +507,7 @@ def check_config(config):
return config return config
class TurnDeployer(Deployer): def deploy_turn_server(config):
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.units = ["turnserver"]
def install(self):
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux", "https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
@@ -757,6 +519,8 @@ class TurnDeployer(Deployer):
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
need_restart = False
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn") existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum: if existing_sha256sum != sha256sum:
server.shell( server.shell(
@@ -766,19 +530,34 @@ class TurnDeployer(Deployer):
"chmod 755 /usr/local/bin/chatmail-turn", "chmod 755 /usr/local/bin/chatmail-turn",
], ],
) )
need_restart = True
def configure(self): source_path = importlib.resources.files(__package__).joinpath(
_configure_remote_units(self.mail_domain, self.units) "service", "turnserver.service.f"
)
content = source_path.read_text().format(mail_domain=config.mail_domain).encode()
def activate(self): systemd_unit = files.put(
_activate_remote_units(self.units) name="Upload turnserver.service",
src=io.BytesIO(content),
dest="/etc/systemd/system/turnserver.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
systemd.service(
name="Setup turnserver service",
service="turnserver.service",
running=True,
enabled=True,
restarted=need_restart,
daemon_reload=systemd_unit.changed,
)
class MtailDeployer(Deployer): def deploy_mtail(config):
def __init__(self, mtail_address):
self.mtail_address = mtail_address
def install(self):
# Uninstall mtail package, we are going to install a static binary. # Uninstall mtail package, we are going to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False) apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
@@ -801,18 +580,15 @@ class MtailDeployer(Deployer):
], ],
) )
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.
files.template( files.template(
src=importlib.resources.files(__package__).joinpath( src=importlib.resources.files(__package__).joinpath("mtail/mtail.service.j2"),
"mtail/mtail.service.j2"
),
dest="/etc/systemd/system/mtail.service", dest="/etc/systemd/system/mtail.service",
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
address=self.mtail_address or "127.0.0.1", address=config.mtail_address or "127.0.0.1",
port=3903, port=3903,
) )
@@ -826,24 +602,17 @@ class MtailDeployer(Deployer):
group="root", group="root",
mode="644", mode="644",
) )
self.need_restart = mtail_conf.changed
def activate(self):
systemd.service( systemd.service(
name="Start and enable mtail", name="Start and enable mtail",
service="mtail.service", service="mtail.service",
running=bool(self.mtail_address), running=bool(config.mtail_address),
enabled=bool(self.mtail_address), enabled=bool(config.mtail_address),
restarted=self.need_restart, restarted=mtail_conf.changed,
) )
self.need_restart = False
class IrohDeployer(Deployer): def deploy_iroh_relay(config) -> None:
def __init__(self, enable_iroh_relay):
self.enable_iroh_relay = enable_iroh_relay
def install(self):
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz", "https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz",
@@ -855,6 +624,13 @@ class IrohDeployer(Deployer):
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
apt.packages(
name="Install curl",
packages=["curl"],
)
need_restart = False
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay") existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
if existing_sha256sum != sha256sum: if existing_sha256sum != sha256sum:
server.shell( server.shell(
@@ -864,14 +640,8 @@ class IrohDeployer(Deployer):
"chmod 755 /usr/local/bin/iroh-relay", "chmod 755 /usr/local/bin/iroh-relay",
], ],
) )
need_restart = True
#
# This will set need_restart when called from an object's
# install() method.
#
return True
def configure(self):
systemd_unit = files.put( systemd_unit = files.put(
name="Upload iroh-relay systemd unit", name="Upload iroh-relay systemd unit",
src=importlib.resources.files(__package__).joinpath("iroh-relay.service"), src=importlib.resources.files(__package__).joinpath("iroh-relay.service"),
@@ -880,7 +650,7 @@ class IrohDeployer(Deployer):
group="root", group="root",
mode="644", mode="644",
) )
self.need_restart |= systemd_unit.changed need_restart |= systemd_unit.changed
iroh_config = files.put( iroh_config = files.put(
name="Upload iroh-relay config", name="Upload iroh-relay config",
@@ -890,171 +660,14 @@ class IrohDeployer(Deployer):
group="root", group="root",
mode="644", mode="644",
) )
self.need_restart |= iroh_config.changed need_restart |= iroh_config.changed
def activate(self):
systemd.service( systemd.service(
name="Start and enable iroh-relay", name="Start and enable iroh-relay",
service="iroh-relay.service", service="iroh-relay.service",
running=True, running=True,
enabled=self.enable_iroh_relay, enabled=config.enable_iroh_relay,
restarted=self.need_restart, restarted=need_restart,
)
self.need_restart = False
class JournaldDeployer(Deployer):
def configure(self):
journald_conf = files.put(
name="Configure journald",
src=importlib.resources.files(__package__).joinpath("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
self.need_restart = journald_conf.changed
def activate(self):
systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
class EchobotDeployer(Deployer):
#
# This deployer depends on the dovecot and postfix deployers because
# it needs to base its decision of whether to restart the service on
# whether those two services were restarted.
#
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.units = ["echobot"]
def install(self):
apt.packages(
# required for setfacl for echobot
name="Install acl",
packages="acl",
)
def configure(self):
_configure_remote_units(self.mail_domain, self.units)
def activate(self):
_activate_remote_units(self.units)
class ChatmailVenvDeployer(Deployer):
def __init__(self, config):
self.config = config
self.units = (
"filtermail",
"filtermail-incoming",
"chatmail-metadata",
"lastlogin",
"chatmail-expire",
"chatmail-expire.timer",
"chatmail-fsreport",
"chatmail-fsreport.timer",
)
def install(self):
_install_remote_venv_with_chatmaild()
def configure(self):
_configure_remote_venv_with_chatmaild(self.config)
_configure_remote_units(self.config.mail_domain, self.units)
def activate(self):
_activate_remote_units(self.units)
class ChatmailDeployer(Deployer):
required_users = [
("vmail", "vmail", None),
("echobot", None, None),
("iroh", None, None),
]
def __init__(self, mail_domain):
self.mail_domain = mail_domain
def install(self):
# Remove OBS repository key that is no longer used.
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,
present=False,
)
apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
apt.packages(
name="Install curl",
packages=["curl"],
)
apt.packages(
name="Install rsync",
packages=["rsync"],
)
apt.packages(
name="Ensure cron is installed",
packages=["cron"],
)
def configure(self):
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[
f"echo {self.mail_domain} >/etc/mailname; chmod 644 /etc/mailname"
],
)
class FcgiwrapDeployer(Deployer):
def install(self):
apt.packages(
name="Install fcgiwrap",
packages=["fcgiwrap"],
)
def activate(self):
systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
class GithashDeployer(Deployer):
def activate(self):
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
files.put(
name="Upload chatmail relay git commiit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
) )
@@ -1068,12 +681,64 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
check_config(config) check_config(config)
mail_domain = config.mail_domain mail_domain = config.mail_domain
from .www import build_webpages, get_paths
server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
user="opendkim",
groups=["opendkim"],
system=True,
)
server.user(
name="Add postfix user to opendkim group for socket access",
user="postfix",
groups=["opendkim"],
system=True,
)
server.user(name="Create echobot user", user="echobot", system=True)
server.user(name="Create iroh user", user="iroh", system=True)
# Add our OBS repository for dovecot_no_delay
files.put(
name="Add Deltachat OBS GPG key to apt keyring",
src=importlib.resources.files(__package__).joinpath("obs-home-deltachat.gpg"),
dest="/etc/apt/keyrings/obs-home-deltachat.gpg",
user="root",
group="root",
mode="644",
)
files.line(
name="Add DeltaChat OBS home repository to 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,
present=False,
)
if host.get_fact(Port, port=53) != "unbound": if host.get_fact(Port, port=53) != "unbound":
files.line( files.line(
name="Add 9.9.9.9 to resolv.conf", name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf", path="/etc/resolv.conf",
line="nameserver 9.9.9.9", line="nameserver 9.9.9.9",
) )
apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True)
apt.packages(
name="Install rsync",
packages=["rsync"],
)
deploy_turn_server(config)
# Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver.
from cmdeploy.cmdeploy import Out
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
@@ -1101,40 +766,174 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
) )
exit(1) exit(1)
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
)
server.shell(
name="Generate root keys for validating DNSSEC",
commands=[
"unbound-anchor -a /var/lib/unbound/root.key || true",
"systemctl reset-failed unbound.service",
],
)
systemd.service(
name="Start and enable unbound",
service="unbound.service",
running=True,
enabled=True,
)
deploy_iroh_relay(config)
# Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
email=config.acme_email,
domains=tls_domains,
)
apt.packages(
# required for setfacl for echobot
name="Install acl",
packages="acl",
)
all_deployers = [ apt.packages(
ChatmailDeployer(mail_domain=mail_domain), name="Install Postfix",
JournaldDeployer(), packages="postfix",
UnboundDeployer(), )
TurnDeployer(mail_domain=mail_domain),
IrohDeployer(enable_iroh_relay=config.enable_iroh_relay),
AcmetoolDeployer(email=config.acme_email, domains=tls_domains),
WebsiteDeployer(config=config), if not "dovecot.service" in host.get_fact(SystemdEnabled):
ChatmailVenvDeployer(config=config), _install_dovecot_package("core", host.get_fact(facts.server.Arch))
MtastsDeployer(), _install_dovecot_package("imapd", host.get_fact(facts.server.Arch))
OpendkimDeployer(mail_domain=mail_domain), _install_dovecot_package("lmtpd", host.get_fact(facts.server.Arch))
apt.packages(
name="Install nginx",
packages=["nginx", "libnginx-mod-stream"],
)
apt.packages(
name="Install fcgiwrap",
packages=["fcgiwrap"],
)
www_path, src_dir, build_dir = get_paths(config)
# if www_folder was set to a non-existing folder, skip upload
if not www_path.is_dir():
logger.warning("Building web pages is disabled in chatmail.ini, skipping")
else:
# if www_folder is a hugo page, build it
if build_dir:
www_path = build_webpages(src_dir, build_dir, config)
# if it is not a hugo page, upload it as is
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"])
_install_remote_venv_with_chatmaild(config)
debug = False
dovecot_need_restart = _configure_dovecot(config, debug=debug)
postfix_need_restart = _configure_postfix(config, debug=debug)
nginx_need_restart = _configure_nginx(config)
_uninstall_mta_sts_daemon()
_remove_rspamd()
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim")
systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
daemon_reload=opendkim_need_restart,
restarted=opendkim_need_restart,
)
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.
DovecotDeployer(config=config, disable_mail=disable_mail), systemd.service(
PostfixDeployer(config=config, disable_mail=disable_mail), name="disable dovecot for now" if disable_mail else "Start and enable Dovecot",
FcgiwrapDeployer(), service="dovecot.service",
NginxDeployer(config=config), running=False if disable_mail else True,
RspamdDeployer(), enabled=False if disable_mail else True,
EchobotDeployer(mail_domain=mail_domain), restarted=dovecot_need_restart if not disable_mail else False,
MtailDeployer(mtail_address=config.mtail_address), )
GithashDeployer(),
]
Deployment().perform_stages(all_deployers) systemd.service(
name="disable postfix for now" if disable_mail else "Start and enable Postfix",
service="postfix.service",
running=False if disable_mail else True,
enabled=False if disable_mail else True,
restarted=postfix_need_restart if not disable_mail else False,
)
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart echobot if postfix and dovecot were just started",
service="echobot.service",
restarted=postfix_need_restart and dovecot_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
journald_conf = files.put(
name="Configure journald",
src=importlib.resources.files(__package__).joinpath("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=journald_conf.changed,
)
files.directory( files.directory(
name="Ensure old logs on disk are deleted", name="Ensure old logs on disk are deleted",
path="/var/log/journal/", path="/var/log/journal/",
present=False, present=False,
) )
apt.packages(
name="Ensure cron is installed",
packages=["cron"],
)
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
files.put(
name="Upload chatmail relay git commiit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
deploy_mtail(config)

View File

@@ -2,24 +2,14 @@ import importlib.resources
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, files, server, systemd
from ..deployer import Deployer
def deploy_acmetool(email="", domains=[]):
class AcmetoolDeployer(Deployer): """Deploy acmetool."""
def __init__(self, *, email, domains, **kwargs):
super().__init__(**kwargs)
self.domains = domains
self.email = email
self.need_restart = False
@staticmethod
def install_impl():
apt.packages( apt.packages(
name="Install acmetool", name="Install acmetool",
packages=["acmetool"], packages=["acmetool"],
) )
def configure_impl(self):
files.put( files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"), src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"),
dest="/etc/cron.d/acmetool", dest="/etc/cron.d/acmetool",
@@ -42,7 +32,7 @@ class AcmetoolDeployer(Deployer):
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
email=self.email, email=email,
) )
files.template( files.template(
@@ -62,19 +52,16 @@ class AcmetoolDeployer(Deployer):
group="root", group="root",
mode="644", mode="644",
) )
self.need_restart = service_file.changed
def activate_impl(self):
systemd.service( systemd.service(
name="Setup acmetool-redirector service", name="Setup acmetool-redirector service",
service="acmetool-redirector.service", service="acmetool-redirector.service",
running=True, running=True,
enabled=True, enabled=True,
restarted=self.need_restart, restarted=service_file.changed,
) )
self.need_restart = False
server.shell( server.shell(
name=f"Request certificate for: {', '.join(self.domains)}", name=f"Request certificate for: {', '.join(domains)}",
commands=[f"acmetool want --xlog.severity=debug {' '.join(self.domains)}"], commands=[f"acmetool want --xlog.severity=debug {' '.join(domains)}"],
) )

View File

@@ -1,8 +1,7 @@
request: request:
provider: https://acme-v02.api.letsencrypt.org/directory provider: https://acme-v02.api.letsencrypt.org/directory
key: key:
type: ecdsa type: rsa
ecdsa-curve: nistp256
challenge: challenge:
webroot-paths: webroot-paths:
- /var/www/html/.well-known/acme-challenge - /var/www/html/.well-known/acme-challenge

View File

@@ -1,59 +0,0 @@
import os
from pyinfra.operations import server
class Deployment:
def install(self, deployer):
# optional 'required_users' contains a list of (user, group, secondary-group-list) tuples.
# If the group is None, no group is created corresponding to that user.
# If the secondary group list is not None, all listed groups are created as well.
required_users = getattr(deployer, "required_users", [])
for user, group, groups in required_users:
if group is not None:
server.group(
name="Create {} group".format(group), group=group, system=True
)
if groups is not None:
for group2 in groups:
server.group(
name="Create {} group".format(group2), group=group2, system=True
)
server.user(
name="Create {} user".format(user),
user=user,
group=group,
groups=groups,
system=True,
)
ret = bool(deployer.install())
if ret:
deployer.need_restart = True
def configure(self, deployer):
deployer.configure()
def activate(self, deployer):
deployer.activate()
def perform_stages(self, deployers):
default_stages = "install,configure,activate"
stages = os.getenv("CMDEPLOY_STAGES", default_stages).split(",")
for stage in stages:
for deployer in deployers:
getattr(self, stage)(deployer)
class Deployer:
need_restart = False
def install(self):
pass
def configure(self):
pass
def activate(self):
pass

View File

@@ -70,12 +70,6 @@ userdb {
# Mailboxes are stored in the "mail" directory of the vmail user home. # Mailboxes are stored in the "mail" directory of the vmail user home.
mail_location = maildir:{{ config.mailboxes_dir }}/%u mail_location = maildir:{{ config.mailboxes_dir }}/%u
# index/cache files are not very useful for chatmail relay operations
# but it's not clear how to disable them completely.
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
# if the cache file becomes larger than the specified size, it is truncated by dovecot
mail_cache_max_size = 500K
namespace inbox { namespace inbox {
inbox = yes inbox = yes

Binary file not shown.

View File

@@ -1,3 +0,0 @@
#!/bin/sh
echo "All runlevel operations denied by policy" >&2
exit 101

View File

@@ -26,7 +26,6 @@ smtp_tls_security_level=verify
smtp_tls_servername = hostname smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may} smtp_tls_policy_maps = inline:{nauta.cu=may}
smtp_tls_protocols = >=TLSv1.2
smtpd_tls_protocols = >=TLSv1.2 smtpd_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites # Disable anonymous cipher suites

View File

@@ -14,7 +14,6 @@ smtp inet n - y - - smtpd -v
{%- else %} {%- else %}
smtp inet n - y - - smtpd smtp inet n - y - - smtpd
{%- endif %} {%- endif %}
-o smtpd_tls_security_level=encrypt
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
submission inet n - y - 5000 smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission

View File

@@ -73,7 +73,9 @@ def query_dns(typ, domain):
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{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(';')), '') if res:
return res.split("\n")[0]
return ""
def check_zonefile(zonefile, verbose=True): def check_zonefile(zonefile, verbose=True):

View File

@@ -37,7 +37,7 @@ class TestDC:
def test_ping_pong(self, benchmark, cmfactory): def test_ping_pong(self, benchmark, cmfactory):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
def dc_ping_pong(): def dc_ping_pong():
chat.send_text("ping") chat.send_text("ping")
@@ -49,7 +49,7 @@ class TestDC:
def test_send_10_receive_10(self, benchmark, cmfactory, lp): def test_send_10_receive_10(self, benchmark, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
def dc_send_10_receive_10(): def dc_send_10_receive_10():
for i in range(10): for i in range(10):

View File

@@ -1,5 +1,5 @@
import queue import queue
import smtplib import socket
import threading import threading
import pytest import pytest
@@ -91,23 +91,25 @@ def test_concurrent_logins_same_account(
def test_no_vrfy(chatmail_config): def test_no_vrfy(chatmail_config):
domain = chatmail_config.mail_domain domain = chatmail_config.mail_domain
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s = smtplib.SMTP(domain) sock.settimeout(10)
s.starttls() try:
sock.connect((domain, 25))
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") except socket.timeout:
result = s.getreply() pytest.skip(f"port 25 not reachable for {domain}")
banner = sock.recv(1024)
print(banner)
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result = sock.recv(1024)
print(result) print(result)
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}") sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result2 = s.getreply() result2 = sock.recv(1024)
print(result2) print(result2)
assert result[0] == result2[0] == 252 assert result[0:10] == result2[0:10]
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 " sock.send(b"VRFY wrongaddress\r\n")
s.putcmd("vrfy", "wrongaddress") result = sock.recv(1024)
result = s.getreply()
print(result) print(result)
s.putcmd("vrfy", "echo") sock.send(b"VRFY echo\r\n")
result2 = s.getreply() result2 = sock.recv(1024)
print(result2) print(result2)
assert result[0] == result2[0] == 252 assert result[0:10] == result2[0:10] == b"252 2.0.0 "
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "

View File

@@ -143,7 +143,6 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr "encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
).as_string() ).as_string()
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10) conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
conn.starttls()
with conn as s: with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"): with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):

View File

@@ -56,7 +56,7 @@ class TestEndToEndDeltaChat:
"""Test that a DC account can send a message to a second DC account """Test that a DC account can send a message to a second DC account
on the same chat-mail instance.""" on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
@@ -70,7 +70,7 @@ class TestEndToEndDeltaChat:
before quota is exceeded, and thus depends on the speed of the upload. before quota is exceeded, and thus depends on the speed of the upload.
""" """
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2) chat = cmfactory.get_protected_chat(ac1, ac2)
user = ac2.get_config("configured_addr") user = ac2.get_config("configured_addr")
@@ -153,7 +153,7 @@ def test_hide_senders_ip_address(cmfactory):
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2) user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(user1, user2) chat = cmfactory.get_protected_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()

View File

@@ -1,5 +1,3 @@
from copy import deepcopy
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote
@@ -10,30 +8,20 @@ from cmdeploy.dns import check_full_zone, check_initial_remote_data
def mockdns_base(monkeypatch): def mockdns_base(monkeypatch):
qdict = {} qdict = {}
def shell(command, fail_ok=False, print=print): def query_dns(typ, domain):
if command.startswith("dig"):
if command == "dig":
return "."
if "SOA" in command:
return (
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
" 2025102800 14400 1800 604800 3600"
)
command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6]
try: try:
return qdict[typ][domain] return qdict[typ][domain]
except KeyError: except KeyError:
return "" return ""
return remote.rshell.shell(command=command, fail_ok=fail_ok, print=print)
monkeypatch.setattr(remote.rdns, shell.__name__, shell) monkeypatch.setattr(remote.rdns, query_dns.__name__, query_dns)
return qdict return qdict
@pytest.fixture @pytest.fixture
def mockdns_expected(): def mockdns(mockdns_base):
return { mockdns_base.update(
{
"A": {"some.domain": "1.1.1.1"}, "A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"}, "AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": { "CNAME": {
@@ -41,32 +29,17 @@ def mockdns_expected():
"www.some.domain": "some.domain.", "www.some.domain": "some.domain.",
}, },
} }
@pytest.fixture(params=["plain", "with-dns-comments"])
def mockdns(request, mockdns_base, mockdns_expected):
mockdns_base.update(deepcopy(mockdns_expected))
match request.param:
case "plain":
pass
case "with-dns-comments":
for typ, data in mockdns_base.items():
for host, result in data.items():
mockdns_base[typ][host] = (
";; some unsuccessful attempt result\n"
"; and another with a single semicolon\n"
f"{result}"
) )
return mockdns_base return mockdns_base
class TestPerformInitialChecks: class TestPerformInitialChecks:
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected): def test_perform_initial_checks_ok1(self, mockdns):
remote_data = remote.rdns.perform_initial_checks("some.domain") remote_data = remote.rdns.perform_initial_checks("some.domain")
assert remote_data["A"] == mockdns_expected["A"]["some.domain"] assert remote_data["A"] == mockdns["A"]["some.domain"]
assert remote_data["AAAA"] == mockdns_expected["AAAA"]["some.domain"] assert remote_data["AAAA"] == mockdns["AAAA"]["some.domain"]
assert remote_data["MTA_STS"] == mockdns_expected["CNAME"]["mta-sts.some.domain"] assert remote_data["MTA_STS"] == mockdns["CNAME"]["mta-sts.some.domain"]
assert remote_data["WWW"] == mockdns_expected["CNAME"]["www.some.domain"] assert remote_data["WWW"] == mockdns["CNAME"]["www.some.domain"]
@pytest.mark.parametrize("drop", ["A", "AAAA"]) @pytest.mark.parametrize("drop", ["A", "AAAA"])
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop): def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):

View File

@@ -4,7 +4,6 @@ import time
import traceback import traceback
import webbrowser import webbrowser
from pathlib import Path from pathlib import Path
import re
import markdown import markdown
from chatmaild.config import read_config from chatmaild.config import read_config
@@ -13,9 +12,6 @@ from jinja2 import Template
from .genqr import gen_qr_png_data from .genqr import gen_qr_png_data
_MERGE_CONFLICT_RE = re.compile(r"^<<<<<<<.+^=======.+^>>>>>>>", re.DOTALL | re.MULTILINE)
def snapshot_dir_stats(somedir): def snapshot_dir_stats(somedir):
d = {} d = {}
for path in somedir.iterdir(): for path in somedir.iterdir():
@@ -120,17 +116,6 @@ def _build_webpages(src_dir, build_dir, config):
return build_dir return build_dir
def find_merge_conflict(src_dir) -> Path:
assert src_dir.exists(), src_dir
result = None
for path in src_dir.iterdir():
if path.suffix in [".css", ".html", ".md"]:
if _MERGE_CONFLICT_RE.search(path.read_text()):
result = path
break
return result
def main(): def main():
path = importlib.resources.files(__package__) path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve() reporoot = path.joinpath("../../../").resolve()