Compare commits

..
70 changed files with 564 additions and 1667 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ name: Trigger Docker build
on: on:
push: push:
branches: [main, j4n/dovecot-multidist] branches: [main]
tags: ['[0-9]+.[0-9]+.[0-9]+'] tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch: workflow_dispatch:
+1 -6
View File
@@ -13,11 +13,6 @@ jobs:
scripts: scripts:
name: build name: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Pin the repository links in the docs to this pull request's head commit
# so that linkcheck resolves files which only exist on the branch so far.
# see doc/conf.py
DOC_GITHUB_REF: ${{ github.event.pull_request.head.sha }}
environment: environment:
name: 'staging.chatmail.at/doc/relay/' name: 'staging.chatmail.at/doc/relay/'
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }} url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
@@ -56,7 +51,7 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:${STEPS_PREPARE_OUTPUTS_PRID}/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${STEPS_PREPARE_OUTPUTS_PRID}/"
env: env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }} STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
+1 -1
View File
@@ -47,5 +47,5 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/"
-55
View File
@@ -1,60 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## [1.12.0] - 2026-07-31
### Breaking Changes
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
Dovecot default connection limit lowered from 50k to 10k,
Postfix default connection limit lowered from 5k to 1k,
larger relays need to adjust their settings.
### Features
- Reduce maximal_queue_lifetime from 5d to 2d
- Disable negative cache in unbound (#992)
- *(mtail)* Add incoming_mailer_daemon_mail_count
- *(postfix)* Disable processing of MIME headers
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
### Bug Fixes
- Set relay restrictions per smtpd service with default reject
- Reduce maxproc for filtermail-transport LMTP client to 500
- Core 2.50.0 does not have delete_server_after config anymore.
- Check if all required ports are available for filtermail (#983)
- Always deploy unbound.conf.d/chatmail.conf (#993)
- Expire empty directories (#994)
- Crypt-r dependency was declared for wrong Python version
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
- Pass kwargs to files.put()
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
- Fix port discovery when ss -tulpn shows dovecot before stats
### Documentation
- Add scripts/initenv.sh to upgrade instructions
- Update overview diagrams (#995)
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
- *(README.md)* Clarify security enforcement (#1011)
### Miscellaneous Tasks
- *(ci)* Auto-trigger docker build on release tag push
- *(acmetool)* Update let's encrypt ToS link to 1.8
- *(ci)* Update doc staging upload path
- *(ci)* Fix docs upload path
### Refactor
- *(postfix)* Remove unused "filter" lmtp service
- Install dns-root-data instead of using unbound-anchor
- *(deps)* Remove domain-validator dependency
### Testing
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
## [1.11.0] - 2026-05-15 ## [1.11.0] - 2026-05-15
### Breaking Changes ### Breaking Changes
-3
View File
@@ -5,6 +5,3 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/ [git-cliff]: https://git-cliff.org/
To update client app version information,
edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json).
+6 -4
View File
@@ -1,13 +1,15 @@
# Releasing a new version of chatmail relay # Releasing a new version of chatmail relay
For example, to release version 1.13.0 of chatmail relay, do the following steps. For example, to release version 1.9.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`. 1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required. 2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`. 3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
4. Open a PR with the new commit, merge it to main after review. 3. Tag the release: `git tag --annotate 1.9.0`.
5. In the web interface, create a GitHub release, tell it to create a new tag. 4. Push the release tag: `git push origin 1.9.0`.
5. Create a GitHub release: `gh release create 1.9.0`.
-1
View File
@@ -1,4 +1,3 @@
include src/chatmaild/defaults/*.json
include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/* include src/chatmaild/tests/mail-data/*
+3 -1
View File
@@ -8,7 +8,6 @@ version = "0.3"
dependencies = [ dependencies = [
"iniconfig", "iniconfig",
"filelock", "filelock",
"psutil",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.13'", "crypt-r >= 3.13.1 ; python_version >= '3.13'",
] ]
@@ -27,6 +26,9 @@ chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
log_format = "%(asctime)s %(levelname)s %(message)s" log_format = "%(asctime)s %(levelname)s %(message)s"
+15 -13
View File
@@ -1,5 +1,6 @@
import ipaddress import ipaddress
from pathlib import Path from pathlib import Path
from random import randint
import iniconfig import iniconfig
@@ -41,6 +42,11 @@ class Config:
self.username_max_length = int(params.pop("username_max_length", 9)) self.username_max_length = int(params.pop("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9)) self.password_min_length = int(params.pop("password_min_length", 9))
self.www_folder = params.pop("www_folder", "") self.www_folder = params.pop("www_folder", "")
self.imap_port = int(params.pop("imap_port", 143))
self.imaps_port = int(params.pop("imaps_port", 993))
self.smtp_port = int(params.pop("smtp_port", 587))
self.smtps_port = int(params.pop("smtps_port", 465))
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.pop("filtermail_smtp_port_incoming", "10081") params.pop("filtermail_smtp_port_incoming", "10081")
@@ -55,7 +61,6 @@ class Config:
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.pop("postfix_reinject_port_incoming", "10026") params.pop("postfix_reinject_port_incoming", "10026")
) )
self.doveauth_http_port = int(params.pop("doveauth_http_port", "10084"))
self.mtail_address = params.pop("mtail_address", None) self.mtail_address = params.pop("mtail_address", None)
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true"
self.acme_email = params.pop("acme_email", "") self.acme_email = params.pop("acme_email", "")
@@ -76,16 +81,6 @@ class Config:
self.privacy_pdo = params.pop("privacy_pdo", None) self.privacy_pdo = params.pop("privacy_pdo", None)
self.privacy_supervisor = params.pop("privacy_supervisor", None) self.privacy_supervisor = params.pop("privacy_supervisor", None)
self.max_load_1m = float(params.pop("max_load_1m", 5))
self.min_available_memory_mb = parse_size_mb(
params.pop("min_available_memory", "200M")
)
self.min_free_disk_space_mb = parse_size_mb(
params.pop("min_free_disk_space", "1G")
)
self.max_imap_connections = int(params.pop("max_imap_connections", 10000))
self.max_smtp_connections = int(params.pop("max_smtp_connections", 1000))
# TLS certificate management. # TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs. # If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name: # Otherwise derived from the domain name:
@@ -149,8 +144,15 @@ def parse_size_mb(limit):
def write_initial_config(inipath, mail_domain, overrides): def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides.""" """Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides) content = get_default_config_content(mail_domain, **overrides).splitlines()
inipath.write_text(content) used_ports = [25, 53, 80, 143, 402, 443, 465, 587, 993, 3340, 3903, 3904, 8443, 10080, 10081, 10082, 10083, 10025, 10026]
for config_key in ["smtp_port", "imap_port", "smtps_port", "imaps_port"]:
value = randint(1024, 65536)
while value in used_ports:
value = randint(65535)
used_ports.append(value)
content.append(f"{config_key} = {value}")
inipath.write_text("\n".join(content))
def get_default_config_content(mail_domain, **overrides): def get_default_config_content(mail_domain, **overrides):
@@ -1,15 +0,0 @@
{
"clients": [
{
"clientId": "deltachat",
"sources": [
{
"sourceId": "gplay",
"versionInteger": 757,
"versionString": "2.59.1",
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.59.1/deltachat-gplay-release-2.59.1.apk"
}
]
}
]
}
+94 -111
View File
@@ -1,17 +1,10 @@
"""Create chatmail addresses on first login. import json
Dovecot only asks us about addresses it does not already find in the mailbox:
the auth.lua we deploy with dovecot (cmdeploy/src/cmdeploy/dovecot/auth.lua.j2)
verifies existing users itself against a mailbox password file,
and HTTP-POSTs everything else to the /create endpoint implemented in this module.
"""
import logging import logging
import os import os
import re import re
import sys import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import filelock
try: try:
import crypt_r import crypt_r
@@ -19,8 +12,8 @@ except ImportError:
import crypt as crypt_r import crypt as crypt_r
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy
from .migrate_db import migrate_from_db_to_maildir from .migrate_db import migrate_from_db_to_maildir
from .syslimits import has_sufficient_resources
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
@@ -70,117 +63,107 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def verify_password(stored, cleartext_password) -> bool: def split_and_unescape(s):
if stored.startswith("{"): """Split strings using double quote as a separator and backslash as escape character
stored = stored.split("}", 1)[1] into parts."""
return crypt_r.crypt(cleartext_password, stored) == stored
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
class DoveAuth: class AuthDictProxy(DictProxy):
def __init__(self, config): def __init__(self, config):
super().__init__()
self.config = config self.config = config
self.creation_lock = threading.Lock()
def create_user(self, addr, cleartext_password) -> bool: def handle_lookup(self, parts):
"""Create the address, or verify the password if it exists already.""" # Dovecot <2.3.17 has only one part,
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
config = self.config config = self.config
if not addr.endswith(f"@{config.mail_domain}"): reply_command = "F"
logging.warning("address not in mail domain: %r", addr) res = ""
return False if namespace == "shared":
try: if type == "userdb":
user = config.get_user(addr) user = args[0]
except ValueError: if user.endswith(f"@{config.mail_domain}"):
logging.warning("invalid address: %r", addr) res = self.lookup_userdb(user)
return False if res:
with self.creation_lock: reply_command = "O"
passhash = user.get_password_hash() else:
if passhash is not None: reply_command = "N"
# a concurrent first login may have just created the address elif type == "passdb":
return verify_password(passhash, cleartext_password) user = args[1]
if not is_allowed_to_create(config, addr, cleartext_password): if user.endswith(f"@{config.mail_domain}"):
return False res = self.lookup_passdb(user, cleartext_password=args[0])
if not has_sufficient_resources(config): if res:
return False reply_command = "O"
else:
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/
if parts[2] == "shared/userdb/":
result = "".join(
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n"
def iter_userdb(self) -> list:
"""Get a list of all user addresses."""
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
def lookup_userdb(self, addr):
return self.config.get_user(addr).get_userdb_dict()
def lookup_passdb(self, addr, cleartext_password):
user = self.config.get_user(addr)
userdata = user.get_userdb_dict()
if userdata:
return userdata
if not is_allowed_to_create(self.config, addr, cleartext_password):
return
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
with lock:
userdata = user.get_userdb_dict()
if userdata:
return userdata
user.set_password(encrypt_password(cleartext_password)) user.set_password(encrypt_password(cleartext_password))
# mtail counts created_accounts off this exact line print(f"Created address: {addr}", file=sys.stderr)
print(f"Created address: {addr}", file=sys.stderr) return user.get_userdb_dict()
return True
class CreateHandler(BaseHTTPRequestHandler):
"""Answer POST /create requests from dovecot's auth.lua, body `addr\\tpassword`.
The body must be UTF-8 and only the first tab separates the fields,
so a password may itself contain tabs.
Any non-UTF8 or \\0 bytes in the body fail the request.
Addresses are ASCII: dovecot refuses any login name outside its
auth_username_chars before auth.lua ever sees it.
Dovecot hands auth.lua the exact password bytes the client sent;
decoding and re-encoding UTF-8 is byte-identical,
so dovecot's password_verify later recomputes the same hash crypt() stores here.
"""
protocol_version = "HTTP/1.1" # dovecot's HTTP client reuses connections
max_body_len = 512 # an address and a password
def do_POST(self):
if self.path != "/create":
self.reply(404)
return
length = self.body_length()
if length is None:
self.reply(400)
return
body = self.rfile.read(length)
try:
addr, _, password = body.decode("utf-8").partition("\t")
except UnicodeDecodeError:
self.reply(400)
return
if "\0" in addr or "\0" in password:
self.reply(400)
return
self.reply(200 if self.server.doveauth.create_user(addr, password) else 403)
def body_length(self):
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError):
return None
return length if 0 <= length <= self.max_body_len else None
def reply(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
if status != 200:
# Just close on any failure, as body might not be fully read.
# It's anyway cheap to re-establish http localhost without TLS.
self.send_header("Connection", "close")
self.end_headers()
def log_message(self, format, *args):
# the per-request access log would only duplicate our own stderr lines
pass
class DoveAuthServer(ThreadingHTTPServer):
# a burst of first-time logins (e.g. from CI) must not overflow
# the accept queue, see https://github.com/chatmail/relay/issues/436
request_queue_size = 1000
def __init__(self, config, port):
super().__init__(("127.0.0.1", port), CreateHandler)
self.doveauth = DoveAuth(config)
def main(): def main():
(cfgpath,) = sys.argv[1:] socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
migrate_from_db_to_maildir(config) migrate_from_db_to_maildir(config)
server = DoveAuthServer(config, config.doveauth_http_port) dictproxy = AuthDictProxy(config=config)
server.serve_forever()
dictproxy.serve_forever_from_socket(socket)
+1 -1
View File
@@ -259,7 +259,7 @@ def daily_expire_main(args=None):
args = parser.parse_args(args) args = parser.parse_args(args)
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = time.time() now = datetime.utcnow().timestamp()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
+1 -2
View File
@@ -27,7 +27,6 @@ to also write legacy metrics.py style output (default: /var/www/html/metrics):
import os import os
import tempfile import tempfile
import time
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime from datetime import datetime
@@ -265,7 +264,7 @@ def main(args=None):
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = time.time() now = datetime.utcnow().timestamp()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
@@ -42,33 +42,6 @@ mail_domain = {mail_domain}
# minimum length a password must have # minimum length a password must have
#password_min_length = 9 #password_min_length = 9
#
# System resource limits
#
# The following three limits refuse creation of new addresses
# while existing addresses keep working.
# Rejections are logged by the doveauth service.
# Maximum 1-minute load average, as reported by "uptime";
# it counts processes waiting for disk I/O as well as for CPU.
#max_load_1m = 5
# Minimum memory available without swapping.
#min_available_memory = 200M
# Minimum free disk space on the file system holding the mailboxes.
#min_free_disk_space = 1G
# Maximum number of concurrent IMAP connections
# (the Dovecot imap process limit).
#max_imap_connections = 10000
# Maximum number of concurrent SMTP connections
# on each of the submission and smtps ports (the Postfix process limit).
# A single client IP may use up to a fifth of this.
#max_smtp_connections = 1000
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy. # Both files must already exist before running cmdeploy.
-18
View File
@@ -1,10 +1,8 @@
import json
import logging import logging
import socket import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
@@ -20,18 +18,6 @@ def turn_credentials(turn_socket_path):
return file.readline().decode("utf-8").strip() return file.readline().decode("utf-8").strip()
def read_appversions(path):
try:
data = json.loads(path.read_bytes())
except FileNotFoundError:
return None
except (OSError, ValueError):
logging.exception(f"failed to read {path}")
return None
# the dict protocol is line-based, keep the value single-line
return json.dumps(data, separators=(",", ":"))
def _is_valid_token_timestamp(timestamp, now): def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days # Token if invalid after 90 days
# or if the timestamp is in the future. # or if the timestamp is in the future.
@@ -115,7 +101,6 @@ class MetadataDictProxy(DictProxy):
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path self.turn_socket_path = turn_socket_path
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -140,9 +125,6 @@ class MetadataDictProxy(DictProxy):
case "maxsmtprecipients": case "maxsmtprecipients":
# postfix default (see "postconf smtpd_recipient_limit") # postfix default (see "postconf smtpd_recipient_limit")
return "O1000\n" return "O1000\n"
case "appversions":
value = read_appversions(self.appversions_path)
return f"O{value}\n" if value else "N\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
-32
View File
@@ -1,32 +0,0 @@
"""Detect whether the system is at its limits."""
import logging
import psutil
MB = 1024 * 1024
def read_value(getter):
try:
return getter()
except Exception as e:
logging.warning("ignoring unreadable system limit: %s", e)
return None
def has_sufficient_resources(config):
"""Return False if load, memory or disk exceeds a configured limit."""
load = read_value(lambda: psutil.getloadavg()[0])
mem = read_value(lambda: psutil.virtual_memory().available // MB)
disk = read_value(lambda: psutil.disk_usage(str(config.mailboxes_dir)).free // MB)
if load is not None and load > config.max_load_1m:
msg = f"load avg {load:.2f} > {config.max_load_1m:.2f}"
elif mem is not None and mem < config.min_available_memory_mb:
msg = f"available memory {mem}MB < {config.min_available_memory_mb}MB"
elif disk is not None and disk < config.min_free_disk_space_mb:
msg = f"free disk {disk}MB < {config.min_free_disk_space_mb}MB"
else:
return True
logging.warning("registration rejected: %s", msg)
return False
@@ -1,3 +0,0 @@
"""Opt in to the chatmaild fixtures, which are not registered globally."""
from chatmaild.tests.plugin import * # noqa: F403
+5 -9
View File
@@ -20,10 +20,6 @@ def make_config(tmp_path):
basedir.mkdir(parents=True, exist_ok=True) basedir.mkdir(parents=True, exist_ok=True)
overrides = settings.copy() if settings else {} overrides = settings.copy() if settings else {}
overrides["mailboxes_dir"] = str(basedir) overrides["mailboxes_dir"] = str(basedir)
# permissive resource limits so tests never depend on host load/memory/disk
overrides.setdefault("max_load_1m", "99999")
overrides.setdefault("min_available_memory", "0")
overrides.setdefault("min_free_disk_space", "0")
write_initial_config(inipath, mail_domain, overrides=overrides) write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath) return read_config(inipath)
@@ -41,22 +37,22 @@ def ipv4_config(make_config):
@pytest.fixture @pytest.fixture
def example_maildomain(example_config): def maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain
@pytest.fixture @pytest.fixture
def testaddr(example_maildomain): def testaddr(maildomain):
return f"user.name@{example_maildomain}" return f"user.name@{maildomain}"
@pytest.fixture @pytest.fixture
def example_gencreds(example_maildomain): def gencreds(maildomain):
count = itertools.count() count = itertools.count()
next(count) next(count)
def gen(domain=None): def gen(domain=None):
domain = domain if domain else example_maildomain domain = domain if domain else maildomain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -1,96 +0,0 @@
import json
import pytest
from chatmaild.metadata import MetadataDictProxy
ALLOWED_URL_PREFIXES = (
"https://github.com/deltachat/",
"https://download.delta.chat/",
)
def check_string(value):
assert isinstance(value, str), value
assert value
def check_version_integer(value):
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
assert isinstance(value, int) and not isinstance(value, bool), value
assert 0 <= value < 2**32, value
def check_appversions(data):
"""Verifies the file the way core parses it.
core deserializes into typed structs and drops the whole payload
of a relay if a single value has an unexpected type,
while missing or misspelled keys silently turn into defaults.
"""
assert set(data) == {"clients"}, data
assert isinstance(data["clients"], list)
assert data["clients"]
client_ids = []
for client in data["clients"]:
assert set(client) == {"clientId", "sources"}, client
check_string(client["clientId"])
client_ids.append(client["clientId"])
assert isinstance(client["sources"], list)
assert client["sources"]
source_ids = []
for source in client["sources"]:
assert set(source) == {
"sourceId",
"versionInteger",
"versionString",
"downloadUrl",
}, source
check_string(source["sourceId"])
source_ids.append(source["sourceId"])
check_version_integer(source["versionInteger"])
check_string(source["versionString"])
check_string(source["downloadUrl"])
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
# core takes the first matching source, later duplicates never surface
assert len(set(source_ids)) == len(source_ids), source_ids
assert len(set(client_ids)) == len(client_ids), client_ids
@pytest.fixture
def appversions():
# check the file which chatmail-metadata actually serves
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
return json.loads(path.read_text())
def test_appversions_schema(appversions):
check_appversions(appversions)
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
def test_version_integer_rejected(appversions, value):
appversions["clients"][0]["sources"][0]["versionInteger"] = value
with pytest.raises(AssertionError):
check_appversions(appversions)
@pytest.mark.parametrize("key", ["clientId", "sources"])
def test_misspelled_client_key_rejected(appversions, key):
client = appversions["clients"][0]
client[key + "s"] = client.pop(key)
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_duplicate_source_id_rejected(appversions):
sources = appversions["clients"][0]["sources"]
sources.append(dict(sources[0]))
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_foreign_download_url_rejected(appversions):
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
with pytest.raises(AssertionError):
check_appversions(appversions)
+2 -4
View File
@@ -30,9 +30,9 @@ def test_read_config_ipv4(ipv4_config):
assert ipv4_config.mail_domain == "[1.3.3.7]" assert ipv4_config.mail_domain == "[1.3.3.7]"
def test_read_config_basic_using_defaults(tmp_path, example_maildomain): def test_read_config_basic_using_defaults(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
inipath.write_text(f"[params]\nmail_domain = {example_maildomain}") inipath.write_text(f"[params]\nmail_domain = {maildomain}")
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60 assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081 assert example_config.filtermail_smtp_port_incoming == 10081
@@ -45,8 +45,6 @@ def test_read_config_basic_using_defaults(tmp_path, example_maildomain):
assert example_config.username_min_length == 9 assert example_config.username_min_length == 9
assert example_config.username_max_length == 9 assert example_config.username_max_length == 9
assert example_config.password_min_length == 9 assert example_config.password_min_length == 9
assert example_config.max_imap_connections == 10000
assert example_config.max_smtp_connections == 1000
assert example_config._unused_keys == [] assert example_config._unused_keys == []
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import DoveAuth from chatmaild.doveauth import AuthDictProxy
from chatmaild.expire import daily_expire_main as main_expire from chatmaild.expire import daily_expire_main as main_expire
@@ -18,10 +18,10 @@ def test_login_timestamps(example_config):
def test_delete_inactive_users(example_config): def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
doveauth = DoveAuth(example_config) dictproxy = AuthDictProxy(example_config)
def create_user(addr, last_login): def create_user(addr, last_login):
doveauth.create_user(addr, "q9mr3faue") dictproxy.lookup_passdb(addr, "q9mr3faue")
user = example_config.get_user(addr) user = example_config.get_user(addr)
user.maildir.joinpath("cur").mkdir() user.maildir.joinpath("cur").mkdir()
user.maildir.joinpath("cur", "something").mkdir() user.maildir.joinpath("cur", "something").mkdir()
+130 -168
View File
@@ -1,37 +1,42 @@
import http.client import io
import json
import queue
import threading import threading
from concurrent.futures import ThreadPoolExecutor import traceback
import pytest import pytest
import chatmaild.doveauth import chatmaild.doveauth
from chatmaild.doveauth import ( from chatmaild.doveauth import (
CreateHandler, AuthDictProxy,
DoveAuth,
DoveAuthServer,
is_allowed_to_create, is_allowed_to_create,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
@pytest.fixture @pytest.fixture
def doveauth(example_config): def dictproxy(example_config):
return DoveAuth(example_config) return AuthDictProxy(config=example_config)
def stored_hash(config, addr): def test_basic(dictproxy, gencreds):
return config.get_user(addr).get_password_hash() addr, password = gencreds()
dictproxy.lookup_passdb(addr, password)
data = dictproxy.lookup_userdb(addr)
assert data
data2 = dictproxy.lookup_passdb(addr, password)
assert data == data2
def test_basic(doveauth, example_config, example_gencreds): def test_iterate_addresses(dictproxy):
addr, password = example_gencreds() addresses = []
assert doveauth.create_user(addr, password)
passhash = stored_hash(example_config, addr)
assert passhash.startswith("{SHA512-CRYPT}")
# a second login verifies against the stored hash and rewrites nothing for i in range(10):
assert doveauth.create_user(addr, password) addresses.append(f"asdf1234{i}@chat.example.org")
assert stored_hash(example_config, addr) == passhash dictproxy.lookup_passdb(addresses[-1], "q9mr3faue")
res = dictproxy.iter_userdb()
assert set(res) == set(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -48,32 +53,75 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(doveauth, example_config): def test_dont_overwrite_password_on_wrong_login(dictproxy):
addr = "newuser12@chat.example.org" """Test that logging in with a different password doesn't create a new user"""
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983") res = dictproxy.lookup_passdb(
passhash = stored_hash(example_config, addr) "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
)
assert not doveauth.create_user(addr, "kajdslqwe") assert res["password"]
assert stored_hash(example_config, addr) == passhash res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe")
# this function always returns a password hash, which is actually compared by dovecot.
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983") assert res["password"] == res2["password"]
assert stored_hash(example_config, addr) == passhash
def test_foreign_domain_is_refused(doveauth): def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
assert not doveauth.create_user("newuser12@evil.example.org", "qlwkejqlwe12")
def test_nocreate_file(monkeypatch, tmpdir, doveauth, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
addr = "newuser12@chat.example.org" dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik")
assert not doveauth.create_user(addr, "zequ0Aimuchoodaechik") assert not dictproxy.lookup_userdb("newuser12@chat.example.org")
assert stored_hash(example_config, addr) is None
def test_handle_dovecot_request(dictproxy):
transactions = {}
# Test that password can contain ", ', \ and /
msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = dictproxy.handle_dovecot_request(msg, transactions)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(example_config):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(gencreds, example_config):
dictproxy = AuthDictProxy(config=example_config)
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
lines = wfile.getvalue().decode("ascii").split("\n")
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
assert not lines[2]
def test_invalid_localpart_characters(make_config): def test_invalid_localpart_characters(make_config):
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
config = make_config("chat.example.org", {"username_min_length": "3"}) config = make_config("chat.example.org", {"username_min_length": "3"})
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
domain = config.mail_domain domain = config.mail_domain
@@ -93,150 +141,64 @@ def test_invalid_localpart_characters(make_config):
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password) assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
assert not is_allowed_to_create(config, f"abc/def@{domain}", password) assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password) assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
assert not is_allowed_to_create(config, f"üser123@{domain}", password)
def test_concurrent_creation_same_account(doveauth, example_config, capsys): def test_concurrent_creation_same_account(dictproxy):
"""Test that concurrent creation of the same account doesn't corrupt password."""
addr = "racetest1@chat.example.org" addr = "racetest1@chat.example.org"
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
num_threads = 10
results = queue.Queue()
def create(_): def create():
ok = doveauth.create_user(addr, password) try:
return ok, stored_hash(example_config, addr) res = dictproxy.lookup_passdb(addr, password)
results.put(("ok", res))
except Exception:
results.put(("err", traceback.format_exc()))
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
passwords_seen = set()
for _ in range(num_threads):
status, res = results.get()
if status == "err":
pytest.fail(f"concurrent creation failed\n{res}")
passwords_seen.add(res["password"])
with ThreadPoolExecutor(10) as pool:
results = list(pool.map(create, range(10)))
assert all(ok for ok, _ in results)
# all threads must see the same password hash # all threads must see the same password hash
assert len({passhash for _, passhash in results}) == 1 assert len(passwords_seen) == 1
assert capsys.readouterr().err.count("Created address:") == 1
def test_insufficient_resources_block_creation_not_existing_logins( def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
doveauth, example_gencreds, monkeypatch num_threads = 50
): req_per_thread = 5
addr, password = example_gencreds() results = queue.Queue()
assert doveauth.create_user(addr, password)
monkeypatch.setattr( def lookup():
chatmaild.doveauth, "has_sufficient_resources", lambda config: False for i in range(req_per_thread):
) addr, password = gencreds()
newaddr, newpassword = example_gencreds()
assert not doveauth.create_user(newaddr, newpassword)
assert doveauth.create_user(addr, password)
class TestHttpPost:
@pytest.fixture
def doveauth_server(self, example_config):
server = DoveAuthServer(example_config, port=0)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"127.0.0.1:{server.server_address[1]}"
server.shutdown()
server.server_close()
@pytest.fixture
def post(self, doveauth_server):
def post(path, data):
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
try: try:
return self.post_on(conn, path, data).status dictproxy.lookup_passdb(addr, password)
finally: except Exception:
conn.close() results.put(traceback.format_exc())
else:
results.put(None)
return post threads = []
for i in range(num_threads):
thread = threading.Thread(target=lookup, daemon=True)
threads.append(thread)
@pytest.fixture print(f"created {num_threads} threads, starting them and waiting for results")
def connection(self, doveauth_server): for thread in threads:
"""One kept-alive connection, which is all dovecot's HTTP client opens.""" thread.start()
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
yield conn
conn.close()
@staticmethod for i in range(num_threads * req_per_thread):
def post_on(conn, path, data): res = results.get()
conn.request("POST", path, body=data) if res is not None:
resp = conn.getresponse() pytest.fail(f"concurrent lookup failed\n{res}")
resp.read()
return resp
def test_create_and_verify(self, post, example_config, example_gencreds):
addr, password = example_gencreds()
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert stored_hash(example_config, addr).startswith("{SHA512-CRYPT}")
# second login with the same password verifies, a wrong one is refused
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert post("/create", f"{addr}\twrong{password}".encode()) == 403
def test_password_special_chars_survive_transport(self, post, example_gencreds):
addr, _ = example_gencreds()
password = "laksjdlaksjdlak\\sjdlk\"12j'3l1/k2\tj3123"
body = f"{addr}\t{password}".encode()
assert post("/create", body) == 200
assert post("/create", body) == 200
assert post("/create", f"{addr}\totherpassword1".encode()) == 403
def test_password_must_be_utf8(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpässwort12".encode()) == 200
assert post("/create", addr.encode() + b"\tp\xe4sswort12") == 400
def test_nul_is_refused_before_crypt_sees_it(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpass\0word12".encode()) == 400
assert (
post("/create", "us\0er12345@chat.example.org\tlongenough1".encode()) == 400
)
def test_refused_creation(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tshort".encode()) == 403
assert post("/create", b"not-an-address\tlongenoughpassword") == 403
body = "bürger123@chat.example.org\tlongenoughpw".encode()
assert post("/create", body) == 403
assert post("/create", b"") == 403
def test_body_length_limit(self, post, example_gencreds):
addr, _ = example_gencreds()
fill = CreateHandler.max_body_len - len(addr) - len("\t")
body = f"{addr}\t{'x' * fill}".encode()
assert len(body) == CreateHandler.max_body_len
assert post("/create", body) == 200
body = f"{addr}\t{'x' * (fill + 1)}".encode()
assert len(body) == CreateHandler.max_body_len + 1
assert post("/create", body) == 400
def test_connection_is_reused_across_200_replies(
self, connection, example_gencreds
):
addr, password = example_gencreds()
body = f"{addr}\t{password}".encode()
# create, then verify the same password, on one connection
for _ in range(2):
resp = self.post_on(connection, "/create", body)
assert (resp.status, resp.will_close) == (200, False)
@pytest.mark.parametrize(
"path,data,status",
[
("/other", b"not read", 404),
("/create", b"x" * (CreateHandler.max_body_len + 1), 400),
("/create", b"not-an-address\tlongenoughpassword", 403),
],
)
def test_error_replies_close_the_connection(self, connection, path, data, status):
resp = self.post_on(connection, path, data)
assert (resp.status, resp.will_close) == (status, True)
@pytest.mark.parametrize("content_length", [None, "-1", "notanumber", "999999"])
def test_bad_content_length(self, connection, content_length):
# the fixture timeout turns a server that waits for the body into a failure
connection.putrequest("POST", "/create", skip_accept_encoding=True)
if content_length is not None:
connection.putheader("Content-Length", content_length)
connection.endheaders()
resp = connection.getresponse()
assert resp.status == 400
assert resp.will_close
+6 -3
View File
@@ -3,6 +3,7 @@ import os
import random import random
import shutil import shutil
import time import time
from datetime import datetime
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
@@ -40,7 +41,7 @@ def fill_mbox(folderdir):
def create_new_messages(basedir, relpaths, size=1000, days=0): def create_new_messages(basedir, relpaths, size=1000, days=0):
now = time.time() now = datetime.utcnow().timestamp()
for relpath in relpaths: for relpath in relpaths:
msg_path = Path(basedir).joinpath(relpath) msg_path = Path(basedir).joinpath(relpath)
@@ -111,7 +112,9 @@ def test_mbox_without_password(mbox1, example_config, capsys):
mbox_rescan = MailboxStat(mbox1.basedir) mbox_rescan = MailboxStat(mbox1.basedir)
assert mbox_rescan.last_login is None assert mbox_rescan.last_login is None
exp = Expiry(example_config, dry=False, now=time.time(), verbose=False) exp = Expiry(
example_config, dry=False, now=datetime.now().timestamp(), verbose=False
)
exp.process_mailbox_stat(mbox_rescan) exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" in err assert "doesn't have last_login but isn't empty" in err
@@ -149,7 +152,7 @@ def test_report_mdir_filters_by_path(mbox1, example_config):
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory.""" """Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
from chatmaild.fsreport import Report from chatmaild.fsreport import Report
now = time.time() now = datetime.utcnow().timestamp()
# Set password mtime to old enough so min_login_age check passes # Set password mtime to old enough so min_login_age check passes
password = Path(mbox1.basedir).joinpath("password") password = Path(mbox1.basedir).joinpath("password")
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import DoveAuth from chatmaild.doveauth import AuthDictProxy
from chatmaild.lastlogin import ( from chatmaild.lastlogin import (
LastLoginDictProxy, LastLoginDictProxy,
) )
@@ -9,8 +9,8 @@ from chatmaild.lastlogin import (
def test_handle_dovecot_request_last_login(testaddr, example_config): def test_handle_dovecot_request_last_login(testaddr, example_config):
dictproxy = LastLoginDictProxy(config=example_config) dictproxy = LastLoginDictProxy(config=example_config)
doveauth = DoveAuth(example_config) authproxy = AuthDictProxy(config=example_config)
doveauth.create_user(testaddr, "1l2k3j1l2k3jl123") authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {} dictproxy_transactions = {}
@@ -1,5 +1,4 @@
import io import io
import json
import time import time
import pytest import pytest
@@ -8,7 +7,6 @@ import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, MetadataDictProxy,
read_appversions,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -371,32 +369,6 @@ def test_iroh_relay(dictproxy):
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_read_appversions(tmp_path):
path = tmp_path.joinpath("appversions.json")
assert read_appversions(path) is None
path.write_text('{\n "clients": []\n}')
assert read_appversions(path) == '{"clients":[]}'
# the value travels as a single dict protocol line
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
path.write_text("bad json")
assert read_appversions(path) is None
def test_appversions_lookup(dictproxy):
# the version information shipped with chatmaild is served as a single line
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
key += b"\tuser@example.org"
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
value = wfile.getvalue()
assert value.startswith(b"O") and value.endswith(b"\n")
assert json.loads(value[1:])["clients"]
def test_legacy_token_migration(metadata, testaddr): def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data: with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"] data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
@@ -63,7 +63,7 @@ def test_migration(tmp_path, example_config, caplog):
user = example_config.get_user(path.name) user = example_config.get_user(path.name)
if last_login: if last_login:
assert user.get_last_login_timestamp() == last_login assert user.get_last_login_timestamp() == last_login
assert password == user.get_password_hash() assert password == user.get_userdb_dict()["password"]
assert not all assert not all
assert not example_config.passdb_path.exists() assert not example_config.passdb_path.exists()
@@ -48,7 +48,7 @@ def test_create_dclogin_url_ipv4(ipv4_config):
assert addr in url assert addr in url
def test_print_new_account(capsys, monkeypatch, tmpdir, example_config): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()
out, err = capsys.readouterr() out, err = capsys.readouterr()
@@ -1,45 +0,0 @@
import shutil
import psutil
from chatmaild.syslimits import has_sufficient_resources
PERMISSIVE = {
"max_load_1m": "99999",
"min_available_memory": "0",
"min_free_disk_space": "0",
}
def test_rejects_constrained_system(make_config, caplog):
assert has_sufficient_resources(make_config("chat.example.org", PERMISSIVE))
for settings in (
{"max_load_1m": "-1.0"},
{"min_available_memory": "99999999G"},
{"min_free_disk_space": "99999999G"},
):
config = make_config("chat.example.org", PERMISSIVE | settings)
caplog.clear()
assert not has_sufficient_resources(config), settings
assert "registration rejected" in caplog.text
def test_unreadable_disk_does_not_reject(make_config, caplog):
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
shutil.rmtree(config.mailboxes_dir)
assert has_sufficient_resources(config)
assert "ignoring" in caplog.text
def test_one_unreadable_value_keeps_other_checks(make_config, monkeypatch, caplog):
def raise_error(*args):
raise psutil.Error("dud")
monkeypatch.setattr(psutil, "getloadavg", raise_error)
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
assert not has_sufficient_resources(config)
assert "ignoring" in caplog.text
+11 -6
View File
@@ -8,23 +8,28 @@ def test_login_timestamp(testaddr, example_config):
assert user.get_last_login_timestamp() == 86400 * 2 assert user.get_last_login_timestamp() == 86400 * 2
def test_get_password_hash_not_set(testaddr, example_config, caplog): def test_get_user_dict_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr) user = example_config.get_user(testaddr)
assert not caplog.records assert not caplog.records
assert user.get_password_hash() is None assert user.get_userdb_dict() == {}
assert len(caplog.records) == 0 assert len(caplog.records) == 0
user.set_password("") user.set_password("")
assert user.get_password_hash() is None assert user.get_userdb_dict() == {}
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_get_password_hash(make_config, tmp_path): def test_get_user_dict(make_config, tmp_path):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
user = config.get_user("user1@something.org") addr = "user1@something.org"
user = config.get_user(addr)
enc_password = "l1k2j31lk2j3l1k23j123" enc_password = "l1k2j31lk2j3l1k23j123"
user.set_password(enc_password) user.set_password(enc_password)
assert user.get_password_hash() == enc_password data = user.get_userdb_dict()
assert addr in str(data["home"])
assert data["uid"] == "vmail"
assert data["gid"] == "vmail"
assert data["password"] == enc_password
def test_no_mailboxes_dir(testaddr, example_config, tmp_path): def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
+9 -6
View File
@@ -21,17 +21,20 @@ class User:
def can_track(self): def can_track(self):
return "@" in self.addr return "@" in self.addr
def get_password_hash(self): def get_userdb_dict(self):
"""Return a non-empty dovecot 'userdb' style dict
if the user has an existing non-empty password"""
try: try:
passhash = self.password_path.read_text() pw = self.password_path.read_text()
except FileNotFoundError: except FileNotFoundError:
return None return {}
if not passhash: if not pw:
logging.error(f"password is empty for: {self.addr}") logging.error(f"password is empty for: {self.addr}")
return None return {}
return passhash home = str(self.maildir)
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self): def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists() return not self.enforce_E2EE_path.exists()
+4 -2
View File
@@ -19,8 +19,6 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"jinja2",
"lupa",
"deltachat-rpc-client", "deltachat-rpc-client",
"deltachat-rpc-server", "deltachat-rpc-server",
] ]
@@ -28,6 +26,10 @@ dependencies = [
[project.scripts] [project.scripts]
cmdeploy = "cmdeploy.cmdeploy:main" cmdeploy = "cmdeploy.cmdeploy:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
"cmdeploy.testplugin" = "cmdeploy.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
-1
View File
@@ -1 +0,0 @@
+3 -9
View File
@@ -4,8 +4,6 @@ from ..basedeploy import Deployer
class AcmetoolDeployer(Deployer): class AcmetoolDeployer(Deployer):
bin_path = "/usr/bin/acmetool"
def __init__(self, email, domains): def __init__(self, email, domains):
self.domains = domains self.domains = domains
self.email = email self.email = email
@@ -43,12 +41,8 @@ class AcmetoolDeployer(Deployer):
domains=self.domains, domains=self.domains,
) )
self.ensure_systemd_unit( self.ensure_systemd_unit("acmetool/acmetool-redirector.service")
"acmetool/acmetool-redirector.service.j2", bin_path=self.bin_path self.ensure_systemd_unit("acmetool/acmetool-reconcile.service")
)
self.ensure_systemd_unit(
"acmetool/acmetool-reconcile.service.j2", bin_path=self.bin_path
)
self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer") self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer")
def activate(self): def activate(self):
@@ -58,5 +52,5 @@ class AcmetoolDeployer(Deployer):
server.shell( server.shell(
name=f"Reconcile certificates for: {', '.join(self.domains)}", name=f"Reconcile certificates for: {', '.join(self.domains)}",
commands=[f"{self.bin_path} --batch --xlog.severity=debug reconcile"], commands=["acmetool --batch --xlog.severity=debug reconcile"],
) )
@@ -4,5 +4,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart={{ bin_path }} --batch reconcile ExecStart=/usr/bin/acmetool --batch reconcile
@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service] [Service]
Type=notify Type=notify
ExecStart={{ bin_path }} redirector --service.uid=daemon --bind=127.0.0.1:402 ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
Restart=always Restart=always
RestartSec=30 RestartSec=30
+3 -4
View File
@@ -51,7 +51,7 @@ def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)
def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None: def configure_remote_units(deployer, mail_domain, units) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
@@ -63,7 +63,6 @@ def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None:
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=mail_domain,
**kwargs,
) )
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -227,7 +226,7 @@ class Deployer:
res = files.directory(name=name, path=path, present=False, **kwargs) res = files.directory(name=name, path=path, present=False, **kwargs)
return self._update_restart_signals(path, res) return self._update_restart_signals(path, res)
def download_executable(self, url, dest, sha256sum, extract=None, mode="755"): def download_executable(self, url, dest, sha256sum, extract=None):
existing = host.get_fact(Sha256File, dest) existing = host.get_fact(Sha256File, dest)
if existing == sha256sum: if existing == sha256sum:
return return
@@ -244,7 +243,7 @@ class Deployer:
f"({dl_cmd}" f"({dl_cmd}"
f" && echo '{sha256sum} {tmp}' | sha256sum -c" f" && echo '{sha256sum} {tmp}' | sha256sum -c"
f" && mv {tmp} {dest})", f" && mv {tmp} {dest})",
f"chmod {mode} {dest}", f"chmod 755 {dest}",
], ],
) )
self.need_restart = True self.need_restart = True
+37 -70
View File
@@ -33,7 +33,6 @@ from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
from .pins import IROH_ARTIFACTS, TURN_ARTIFACTS
from .postfix.deployer import PostfixDeployer from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths from .www import build_webpages, find_merge_conflict, get_paths
@@ -99,23 +98,6 @@ def _install_remote_venv_with_chatmaild(deployer) -> None:
dest=remote_dist_file, dest=remote_dist_file,
) )
# Remove venv if its Python major.minor doesn't match the system Python
server.shell(
name="remove stale chatmaild venv if python version changed",
commands=[
"\n".join(
[
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
# an empty sys_version means we could not tell: keep the venv
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
f"|| rm -rf {remote_venv_dir}",
]
)
],
)
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,
@@ -156,22 +138,9 @@ class UnboundDeployer(Deployer):
# On an IPv4-only system, if unbound is started but not configured, # On an IPv4-only system, if unbound is started but not configured,
# it causes subsequent steps to fail to resolve hosts. # it causes subsequent steps to fail to resolve hosts.
with blocked_service_startup(): with blocked_service_startup():
# dns-root-data is an optional package
# that contains /usr/share/dns/root.key
#
# This file is copied into /var/lib/unbound/root.key
# at the start of "unbound" systemd unit
# by /usr/libexec/unbound-helper shell script
# from the "unbound" package as of version 1.17.1-2+deb12u4
#
# The same /var/lib/unbound/root.key can be retrieved directly
# following the procedure from
# <https://www.rfc-editor.org/info/rfc7958/#section-3.1>
# with "unbound-anchor -a /var/lib/unbound/root.key"
# We don't install and use "unbound-anchor".
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages=["unbound", "dns-root-data", "dnsutils"], packages=["unbound", "unbound-anchor", "dnsutils"],
) )
def configure(self): def configure(self):
@@ -197,6 +166,12 @@ class UnboundDeployer(Deployer):
dest="/etc/resolv.conf", dest="/etc/resolv.conf",
force=True, force=True,
) )
server.shell(
name="Generate root keys for validating DNSSEC",
commands=[
"unbound-anchor -a /var/lib/unbound/root.key || true",
],
)
self.ensure_directory( self.ensure_directory(
path="/etc/unbound/unbound.conf.d", path="/etc/unbound/unbound.conf.d",
) )
@@ -271,13 +246,6 @@ class LegacyRemoveDeployer(Deployer):
def install(self): def install(self):
apt.packages(name="Remove rspamd", packages="rspamd", present=False) apt.packages(name="Remove rspamd", packages="rspamd", present=False)
# unbound-anchor was used to download /var/lib/unbound/root.key
# It is replaced by dns-root-data which contains /usr/share/dns/root.key.
# unbound systemd unit copies /usr/share/dns/root.key
# into /var/lib/unbound/root.key automatically on start
# as long as /usr/share/dns/root.key is present.
apt.packages(name="Remove unbound-anchor", packages="unbound-anchor", present=False)
# remove historic expunge script # remove historic expunge script
# which is now implemented through a systemd timer (chatmail-expire) # which is now implemented through a systemd timer (chatmail-expire)
self.remove_file("/etc/cron.d/expunge") self.remove_file("/etc/cron.d/expunge")
@@ -319,48 +287,55 @@ def check_config(config):
class TurnDeployer(Deployer): class TurnDeployer(Deployer):
bin_path = "/usr/local/bin/chatmail-turn"
def __init__(self, mail_domain): def __init__(self, mail_domain):
self.mail_domain = mail_domain self.mail_domain = mail_domain
self.units = ["turnserver"] self.units = ["turnserver"]
def install(self): def install(self):
(url, sha256sum) = TURN_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
self.download_executable(url, self.bin_path, sha256sum) "x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
),
"aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}[host.get_fact(facts.server.Arch)]
self.download_executable(url, "/usr/local/bin/chatmail-turn", sha256sum)
def configure(self): def configure(self):
configure_remote_units( configure_remote_units(self, self.mail_domain, self.units)
self, self.mail_domain, self.units, bin_path=self.bin_path
)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self, self.units)
class IrohDeployer(Deployer): class IrohDeployer(Deployer):
bin_path = "/usr/local/bin/iroh-relay"
config_path = "/etc/iroh-relay.toml"
def __init__(self, enable_iroh_relay): def __init__(self, enable_iroh_relay):
self.enable_iroh_relay = enable_iroh_relay self.enable_iroh_relay = enable_iroh_relay
def install(self): def install(self):
(url, sha256sum) = IROH_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
"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",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
),
"aarch64": (
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}[host.get_fact(facts.server.Arch)]
self.download_executable( self.download_executable(
url, url,
self.bin_path, "/usr/local/bin/iroh-relay",
sha256sum, sha256sum,
extract="gunzip | tar -xf - ./iroh-relay -O", extract="gunzip | tar -xf - ./iroh-relay -O",
) )
def configure(self): def configure(self):
self.ensure_systemd_unit( self.ensure_systemd_unit("iroh-relay.service")
"iroh-relay.service.j2", self.put_file("iroh-relay.toml", "/etc/iroh-relay.toml")
bin_path=self.bin_path,
config_path=self.config_path,
)
self.put_file("iroh-relay.toml", self.config_path)
def activate(self): def activate(self):
self.ensure_service( self.ensure_service(
@@ -381,8 +356,6 @@ class ChatmailVenvDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
# doveauth must restart when chatmaild/ini file changes
"doveauth",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -417,12 +390,6 @@ class ChatmailDeployer(Deployer):
src=BytesIO(b'APT::Install-Recommends "false";\n'), src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends", dest="/etc/apt/apt.conf.d/00InstallRecommends",
) )
# Pin dovecot-* to priority -1 before any apt operation, apt should
# never manage dovecot as our version might be lower than the distro's.
self.put_file(
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
dest="/etc/apt/preferences.d/pin-dovecot",
)
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -529,18 +496,18 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
if config.tls_cert_mode == "acme": if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 402)) port_services.append(("acmetool", 402))
port_services += [ port_services += [
(["imap-login", "dovecot"], 143), (["imap-login", "dovecot", "nginx"], config.imap_port),
# acmetool previously listened on port 80, # acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402 # so don't complain during upgrade that moved it to port 402
# and gave the port to nginx. # and gave the port to nginx.
(["acmetool", "nginx"], 80), (["acmetool", "nginx"], 80),
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd", "nginx"], config.smtp_port),
(["master", "smtpd"], 587), (["master", "smtpd", "nginx"], config.smtps_port),
(["dovecot", "imap-login"], 993), (["imap-login", "dovecot", "nginx"], config.imaps_port),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
(["dovecot", "stats"], 3904), ("stats", 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
+12
View File
@@ -0,0 +1,12 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = no
iterate_prefix = userdb/
default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/2.3/configuration_manual/config_file/config_variables/#modifiers>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu
-68
View File
@@ -1,68 +0,0 @@
-- Existing addresses are served from the maildir directly.
-- Unknown ones are offered to doveauth, which owns the creation policy.
local mailboxes_dir = "{{ config.mailboxes_dir }}"
local domain_suffix = "@{{ config.mail_domain }}"
local create_url = "http://127.0.0.1:{{ config.doveauth_http_port }}/create"
local http_client
local function is_ours(user)
return user:sub(-#domain_suffix) == domain_suffix
and not user:find("/", 1, true)
end
local function password_hash(user)
local fh = io.open(mailboxes_dir .. "/" .. user .. "/password", "r")
if not fh then
return nil
end
local hash, rest = fh:read("l", "a")
fh:close()
if hash == nil or hash == "" or rest ~= "" then
return nil
end
return hash
end
local function userdb_fields(user)
return {home = mailboxes_dir .. "/" .. user, uid = "vmail", gid = "vmail"}
end
local function create(user, password)
local request = http_client:request({url = create_url, method = "POST"})
request:set_payload(user .. "\t" .. password)
return request:submit():status()
end
-- Entry points called by dovecot
function script_init()
http_client = dovecot.http.client({request_timeout_msecs = 5000, max_attempts = 1})
return 0
end
function auth_userdb_lookup(req)
if not is_ours(req.user) or password_hash(req.user) == nil then
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, {}
end
return dovecot.auth.USERDB_RESULT_OK, userdb_fields(req.user)
end
function auth_password_verify(req, password)
if not is_ours(req.user) then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
local hash = password_hash(req.user)
if hash == nil then
-- doveauth refuses with 4xx; dovecot reports its own failures as 9000 and up
local status = create(req.user, password)
if status >= 500 then
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, {}
elseif status ~= 200 then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
elseif req:password_verify(hash, password) ~= 1 then
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, {}
end
return dovecot.auth.PASSDB_RESULT_OK, userdb_fields(req.user)
end
+41 -53
View File
@@ -1,10 +1,11 @@
import io
import urllib.request import urllib.request
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.operations import files, server from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
@@ -13,15 +14,18 @@ from cmdeploy.basedeploy import (
configure_remote_units, configure_remote_units,
is_in_container, is_in_container,
) )
from cmdeploy.pins import DOVECOT_SHA256, DOVECOT_VERSION
VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release" DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
DOVECOT_SHA256 = {
def _stamped_version(deb_release: int) -> str: ("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
"""Version as built, including the per-distro suffix stamped by ("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
chatmail/dovecot CI into package version and filename.""" ("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
return f"{DOVECOT_VERSION}+deb{deb_release}u1" ("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
}
class DovecotDeployer(Deployer): class DovecotDeployer(Deployer):
@@ -30,34 +34,38 @@ class DovecotDeployer(Deployer):
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
self.disable_mail = disable_mail self.disable_mail = disable_mail
self.units = [] self.units = ["doveauth"]
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
with blocked_service_startup(): with blocked_service_startup():
debs = [] debs = []
for pkg in ("core", "imapd", "lmtpd", "auth-lua"): for pkg in ("core", "imapd", "lmtpd"):
deb, changed = _download_dovecot_package(pkg, arch, deb_release) deb, changed = _download_dovecot_package(pkg, arch)
self.need_restart |= changed self.need_restart |= changed
if deb: if deb:
debs.append(deb) debs.append(deb)
if debs: if debs:
deb_list = " ".join(debs) deb_list = " ".join(debs)
# apt-get install with local .deb paths resolves depends # First dpkg may fail on missing dependencies (stderr suppressed);
# against the configured repos (e.g. pulls libwrap0), # apt-get --fix-broken pulls them in, then dpkg retries cleanly.
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
server.shell( server.shell(
name="Install dovecot packages", name="Install dovecot packages",
commands=[ commands=[
"DEBIAN_FRONTEND=noninteractive apt-get install -y " f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
'-o Dpkg::Options::="--force-confdef" ' "DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
'-o Dpkg::Options::="--force-confold" ' f"dpkg --force-confdef --force-confold -i {deb_list}",
f"--allow-downgrades {deb_list}",
], ],
) )
self.need_restart = True self.need_restart = True
self.put_file(
src=io.StringIO(
"Package: dovecot-*\n"
"Pin: version *\n"
"Pin-Priority: -1\n"
),
dest="/etc/apt/preferences.d/pin-dovecot",
)
def configure(self): def configure(self):
configure_remote_units(self, self.config.mail_domain_bare, self.units) configure_remote_units(self, self.config.mail_domain_bare, self.units)
@@ -70,7 +78,7 @@ class DovecotDeployer(Deployer):
if not self.disable_mail and not self.need_restart: if not self.disable_mail and not self.need_restart:
stale = host.get_fact( stale = host.get_fact(
Command, Command,
"pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);" 'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);'
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"' ' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true", " && echo STALE || true",
) )
@@ -85,15 +93,6 @@ class DovecotDeployer(Deployer):
) )
def _parse_version_id(version_line: str) -> int:
"""Debian major release from an /etc/os-release VERSION_ID line."""
_, _, raw = (version_line or "").strip().partition("=")
try:
return int(raw.strip('"'))
except ValueError:
raise ValueError(f"cannot determine Debian release from {version_line!r}")
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
try: try:
req = urllib.request.Request(primary, method="HEAD") req = urllib.request.Request(primary, method="HEAD")
@@ -103,36 +102,27 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]: def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
"""Download a dovecot .deb if needed, return (path, changed).""" """Download a dovecot .deb if needed, return (path, changed)."""
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
pkg_name = f"dovecot-{package}" pkg_name = f"dovecot-{package}"
try: sha256 = DOVECOT_SHA256.get((package, arch))
# never fall back to the distro package: it is pinned to -1 and would if sha256 is None:
# in any case be a version we did not build and do not support op = apt.packages(packages=[pkg_name])
sha256 = DOVECOT_SHA256[(arch, deb_release, package)] return None, bool(getattr(op, "changed", False))
except KeyError:
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
stamped_version = _stamped_version(deb_release)
installed_versions = host.get_fact(DebPackages).get(pkg_name, []) installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if f"1:{stamped_version}" in installed_versions: if DOVECOT_PACKAGE_VERSION in installed_versions:
return None, False return None, False
# Primary URL: flat structure with distro suffix in filename url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb" deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{primary_deb}" primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
# GitHub release files: escaped + in filename; the release tag stays fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
# distro-neutral, both distros ship in one combined release
tag_version = DOVECOT_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
)
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{primary_deb}" deb_filename = f"/root/{deb_base}"
files.download( files.download(
name=f"Download {pkg_name}", name=f"Download {pkg_name}",
@@ -144,7 +134,6 @@ def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tupl
return deb_filename, True return deb_filename, True
def _configure_dovecot(deployer, config: Config, debug: bool = False): def _configure_dovecot(deployer, config: Config, debug: bool = False):
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
deployer.put_template( deployer.put_template(
@@ -154,8 +143,7 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config) deployer.put_file("dovecot/auth.conf", "/etc/dovecot/auth.conf")
deployer.remove_file("/etc/dovecot/auth.conf")
deployer.put_file( deployer.put_file(
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua" "dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
) )
+7 -11
View File
@@ -37,15 +37,11 @@ default_client_limit = 20000
# the following warning will be logged: # the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped # Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap { service imap {
process_limit = {{ config.max_imap_connections }} process_limit = 50000
} }
{% if config.privacy_mail %} mail_server_admin = mailto:root@{{ config.mail_domain }}
# Advertised to clients as IMAP METADATA /shared/admin (RFC 5464). mail_server_comment = Chatmail server
# The privacy_mail contact from chatmail.ini is used because it is
# the only address set by an operator.
mail_server_admin = mailto:{{ config.privacy_mail }}
{% endif %}
# `zlib` enables compressing messages stored in the maildir. # `zlib` enables compressing messages stored in the maildir.
# See # See
@@ -61,12 +57,12 @@ imap_capability = +XDELTAPUSH XCHATMAIL
# Authentication for system users. # Authentication for system users.
passdb { passdb {
driver = lua driver = dict
args = file=/etc/dovecot/auth.lua blocking=yes args = /etc/dovecot/auth.conf
} }
userdb { userdb {
driver = lua driver = dict
args = file=/etc/dovecot/auth.lua blocking=yes args = /etc/dovecot/auth.conf
} }
## ##
## Mailbox locations and namespaces ## Mailbox locations and namespaces
+5 -2
View File
@@ -3,7 +3,6 @@ import os
from pyinfra import facts, host from pyinfra import facts, host
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer
from cmdeploy.pins import FILTERMAIL_ARTIFACTS
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
@@ -21,7 +20,11 @@ class FiltermailDeployer(Deployer):
return return
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url, sha256sum = FILTERMAIL_ARTIFACTS[arch] url = f"https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-{arch}"
sha256sum = {
"x86_64": "484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
"aarch64": "66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
}[arch]
self.download_executable(url, self.bin_path, sha256sum) self.download_executable(url, self.bin_path, sha256sum)
def configure(self): def configure(self):
@@ -2,7 +2,7 @@
Description=Iroh relay Description=Iroh relay
[Service] [Service]
ExecStart={{ bin_path }} --config-path {{ config_path }} ExecStart=/usr/local/bin/iroh-relay --config-path /etc/iroh-relay.toml
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
User=iroh User=iroh
@@ -28,13 +28,6 @@ counter created_nonci_accounts
} }
} }
# doveauth refusing new addresses because a chatmail.ini
# system resource limit is exceeded.
counter rejected_registrations
/registration rejected: / {
rejected_registrations++
}
counter postfix_timeouts counter postfix_timeouts
/timeout after DATA/ { /timeout after DATA/ {
postfix_timeouts++ postfix_timeouts++
+13 -31
View File
@@ -1,14 +1,10 @@
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import apt, server from pyinfra.operations import apt
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer
from cmdeploy.pins import FILTERMAIL_ARTIFACTS, MTAIL_ARTIFACTS
class MtailDeployer(Deployer): class MtailDeployer(Deployer):
bin_path = "/usr/local/bin/mtail"
progs_dir = "/etc/mtail"
def __init__(self, mtail_address): def __init__(self, mtail_address):
self.mtail_address = mtail_address self.mtail_address = mtail_address
@@ -16,10 +12,19 @@ class MtailDeployer(Deployer):
# Uninstall mtail package to install a static binary. # Uninstall mtail package to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False) apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
(url, sha256sum) = MTAIL_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
"x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"d55cb601049c5e61eabab29998dbbcea95d480e5448544f9470337ba2eea882e",
),
"aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"f748db8ad2a1e0b63684d4c8868cf6a373a20f7e6922e5ece601fff0ee00eb1a",
),
}[host.get_fact(facts.server.Arch)]
self.download_executable( self.download_executable(
url, url,
self.bin_path, "/usr/local/bin/mtail",
sha256sum, sha256sum,
extract="gunzip | tar -xf - mtail -O", extract="gunzip | tar -xf - mtail -O",
) )
@@ -31,31 +36,8 @@ class MtailDeployer(Deployer):
"mtail/mtail.service.j2", "mtail/mtail.service.j2",
address=self.mtail_address or "127.0.0.1", address=self.mtail_address or "127.0.0.1",
port=3903, port=3903,
bin_path=self.bin_path,
progs_dir=self.progs_dir,
) )
if self.mtail_address: self.put_file("mtail/delivered_mail.mtail", "/etc/mtail/delivered_mail.mtail")
self.put_file(
"mtail/delivered_mail.mtail", f"{self.progs_dir}/delivered_mail.mtail"
)
url, sha256sum = FILTERMAIL_ARTIFACTS['mtail']
self.download_executable(
url,
f"{self.progs_dir}/filtermail.mtail",
sha256sum,
mode="644",
)
if self.need_restart:
# Check if all installed mtail rules compile or fail early
# --one_shot to exit, --port 0 to not clash with running mtail.
server.shell(
name="Validate mtail programs",
commands=[
f"timeout 30 {self.bin_path} --compile_only --one_shot"
f" --progs {self.progs_dir} --logs /dev/null"
" --address 127.0.0.1 --port 0"
],
)
def activate(self): def activate(self):
active = bool(self.mtail_address) active = bool(self.mtail_address)
+1 -1
View File
@@ -5,7 +5,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | {{ bin_path }} --address={{ address }} --port={{ port }} --progs {{ progs_dir }} --logtostderr --logs -" ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
Restart=on-failure Restart=on-failure
RestartSec=2s RestartSec=2s
@@ -7,14 +7,14 @@
<displayShortName>{{ config.mail_domain }}</displayShortName> <displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>993</port> <port>{{ config.imaps_port }}</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</incomingServer> </incomingServer>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>143</port> <port>{{ config.imap_port }}</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
@@ -28,14 +28,14 @@
</incomingServer> </incomingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>465</port> <port>{{ config.smtps_port }}</port>
<socketType>SSL</socketType> <socketType>SSL</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
</outgoingServer> </outgoingServer>
<outgoingServer type="smtp"> <outgoingServer type="smtp">
<hostname>{{ config.mail_domain }}</hostname> <hostname>{{ config.mail_domain }}</hostname>
<port>587</port> <port>{{ config.smtp_port }}</port>
<socketType>STARTTLS</socketType> <socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication> <authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username> <username>%EMAILADDRESS%</username>
+21 -4
View File
@@ -31,6 +31,26 @@ stream {
~\bimap\b 127.0.0.1:993; ~\bimap\b 127.0.0.1:993;
} }
server {
listen {{ config.smtp_port }};
proxy_pass 127.0.0.1:587;
}
server {
listen {{ config.imap_port }};
proxy_pass 127.0.0.1:143;
}
server {
listen {{ config.smtps_port }};
proxy_pass 127.0.0.1:465;
}
server {
listen {{ config.imaps_port }};
proxy_pass 127.0.0.1:993;
}
server { server {
listen 443; listen 443;
{% if not disable_ipv6 %} {% if not disable_ipv6 %}
@@ -128,10 +148,7 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
# Endpoints Iroh uses for net_report probes and that clients location /relay/probe {
# probe to tell whether this relay works. Both paths are served
# by iroh-relay 0.35 and by the 1.0 line.
location /ping {
proxy_pass http://127.0.0.1:3340; proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1; proxy_http_version 1.1;
} }
-73
View File
@@ -1,73 +0,0 @@
"""Versions, hashes, and download URLs for pre-built artifacts fetched during deploy."""
FILTERMAIL_VERSION = "v0.7.4"
FILTERMAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-x86_64",
"484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
),
"aarch64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-aarch64",
"66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
),
"mtail": (
f"https://raw.githubusercontent.com/chatmail/filtermail/{FILTERMAIL_VERSION}/contrib/filtermail.mtail",
"948f688bb89ad47e6eb0fc8fa107e201a689f5adc264ff926be487a2a8562b51",
),
}
MTAIL_VERSION = "3.4.9"
MTAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_amd64.tar.gz",
"55f64a87f71955bb871c724b4aadf19fe9d854e6327196919c7fe44943427eab",
),
"aarch64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_arm64.tar.gz",
"e0a2b66b372ca257d7daeb7ba10f9233a2192a1f9057618fccc6be5c854a2a3c",
),
}
# distro-neutral base version, as committed in chatmail/dovecot debian/changelog
DOVECOT_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_SHA256 = {
("amd64", 12, "auth-lua"): "ef1b8e1db45147a74b48d63125bd61b2cc2f250e1006656ba1c58b9c12f5cde6",
("arm64", 12, "auth-lua"): "c1a06ee9374439893e397ba3b0cacf532a733290c184f4f30ea39df8699be329",
("amd64", 13, "auth-lua"): "6c0946d2516efcbcaa09a27df9b8ea701861cce270d71941056b3c69831a5ea2",
("arm64", 13, "auth-lua"): "5e6c9cfe47f7f3b8aa0d68a3e607161b6abaee1ad696cb1419e997b3099b2985",
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
}
TURN_VERSION = "v0.4"
TURN_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
),
"aarch64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}
IROH_VERSION = "v0.35.0"
IROH_ARTIFACTS = {
"x86_64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-x86_64-unknown-linux-musl.tar.gz",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
),
"aarch64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}
+5 -4
View File
@@ -18,7 +18,7 @@ smtp inet n - y - - smtpd
-o smtpd_tls_mandatory_protocols=>=TLSv1.2 -o smtpd_tls_mandatory_protocols=>=TLSv1.2
-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 }}
-o smtpd_relay_restrictions=reject_unauth_destination -o smtpd_relay_restrictions=reject_unauth_destination
submission inet n - y - {{ config.max_smtp_connections }} smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3 -o smtpd_tls_mandatory_protocols=>=TLSv1.3
@@ -32,9 +32,9 @@ submission inet n - y - {{ config.max_smtp_connections }
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - {{ config.max_smtp_connections }} smtpd smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -48,7 +48,7 @@ smtps inet n - y - {{ config.max_smtp_connections }
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
@@ -76,6 +76,7 @@ lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting outgoing filtered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart={execpath} {config_path} -v --remove ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart={execpath} {config_path} ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
@@ -1,11 +1,12 @@
[Unit] [Unit]
Description=Chatmail HTTP authentication service for dovecot Description=Chatmail dict authentication proxy for dovecot
[Service] [Service]
ExecStart={execpath} {config_path} ExecStart={execpath} /run/doveauth/doveauth.socket {config_path}
Restart=always Restart=always
RestartSec=5 RestartSec=30
User=vmail User=vmail
RuntimeDirectory=doveauth
UMask=0077 UMask=0077
[Install] [Install]
@@ -5,7 +5,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
Restart=always Restart=always
ExecStart={bin_path} --realm {mail_domain} --socket /run/chatmail-turn/turn.socket ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
# Create /run/chatmail-turn # Create /run/chatmail-turn
RuntimeDirectory=chatmail-turn RuntimeDirectory=chatmail-turn
-27
View File
@@ -1,27 +0,0 @@
"""Run the lua scripts we ship under lupa, which bundles Lua 5.4 like dovecot."""
import pytest
from chatmaild.tests.plugin import * # noqa: F403
from lupa import lua54
from cmdeploy.basedeploy import get_resource
from cmdeploy.tests.plugin import * # noqa: F403
class Lua:
"""A Lua runtime to load shipped scripts and mocks into."""
def __init__(self):
self.rt = lua54.LuaRuntime(unpack_returned_tuples=True)
self.g = self.rt.globals()
def load(self, path):
self.rt.execute(get_resource(path).read_text())
def table(self, **kwargs):
return self.rt.table(**kwargs)
@pytest.fixture
def lua():
return Lua()
@@ -20,17 +20,17 @@ def test_fastcgi_working(maildomain, chatmail_config):
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, cmrpc, chatmail_config): def test_newemail_configure(maildomain, rpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3): for i in range(3):
account_id = cmrpc.add_account() account_id = rpc.add_account()
if chatmail_config.tls_cert_mode == "self": if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during # deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead # set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False) res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json() data = res.json()
cmrpc.add_or_update_transport(account_id, { rpc.add_or_update_transport(account_id, {
"addr": data["email"], "addr": data["email"],
"password": data["password"], "password": data["password"],
"imapServer": maildomain, "imapServer": maildomain,
@@ -38,4 +38,4 @@ def test_newemail_configure(maildomain, cmrpc, chatmail_config):
"certificateChecks": "acceptInvalidCertificates", "certificateChecks": "acceptInvalidCertificates",
}) })
else: else:
cmrpc.add_transport_from_qr(account_id, url) rpc.add_transport_from_qr(account_id, url)
@@ -1,12 +1,10 @@
import ipaddress import ipaddress
import json
import re import re
import time import time
import imap_tools import imap_tools
import pytest import pytest
import requests import requests
from chatmaild.tests.test_appversions import check_appversions
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
@@ -53,17 +51,6 @@ class TestMetadataTokens:
assert res == b"1111 2222" assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline() assert b"Getmetadata completed" in client.readline()
def test_get_appversions(self, imap_mailbox):
"get app version information shipped with the relay"
client = imap_mailbox.client
client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n')
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
# the served value is a single line and passes the shipped file's schema
check_appversions(json.loads(res))
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance." "Tests that use Delta Chat accounts on the chat mail instance."
+11 -9
View File
@@ -18,8 +18,14 @@ def format_mail_domain(raw_domain: str) -> str:
return raw_domain return raw_domain
conftestdir = Path(__file__).parent
def pytest_configure(config): def pytest_configure(config):
config._benchresults = {} config._benchresults = {}
config.addinivalue_line(
"markers", "slow: mark test to require --slow option to run"
)
def _get_chatmail_config(): def _get_chatmail_config():
@@ -205,7 +211,7 @@ class ImapConn:
status, res = self.conn.select() status, res = self.conn.select()
if int(res[0]) == 0: if int(res[0]) == 0:
raise ValueError("no messages in imap folder") raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(BODY.PEEK[])") status, results = self.conn.fetch("1:*", "(RFC822)")
assert status == "OK" assert status == "OK"
return results return results
@@ -371,12 +377,8 @@ class ChatmailACFactory:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def cmrpc(tmp_path_factory): def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session. """Start a deltachat-rpc-server process for the test session."""
Not named "rpc": the deltachat-rpc-client pytest plugin registers a
function-scoped fixture under that name and would shadow this one.
"""
# NB: accounts_dir must NOT already exist as directory -- # NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet. # core-rust only creates accounts.toml if the dir doesn't exist yet.
@@ -388,10 +390,10 @@ def cmrpc(tmp_path_factory):
@pytest.fixture @pytest.fixture
def cmfactory(cmrpc, gencreds, maildomain, chatmail_config): def cmfactory(rpc, gencreds, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts.""" """Return a ChatmailACFactory for creating online Delta Chat accounts."""
return ChatmailACFactory( return ChatmailACFactory(
rpc=cmrpc, rpc=rpc,
maildomain=maildomain, maildomain=maildomain,
gencreds=gencreds, gencreds=gencreds,
chatmail_config=chatmail_config, chatmail_config=chatmail_config,
+2
View File
@@ -0,0 +1,2 @@
[pytest]
addopts = -vrsx --strict-markers
-216
View File
@@ -1,216 +0,0 @@
"""Test auth.lua script against mocked dovecot auth API."""
import jinja2
import pytest
from chatmaild.doveauth import encrypt_password, verify_password
from cmdeploy.basedeploy import get_resource
USER1 = "user12345@chat.example.org"
USER2 = "newuser12@chat.example.org"
OK, UNKNOWN, MISMATCH, INTERNAL = 1, -2, -3, -4
DOVECOT_MOCKS = """
create_status = 200
dovecot = {
auth = {
PASSDB_RESULT_OK = OK,
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
PASSDB_RESULT_INTERNAL_FAILURE = INTERNAL,
USERDB_RESULT_OK = OK,
USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
},
http = {
client = function(options)
client_options = options
return {request = function(_, options)
create_request = options
return {
set_payload = function(_, payload) create_payload = payload end,
submit = function()
return {status = function() return create_status end}
end,
}
end}
end,
},
}
"""
def load_authlua(lua, config):
lua.g.OK, lua.g.UNKNOWN = OK, UNKNOWN
lua.g.MISMATCH, lua.g.INTERNAL = MISMATCH, INTERNAL
lua.rt.execute(DOVECOT_MOCKS)
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
lua.rt.execute(template.render(config=config))
assert lua.g.script_init() == 0
return lua
@pytest.fixture
def authlua(lua, example_config):
return load_authlua(lua, example_config)
@pytest.fixture
def request_for(lua):
def request_for(addr):
def password_verify(_self, hashed, plain):
return 1 if verify_password(hashed, plain) else 0
return lua.table(user=addr, password_verify=password_verify)
return request_for
@pytest.fixture
def create_user(example_config):
def create_user(addr, password):
example_config.get_user(addr).set_password(encrypt_password(password))
return create_user
@pytest.fixture
def write_password_file(example_config):
def write_password_file(addr, content):
maildir = example_config.mailboxes_dir / addr
maildir.mkdir(parents=True, exist_ok=True)
maildir.joinpath("password").write_text(content)
return write_password_file
def test_http_client_uses_dovecot_setting_names(authlua):
"""dovecot's lua http binding silently ignores keys it does not know."""
assert dict(authlua.g.client_options) == {
"request_timeout_msecs": 5000,
"max_attempts": 1,
}
def test_existing_address_correct_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert fields["uid"] == fields["gid"] == "vmail"
assert fields["home"].endswith(USER1)
assert authlua.g.create_payload is None
def test_existing_address_wrong_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "wronghorse")
assert res == MISMATCH
def test_foreign_domain_is_refused_without_calling_out(
authlua, request_for, create_user
):
create_user("user12345@evil.example.org", "correctgoose")
request = request_for("user12345@evil.example.org")
res, _ = authlua.g.auth_password_verify(request, "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_name_shorter_than_the_domain_is_refused(authlua, request_for):
for name in ("x", "", "chat.example.org"):
res, _ = authlua.g.auth_password_verify(request_for(name), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(name))[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_slash_in_username_is_refused(authlua, request_for):
request = request_for("../../etc/shadow@chat.example.org")
res, _ = authlua.g.auth_password_verify(request, "somepassword")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_localpart_policy_is_left_to_doveauth(authlua, request_for):
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for("@chat.example.org"), "somepw")
assert res == UNKNOWN
assert authlua.g.create_payload == "@chat.example.org\tsomepw"
def test_unknown_address_is_created_via_endpoint(authlua, request_for):
res, fields = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == OK
assert fields["home"].endswith(USER2)
assert authlua.g.create_payload == f"{USER2}\tbrandnewpass"
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
# doveauth refusing is the user's problem, doveauth failing is ours
@pytest.mark.parametrize("status", [400, 403, 404])
def test_creation_refused_by_doveauth_is_user_unknown(authlua, request_for, status):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN
# 9003 is dovecot's own CONNECT_FAILED, what a stopped doveauth actually yields
@pytest.mark.parametrize("status", [500, 502, 9003, 9005])
def test_creation_that_doveauth_could_not_answer_is_internal_failure(
authlua, request_for, status
):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == INTERNAL
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):
request = request_for(USER1)
res, _ = authlua.g.auth_userdb_lookup(request)
assert res == UNKNOWN
# a userdb lookup must never create anything
assert authlua.g.create_payload is None
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_userdb_lookup(request)
assert res == OK
assert fields["home"].endswith(USER1)
assert fields["uid"] == fields["gid"] == "vmail"
def test_empty_password_file_is_unknown(authlua, request_for, write_password_file):
write_password_file(USER1, "")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
write_password_file(USER1, "\n")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_password_file_format_checks(authlua, request_for, write_password_file):
write_password_file(USER1, encrypt_password("correctgoose") + "\n")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == OK
passhash = encrypt_password("correctgoose")
write_password_file(USER1, passhash + "\ntrailing junk")
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_ipv4_relay_uses_bracketed_domain(lua, ipv4_config, request_for):
# mail_domain is "[1.3.3.7]" here, and is_ours must not read it as a pattern
authlua = load_authlua(lua, ipv4_config)
addr = f"user12345@{ipv4_config.mail_domain}"
ipv4_config.get_user(addr).set_password(encrypt_password("correctgoose"))
res, fields = authlua.g.auth_password_verify(request_for(addr), "correctgoose")
assert res == OK
assert fields["home"].endswith(addr)
assert authlua.g.auth_userdb_lookup(request_for(addr))[0] == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
@@ -3,40 +3,29 @@ from types import SimpleNamespace
import pytest import pytest
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer from cmdeploy.dovecot import deployer as dovecot_deployer
def _fact_name(key):
if isinstance(key, tuple):
return f"{key[0].__name__}{key[1:]!r}"
return key.__name__
def make_host(*fact_pairs): def make_host(*fact_pairs):
"""Build a mock host; get_fact() dispatches to the provided facts mapping. """Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
Args: Args:
*fact_pairs: (fact_class, value) to match any call of that fact, or *fact_pairs: tuples of (fact_class, fact_value) to register
((fact_class, *args), value) to match one specific call. Needed
for Command, which install() and check_restart() invoke with
different scripts; a bare Command entry would serve both.
Returns: Returns:
SimpleNamespace with get_fact that raises a clear error if an SimpleNamespace with get_fact that raises a clear error if an
unregistered fact is requested. unexpected fact type is requested.
""" """
facts = dict(fact_pairs) facts = dict(fact_pairs)
def get_fact(cls, *args): def get_fact(cls):
for key in ((cls, *args), cls): if cls not in facts:
if key in facts: registered = ", ".join(c.__name__ for c in facts)
return facts[key] raise LookupError(
registered = ", ".join(_fact_name(k) for k in facts) f"unexpected get_fact({cls.__name__}); only registered: {registered}"
raise LookupError( )
f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}" return facts[cls]
)
return SimpleNamespace(get_fact=get_fact) return SimpleNamespace(get_fact=get_fact)
@@ -75,9 +64,7 @@ def track_shell(monkeypatch):
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch): def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
# what dpkg reports after installing our deb: epoch + the +debNu1 suffix epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
# that chatmail/dovecot CI stamps via dch before building
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
@@ -95,17 +82,15 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12) deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
assert deb is None, f"expected no deb path when version matches, got {deb!r}" assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed" assert changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed" assert downloads == [], "should not download when version already installed"
@pytest.mark.parametrize("deb_release", [12, 13])
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
def test_download_dovecot_package_uses_archive_version_for_url_and_filename( def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch, deb_release, arch monkeypatch,
): ):
downloads = [] downloads = []
monkeypatch.setattr( monkeypatch.setattr(
@@ -124,26 +109,18 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
lambda **kwargs: downloads.append(kwargs), lambda **kwargs: downloads.append(kwargs),
) )
deb, changed = dovecot_deployer._download_dovecot_package( deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
"core", arch, deb_release=deb_release
)
stamped = dovecot_deployer._stamped_version(deb_release) archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb" expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
# path uses the stamped version, and deb filenames never carry the epoch # Verify the returned path uses archive version, not package version (with epoch)
assert changed is True, "should flag changed when package not yet installed" assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}" assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}" assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
assert len(downloads) == 1, "files.download should be called exactly once" f"deb path should use archive version (no epoch), got {deb!r}"
# the checksum is the security boundary: verify the right table row is used
assert (
downloads[0]["sha256sum"]
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
), "must pass the sha256 matching (arch, release, package)"
assert f"deb{deb_release}u1" in downloads[0]["src"], (
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
) )
assert len(downloads) == 1, "files.download should be called exactly once"
def test_install_skips_dpkg_path_when_epoch_matched_packages_present( def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
@@ -156,14 +133,12 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
( (
dovecot_deployer.DebPackages, dovecot_deployer.DebPackages,
{ {
"dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"], "dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"], "dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"], "dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
"dovecot-auth-lua": [f"1:{dovecot_deployer._stamped_version(12)}"],
}, },
), ),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
downloads = [] downloads = []
@@ -177,26 +152,41 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
assert downloads == [], "should not download when all packages epoch-matched" assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched" assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, "need_restart should be False when nothing changed" assert deployer.need_restart is False, (
"need_restart should be False when nothing changed"
)
def test_install_unsupported_arch_raises( def test_install_unsupported_arch_falls_back_to_apt(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
): ):
# For unsupported architectures, all fact lookups return the arch string.
monkeypatch.setattr( monkeypatch.setattr(
dovecot_deployer, dovecot_deployer,
"host", "host",
make_host( SimpleNamespace(get_fact=lambda cls: "riscv64"),
(dovecot_deployer.Arch, "riscv64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
) )
apt_calls = []
# we never fall back to the pinned distro package # Mirrors apt.packages() return value: OperationMeta with .changed property.
with pytest.raises(ValueError, match="no dovecot build for dovecot-core"): # Only lmtpd triggers a change to verify |= accumulation of changed flags.
deployer.install() def fake_apt(**kwargs):
apt_calls.append(kwargs)
changed = "lmtpd" in kwargs["packages"][0]
return SimpleNamespace(changed=changed)
assert track_shell == [], "should not run apt-get for unsupported arch" monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
deployer.install()
actual_pkgs = [c["packages"] for c in apt_calls]
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
)
assert track_shell == [], "should not run dpkg for unsupported arch"
assert deployer.need_restart is True, (
"need_restart should be True when apt installed a package"
)
def test_install_runs_dpkg_when_packages_need_download( def test_install_runs_dpkg_when_packages_need_download(
@@ -208,7 +198,6 @@ def test_install_runs_dpkg_when_packages_need_download(
make_host( make_host(
(dovecot_deployer.DebPackages, {}), (dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"), (dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
), ),
) )
monkeypatch.setattr( monkeypatch.setattr(
@@ -224,15 +213,17 @@ def test_install_runs_dpkg_when_packages_need_download(
deployer.install() deployer.install()
assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}" assert len(track_shell) == 1, (
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
)
cmds = track_shell[0]["commands"] cmds = track_shell[0]["commands"]
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}" assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
assert "apt-get install -y" in cmds[0] assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0] assert "apt-get -y --fix-broken install" in cmds[1]
assert '-o Dpkg::Options::="--force-confold"' in cmds[0] assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
assert "--allow-downgrades" in cmds[0] assert deployer.need_restart is True, (
assert ".deb" in cmds[0] "need_restart should be True after dpkg install"
assert deployer.need_restart is True, "need_restart should be True after dpkg install" )
def test_pick_url_falls_back_on_primary_error(monkeypatch): def test_pick_url_falls_back_on_primary_error(monkeypatch):
@@ -241,45 +232,6 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error) monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback") result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", f"should fall back when primary fails, got {result!r}" assert result == "http://fallback", (
f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
),
) )
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
deployer.install()
@pytest.mark.parametrize(
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
)
def test_parse_version_id_raises_without_version_id(version_line):
with pytest.raises(ValueError, match="cannot determine Debian release"):
dovecot_deployer._parse_version_id(version_line)
@pytest.mark.parametrize("deb_release", [12, 13])
def test_parse_version_id(deb_release):
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
assert parsed == deb_release
def test_dovecot_sha256_covers_all_packages_per_release():
"""Every release in the table needs all four packages on both arches."""
table = dovecot_deployer.DOVECOT_SHA256
expected = {
(arch, pkg)
for arch in ("amd64", "arm64")
for pkg in ("core", "imapd", "lmtpd", "auth-lua")
}
for release in {r for _, r, _ in table}:
got = {(arch, pkg) for arch, r, pkg in table if r == release}
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
@@ -1,85 +0,0 @@
"""Test the push_notification.lua we ship, against a mocked dovecot mail API."""
import textwrap
import pytest
USER1 = "user12345@chat.example.org"
USER2 = "user67890@chat.example.org"
DOVECOT_MOCKS = textwrap.dedent("""
function make_user(username)
local function mailbox(_, name)
record("mailbox " .. name)
return {
sync = function() record("sync") end,
metadata_set = function(_, k, v)
record("metadata_set " .. k .. "=" .. v)
end,
free = function() record("free") end,
}
end
return {username = username, mailbox = mailbox}
end
""")
@pytest.fixture
def script(lua):
lua.rt.execute(DOVECOT_MOCKS)
lua.load("dovecot/push_notification.lua")
return lua
@pytest.fixture
def deliver(script):
def deliver(recipient, sender):
calls = []
script.g.record = calls.append
user = script.g.make_user(recipient)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
event = script.table(mailbox="INBOX", from_address=sender)
script.g.dovecot_lua_notify_event_message_new(ctx, event)
script.g.dovecot_lua_notify_end_txn(ctx, True)
return calls
return deliver
def test_entry_points_have_the_names_dovecot_calls(script):
assert script.g.dovecot_lua_notify_begin_txn is not None
assert script.g.dovecot_lua_notify_event_message_new is not None
assert script.g.dovecot_lua_notify_end_txn is not None
def test_begin_txn_returns_the_user_as_event_context(script):
user = script.g.make_user(USER1)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
ctx.marker = "seen"
assert user.marker == "seen"
def test_incoming_message_notifies_metadata_server(deliver):
assert deliver(USER1, sender=USER2) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
def test_own_message_does_not_wake_the_sending_device(deliver):
assert deliver(USER1, sender=USER1) == [
"mailbox INBOX",
"sync",
"free",
]
def test_message_without_from_address_is_notified(deliver):
assert deliver(USER1, sender=None) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
-14
View File
@@ -3,8 +3,6 @@
# For the full list of built-in configuration values, see the documentation: # For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html # https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@@ -18,24 +16,12 @@ author = 'chatmail collective'
extensions = [ extensions = [
#'sphinx.ext.autodoc', #'sphinx.ext.autodoc',
#'sphinx.ext.viewdoc', #'sphinx.ext.viewdoc',
'sphinx.ext.extlinks',
'sphinxcontrib.mermaid', 'sphinxcontrib.mermaid',
] ]
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = [] exclude_patterns = []
# Repository links go through the roles below.
# CI sets DOC_GITHUB_REF to the head commit of a pull request,
gh_ref = os.environ.get("DOC_GITHUB_REF", "main")
extlinks = {
"repofile": (f"https://github.com/chatmail/relay/blob/{gh_ref}/%s", "%s"),
"repodir": (f"https://github.com/chatmail/relay/tree/{gh_ref}/%s", "%s"),
}
# Warn about repository links spelled out in full instead of using the roles.
extlinks_detect_hardcoded_links = True
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------
+1 -1
View File
@@ -54,7 +54,7 @@ How can I upgrade my chatmail relay?
------------------------------------ ------------------------------------
To upgrade to the latest ``main`` branch, To upgrade to the latest ``main`` branch,
``cd`` into your local checkout of https://github.com/chatmail/relay/ ``cd`` into your local checkout of `https://github.com/chatmail/relay/`_
and run the following commands: and run the following commands:
:: ::
-87
View File
@@ -199,93 +199,6 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
.. _system-limits:
Configurable System Limits
--------------------------
Limits for auto-rejecting address creation
..........................................
A relay refuses creation of new addresses
when the machine runs low on resources,
but existing addresses keep working.
Three ``chatmail.ini`` settings control this,
shown here with their defaults::
max_load_1m = 5
min_available_memory = 200M
min_free_disk_space = 1G
- ``max_load_1m`` is the maximum 1-minute load average,
as reported by ``uptime``;
it counts processes waiting for disk I/O as well as for CPU.
It is deliberately not scaled by the number of CPUs
because I/O rather than CPU is what typically limits a relay.
- ``min_available_memory`` is the minimum memory available without swapping.
- ``min_free_disk_space`` is the minimum free disk space
on the file system holding the mailboxes.
The defaults suit the small machine described in
`Minimal requirements and prerequisites`_.
.. note::
If you run a bigger machine,
raise ``max_load_1m`` after watching ``uptime`` under typical load.
Rejections are logged by the ``doveauth`` service,
so you can check whether a limit is set too tightly::
journalctl -u doveauth --grep 'registration rejected'
If ``mtail_address`` is set, rejections are also counted
in the ``rejected_registrations`` metric.
Overall IMAP and SMTP connection limits
.......................................
Two further settings bound how many connections
the relay accepts at all, again shown with their defaults::
max_imap_connections = 10000
max_smtp_connections = 1000
``max_imap_connections`` becomes the Dovecot imap process limit,
and ``max_smtp_connections`` the Postfix process limit
on each of the submission and smtps ports.
A single client IP may use up to a fifth of ``max_smtp_connections``.
Each connection costs memory,
so these limits defend the relay against running out of RAM.
Unless ``imap_compress`` is enabled,
an IMAP connection that is idle for ``imap_hibernate_timeout``
is handed over to the ``imap-hibernate`` process
and does not count towards ``max_imap_connections``,
which is why a relay can serve far more IMAP clients
than this setting suggests.
If you run a large relay with 10k or 100k's of addresses,
check current connection counts before upgrading
and set the limits accordingly.
To see how close a running relay is to these two limits,
copy ``scripts/check-connections.sh`` from the relay repository
onto the relay and run it there::
imap 5 ports 143,993 (max_imap_connections)
5 dovecot sessions, 0 of them in an active imap process
submission 0 ports 465,587 (max_smtp_connections per port)
incoming 0 port 25 (from other relays, no chatmail.ini limit)
It counts established sockets with ``ss``
and cross-checks the IMAP number against ``doveadm who``.
Running a relay with self-signed certificates Running a relay with self-signed certificates
---------------------------------------------- ----------------------------------------------
+20 -46
View File
@@ -6,13 +6,13 @@ Technical overview
Directories of the relay repository Directories of the relay repository
----------------------------------- -----------------------------------
The `chatmail relay repository <https://github.com/chatmail/relay>`_ The `chatmail relay repository <https://github.com/chatmail/relay/tree/main/>`_
has four main directories. has four main directories.
``scripts/`` ``scripts/``
~~~~~~~~~~~~~ ~~~~~~~~~~~~~
:repodir:`scripts` `scripts <https://github.com/chatmail/relay/tree/main/scripts>`_
offers two convenience tools for beginners: offers two convenience tools for beginners:
- ``initenv.sh`` installs a local virtualenv Python environment and - ``initenv.sh`` installs a local virtualenv Python environment and
@@ -71,7 +71,7 @@ The deployed system components of a chatmail relay are:
``chatmaild/`` ``chatmaild/``
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
:repodir:`chatmaild` `chatmaild <https://github.com/chatmail/relay/tree/main/chatmaild>`_
is a Python package containing several small services which handle is a Python package containing several small services which handle
authentication, trigger push notifications on new messages, ensure authentication, trigger push notifications on new messages, ensure
that outbound mails are encrypted, delete inactive users, and some that outbound mails are encrypted, delete inactive users, and some
@@ -83,25 +83,25 @@ that integrate with Dovecot and Postfix to achieve instant-onboarding
and only relaying OpenPGP end-to-end messages encrypted messages. A and only relaying OpenPGP end-to-end messages encrypted messages. A
short overview of ``chatmaild`` services: short overview of ``chatmaild`` services:
- :repofile:`doveauth <chatmaild/src/chatmaild/doveauth.py>` - `doveauth <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/doveauth.py>`_
implements create-on-login address semantics. implements create-on-login address semantics and is used by Dovecot
Dovecot authenticates IMAP logins, and Postfix SMTP/SUBMISSION logins through `Dovecot SASL during IMAP login and by Postfix during SMTP/SUBMISSION login which
<https://doc.dovecot.org/2.3/configuration_manual/authentication/authentication_mechanisms/>`_, in turn uses `Dovecot SASL
from an :repofile:`auth.lua <cmdeploy/src/cmdeploy/dovecot/auth.lua.j2>` script <https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
that reads the maildir directly. Only addresses which do not exist yet to authenticate logins.
are passed on to doveauth, which owns the creation policy.
- :repofile:`chatmail-metadata <chatmaild/src/chatmaild/metadata.py>` - `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
is contacted by a is contacted by a `Dovecot lua
:repofile:`Dovecot lua script <cmdeploy/src/cmdeploy/dovecot/push_notification.lua>` script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_
to store user-specific relay-side config. On new messages, it to store user-specific relay-side config. On new messages, it `passes
:repofile:`passes the users push notification token <chatmaild/src/chatmaild/notifier.py>` the users push notification
token <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/notifier.py>`_
to to
`notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_ `notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_
so the push notifications on the users phone can be triggered by so the push notifications on the users phone can be triggered by
Apple/Google/Huawei. Apple/Google/Huawei.
- :repofile:`chatmail-expire <chatmaild/src/chatmaild/expire.py>` - `chatmail-expire <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/expire.py>`_
deletes old messages, large messages, and entire mailboxes deletes old messages, large messages, and entire mailboxes
of users who have not logged in for longer than of users who have not logged in for longer than
``delete_inactive_users_after`` days. ``delete_inactive_users_after`` days.
@@ -109,14 +109,15 @@ short overview of ``chatmaild`` services:
- ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism - ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism
and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``. and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``.
- :repofile:`lastlogin <chatmaild/src/chatmaild/lastlogin.py>` - `lastlogin <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py>`_
is contacted by Dovecot when a user logs in and stores the date of is contacted by Dovecot when a user logs in and stores the date of
the login. the login.
``www/`` ``www/``
~~~~~~~~~ ~~~~~~~~~
:repodir:`www` contains the html, css, and markdown files which make up a chatmail relays `www <https://github.com/chatmail/relay/tree/main/www>`_ contains
the html, css, and markdown files which make up a chatmail relays
web page. Edit them before deploying to make your chatmail relay web page. Edit them before deploying to make your chatmail relay
stand out. stand out.
@@ -156,7 +157,7 @@ Chatmail relay dependency diagram
filtermail-outgoing --- |10025 reinject|postfix; filtermail-outgoing --- |10025 reinject|postfix;
filtermail-incoming --- |10026 reinject|postfix; filtermail-incoming --- |10026 reinject|postfix;
postfix --- |milter opendkim.sock|OpenDKIM postfix --- |milter opendkim.sock|OpenDKIM
dovecot --- |10084 create|doveauth; dovecot --- |doveauth.socket|doveauth;
dovecot --- |message delivery|maildir["maildir dovecot --- |message delivery|maildir["maildir
/home/vmail/.../user"]; /home/vmail/.../user"];
dovecot --- |lastlogin.socket|lastlogin; dovecot --- |lastlogin.socket|lastlogin;
@@ -248,33 +249,6 @@ Fresh chatmail addresses have a mailbox directory that contains:
directories will typically be empty unless the user of that address directories will typically be empty unless the user of that address
hasnt been online for a while. hasnt been online for a while.
App version information (experimental)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
A chatmail relay ships the
:repofile:`appversions.json <chatmaild/src/chatmaild/defaults/appversions.json>`
file of the ``chatmaild`` package
and serves its content under the IMAP METADATA key
``/shared/vendor/deltachat/appversions``.
Chat apps installed outside of app stores read this key
to learn about updates and where to download them.
The mechanism is experimental and may change.
The file travels with the normal deploy:
update the repository checkout and run ``cmdeploy run``.
Local modifications of ``appversions.json`` are deployed as-is,
so you can serve your own app version information,
including links to app downloads.
There is no automatic refresh:
version information changes only when you deploy again.
.. note::
Note that as of August 2026, only Delta Chat Android Google Play version
is beginning to support discovering app versions from relays.
Generally, consumers of relay-provided app version information
need to verify themselves that downloaded app files are valid.
Active ports Active ports
~~~~~~~~~~~~ ~~~~~~~~~~~~
+4 -5
View File
@@ -14,11 +14,10 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required it to support all of the features and configuration settings required
to operate as a chatmail relay. to operate as a chatmail relay.
- `Madmail <https://github.com/themadorg/madmail>`_: a Rust-based - `Madmail <https://github.com/themadorg/madmail>`_: an
single-binary chatmail relay. Madmail v2 is a rewrite of an earlier experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
experimental fork of `Maddy Mail Server <https://maddy.email/>`_. for chatmail deployments. It provides a single binary solution
It includes SMTP, IMAP, encryption enforcement, and real-time for running a chatmail relay.
services (TURN/Iroh), and runs on Linux and Windows.
- `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_: - `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_:
A Chef Cookbook implementing a relay server. The project follows the A Chef Cookbook implementing a relay server. The project follows the
-26
View File
@@ -1,26 +0,0 @@
#!/bin/bash
#
# Show current IMAP and SMTP connections on a chatmail relay,
# to compare against the max_imap_connections and max_smtp_connections
# settings in chatmail.ini. Run this on the relay itself.
set -e
# Count established TCP connections whose local port is one of the arguments.
established() {
filter=$(printf 'sport = :%s or ' "$@")
ss -Htn state established "( ${filter% or } )" | wc -l
}
# doveadm prints a header line and then one line per logged-in user,
# with that user's number of connections in the second column.
sessions=$(doveadm who | awk 'NR > 1 { n += $2 } END { print n + 0 }')
# Unless imap_compress is enabled, connections idle for
# imap_hibernate_timeout are handed over to the imap-hibernate
# process, so they cost no imap process while idle.
active=$(pgrep -x imap | wc -l)
printf 'imap %6d ports 143,993 (max_imap_connections)\n' "$(established 143 993)"
printf ' %6d dovecot sessions, %d of them in an active imap process\n' "$sessions" "$active"
printf 'submission %6d ports 465,587 (max_smtp_connections per port)\n' "$(established 465 587)"
printf 'incoming %6d port 25 (from other relays, no chatmail.ini limit)\n' "$(established 25)"
-4
View File
@@ -20,10 +20,6 @@ fi
python3 -m venv --upgrade-deps venv python3 -m venv --upgrade-deps venv
# an editable install puts src/ on sys.path, so a leftover egg-info there is
# found as a second distribution and keeps removed entry points registered
rm -rf chatmaild/src/*.egg-info cmdeploy/src/*.egg-info
venv/bin/pip install -e chatmaild venv/bin/pip install -e chatmaild
venv/bin/pip install -e cmdeploy venv/bin/pip install -e cmdeploy
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs