Compare commits

..

13 Commits

Author SHA1 Message Date
missytake
84e731034a tests: make sure chatmail-metadata was started
fix a flaky test: https://github.com/chatmail/relay/pull/856#issuecomment-3919881473
since #856 chatmail-metadata is restarted every 5 second, if it didn't come up after that, the failure likely sits deeper.
2026-03-04 18:03:48 +01:00
Jagoda Estera Ślązak
dbd5cd16f5 feat: replace DKIM verification with filtermail v0.5 (#831)
Upgrade to filtermail v0.5, which has a built-in DKIM verifier
and disable OpenDKIM on reinject_incoming.

Signed-off-by: Jagoda Ślązak <jslazak@jslazak.com>
2026-02-25 12:39:33 +01:00
holger krekel
e21f2a0fa2 feat: support externally managed TLS via tls_external_cert_and_key option (#860)
Adds a new tls_external_cert_and_key config option for chatmail servers
that manage their own TLS certificates (e.g. via an external ACME client
or a load balancer).

A systemd path unit (tls-cert-reload.path) watches the certificate file
via inotify and automatically reloads dovecot and nginx when it changes.
Postfix reads certs per TLS handshake so needs no reload.

Also extracts openssl_selfsigned_args() so cert generation parameters
are shared between SelfSignedTlsDeployer and the e2e test.
2026-02-24 09:46:38 +01:00
holger krekel
8ca0909fa5 cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings (#872)
* cleanup: remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings

major simplification: all chatmail fixtures used in the test are now created inside the cmdeploy plugin,
and do not inherit anything from other fixture machineries, let alone the legacy deltachat CFFI ones.
also fix that pytest report headers show correct chatmail domains under test
2026-02-24 08:27:56 +01:00
j4n
2c99cc84aa cmdeploy: prepare chatmaild/cmdeploy changes for Docker support
- chatmaild:
  - basedeploy.py: Add has_systemd() guard. During Docker image builds
    there's no running systemd, so deployers that query SystemdEnabled
    facts would crash; this change might also be helpful for non-systemd
    platforms.
- cmdeploy:
  - cmdeploy.py:
    - when deploying to @docker, auto-set CHATMAIL_NOPORTCHECK and
      CHATMAIL_NOSYSCTL since neither makes sense inside a container
    - --config default now reads CHATMAIL_INI env var, so Docker
      entrypoints can point to a mounted ini without CLI flags.
  - deployers.py:
    - skip port check / CHATMAIL_NOPORTCHECK
    - skip echobot systemd cleanup w/ has_systemd
  - dovecot/deployer.py:
    - Guard sysctl writes behind CHATMAIL_NOSYSCTL
    - invert dovecot install check so it works without systemd
  - sshexec.py: Add __call__ to LocalExec so cmdeploy status works with
    @local target. Without it, cmdeploy status tried to call the
    executor directly and got TypeError.

Consolidated from j4n/docker branch commits (selection):
- 8953fde feat(cmdeploy): read CHATMAIL_INI env var for default --config path
- 81d7782 fix(cmdeploy): add __call__ to LocalExec so status works with @local
- 8bba78e docker: disable port check if docker is running. fix #694
- 865b514 docker: replace config flags with env vars, drop docker param (instead of f26cb08)

Files: cmdeploy/src/cmdeploy/{basedeploy,cmdeploy,deployers,sshexec,dovecot/deployer}.py

Co-authored-by: Keonik1 <keonik.dev@gmail.com>
Co-authored-by: missytake <missytake@systemli.org>
2026-02-23 09:12:48 +01:00
373[Ø]™
73309778c2 Merge pull request #867 from chatmail/373/benchmark-filtermail-refinement
stabilize online benchmark timing adding rate-limit-aware cooldown between iterations
2026-02-22 18:13:34 +00:00
373[Ø]™
50ecc2b315 Merge pull request #868 from chatmail/hpk/simplify-cooldown
refactor(benchmark): move rate-limit cooldown to benchmark fixture
2026-02-22 18:05:19 +00:00
holger krekel
7b5b180b4b refactor(benchmark): move rate-limit cooldown to benchmark fixture 2026-02-22 18:26:15 +01:00
373[Ø]™
193624e522 fix(benchmark): add rate-limit refill cooldown for send_10_receive_10 and avoid fixture signature mismatch 2026-02-22 15:58:21 +00:00
373[Ø]™
437287fadc feat(tests): add optional benchmark cooldown between iterations 2026-02-22 15:55:03 +00:00
link2xt
0ad679997a feat: reconfigure acmetool from redirector to proxy mode
This elimitates the problem of acmetool failing
to start when nginx is installed already and uses port 80.

This also makes nginx redirect HTTP requests to HTTPS
for setups that don't have acmetool.
2026-02-21 22:10:20 +00:00
missytake
38cc1c7cd6 fix(cmdeploy): make tests work with --ssh-host localhost (#856)
* tests: fix test_remote[imap]
* cmdeploy: call LocalExec directly, not .logged()
* tests: fix TestSSHExecutor.test_logged
* tests: fix test_status_cmd with --ssh-host @local
* tests: fix test_logged with --ssh-host localhost
* tests: fix TestSSHExecutor::test_exception with --ssh-host localhost
* ci: deploy with --ssh-host localhost on staging-ipv4
* metadata: lower RestartSec
2026-02-19 21:34:39 +01:00
link2xt
7a6ed8340e test: mark f-string with f prefix in test_expunged
This one was not marked accidentally.
2026-02-19 19:41:14 +00:00
42 changed files with 280 additions and 1306 deletions

View File

@@ -1,18 +0,0 @@
data/
venv/
__pycache__
*.pyc
*.orig
*.ini
.pytest_cache
.env
# Slim build context — .git/ alone can be 100s of MB
.git
.github/
docs/
tests/
# Exclude markdown files but keep www/src/*.md (used by WebsiteDeployer)
*.md
!www/**/*.md

View File

@@ -15,7 +15,7 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.5.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests
working-directory: chatmaild
run: pipx run tox

View File

@@ -1,76 +0,0 @@
name: Docker Build
on:
pull_request:
paths:
- 'docker/**'
- 'docker-compose.yaml'
- '.dockerignore'
- 'chatmaild/**'
- 'cmdeploy/**'
- '.github/workflows/docker-build.yaml'
push:
branches:
- main
- j4n/docker
paths:
- 'docker/**'
- 'docker-compose.yaml'
- '.dockerignore'
- 'chatmaild/**'
- 'cmdeploy/**'
- '.github/workflows/docker-build.yaml'
tags:
- 'v*'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tagged releases: v1.2.3 → :1.2.3, :1.2, :latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Branch pushes: j4n/docker → :j4n-docker
type=ref,event=branch
# Always: :sha-<hash>
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/chatmail_relay.dockerfile
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_HASH=${{ github.sha }}

View File

@@ -71,25 +71,35 @@ jobs:
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
cmdeploy run --verbose --skip-dns-check
- name: setup dependencies
run: |
ssh root@staging-ipv4.testrun.org apt update
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: cmdeploy dns
run: cmdeploy dns -v
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"

View File

@@ -1,37 +0,0 @@
name: test tls_external_cert_and_key on staging2.testrun.org
on:
workflow_run:
workflows:
- "deploy on staging2.testrun.org, and run tests"
types:
- completed
jobs:
test-tls-external:
name: test tls_external_cert_and_key
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency: staging2.testrun.org
environment:
name: staging2.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging2.testrun.org >> ~/.ssh/known_hosts 2>/dev/null
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: run tls_external e2e test
run: |
python -m cmdeploy.tests.setup_tls_external \
staging2.testrun.org

8
.gitignore vendored
View File

@@ -4,7 +4,7 @@ __pycache__/
*$py.class
*.swp
*qr-*.png
chatmail.ini
chatmail*.ini
# C extensions
@@ -164,9 +164,3 @@ cython_debug/
#.idea/
chatmail.zone
# docker
/data/
/custom/
docker-compose.override.yaml
.env

View File

@@ -75,8 +75,7 @@ class Config:
" paths: CERT_PATH KEY_PATH"
)
self.tls_cert_mode = "external"
self.tls_cert_path = parts[0]
self.tls_key_path = parts[1]
self.tls_cert_path, self.tls_key_path = parts
elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"

View File

@@ -110,6 +110,7 @@ def test_config_tls_external_overrides_underscore(make_config):
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/certs/fullchain.pem"
assert config.tls_key_path == "/certs/privkey.pem"
def test_config_tls_external_bad_format(make_config):

View File

@@ -47,6 +47,8 @@ def test_one_mail(
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
):
monkeypatch.setenv("PYTHONUNBUFFERED", "1")
# DKIM is tested by cmdeploy tests.
monkeypatch.setenv("FILTERMAIL_SKIP_DKIM", "1")
smtp_inject_port = 20025
if filtermail_mode == "outgoing":
settings = dict(
@@ -64,6 +66,10 @@ def test_one_mail(
popen = make_popen(["filtermail", path, filtermail_mode])
line = popen.stderr.readline().strip()
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod
if b"DKIM verification DISABLED!" in line:
line = popen.stderr.readline().strip()
if b"loop" not in line:
print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed")

View File

@@ -20,6 +20,7 @@ dependencies = [
"pytest-xdist",
"execnet",
"imap_tools",
"deltachat-rpc-client",
]
[project.scripts]

View File

@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service]
Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
Restart=always
RestartSec=30

View File

@@ -5,7 +5,6 @@ along with command line option and subcommand parsing.
import argparse
import importlib.resources
import importlib.util
import os
import pathlib
import shutil
@@ -210,17 +209,15 @@ def test_cmd_options(parser):
action="store_true",
help="also run slow tests",
)
add_ssh_host_option(parser)
def test_cmd(args, out):
"""Run local and online tests for chatmail deployment.
"""Run local and online tests for chatmail deployment."""
This will automatically pip-install 'deltachat' if it's not available.
"""
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
env = os.environ.copy()
if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host
pytest_path = shutil.which("pytest")
pytest_args = [
@@ -234,7 +231,7 @@ def test_cmd(args, out):
]
if args.slow:
pytest_args.append("--slow")
ret = out.run_ret(pytest_args)
ret = out.run_ret(pytest_args, env=env)
return ret

View File

@@ -11,8 +11,8 @@ from pathlib import Path
from chatmaild.config import read_config
from pyinfra import facts, host, logger
from pyinfra.facts import hardware
from pyinfra.api import FactBase
from pyinfra.facts import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd
@@ -20,7 +20,6 @@ from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out
from .acmetool import AcmetoolDeployer
from .external.deployer import ExternalTlsDeployer
from .basedeploy import (
Deployer,
Deployment,
@@ -30,6 +29,7 @@ from .basedeploy import (
has_systemd,
)
from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer
@@ -592,9 +592,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
("unbound", 53),
]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 80))
port_services.append(("acmetool", 402))
port_services += [
(["imap-login", "dovecot"], 143),
# acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402
# and gave the port to nginx.
(["acmetool", "nginx"], 80),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),

View File

@@ -1,4 +1,8 @@
from pyinfra.operations import files, server, systemd
import io
from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
@@ -17,19 +21,18 @@ class ExternalTlsDeployer(Deployer):
self.key_path = key_path
def configure(self):
server.shell(
name="Verify external TLS certificate and key exist",
commands=[
f"test -f {self.cert_path} && test -f {self.key_path}",
],
)
# Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}")
# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
import io
path_unit = files.put(
name="Upload tls-cert-reload.path",
src=io.BytesIO(content),
@@ -60,10 +63,5 @@ class ExternalTlsDeployer(Deployer):
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# Always trigger a reload so services pick up the current cert.
# The path unit handles future changes via inotify.
server.shell(
name="Reload TLS services for current certificate",
commands=["systemctl start tls-cert-reload.service"],
)
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.

View File

@@ -1,6 +1,10 @@
# Watch the TLS certificate file for changes.
# When the cert is updated (e.g. renewed by an external process),
# this triggers tls-cert-reload.service to restart the affected services.
# this triggers tls-cert-reload.service to reload the affected services.
#
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
# After cert renewal, you must then trigger the reload explicitly:
# systemctl start tls-cert-reload.service
[Unit]
Description=Watch TLS certificate for changes

View File

@@ -11,5 +11,5 @@ Description=Reload TLS services after certificate change
[Service]
Type=oneshot
ExecStart=/bin/systemctl reload dovecot
ExecStart=/bin/systemctl reload nginx
ExecStart=/bin/systemctl try-reload-or-restart dovecot
ExecStart=/bin/systemctl try-reload-or-restart nginx

View File

@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
def install(self):
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.1/filtermail-{arch}"
sha256sum = {
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
"x86_64": "adce2ddb461c5fd744df699f3b0b3c33b6d52413c641f18695b93826e5e0d234",
"aarch64": "b51cf4248c6c443308f21b1811da1cc919b98b719a2138f4b60940ea093a5422",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",

View File

@@ -145,4 +145,25 @@ http {
return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
}
server {
listen 80;
{% if not disable_ipv6 %}
listen [::]:80;
{% endif %}
{% if config.tls_cert_mode == "acme" %}
location /.well-known/acme-challenge/ {
proxy_pass http://acmetool;
}
{% endif %}
return 301 https://$host$request_uri;
}
{% if config.tls_cert_mode == "acme" %}
upstream acmetool {
server 127.0.0.1:402;
}
{% endif %}
}

View File

@@ -37,21 +37,15 @@ class OpendkimDeployer(Deployer):
)
need_restart |= main_config.changed
screen_script = files.put(
src=get_resource("opendkim/screen.lua"),
dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
screen_script = files.file(
path="/etc/opendkim/screen.lua",
present=False,
)
need_restart |= screen_script.changed
final_script = files.put(
src=get_resource("opendkim/final.lua"),
dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
final_script = files.file(
path="/etc/opendkim/final.lua",
present=False,
)
need_restart |= final_script.changed

View File

@@ -1,42 +0,0 @@
mtaname = odkim.get_mtasymbol(ctx, "{daemon_name}")
if mtaname == "ORIGINATING" then
-- Outgoing message will be signed,
-- no need to look for signatures.
return nil
end
nsigs = odkim.get_sigcount(ctx)
if nsigs == nil then
return nil
end
local valid = false
local error_msg = "No valid DKIM signature found."
for i = 1, nsigs do
sig = odkim.get_sighandle(ctx, i - 1)
sigres = odkim.sig_result(sig)
-- All signatures that do not correspond to From:
-- were ignored in screen.lua and return sigres -1.
--
-- Any valid signature that was not ignored like this
-- means the message is acceptable.
if sigres == 0 then
valid = true
else
error_msg = "DKIM signature is invalid, error code " .. tostring(sigres) .. ", search https://github.com/trusteddomainproject/OpenDKIM/blob/master/libopendkim/dkim.h#L108"
end
end
if valid then
-- Strip all DKIM-Signature headers after successful validation
-- Delete in reverse order to avoid index shifting.
for i = nsigs, 1, -1 do
odkim.del_header(ctx, "DKIM-Signature", i)
end
else
odkim.set_reply(ctx, "554", "5.7.1", error_msg)
odkim.set_result(ctx, SMFIS_REJECT)
end
return nil

View File

@@ -45,12 +45,6 @@ SignHeaders *,+autocrypt,+content-type
# Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua
# Script to reject mails without a valid DKIM signature.
FinalPolicyScript /etc/opendkim/final.lua
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group

View File

@@ -1,21 +0,0 @@
-- Ignore signatures that do not correspond to the From: domain.
from_domain = odkim.get_fromdomain(ctx)
if from_domain == nil then
return nil
end
n = odkim.get_sigcount(ctx)
if n == nil then
return nil
end
for i = 1, n do
sig = odkim.get_sighandle(ctx, i - 1)
sig_domain = odkim.sig_getdomain(sig)
if from_domain ~= sig_domain then
odkim.sig_ignore(sig)
end
end
return nil

View File

@@ -86,7 +86,6 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP.

View File

@@ -4,7 +4,7 @@ Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
Restart=always
RestartSec=30
RestartSec=5
User=vmail
RuntimeDirectory=chatmail-metadata
UMask=0077

View File

@@ -85,6 +85,8 @@ class SSHExec:
class LocalExec:
FuncError = FuncError
def __init__(self, verbose=False, docker=False):
self.verbose = verbose
self.docker = docker
@@ -95,11 +97,19 @@ class LocalExec:
return call(**kwargs)
def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail "
where = "in docker"
if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})")
return call(**kwargs)
print_stderr(f"Running {where}: {title}(**{kwargs})")
return self(call, kwargs, log_callback=print_stderr)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res

View File

@@ -1,3 +1,4 @@
import time
def test_tls_imap(benchmark, imap):
def imap_connect():
imap.connect()
@@ -41,9 +42,9 @@ class TestDC:
def dc_ping_pong():
chat.send_text("ping")
msg = ac2._evtracker.wait_next_incoming_message()
msg.chat.send_text("pong")
ac1._evtracker.wait_next_incoming_message()
msg = ac2.wait_for_incoming_msg()
msg.get_snapshot().chat.send_text("pong")
ac1.wait_for_incoming_msg()
benchmark(dc_ping_pong, 5)
@@ -55,6 +56,6 @@ class TestDC:
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2._evtracker.wait_next_incoming_message()
ac2.wait_for_incoming_msg()
benchmark(dc_send_10_receive_10, 5)
benchmark(dc_send_10_receive_10, 5, cooldown="auto")

View File

@@ -7,13 +7,13 @@ import time
import pytest
from cmdeploy import remote
from cmdeploy.sshexec import SSHExec
from cmdeploy.cmdeploy import get_sshexec
class TestSSHExecutor:
@pytest.fixture(scope="class")
def sshexec(self, sshdomain):
return SSHExec(sshdomain)
return get_sshexec(sshdomain)
def test_ls(self, sshexec):
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
@@ -27,6 +27,7 @@ class TestSSHExecutor:
assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys):
sshexec.verbose = False
sshexec.logged(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
@@ -52,6 +53,8 @@ class TestSSHExecutor:
remote.rdns.perform_initial_checks,
kwargs=dict(mail_domain=None),
)
except AssertionError:
pass
except sshexec.FuncError as e:
assert "rdns.py" in str(e)
assert "AssertionError" in str(e)
@@ -83,10 +86,8 @@ def test_remote(remote, imap_or_smtp):
def test_use_two_chatmailservers(cmfactory, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
cmfactory.get_accepted_chat(ac1, ac2)
domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1]
@@ -146,7 +147,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
conn.starttls()
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
@@ -218,7 +219,7 @@ def test_expunged(remote, chatmail_config):
]
outdated_days = int(chatmail_config.delete_large_after) + 1
find_cmds.append(
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
)
for cmd in find_cmds:
for line in remote.iter_output(cmd):

View File

@@ -7,7 +7,7 @@ import pytest
import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture
@@ -27,6 +27,7 @@ class TestMetadataTokens:
def test_set_get_metadata(self, imap_mailbox):
"set and get metadata token for an account"
time.sleep(5) # make sure Metadata service had a chance to restart
client = imap_mailbox.client
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
res = client.readline()
@@ -62,8 +63,8 @@ class TestEndToEndDeltaChat:
chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0"
msg2 = ac2.wait_for_incoming_msg()
assert msg2.get_snapshot().text == "message0"
def test_exceed_quota(
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
@@ -91,45 +92,41 @@ class TestEndToEndDeltaChat:
lp.sec(f"filling remote inbox for {user}")
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
sshexec = SSHExec(sshdomain)
sshexec = get_sshexec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100
lp.sec("ac2: check quota is triggered")
starting = True
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
if starting:
chat.send_text("hello")
starting = False
def send_hello():
chat.send_text("hello")
for line in remote.iter_output(
"journalctl -n1 -f -u dovecot", ready=send_hello
):
if user not in line:
# print(line)
continue
if "quota exceeded" in line:
return
def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_setup_contact_qr()
qr = ac1.get_qr_code()
lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.qr_setup_contact(qr)
ch = ac2.secure_join(qr)
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)
ac1.wait_for_securejoin_inviter_success()
def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox):
"""Test that if a DC address receives a message, it has no
DKIM-Signature and Authentication-Results headers."""
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
chat.send_text("message0")
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
@@ -146,29 +143,28 @@ class TestEndToEndDeltaChat:
assert "dkim-signature" not in msg.headers
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr()
ch = ac2.qr_setup_contact(qr)
qr = ac1.get_qr_code()
ch = ac2.secure_join(qr)
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)
ac1.wait_for_securejoin_inviter_success()
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
msg = chat.send_text("hi")
m = ac2._evtracker.wait_next_incoming_message()
m = ac2.wait_for_incoming_msg()
m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error
lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1
while time.time() < deadline:
msgs = m.chat.get_messages()
m_snap = m.get_snapshot()
msgs = m_snap.chat.get_messages()
for msg in msgs:
assert "error" not in m.get_message_info()
assert "error" not in m.get_info()
time.sleep(1)
@@ -180,7 +176,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message()
user2.wait_for_incoming_msg()
addr = user2.get_config("addr")
host = addr.split("@")[1]
pw = user2.get_config("mail_pw")

View File

@@ -5,7 +5,11 @@ from cmdeploy.cmdeploy import main
def test_status_cmd(chatmail_config, capsys, request):
os.chdir(request.config.invocation_params.dir)
assert main(["status"]) == 0
command = ["status"]
if os.getenv("CHATMAIL_SSH"):
command.append("--ssh-host")
command.append(os.getenv("CHATMAIL_SSH"))
assert main(command) == 0
status_out = capsys.readouterr()
print(status_out.out)

View File

@@ -1,5 +1,4 @@
import imaplib
import io
import itertools
import os
import random
@@ -35,17 +34,24 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run")
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
def _get_chatmail_config():
current = Path().resolve()
while 1:
path = current.joinpath("chatmail.ini").resolve()
if path.exists():
return read_config(path)
return read_config(path), path
if current == current.parent:
break
current = current.parent
return None, None
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
config, path = _get_chatmail_config()
if config:
return config
basedir = Path().resolve()
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@@ -73,10 +79,17 @@ def sshdomain2(maildomain2):
def pytest_report_header():
domain = os.environ.get("CHATMAIL_DOMAIN")
if domain:
text = f"chatmail test instance: {domain}"
return ["-" * len(text), text, "-" * len(text)]
config, path = _get_chatmail_config()
domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET")
domain = config.mail_domain if config else "NOT SET"
path = path if path else "NOT SET"
lines = [
f"chatmail.ini {domain} location: {path}",
f"chatmail2: {domain2}",
]
sep = "-" * max(map(len, lines))
return [sep, *lines, sep]
@pytest.fixture
@@ -91,15 +104,22 @@ def cm_data(request):
@pytest.fixture
def benchmark(request):
def bench(func, num, name=None, reportfunc=None):
def benchmark(request, chatmail_config):
def bench(func, num, name=None, reportfunc=None, cooldown=0.0):
if name is None:
name = func.__name__
if cooldown == "auto":
per_minute = max(chatmail_config.max_user_send_per_minute, 1)
cooldown = chatmail_config.max_user_send_burst_size * 60 / per_minute
durations = []
for i in range(num):
now = time.time()
func()
durations.append(time.time() - now)
if cooldown > 0 and i + 1 < num:
# Keep post-run cooldown out of measured benchmark duration.
time.sleep(cooldown)
durations.sort()
request.config._benchresults[name] = (reportfunc, durations)
@@ -276,79 +296,95 @@ def gencreds(chatmail_config):
#
# Delta Chat testplugin re-use
# Delta Chat RPC-based test support
# use the cmfactory fixture to get chatmail instance accounts
#
from deltachat_rpc_client import DeltaChat, Rpc
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
assert "." in self.maildomain, maildomain
class ChatmailACFactory:
"""RPC-based account factory for chatmail testing."""
def __init__(self, rpc, maildomain, gencreds, chatmail_config):
self.dc = DeltaChat(rpc)
self.rpc = rpc
self._maildomain = maildomain
self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {}
def get_liveconfig_producer(self):
while 1:
user, password = self.gencreds(self.maildomain)
config = {
"addr": user,
"mail_pw": password,
}
# speed up account configuration
config["mail_server"] = self.maildomain
config["send_server"] = self.maildomain
if self.chatmail_config.tls_cert_mode == "self":
# Accept self-signed TLS certificates
config["imap_certificate_checks"] = "3"
yield config
def _make_transport(self, domain):
"""Build a transport config dict for the given domain."""
addr, password = self.gencreds(domain)
transport = {
"addr": addr,
"password": password,
# Setting server explicitly skips requesting autoconfig XML,
# see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/
"imapServer": domain,
"smtpServer": domain,
}
if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates"
return transport
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
pass
def get_online_account(self, domain=None):
"""Create, configure and bring online a single account."""
return self.get_online_accounts(1, domain)[0]
def cache_maybe_store_configured_db_files(self, acc):
pass
def get_online_accounts(self, num, domain=None):
"""Create multiple online accounts in parallel."""
domain = domain or self._maildomain
futures = []
accounts = []
for _ in range(num):
account = self.dc.add_account()
future = account.add_or_update_transport.future(
self._make_transport(domain)
)
futures.append(future)
# ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests
account.set_config("delete_server_after", "10")
accounts.append(account)
for future in futures:
future()
for account in accounts:
account.bring_online()
return accounts
def get_accepted_chat(self, ac1, ac2):
"""Create a 1:1 chat between ac1 and ac2 accepted on both sides."""
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
@pytest.fixture(scope="session")
def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session."""
# NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet.
accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts")
rpc = Rpc(accounts_dir=accounts_dir)
rpc.start()
yield rpc
rpc.close()
@pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config):
# cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(
request.config, maildomain, gencreds, chatmail_config
def cmfactory(rpc, gencreds, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts."""
return ChatmailACFactory(
rpc=rpc,
maildomain=maildomain,
gencreds=gencreds,
chatmail_config=chatmail_config,
)
class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# Skip upstream's init_imap to prevent extra imap connections not
# needed for relay testing
am._acsetup.init_imap = lambda acc: None
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture
def remote(sshdomain):
@@ -359,19 +395,27 @@ class Remote:
def __init__(self, sshdomain):
self.sshdomain = sshdomain
def iter_output(self, logcmd=""):
def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal],
command,
stdout=subprocess.PIPE,
)
while 1:
line = self.popen.stdout.readline()
res = line.decode().strip().lower()
if res:
yield res
else:
if not res:
break
if ready is not None:
ready()
ready = None
yield res
@pytest.fixture

View File

@@ -1,362 +0,0 @@
"""Setup and verify external TLS certificates for a chatmail server.
Generates a self-signed TLS certificate, uploads it to the chatmail
server via SCP, runs ``cmdeploy run``, and then probes all TLS-enabled
ports (nginx, postfix, dovecot) to verify the certificate is actually
served. After probing, checks remote service logs for errors.
Prerequisites
~~~~~~~~~~~~~
- SSH root access to the target server (same as ``cmdeploy run``)
- ``cmdeploy`` in PATH (activate the venv first)
How to run
~~~~~~~~~~
From the repository root::
# Full run: generate cert, deploy, probe ports, check services
python -m cmdeploy.tests.setup_tls_external DOMAIN
# Re-probe only (after a previous deploy)
python -m cmdeploy.tests.setup_tls_external DOMAIN \\
--skip-deploy --skip-certgen
# Override SSH host (e.g. when domain doesn't resolve to the server)
python -m cmdeploy.tests.setup_tls_external DOMAIN \\
--ssh-host staging-ipv4.testrun.org
Arguments
~~~~~~~~~
DOMAIN mail domain for the chatmail server (SSH root login must work)
Options
~~~~~~~
--skip-deploy skip ``cmdeploy run``, only probe ports
--skip-certgen skip cert generation/upload, use certs already on server
--ssh-host HOST SSH host override (defaults to DOMAIN)
"""
import argparse
import shutil
import smtplib
import socket
import ssl
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# Cert paths on the remote server
REMOTE_CERT = "/etc/ssl/certs/tmp_fullchain.pem"
REMOTE_KEY = "/etc/ssl/private/tmp_privkey.pem"
# ---------------------------------------------------------------------------
# Config generation
# ---------------------------------------------------------------------------
def generate_config(domain: str, config_dir: Path) -> Path:
"""Generate a chatmail.ini with tls_external_cert_and_key for *domain*."""
from chatmaild.config import write_initial_config
ini_path = config_dir / "chatmail.ini"
write_initial_config(
ini_path,
domain,
overrides={
"tls_external_cert_and_key": f"{REMOTE_CERT} {REMOTE_KEY}",
},
)
print(f"[+] Generated chatmail.ini for {domain} in {config_dir}")
return ini_path
# ---------------------------------------------------------------------------
# Certificate generation
# ---------------------------------------------------------------------------
def generate_cert(domain: str, cert_dir: Path) -> tuple:
"""Generate a self-signed TLS cert+key for *domain* with proper SANs."""
from cmdeploy.selfsigned.deployer import openssl_selfsigned_args
cert_path = cert_dir / "fullchain.pem"
key_path = cert_dir / "privkey.pem"
subprocess.check_call(openssl_selfsigned_args(domain, cert_path, key_path, days=30))
print(f"[+] Generated cert for {domain} in {cert_dir}")
return cert_path, key_path
# ---------------------------------------------------------------------------
# Upload certs to remote server
# ---------------------------------------------------------------------------
def upload_certs(
ssh_host: str,
cert_path: Path,
key_path: Path,
) -> None:
"""SCP cert and key to the remote server."""
subprocess.check_call([
"scp", str(cert_path), f"root@{ssh_host}:{REMOTE_CERT}",
])
subprocess.check_call([
"scp", str(key_path), f"root@{ssh_host}:{REMOTE_KEY}",
])
# Ensure cert is world-readable and key is readable by ssl-cert group
# (dovecot/postfix/nginx need to read these files)
subprocess.check_call([
"ssh", f"root@{ssh_host}",
f"chmod 644 {REMOTE_CERT} && chmod 640 {REMOTE_KEY}"
f" && chgrp ssl-cert {REMOTE_KEY}",
])
print(f"[+] Uploaded cert/key to {ssh_host}")
# ---------------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------------
def run_deploy(ini_path: str) -> None:
"""Run ``cmdeploy run --skip-dns-check --config <ini>``."""
cmd = ["cmdeploy", "run", "--config", str(ini_path), "--skip-dns-check"]
print(f"[+] Running: {' '.join(cmd)}")
subprocess.check_call(cmd)
print("[+] Deploy completed successfully")
# ---------------------------------------------------------------------------
# TLS port probing
# ---------------------------------------------------------------------------
def get_peer_cert_binary(host: str, port: int) -> bytes:
"""Connect to host:port with TLS and return the DER-encoded peer cert."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((host, port), timeout=15) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as ssock:
return ssock.getpeercert(binary_form=True)
def get_smtp_starttls_cert_binary(host: str, port: int = 587) -> bytes:
"""Connect via SMTP STARTTLS and return the DER cert."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with smtplib.SMTP(host, port, timeout=15) as smtp:
smtp.starttls(context=ctx)
return smtp.sock.getpeercert(binary_form=True)
def check_cert_matches(
label: str, served_der: bytes, expected_der: bytes,
) -> bool:
"""Compare served DER cert against the expected cert."""
if served_der == expected_der:
print(f" [OK] {label}: certificate matches")
return True
else:
print(f" [FAIL] {label}: certificate does NOT match")
return False
def load_cert_der(cert_pem_path: Path) -> bytes:
"""Load a PEM cert file and return its DER encoding."""
pem_text = cert_pem_path.read_text()
start = pem_text.index("-----BEGIN CERTIFICATE-----")
end = pem_text.index("-----END CERTIFICATE-----") + len(
"-----END CERTIFICATE-----"
)
return ssl.PEM_cert_to_DER_cert(pem_text[start:end])
def probe_all_ports(host: str, expected_cert_der: bytes) -> bool:
"""Probe TLS ports and verify the served certificate matches.
Checks ports 993 (IMAP), 465 (SMTPS), 587 (STARTTLS), and 443
(nginx stream). Port 8443 is skipped as nginx binds it to
localhost behind the stream proxy on 443.
"""
print(f"\n[+] Probing TLS ports on {host}...")
all_ok = True
for label, port in [
("IMAP/TLS (993)", 993),
("SMTP/TLS (465)", 465),
]:
try:
served = get_peer_cert_binary(host, port)
if not check_cert_matches(label, served, expected_cert_der):
all_ok = False
except Exception as e:
print(f" [FAIL] {label}: connection failed: {e}")
all_ok = False
# STARTTLS on port 587
try:
served = get_smtp_starttls_cert_binary(host, 587)
if not check_cert_matches("SMTP/STARTTLS (587)", served, expected_cert_der):
all_ok = False
except Exception as e:
print(f" [FAIL] SMTP/STARTTLS (587): connection failed: {e}")
all_ok = False
# Port 443 (nginx stream proxy with ALPN routing)
try:
served = get_peer_cert_binary(host, 443)
if not check_cert_matches("nginx/443 (stream)", served, expected_cert_der):
all_ok = False
except Exception as e:
print(f" [FAIL] nginx/443 (stream): connection failed: {e}")
all_ok = False
return all_ok
# ---------------------------------------------------------------------------
# Post-deploy service health checks
# ---------------------------------------------------------------------------
SERVICES = ["dovecot", "postfix", "nginx"]
def check_remote_services(ssh_host: str, since: str = "") -> bool:
"""SSH to the server and check for service failures or errors.
*since* is a ``journalctl --since`` timestamp (e.g. ``"5 min ago"``).
If empty, checks the entire boot journal.
"""
print(f"\n[+] Checking remote service health on {ssh_host}...")
all_ok = True
for svc in SERVICES:
try:
result = subprocess.run(
["ssh", f"root@{ssh_host}",
f"systemctl is-active {svc}.service"],
capture_output=True, text=True, timeout=15, check=False,
)
status = result.stdout.strip()
if status == "active":
print(f" [OK] {svc}: active")
else:
print(f" [FAIL] {svc}: {status}")
all_ok = False
except Exception as e:
print(f" [FAIL] {svc}: check failed: {e}")
all_ok = False
since_arg = f'--since="{since}"' if since else ""
print(f"\n[+] Checking journal for errors on {ssh_host}...")
for svc in SERVICES:
try:
result = subprocess.run(
["ssh", f"root@{ssh_host}",
f"journalctl -u {svc}.service {since_arg}"
f" --no-pager -p err -q"],
capture_output=True, text=True, timeout=15, check=False,
)
errors = result.stdout.strip()
if errors:
print(f" [WARN] {svc} errors in journal:")
for line in errors.splitlines()[:10]:
print(f" {line}")
all_ok = False
else:
print(f" [OK] {svc}: no errors in journal")
except Exception as e:
print(f" [FAIL] {svc}: journal check failed: {e}")
all_ok = False
return all_ok
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"domain",
help="mail domain (SSH root login must work to this host)",
)
parser.add_argument(
"--skip-deploy",
action="store_true",
help="skip cmdeploy run, only probe ports",
)
parser.add_argument(
"--skip-certgen",
action="store_true",
help="skip cert generation and upload (use existing)",
)
parser.add_argument(
"--ssh-host",
help="SSH host override (defaults to DOMAIN)",
)
args = parser.parse_args()
domain = args.domain
ssh_host = args.ssh_host or domain
print(f"[+] Domain: {domain}")
print(f"[+] SSH host: {ssh_host}")
print(f"[+] Remote cert: {REMOTE_CERT}")
print(f"[+] Remote key: {REMOTE_KEY}")
work_dir = Path(tempfile.mkdtemp(prefix="tls-external-test-"))
try:
# Generate chatmail.ini
ini_path = generate_config(domain, work_dir)
if not args.skip_certgen:
local_cert, local_key = generate_cert(domain, work_dir)
upload_certs(ssh_host, local_cert, local_key)
else:
local_cert = work_dir / "fullchain.pem"
subprocess.check_call([
"scp", f"root@{ssh_host}:{REMOTE_CERT}", str(local_cert),
])
# Record timestamp before deploy for journal filtering
deploy_start = time.strftime("%Y-%m-%d %H:%M:%S")
if not args.skip_deploy:
run_deploy(ini_path)
# Probe TLS ports
expected_der = load_cert_der(local_cert)
ports_ok = probe_all_ports(domain, expected_der)
# Check service health (only errors since deploy started)
services_ok = check_remote_services(ssh_host, since=deploy_start)
if ports_ok and services_ok:
print(
"\n[SUCCESS] All TLS port probes passed and services are healthy"
)
return 0
else:
if not ports_ok:
print("\n[FAILURE] Some TLS port probes failed", file=sys.stderr)
if not services_ok:
print(
"\n[FAILURE] Some services have errors", file=sys.stderr
)
return 1
finally:
shutil.rmtree(work_dir, ignore_errors=True)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -1,260 +0,0 @@
Docker installation
===================
This section provides instructions for installing a chatmail relay
using Docker Compose.
.. note::
- Docker support is experimental and not yet covered by automated tests, please report bugs.
- This preliminary image simply wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a full Debian-systemd image with r/w access to `/sys/fs`
- Currently, the image has only been tested and built on amd64, though arm64 should theoretically work as well.
Setup Preparation
-----------------
We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain.
1. Install docker and docker compose v2 (check with `docker compose version`), install, e.g., through
- Debian 12 through the `official install instructions <https://docs.docker.com/engine/install/debian/#install-using-the-repository>`_
- Debian 13+ with `apt install docker docker-compose`
If you must use v1 (EOL since 2023), use `docker-compose` in the following and modify the `docker-compose.yaml` to use `privileged: true` instead of `cgroup: host`, though that will run give the container all priviledges.
2. Setup the initial DNS records.
The following is an example in the familiar BIND zone file format with
a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses.
::
chat.example.org. 3600 IN A 198.51.100.5
chat.example.org. 3600 IN AAAA 2001:db8::5
www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
3. Configure kernel parameters on the host, as these can not be set from the container::
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
sudo sysctl --system
Docker Compose Setup
--------------------
Pre-built images are available from GitHub Container Registry. The
``main`` branch and tagged releases are pushed automatically by CI::
docker pull ghcr.io/chatmail/relay:main # latest main branch
docker pull ghcr.io/chatmail/relay:1.2.3 # tagged release
Create service directory
^^^^^^^^^^^^^^^^^^^^^^^^
Either:
- Create a service directory, e.g., `/srv/chatmail-relay`::
mkdir -p /srv/chatmail-relay && cd /srv/chatmail-relay
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker-compose.yaml
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker-compose.override.yaml.example -O docker-compose.override.yaml
- or clone the chatmail repo ::
git clone https://github.com/chatmail/relay
cd relay
Customize and start
^^^^^^^^^^^^^^^^^^^
1. Set the fully qualified domain name of the relay::
echo 'MAIL_DOMAIN=chat.example.org' > .env
The container generates a ``chatmail.ini`` with defaults from
``MAIL_DOMAIN`` on first start. To customize chatmail settings, mount
your own ``chatmail.ini`` instead (see `Custom chatmail.ini`_ below).
2. All local customizations (data paths, extra volumes, config mounts) go in
``docker-compose.override.yaml``, which Compose merges automatically with
the base file. By default, all data is stored in docker volumes, you will
likely want to at least create and configure the mail storage location, but
you might also want to configure external TLS certificates there.
3. Start the container::
docker compose up -d
docker compose logs -f chatmail # view logs, Ctrl+C to exit
4. After installation is complete, open ``https://chat.example.org`` in
your browser.
Finish install and test
-----------------------
You can test the installation with::
pip install cmping chat.example.org # or
uvx cmping chat.example.org # if you use https://docs.astral.sh/uv/
You should check and extend your DNS records for better interoperability::
# Show required DNS records
docker exec chatmail /opt/cmdeploy/bin/cmdeploy dns --ssh-host @local
You can check server status with::
docker exec chatmail /opt/cmdeploy/bin/cmdeploy status --ssh-host @local
You can run some benchmarks (can also run from any machine with cmdeploy installed)
docker exec chatmail /opt/cmdeploy/bin/cmdeploy bench chat.example.org
You can run the test suite with
docker exec chatmail /opt/cmdeploy/bin/cmdeploy test chat.example.org --ssh-host localhost
Customization
-------------
Website
^^^^^^^^^^^^^^
You can customize the chatmail landing page by mounting a directory with
your own website source files.
1. Create a directory with your custom website source::
mkdir -p ./custom/www/src
nano ./custom/www/src/index.md
2. Add the volume mount in ``docker-compose.override.yaml``::
services:
chatmail:
volumes:
- ./custom/www:/opt/chatmail-www
3. Restart the service::
docker compose down
docker compose up -d
Custom chatmail.ini
^^^^^^^^^^^^^^^^^^^
If you want to go beyond simply setting the ``MAIL_DOMAIN`` in ``.env``, you
can use a regular `chatmail.ini` to give you full control.
1. Extract the generated config from a running container::
docker cp chatmail:/etc/chatmail/chatmail.ini ./chatmail.ini
2. Edit ``chatmail.ini`` as needed.
3. Add the volume mount in ``docker-compose.override.yaml`` ::
services:
chatmail:
volumes:
- ./chatmail.ini:/etc/chatmail/chatmail.ini
4. Restart the container, the container skips generating a new one: ::
docker compose down && docker compose up -d
External TLS certificates
^^^^^^^^^^^^^^^^^^^^^^^^^
If TLS certificates are managed outside the container (e.g. by certbot,
acmetool, or Traefik on the host), mount them into the container and set
``TLS_EXTERNAL_CERT_AND_KEY`` in ``docker-compose.override.yaml``.
Changed certificates are picked up automatically via inotify.
See the examples in the example override and :ref:`external-tls` in the getting started guide for details.
Migrating from a bare-metal install
------------------------------------
If you have an existing bare-metal chatmail installation and want to
switch to Docker:
1. Stop all existing services::
systemctl stop postfix dovecot doveauth nginx opendkim unbound \
acmetool-redirector filtermail filtermail-incoming chatmail-turn \
iroh-relay chatmail-metadata lastlogin mtail
systemctl disable postfix dovecot doveauth nginx opendkim unbound \
acmetool-redirector filtermail filtermail-incoming chatmail-turn \
iroh-relay chatmail-metadata lastlogin mtail
2. Copy your existing ``chatmail.ini`` and mount it into the container
(see `Custom chatmail.ini`_ above)::
cp /usr/local/lib/chatmaild/chatmail.ini ./chatmail.ini
3. Copy persistent data into the ``./data/`` subdirectories (for example, as configured in `Customize and start`_) ::
mkdir -p data/dkim data/certs data/mail
# DKIM keys
cp -a /etc/dkimkeys/* data/dkim/
# TLS certificates
rsync -a /var/lib/acme/ data/certs/
Note that ownership of dkim and acme is adjusted on container start.
For the mail directory::
rsync -a /home/vmail/ data/mail/
Alternatively, mount ``/home/vmail`` directly by changing the volume
in ``docker-compose-override.yaml``::
- /home/vmail:/home/vmail
The three ``./data/`` subdirectories cover all persistent state.
Everything else is regenerated by the ``configure`` and ``activate``
stages on container start.
Building the image
------------------
Clone the repository and build the Docker image::
git clone https://github.com/chatmail/relay
cd relay
docker compose build chatmail
The build bakes all binaries, Python packages, and the install stage
into the image. After building, only ``docker-compose.yaml`` and a ``.env`` with
``MAIL_DOMAIN`` are needed to run the container.
You can transfer a locally built image to your server directly (pigz is parallel `gzip` which can be used instead as well) ::
docker save chatmail-relay:latest | pigz | ssh chat.example.org 'pigz -d | docker load'
Forcing a full reinstall
------------------------
On container start, only the ``configure`` and ``activate`` stages run by default.
To force a full reinstall (e.g. after updating the source), either
rebuild the image::
docker compose build chatmail
docker compose up -d
Or override the stages at runtime without rebuilding::
CMDEPLOY_STAGES="install,configure,activate" docker compose up -d

View File

@@ -98,12 +98,6 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are
public).
Docker installation
-------------------
There is experimental support for running chatmail via Docker Compose.
See :doc:`docker` for full setup instructions.
Other helpful commands
----------------------
@@ -235,7 +229,11 @@ The deploy will verify that both files exist on the server.
You are responsible for certificate renewal.
When the certificate file changes on disk,
all relay services pick up the new certificate automatically
(via a systemd path watcher installed during deploy).
via a systemd path watcher installed during deploy.
The watcher uses inotify, which does not cross bind-mount boundaries.
If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service
Migrating to a new build machine

View File

@@ -13,7 +13,6 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
:maxdepth: 5
getting_started
docker
proxy
migrate
overview

View File

@@ -1,40 +0,0 @@
# Local overrides — copy to docker-compose.override.yaml in the repo root.
# Compose automatically merges this with docker-compose.yaml.
#
# cp docker-compose.override.yaml.example docker-compose.override.yaml
#
# Volumes are APPENDED to the base file's volumes list.
# Environment and other scalar keys are MERGED by key.
services:
chatmail:
volumes:
## Data paths — bind-mount to host directories for easy access/backup.
# - ./data/dkim:/etc/dkimkeys
# - ./data/certs:/var/lib/acme
# - ./data/mail:/home/vmail
## Or mount from an existing bare-metal install.
# - /home/vmail:/home/vmail
## Mount your own chatmail.ini (skips auto-generation):
# - ./chatmail.ini:/etc/chatmail/chatmail.ini
## Custom website:
# - ./custom/www:/opt/chatmail-www
## Debug — mount scripts from the repo for live editing:
# - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
# - ./docker/files/entrypoint.sh:/entrypoint.sh
# environment:
## Mount certs (above) and set TLS_EXTERNAL_CERT_AND_KEY to in-container paths.
## Changed certs are picked up automatically (inotify via tls-cert-reload.path).
##
## Host acmetool (bare-metal migration): create mount above, and
## rsync -a /var/lib/acme/live data/certs
# TLS_EXTERNAL_CERT_AND_KEY: "/var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey"
##
## (Untested) Traefik certs-dumper (see docker/docker-compose-traefik.yaml) - also add volume:
## - traefik-certs:/certs:ro
# TLS_EXTERNAL_CERT_AND_KEY: "/certs/${MAIL_DOMAIN}/certificate.crt /certs/${MAIL_DOMAIN}/privatekey.key"

View File

@@ -1,47 +0,0 @@
# Base compose file — do not edit. Put customizations (data paths, extra
# volumes, env overrides) in docker-compose.override.yaml instead.
# See docker/docker-compose.override.yaml.example for a starting point.
#
# Security note: this container uses network_mode:host (chatmail needs many
# ports: 25, 53, 80, 143, 443, 465, 587, 993, 3340, 8443) and cgroup:host
# (required for systemd). Together these give the container near-host-level
# access. This is acceptable for a dedicated mail server, but be aware that
# the container can bind any port and see all host network traffic.
services:
chatmail:
build:
context: ./
dockerfile: docker/chatmail_relay.dockerfile
args:
GIT_HASH: ${GIT_HASH:-unknown}
image: chatmail-relay:latest
restart: unless-stopped
container_name: chatmail
# Required for systemd — use only one of the following:
cgroup: host # compose v2
# privileged: true # compose v1 (less restricted)
tty: true # required for logs
tmpfs: # required for systemd
- /tmp
- /run
- /run/lock
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
MAIL_DOMAIN: $MAIL_DOMAIN
network_mode: "host"
volumes:
## system (required)
- /sys/fs/cgroup:/sys/fs/cgroup:rw
## data (defaults — override in docker-compose.override.yaml)
- mail:/home/vmail
- dkim:/etc/dkimkeys
- certs:/var/lib/acme
volumes:
mail:
dkim:
certs:

View File

@@ -1,9 +0,0 @@
#!/bin/sh
# Build the chatmail Docker image with the current git hash baked in.
# Usage: ./docker/build.sh [extra docker-compose build args...]
#
# .git/ is excluded from the build context (.dockerignore) so the hash
# must be passed as a build arg from the host.
export GIT_HASH=$(git rev-parse --short HEAD)
exec docker compose build "$@"

View File

@@ -1,99 +0,0 @@
FROM jrei/systemd-debian:12 AS base
ENV LANG=en_US.UTF-8
RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \
apt-get update && \
apt-get install -y \
ca-certificates && \
DEBIAN_FRONTEND=noninteractive \
TZ=UTC \
apt-get install -y tzdata && \
apt-get install -y locales && \
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=$LANG \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y \
git \
python3 \
python3-venv \
python3-virtualenv \
gcc \
python3-dev \
opendkim \
opendkim-tools \
curl \
rsync \
unbound \
unbound-anchor \
dnsutils \
postfix \
acl \
nginx \
libnginx-mod-stream \
fcgiwrap \
cron \
&& rm -rf /var/lib/apt/lists/*
# --- Build-time: install cmdeploy venv and run install stage ---
# Editable install so importlib.resources reads directly from the source tree.
# On container start only "configure,activate" stages run.
COPY . /opt/chatmail/
WORKDIR /opt/chatmail
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
# Dummy git repo init: .git/ is excluded from the build context (.dockerignore)
# but setuptools calls `git ls-files` when building the sdist.
RUN git init -q && \
python3 -m venv /opt/cmdeploy && \
/opt/cmdeploy/bin/pip install --no-cache-dir \
-e chatmaild/ -e cmdeploy/
RUN CMDEPLOY_STAGES=install \
CHATMAIL_INI=/tmp/chatmail.ini \
CHATMAIL_NOSYSCTL=True \
CHATMAIL_NOPORTCHECK=True \
/opt/cmdeploy/bin/pyinfra @local \
/opt/chatmail/cmdeploy/src/cmdeploy/run.py -y
RUN cp -a www/ /opt/chatmail-www/
RUN rm -f /tmp/chatmail.ini
# Record image version (used in deploy fingerprint at runtime).
# GIT_HASH is passed as a build arg (from docker-compose or CI) so that
# .git/ can be excluded from the build context via .dockerignore.
ARG GIT_HASH=unknown
RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \
echo "$GIT_HASH" > /etc/chatmail-version
# --- End build-time install ---
ENV TZ=:/etc/localtime
ENV PATH="/opt/cmdeploy/bin:${PATH}"
RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini
ARG SETUP_CHATMAIL_SERVICE_PATH=/lib/systemd/system/setup_chatmail.service
COPY ./docker/files/setup_chatmail.service "$SETUP_CHATMAIL_SERVICE_PATH"
RUN ln -sf "$SETUP_CHATMAIL_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/setup_chatmail.service"
# Remove default nginx site config at build time (not in entrypoint)
RUN rm -f /etc/nginx/sites-enabled/default
COPY --chmod=555 ./docker/files/setup_chatmail_docker.sh /setup_chatmail_docker.sh
COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.sh
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
CMD systemctl is-active dovecot postfix nginx unbound opendkim filtermail doveauth chatmail-metadata || exit 1
STOPSIGNAL SIGRTMIN+3
ENTRYPOINT ["/entrypoint.sh"]
CMD [ "--default-standard-output=journal+console", \
"--default-standard-error=journal+console" ]

View File

@@ -1,12 +0,0 @@
#!/bin/bash
set -eo pipefail
SETUP_CHATMAIL_SERVICE_PATH="${SETUP_CHATMAIL_SERVICE_PATH:-/lib/systemd/system/setup_chatmail.service}"
# Whitelist only the env vars needed by setup_chatmail_docker.sh.
# Forwarding all env vars (via printenv) would leak Docker internals,
# orchestrator secrets, and other unrelated variables into systemd.
env_vars="MAIL_DOMAIN CMDEPLOY_STAGES CHATMAIL_INI TLS_EXTERNAL_CERT_AND_KEY PATH"
sed -i "s|<envs_list>|$env_vars|g" "$SETUP_CHATMAIL_SERVICE_PATH"
exec /lib/systemd/systemd "$@"

View File

@@ -1,14 +0,0 @@
[Unit]
Description=Run container setup commands
After=multi-user.target
ConditionPathExists=/setup_chatmail_docker.sh
[Service]
Type=oneshot
ExecStart=/bin/bash /setup_chatmail_docker.sh
RemainAfterExit=true
WorkingDirectory=/opt/chatmail
PassEnvironment=<envs_list>
[Install]
WantedBy=multi-user.target

View File

@@ -1,63 +0,0 @@
#!/bin/bash
set -euo pipefail
export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}"
export CHATMAIL_NOSYSCTL=True
export CHATMAIL_NOPORTCHECK=True
CMDEPLOY=/opt/cmdeploy/bin/cmdeploy
if [ -z "$MAIL_DOMAIN" ]; then
echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2
exit 1
fi
### MAIN
if [ ! -f /etc/dkimkeys/opendkim.private ]; then
/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d "$MAIL_DOMAIN" -s opendkim
fi
# Fix ownership for bind-mounted keys (host opendkim UID may differ from container)
chown -R opendkim:opendkim /etc/dkimkeys
# Journald: forward to console for docker logs
grep -q '^ForwardToConsole=yes' /etc/systemd/journald.conf \
|| echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf
systemctl restart systemd-journald
# Create chatmail.ini (skips if file already exists, e.g. volume-mounted)
mkdir -p "$(dirname "$CHATMAIL_INI")"
if [ ! -f "$CHATMAIL_INI" ]; then
$CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN"
fi
# Inject external TLS paths from env var (unless user mounted their own ini)
if [ -n "${TLS_EXTERNAL_CERT_AND_KEY:-}" ]; then
if ! grep -q '^tls_external_cert_and_key' "$CHATMAIL_INI"; then
echo "tls_external_cert_and_key = $TLS_EXTERNAL_CERT_AND_KEY" >> "$CHATMAIL_INI"
fi
fi
# --- Deploy fingerprint: skip cmdeploy run if nothing changed ---
# On restart with identical image+config, systemd already brings up all
# enabled services — the full cmdeploy run is redundant (~30s saved).
# The install stage runs at image build time (Dockerfile), so only
# configure+activate are needed here.
IMAGE_VERSION_FILE="/etc/chatmail-image-version"
FINGERPRINT_FILE="/etc/chatmail/.deploy-fingerprint"
image_ver="none"
[ -f "$IMAGE_VERSION_FILE" ] && image_ver=$(cat "$IMAGE_VERSION_FILE")
config_hash=$(sha256sum "$CHATMAIL_INI" | cut -c1-16)
current_fp="${image_ver}:${config_hash}"
# CMDEPLOY_STAGES non-empty in env = operator override → always run.
# Otherwise, if fingerprint matches the last successful deploy, skip.
if [ -z "${CMDEPLOY_STAGES:-}" ] \
&& [ -f "$FINGERPRINT_FILE" ] \
&& [ "$(cat "$FINGERPRINT_FILE")" = "$current_fp" ]; then
echo "[INFO] No changes detected ($current_fp), skipping deploy."
else
export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}"
$CMDEPLOY run --config "$CHATMAIL_INI" --ssh-host @local
echo "$current_fp" > "$FINGERPRINT_FILE"
fi

View File

@@ -1 +0,0 @@
MAIL_DOMAIN=chat.example.com