Compare commits

..

15 Commits

Author SHA1 Message Date
j4n
07938544a1 docker: trim compose override example 2026-02-20 17:02:34 +01:00
j4n
3cc74a4c9a docker: get rid of CHATMAIL_* in compose 2026-02-20 16:56:05 +01:00
j4n
77676a4e87 docker: streamline overrides, rename datadirs, external TLS 2026-02-20 16:38:35 +01:00
j4n
dc2a6fda05 docker: migrate to new external tls logic
- remove all traces of CHATMAIL_NOACME; purge certwatch service
- introduce TLS_EXTERNAL_CERT_AND_KEY as per new logic
2026-02-20 10:00:44 +01:00
j4n
d9dce2ccee Merge remote-tracking branch 'origin/hpk/tls-external' into j4n/docker-traefik 2026-02-19 21:04:21 +01:00
j4n
fcfc2cca1a fix(docker): remove CHATMAIL_INI from env 2026-02-19 20:41:18 +01:00
j4n
beb4041e3f fix(docker): Add TZ to env 2026-02-19 20:36:51 +01:00
holger krekel
da3d726fb1 feat: support externally managed TLS via tls_external_cert_and_key option
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-19 19:49:53 +01:00
j4n
854b7ef368 typo 2026-02-19 16:03:41 +01:00
j4n
7e30bafd57 docker: clear up docker compose v1/v2 differences (doc/compose.yaml) 2026-02-19 16:03:41 +01:00
j4n
3ef59c3def feat: add Docker and Compose support
Add Docker-based deployment: Dockerfile based on systemd image,
docker-compose.yaml, build script, entrypoint, external certificate
monitoring, CI workflow, and documentation.

This builds on the chatmaild/cmdeploy preparation in the previous
commit (j4n/docker-prep-chatmail) which added the env-var-driven
feature flags (CHATMAIL_NOSYSCTL, CHATMAIL_NOPORTCHECK, CHATMAIL_NOACME)
and @local deployment support needed by the container.

This is commit 2 of 3 to merge squashed changes on j4n/docker and docker
branches, original commits were beef0ec..606f36e

Architecture overview (mostly by original author Keonik1):
- Debian-systemd image wrapping the existing cmdeploy install
- Host networking to not manually expose the many ports needed
- Config via MAIL_DOMAIN env var or (new) mounted chatmail.ini
- New: cmdeploy stages: install at build, configure+activate at startup
- New: Monitoring service for external certs via systemd timer (chatmail-certmon)
- New: Image version tracking for automatic upgrade detection (cm + config hash)
- New: docker-compose.override.yaml pattern for user customizations
- New: GitHub Actions CI for ghcr.io image builds

Traefik reverse-proxy support is prepared but the specific files are
excluded from this PR and will be submitted separately.

TODO:
- [ ] Pull out CHATMAIL_NOACME as PR #855 introduced a proper mechanism
- [ ] Check if underlying image could be based on regular debian-slim
  images with a step to enable systemd, similar to
  https://github.com/alexdzyoba/docker-debian-systemd

Files added:
  .dockerignore
  .github/workflows/docker-build.yaml
  docker-compose.yaml
  docker-compose.override.yaml.example
  docker/build.sh
  docker/chatmail_relay.dockerfile
  docker/files/chatmail-certmon.{service,sh,timer}
  docker/files/entrypoint.sh
  docker/files/setup_chatmail.service
  docker/files/setup_chatmail_docker.sh
  env.example
  doc/source/docker.rst

Files modified:
  .gitignore
  doc/source/getting_started.rst
  doc/source/index.rst

Co-authored-by: Keonik1 <keonik.dev@gmail.com>
Co-authored-by: missytake <missytake@systemli.org>
2026-02-19 16:03:41 +01:00
j4n
a7b3893fee 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-19 16:03:41 +01:00
j4n
58fa5e5c98 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-19 16:03:39 +01: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
50 changed files with 1147 additions and 416 deletions

View File

@@ -75,8 +75,7 @@ jobs:
cmdeploy init staging-ipv4.testrun.org cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
cmdeploy run --verbose --skip-dns-check
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries - name: set DNS entries
run: | run: |

View File

@@ -0,0 +1,37 @@
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

View File

@@ -121,13 +121,6 @@
Provide an "fsreport" CLI for more fine grained analysis of message files. Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/637)) ([#637](https://github.com/chatmail/relay/pull/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 ## 1.7.0 2025-09-11

View File

@@ -44,7 +44,6 @@ class Config:
) )
self.mtail_address = params.get("mtail_address") self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true" 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_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "") self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "") self.acme_email = params.get("acme_email", "")
@@ -61,6 +60,32 @@ class Config:
self.privacy_pdo = params.get("privacy_pdo") self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.get("privacy_supervisor") self.privacy_supervisor = params.get("privacy_supervisor")
# TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates
# - All other domains use ACME.
external = params.get("tls_external_cert_and_key", "").strip()
if external:
parts = external.split()
if len(parts) != 2:
raise ValueError(
"tls_external_cert_and_key must have two space-separated"
" paths: CERT_PATH KEY_PATH"
)
self.tls_cert_mode = "external"
self.tls_cert_path = parts[0]
self.tls_key_path = parts[1]
elif 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 # deprecated option
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}") mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
self.mailboxes_dir = Path(mbdir.strip()) self.mailboxes_dir = Path(mbdir.strip())

View File

@@ -48,6 +48,13 @@ passthrough_senders =
# (space-separated, item may start with "@" to whitelist whole recipient domains) # (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients = passthrough_recipients =
# Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine).
# Both files must already exist before running cmdeploy.
# Certificate renewal is your responsibility; changed files are
# picked up automatically by all relay services.
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages # path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
#www_folder = www #www_folder = www

View File

@@ -6,6 +6,7 @@ import json
import random import random
import secrets import secrets
import string import string
from urllib.parse import quote
from chatmaild.config import Config, read_config 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}") 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(): def print_new_account():
config = read_config(CONFIG_PATH) config = read_config(CONFIG_PATH)
creds = create_newemail_dict(config) 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("Content-Type: application/json")
print("") print("")
print(json.dumps(creds)) print(json.dumps(result))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -73,3 +73,50 @@ def test_config_userstate_paths(make_config, tmp_path):
def test_config_max_message_size(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")) config = make_config("something.testrun.org", dict(max_message_size="10000"))
assert config.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"
def test_config_tls_external(make_config):
config = make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/custom/fullchain.pem"
assert config.tls_key_path == "/custom/privkey.pem"
def test_config_tls_external_overrides_underscore(make_config):
config = make_config(
"_test.example.org",
{
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
},
)
assert config.tls_cert_mode == "external"
assert config.tls_cert_path == "/certs/fullchain.pem"
def test_config_tls_external_bad_format(make_config):
with pytest.raises(ValueError, match="two space-separated"):
make_config(
"chat.example.org",
{
"tls_external_cert_and_key": "/only/one/path.pem",
},
)

View File

@@ -1,9 +1,15 @@
import shutil
import smtplib import smtplib
import subprocess import subprocess
import sys import sys
import pytest import pytest
pytestmark = pytest.mark.skipif(
shutil.which("filtermail") is None,
reason="filtermail binary not found",
)
@pytest.fixture @pytest.fixture
def smtpserver(): def smtpserver():
@@ -57,19 +63,10 @@ def test_one_mail(
path = str(config._inipath) path = str(config._inipath)
popen = make_popen(["filtermail", path, filtermail_mode]) popen = make_popen(["filtermail", path, filtermail_mode])
line = popen.stderr.readline().strip()
# Wait for filtermail to start accepting connections if b"loop" not in line:
import socket print(line.decode("ascii"), file=sys.stderr)
import time pytest.fail("starting filtermail failed")
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")
addr = f"user1@{config.mail_domain}" addr = f"user1@{config.mail_domain}"
config.get_user(addr).set_password("l1k2j3l1k2j3l") config.get_user(addr).set_password("l1k2j3l1k2j3l")

View File

@@ -1,7 +1,11 @@
import json import json
import chatmaild 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): def test_create_newemail_dict(example_config):
@@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] 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): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath)) monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
print_new_account() print_new_account()
@@ -25,3 +41,20 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
dic = json.loads(lines[2]) dic = json.loads(lines[2])
assert dic["email"].endswith(f"@{example_config.mail_domain}") assert dic["email"].endswith(f"@{example_config.mail_domain}")
assert len(dic["password"]) >= 10 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

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

View File

@@ -91,9 +91,10 @@ def run_cmd(args, out):
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.mail_domain
sshexec = get_sshexec(ssh_host) sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
strict_tls = args.config.tls_cert_mode == "acme"
if not args.dns_check_disabled: if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) 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 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -127,7 +128,7 @@ def run_cmd(args, out):
out.red("Website deployment failed.") out.red("Website deployment failed.")
elif retcode == 0: elif retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.") 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("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
retcode = 0 retcode = 0
@@ -154,11 +155,13 @@ def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """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.mail_domain
sshexec = get_sshexec(ssh_host, verbose=args.verbose) 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) 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 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'") out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1 return 1
@@ -166,6 +169,7 @@ def dns_cmd(args, out):
out.red("could not determine dkim_entry, please run 'cmdeploy run'") out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1 return 1
remote_data["strict_tls"] = strict_tls
zonefile = dns.get_filled_zone_file(remote_data) zonefile = dns.get_filled_zone_file(remote_data)
if args.zonefile: if args.zonefile:

View File

@@ -20,6 +20,7 @@ from pyinfra.operations import apt, files, pip, server, systemd
from cmdeploy.cmdeploy import Out from cmdeploy.cmdeploy import Out
from .acmetool import AcmetoolDeployer from .acmetool import AcmetoolDeployer
from .external.deployer import ExternalTlsDeployer
from .basedeploy import ( from .basedeploy import (
Deployer, Deployer,
Deployment, Deployment,
@@ -34,6 +35,7 @@ from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
from .postfix.deployer import PostfixDeployer from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer
from .www import build_webpages, find_merge_conflict, get_paths from .www import build_webpages, find_merge_conflict, get_paths
@@ -539,6 +541,20 @@ class GithashDeployer(Deployer):
) )
def get_tls_deployer(config, mail_domain):
"""Select the appropriate TLS deployer based on config."""
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
if config.tls_cert_mode == "acme":
return AcmetoolDeployer(config.acme_email, tls_domains)
elif config.tls_cert_mode == "self":
return SelfSignedTlsDeployer(mail_domain)
elif config.tls_cert_mode == "external":
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
else:
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None: def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
"""Deploy a chat-mail instance. """Deploy a chat-mail instance.
@@ -574,7 +590,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
("acmetool", 80), ]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 80))
port_services += [
(["imap-login", "dovecot"], 143), (["imap-login", "dovecot"], 143),
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
@@ -600,7 +619,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
@@ -610,12 +629,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(mail_domain), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
] tls_deployer,
if not config.noacme:
all_deployers.append(AcmetoolDeployer(config.acme_email, tls_domains))
all_deployers += [
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), 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"] mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]: if not remote_data["A"] and not remote_data["AAAA"]:
print(f"Missing A and/or AAAA DNS records for {mail_domain}!") 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("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.") 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("Missing www CNAME record:")
print(f"www.{mail_domain}. CNAME {mail_domain}.") print(f"www.{mail_domain}. CNAME {mail_domain}.")
else: else:

View File

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

View File

@@ -0,0 +1,69 @@
from pyinfra.operations import files, server, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class ExternalTlsDeployer(Deployer):
"""Expects TLS certificates to be managed on the server.
Validates that the configured certificate and key files
exist on the remote host. Installs a systemd path unit
that watches the certificate file and automatically
restarts/reloads affected services when it changes.
"""
def __init__(self, cert_path, key_path):
self.cert_path = cert_path
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}",
],
)
# Deploy the .path unit (templated with the cert path).
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),
dest="/etc/systemd/system/tls-cert-reload.path",
user="root",
group="root",
mode="644",
)
service_unit = files.put(
name="Upload tls-cert-reload.service",
src=get_resource("tls-cert-reload.service", pkg=__package__),
dest="/etc/systemd/system/tls-cert-reload.service",
user="root",
group="root",
mode="644",
)
if path_unit.changed or service_unit.changed:
self.need_restart = True
def activate(self):
systemd.service(
name="Enable tls-cert-reload path watcher",
service="tls-cert-reload.path",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=self.need_restart,
)
# 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"],
)

View File

@@ -0,0 +1,11 @@
# 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.
[Unit]
Description=Watch TLS certificate for changes
[Path]
PathChanged={cert_path}
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,15 @@
# Reload services that cache the TLS certificate.
#
# dovecot: caches the cert at startup; reload re-reads SSL certs
# without dropping existing connections.
# nginx: caches the cert at startup; reload gracefully picks up
# the new cert for new connections.
# postfix: reads the cert fresh on each TLS handshake,
# does NOT need a reload/restart.
[Unit]
Description=Reload TLS services after certificate change
[Service]
Type=oneshot
ExecStart=/bin/systemctl reload dovecot
ExecStart=/bin/systemctl reload nginx

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import shlex
from pyinfra.operations import apt, server
from cmdeploy.basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"""Return the openssl argument list for a self-signed certificate.
The certificate uses an EC P-256 key with SAN entries for *domain*,
``www.<domain>`` and ``mta-sts.<domain>``.
"""
return [
"openssl", "req", "-x509",
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:P-256",
"-noenc", "-days", str(days),
"-keyout", str(key_path),
"-out", str(cert_path),
"-subj", f"/CN={domain}",
"-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
]
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):
args = openssl_selfsigned_args(
self.mail_domain, self.cert_path, self.key_path,
)
cmd = shlex.join(args)
server.shell(
name="Generate self-signed TLS certificate if not present",
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
)
def activate(self):
pass

View File

@@ -1,3 +1,4 @@
import pytest
import requests import requests
from cmdeploy.genqr import gen_qr_png_data from cmdeploy.genqr import gen_qr_png_data
@@ -8,18 +9,33 @@ def test_gen_qr_png_data(maildomain):
assert data assert data
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_fastcgi_working(maildomain, chatmail_config): def test_fastcgi_working(maildomain, chatmail_config):
url = f"https://{maildomain}/new" url = f"https://{maildomain}/new"
print(url) 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 maildomain in res.json().get("email")
assert len(res.json().get("password")) > chatmail_config.password_min_length 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.""" """Test configuring accounts by scanning a QR code works."""
url = f"DCACCOUNT:https://{maildomain}/new" url = f"DCACCOUNT:https://{maildomain}/new"
for i in range(3): for i in range(3):
account_id = rpc.add_account() account_id = rpc.add_account()
rpc.set_config_from_qr(account_id, url) if chatmail_config.tls_cert_mode == "self":
rpc.configure(account_id) # 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

@@ -11,11 +11,12 @@ from cmdeploy.sshexec import SSHExec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") 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.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
return mailbox return mailbox
@@ -171,7 +172,7 @@ class TestEndToEndDeltaChat:
time.sleep(1) 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() public_ip = requests.get("http://icanhazip.com").content.decode().strip()
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
@@ -180,6 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
user2.direct_imap.select_folder("Inbox") addr = user2.get_config("addr")
msg = user2.direct_imap.get_all_messages()[0] host = addr.split("@")[1]
assert public_ip not in msg.obj.as_string() 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

@@ -4,6 +4,7 @@ import itertools
import os import os
import random import random
import smtplib import smtplib
import ssl
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -144,15 +145,25 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line) tr.write_line(line)
@pytest.fixture @pytest.fixture(scope="session")
def imap(maildomain): def ssl_context(chatmail_config):
return ImapConn(maildomain) 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 @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(): def make_imap_connection():
conn = ImapConn(maildomain) conn = ImapConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -164,12 +175,13 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot" logcmd = "journalctl -f -u dovecot"
name = "dovecot" name = "dovecot"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"imap-connect {self.host}") 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): def login(self, user, password):
print(f"imap-login {user!r} {password!r}") print(f"imap-login {user!r} {password!r}")
@@ -195,14 +207,14 @@ class ImapConn:
@pytest.fixture @pytest.fixture
def smtp(maildomain): def smtp(maildomain, ssl_context):
return SmtpConn(maildomain) return SmtpConn(maildomain, ssl_context=ssl_context)
@pytest.fixture @pytest.fixture
def make_smtp_connection(maildomain): def make_smtp_connection(maildomain, ssl_context):
def make_smtp_connection(): def make_smtp_connection():
conn = SmtpConn(maildomain) conn = SmtpConn(maildomain, ssl_context=ssl_context)
conn.connect() conn.connect()
return conn return conn
@@ -214,12 +226,14 @@ class SmtpConn:
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp" logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
name = "postfix" name = "postfix"
def __init__(self, host): def __init__(self, host, ssl_context=None):
self.host = host self.host = host
self.ssl_context = ssl_context
def connect(self): def connect(self):
print(f"smtp-connect {self.host}") 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): def login(self, user, password):
print(f"smtp-login {user!r} {password!r}") print(f"smtp-login {user!r} {password!r}")
@@ -270,11 +284,12 @@ def gencreds(chatmail_config):
class ChatmailTestProcess: class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory""" """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.pytestconfig = pytestconfig
self.maildomain = maildomain self.maildomain = maildomain
assert "." in self.maildomain, maildomain assert "." in self.maildomain, maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config
self._addr2files = {} self._addr2files = {}
def get_liveconfig_producer(self): def get_liveconfig_producer(self):
@@ -287,6 +302,9 @@ class ChatmailTestProcess:
# speed up account configuration # speed up account configuration
config["mail_server"] = self.maildomain config["mail_server"] = self.maildomain
config["send_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 yield config
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path): def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
@@ -297,12 +315,14 @@ class ChatmailTestProcess:
@pytest.fixture @pytest.fixture
def cmfactory(request, gencreds, tmpdir, maildomain): def cmfactory(request, gencreds, tmpdir, maildomain, chatmail_config):
# cloned from deltachat.testplugin.amfactory # cloned from deltachat.testplugin.amfactory
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(
request.config, maildomain, gencreds, chatmail_config
)
class Data: class Data:
def read_path(self, path): 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()) 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 # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2): def switch_maildomain(maildomain2):
@@ -363,38 +387,40 @@ def lp(request):
@pytest.fixture @pytest.fixture
def cmsetup(maildomain, gencreds): def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds) return CMSetup(maildomain, gencreds, ssl_context)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds): def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain self.maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.ssl_context = ssl_context
def gen_users(self, num): def gen_users(self, num):
print(f"Creating {num} online users") print(f"Creating {num} online users")
users = [] users = []
for i in range(num): for i in range(num):
addr, password = self.gencreds() addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password) user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp assert user.smtp
users.append(user) users.append(user)
return users return users
class CMUser: class CMUser:
def __init__(self, maildomain, addr, password): def __init__(self, maildomain, addr, password, ssl_context=None):
self.maildomain = maildomain self.maildomain = maildomain
self.addr = addr self.addr = addr
self.password = password self.password = password
self.ssl_context = ssl_context
self._smtp = None self._smtp = None
self._imap = None self._imap = None
@property @property
def smtp(self): def smtp(self):
if not self._smtp: if not self._smtp:
handle = SmtpConn(self.maildomain) handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
handle.connect() handle.connect()
handle.login(self.addr, self.password) handle.login(self.addr, self.password)
self._smtp = handle self._smtp = handle
@@ -403,7 +429,7 @@ class CMUser:
@property @property
def imap(self): def imap(self):
if not self._imap: if not self._imap:
imap = ImapConn(self.maildomain) imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
imap.connect() imap.connect()
imap.login(self.addr, self.password) imap.login(self.addr, self.password)
self._imap = imap self._imap = imap

View File

@@ -0,0 +1,362 @@
"""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

@@ -91,6 +91,16 @@ class TestPerformInitialChecks:
assert not res assert not res
assert len(l) == 2 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): def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
for zf_line in zonefile.split("\n"): for zf_line in zonefile.split("\n"):

View File

@@ -0,0 +1,78 @@
"""Functional tests for tls_external_cert_and_key option."""
import json
import chatmaild.newemail
import pytest
from chatmaild.config import read_config, write_initial_config
def make_external_config(tmp_path, cert_key=None):
inipath = tmp_path / "chatmail.ini"
overrides = {}
if cert_key is not None:
overrides["tls_external_cert_and_key"] = cert_key
write_initial_config(inipath, "chat.example.org", overrides=overrides)
return inipath
def test_external_tls_config_reads_paths(tmp_path):
inipath = make_external_config(
tmp_path,
cert_key=(
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
),
)
config = read_config(inipath)
assert config.tls_cert_mode == "external"
assert (
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
)
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
def test_external_tls_missing_option_uses_acme(tmp_path):
config = read_config(make_external_config(tmp_path))
assert config.tls_cert_mode == "acme"
def test_external_tls_bad_format_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_three_paths_raises(tmp_path):
inipath = make_external_config(tmp_path, cert_key="/a /b /c")
with pytest.raises(ValueError, match="two space-separated"):
read_config(inipath)
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
chatmaild.newemail.print_new_account()
out, _ = capsys.readouterr()
lines = out.split("\n")
dic = json.loads(lines[2])
assert "dclogin_url" not in dic
def test_external_tls_selects_correct_deployer(tmp_path):
from cmdeploy.deployers import get_tls_deployer
from cmdeploy.external.deployer import ExternalTlsDeployer
from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer
inipath = make_external_config(
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
)
config = read_config(inipath)
deployer = get_tls_deployer(config, "chat.example.org")
assert isinstance(deployer, ExternalTlsDeployer)
assert not isinstance(deployer, SelfSignedTlsDeployer)
assert deployer.cert_path == "/certs/fullchain.pem"
assert deployer.key_path == "/certs/privkey.pem"

View File

@@ -6,37 +6,24 @@ using Docker Compose.
.. note:: .. note::
Docker support is experimental and not yet covered by automated tests, please report bugs. - 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.
Known limitations Setup Preparation
-----------------
- 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 We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records. 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 The following is an example in the familiar BIND zone file format with
a TTL of 1 hour (3600 seconds). a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
@@ -48,7 +35,7 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.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:: 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_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 echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
@@ -73,38 +60,32 @@ Either:
- Create a service directory, e.g., `/srv/chatmail-relay`:: - Create a service directory, e.g., `/srv/chatmail-relay`::
mkdir -p /srv/chatmail-relay && cd /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-compose.yaml
wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/env.example -O .env 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 :: - or clone the chatmail repo ::
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
cp example.env .env
Customize and start Customize and start
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
1. All local customizations (data paths, extra volumes, config mounts) go in 1. Set the fully qualified domain name of the relay::
``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 echo 'MAIL_DOMAIN=chat.example.org' > .env
# 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 The container generates a ``chatmail.ini`` with defaults from
``MAIL_DOMAIN`` on first start. To customize chatmail settings, mount ``MAIL_DOMAIN`` on first start. To customize chatmail settings, mount
your own ``chatmail.ini`` instead (see `Custom chatmail.ini`_ below). 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:: 3. Start the container::
docker compose up -d docker compose up -d
@@ -113,26 +94,36 @@ Customize and start
4. After installation is complete, open ``https://chat.example.org`` in 4. After installation is complete, open ``https://chat.example.org`` in
your browser. your browser.
Finish install and test
-----------------------
Managing the server You can test the installation with::
-------------------
Use ``docker exec`` to run cmdeploy commands inside the container:: 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 # Show required DNS records
docker exec chatmail /opt/cmdeploy/bin/cmdeploy dns --ssh-host @local docker exec chatmail /opt/cmdeploy/bin/cmdeploy dns --ssh-host @local
# Check server status You can check server status with::
docker exec chatmail /opt/cmdeploy/bin/cmdeploy status --ssh-host @local docker exec chatmail /opt/cmdeploy/bin/cmdeploy status --ssh-host @local
# Run benchmarks (can also run from any machine with cmdeploy installed) 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 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 Customization
------------- -------------
Custom website Website
^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^
You can customize the chatmail landing page by mounting a directory with You can customize the chatmail landing page by mounting a directory with
@@ -159,14 +150,8 @@ your own website source files.
Custom chatmail.ini Custom chatmail.ini
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
There are two configuration modes: 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.
**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:: 1. Extract the generated config from a running container::
@@ -186,6 +171,16 @@ the container. This gives you full control over all chatmail settings.
docker compose down && docker compose up -d 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 Migrating from a bare-metal install
------------------------------------ ------------------------------------
@@ -208,16 +203,19 @@ switch to Docker:
3. Copy persistent data into the ``./data/`` subdirectories (for example, as configured in `Customize and start`_) :: 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 mkdir -p data/dkim data/certs data/mail
# DKIM keys # DKIM keys
cp -a /etc/dkimkeys/* data/chatmail-dkimkeys/ cp -a /etc/dkimkeys/* data/dkim/
# ACME certificates and account # TLS certificates
rsync -a /var/lib/acme/ data/chatmail-acme/ rsync -a /var/lib/acme/ data/certs/
# Mail data Note that ownership of dkim and acme is adjusted on container start.
rsync -a /home/ data/chatmail/
For the mail directory::
rsync -a /home/vmail/ data/mail/
Alternatively, mount ``/home/vmail`` directly by changing the volume Alternatively, mount ``/home/vmail`` directly by changing the volume
in ``docker-compose-override.yaml``:: in ``docker-compose-override.yaml``::
@@ -238,8 +236,8 @@ Clone the repository and build the Docker image::
docker compose build chatmail docker compose build chatmail
The build bakes all binaries, Python packages, and the install stage The build bakes all binaries, Python packages, and the install stage
into the image. After building, only ``docker-compose.yaml`` and ``.env`` into the image. After building, only ``docker-compose.yaml`` and a ``.env`` with
are needed to run the container. ``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) :: You can transfer a locally built image to your server directly (pigz is parallel `gzip` which can be used instead as well) ::

View File

@@ -47,6 +47,14 @@ steps. Please substitute it with your own domain.
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.org. 3600 IN CNAME chat.example.org.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
.. 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. On your local PC, clone the repository and bootstrap the Python 2. On your local PC, clone the repository and bootstrap the Python
virtualenv. virtualenv.
@@ -63,6 +71,16 @@ steps. Please substitute it with your own domain.
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
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.
4. Verify that SSH root login to the deployment server server works: 4. Verify that SSH root login to the deployment server server works:
:: ::
@@ -175,6 +193,51 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
Running a relay with self-signed certificates
----------------------------------------------
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.
.. _external-tls:
Running a relay with externally managed certificates
-----------------------------------------------------
If you already have a TLS certificate manager
(e.g. Traefik, certbot, or another ACME client)
running on the deployment server,
you can configure the relay to use those certificates
instead of the built-in ``acmetool``.
Set the following in ``chatmail.ini``::
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
The paths must point to certificate and key files
on the deployment server.
During ``cmdeploy run``, these paths are written into
the Postfix, Dovecot, and Nginx configurations.
No certificate files are transferred from the build machine —
they must already exist on the server,
managed by your external certificate tool.
The deploy will verify that both files exist on the server.
``acmetool`` is **not** installed or run in this mode.
.. note::
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).
Migrating to a new build machine Migrating to a new build machine
---------------------------------- ----------------------------------

View File

@@ -297,8 +297,7 @@ TLS requirements
Postfix is configured to require valid TLS by setting Postfix is configured to require valid TLS by setting
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_ `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 to ``verify``.
problem is likely that your relay does not have a valid TLS certificate.
You can test it by resolving ``MX`` records of your relay domain and You can test it by resolving ``MX`` records of your relay domain and
then connecting to MX relays (e.g ``mx.example.org``) with then connecting to MX relays (e.g ``mx.example.org``) with
@@ -309,6 +308,11 @@ When providing a TLS certificate to your chatmail relay server, make
sure to provide the full certificate chain and not just the last sure to provide the full certificate chain and not just the last
certificate. certificate.
If you use an external certificate manager (e.g. Traefik or certbot),
set ``tls_external_cert_and_key`` in ``chatmail.ini``
to provide the certificate and key paths.
See :ref:`external-tls` for details.
If you are running an Exim server and dont see incoming connections If you are running an Exim server and dont see incoming connections
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
item is enabled in the config with ``log_selector = +smtp_no_mail``. By item is enabled in the config with ``log_selector = +smtp_no_mail``. By
@@ -317,6 +321,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 by Postfix, so you might think that connection is not established while
actually it is a problem with your TLS certificate. 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 .. _dovecot: https://dovecot.org
.. _postfix: https://www.postfix.org .. _postfix: https://www.postfix.org

View File

@@ -1,26 +1,21 @@
# Local overrides — copy to docker-compose.override.yaml in the repo root. # Local overrides — copy to docker-compose.override.yaml in the repo root.
# Compose automatically merges this with docker-compose.yaml. # Compose automatically merges this with docker-compose.yaml.
# #
# cp docker/docker-compose.override.yaml.example docker-compose.override.yaml # cp docker-compose.override.yaml.example docker-compose.override.yaml
# #
# Volumes listed here are APPENDED to the base file's volumes. # Volumes are APPENDED to the base file's volumes list.
# Scalar values (environment, image, etc.) are REPLACED. # Environment and other scalar keys are MERGED by key.
services: services:
chatmail: chatmail:
volumes: volumes:
## Data paths — bind-mount to host directories for easy access/backup. ## 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. # - ./data/dkim:/etc/dkimkeys
## Note: DKIM key ownership is fixed automatically on startup # - ./data/certs:/var/lib/acme
## (the host's opendkim UID may differ from the container's).
# - ./data/mail:/home/vmail
## Or mount from an existing bare-metal install.
# - /home/vmail:/home/vmail # - /home/vmail:/home/vmail
# - /etc/dkimkeys:/etc/dkimkeys
# - /var/lib/acme:/var/lib/acme
## Mount your own chatmail.ini (skips auto-generation): ## Mount your own chatmail.ini (skips auto-generation):
# - ./chatmail.ini:/etc/chatmail/chatmail.ini # - ./chatmail.ini:/etc/chatmail/chatmail.ini
@@ -31,3 +26,15 @@ services:
## Debug — mount scripts from the repo for live editing: ## Debug — mount scripts from the repo for live editing:
# - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh # - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
# - ./docker/files/entrypoint.sh:/entrypoint.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

@@ -18,8 +18,8 @@ services:
restart: unless-stopped restart: unless-stopped
container_name: chatmail container_name: chatmail
# Required for systemd — use only one of the following: # Required for systemd — use only one of the following:
cgroup: host # compose v2 only cgroup: host # compose v2
# privileged: true # compose v1 (not tested) # privileged: true # compose v1 (less restricted)
tty: true # required for logs tty: true # required for logs
tmpfs: # required for systemd tmpfs: # required for systemd
- /tmp - /tmp
@@ -32,20 +32,16 @@ services:
max-file: "3" max-file: "3"
environment: environment:
MAIL_DOMAIN: $MAIL_DOMAIN 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" network_mode: "host"
volumes: volumes:
## system (required) ## system (required)
- /sys/fs/cgroup:/sys/fs/cgroup:rw - /sys/fs/cgroup:/sys/fs/cgroup:rw
## data (defaults — override in docker-compose.override.yaml) ## data (defaults — override in docker-compose.override.yaml)
- chatmail-data:/home/vmail - mail:/home/vmail
- chatmail-dkimkeys:/etc/dkimkeys - dkim:/etc/dkimkeys
- chatmail-acme:/var/lib/acme - certs:/var/lib/acme
volumes: volumes:
chatmail-data: mail:
chatmail-dkimkeys: dkim:
chatmail-acme: certs:

View File

@@ -73,7 +73,7 @@ RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \
echo "$GIT_HASH" > /etc/chatmail-version echo "$GIT_HASH" > /etc/chatmail-version
# --- End build-time install --- # --- End build-time install ---
ENV CHATMAIL_INI=/etc/chatmail/chatmail.ini ENV TZ=:/etc/localtime
ENV PATH="/opt/cmdeploy/bin:${PATH}" ENV PATH="/opt/cmdeploy/bin:${PATH}"
RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini
@@ -87,12 +87,6 @@ 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/setup_chatmail_docker.sh /setup_chatmail_docker.sh
COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.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 \ HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
CMD systemctl is-active dovecot postfix nginx unbound opendkim filtermail doveauth chatmail-metadata || exit 1 CMD systemctl is-active dovecot postfix nginx unbound opendkim filtermail doveauth chatmail-metadata || exit 1

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

@@ -6,7 +6,7 @@ SETUP_CHATMAIL_SERVICE_PATH="${SETUP_CHATMAIL_SERVICE_PATH:-/lib/systemd/system/
# Whitelist only the env vars needed by setup_chatmail_docker.sh. # Whitelist only the env vars needed by setup_chatmail_docker.sh.
# Forwarding all env vars (via printenv) would leak Docker internals, # Forwarding all env vars (via printenv) would leak Docker internals,
# orchestrator secrets, and other unrelated variables into systemd. # 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" 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" sed -i "s|<envs_list>|$env_vars|g" "$SETUP_CHATMAIL_SERVICE_PATH"
exec /lib/systemd/systemd "$@" exec /lib/systemd/systemd "$@"

View File

@@ -2,6 +2,8 @@
set -euo pipefail set -euo pipefail
export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}" export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}"
export CHATMAIL_NOSYSCTL=True
export CHATMAIL_NOPORTCHECK=True
CMDEPLOY=/opt/cmdeploy/bin/cmdeploy CMDEPLOY=/opt/cmdeploy/bin/cmdeploy
@@ -29,6 +31,13 @@ if [ ! -f "$CHATMAIL_INI" ]; then
$CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN" $CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN"
fi 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 --- # --- Deploy fingerprint: skip cmdeploy run if nothing changed ---
# On restart with identical image+config, systemd already brings up all # On restart with identical image+config, systemd already brings up all
# enabled services — the full cmdeploy run is redundant (~30s saved). # enabled services — the full cmdeploy run is redundant (~30s saved).

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 +1 @@
MAIL_DOMAIN="chat.example.com" 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). please see our [privacy policy](privacy.html).
{% endif %} {% 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> <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 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"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
{% endif %}
🐣 **Choose** your Avatar and Name 🐣 **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