Compare commits

..

65 Commits

Author SHA1 Message Date
j4n
606f36ee13 docker: integrate documentation
delete original markdown notes (the russian version was severely
outdated) and add a new section.
2026-02-18 17:05:28 +01:00
j4n
72973631f7 docker: move key files to repo root as per convention 2026-02-18 17:05:28 +01:00
j4n
5b5b09dc2e filtermail wait dont know where this came from 2026-02-18 17:05:28 +01:00
j4n
aa2f41158f docker: move all relevant files to repository root as per convention 2026-02-18 17:05:28 +01:00
j4n
e0ca4b25f4 docker/chatmaild/config: default to false on CHATMAIL_NOACME 2026-02-18 17:05:28 +01:00
j4n
0b593f98bf docker: chatmail.ini.f empty line remove 2026-02-18 17:05:28 +01:00
j4n
2e23fadb54 docker: skip redundant cmdeploy run on container restart
Replace the old IMAGE_VERSION_FILE/RUNNING_VERSION_FILE mechanism with a
single deploy fingerprint (image_version:sha256(chatmail.ini)) stored at
/etc/chatmail/.deploy-fingerprint. On restart, if the fingerprint matches
the last successful deploy, skip cmdeploy run entirely. The fingerprint
lives on the container's writable layer. On fresh containers, setting
CMDEPLOY_STAGES non-empty in env forces a deploy run regardless of
fingerprint.

Also narrow the /home volume mount to /home/vmail.
2026-02-18 17:05:28 +01:00
j4n
bc19966801 docker: run a dummy git init to make cmdeploy tooling happy 2026-02-18 17:05:28 +01:00
j4n
bafbaa1b81 docker: fix DKIM key permission denied on bind-mounted volumes
chown the entire /etc/acmekeys directory
2026-02-18 17:05:28 +01:00
j4n
feecf6affd docker: add build.sh to set GIT_HASH for local builds
Simple wrapper as .git is excluded from build context.
2026-02-18 17:05:28 +01:00
j4n
c2c3be1115 docker: add DKIM/ACME mount examples for bare-metal migration 2026-02-18 17:05:28 +01:00
j4n
c6d6e272be docker: pass CHATMAIL_NOSYSCTL and CHATMAIL_NOPORTCHECK to container
These got lost somehow
2026-02-18 17:05:28 +01:00
j4n
425e3db07a docker: slim build by excluding .git and non-essential files
Replace the in-Dockerfile `git rev-parse HEAD` with a GIT_HASH build arg
passed from docker-compose (local) or github.sha (CI), defaulting to
"unknown" when unset.

Also exclude .github/, docs/, tests/, and *.md (except www/**/*.md).
2026-02-18 17:05:28 +01:00
j4n
c22efeb74b docker: use docker-compose.override.yaml for user customizations
The base docker-compose.yaml was checked into git and thus would get
overwritten on pull.
- docker-compose.yaml uses named volumes as safe defaults
- docker-compose.override.yaml (gitignored) holds user customizations
- Compose automatically merges both files
2026-02-18 17:05:28 +01:00
j4n
71bd0da51a docs: document ghcr.io built images
Added pull instructions for pre-built images from ghcr.io (main branch,
tagged releases, feature branches).
2026-02-18 17:05:28 +01:00
j4n
0ed5ec75fb docker: add GitHub Action to build Docker image
Builds the Docker image on PRs and pushes that touch docker/, compose,
chatmaild/, or cmdeploy/ files.
- PRs: build only (no push, no login)
- Branch push (main, j4n/docker): build + push as :main or :j4n-docker
- Tagged release (v*): build + push as :1.2.3, :1.2, :sha-<hash>
Uses GITHUB_TOKEN for ghcr.io auth.
2026-02-18 17:05:28 +01:00
j4n
4fd0429cd3 docker: add Traefik support
USE_FOREIGN_CERT_MANAGER existed in compose/example.env but was never
read by any code. This wires it up end-to-end based on PR 662.

- Preliminarily add config options for this, and skip AcmetoolDeployer if
set.
- Add Traefik integration in docker/docker-compose-traefik.yaml, with
  traefik-certs-dumper
- post-hook.sh creates fullchain/privkey symlinks for chatmail
- Chatmail container uses ports 25/143/465/587/993 directly, Traefik
  handles 80/443
- docker/traefik/ contains config.yaml and dynamic configs
- docker/example-traefik.env for the Traefik setup
- rename USE_FOREIGN_CERT_MANAGER to CHATMAIL_NOACME
2026-02-18 17:05:28 +01:00
j4n
45717de6cb docker: add set -u to setup_chatmail_docker.sh 2026-02-18 17:05:28 +01:00
j4n
77dc67dde9 docker: auto-detect image upgrades and include install stage
Without version tracking, if a new image requires the install stage
(e.g. new package versions), the default configure,activate will skip
it and potentially fail silently.

At build time, the git hash is written to /etc/chatmail-image-version.
At runtime, setup_chatmail_docker.sh compares it against the persisted
/home/.chatmail-running-version (survives container restarts via the
/home volume). If they differ, the install stage is automatically
prepended to CMDEPLOY_STAGES. After a successful deploy, the running
version is updated.

Files: docker/chatmail_relay.dockerfile:68-69, docker/files/setup_chatmail_docker.sh:27-48
2026-02-18 17:05:28 +01:00
j4n
f017f88901 docker: docs: replace outdated Russian Docker docs with redirect to EN 2026-02-18 17:05:28 +01:00
j4n
0585314468 docker: extract cert monitor from background process to systemd timer
The cert monitoring was an orphaned background process (`monitor_certificates &`)
Replace with a proper systemd timer/service (every 60s).
Also made journald ForwardToConsole=yes idempotent.
2026-02-18 17:05:28 +01:00
j4n
85ee7dbeb5 docker: document security implications of host networking + cgroups 2026-02-18 17:05:28 +01:00
j4n
e503e120e5 docker: add HEALTHCHECK, remove VOLUME, fix Dockerfile hygiene
- Added HEALTHCHECK that verifies chatmail services are active via systemctl
- Removed `VOLUME ["/sys/fs/cgroup", "/home"]` as anonymous volumes are
  an anti-pattern for user data (leads to data loss on upgrades). Let
  compose/`docker run -v` handle volume management.
- Changed TZ from Europe/London to UTC (server best practice)
- Removed duplicate WORKDIR /opt/chatmail
- Moved `unlink /etc/nginx/sites-enabled/default` from entrypoint.sh to
  Dockerfile build time
2026-02-18 17:05:28 +01:00
j4n
475975dfa0 docker: use @local instead of @docker inside container 2026-02-18 17:05:28 +01:00
j4n
a930f8f46b docker: whitelist env vars in entrypoint, quote $@ and paths
Instead of forwarding ALL environment variables into systemd's
PassEnvironment, only forward a whitelist of variables to prevent
leaking of environment variables.
2026-02-18 17:05:28 +01:00
j4n
75ef0f2698 docker: remove duplicated dovecot hashes from Dockerfile 2026-02-18 17:05:28 +01:00
j4n
57f9327d4d docker: fix cert monitoring — wait for certs dir, use return not exit
Fix bugs in certificate monitoring function:
- `exit 0` inside monitor_certificates() would kill the background process
- calculate_hash() now checks dir existence instead of silenty dying
- Added wait loop until $PATH_TO_SSL exists before monitoring

Files: docker/files/setup_chatmail_docker.sh:16-41
2026-02-18 17:05:28 +01:00
j4n
e99d979eb8 docker: update docs to use @local for cmdeploy 2026-02-18 17:05:28 +01:00
j4n
ffa45c1ca1 docker: symlink chatmail.ini into /opt/chatmail for bench/tests 2026-02-18 17:05:28 +01:00
j4n
9f6de19121 fix(cmdeploy): add __call__ to LocalExec so status works with @local 2026-02-18 17:05:28 +01:00
j4n
cc779ec04f feat(cmdeploy): read CHATMAIL_INI env var for default --config path
Avoids needing --config /etc/chatmail/chatmail.ini on every command
inside the Docker container, where CHATMAIL_INI is already set.
2026-02-18 17:05:28 +01:00
j4n
04bd38baaa docker: add quickstart docker send note 2026-02-18 17:05:28 +01:00
j4n
4df6a96a14 docker: keep .git in build context for GithashDeployer 2026-02-18 17:05:28 +01:00
j4n
47131533df docker: set CHATMAIL_NOPORTCHECK during build-time install 2026-02-18 17:05:28 +01:00
j4n
a84c02e1e5 docker: replace config flags with env vars, drop docker param from deploy_chatmail
Remove change_kernel_settings/fs_inotify_max_user_instances_and_watchers
from chatmail.ini — use CHATMAIL_NOSYSCTL and CHATMAIL_NOPORTCHECK env
vars instead. deploy_chatmail() no longer takes a docker flag; deployers
check the env directly.
2026-02-18 17:05:28 +01:00
j4n
0edff3205f docker: remove dead utilities, fix cmdeploy run using wrong config path
Remove no longer needed docker/files/update_ini.sh and docker/cm_ini_to_env.py.
Fix cmdeploy run not receiving --config.
Document env files
2026-02-18 17:05:28 +01:00
j4n
a48552d69e docker: drop env to ini translation, use chatmail.ini directly
Remove update_ini.sh and the env-var-to-ini pipeline. The container now
has two config modes:

- Simple: set MAIL_DOMAIN in .env, container generates chatmail.ini
  with defaults via `cmdeploy init` on first start.
- Advanced: mount a custom chatmail.ini into the container; the init
  step is skipped when the file already exists.

This eliminates the fragile FORCE_REINIT_INI_FILE / INI_CMD_ARGS
machinery and the env vars that duplicated chatmail.ini settings

Also add *.ini and .env to .dockerignore so local config files
don't leak into the image.
2026-02-18 17:05:28 +01:00
j4n
0c746553b3 docker: move cmdeploy into docker image 2026-02-18 17:05:28 +01:00
j4n
ce65866595 docker: make compose work with cgroups (v2), conversion scripts/docs 2026-02-18 17:05:28 +01:00
j4n
557ad2ed3c docker: don't overwrite existing DKIM keys on container start
opendkim-genkey was running unconditionally on every startup,
check if file exists and skip.
2026-02-18 17:05:28 +01:00
j4n
87b1680621 docker: run install stage at build time, configure+activate at startup
Move the CMDEPLOY_STAGES=install execution into the Dockerfile these
operations baked into the image layer. On container start, only
configure and activate stages run by default. Users can override with
CMDEPLOY_STAGES="install,configure,activate" to force a full reinstall
without rebuilding the image.

Also fixes CERTS_MONITORING_TIMEOUT typo in docker-compose.yaml (was
"$CERTS MONITORING TIMEOUT"), and replaces the docker-commit workaround
in docs with CMDEPLOY_STAGES documentation.
2026-02-18 17:05:28 +01:00
j4n
872fd2d846 docker: widen build context to repo root for build-time install stage
The Dockerfile will need access to chatmaild/ and cmdeploy/ source
trees to run CMDEPLOY_STAGES=install via pyinfra during image build,
moving install-time work out of container startup. The previous context
(./docker) only included helper scripts.

Also adds .dockerignore to exclude .git, data/, venv/ etc. from the
build context, and updates COPY paths accordingly.
2026-02-18 17:05:28 +01:00
j4n
fa2827a07e feat(cmdeploy): guard against non-running systemd
This enables docker image building without systemd running, which would
make pyinfra SystemdEnabled fail.
2026-02-18 17:05:28 +01:00
j4n
c68df8551c docker: remove echobot parts that were lingering in the feature branch 2026-02-18 17:05:28 +01:00
Keonik1
23ddd087ad cmdeploy: Add config parameters change_kernel_settings and fs_inotify_max_user_instances_and_watchers 2026-02-18 17:05:28 +01:00
missytake
4278799f51 cmdeploy: add config (, ) 2026-02-18 17:05:28 +01:00
missytake
ec26ac5dbf docker: use --network=host so chatmail-turn can use any port 2026-02-18 17:05:28 +01:00
missytake
ee4648967e docker: open ports for TURN + STUN 2026-02-18 17:05:28 +01:00
missytake
92c8b83a5e docker: move all configuration to example.env 2026-02-18 17:05:28 +01:00
missytake
c33b5ade30 doc: fix linebreak 2026-02-18 17:05:28 +01:00
missytake
09c0af2c99 docker: disable port check if docker is running. fix #694 2026-02-18 17:05:28 +01:00
missytake
8d76b28a59 Suggestions from @Keonik1
Co-authored-by: Keonik <57857901+Keonik1@users.noreply.github.com>
2026-02-18 17:05:28 +01:00
missytake
ed9c7631bc docker: enable DNS checks before cmdeploy run again 2026-02-18 17:05:28 +01:00
Keonik1
9c0a3a1718 fix unlink if default nginx conf is not exist
- https://github.com/chatmail/relay/pull/614#discussion_r2297828830
2026-02-18 17:05:28 +01:00
Keonik1
bb590bb5ae Fix issue with acmetool
- https://github.com/chatmail/relay/pull/614#discussion_r2279630626
2026-02-18 17:05:28 +01:00
Keonik1
e1c0bffa52 Delete ssh connection from docker installation
- https://github.com/chatmail/relay/pull/614#discussion_r2269986372
- https://github.com/chatmail/relay/pull/614#discussion_r2269991175
- https://github.com/chatmail/relay/pull/614#discussion_r2269995037
- https://github.com/chatmail/relay/pull/614#discussion_r2270004922
2026-02-18 17:05:28 +01:00
Keonik1
e272bb9069 fix docs - nginx "restart" to "reload"
https://github.com/chatmail/relay/pull/614#discussion_r2269896158
2026-02-18 17:05:27 +01:00
Keonik1
87bd0323c2 Fix bug with attaching certs 2026-02-18 17:05:27 +01:00
Keonik1
d2f169af0d pass values to MAIL_DOMAIN and ACME_EMAIL from vars for docker-compose-default
https://github.com/chatmail/relay/pull/614#discussion_r2279591922
2026-02-18 17:05:27 +01:00
Keonik1
0603be8cff change "restart nginx" to "reload nginx"
https://github.com/chatmail/relay/pull/614#discussion_r2269896158
2026-02-18 17:05:27 +01:00
Keonik1
5b66fb9ade add RECREATE_VENV var
https://github.com/chatmail/relay/pull/614#discussion_r2279742769
2026-02-18 17:05:27 +01:00
Keonik1
7f151b368b add 465 port
https://github.com/chatmail/relay/pull/614#discussion_r2279707059
2026-02-18 17:05:27 +01:00
Keonik1
59362b4cf9 add port 80 to docker-compose-default
https://github.com/chatmail/relay/pull/614#discussion_r2279656441
2026-02-18 17:05:27 +01:00
Keonik1
f8af0e2c33 rename dockerfile
https://github.com/chatmail/relay/pull/614#discussion_r2270031966
2026-02-18 17:05:27 +01:00
Keonik1
beef0ecb19 Add installation via docker compose (MVP 1) 2026-02-18 17:05:27 +01:00
75 changed files with 1048 additions and 1696 deletions

View File

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

View File

@@ -1,375 +0,0 @@
name: Deploy
on:
push:
branches:
- main
- j4n/docker-pr
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-docker:
name: Build Docker image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
outputs:
image: ${{ steps.image-ref.outputs.image }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Tagged releases: v1.2.3 -> :1.2.3, :1.2, :latest
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Branch pushes: foo/docker-pr -> :foo-docker-pr
type=ref,event=branch
# Always: :sha-<hash>
type=sha
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: docker/chatmail_relay.dockerfile
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
GIT_HASH=${{ github.sha }}
- name: Output image reference
id: image-ref
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
deploy:
name: Deploy to ${{ matrix.host }}
needs: build-docker
# dont do the regular tests on this branch
if: >-
!cancelled() && (
github.event_name == 'push' ||
(github.event_name == 'pull_request' && !startsWith(github.head_ref, 'j4n/'))
)
runs-on: ubuntu-latest
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- host: staging2.testrun.org
acme_dir: acme
dkim_dir: dkimkeys
zone_file: staging.testrun.org-default.zone
disable_ipv6: false
add_ssh_keys: true
- host: staging-ipv4.testrun.org
acme_dir: acme-ipv4
dkim_dir: dkimkeys-ipv4
zone_file: staging-ipv4.testrun.org-default.zone
disable_ipv6: true
add_ssh_keys: false
environment:
name: ${{ matrix.host }}
url: https://${{ matrix.host }}/
concurrency: ${{ matrix.host }}
steps:
# --- Common setup ---
- uses: actions/checkout@v4
- name: prepare SSH and save ACME/DKIM
env:
HOST: ${{ matrix.host }}
ACME_DIR: ${{ matrix.acme_dir }}
DKIM_DIR: ${{ matrix.dkim_dir }}
ZONE: ${{ matrix.zone_file }}
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan ${HOST} > ~/.ssh/known_hosts
# save previous acme & dkim state (trailing slash = copy contents)
rsync -avz root@${HOST}:/var/lib/acme/ ${ACME_DIR}/ || true
rsync -avz root@${HOST}:/etc/dkimkeys/ ${DKIM_DIR}/ || true
# backup to ns.testrun.org if contents are useful
if [ -f ${DKIM_DIR}/opendkim.private ]; then
rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" ${DKIM_DIR}/ root@ns.testrun.org:/tmp/${DKIM_DIR}/ || true
fi
if [ "$(ls -A ${ACME_DIR}/certs 2>/dev/null)" ]; then
rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" ${ACME_DIR}/ root@ns.testrun.org:/tmp/${ACME_DIR}/ || true
fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/${ZONE} root@ns.testrun.org:/etc/nsd/${HOST}.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/${HOST}.zone
ssh root@ns.testrun.org nsd-checkzone ${HOST} /etc/nsd/${HOST}.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild VPS
env:
SERVER_ID: ${{ matrix.host == 'staging2.testrun.org' && secrets.STAGING_SERVER_ID || secrets.STAGING_IPV4_SERVER_ID }}
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${SERVER_ID}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: wait for VPS rebuild
id: wait-for-vps
env:
HOST: ${{ matrix.host }}
run: |
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new root@${HOST} id -u ; do sleep 1 ; done
- name: restore ACME/DKIM
env:
HOST: ${{ matrix.host }}
ACME_DIR: ${{ matrix.acme_dir }}
DKIM_DIR: ${{ matrix.dkim_dir }}
run: |
# download from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/${ACME_DIR}/ acme-restore/ || true
rsync -avz root@ns.testrun.org:/tmp/${DKIM_DIR}/ dkimkeys-restore/ || true
# restore to VPS
rsync -avz acme-restore/ root@${HOST}:/var/lib/acme/ || true
rsync -avz dkimkeys-restore/ root@${HOST}:/etc/dkimkeys/ || true
ssh root@${HOST} chown root:root -R /var/lib/acme || true
- name: bare offline tests
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
run: pytest --pyargs cmdeploy
- name: bare deploy
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
env:
HOST: ${{ matrix.host }}
DISABLE_IPV6: ${{ matrix.disable_ipv6 }}
run: |
ssh root@${HOST} 'apt update && apt install -y git python3.11-venv python3-dev gcc'
ssh root@${HOST} 'git clone https://github.com/chatmail/relay'
ssh root@${HOST} "cd relay && git checkout ${{ github.head_ref || github.ref_name }}"
ssh root@${HOST} 'cd relay && scripts/initenv.sh'
ssh root@${HOST} "cd relay && scripts/cmdeploy init ${HOST}"
if [ "${DISABLE_IPV6}" = "true" ]; then
ssh root@${HOST} "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
fi
ssh root@${HOST} "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
ssh root@${HOST} "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- name: bare DNS
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
env:
HOST: ${{ matrix.host }}
ZONE: ${{ matrix.zone_file }}
run: |
ssh root@${HOST} chown opendkim:opendkim -R /etc/dkimkeys
ssh root@${HOST} "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
ssh root@${HOST} cat relay/staging-generated.zone >> .github/workflows/${ZONE}
cat .github/workflows/${ZONE}
scp .github/workflows/${ZONE} root@ns.testrun.org:/etc/nsd/${HOST}.zone
ssh root@ns.testrun.org nsd-checkzone ${HOST} /etc/nsd/${HOST}.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: bare integration tests
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
env:
HOST: ${{ matrix.host }}
run: ssh root@${HOST} "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: bare final DNS check
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
env:
HOST: ${{ matrix.host }}
run: ssh root@${HOST} "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
# --- Docker deploy (push only, runs even if bare failed) ---
- name: stop bare services
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
ssh root@${HOST} 'systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true'
- name: install Docker on VPS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
ssh root@${HOST} 'apt-get update && apt-get install -y ca-certificates curl'
ssh root@${HOST} 'install -m 0755 -d /etc/apt/keyrings'
ssh root@${HOST} 'curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc'
ssh root@${HOST} 'echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list'
ssh root@${HOST} 'apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin'
- name: prepare Docker bind mounts
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
ssh root@${HOST} 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim'
ssh root@${HOST} 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true
- name: generate and upload chatmail.ini
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
cmdeploy init ${HOST}
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
scp chatmail.ini root@${HOST}:/srv/chatmail/chatmail.ini
- name: deploy with Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
GHCR_IMAGE="${{ needs.build-docker.outputs.image }}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@${HOST}:/srv/chatmail/relay/
# Login to GHCR on VPS and pull pre-built image
echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@${HOST} 'docker login ghcr.io -u ${{ github.actor }} --password-stdin'
ssh root@${HOST} "docker pull ${GHCR_IMAGE}"
ssh root@${HOST} "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=${HOST} docker compose -f docker-compose.yaml -f docker/docker-compose.ci.yaml up -d"
- name: wait for container healthy
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
# Stream journald inside the container
ssh root@${HOST} 'docker exec chatmail journalctl -f --no-pager' &
LOG_PID=$!
trap "kill $LOG_PID 2>/dev/null || true" EXIT
for i in $(seq 1 60); do
status=$(ssh root@${HOST} 'docker inspect --format={{.State.Health.Status}} chatmail 2>/dev/null' || echo "missing")
echo " [$i/60] status=$status"
if [ "$status" = "healthy" ]; then
echo "Container is healthy."
exit 0
fi
if [ "$status" = "unhealthy" ]; then
echo "Container is unhealthy!"
break
fi
sleep 5
done
echo "Container did not become healthy."
kill $LOG_PID 2>/dev/null || true
echo "--- failed units ---"
ssh root@${HOST} 'docker exec chatmail systemctl --failed --no-pager' || true
echo "--- service logs ---"
ssh root@${HOST} 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true
echo "--- listening ports ---"
ssh root@${HOST} 'docker exec chatmail ss -tlnp' || true
echo "--- chatmail.ini ---"
ssh root@${HOST} 'docker exec chatmail cat /etc/chatmail/chatmail.ini' || true
exit 1
- name: show container state
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: |
echo "--- listening ports ---"
ssh root@${HOST} 'docker exec chatmail ss -tlnp'
echo "--- chatmail.ini ---"
ssh root@${HOST} 'docker exec chatmail cat /etc/chatmail/chatmail.ini'
- name: Docker offline tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: CHATMAIL_DOCKER=chatmail pytest --pyargs cmdeploy
- name: Docker DNS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
ZONE: ${{ matrix.zone_file }}
run: |
# Reset zone file in case bare DNS already appended to it
git checkout .github/workflows/${ZONE}
ssh root@${HOST} 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys'
ssh root@${HOST} 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose'
ssh root@${HOST} 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone'
scp root@${HOST}:/tmp/staging.zone staging-generated.zone
cat staging-generated.zone >> .github/workflows/${ZONE}
cat .github/workflows/${ZONE}
scp .github/workflows/${ZONE} root@ns.testrun.org:/etc/nsd/${HOST}.zone
ssh root@ns.testrun.org nsd-checkzone ${HOST} /etc/nsd/${HOST}.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: Docker integration tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: CHATMAIL_DOCKER=chatmail CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: Docker final DNS check
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
env:
HOST: ${{ matrix.host }}
run: ssh root@${HOST} 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
# --- Cleanup ---
- name: add SSH keys
if: >-
!cancelled() && matrix.add_ssh_keys
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@${{ matrix.host }} 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys'

76
.github/workflows/docker-build.yaml vendored Normal file
View File

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

View File

@@ -0,0 +1,96 @@
name: deploy on staging-ipv4.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging-ipv4.testrun.org
url: https://staging-ipv4.testrun.org/
concurrency: staging-ipv4.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging-ipv4.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging-ipv4.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_IPV4_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

98
.github/workflows/test-and-deploy.yaml vendored Normal file
View File

@@ -0,0 +1,98 @@
name: deploy on staging2.testrun.org, and run tests
on:
push:
branches:
- main
pull_request:
paths-ignore:
- 'scripts/**'
- '**/README.md'
- 'CHANGELOG.md'
- 'LICENSE'
jobs:
deploy:
name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging2.testrun.org
url: https://staging2.testrun.org/
concurrency: staging2.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging2.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: add hpk42 key to staging server
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ __pycache__/
*$py.class *$py.class
*.swp *.swp
*qr-*.png *qr-*.png
chatmail*.ini chatmail.ini
# C extensions # C extensions

View File

@@ -121,6 +121,13 @@
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,6 +44,7 @@ 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", "")
@@ -60,31 +61,6 @@ 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, self.tls_key_path = parts
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

@@ -13,20 +13,9 @@ to show storage summaries only for first 1000 mailboxes
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000 python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
to write Prometheus textfile for node_exporter
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/
writes to /var/lib/prometheus/node-exporter/fsreport.prom
to also write legacy metrics.py style output (default: /var/www/html/metrics):
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/ --legacy-metrics
""" """
import os import os
import tempfile
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime from datetime import datetime
@@ -59,19 +48,7 @@ class Report:
self.num_ci_logins = self.num_all_logins = 0 self.num_ci_logins = self.num_all_logins = 0
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)} self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
KiB = 1024 self.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
MiB = 1024 * KiB
self.message_size_thresholds = (
0,
100 * KiB,
MiB // 2,
1 * MiB,
2 * MiB,
5 * MiB,
10 * MiB,
)
self.message_buckets = {x: 0 for x in self.message_size_thresholds}
self.message_count_buckets = {x: 0 for x in self.message_size_thresholds}
def process_mailbox_stat(self, mailbox): def process_mailbox_stat(self, mailbox):
# categorize login times # categorize login times
@@ -91,10 +68,9 @@ class Report:
for size in self.message_buckets: for size in self.message_buckets:
for msg in mailbox.messages: for msg in mailbox.messages:
if msg.size >= size: if msg.size >= size:
if self.mdir and f"/{self.mdir}/" not in msg.path: if self.mdir and not msg.relpath.startswith(self.mdir):
continue continue
self.message_buckets[size] += msg.size self.message_buckets[size] += msg.size
self.message_count_buckets[size] += 1
self.size_messages += sum(entry.size for entry in mailbox.messages) self.size_messages += sum(entry.size for entry in mailbox.messages)
self.size_extra += sum(entry.size for entry in mailbox.extrafiles) self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
@@ -117,10 +93,9 @@ class Report:
pref = f"[{self.mdir}] " if self.mdir else "" pref = f"[{self.mdir}] " if self.mdir else ""
for minsize, sumsize in self.message_buckets.items(): for minsize, sumsize in self.message_buckets.items():
count = self.message_count_buckets[minsize]
percent = (sumsize / all_messages * 100) if all_messages else 0 percent = (sumsize / all_messages * 100) if all_messages else 0
print( print(
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%), {count} msgs" f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
) )
user_logins = self.num_all_logins - self.num_ci_logins user_logins = self.num_all_logins - self.num_ci_logins
@@ -136,75 +111,6 @@ class Report:
for days, active in self.login_buckets.items(): for days, active in self.login_buckets.items():
print(f"last {days:3} days: {HSize(active)} {p(active)}") print(f"last {days:3} days: {HSize(active)} {p(active)}")
def _write_atomic(self, filepath, content):
"""Atomically write content to filepath via tmp+rename."""
dirpath = os.path.dirname(os.path.abspath(filepath))
fd, tmppath = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
f.write(content)
os.chmod(tmppath, 0o644)
os.rename(tmppath, filepath)
except BaseException:
try:
os.unlink(tmppath)
except OSError:
pass
raise
def dump_textfile(self, filepath):
"""Dump metrics in Prometheus exposition format."""
lines = []
lines.append("# HELP chatmail_storage_bytes Mailbox storage in bytes.")
lines.append("# TYPE chatmail_storage_bytes gauge")
lines.append(f'chatmail_storage_bytes{{kind="messages"}} {self.size_messages}')
lines.append(f'chatmail_storage_bytes{{kind="extra"}} {self.size_extra}')
total = self.size_extra + self.size_messages
lines.append(f'chatmail_storage_bytes{{kind="total"}} {total}')
lines.append("# HELP chatmail_messages_bytes Sum of msg bytes >= threshold.")
lines.append("# TYPE chatmail_messages_bytes gauge")
for minsize, sumsize in self.message_buckets.items():
lines.append(f'chatmail_messages_bytes{{min_size="{minsize}"}} {sumsize}')
lines.append("# HELP chatmail_messages_count Number of msgs >= size threshold.")
lines.append("# TYPE chatmail_messages_count gauge")
for minsize, count in self.message_count_buckets.items():
lines.append(f'chatmail_messages_count{{min_size="{minsize}"}} {count}')
lines.append("# HELP chatmail_accounts Number of accounts.")
lines.append("# TYPE chatmail_accounts gauge")
user_logins = self.num_all_logins - self.num_ci_logins
lines.append(f'chatmail_accounts{{kind="all"}} {self.num_all_logins}')
lines.append(f'chatmail_accounts{{kind="ci"}} {self.num_ci_logins}')
lines.append(f'chatmail_accounts{{kind="user"}} {user_logins}')
lines.append(
"# HELP chatmail_accounts_active Non-CI accounts active within N days."
)
lines.append("# TYPE chatmail_accounts_active gauge")
for days, active in self.login_buckets.items():
lines.append(f'chatmail_accounts_active{{days="{days}"}} {active}')
self._write_atomic(filepath, "\n".join(lines) + "\n")
def dump_compat_textfile(self, filepath):
"""Dump legacy metrics.py style metrics."""
user_logins = self.num_all_logins - self.num_ci_logins
lines = [
"# HELP total number of accounts",
"# TYPE accounts gauge",
f"accounts {self.num_all_logins}",
"# HELP number of CI accounts",
"# TYPE ci_accounts gauge",
f"ci_accounts {self.num_ci_logins}",
"# HELP number of non-CI accounts",
"# TYPE nonci_accounts gauge",
f"nonci_accounts {user_logins}",
]
self._write_atomic(filepath, "\n".join(lines) + "\n")
def main(args=None): def main(args=None):
"""Report about filesystem storage usage of all mailboxes and messages""" """Report about filesystem storage usage of all mailboxes and messages"""
@@ -221,21 +127,19 @@ def main(args=None):
"--days", "--days",
default=0, default=0,
action="store", action="store",
help="assume date to be DAYS older than now", help="assume date to be days older than now",
) )
parser.add_argument( parser.add_argument(
"--min-login-age", "--min-login-age",
default=0, default=0,
metavar="DAYS",
dest="min_login_age", dest="min_login_age",
action="store", action="store",
help="only sum up message size if last login is at least DAYS days old", help="only sum up message size if last login is at least min-login-age days old",
) )
parser.add_argument( parser.add_argument(
"--mdir", "--mdir",
metavar="{cur,new,tmp}",
action="store", action="store",
help="only consider messages in specified Maildir subdirectory for summary", help="only consider 'cur' or 'new' or 'tmp' messages for summary",
) )
parser.add_argument( parser.add_argument(
@@ -244,21 +148,6 @@ def main(args=None):
action="store", action="store",
help="maximum number of mailboxes to iterate on", help="maximum number of mailboxes to iterate on",
) )
parser.add_argument(
"--textfile",
metavar="PATH",
default=None,
help="write Prometheus textfile to PATH (directory or file); "
"if PATH is a directory, writes 'fsreport.prom' inside it",
)
parser.add_argument(
"--legacy-metrics",
metavar="FILENAME",
nargs="?",
const="/var/www/html/metrics",
default=None,
help="write legacy metrics.py textfile (default: /var/www/html/metrics)",
)
args = parser.parse_args(args) args = parser.parse_args(args)
@@ -272,15 +161,7 @@ def main(args=None):
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir) rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum): for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
rep.process_mailbox_stat(mbox) rep.process_mailbox_stat(mbox)
if args.textfile: rep.dump_summary()
path = args.textfile
if os.path.isdir(path):
path = os.path.join(path, "fsreport.prom")
rep.dump_textfile(path)
if args.legacy_metrics:
rep.dump_compat_textfile(args.legacy_metrics)
if not args.textfile and not args.legacy_metrics:
rep.dump_summary()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -48,13 +48,6 @@ 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,7 +6,6 @@ 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
@@ -24,26 +23,13 @@ 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(result)) print(json.dumps(creds))
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -73,51 +73,3 @@ 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"
assert config.tls_key_path == "/certs/privkey.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,15 +1,9 @@
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():
@@ -47,8 +41,6 @@ def test_one_mail(
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
): ):
monkeypatch.setenv("PYTHONUNBUFFERED", "1") monkeypatch.setenv("PYTHONUNBUFFERED", "1")
# DKIM is tested by cmdeploy tests.
monkeypatch.setenv("FILTERMAIL_SKIP_DKIM", "1")
smtp_inject_port = 20025 smtp_inject_port = 20025
if filtermail_mode == "outgoing": if filtermail_mode == "outgoing":
settings = dict( settings = dict(
@@ -65,14 +57,19 @@ 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()
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod # Wait for filtermail to start accepting connections
if b"DKIM verification DISABLED!" in line: import socket
line = popen.stderr.readline().strip() import time
if b"loop" not in line: for _ in range(50): # 5 second timeout
print(line.decode("ascii"), file=sys.stderr) try:
pytest.fail("starting filtermail failed") 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,11 +1,7 @@
import json import json
import chatmaild import chatmaild
from chatmaild.newemail import ( from chatmaild.newemail import create_newemail_dict, print_new_account
create_dclogin_url,
create_newemail_dict,
print_new_account,
)
def test_create_newemail_dict(example_config): def test_create_newemail_dict(example_config):
@@ -19,18 +15,6 @@ 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()
@@ -41,20 +25,3 @@ 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

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

View File

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

View File

@@ -8,10 +8,8 @@
{{ 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

@@ -5,6 +5,7 @@ along with command line option and subcommand parsing.
import argparse import argparse
import importlib.resources import importlib.resources
import importlib.util
import os import os
import pathlib import pathlib
import shutil import shutil
@@ -90,10 +91,9 @@ 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, strict_tls=strict_tls, print=out.red): if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1 return 1
env = os.environ.copy() env = os.environ.copy()
@@ -108,7 +108,10 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host == "localhost": if ssh_host in ["localhost", "@docker"]:
if ssh_host == "@docker":
env["CHATMAIL_NOPORTCHECK"] = "True"
env["CHATMAIL_NOSYSCTL"] = "True"
cmd = f"{pyinf} @local {deploy_path} -y" cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
@@ -124,7 +127,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 strict_tls and not remote_data["acme_account_url"]: elif not args.dns_check_disabled 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
@@ -151,13 +154,11 @@ 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 dns.check_initial_remote_data(remote_data, strict_tls=strict_tls): if not remote_data:
return 1 return 1
if strict_tls and not remote_data["acme_account_url"]: if 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
@@ -165,7 +166,6 @@ 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:
@@ -206,15 +206,17 @@ def test_cmd_options(parser):
action="store_true", action="store_true",
help="also run slow tests", help="also run slow tests",
) )
add_ssh_host_option(parser)
def test_cmd(args, out): def test_cmd(args, out):
"""Run local and online tests for chatmail deployment.""" """Run local and online tests for chatmail deployment.
env = os.environ.copy() This will automatically pip-install 'deltachat' if it's not available.
if args.ssh_host: """
env["CHATMAIL_SSH"] = args.ssh_host
x = importlib.util.find_spec("deltachat")
if x is None:
out.check_call(f"{sys.executable} -m pip install deltachat")
pytest_path = shutil.which("pytest") pytest_path = shutil.which("pytest")
pytest_args = [ pytest_args = [
@@ -228,7 +230,7 @@ def test_cmd(args, out):
] ]
if args.slow: if args.slow:
pytest_args.append("--slow") pytest_args.append("--slow")
ret = out.run_ret(pytest_args, env=env) ret = out.run_ret(pytest_args)
return ret return ret
@@ -319,7 +321,7 @@ def add_ssh_host_option(parser):
parser.add_argument( parser.add_argument(
"--ssh-host", "--ssh-host",
dest="ssh_host", dest="ssh_host",
help="Run commands on 'localhost' or on a specific SSH host " help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
"instead of chatmail.ini's mail_domain.", "instead of chatmail.ini's mail_domain.",
) )
@@ -381,7 +383,9 @@ def get_parser():
def get_sshexec(ssh_host: str, verbose=True): def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]: if ssh_host in ["localhost", "@local"]:
return LocalExec(verbose) return LocalExec(verbose, docker=False)
elif ssh_host == "@docker":
return LocalExec(verbose, docker=True)
if verbose: if verbose:
print(f"[ssh] login to {ssh_host}") print(f"[ssh] login to {ssh_host}")
return SSHExec(ssh_host, verbose=verbose) return SSHExec(ssh_host, verbose=verbose)

View File

@@ -11,8 +11,8 @@ from pathlib import Path
from chatmaild.config import read_config from chatmaild.config import read_config
from pyinfra import facts, host, logger from pyinfra import facts, host, logger
from pyinfra.api import FactBase
from pyinfra.facts import hardware from pyinfra.facts import hardware
from pyinfra.api import FactBase
from pyinfra.facts.files import Sha256File from pyinfra.facts.files import Sha256File
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
@@ -29,13 +29,11 @@ from .basedeploy import (
has_systemd, has_systemd,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
from .filtermail.deployer import FiltermailDeployer from .filtermail.deployer import FiltermailDeployer
from .mtail.deployer import MtailDeployer from .mtail.deployer import MtailDeployer
from .nginx.deployer import NginxDeployer from .nginx.deployer import NginxDeployer
from .opendkim.deployer import OpendkimDeployer from .opendkim.deployer import OpendkimDeployer
from .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
@@ -541,20 +539,6 @@ 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.
@@ -590,15 +574,8 @@ 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", 402))
port_services += [
(["imap-login", "dovecot"], 143), (["imap-login", "dovecot"], 143),
# acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402
# and gave the port to nginx.
(["acmetool", "nginx"], 80),
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
(["master", "smtpd"], 587), (["master", "smtpd"], 587),
@@ -623,7 +600,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, mail_domain) tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
@@ -633,7 +610,12 @@ 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, *, strict_tls=True, print=print): def check_initial_remote_data(remote_data, *, 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 strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.": elif 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 strict_tls and remote_data["WWW"] != f"{mail_domain}.": elif 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

@@ -1,5 +1,4 @@
import os import os
import urllib.request
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
@@ -52,21 +51,10 @@ class DovecotDeployer(Deployer):
self.need_restart = False self.need_restart = False
def _pick_url(primary, fallback):
try:
req = urllib.request.Request(primary, method="HEAD")
urllib.request.urlopen(req, timeout=10)
return primary
except Exception:
return fallback
def _install_dovecot_package(package: str, arch: str): def _install_dovecot_package(package: str, arch: str):
arch = "amd64" if arch == "x86_64" else arch arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch arch = "arm64" if arch == "aarch64" else arch
primary_url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb" url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F2.3.21%2Bdfsg1/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
url = _pick_url(primary_url, fallback_url)
deb_filename = "/root/" + url.split("/")[-1] deb_filename = "/root/" + url.split("/")[-1]
match (package, arch): match (package, arch):

View File

@@ -228,8 +228,8 @@ service anvil {
} }
ssl = required ssl = required
ssl_cert = <{{ config.tls_cert_path }} ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
ssl_key = <{{ config.tls_key_path }} ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
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

@@ -1,67 +0,0 @@
import io
from pyinfra import host
from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
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):
# Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path):
info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}")
# Deploy the .path unit (templated with the cert path).
# pkg=__package__ is required here because the resource files
# live in cmdeploy.external, not the default cmdeploy package.
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
content = source.read_text().format(cert_path=self.cert_path).encode()
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,
)
# No explicit reload needed here: dovecot/nginx read the cert
# on startup, and the .path watcher handles live changes.

View File

@@ -1,15 +0,0 @@
# 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 reload the affected services.
#
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
# After cert renewal, you must then trigger the reload explicitly:
# systemctl start tls-cert-reload.service
[Unit]
Description=Watch TLS certificate for changes
[Path]
PathChanged={cert_path}
[Install]
WantedBy=multi-user.target

View File

@@ -1,15 +0,0 @@
# 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 try-reload-or-restart dovecot
ExecStart=/bin/systemctl try-reload-or-restart nginx

View File

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

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.mail_domain }}"> <emailProvider id="{{ config.domain_name }}">
<domain>{{ config.mail_domain }}</domain> <domain>{{ config.domain_name }}</domain>
<displayName>{{ config.mail_domain }} chatmail</displayName> <displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.mail_domain }}</displayShortName> <displayShortName>{{ config.domain_name }}</displayShortName>
<incomingServer type="imap"> <incomingServer type="imap">
<hostname>{{ config.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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.mail_domain }}</hostname> <hostname>{{ config.domain_name }}</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=config, config={"domain_name": config.mail_domain},
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=config, config={"domain_name": config.mail_domain},
) )
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=config, config={"domain_name": config.mail_domain},
) )
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.mail_domain }} mx: {{ config.domain_name }}
max_age: 2419200 max_age: 2419200

View File

@@ -42,9 +42,6 @@ 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;
@@ -56,8 +53,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 {{ config.tls_cert_path }}; ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
ssl_certificate_key {{ config.tls_key_path }}; ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
gzip on; gzip on;
@@ -69,7 +66,7 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }};
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
@@ -84,15 +81,11 @@ 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.mail_domain }}/new; return 301 dcaccount:https://{{ config.domain_name }}/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;
@@ -106,11 +99,9 @@ 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.mail_domain }}/new; return 301 dcaccount:https://{{ config.domain_name }}/new;
} }
{% endif %}
fastcgi_pass unix:/run/fcgiwrap.socket; fastcgi_pass unix:/run/fcgiwrap.socket;
include /etc/nginx/fastcgi_params; include /etc/nginx/fastcgi_params;
@@ -141,29 +132,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.mail_domain }}; server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.mail_domain }}$request_uri; return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
} }
server {
listen 80;
{% if not disable_ipv6 %}
listen [::]:80;
{% endif %}
{% if config.tls_cert_mode == "acme" %}
location /.well-known/acme-challenge/ {
proxy_pass http://acmetool;
}
{% endif %}
return 301 https://$host$request_uri;
}
{% if config.tls_cert_mode == "acme" %}
upstream acmetool {
server 127.0.0.1:402;
}
{% endif %}
} }

View File

@@ -37,15 +37,21 @@ class OpendkimDeployer(Deployer):
) )
need_restart |= main_config.changed need_restart |= main_config.changed
screen_script = files.file( screen_script = files.put(
path="/etc/opendkim/screen.lua", src=get_resource("opendkim/screen.lua"),
present=False, dest="/etc/opendkim/screen.lua",
user="root",
group="root",
mode="644",
) )
need_restart |= screen_script.changed need_restart |= screen_script.changed
final_script = files.file( final_script = files.put(
path="/etc/opendkim/final.lua", src=get_resource("opendkim/final.lua"),
present=False, dest="/etc/opendkim/final.lua",
user="root",
group="root",
mode="644",
) )
need_restart |= final_script.changed need_restart |= final_script.changed
@@ -103,13 +109,6 @@ class OpendkimDeployer(Deployer):
) )
need_restart |= service_file.changed need_restart |= service_file.changed
files.file(
name="chown opendkim: /etc/dkimkeys/opendkim.private",
path="/etc/dkimkeys/opendkim.private",
user="opendkim",
group="opendkim",
)
self.need_restart = need_restart self.need_restart = need_restart
def activate(self): def activate(self):

View File

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

View File

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

View File

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

View File

@@ -15,12 +15,12 @@ readme_directory = no
compatibility_level = 3.6 compatibility_level = 3.6
# TLS parameters # TLS parameters
smtpd_tls_cert_file={{ config.tls_cert_path }} smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
smtpd_tls_key_file={{ config.tls_key_path }} smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
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" if config.tls_cert_mode == "acme" else "encrypt" }} smtp_tls_security_level=verify
# 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

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

View File

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

View File

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

@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini

View File

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

View File

@@ -50,9 +50,6 @@ class SSHExec:
FuncError = FuncError FuncError = FuncError
def __init__(self, host, verbose=False, python="python3", timeout=60): def __init__(self, host, verbose=False, python="python3", timeout=60):
docker_container = os.environ.get("CHATMAIL_DOCKER")
if docker_container:
python = f"docker exec -i {docker_container} python3"
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}") self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote) self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
self.timeout = timeout self.timeout = timeout
@@ -88,10 +85,9 @@ class SSHExec:
class LocalExec: class LocalExec:
FuncError = FuncError def __init__(self, verbose=False, docker=False):
def __init__(self, verbose=False):
self.verbose = verbose self.verbose = verbose
self.docker = docker
def __call__(self, call, kwargs=None, log_callback=None): def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None: if kwargs is None:
@@ -99,15 +95,11 @@ class LocalExec:
return call(**kwargs) return call(**kwargs)
def logged(self, call, kwargs: dict): def logged(self, call, kwargs: dict):
title = call.__doc__
if not title:
title = call.__name__
where = "locally" where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs["pre_command"] = "docker exec chatmail "
where = "in docker"
if self.verbose: if self.verbose:
print_stderr(f"Running {where}: {title}(**{kwargs})") print(f"Running {where}: {call.__name__}(**{kwargs})")
return self(call, kwargs, log_callback=print_stderr) return call(**kwargs)
else:
print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
print_stderr()
return res

View File

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

View File

@@ -1,4 +1,3 @@
import pytest
import requests import requests
from cmdeploy.genqr import gen_qr_png_data from cmdeploy.genqr import gen_qr_png_data
@@ -9,33 +8,18 @@ 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)
verify = chatmail_config.tls_cert_mode == "acme" res = requests.post(url)
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
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") def test_newemail_configure(maildomain, rpc):
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()
if chatmail_config.tls_cert_mode == "self": rpc.set_config_from_qr(account_id, url)
# deltachat core's rustls rejects self-signed HTTPS certs during rpc.configure(account_id)
# set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json()
rpc.add_or_update_transport(account_id, {
"addr": data["email"],
"password": data["password"],
"imapServer": maildomain,
"smtpServer": maildomain,
"certificateChecks": "acceptInvalidCertificates",
})
else:
rpc.add_transport_from_qr(account_id, url)

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
import imaplib import imaplib
import io
import itertools 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
@@ -34,24 +34,17 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run") pytest.skip("skipping slow test, use --slow to run")
def _get_chatmail_config(): @pytest.fixture(scope="session")
current = Path().resolve() def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
while 1: while 1:
path = current.joinpath("chatmail.ini").resolve() path = current.joinpath("chatmail.ini").resolve()
if path.exists(): if path.exists():
return read_config(path), path return read_config(path)
if current == current.parent: if current == current.parent:
break break
current = current.parent current = current.parent
return None, None
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
config, path = _get_chatmail_config()
if config:
return config
basedir = Path().resolve()
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs") pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@@ -79,17 +72,10 @@ def sshdomain2(maildomain2):
def pytest_report_header(): def pytest_report_header():
config, path = _get_chatmail_config() domain = os.environ.get("CHATMAIL_DOMAIN")
domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET") if domain:
domain = config.mail_domain if config else "NOT SET" text = f"chatmail test instance: {domain}"
path = path if path else "NOT SET" return ["-" * len(text), text, "-" * len(text)]
lines = [
f"chatmail.ini {domain} location: {path}",
f"chatmail2: {domain2}",
]
sep = "-" * max(map(len, lines))
return [sep, *lines, sep]
@pytest.fixture @pytest.fixture
@@ -104,22 +90,15 @@ def cm_data(request):
@pytest.fixture @pytest.fixture
def benchmark(request, chatmail_config): def benchmark(request):
def bench(func, num, name=None, reportfunc=None, cooldown=0.0): def bench(func, num, name=None, reportfunc=None):
if name is None: if name is None:
name = func.__name__ name = func.__name__
if cooldown == "auto":
per_minute = max(chatmail_config.max_user_send_per_minute, 1)
cooldown = chatmail_config.max_user_send_burst_size * 60 / per_minute
durations = [] durations = []
for i in range(num): for i in range(num):
now = time.time() now = time.time()
func() func()
durations.append(time.time() - now) durations.append(time.time() - now)
if cooldown > 0 and i + 1 < num:
# Keep post-run cooldown out of measured benchmark duration.
time.sleep(cooldown)
durations.sort() durations.sort()
request.config._benchresults[name] = (reportfunc, durations) request.config._benchresults[name] = (reportfunc, durations)
@@ -165,25 +144,15 @@ def pytest_terminal_summary(terminalreporter):
tr.write_line(line) tr.write_line(line)
@pytest.fixture(scope="session") @pytest.fixture
def ssl_context(chatmail_config): def imap(maildomain):
if chatmail_config.tls_cert_mode == "self": return ImapConn(maildomain)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
return None
@pytest.fixture @pytest.fixture
def imap(maildomain, ssl_context): def make_imap_connection(maildomain):
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, ssl_context=ssl_context) conn = ImapConn(maildomain)
conn.connect() conn.connect()
return conn return conn
@@ -195,13 +164,12 @@ class ImapConn:
logcmd = "journalctl -f -u dovecot" logcmd = "journalctl -f -u dovecot"
name = "dovecot" name = "dovecot"
def __init__(self, host, ssl_context=None): def __init__(self, host):
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, ssl_context=self.ssl_context) self.conn = imaplib.IMAP4_SSL(self.host)
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}")
@@ -227,14 +195,14 @@ class ImapConn:
@pytest.fixture @pytest.fixture
def smtp(maildomain, ssl_context): def smtp(maildomain):
return SmtpConn(maildomain, ssl_context=ssl_context) return SmtpConn(maildomain)
@pytest.fixture @pytest.fixture
def make_smtp_connection(maildomain, ssl_context): def make_smtp_connection(maildomain):
def make_smtp_connection(): def make_smtp_connection():
conn = SmtpConn(maildomain, ssl_context=ssl_context) conn = SmtpConn(maildomain)
conn.connect() conn.connect()
return conn return conn
@@ -246,14 +214,12 @@ 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, ssl_context=None): def __init__(self, host):
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}")
context = self.ssl_context or ssl.create_default_context() self.conn = smtplib.SMTP_SSL(self.host)
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}")
@@ -296,94 +262,68 @@ def gencreds(chatmail_config):
# #
# Delta Chat RPC-based test support # Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts # use the cmfactory fixture to get chatmail instance accounts
# #
from deltachat_rpc_client import DeltaChat, Rpc
class ChatmailTestProcess:
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
class ChatmailACFactory: def __init__(self, pytestconfig, maildomain, gencreds):
"""RPC-based account factory for chatmail testing.""" self.pytestconfig = pytestconfig
self.maildomain = maildomain
def __init__(self, rpc, maildomain, gencreds, chatmail_config): assert "." in self.maildomain, maildomain
self.dc = DeltaChat(rpc)
self.rpc = rpc
self._maildomain = maildomain
self.gencreds = gencreds self.gencreds = gencreds
self.chatmail_config = chatmail_config self._addr2files = {}
def _make_transport(self, domain): def get_liveconfig_producer(self):
"""Build a transport config dict for the given domain.""" while 1:
addr, password = self.gencreds(domain) user, password = self.gencreds(self.maildomain)
transport = { config = {
"addr": addr, "addr": user,
"password": password, "mail_pw": password,
# Setting server explicitly skips requesting autoconfig XML, }
# see https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/ # speed up account configuration
"imapServer": domain, config["mail_server"] = self.maildomain
"smtpServer": domain, config["send_server"] = self.maildomain
} yield config
if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates"
return transport
def get_online_account(self, domain=None): def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
"""Create, configure and bring online a single account.""" pass
return self.get_online_accounts(1, domain)[0]
def get_online_accounts(self, num, domain=None): def cache_maybe_store_configured_db_files(self, acc):
"""Create multiple online accounts in parallel.""" pass
domain = domain or self._maildomain
futures = []
accounts = []
for _ in range(num):
account = self.dc.add_account()
future = account.add_or_update_transport.future(
self._make_transport(domain)
)
futures.append(future)
# ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests
account.set_config("delete_server_after", "10")
accounts.append(account)
for future in futures:
future()
for account in accounts:
account.bring_online()
return accounts
def get_accepted_chat(self, ac1, ac2):
"""Create a 1:1 chat between ac1 and ac2 accepted on both sides."""
ac2.create_chat(ac1)
return ac1.create_chat(ac2)
@pytest.fixture(scope="session")
def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session."""
# NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet.
accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts")
rpc = Rpc(accounts_dir=accounts_dir)
rpc.start()
yield rpc
rpc.close()
@pytest.fixture @pytest.fixture
def cmfactory(rpc, gencreds, maildomain, chatmail_config): def cmfactory(request, gencreds, tmpdir, maildomain):
"""Return a ChatmailACFactory for creating online Delta Chat accounts.""" # cloned from deltachat.testplugin.amfactory
return ChatmailACFactory( pytest.importorskip("deltachat")
rpc=rpc, from deltachat.testplugin import ACFactory
maildomain=maildomain,
gencreds=gencreds, testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
chatmail_config=chatmail_config,
) class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support
def switch_maildomain(maildomain2):
am.testprocess.maildomain = maildomain2
am.switch_maildomain = switch_maildomain
yield am
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
if testproc.pytestconfig.getoption("--extra-info"):
logfile = io.StringIO()
am.dump_imap_summary(logfile=logfile)
print(logfile.getvalue())
# request.node.add_report_section("call", "imap-server-state", s)
@pytest.fixture @pytest.fixture
@@ -395,30 +335,19 @@ class Remote:
def __init__(self, sshdomain): def __init__(self, sshdomain):
self.sshdomain = sshdomain self.sshdomain = sshdomain
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain)
match self.sshdomain:
case "@local": command = []
case "localhost": command = []
case _: command = ["ssh", f"root@{self.sshdomain}"]
docker_container = os.environ.get("CHATMAIL_DOCKER")
if docker_container:
command += ["docker", "exec", docker_container]
[command.append(arg) for arg in getjournal.split()]
self.popen = subprocess.Popen( self.popen = subprocess.Popen(
command, ["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
) )
while 1: while 1:
line = self.popen.stdout.readline() line = self.popen.stdout.readline()
res = line.decode().strip().lower() res = line.decode().strip().lower()
if not res: if res:
yield res
else:
break break
if ready is not None:
ready()
ready = None
yield res
@pytest.fixture @pytest.fixture
@@ -434,40 +363,38 @@ def lp(request):
@pytest.fixture @pytest.fixture
def cmsetup(maildomain, gencreds, ssl_context): def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds, ssl_context) return CMSetup(maildomain, gencreds)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds, ssl_context): def __init__(self, maildomain, gencreds):
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, self.ssl_context) user = CMUser(self.maildomain, addr, password)
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, ssl_context=None): def __init__(self, maildomain, addr, password):
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, ssl_context=self.ssl_context) handle = SmtpConn(self.maildomain)
handle.connect() handle.connect()
handle.login(self.addr, self.password) handle.login(self.addr, self.password)
self._smtp = handle self._smtp = handle
@@ -476,7 +403,7 @@ class CMUser:
@property @property
def imap(self): def imap(self):
if not self._imap: if not self._imap:
imap = ImapConn(self.maildomain, ssl_context=self.ssl_context) imap = ImapConn(self.maildomain)
imap.connect() imap.connect()
imap.login(self.addr, self.password) imap.login(self.addr, self.password)
self._imap = imap self._imap = imap

View File

@@ -91,16 +91,6 @@ 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

@@ -1,78 +0,0 @@
"""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,24 +6,37 @@ using Docker Compose.
.. note:: .. note::
- Docker support is experimental, CI builds and tests the image automatically, but please report bugs. Docker support is experimental and not yet covered by automated tests, please report bugs.
- The image wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a Debian-systemd image with r/w access to `/sys/fs`
- Currently amd64-only (arm64 should work but is untested).
Setup Preparation Known limitations
-----------------
- Requires cgroups v2 on the host. Operation with cgroups v1 has not been tested.
- This preliminary image simply wraps the cmdeploy process detailed in the :doc:`getting_started` instructions in a full Debian-systemd image.
- Currently, the image has only been tested and built on amd64, though arm64 should theoretically work as well.
Prerequisites
-------------
- **Docker Compose v2** (``docker compose``, not ``docker-compose``) is
required for its ``cgroup: host`` support (`Install instructions <https://docs.docker.com/engine/install/debian/#install-using-the-repository>`_:)
- **DNS records** for your domain (see step 1 below).
- **Kernel parameters**``fs.inotify.max_user_instances`` and
``fs.inotify.max_user_watches`` must be raised on the host because they
cannot be changed inside the container (see step 2 below).
Preliminary setup
----------------- -----------------
We use ``chat.example.org`` as the chatmail domain in the following 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. Install docker and docker compose v2 (check with `docker compose version`), install, e.g., through 1. Setup the initial DNS records.
- 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 gives the container full privileges.
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.
@@ -35,7 +48,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.
3. Configure kernel parameters on the host, as these can not be set from the container:: 2. Configure kernel parameters on the host, as these can not be set from the container::
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf echo "fs.inotify.max_user_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
@@ -60,32 +73,38 @@ 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 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.override.yaml.example -O docker-compose.override.yaml wget https://raw.githubusercontent.com/chatmail/relay/refs/heads/main/docker/env.example -O .env
- 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. Set the fully qualified domain name of the relay:: 1. All local customizations (data paths, extra volumes, config mounts) go in
``docker-compose.override.yaml``, which Compose merges automatically with
the base file. By default, all data is stored in docker volumes, you will
likely want to at least create and configure the mail storage location. Copy
the example to get started::
echo 'MAIL_DOMAIN=chat.example.org' > .env cp docker/docker-compose.override.yaml.example docker-compose.override.yaml
# and edit docker-compose.override.yaml
2. Configure the ``.env`` file. Only ``MAIL_DOMAIN`` is required, the domain
name of the future server.
The container generates a ``chatmail.ini`` with defaults from 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
@@ -94,40 +113,26 @@ 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
-----------------------
You can test the installation with:: Managing the server
-------------------
pip install cmping chat.example.org # or Use ``docker exec`` to run cmdeploy commands inside the container::
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 cmdeploy dns --ssh-host @local docker exec chatmail /opt/cmdeploy/bin/cmdeploy dns --ssh-host @local
You can check server status with:: # Check server status
docker exec chatmail /opt/cmdeploy/bin/cmdeploy status --ssh-host @local
docker exec chatmail cmdeploy status --ssh-host @local # Run benchmarks (can also run from any machine with cmdeploy installed)
docker exec chatmail /opt/cmdeploy/bin/cmdeploy bench chat.example.org
You can run some benchmarks (can also run from any machine with cmdeploy installed)::
docker exec chatmail cmdeploy bench
You can run the test suite with::
docker exec chatmail cmdeploy test --ssh-host localhost
You can look at logs::
docker exec chatmail journalctl -fu postfix@-
Customization Customization
------------- -------------
Website Custom 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
@@ -154,8 +159,14 @@ your own website source files.
Custom chatmail.ini Custom chatmail.ini
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
If you want to go beyond simply setting the ``MAIL_DOMAIN`` in ``.env``, you There are two configuration modes:
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::
@@ -175,16 +186,6 @@ can use a regular `chatmail.ini` to give you full control.
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
------------------------------------ ------------------------------------
@@ -207,19 +208,16 @@ 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/dkim data/certs data/mail mkdir -p data/chatmail-dkimkeys data/chatmail-acme data/chatmail
# DKIM keys # DKIM keys
cp -a /etc/dkimkeys/* data/dkim/ cp -a /etc/dkimkeys/* data/chatmail-dkimkeys/
# TLS certificates # ACME certificates and account
rsync -a /var/lib/acme/ data/certs/ rsync -a /var/lib/acme/ data/chatmail-acme/
Note that ownership of dkim and acme is adjusted on container start. # Mail data
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``::
@@ -237,13 +235,11 @@ Clone the repository and build the Docker image::
git clone https://github.com/chatmail/relay git clone https://github.com/chatmail/relay
cd relay cd relay
docker/build.sh 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 a ``.env`` into the image. After building, only ``docker-compose.yaml`` and ``.env``
with ``MAIL_DOMAIN`` are needed to run the container. The `build.sh` passes the are needed to run the container.
git hash onto the docker build so it can be determined if there has been a
change that warrants a redeploy.
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,14 +47,6 @@ 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.
@@ -71,16 +63,6 @@ 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:
:: ::
@@ -193,55 +175,6 @@ creating addresses, login with ssh to the deployment machine and run:
Chatmail address creation will be denied while this file is present. Chatmail address creation will be denied while this file is present.
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.
The watcher uses inotify, which does not cross bind-mount boundaries.
If you use such a setup, you must trigger the reload explicitly after renewal::
systemctl start tls-cert-reload.service
Migrating to a new build machine Migrating to a new build machine
---------------------------------- ----------------------------------

View File

@@ -297,7 +297,8 @@ 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``. to ``verify``. If emails dont arrive at your chatmail relay server, the
problem is likely that your relay does not have a valid TLS certificate.
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
@@ -308,11 +309,6 @@ 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
@@ -321,14 +317,6 @@ 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,20 +1,26 @@
# 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-compose.override.yaml.example docker-compose.override.yaml # cp docker/docker-compose.override.yaml.example docker-compose.override.yaml
# #
# Volumes are APPENDED to the base file's volumes list, environment and other scalar keys are MERGED by key. # Volumes listed here are APPENDED to the base file's volumes.
# Scalar values (environment, image, etc.) are REPLACED.
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
# - ./data/dkim:/etc/dkimkeys ## Or mount data from an existing bare-metal install.
# - ./data/certs:/var/lib/acme ## Note: DKIM key ownership is fixed automatically on startup
## (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
@@ -23,22 +29,5 @@ services:
# - ./custom/www:/opt/chatmail-www # - ./custom/www:/opt/chatmail-www
## Debug — mount scripts from the repo for live editing: ## Debug — mount scripts from the repo for live editing:
# - ./docker/chatmail-init.sh:/chatmail-init.sh # - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
# - ./docker/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.
## A tls-cert-reload.path watcher inside the container reloads services
## when the cert file changes. However, inotify does not cross bind-mount
## boundaries, so host-side renewals (certbot, acmetool, etc.) must
## notify the container explicitly. Add this to your renewal hook:
##
## docker exec chatmail systemctl start tls-cert-reload.service
##
## 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

@@ -2,15 +2,11 @@
# volumes, env overrides) in docker-compose.override.yaml instead. # volumes, env overrides) in docker-compose.override.yaml instead.
# See docker/docker-compose.override.yaml.example for a starting point. # See docker/docker-compose.override.yaml.example for a starting point.
# #
# Security notes: this container uses # Security note: this container uses network_mode:host (chatmail needs many
# - network_mode:host chatmail needs many ports (25, 53, 80, 143, 443, 465, # ports: 25, 53, 80, 143, 443, 465, 587, 993, 3340, 8443) and cgroup:host
# 587, 993, 3340, 8443) and needs to operate from the real IP, which bridging # (required for systemd). Together these give the container near-host-level
# would make tricky # access. This is acceptable for a dedicated mail server, but be aware that
# - cgroup:host (required for systemd). # the container can bind any port and see all host network traffic.
# Together these give the container near-host-level access. This is acceptable
# for a dedicated mail server, but be aware that the container can bind any
# port and see all host network traffic.
services: services:
chatmail: chatmail:
build: build:
@@ -22,27 +18,34 @@ 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 cgroup: host # compose v2 only
# privileged: true # compose v1 (less restricted) # privileged: true # compose v1 (not tested)
tty: true # required for logs tty: true # required for logs
tmpfs: # required for systemd tmpfs: # required for systemd
- /tmp - /tmp
- /run - /run
- /run/lock - /run/lock
logging: logging:
driver: none driver: json-file
options:
max-size: "10m"
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)
- mail:/home/vmail - chatmail-data:/home/vmail
- dkim:/etc/dkimkeys - chatmail-dkimkeys:/etc/dkimkeys
- certs:/var/lib/acme - chatmail-acme:/var/lib/acme
volumes: volumes:
mail: chatmail-data:
dkim: chatmail-dkimkeys:
certs: chatmail-acme:

View File

@@ -5,5 +5,5 @@
# .git/ is excluded from the build context (.dockerignore) so the hash # .git/ is excluded from the build context (.dockerignore) so the hash
# must be passed as a build arg from the host. # must be passed as a build arg from the host.
export GIT_HASH=$(git rev-parse HEAD) export GIT_HASH=$(git rev-parse --short HEAD)
exec docker compose build "$@" exec docker compose build "$@"

View File

@@ -1,87 +0,0 @@
#!/bin/bash
set -euo pipefail
export CHATMAIL_INI="${CHATMAIL_INI:-/etc/chatmail/chatmail.ini}"
export CHATMAIL_NOSYSCTL=True
export CHATMAIL_NOPORTCHECK=True
CMDEPLOY=/opt/cmdeploy/bin/cmdeploy
if [ -z "$MAIL_DOMAIN" ]; then
echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2
exit 1
fi
# Generate DKIM keys if not mounted
if [ ! -f /etc/dkimkeys/opendkim.private ]; then
/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d "$MAIL_DOMAIN" -s opendkim
fi
# Fix ownership for bind-mounted keys (host opendkim UID may differ from container)
chown -R opendkim:opendkim /etc/dkimkeys
# Create chatmail.ini, skip if mounted
mkdir -p "$(dirname "$CHATMAIL_INI")"
if [ ! -f "$CHATMAIL_INI" ]; then
$CMDEPLOY init --config "$CHATMAIL_INI" "$MAIL_DOMAIN"
fi
# Auto-detect IPv6: if the host has no IPv6 connectivity, set disable_ipv6
# in the ini so dovecot/postfix/nginx bind to IPv4 only.
# Uses network_mode:host so /proc/net/if_inet6 reflects the host's stack.
if [ ! -e /proc/net/if_inet6 ]; then
if grep -q '^disable_ipv6 = False' "$CHATMAIL_INI"; then
sed -i 's/^disable_ipv6 = False/disable_ipv6 = True/' "$CHATMAIL_INI"
echo "[INFO] IPv6 not available, set disable_ipv6 = True"
fi
fi
# Inject external TLS paths from env var unless defined in chatmail.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
# Ensure mailboxes directory exists (chatmail-metadata needs it at startup,
# but Dovecot only creates it on first mail delivery)
mkdir -p "/home/vmail/mail/${MAIL_DOMAIN}"
chown vmail:vmail "/home/vmail/mail/${MAIL_DOMAIN}"
# --- Deploy fingerprint: skip cmdeploy run if nothing changed ---
# On restart with identical image+config, systemd already brings up all
# enabled services only configure+activate are needed here.
IMAGE_VERSION_FILE="/etc/chatmail-image-version"
FINGERPRINT_FILE="/etc/chatmail/.deploy-fingerprint"
image_ver="none"
[ -f "$IMAGE_VERSION_FILE" ] && image_ver=$(cat "$IMAGE_VERSION_FILE")
config_hash=$(sha256sum "$CHATMAIL_INI" | cut -c1-16)
current_fp="${image_ver}:${config_hash}"
# CMDEPLOY_STAGES non-empty in env = operator override -> always run.
# Otherwise, if fingerprint matches the last successful deploy, skip.
if [ -z "${CMDEPLOY_STAGES:-}" ] \
&& [ -f "$FINGERPRINT_FILE" ] \
&& [ "$(cat "$FINGERPRINT_FILE")" = "$current_fp" ]; then
echo "[INFO] No changes detected ($current_fp), skipping deploy."
else
export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}"
# Skip DNS check when MAIL_DOMAIN is a bare IP address
SKIP_DNS=""
if [[ "$MAIL_DOMAIN" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$MAIL_DOMAIN" =~ : ]]; then
SKIP_DNS="--skip-dns-check"
fi
$CMDEPLOY run --config "$CHATMAIL_INI" --ssh-host @local $SKIP_DNS
# Restore the build-time hash
cp /etc/chatmail-image-version /etc/chatmail-version
echo "$current_fp" > "$FINGERPRINT_FILE"
fi
# Signal success to Docker healthcheck
touch /run/chatmail-init.done
# Forward journald to console so `docker compose logs` works
grep -q '^ForwardToConsole=yes' /etc/systemd/journald.conf \
|| echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf
systemctl restart systemd-journald

View File

@@ -1,55 +1,59 @@
# syntax=docker/dockerfile:1
FROM jrei/systemd-debian:12 AS base FROM jrei/systemd-debian:12 AS base
ENV LANG=en_US.UTF-8 ENV LANG=en_US.UTF-8
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \ echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \
apt-get update && \ apt-get update && \
DEBIAN_FRONTEND=noninteractive TZ=UTC \
apt-get install -y \ apt-get install -y \
ca-certificates \ ca-certificates && \
gcc \ DEBIAN_FRONTEND=noninteractive \
git \ TZ=UTC \
python3 \ apt-get install -y tzdata && \
python3-dev \ apt-get install -y locales && \
python3-venv \
tzdata \
locales && \
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \ sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \ dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=$LANG update-locale LANG=$LANG \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && \
apt-get install -y \
git \
python3 \
python3-venv \
python3-virtualenv \
gcc \
python3-dev \
opendkim \
opendkim-tools \
curl \
rsync \
unbound \
unbound-anchor \
dnsutils \
postfix \
acl \
nginx \
libnginx-mod-stream \
fcgiwrap \
cron \
&& rm -rf /var/lib/apt/lists/*
# --- Build-time: install cmdeploy venv and run install stage --- # --- Build-time: install cmdeploy venv and run install stage ---
# Editable install so importlib.resources reads directly from the source tree. # Editable install so importlib.resources reads directly from the source tree.
# On container start only "configure,activate" stages run. # On container start only "configure,activate" stages run.
# Copy dependency metadata first so pip install layer is cached
COPY cmdeploy/pyproject.toml /opt/chatmail/cmdeploy/pyproject.toml
COPY chatmaild/pyproject.toml /opt/chatmail/chatmaild/pyproject.toml
# Dummy scaffolding so editable install can discover packages
RUN mkdir -p /opt/chatmail/cmdeploy/src/cmdeploy \
/opt/chatmail/chatmaild/src/chatmaild && \
touch /opt/chatmail/cmdeploy/src/cmdeploy/__init__.py \
/opt/chatmail/chatmaild/src/chatmaild/__init__.py
# Dummy git repo: .git/ is excluded from the build context (.dockerignore)
# but setuptools calls `git ls-files` when building the sdist.
WORKDIR /opt/chatmail
RUN --mount=type=cache,target=/root/.cache/pip \
git init -q && \
python3 -m venv /opt/cmdeploy && \
/opt/cmdeploy/bin/pip install -e chatmaild/ -e cmdeploy/
# Full source copy (editable install's .egg-link still points here)
COPY . /opt/chatmail/ COPY . /opt/chatmail/
WORKDIR /opt/chatmail
# Minimal chatmail.ini
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
# Dummy git repo init: .git/ is excluded from the build context (.dockerignore)
# but setuptools calls `git ls-files` when building the sdist.
RUN git init -q && \
python3 -m venv /opt/cmdeploy && \
/opt/cmdeploy/bin/pip install --no-cache-dir \
-e chatmaild/ -e cmdeploy/
RUN CMDEPLOY_STAGES=install \ RUN CMDEPLOY_STAGES=install \
CHATMAIL_INI=/tmp/chatmail.ini \ CHATMAIL_INI=/tmp/chatmail.ini \
CHATMAIL_NOSYSCTL=True \ CHATMAIL_NOSYSCTL=True \
@@ -59,39 +63,38 @@ RUN CMDEPLOY_STAGES=install \
RUN cp -a www/ /opt/chatmail-www/ RUN cp -a www/ /opt/chatmail-www/
# Remove build-only packages and their deps — not needed at runtime RUN rm -f /tmp/chatmail.ini
RUN apt-get purge -y gcc git python3-dev && \
apt-get autoremove -y && \
rm -f /tmp/chatmail.ini
# Record image version (used in deploy fingerprint at runtime). # Record image version (used in deploy fingerprint at runtime).
# GIT_HASH is passed as a build arg (from docker-compose or CI) so that # GIT_HASH is passed as a build arg (from docker-compose or CI) so that
# .git/ can be excluded from the build context via .dockerignore. # .git/ can be excluded from the build context via .dockerignore.
# Two files: chatmail-image-version is the immutable build hash (survives
# deploys); chatmail-version is overwritten by cmdeploy run and restored
# from the image version after each deploy in chatmail-init.sh.
ARG GIT_HASH=unknown ARG GIT_HASH=unknown
RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \ 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 TZ=:/etc/localtime ENV CHATMAIL_INI=/etc/chatmail/chatmail.ini
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
ARG CHATMAIL_INIT_SERVICE_PATH=/lib/systemd/system/chatmail-init.service ARG SETUP_CHATMAIL_SERVICE_PATH=/lib/systemd/system/setup_chatmail.service
COPY ./docker/chatmail-init.service "$CHATMAIL_INIT_SERVICE_PATH" COPY ./docker/files/setup_chatmail.service "$SETUP_CHATMAIL_SERVICE_PATH"
RUN ln -sf "$CHATMAIL_INIT_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/chatmail-init.service" RUN ln -sf "$SETUP_CHATMAIL_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/setup_chatmail.service"
# Remove default nginx site config at build time (not in entrypoint) # Remove default nginx site config at build time (not in entrypoint)
RUN rm -f /etc/nginx/sites-enabled/default RUN rm -f /etc/nginx/sites-enabled/default
COPY --chmod=555 ./docker/chatmail-init.sh /chatmail-init.sh COPY --chmod=555 ./docker/files/setup_chatmail_docker.sh /setup_chatmail_docker.sh
COPY --chmod=555 ./docker/entrypoint.sh /entrypoint.sh COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.sh
COPY --chmod=555 ./docker/healthcheck.sh /healthcheck.sh
HEALTHCHECK --interval=10s --start-period=180s --timeout=10s --retries=3 \ # Certificate monitoring as a proper systemd timer (not a background process)
CMD /healthcheck.sh COPY --chmod=555 ./docker/files/chatmail-certmon.sh /chatmail-certmon.sh
COPY ./docker/files/chatmail-certmon.service /lib/systemd/system/chatmail-certmon.service
COPY ./docker/files/chatmail-certmon.timer /lib/systemd/system/chatmail-certmon.timer
RUN ln -sf /lib/systemd/system/chatmail-certmon.timer /etc/systemd/system/timers.target.wants/chatmail-certmon.timer
HEALTHCHECK --interval=60s --timeout=10s --retries=3 \
CMD systemctl is-active dovecot postfix nginx unbound opendkim filtermail doveauth chatmail-metadata || exit 1
STOPSIGNAL SIGRTMIN+3 STOPSIGNAL SIGRTMIN+3
@@ -99,3 +102,4 @@ ENTRYPOINT ["/entrypoint.sh"]
CMD [ "--default-standard-output=journal+console", \ CMD [ "--default-standard-output=journal+console", \
"--default-standard-error=journal+console" ] "--default-standard-error=journal+console" ]

View File

@@ -0,0 +1,116 @@
# 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,11 +0,0 @@
# Used by .github/workflows/docker-ci.yaml
# The GHCR image is set via CHATMAIL_IMAGE env var at deploy time.
services:
chatmail:
image: ${CHATMAIL_IMAGE:-chatmail-relay:latest}
volumes:
- /srv/chatmail/chatmail.ini:/etc/chatmail/chatmail.ini
- /srv/chatmail/dkim:/etc/dkimkeys
- /srv/chatmail/certs:/var/lib/acme
environment:
TLS_EXTERNAL_CERT_AND_KEY: /var/lib/acme/live/${MAIL_DOMAIN}/fullchain /var/lib/acme/live/${MAIL_DOMAIN}/privkey

View File

@@ -1,9 +0,0 @@
#!/bin/bash
set -eo pipefail
CHATMAIL_INIT_SERVICE_PATH="${CHATMAIL_INIT_SERVICE_PATH:-/lib/systemd/system/chatmail-init.service}"
env_vars="MAIL_DOMAIN CMDEPLOY_STAGES CHATMAIL_INI TLS_EXTERNAL_CERT_AND_KEY PATH"
sed -i "s|<envs_list>|$env_vars|g" "$CHATMAIL_INIT_SERVICE_PATH"
exec /lib/systemd/systemd "$@"

View File

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

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

@@ -0,0 +1,28 @@
#!/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

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

12
docker/files/entrypoint.sh Executable file
View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
#!/bin/bash
# returns 0 when chatmail-init succeeded and all expected services are running.
set -e
test -f /run/chatmail-init.done
# Core services
services="chatmail-metadata doveauth dovecot filtermail filtermail-incoming nginx postfix unbound"
# Optional services
for svc in iroh-relay turnserver; do
systemctl is-enabled "$svc" 2>/dev/null && services="$services $svc"
done
exec systemctl is-active $services

View File

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

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

View File

@@ -0,0 +1,12 @@
#!/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 +1,7 @@
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"

View File

@@ -1,21 +0,0 @@
/* 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,18 +11,6 @@ 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
@@ -31,7 +19,6 @@ 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

File diff suppressed because one or more lines are too long