Compare commits

..

10 Commits

Author SHA1 Message Date
missytake
026b09ac12 ci: deploy with --ssh-host localhost on staging-ipv4 2026-02-19 21:42:49 +01:00
missytake
8163db3b46 docs: webdev needs to be exposed via nginx if run on the relay 2026-02-19 21:42:49 +01:00
missytake
0a4e67dd30 docs: use ssh_host = localhost in getting started docs
remove mentions of the build machine / deployment server separation
2026-02-19 21:42:49 +01:00
missytake
108cc5ffde docs: cmdeploy dns + test are kind of necessary 2026-02-19 21:42:48 +01:00
missytake
45660731d2 cmdeploy: add ssh_host chatmail.ini option to deploy remotely 2026-02-19 21:42:48 +01:00
missytake
13dd64798a config: load default values from Config(), not chatmail.ini.f 2026-02-19 21:41:17 +01: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
missytake
2ce9e5fe78 dovecot: install also if dovecot.service=False in SystemdEnabled Fact 2026-02-19 16:00:25 +01:00
holger krekel
cf96be2cbb feat: support self-signed chatmail relays (#855)
feat: support self-signed TLS via underscore domain convention
Domains starting with "_" (e.g. _chat.example.org) automatically use
self-signed TLS certificates instead of ACME/Let's Encrypt. The TLS
mode is derived from the domain name — no separate config option needed.

Internally, when config.tls_cert_mode is "self" (underscore domain):
- Generate self-signed certificates via openssl
- Set Postfix smtp_tls_security_level to "encrypt" (opportunistic TLS)
- Add smtp_tls_policy_map entry for underscore domains
- Skip ACME, MTA-STS and www CNAME checks in `cmdeploy dns`
- Serve /new via GET (not redirect to dcaccount:) with rate-limiting
  (nginx limit_req, 2r/s burst=5)
- Return dclogin: URLs with ic=3 (AcceptInvalidCertificates) from /new
- Render QR codes client-side via JavaScript and qrcode-svg
- Use config.tls_cert_path/tls_key_path in Postfix, Dovecot and nginx
  templates instead of hardcoded ACME paths
2026-02-19 10:27:41 +01:00
57 changed files with 502 additions and 1117 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

@@ -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,26 +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
- 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"
- run: cmdeploy run --verbose --skip-dns-check
- 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"
- 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 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"
- name: cmdeploy dns
run: cmdeploy dns -v
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v"

View File

@@ -76,6 +76,7 @@ jobs:
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/^ssh_host/#ssh_host/' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check

6
.gitignore vendored
View File

@@ -164,9 +164,3 @@ cython_debug/
#.idea/
chatmail.zone
# docker
/data/
/custom/
docker-compose.override.yaml
.env

View File

@@ -121,13 +121,6 @@
Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/637))
- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
([#614](https://github.com/chatmail/relay/pull/614))
- Add configuration parameters
([#614](https://github.com/chatmail/relay/pull/614)):
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)
## 1.7.0 2025-09-11

View File

@@ -9,30 +9,28 @@ from chatmaild.user import User
def read_config(inipath):
assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
return Config(inipath, params=cfg.sections["params"])
class Config:
def __init__(self, inipath, params):
self._inipath = inipath
self.mail_domain = params["mail_domain"]
self.ssh_host = params.get("ssh_host", self.mail_domain)
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.max_mailbox_size = params.get("max_mailbox_size", "500M")
self.max_message_size = int(params.get("max_message_size", 31457280))
self.delete_mails_after = params.get("delete_mails_after", "20")
self.delete_large_after = params.get("delete_large_after", "7")
self.delete_inactive_users_after = int(
params.get("delete_inactive_users_after", 100)
)
self.username_min_length = int(params.get("username_min_length", 9))
self.username_max_length = int(params.get("username_max_length", 9))
self.password_min_length = int(params.get("password_min_length", 9))
self.passthrough_senders = params.get("passthrough_senders", "").split()
self.passthrough_recipients = params.get("passthrough_recipients", "").split()
self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int(
@@ -44,7 +42,6 @@ class Config:
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.noacme = os.environ.get("CHATMAIL_NOACME", "false").lower() == "true"
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "")
@@ -61,6 +58,18 @@ class Config:
self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor")
# TLS certificate management: derived from the domain name.
# Domains starting with "_" use self-signed certificates
# All other domains use ACME.
if self.mail_domain.startswith("_"):
self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key"
else:
self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip())

View File

@@ -3,6 +3,9 @@
# mail domain (MUST be set to fully qualified chat mail domain)
mail_domain = {mail_domain}
# Where to deploy the relay - if unspecified, mail_domain will be used.
ssh_host = localhost
#
# If you only do private test deploys, you don't need to modify any settings below
#

View File

@@ -6,6 +6,7 @@ import json
import random
import secrets
import string
from urllib.parse import quote
from chatmaild.config import Config, read_config
@@ -23,13 +24,26 @@ def create_newemail_dict(config: Config):
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates.
"""
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
def print_new_account():
config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config)
result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
print("Content-Type: application/json")
print("")
print(json.dumps(creds))
print(json.dumps(result))
if __name__ == "__main__":

View File

@@ -73,3 +73,17 @@ def test_config_userstate_paths(make_config, tmp_path):
def test_config_max_message_size(make_config, tmp_path):
config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.max_message_size == 10000
def test_config_tls_default_acme(make_config):
config = make_config("chat.example.org")
assert config.tls_cert_mode == "acme"
assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain"
assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey"
def test_config_tls_self(make_config):
config = make_config("_test.example.org")
assert config.tls_cert_mode == "self"
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"

View File

@@ -1,9 +1,15 @@
import shutil
import smtplib
import subprocess
import sys
import pytest
pytestmark = pytest.mark.skipif(
shutil.which("filtermail") is None,
reason="filtermail binary not found",
)
@pytest.fixture
def smtpserver():
@@ -57,19 +63,10 @@ def test_one_mail(
path = str(config._inipath)
popen = make_popen(["filtermail", path, filtermail_mode])
# Wait for filtermail to start accepting connections
import socket
import time
for _ in range(50): # 5 second timeout
try:
sock = socket.create_connection(("127.0.0.1", smtp_inject_port), timeout=0.1)
sock.close()
break
except (ConnectionRefusedError, OSError):
time.sleep(0.1)
else:
pytest.fail("filtermail failed to start accepting connections")
line = popen.stderr.readline().strip()
if b"loop" not in line:
print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed")
addr = f"user1@{config.mail_domain}"
config.get_user(addr).set_password("l1k2j3l1k2j3l")

View File

@@ -1,7 +1,11 @@
import json
import chatmaild
from chatmaild.newemail import create_newemail_dict, print_new_account
from chatmaild.newemail import (
create_dclogin_url,
create_newemail_dict,
print_new_account,
)
def test_create_newemail_dict(example_config):
@@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"]
def test_create_dclogin_url():
url = create_dclogin_url("user@example.org", "p@ss w+rd")
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert "user@example.org" in url
# password special chars must be encoded
assert "p%40ss" in url
assert "w%2Brd" in url
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account()
@@ -25,3 +41,20 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10
# default tls_cert=acme should not include dclogin_url
assert "dclogin_url" not in dic
def test_print_new_account_self_signed(capsys, monkeypatch, make_config):
config = make_config("_test.example.org")
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath))
print_new_account()
out, err = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" in dic
url = dic["dclogin_url"]
assert url.startswith("dclogin:")
assert "ic=3" in url
assert dic["email"].split("@")[0] in url

View File

@@ -5,11 +5,6 @@ import os
from pyinfra.operations import files, server, systemd
def has_systemd():
"""Returns False during Docker image builds or any other non-systemd environment."""
return os.path.isdir("/run/systemd/system")
def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg)

View File

@@ -8,8 +8,10 @@
{{ mail_domain }}. AAAA {{ AAAA }}
{% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}.
{% if strict_tls %}
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
{% endif %}
www.{{ mail_domain }}. CNAME {{ mail_domain }}.
{{ dkim_entry }}

View File

@@ -88,12 +88,13 @@ def run_cmd_options(parser):
def run_cmd(args, out):
"""Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme"
if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
return 1
env = os.environ.copy()
@@ -108,10 +109,7 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]:
if ssh_host == "@docker":
env["CHATMAIL_NOPORTCHECK"] = "True"
env["CHATMAIL_NOSYSCTL"] = "True"
if ssh_host in ["localhost", "@local", "@docker"]:
cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -127,7 +125,7 @@ def run_cmd(args, out):
out.red("Website deployment failed.")
elif retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not args.dns_check_disabled and not remote_data["acme_account_url"]:
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again")
retcode = 0
@@ -152,13 +150,15 @@ def dns_cmd_options(parser):
def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
tls_cert_mode = args.config.tls_cert_mode
strict_tls = tls_cert_mode == "acme"
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data:
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls):
return 1
if not remote_data["acme_account_url"]:
if strict_tls and not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1
@@ -166,6 +166,7 @@ def dns_cmd(args, out):
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1
remote_data["strict_tls"] = strict_tls
zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile:
@@ -186,7 +187,7 @@ def status_cmd_options(parser):
def status_cmd(args, out):
"""Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
ssh_host = args.ssh_host if args.ssh_host else args.config.ssh_host
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
out.green(f"chatmail domain: {args.config.mail_domain}")
@@ -206,6 +207,7 @@ def test_cmd_options(parser):
action="store_true",
help="also run slow tests",
)
add_ssh_host_option(parser)
def test_cmd(args, out):
@@ -217,6 +219,9 @@ def test_cmd(args, out):
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 = [
@@ -230,7 +235,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
@@ -331,7 +336,7 @@ def add_config_option(parser):
"--config",
dest="inipath",
action="store",
default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")),
default=Path("chatmail.ini"),
type=Path,
help="path to the chatmail.ini file",
)

View File

@@ -2,7 +2,6 @@
Chat Mail pyinfra deploy.
"""
import os
import shutil
import subprocess
import sys
@@ -20,13 +19,13 @@ from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out
from .acmetool import AcmetoolDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .basedeploy import (
Deployer,
Deployment,
activate_remote_units,
configure_remote_units,
get_resource,
has_systemd,
)
from .dovecot.deployer import DovecotDeployer
from .filtermail.deployer import FiltermailDeployer
@@ -67,8 +66,6 @@ def _build_chatmaild(dist_dir) -> None:
def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
@@ -303,7 +300,7 @@ class LegacyRemoveDeployer(Deployer):
present=False,
)
# remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
if host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service(
name="Disable echobot.service",
service="echobot.service",
@@ -570,38 +567,45 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
exit(1)
if not os.environ.get("CHATMAIL_NOPORTCHECK"):
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 80))
port_services += [
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
if config.tls_cert_mode == "acme":
tls_deployer = AcmetoolDeployer(config.acme_email, tls_domains)
else:
tls_deployer = SelfSignedTlsDeployer(mail_domain)
all_deployers = [
ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(),
@@ -610,12 +614,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
UnboundDeployer(config),
TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay),
]
if not config.noacme:
all_deployers.append(AcmetoolDeployer(config.acme_email, tls_domains))
all_deployers += [
tls_deployer,
WebsiteDeployer(config),
ChatmailVenvDeployer(config),
MtastsDeployer(),

View File

@@ -12,14 +12,14 @@ def get_initial_remote_data(sshexec, mail_domain):
)
def check_initial_remote_data(remote_data, *, print=print):
def check_initial_remote_data(remote_data, *, strict_tls=True, print=print):
mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!")
elif remote_data["MTA_STS"] != f"{mail_domain}.":
elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.":
print("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.")
elif remote_data["WWW"] != f"{mail_domain}.":
elif strict_tls and remote_data["WWW"] != f"{mail_domain}.":
print("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.")
else:

View File

@@ -1,5 +1,3 @@
import os
from chatmaild.config import Config
from pyinfra import host
from pyinfra.facts.server import Arch, Sysctl
@@ -11,7 +9,6 @@ from cmdeploy.basedeploy import (
activate_remote_units,
configure_remote_units,
get_resource,
has_systemd,
)
@@ -25,11 +22,10 @@ class DovecotDeployer(Deployer):
def install(self):
arch = host.get_fact(Arch)
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
return # already installed and running
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
if not host.get_fact(SystemdEnabled).get("dovecot.service"):
_install_dovecot_package("core", arch)
_install_dovecot_package("imapd", arch)
_install_dovecot_package("lmtpd", arch)
def configure(self):
configure_remote_units(self.config.mail_domain, self.units)
@@ -120,19 +116,18 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits
if not os.environ.get("CHATMAIL_NOSYSCTL"):
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
timezone_env = files.line(
name="Set TZ environment variable",

View File

@@ -228,8 +228,8 @@ service anvil {
}
ssl = required
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_cert = <{{ config.tls_cert_path }}
ssl_key = <{{ config.tls_key_path }}
ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.3
ssl_prefer_server_ciphers = yes

View File

@@ -1,47 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}">
<domain>{{ config.domain_name }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName>
<emailProvider id="{{ config.mail_domain }}">
<domain>{{ config.mail_domain }}</domain>
<displayName>{{ config.mail_domain }} chatmail</displayName>
<displayShortName>{{ config.mail_domain }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<hostname>{{ config.mail_domain }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>

View File

@@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config={"domain_name": config.mail_domain},
config=config,
disable_ipv6=config.disable_ipv6,
)
need_restart |= main_config.changed
@@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config={"domain_name": config.mail_domain},
config=config,
)
need_restart |= autoconfig.changed
@@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
user="root",
group="root",
mode="644",
config={"domain_name": config.mail_domain},
config=config,
)
need_restart |= mta_sts_config.changed

View File

@@ -1,4 +1,4 @@
version: STSv1
mode: enforce
mx: {{ config.domain_name }}
mx: {{ config.mail_domain }}
max_age: 2419200

View File

@@ -42,6 +42,9 @@ stream {
}
http {
{% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %}
sendfile on;
tcp_nopush on;
@@ -53,8 +56,8 @@ http {
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
ssl_certificate {{ config.tls_cert_path }};
ssl_certificate_key {{ config.tls_key_path }};
gzip on;
@@ -66,7 +69,7 @@ http {
index index.html index.htm;
server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }};
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
access_log syslog:server=unix:/dev/log,facility=local7;
@@ -81,11 +84,15 @@ http {
}
location /new {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) {
# Redirect to Delta Chat,
# which will in turn do a POST request.
return 301 dcaccount:https://{{ config.domain_name }}/new;
return 301 dcaccount:https://{{ config.mail_domain }}/new;
}
{% else %}
limit_req zone=newaccount burst=5 nodelay;
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
@@ -99,9 +106,11 @@ http {
#
# Redirects are only for browsers.
location /cgi-bin/newemail.py {
{% if config.tls_cert_mode == "acme" %}
if ($request_method = GET) {
return 301 dcaccount:https://{{ config.domain_name }}/new;
return 301 dcaccount:https://{{ config.mail_domain }}/new;
}
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params;
@@ -132,8 +141,8 @@ http {
# Redirect www. to non-www
server {
listen 127.0.0.1:8443 ssl;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
}
}

View File

@@ -15,12 +15,12 @@ readme_directory = no
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
smtpd_tls_cert_file={{ config.tls_cert_path }}
smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify
smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
# Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname

View File

@@ -1,2 +1,3 @@
/^\[[^]]+\]$/ encrypt
/^_/ encrypt
/^nauta\.cu$/ may

View File

@@ -0,0 +1,36 @@
from pyinfra.operations import apt, files, server
from cmdeploy.basedeploy import Deployer
class SelfSignedTlsDeployer(Deployer):
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
def __init__(self, mail_domain):
self.mail_domain = mail_domain
self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self):
server.shell(
name="Generate self-signed TLS certificate if not present",
commands=[
f"[ -f {self.cert_path} ] || openssl req -x509"
f" -newkey ec -pkeyopt ec_paramgen_curve:P-256"
f" -noenc -days 36500"
f" -keyout {self.key_path}"
f" -out {self.cert_path}"
f' -subj "/CN={self.mail_domain}"'
f' -addext "extendedKeyUsage=serverAuth,clientAuth"'
f' -addext "subjectAltName=DNS:{self.mail_domain},DNS:www.{self.mail_domain},DNS:mta-sts.{self.mail_domain}"',
],
)
def activate(self):
pass

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 pytest
import requests
from cmdeploy.genqr import gen_qr_png_data
@@ -8,18 +9,33 @@ def test_gen_qr_png_data(maildomain):
assert data
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new"
print(url)
res = requests.post(url)
verify = chatmail_config.tls_cert_mode == "acme"
res = requests.post(url, verify=verify)
assert maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length
def test_newemail_configure(maildomain, rpc):
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, rpc, chatmail_config):
"""Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3):
account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url)
rpc.configure(account_id)
if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json()
rpc.add_or_update_transport(account_id, {
"addr": data["email"],
"password": data["password"],
"imapServer": maildomain,
"smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
})
else:
rpc.add_transport_from_qr(account_id, url)

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)
@@ -218,7 +221,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,15 +7,16 @@ import pytest
import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
from cmdeploy.cmdeploy import get_sshexec
@pytest.fixture
def imap_mailbox(cmfactory):
def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr")
password = ac1.get_config("mail_pw")
mailbox = imap_tools.MailBox(user.split("@")[1])
host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password)
mailbox.dc_ac = ac1
return mailbox
@@ -90,7 +91,7 @@ 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
@@ -171,7 +172,7 @@ class TestEndToEndDeltaChat:
time.sleep(1)
def test_hide_senders_ip_address(cmfactory):
def test_hide_senders_ip_address(cmfactory, ssl_context):
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip)
@@ -180,6 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()
addr = user2.get_config("addr")
host = addr.split("@")[1]
pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw)
msgs = list(mailbox.fetch(mark_seen=False))
assert msgs, "expected at least one message"
assert public_ip not in msgs[0].obj.as_string()

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

@@ -4,6 +4,7 @@ import itertools
import os
import random
import smtplib
import ssl
import subprocess
import time
from pathlib import Path
@@ -54,8 +55,8 @@ def maildomain(chatmail_config):
@pytest.fixture(scope="session")
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)
def sshdomain(chatmail_config):
return os.environ.get("CHATMAIL_SSH", chatmail_config.ssh_host)
@pytest.fixture
@@ -144,15 +145,25 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line)
@pytest.fixture
def imap(maildomain):
return ImapConn(maildomain)
@pytest.fixture(scope="session")
def ssl_context(chatmail_config):
if chatmail_config.tls_cert_mode == "self":
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
@pytest.fixture
def make_imap_connection(maildomain):
def imap(maildomain, ssl_context):
return ImapConn(maildomain, ssl_context=ssl_context)
@pytest.fixture
def make_imap_connection(maildomain, ssl_context):
def make_imap_connection():
conn = ImapConn(maildomain)
conn = ImapConn(maildomain, ssl_context=ssl_context)
conn.connect()
return conn
@@ -164,12 +175,13 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot"
name = "dovecot"
def __init__(self, host):
def __init__(self, host, ssl_context=None):
self.host = host
self.ssl_context = ssl_context
def connect(self):
print(f"imap-connect {self.host}")
self.conn = imaplib.IMAP4_SSL(self.host)
self.conn = imaplib.IMAP4_SSL(self.host, ssl_context=self.ssl_context)
def login(self, user, password):
print(f"imap-login {user!r} {password!r}")
@@ -195,14 +207,14 @@ class ImapConn:
@pytest.fixture
def smtp(maildomain):
return SmtpConn(maildomain)
def smtp(maildomain, ssl_context):
return SmtpConn(maildomain, ssl_context=ssl_context)
@pytest.fixture
def make_smtp_connection(maildomain):
def make_smtp_connection(maildomain, ssl_context):
def make_smtp_connection():
conn = SmtpConn(maildomain)
conn = SmtpConn(maildomain, ssl_context=ssl_context)
conn.connect()
return conn
@@ -214,12 +226,14 @@ class SmtpConn:
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix"
def __init__(self, host):
def __init__(self, host, ssl_context=None):
self.host = host
self.ssl_context = ssl_context
def connect(self):
print(f"smtp-connect {self.host}")
self.conn = smtplib.SMTP_SSL(self.host)
context = self.ssl_context or ssl.create_default_context()
self.conn = smtplib.SMTP_SSL(self.host, context=context)
def login(self, user, password):
print(f"smtp-login {user!r} {password!r}")
@@ -270,11 +284,12 @@ def gencreds(chatmail_config):
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
def __init__(self, pytestconfig, maildomain, gencreds):
def __init__(self, pytestconfig, maildomain, gencreds, chatmail_config):
self.pytestconfig = pytestconfig
self.maildomain = maildomain
assert "." in self.maildomain, maildomain
self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {}
def get_liveconfig_producer(self):
@@ -287,6 +302,9 @@ class ChatmailTestProcess:
# 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 cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
@@ -297,12 +315,14 @@ class ChatmailTestProcess:
@pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain):
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)
testproc = ChatmailTestProcess(
request.config, maildomain, gencreds, chatmail_config
)
class Data:
def read_path(self, path):
@@ -310,6 +330,10 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
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):
@@ -337,8 +361,14 @@ class Remote:
def iter_output(self, logcmd=""):
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:
@@ -363,38 +393,40 @@ def lp(request):
@pytest.fixture
def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds)
def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds, ssl_context)
class CMSetup:
def __init__(self, maildomain, gencreds):
def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain
self.gencreds = gencreds
self.ssl_context = ssl_context
def gen_users(self, num):
print(f"Creating {num} online users")
users = []
for i in range(num):
addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password)
user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp
users.append(user)
return users
class CMUser:
def __init__(self, maildomain, addr, password):
def __init__(self, maildomain, addr, password, ssl_context=None):
self.maildomain = maildomain
self.addr = addr
self.password = password
self.ssl_context = ssl_context
self._smtp = None
self._imap = None
@property
def smtp(self):
if not self._smtp:
handle = SmtpConn(self.maildomain)
handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
handle.connect()
handle.login(self.addr, self.password)
self._smtp = handle
@@ -403,7 +435,7 @@ class CMUser:
@property
def imap(self):
if not self._imap:
imap = ImapConn(self.maildomain)
imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
imap.connect()
imap.login(self.addr, self.password)
self._imap = imap

View File

@@ -91,6 +91,16 @@ class TestPerformInitialChecks:
assert not res
assert len(l) == 2
def test_perform_initial_checks_no_mta_sts_self_signed(self, mockdns):
del mockdns["CNAME"]["mta-sts.some.domain"]
remote_data = remote.rdns.perform_initial_checks("some.domain")
assert not remote_data["MTA_STS"]
l = []
res = check_initial_remote_data(remote_data, strict_tls=False, print=l.append)
assert res
assert not l
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"):

View File

@@ -1,262 +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.
Known limitations
-----------------
- Requires cgroups v2 on the host. Operation with cgroups v1 has not been tested.
- This preliminary image simply wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a full Debian-systemd image.
- Currently, the image has only been tested and built on amd64, though arm64 should theoretically work as well.
Prerequisites
-------------
- **Docker Compose v2** (``docker compose``, not ``docker-compose``) is
required for its ``cgroup: host`` support (`Install instructions <https://docs.docker.com/engine/install/debian/#install-using-the-repository>`_:)
- **DNS records** for your domain (see step 1 below).
- **Kernel parameters**``fs.inotify.max_user_instances`` and
``fs.inotify.max_user_watches`` must be raised on the host because they
cannot be changed inside the container (see step 2 below).
Preliminary setup
-----------------
We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain.
1. 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.
2. 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 https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker-compose.override.yaml.example
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/env.example -O .env
- or clone the chatmail repo ::
git clone https://github.com/chatmail/relay
cd relay
cp example.env .env
Customize and start
^^^^^^^^^^^^^^^^^^^
1. 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. Copy
the example to get started::
cp docker/docker-compose.override.yaml.example docker-compose.override.yaml
# and edit docker-compose.override.yaml
2. Configure the ``.env`` file. Only ``MAIL_DOMAIN`` is required, the domain
name of the future server.
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).
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.
Managing the server
-------------------
Use ``docker exec`` to run cmdeploy commands inside the container::
# Show required DNS records
docker exec chatmail /opt/cmdeploy/bin/cmdeploy dns --ssh-host @local
# Check server status
docker exec chatmail /opt/cmdeploy/bin/cmdeploy status --ssh-host @local
# Run benchmarks (can also run from any machine with cmdeploy installed)
docker exec chatmail /opt/cmdeploy/bin/cmdeploy bench chat.example.org
Customization
-------------
Custom 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
^^^^^^^^^^^^^^^^^^^
There are two configuration modes:
**Simple (default):** Set ``MAIL_DOMAIN`` in ``.env``. The container
auto-generates ``chatmail.ini`` with defaults on first start. This is
sufficient for most deployments.
**Advanced:** Generate a ``chatmail.ini``, edit it, and mount it into
the container. This gives you full control over all chatmail settings.
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
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/chatmail-dkimkeys data/chatmail-acme data/chatmail
# DKIM keys
cp -a /etc/dkimkeys/* data/chatmail-dkimkeys/
# ACME certificates and account
rsync -a /var/lib/acme/ data/chatmail-acme/
# Mail data
rsync -a /home/ data/chatmail/
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 ``.env``
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

@@ -16,18 +16,11 @@ You will need the following:
- Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root
user of the deployment server.
You must add a passphrase-protected private key to your local ssh-agent because you
cant type in your passphrase during deployment.
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
Setup with ``scripts/cmdeploy``
-------------------------------------
@@ -35,7 +28,7 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server.
1. Setup the initial DNS records for your relay.
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.
@@ -47,29 +40,44 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
2. On your local PC, clone the repository and bootstrap the Python
.. note::
For experimental deployments using self-signed certificates,
use a domain name starting with ``_``
(e.g. ``_chat.example.org``).
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
are not needed for such domains.
2. Login to the server with SSH, clone the repository and bootstrap the Python
virtualenv.
::
ssh root@chat.example.org
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file
3. Then, create a chatmail configuration file
``chatmail.ini``:
::
scripts/cmdeploy init chat.example.org # <-- use your domain
4. Verify that SSH root login to the deployment server server works:
.. note::
::
To use self-signed TLS certificates
instead of Let's Encrypt,
use a domain name starting with ``_``
(e.g. ``scripts/cmdeploy init _chat.example.org``).
Domains starting with ``_`` cannot obtain WebPKI certificates,
so self-signed mode is derived automatically.
This is useful for private or test deployments.
See the :doc:`overview`
for details on certificate provisioning.
ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server:
4. Now run the deployment script to install the relay to the server:
::
@@ -80,33 +88,32 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are
public).
Docker installation
-------------------
Next Steps
----------
There is experimental support for running chatmail via Docker Compose.
See :doc:`docker` for full setup instructions.
Other helpful commands
----------------------
To check the status of your deployment server running the chatmail service:
::
scripts/cmdeploy status
To display and check all recommended DNS records:
Now you should display and check all recommended DNS records
to enable federation with other relays:
::
scripts/cmdeploy dns
To test whether your chatmail service is working correctly:
You should also test whether your chatmail service is working correctly:
::
scripts/cmdeploy test
Other Helpful Commands
----------------------
To check the status of your chatmail relay:
::
scripts/cmdeploy status
To measure the performance of your chatmail service:
::
@@ -147,8 +154,9 @@ This starts a local live development cycle for chatmail web pages:
directory and generating HTML files and copying assets to the
``www/build`` directory.
- Starts a browser window automatically where you can “refresh” as
needed.
- if you are running scripts/cmdeploy webdev on the relay itself,
you need to configure a route in /etc/nginx/nginx.conf
to expose the build directory.
Custom web pages
----------------
@@ -166,7 +174,7 @@ Disable automatic address creation
--------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run:
creating addresses, login with ssh to the relay and run:
::
@@ -175,23 +183,14 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present.
Migrating to a new build machine
----------------------------------
Running a relay with self-signed certificates
----------------------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
Use a domain name starting with ``_`` (e.g. ``_chat.example.org``)
to run a relay with self-signed certificates.
Domains starting with ``_`` cannot obtain WebPKI certificates
so the relay automatically uses self-signed certificates
and all other relays will accept connections from it
without requiring certificate verification.
This is useful for experimental setups and testing.
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.

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

@@ -297,8 +297,7 @@ TLS requirements
Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
to ``verify``. If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
to ``verify``.
You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with
@@ -317,6 +316,14 @@ default Exim does not log sessions that are closed before sending the
by Postfix, so you might think that connection is not established while
actually it is a problem with your TLS certificate.
If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
Note that connections to relays with underscore-prefixed test domains
(e.g. ``_chat.example.org``) use ``encrypt`` tls security level,
because such domains cannot obtain valid Let's Encrypt certificates
and run with self-signed certificates.
.. _dovecot: https://dovecot.org
.. _postfix: https://www.postfix.org

View File

@@ -1,33 +0,0 @@
# Local overrides — copy to docker-compose.override.yaml in the repo root.
# Compose automatically merges this with docker-compose.yaml.
#
# cp docker/docker-compose.override.yaml.example docker-compose.override.yaml
#
# Volumes listed here are APPENDED to the base file's volumes.
# Scalar values (environment, image, etc.) are REPLACED.
services:
chatmail:
volumes:
## Data paths — bind-mount to host directories for easy access/backup.
## Uncomment and adjust paths as needed. These override the named
## volumes in the base docker-compose.yaml.
# - ./data/chatmail:/home/vmail
# - ./data/chatmail-dkimkeys:/etc/dkimkeys
# - ./data/chatmail-acme:/var/lib/acme
## Or mount data from an existing bare-metal install.
## Note: DKIM key ownership is fixed automatically on startup
## (the host's opendkim UID may differ from the container's).
# - /home/vmail:/home/vmail
# - /etc/dkimkeys:/etc/dkimkeys
# - /var/lib/acme:/var/lib/acme
## 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

View File

@@ -1,51 +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 only
# privileged: true # compose v1 (not tested)
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
CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-}
CHATMAIL_NOSYSCTL: ${CHATMAIL_NOSYSCTL:-True}
CHATMAIL_NOPORTCHECK: ${CHATMAIL_NOPORTCHECK:-True}
CHATMAIL_NOACME: ${CHATMAIL_NOACME:-}
network_mode: "host"
volumes:
## system (required)
- /sys/fs/cgroup:/sys/fs/cgroup:rw
## data (defaults — override in docker-compose.override.yaml)
- chatmail-data:/home/vmail
- chatmail-dkimkeys:/etc/dkimkeys
- chatmail-acme:/var/lib/acme
volumes:
chatmail-data:
chatmail-dkimkeys:
chatmail-acme:

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,105 +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 CHATMAIL_INI=/etc/chatmail/chatmail.ini
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
# Certificate monitoring as a proper systemd timer (not a background process)
COPY --chmod=555 ./docker/files/chatmail-certmon.sh /chatmail-certmon.sh
COPY ./docker/files/chatmail-certmon.service /lib/systemd/system/chatmail-certmon.service
COPY ./docker/files/chatmail-certmon.timer /lib/systemd/system/chatmail-certmon.timer
RUN ln -sf /lib/systemd/system/chatmail-certmon.timer /etc/systemd/system/timers.target.wants/chatmail-certmon.timer
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,116 +0,0 @@
# Traefik reverse proxy + cert manager for chatmail.
# Use this instead of docker-compose.yaml when Traefik manages TLS certificates.
#
# Required .env vars:
# MAIL_DOMAIN=chat.example.com
# ACME_EMAIL=admin@example.com
#
# Usage:
# cp docker/example-traefik.env .env
# docker compose -f docker/docker-compose-traefik.yaml build
# docker compose -f docker/docker-compose-traefik.yaml up -d
services:
chatmail:
build:
context: ../
dockerfile: docker/chatmail_relay.dockerfile
image: chatmail-relay:latest
restart: unless-stopped
container_name: chatmail
depends_on:
traefik-certs-dumper:
condition: service_started
cgroup: host
tty: true
tmpfs:
- /tmp
- /run
- /run/lock
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
MAIL_DOMAIN: $MAIL_DOMAIN
CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-}
CHATMAIL_NOACME: "true"
PATH_TO_SSL: /var/lib/acme/live/${MAIL_DOMAIN}
ports:
- "25:25"
- "143:143"
- "465:465"
- "587:587"
- "993:993"
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
- chatmail-data:/home
- chatmail-dkimkeys:/etc/dkimkeys
- traefik-certs:/var/lib/acme/live:ro
labels:
- traefik.enable=true
- traefik.http.services.chatmail.loadbalancer.server.scheme=https
- traefik.http.services.chatmail.loadbalancer.server.port=443
- traefik.http.services.chatmail.loadbalancer.serverstransport=insecure@file
- traefik.http.routers.chatmail.rule=Host(`${MAIL_DOMAIN}`) || Host(`mta-sts.${MAIL_DOMAIN}`) || Host(`www.${MAIL_DOMAIN}`)
- traefik.http.routers.chatmail.tls=true
- traefik.http.routers.chatmail.tls.certresolver=letsEncrypt
traefik:
image: traefik:v3.3
container_name: traefik
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
command:
- "--configFile=/config.yaml"
- "--certificatesresolvers.letsEncrypt.acme.email=${ACME_EMAIL}"
network_mode: host
depends_on:
traefik-init:
condition: service_completed_successfully
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/config.yaml:/config.yaml:ro
- traefik-data:/data
- ./traefik/dynamic-configs:/dynamic/conf:ro
traefik-init:
image: alpine:latest
restart: "no"
entrypoint: sh -c 'touch /data/acme.json && chmod 600 /data/acme.json'
volumes:
- traefik-data:/data
traefik-certs-dumper:
image: ldez/traefik-certs-dumper:v2.10.0
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
depends_on:
- traefik
entrypoint: sh -c '
apk add openssl
&& while ! [ -e /data/acme.json ] || ! [ "$$(jq ".[] | .Certificates | length" /data/acme.json | jq -s "add")" != "0" ]; do
sleep 1
; done
&& traefik-certs-dumper file --version v3 --watch --domain-subdir=true
--source /data/acme.json --dest /certs --post-hook "sh /post-hook.sh"'
volumes:
- traefik-data:/data:ro
- traefik-certs:/certs
- ./traefik/post-hook.sh:/post-hook.sh:ro
volumes:
chatmail-data:
chatmail-dkimkeys:
traefik-data:
traefik-certs:

View File

@@ -1,5 +0,0 @@
MAIL_DOMAIN="chat.example.com"
ACME_EMAIL="admin@example.com"
# CMDEPLOY_STAGES - default: "configure,activate". Set to "install,configure,activate" to force full reinstall.
# CMDEPLOY_STAGES="configure,activate"

View File

@@ -1,8 +0,0 @@
[Unit]
Description=Check TLS certificate changes and reload services
After=setup_chatmail.service
[Service]
Type=oneshot
ExecStart=/bin/bash /chatmail-certmon.sh
PassEnvironment=MAIL_DOMAIN PATH_TO_SSL

View File

@@ -1,28 +0,0 @@
#!/bin/bash
# Check if TLS certificates have changed and reload services if so.
# Called by chatmail-certmon.timer (systemd timer, default every 60s).
set -eo pipefail
PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}"
HASH_FILE="/run/chatmail-certmon.hash"
if [ ! -d "$PATH_TO_SSL" ]; then
exit 0
fi
current_hash=$(find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}')
previous_hash=""
if [ -f "$HASH_FILE" ]; then
previous_hash=$(cat "$HASH_FILE")
fi
if [ -n "$current_hash" ] && [ "$current_hash" != "$previous_hash" ]; then
echo "[INFO] Certificate hash changed, reloading nginx, dovecot and postfix."
echo "$current_hash" > "$HASH_FILE"
# On first run (no previous hash), don't reload — services may not be up yet
if [ -n "$previous_hash" ]; then
systemctl reload nginx.service
systemctl reload dovecot.service
systemctl reload postfix.service
fi
fi

View File

@@ -1,9 +0,0 @@
[Unit]
Description=Periodically check TLS certificate changes
[Timer]
OnBootSec=120
OnUnitActiveSec=60
[Install]
WantedBy=timers.target

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 CHATMAIL_NOSYSCTL CHATMAIL_NOPORTCHECK CHATMAIL_NOACME PATH_TO_SSL 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,54 +0,0 @@
#!/bin/bash
set -euo pipefail
export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}"
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
# --- 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,30 +0,0 @@
log:
level: INFO
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
permanent: true
websecure:
address: ":443"
providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
directory: /dynamic/conf
watch: true
certificatesResolvers:
letsEncrypt:
acme:
storage: /data/acme.json
caServer: "https://acme-v02.api.letsencrypt.org/directory"
tlschallenge: true
httpChallenge:
entryPoint: web

View File

@@ -1,4 +0,0 @@
http:
serversTransports:
insecure:
insecureSkipVerify: true

View File

@@ -1,12 +0,0 @@
#!/bin/sh
# Post-hook for traefik-certs-dumper: create symlinks from Traefik's
# cert dump format to the paths chatmail expects (fullchain, privkey).
CERTS_DIR="${CERTS_DIR:-/certs}"
for dir in "$CERTS_DIR"/*/; do
[ -d "$dir" ] || continue
cd "$dir"
[ -f "certificate.crt" ] && ln -sf certificate.crt fullchain
[ -f "privatekey.key" ] && ln -sf privatekey.key privkey
cd - > /dev/null
done

View File

@@ -1,7 +0,0 @@
MAIL_DOMAIN="chat.example.com"
# CMDEPLOY_STAGES - default: "configure,activate". Set to "install,configure,activate" to force full reinstall.
# CMDEPLOY_STAGES="configure,activate"
# Skip acmetool when using an external certificate manager (e.g. Traefik, Caddy).
# CHATMAIL_NOACME="True"

21
www/src/dclogin.js Normal file
View File

@@ -0,0 +1,21 @@
/* dclogin profile generator for self-signed chatmail relays.
* Fetches credentials from /new and generates a dclogin: QR code.
* Requires qrcode-svg.min.js to be loaded first.
*/
(function () {
function generateProfile() {
fetch('/new')
.then(function (r) { return r.json(); })
.then(function (data) {
var url = data.dclogin_url;
var link = document.getElementById('dclogin-link');
link.href = url;
var qrLink = document.getElementById('qr-link');
qrLink.href = url;
var qrCode = document.getElementById('qr-code');
var qr = new QRCode({ content: url, width: 300, height: 300, padding: 1, join: true });
qrCode.innerHTML = qr.svg();
});
}
generateProfile();
})();

View File

@@ -11,6 +11,18 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html).
{% endif %}
{% if config.tls_cert_mode == "self" %}
<a class="cta-button" id="dclogin-link" href="#">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
without a Delta Chat app,
you can also **scan this QR code** with Delta Chat:
<a id="qr-link" href="#"><div id="qr-code"></div></a>
<script src="qrcode-svg.min.js"></script>
<script src="dclogin.js"></script>
{% else %}
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
@@ -19,6 +31,7 @@ you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
{% endif %}
🐣 **Choose** your Avatar and Name

9
www/src/qrcode-svg.min.js vendored Normal file

File diff suppressed because one or more lines are too long