Compare commits

..

3 Commits

Author SHA1 Message Date
holger krekel
c849036d0b fix tar commands 2025-12-18 23:40:20 +01:00
holger krekel
bf371e7b6d use $OLD_IP4 and $NEW_IP4 to make docs more readable. Also streamline "set TTL to 5 minute" phrasing a bit. 2025-12-18 16:58:05 +01:00
missytake
35867153af docs: update migration guide after nine migration 2025-12-18 09:32:25 +01:00
84 changed files with 1453 additions and 2691 deletions

View File

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

View File

@@ -14,8 +14,7 @@ jobs:
# Otherwise `test_deployed_state` will be unhappy. # Otherwise `test_deployed_state` will be unhappy.
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
- 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
- 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'

View File

@@ -0,0 +1,100 @@
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:
group: ci-ipv4-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
steps:
- uses: jsok/serialize-workflow-action@515cd04c46d7ea7435c4a22a3b4419127afdefe9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- 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
- 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=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

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

@@ -0,0 +1,101 @@
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:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
steps:
- uses: jsok/serialize-workflow-action@515cd04c46d7ea7435c4a22a3b4419127afdefe9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- 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
- 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=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v

8
.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
@@ -164,9 +164,3 @@ cython_debug/
#.idea/ #.idea/
chatmail.zone chatmail.zone
# docker
/data/
/custom/
docker-compose.override.yaml
.env

View File

@@ -1,31 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## 1.9.0 2025-12-18
### Documentation
- Add RELEASE.md and CONTRIBUTING.md
- README update, mention Chatmail Cookbook project
### Bug Fixes
- Expire messages also from IMAP subfolders
- Use absolute path instead of relative path in message expiration script
- Restart Postfix and Dovecot automatically on failure
- acmetool: Use a fixed name and `reconcile` instead of `want`
### Features
- Report DKIM error code in SMTP response
- Remove development notice from the web pages
### Miscellaneous Tasks
- Update the heading in the CHANGELOG.md
- Setup git-cliff
- Run tests against ci-chatmail.testrun.org instead of nine.testrun.org
- Cleanup remaining echobot code, remove echobot user from deployment and passthrough recipients
## 1.8.0 2025-12-12 ## 1.8.0 2025-12-12
- Add imap_compress option to chatmail.ini - Add imap_compress option to chatmail.ini

View File

@@ -24,6 +24,7 @@ where = ['src']
[project.scripts] [project.scripts]
doveauth = "chatmaild.doveauth:main" doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main" chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
chatmail-expire = "chatmaild.expire:main" chatmail-expire = "chatmaild.expire:main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"

View File

@@ -1,4 +1,3 @@
import os
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -21,8 +20,7 @@ class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
self.mail_domain = params["mail_domain"] self.mail_domain = params["mail_domain"]
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60)) self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280")) self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params["delete_mails_after"] self.delete_mails_after = params["delete_mails_after"]
@@ -34,18 +32,16 @@ class Config:
self.passthrough_senders = params["passthrough_senders"].split() self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split() self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "") self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080")) self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.get("filtermail_smtp_port_incoming", "10081") params["filtermail_smtp_port_incoming"]
) )
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025")) self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.get("postfix_reinject_port_incoming", "10026") params["postfix_reinject_port_incoming"]
) )
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.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.acme_email = params.get("acme_email", "") self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.imap_compress = params.get("imap_compress", "false").lower() == "true" self.imap_compress = params.get("imap_compress", "false").lower() == "true"
@@ -60,31 +56,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

@@ -22,7 +22,7 @@ class DictProxy:
wfile.flush() wfile.flush()
def handle_dovecot_request(self, msg, transactions): def handle_dovecot_request(self, msg, transactions):
# see https://doc.dovecot.org/2.3/developer_manual/design/dict_protocol/#dovecot-dict-protocol # see https://doc.dovecot.org/developer_manual/design/dict_protocol/#dovecot-dict-protocol
short_command = msg[0] short_command = msg[0]
parts = msg[1:].split("\t") parts = msg[1:].split("\t")

View File

@@ -16,7 +16,7 @@ NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str): def encrypt_password(password: str):
# https://doc.dovecot.org/2.3/configuration_manual/authentication/password_schemes/ # https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt_r.crypt(password, crypt_r.METHOD_SHA512) passhash = crypt_r.crypt(password, crypt_r.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash return "{SHA512-CRYPT}" + passhash

View File

@@ -14,7 +14,7 @@ from stat import S_ISREG
from chatmaild.config import read_config from chatmaild.config import read_config
FileEntry = namedtuple("FileEntry", ("path", "mtime", "size")) FileEntry = namedtuple("FileEntry", ("relpath", "mtime", "size"))
def iter_mailboxes(basedir, maxnum): def iter_mailboxes(basedir, maxnum):
@@ -51,27 +51,33 @@ class MailboxStat:
def __init__(self, basedir): def __init__(self, basedir):
self.basedir = str(basedir) self.basedir = str(basedir)
# all detected messages in cur/new/tmp folders
self.messages = [] self.messages = []
self.extrafiles = []
self.scandir(self.basedir)
def scandir(self, folderdir): # all detected files in mailbox top dir
for name in os_listdir_if_exists(folderdir): self.extrafiles = []
path = f"{folderdir}/{name}"
# scan all relevant files (without recursion)
old_cwd = os.getcwd()
try:
os.chdir(self.basedir)
except FileNotFoundError:
return
for name in os_listdir_if_exists("."):
if name in ("cur", "new", "tmp"): if name in ("cur", "new", "tmp"):
for msg_name in os_listdir_if_exists(path): for msg_name in os_listdir_if_exists(name):
entry = get_file_entry(f"{path}/{msg_name}") entry = get_file_entry(f"{name}/{msg_name}")
if entry is not None: if entry is not None:
self.messages.append(entry) self.messages.append(entry)
elif os.path.isdir(path):
self.scandir(path)
else: else:
entry = get_file_entry(path) entry = get_file_entry(name)
if entry is not None: if entry is not None:
self.extrafiles.append(entry) self.extrafiles.append(entry)
if name == "password": if name == "password":
self.last_login = entry.mtime self.last_login = entry.mtime
self.extrafiles.sort(key=lambda x: -x.size) self.extrafiles.sort(key=lambda x: -x.size)
os.chdir(old_cwd)
def print_info(msg): def print_info(msg):
@@ -124,6 +130,13 @@ class Expiry:
self.remove_mailbox(mbox.basedir) self.remove_mailbox(mbox.basedir)
return return
# all to-be-removed files are relative to the mailbox basedir
try:
os.chdir(mbox.basedir)
except FileNotFoundError:
print_info(f"mailbox not found/vanished {mbox.basedir}")
return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
if self.verbose: if self.verbose:
date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None
@@ -134,17 +147,16 @@ class Expiry:
self.all_files += len(mbox.messages) self.all_files += len(mbox.messages)
for message in mbox.messages: for message in mbox.messages:
if message.mtime < cutoff_mails: if message.mtime < cutoff_mails:
self.remove_file(message.path, mtime=message.mtime) self.remove_file(message.relpath, mtime=message.mtime)
elif message.size > 200000 and message.mtime < cutoff_large_mails: elif message.size > 200000 and message.mtime < cutoff_large_mails:
# we only remove noticed large files (not unnoticed ones in new/) # we only remove noticed large files (not unnoticed ones in new/)
parts = message.path.split("/") if message.relpath.startswith("cur/"):
if len(parts) >= 2 and parts[-2] == "cur": self.remove_file(message.relpath, mtime=message.mtime)
self.remove_file(message.path, mtime=message.mtime)
else: else:
continue continue
changed = True changed = True
if changed: if changed:
self.remove_file(f"{mbox.basedir}/maildirsize") self.remove_file("maildirsize")
def get_summary(self): def get_summary(self):
return ( return (

View File

@@ -0,0 +1,381 @@
#!/usr/bin/env python3
import asyncio
import base64
import binascii
import sys
import time
from email import policy
from email.parser import BytesParser
from email.utils import parseaddr
from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP
from .config import read_config
ENCRYPTION_NEEDED_523 = "523 Encryption Needed: Invalid Unencrypted Mail"
def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload.
OpenPGP payload must consist only of PKESK and SKESK packets
terminated by a single SEIPD packet.
Returns True if OpenPGP payload is correct,
False otherwise.
May raise IndexError while trying to read OpenPGP packet header
if it is truncated.
"""
i = 0
while i < len(payload):
# Only OpenPGP format is allowed.
if payload[i] & 0xC0 != 0xC0:
return False
packet_type_id = payload[i] & 0x3F
i += 1
while payload[i] >= 224 and payload[i] < 255:
# Partial body length.
partial_length = 1 << (payload[i] & 0x1F)
i += 1 + partial_length
if payload[i] < 192:
# One-octet length.
body_len = payload[i]
i += 1
elif payload[i] < 224:
# Two-octet length.
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
i += 2
elif payload[i] == 255:
# Five-octet length.
body_len = (
(payload[i + 1] << 24)
| (payload[i + 2] << 16)
| (payload[i + 3] << 8)
| payload[i + 4]
)
i += 5
else:
# Impossible, partial body length was processed above.
return False
i += body_len
if i == len(payload):
# Last packet should be
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
#
# This is the only place where this function may return `True`.
return packet_type_id == 18
elif packet_type_id not in [1, 3]:
# All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK)
# or
# Symmetric-Key Encrypted Session Key Packet (SKESK)
return False
return False
def check_armored_payload(payload: str, outgoing: bool):
"""Check the armored PGP message for invalid content.
:param payload: the armored PGP message
:param outgoing: whether the message is outgoing or incoming
:return: whether the message is a valid PGP message
"""
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
while payload.endswith("\r\n"):
payload = payload.removesuffix("\r\n")
suffix = "-----END PGP MESSAGE-----"
if not payload.endswith(suffix):
return False
payload = payload.removesuffix(suffix)
version_comment = "Version: "
if payload.startswith(version_comment):
if outgoing: # Disallow comments in outgoing messages
return False
# Remove comments from incoming messages
payload = payload.partition("\r\n")[2]
while payload.startswith("\r\n"):
payload = payload.removeprefix("\r\n")
# Remove CRC24.
payload = payload.rpartition("=")[0]
try:
payload = base64.b64decode(payload)
except binascii.Error:
return False
try:
return check_openpgp_payload(payload)
except IndexError:
return False
def is_securejoin(message):
if message.get("secure-join") not in ["vc-request", "vg-request"]:
return False
if not message.is_multipart():
return False
parts_count = 0
for part in message.iter_parts():
parts_count += 1
if parts_count > 1:
return False
if part.is_multipart():
return False
if part.get_content_type() != "text/plain":
return False
payload = part.get_payload().strip().lower()
if payload not in ("secure-join: vc-request", "secure-join: vg-request"):
return False
return True
def check_encrypted(message, outgoing=True):
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
"""
if not message.is_multipart():
return False
if message.get_content_type() != "multipart/encrypted":
return False
parts_count = 0
for part in message.iter_parts():
# We explicitly check Content-Type of each part later,
# but this is to be absolutely sure `get_payload()` returns string and not list.
if part.is_multipart():
return False
if parts_count == 0:
if part.get_content_type() != "application/pgp-encrypted":
return False
payload = part.get_payload()
if payload.strip() != "Version: 1":
return False
elif parts_count == 1:
if part.get_content_type() != "application/octet-stream":
return False
if not check_armored_payload(part.get_payload(), outgoing=outgoing):
return False
else:
return False
parts_count += 1
return True
async def asyncmain_beforequeue(config, mode):
if mode == "outgoing":
port = config.filtermail_smtp_port
handler = OutgoingBeforeQueueHandler(config)
else:
port = config.filtermail_smtp_port_incoming
handler = IncomingBeforeQueueHandler(config)
HackedController(
handler,
hostname="127.0.0.1",
port=port,
data_size_limit=config.max_message_size,
).start()
def recipient_matches_passthrough(recipient, passthrough_recipients):
for addr in passthrough_recipients:
if recipient == addr:
return True
if addr[0] == "@" and recipient.endswith(addr):
return True
return False
class HackedController(Controller):
def factory(self):
return SMTPDiscardRCPTO_options(self.handler, **self.SMTP_kwargs)
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# Ignore RCPT TO parameters.
#
# Otherwise parameters such as `ORCPT=...`
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
# make aiosmtpd reject the message here:
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
return {}
class OutgoingBeforeQueueHandler:
def __init__(self, config):
self.config = config
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
log_info(f"handle_MAIL from {address}")
envelope.mail_from = address
max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
return f"450 4.7.1: Too much mail from {address}"
parts = envelope.mail_from.split("@")
if len(parts) != 2:
return f"500 Invalid from address <{envelope.mail_from!r}>"
return "250 OK"
async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
log_info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=True)
_, from_addr = parseaddr(message.get("from").strip())
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if mail_encrypted or is_securejoin(message):
print("Outgoing: Filtering encrypted mail.", file=sys.stderr)
return
print("Outgoing: Filtering unencrypted mail.", file=sys.stderr)
if envelope.mail_from in self.config.passthrough_senders:
return
# allow self-sent Autocrypt Setup Message
if envelope.rcpt_tos == [from_addr]:
if message.get("subject") == "Autocrypt Setup Message":
if message.get_content_type() == "multipart/mixed":
return
passthrough_recipients = self.config.passthrough_recipients
for recipient in envelope.rcpt_tos:
if recipient_matches_passthrough(recipient, passthrough_recipients):
continue
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class IncomingBeforeQueueHandler:
def __init__(self, config):
self.config = config
async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
log_info("re-injecting the mail that passed checks")
# the smtp daemon on reinject_port_incoming gives it to dkim milter
# which looks at source address to determine whether to verify or sign
client = SMTPClient(
"localhost",
self.config.postfix_reinject_port_incoming,
source_address=("127.0.0.2", 0),
)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=False)
if mail_encrypted or is_securejoin(message):
print("Incoming: Filtering encrypted mail.", file=sys.stderr)
return
print("Incoming: Filtering unencrypted mail.", file=sys.stderr)
# we want cleartext mailer-daemon messages to pass through
# chatmail core will typically not display them as normal messages
if message.get("auto-submitted"):
_, from_addr = parseaddr(message.get("from").strip())
if from_addr.lower().startswith("mailer-daemon@"):
if message.get_content_type() == "multipart/report":
return
for recipient in envelope.rcpt_tos:
user = self.config.get_user(recipient)
if user is None or user.is_incoming_cleartext_ok():
continue
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class SendRateLimiter:
def __init__(self):
self.addr2timestamps = {}
def is_sending_allowed(self, mail_from, max_send_per_minute):
last = self.addr2timestamps.setdefault(mail_from, [])
now = time.time()
last[:] = [ts for ts in last if ts >= (now - 60)]
if len(last) <= max_send_per_minute:
last.append(now)
return True
return False
def log_info(msg):
print(msg, file=sys.stderr)
def main():
args = sys.argv[1:]
assert len(args) == 2
config = read_config(args[0])
mode = args[1]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
assert mode in ["incoming", "outgoing"]
task = asyncmain_beforequeue(config, mode)
loop.create_task(task)
log_info("entering serving loop")
loop.run_forever()

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

@@ -11,14 +11,11 @@ mail_domain = {mail_domain}
# Restrictions on user addresses # Restrictions on user addresses
# #
# email sending rate per user and minute # how many mails a user can send out per minute
max_user_send_per_minute = 60 max_user_send_per_minute = 60
# per-user max burst size for sending rate limiting (GCRA bucket capacity)
max_user_send_burst_size = 10
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
max_mailbox_size = 500M max_mailbox_size = 100M
# maximum message size for an e-mail in bytes # maximum message size for an e-mail in bytes
max_message_size = 31457280 max_message_size = 31457280
@@ -48,13 +45,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

@@ -33,7 +33,7 @@ def test_read_config_testrun(make_config):
assert config.filtermail_smtp_port == 10080 assert config.filtermail_smtp_port == 10080
assert config.postfix_reinject_port == 10025 assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60 assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "500M" assert config.max_mailbox_size == "100M"
assert config.delete_mails_after == "20" assert config.delete_mails_after == "20"
assert config.delete_large_after == "7" assert config.delete_large_after == "7"
assert config.username_min_length == 9 assert config.username_min_length == 9
@@ -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

@@ -17,17 +17,19 @@ from chatmaild.expire import main as expiry_main
from chatmaild.fsreport import main as report_main from chatmaild.fsreport import main as report_main
def fill_mbox(folderdir): def fill_mbox(basedir):
password = folderdir.joinpath("password") basedir1 = basedir.joinpath("mailbox1@example.org")
basedir1.mkdir()
password = basedir1.joinpath("password")
password.write_text("xxx") password.write_text("xxx")
folderdir.joinpath("maildirsize").write_text("xxx") basedir1.joinpath("maildirsize").write_text("xxx")
garbagedir = folderdir.joinpath("garbagedir") garbagedir = basedir1.joinpath("garbagedir")
garbagedir.mkdir() garbagedir.mkdir()
garbagedir.joinpath("bimbum").write_text("hello")
create_new_messages(folderdir, ["cur/msg1"], size=500) create_new_messages(basedir1, ["cur/msg1"], size=500)
create_new_messages(folderdir, ["new/msg2"], size=600) create_new_messages(basedir1, ["new/msg2"], size=600)
return basedir1
def create_new_messages(basedir, relpaths, size=1000, days=0): def create_new_messages(basedir, relpaths, size=1000, days=0):
@@ -43,21 +45,8 @@ def create_new_messages(basedir, relpaths, size=1000, days=0):
@pytest.fixture @pytest.fixture
def mbox1(example_config): def mbox1(example_config):
mboxdir = example_config.mailboxes_dir.joinpath("mailbox1@example.org") basedir1 = fill_mbox(example_config.mailboxes_dir)
mboxdir.mkdir() return MailboxStat(basedir1)
fill_mbox(mboxdir)
return MailboxStat(mboxdir)
def test_deltachat_folder(example_config):
"""Test old setups that might have a .DeltaChat folder where messages also need to get removed."""
mboxdir = example_config.mailboxes_dir.joinpath("mailbox1@example.org")
mboxdir.mkdir()
mbox2dir = mboxdir.joinpath(".DeltaChat")
mbox2dir.mkdir()
fill_mbox(mbox2dir)
mb = MailboxStat(mboxdir)
assert len(mb.messages) == 2
def test_filentry_ordering(tmp_path): def test_filentry_ordering(tmp_path):
@@ -87,7 +76,7 @@ def test_stats_mailbox(mbox1):
create_new_messages(mbox1.basedir, ["large-extra"], size=1000) create_new_messages(mbox1.basedir, ["large-extra"], size=1000)
create_new_messages(mbox1.basedir, ["index-something"], size=3) create_new_messages(mbox1.basedir, ["index-something"], size=3)
mbox2 = MailboxStat(mbox1.basedir) mbox2 = MailboxStat(mbox1.basedir)
assert len(mbox2.extrafiles) == 5 assert len(mbox2.extrafiles) == 4
assert mbox2.extrafiles[0].size == 1000 assert mbox2.extrafiles[0].size == 1000
# cope well with mailbox dirs that have no password (for whatever reason) # cope well with mailbox dirs that have no password (for whatever reason)

View File

@@ -0,0 +1,361 @@
import pytest
from chatmaild.filtermail import (
IncomingBeforeQueueHandler,
OutgoingBeforeQueueHandler,
SendRateLimiter,
check_armored_payload,
check_encrypted,
is_securejoin,
)
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
@pytest.fixture
def handler(make_config, maildomain):
config = make_config(maildomain)
return OutgoingBeforeQueueHandler(config)
@pytest.fixture
def inhandler(make_config, maildomain):
config = make_config(maildomain)
return IncomingBeforeQueueHandler(config)
def test_reject_forged_from(maildata, gencreds, handler):
class env:
mail_from = gencreds()[0]
rcpt_tos = [gencreds()[0]]
# test that the filter lets good mail through
to_addr = gencreds()[0]
env.content = maildata(
"encrypted.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes()
assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail
env.content = maildata(
"encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes()
error = handler.check_DATA(envelope=env)
assert "500" in error
def test_filtermail_no_encryption_detection(maildata):
msg = maildata(
"plain.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
# https://xkcd.com/1181/
msg = maildata(
"fake-encrypted.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert not check_encrypted(msg)
def test_filtermail_securejoin_detection(maildata):
msg = maildata(
"securejoin-vc.eml", from_addr="some@example.org", to_addr="other@example.org"
)
assert is_securejoin(msg)
msg = maildata(
"securejoin-vc-fake.eml",
from_addr="some@example.org",
to_addr="other@example.org",
)
assert not is_securejoin(msg)
def test_filtermail_encryption_detection(maildata):
msg = maildata(
"encrypted.eml",
from_addr="1@example.org",
to_addr="2@example.org",
subject="Subject does not matter, will be replaced anyway",
)
assert check_encrypted(msg)
def test_filtermail_no_literal_packets(maildata):
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
assert not check_encrypted(msg)
def test_filtermail_unencrypted_mdn(maildata, gencreds):
"""Unencrypted MDNs should not pass."""
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr=from_addr, to_addr=to_addr)
assert not check_encrypted(msg)
def test_send_rate_limiter():
limiter = SendRateLimiter()
for i in range(100):
if limiter.is_sending_allowed("some@example.org", 10):
if i <= 10:
continue
pytest.fail("limiter didn't work")
else:
assert i == 11
break
def test_cleartext_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr]
false_to = "privacy@something.org"
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2)
def test_cleartext_self_send_autocrypt_setup_message(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = from_addr
msg = maildata("asm.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not handler.check_DATA(envelope=env)
def test_cleartext_send_fails(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = gencreds()[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
res = handler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
def test_cleartext_incoming_fails(maildata, gencreds, inhandler):
from_addr = gencreds()[0]
to_addr, password = gencreds()
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
user = inhandler.config.get_user(to_addr)
user.set_password(password)
res = inhandler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
user.allow_incoming_cleartext()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_incoming_mailer_daemon(maildata, gencreds, inhandler):
from_addr = "mailer-daemon@example.org"
to_addr = gencreds()[0]
msg = maildata("mailer-daemon.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = "privacy@x.y.z"
handler.config.passthrough_recipients = ["@x.y.z"]
false_to = "something@x.y"
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
class env2:
mail_from = from_addr
rcpt_tos = [to_addr, false_to]
content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2)
def test_cleartext_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0]
to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1]
msg = maildata("plain.eml", from_addr=acc1, to_addr=to_addr)
class env:
mail_from = acc1
rcpt_tos = to_addr
content = msg.as_bytes()
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
def test_check_armored_payload():
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
comment = "Version: ProtonMail\r\n"
payload = """\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
=b5Kp\r
-----END PGP MESSAGE-----\r
\r
\r
"""
commented_payload = prefix + comment + payload
assert check_armored_payload(commented_payload, outgoing=False) == True
assert check_armored_payload(commented_payload, outgoing=True) == False
payload = prefix + payload
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = """-----BEGIN PGP MESSAGE-----\r
\r
HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
payload = """-----BEGIN PGP MESSAGE-----\r
\r
=njUN
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload, outgoing=False) == False
assert check_armored_payload(payload, outgoing=True) == False
# Test payload using partial body length
# as generated by GopenPGP.
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wV4DdCVjRfOT3TQSAQdAY5+pjT6mlCxPGdR3be4w7oJJRUGIPI/Vnh+mJxGSm34w\r
LNlVc89S1g22uQYFif2sUJsQWbpoHpNkuWpkSgOaHmNvrZiY/YU5iv+cZ3LbmtUG\r
0uoBisSHh9O1c+5sYZSbrvYZ1NOwlD7Fv/U5/Mw4E5+CjxfdgNGp5o3DDddzPK78\r
jseDhdSXxnaiIJC93hxNX6R1RPt3G2gukyzx69wciPQShcF8zf3W3o75Ed7B8etV\r
QEeB16xzdFhKa9JxdjTu3osgCs21IO7wpcFkjc7nZzlW6jPnELJJaNmv4yOOCjMp\r
6YAkaN/BkL+jHTznHDuDsT5ilnTXpwHDU1Cm9PIx/KFcNCQnIB+2DcdIHPHUH1ci\r
jvqoeXAVWjKXEjS7PqPFuP/xGbrWG2ugs+toXJOKbgRkExvKs1dwPFKrgghvCVbW\r
AcKejQKAPArLwpkA7aD875TZQShvGt74fNs45XBlGOYOnNOAJ1KAmzrXLIDViyyB\r
kDsmTBk785xofuCkjBpXSe6vsMprPzCteDfaUibh8FHeJjucxPerwuOPEmnogNaf\r
YyL4+iy8H8I9/p7pmUqILprxTG0jTOtlk0bTVzeiF56W1xbtSEMuOo4oFbQTyOM2\r
bKXaYo774Jm+rRtKAnnI2dtf9RpK19cog6YNzfYjesLKbXDsPZbN5rmwyFiCvvxC\r
kQ6JLob+B2fPdY2gzy7LypxktS8Zi1HJcWDHJGVmQodaDLqKUObb4M26bXDe6oxI\r
NS8PJz5exVbM3KhZnUOEn6PJRBBf5a/ZqxlhZPcQo/oBuhKpBRpO5kSDwPIUByu3\r
UlXLSkpMqe9pUarAOEuQjfl2RVY7U+RrQYp4YP5keMO+i8NCefAFbowTTufO1JIq\r
2nVgCi/QVnxZyEc9OYt/8AE3g4cdojE+vsSDifZLSWYIetpfrohHv3dT3StD1QRG\r
0QE6qq6oKpg/IL0cjvuX4c7a7bslv2fXp8t75y37RU6253qdIebhxc/cRhPbc/yu\r
p0YLyD4SrvKTLP2ZV95jT4IPEpqm4AN3QmiOzdtqR2gLyb62L8QfqI/FdwsIiRiM\r
hqydwoqt/lfSqG1WKPh+6EkMkH+TDiCC1BQdbN1MNcyUtcjb35PR2c8Ld2TF3guA\r
jLIqMt/Vb7hBoMb2FcsOYY25ka9oV62OwgKWLXnFzk+modMR5fzb4kxVVAYEqP+D\r
T5KO1Vs76v1fyPGOq6BbBCvLwTqe/e6IZInJles4v5jrhnLcGKmNGivCUDe6X6NY\r
UKNt5RsZllwDQpaAb5dMNhyrk8SgIE7TBI7rvqIdUCE52Vy+0JDxFg5olRpFUfO6\r
/MyTW3Yo/ekk/npHr7iYYqJTCc21bDGLWQcIo/XO7WPxrKNWGBNPFnkRdw0MaKr4\r
+cEM3V8NFnSEpC12xA+RX/CezuJtwXZK5MpG76eYqMO6qyC+c25YcFecEufDZDxx\r
ZLqRszVRyxyWPtk/oIeQK2v9wOqY6N9/ff01gHz69vqYqN5bUw/QKZsmx1zW+gPw\r
6x2tDK2BHeYl182gCbhlKISRFwCtbjqZSkiKWao/VtygHkw0fK34avJuyQ/X9YaN\r
BRy+7Lf3VA53pnB5WJ1xwRXN8VDvmZeXzv2krHveCMemj0OjnRoCLu117xN0A5m9\r
Fm/RoDix5PolDHtWTtr2m1n2hp2LHnj8at9lFEd0SKhAYHVL9KjzycwWODZRXt+x\r
zGDDuooEeTvdY5NLyKcl4gETz1ZP4Ez5jGGjhPSwSpq1mU7UaJ9ZXXdr4KHyifW6\r
ggNzNsGhXTap7IWZpTtqXABydfiBshmH2NjqtNDwBweJVSgP10+r0WhMWlaZs6xl\r
V3o5yskJt6GlkwpJxZrTvN6Tiww/eW7HFV6NGf7IRSWY5tJc/iA7/92tOmkdvJ1q\r
myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
1CcnTPVtApPZJEQzAWJEgVAM8uIlkqWJJMgyWT34sTkdBeCUFGloXQFs9Yxd0AGf\r
/zHEkYZSTKpVSvAIGu4=\r
=6iHb\r
-----END PGP MESSAGE-----\r
"""
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True

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(
@@ -66,10 +58,6 @@ def test_one_mail(
popen = make_popen(["filtermail", path, filtermail_mode]) popen = make_popen(["filtermail", path, filtermail_mode])
line = popen.stderr.readline().strip() line = popen.stderr.readline().strip()
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod
if b"DKIM verification DISABLED!" in line:
line = popen.stderr.readline().strip()
if b"loop" not in line: if b"loop" not in line:
print(line.decode("ascii"), file=sys.stderr) print(line.decode("ascii"), file=sys.stderr)
pytest.fail("starting filtermail failed") pytest.fail("starting filtermail failed")

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

@@ -5,11 +5,6 @@ import os
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
def has_systemd():
"""Returns False during Docker image builds or any other non-systemd environment."""
return os.path.isdir("/run/systemd/system")
def get_resource(arg, pkg=__package__): def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)
@@ -22,8 +17,9 @@ def configure_remote_units(mail_domain, units) -> None:
# install systemd units # install systemd units
for fn in units: for fn in units:
execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{fn}", execpath=f"{remote_venv_dir}/bin/{execpath}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=mail_domain, mail_domain=mail_domain,

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
@@ -70,11 +71,6 @@ def run_cmd_options(parser):
action="store_true", action="store_true",
help="install/upgrade the server, but disable postfix & dovecot for now", help="install/upgrade the server, but disable postfix & dovecot for now",
) )
parser.add_argument(
"--website-only",
action="store_true",
help="only update/deploy the website, skipping full server upgrade/deployment, useful when you only changed/updated the web pages and don't need to re-run a full server upgrade",
)
parser.add_argument( parser.add_argument(
"--skip-dns-check", "--skip-dns-check",
dest="dns_check_disabled", dest="dns_check_disabled",
@@ -90,25 +86,20 @@ 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()
env["CHATMAIL_INI"] = args.inipath env["CHATMAIL_INI"] = args.inipath
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else ""
env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else "" env["CHATMAIL_DISABLE_MAIL"] = "True" if args.disable_mail else ""
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
if not args.dns_check_disabled:
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
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"]:
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"):
@@ -117,14 +108,9 @@ def run_cmd(args, out):
try: try:
retcode = out.check_call(cmd, env=env) retcode = out.check_call(cmd, env=env)
if args.website_only: if retcode == 0:
if retcode == 0:
out.green("Website deployment completed.")
else:
out.red("Website deployment failed.")
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 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 +137,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 +149,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 +189,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 +213,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 +304,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.",
) )
@@ -329,7 +314,7 @@ def add_config_option(parser):
"--config", "--config",
dest="inipath", dest="inipath",
action="store", action="store",
default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")), default=Path("chatmail.ini"),
type=Path, type=Path,
help="path to the chatmail.ini file", help="path to the chatmail.ini file",
) )
@@ -381,7 +366,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

@@ -2,7 +2,6 @@
Chat Mail pyinfra deploy. Chat Mail pyinfra deploy.
""" """
import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -12,7 +11,6 @@ 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.api import FactBase
from pyinfra.facts import hardware
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
@@ -26,22 +24,18 @@ from .basedeploy import (
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer
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
class Port(FactBase): class Port(FactBase):
""" """
Returns the process occupying a port. Returns the process occuping a port.
""" """
def command(self, port: int) -> str: def command(self, port: int) -> str:
@@ -69,8 +63,6 @@ def _build_chatmaild(dist_dir) -> None:
def remove_legacy_artifacts(): def remove_legacy_artifacts():
if not has_systemd():
return
# disable legacy doveauth-dictproxy.service # disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"): if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service( systemd.service(
@@ -148,10 +140,6 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
class UnboundDeployer(Deployer): class UnboundDeployer(Deployer):
def __init__(self, config):
self.config = config
self.need_restart = False
def install(self): def install(self):
# Run local DNS resolver `unbound`. # Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf # `resolvconf` takes care of setting up /etc/resolv.conf
@@ -188,27 +176,6 @@ class UnboundDeployer(Deployer):
"unbound-anchor -a /var/lib/unbound/root.key || true", "unbound-anchor -a /var/lib/unbound/root.key || true",
], ],
) )
if self.config.disable_ipv6:
files.directory(
path="/etc/unbound/unbound.conf.d",
present=True,
user="root",
group="root",
mode="755",
)
conf = files.put(
src=get_resource("unbound/unbound.conf.j2"),
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
user="root",
group="root",
mode="644",
)
else:
conf = files.file(
path="/etc/unbound/unbound.conf.d/chatmail.conf",
present=False,
)
self.need_restart |= conf.changed
def activate(self): def activate(self):
server.shell( server.shell(
@@ -223,7 +190,6 @@ class UnboundDeployer(Deployer):
service="unbound.service", service="unbound.service",
running=True, running=True,
enabled=True, enabled=True,
restarted=self.need_restart,
) )
@@ -305,7 +271,7 @@ class LegacyRemoveDeployer(Deployer):
present=False, present=False,
) )
# remove echobot if it is still running # remove echobot if it is still running
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"): if host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service( systemd.service(
name="Disable echobot.service", name="Disable echobot.service",
service="echobot.service", service="echobot.service",
@@ -450,6 +416,8 @@ class ChatmailVenvDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
"filtermail",
"filtermail-incoming",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -534,106 +502,66 @@ class GithashDeployer(Deployer):
except Exception: except Exception:
git_diff = "" git_diff = ""
files.put( files.put(
name="Upload chatmail relay git commit hash", name="Upload chatmail relay git commiit hash",
src=StringIO(git_hash + git_diff), src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version", dest="/etc/chatmail-version",
mode="700", mode="700",
) )
def get_tls_deployer(config, mail_domain): def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
"""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:
"""Deploy a chat-mail instance. """Deploy a chat-mail instance.
:param config_path: path to chatmail.ini :param config_path: path to chatmail.ini
:param disable_mail: whether to disable postfix & dovecot :param disable_mail: whether to disable postfix & dovecot
:param website_only: if True, only deploy the website
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
mail_domain = config.mail_domain mail_domain = config.mail_domain
if website_only:
Deployment().perform_stages([WebsiteDeployer(config)])
return
if host.get_fact(Port, port=53) != "unbound": if host.get_fact(Port, port=53) != "unbound":
files.line( files.line(
name="Add 9.9.9.9 to resolv.conf", name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf", path="/etc/resolv.conf",
# Guard against resolv.conf missing a trailing newline (SolusVM bug). line="nameserver 9.9.9.9",
line="\nnameserver 9.9.9.9",
) )
# Check if mtail_address interface is available (if configured) port_services = [
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'): (["master", "smtpd"], 25),
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) ("unbound", 53),
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] ("acmetool", 80),
if config.mtail_address not in all_addresses: (["imap-login", "dovecot"], 143),
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n") ("nginx", 443),
exit(1) (["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
if running_service:
if running_service not in service:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
if not os.environ.get("CHATMAIL_NOPORTCHECK"): tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
]
if config.tls_cert_mode == "acme":
port_services.append(("acmetool", 402))
port_services += [
(["imap-login", "dovecot"], 143),
# acmetool previously listened on port 80,
# so don't complain during upgrade that moved it to port 402
# and gave the port to nginx.
(["acmetool", "nginx"], 80),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("mtail", 3903),
("stats", 3904),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
services = [service] if isinstance(service, str) else service
if running_service:
if running_service not in services:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(mail_domain), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(),
TurnDeployer(mail_domain), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, AcmetoolDeployer(config.acme_email, tls_domains),
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

@@ -4,7 +4,7 @@ iterate_prefix = userdb/
default_pass_scheme = plain default_pass_scheme = plain
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash). # %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
# See <https://doc.dovecot.org/2.3/configuration_manual/config_file/config_variables/#modifiers> # See <https://doc.dovecot.org/configuration_manual/config_file/config_variables/#modifiers>
# for documentation. # for documentation.
# #
# We escape user-provided input and use double quote as a separator. # We escape user-provided input and use double quote as a separator.

View File

@@ -1,6 +1,3 @@
import os
import urllib.request
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.server import Arch, Sysctl from pyinfra.facts.server import Arch, Sysctl
@@ -12,7 +9,6 @@ from cmdeploy.basedeploy import (
activate_remote_units, activate_remote_units,
configure_remote_units, configure_remote_units,
get_resource, get_resource,
has_systemd,
) )
@@ -26,11 +22,10 @@ class DovecotDeployer(Deployer):
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled): if not "dovecot.service" in host.get_fact(SystemdEnabled):
return # already installed and running _install_dovecot_package("core", arch)
_install_dovecot_package("core", arch) _install_dovecot_package("imapd", arch)
_install_dovecot_package("imapd", arch) _install_dovecot_package("lmtpd", arch)
_install_dovecot_package("lmtpd", arch)
def configure(self): def configure(self):
configure_remote_units(self.config.mail_domain, self.units) configure_remote_units(self.config.mail_domain, self.units)
@@ -42,7 +37,9 @@ class DovecotDeployer(Deployer):
restart = False if self.disable_mail else self.need_restart restart = False if self.disable_mail else self.need_restart
systemd.service( systemd.service(
name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot", name="disable dovecot for now"
if self.disable_mail
else "Start and enable Dovecot",
service="dovecot.service", service="dovecot.service",
running=False if self.disable_mail else True, running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True, enabled=False if self.disable_mail else True,
@@ -52,21 +49,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):
@@ -130,21 +116,20 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
) )
need_restart |= lua_push_notification_script.changed need_restart |= lua_push_notification_script.changed
# as per https://doc.dovecot.org/2.3/configuration_manual/os/ # as per https://doc.dovecot.org/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
if not os.environ.get("CHATMAIL_NOSYSCTL"): for name in ("max_user_instances", "max_user_watches"):
for name in ("max_user_instances", "max_user_watches"): key = f"fs.inotify.{name}"
key = f"fs.inotify.{name}" if host.get_fact(Sysctl)[key] > 65535:
if host.get_fact(Sysctl)[key] > 65535: # Skip updating limits if already sufficient
# Skip updating limits if already sufficient # (enables running in incus containers where sysctl readonly)
# (enables running in incus containers where sysctl readonly) continue
continue server.sysctl(
server.sysctl( name=f"Change {key}",
name=f"Change {key}", key=key,
key=key, value=65535,
value=65535, persist=True,
persist=True, )
)
timezone_env = files.line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",
@@ -160,11 +145,4 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
) )
daemon_reload |= restart_conf.changed daemon_reload |= restart_conf.changed
# Validate dovecot configuration before restart
if need_restart:
server.shell(
name="Validate dovecot configuration",
commands=["doveconf -n >/dev/null"],
)
return need_restart, daemon_reload return need_restart, daemon_reload

View File

@@ -1,7 +1,7 @@
## Dovecot configuration file ## Dovecot configuration file
{% if disable_ipv6 %} {% if disable_ipv6 %}
listen = 0.0.0.0 listen = *
{% endif %} {% endif %}
protocols = imap lmtp protocols = imap lmtp
@@ -26,7 +26,7 @@ default_client_limit = 20000
# Increase number of logged in IMAP connections. # Increase number of logged in IMAP connections.
# Each connection is handled by a separate `imap` process. # Each connection is handled by a separate `imap` process.
# `imap` process should have `client_limit=1` as described in # `imap` process should have `client_limit=1` as described in
# <https://doc.dovecot.org/2.3/configuration_manual/service_configuration/#service-limits> # <https://doc.dovecot.org/configuration_manual/service_configuration/#service-limits>
# so each logged in IMAP session will need its own `imap` process. # so each logged in IMAP session will need its own `imap` process.
# #
# If this limit is reached, # If this limit is reached,
@@ -44,11 +44,11 @@ mail_server_comment = Chatmail server
# `zlib` enables compressing messages stored in the maildir. # `zlib` enables compressing messages stored in the maildir.
# See # See
# <https://doc.dovecot.org/2.3/configuration_manual/zlib_plugin/> # <https://doc.dovecot.org/configuration_manual/zlib_plugin/>
# for documentation. # for documentation.
# #
# quota plugin documentation: # quota plugin documentation:
# <https://doc.dovecot.org/2.3/configuration_manual/quota_plugin/> # <https://doc.dovecot.org/configuration_manual/quota_plugin/>
mail_plugins = zlib quota mail_plugins = zlib quota
imap_capability = +XDELTAPUSH XCHATMAIL imap_capability = +XDELTAPUSH XCHATMAIL
@@ -125,13 +125,13 @@ plugin {
protocol lmtp { protocol lmtp {
# notify plugin is a dependency of push_notification plugin: # notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/2.3/settings/plugin/notify-plugin/> # <https://doc.dovecot.org/settings/plugin/notify-plugin/>
# #
# push_notification plugin documentation: # push_notification plugin documentation:
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/> # <https://doc.dovecot.org/configuration_manual/push_notification/>
# #
# mail_lua and push_notification_lua are needed for Lua push notification handler. # mail_lua and push_notification_lua are needed for Lua push notification handler.
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/#configuration> # <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
} }
@@ -154,7 +154,7 @@ plugin {
# push_notification configuration # push_notification configuration
plugin { plugin {
# <https://doc.dovecot.org/2.3/configuration_manual/push_notification/#lua-lua> # <https://doc.dovecot.org/configuration_manual/push_notification/#lua-lua>
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
} }
@@ -168,8 +168,6 @@ service lmtp {
} }
} }
lmtp_add_received_header = no
service auth { service auth {
unix_listener /var/spool/postfix/private/auth { unix_listener /var/spool/postfix/private/auth {
mode = 0660 mode = 0660
@@ -228,8 +226,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
@@ -279,156 +277,3 @@ service imap-hibernate {
} }
} }
{% endif %} {% endif %}
{% if config.mtail_address %}
#
# Dovecot Statistics
#
# OpenMetrics endpoint at http://{{- config.mtail_address}}:3904/metrics
service stats {
inet_listener http {
port = 3904
address = {{- config.mtail_address}}
}
}
# IMAP Command Metrics
# - Bytes in/out for compression efficiency analysis
# - Lock wait time for contention debugging
# - Grouped by command name and reply state
metric imap_command {
filter = event=imap_command_finished
fields = bytes_in bytes_out lock_wait_usecs running_usecs
group_by = cmd_name tagged_reply_state
}
# Duration buckets for latency histograms (base 10: 10us, 100us, 1ms, 10ms, 100ms, 1s, 10s, 100s)
metric imap_command_duration {
filter = event=imap_command_finished
group_by = cmd_name duration:exponential:1:8:10
}
# Slow command outliers (>1 second = 1000000 usecs)
# Useful for alerting without high cardinality
metric imap_command_slow {
filter = event=imap_command_finished AND duration>1000000 AND NOT cmd_name=IDLE
group_by = cmd_name
}
# IDLE-specific Metrics
metric imap_idle {
filter = event=imap_command_finished AND cmd_name=IDLE
fields = bytes_in bytes_out running_usecs
group_by = tagged_reply_state
}
metric imap_idle_duration {
filter = event=imap_command_finished AND cmd_name=IDLE
# Base 10: 100ms to 27h (covers short wakeups to long idle sessions)
group_by = duration:exponential:5:11:10
}
metric imap_idle_commands {
filter = event=imap_command_finished AND cmd_name=IDLE
group_by = tagged_reply_state
}
metric imap_idle_failed {
filter = event=imap_command_finished AND cmd_name=IDLE AND NOT tagged_reply_state=OK
}
# Hibernation Metrics (requires imap_hibernate_timeout)
metric imap_hibernated {
filter = event=imap_client_hibernated
}
metric imap_hibernated_failed {
filter = event=imap_client_hibernated AND error=*
}
metric imap_unhibernated {
filter = event=imap_client_unhibernated
fields = hibernation_usecs
}
metric imap_unhibernated_reason {
filter = event=imap_client_unhibernated
group_by = reason
fields = hibernation_usecs
}
metric imap_unhibernated_reason_sleep {
filter = event=imap_client_unhibernated
group_by = reason hibernation_usecs:exponential:4:8:10
}
metric imap_unhibernated_failed {
filter = event=imap_client_unhibernated AND error=*
}
# Hibernation duration buckets (how long clients stayed hibernated)
# Base 10: 100ms to 27h
metric imap_hibernation_duration {
filter = event=imap_client_unhibernated
group_by = reason duration:exponential:5:11:10
}
# Authentication / Login Metrics
metric auth_request {
filter = event=auth_request_finished
group_by = success
}
metric auth_request_duration {
filter = event=auth_request_finished
group_by = success duration:exponential:2:6:10
}
metric auth_failed {
filter = event=auth_request_finished AND success=no
}
# Passdb cache effectiveness
metric auth_passdb {
filter = event=auth_passdb_request_finished
group_by = result cache
}
# Master login (post-auth userdb lookup)
metric auth_master_login {
filter = event=auth_master_client_login_finished
}
metric auth_master_login_failed {
filter = event=auth_master_client_login_finished AND error=*
}
# Mail Delivery (LMTP) - affects IDLE wakeup latency
metric mail_delivery {
filter = event=mail_delivery_finished
}
metric mail_delivery_duration {
filter = event=mail_delivery_finished
group_by = duration:exponential:3:7:10
}
metric mail_delivery_failed {
filter = event=mail_delivery_finished AND error=*
}
# Connection Events
metric client_connected {
filter = event=client_connection_connected AND category="service:imap"
}
metric client_disconnected {
filter = event=client_connection_disconnected AND category="service:imap"
fields = bytes_in bytes_out
}
{% endif %}

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

@@ -1,52 +0,0 @@
from pyinfra import facts, host
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer, get_resource
class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming"]
bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def __init__(self):
self.need_restart = False
def install(self):
arch = host.get_fact(facts.server.Arch)
url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-{arch}"
sha256sum = {
"x86_64": "ce24ca0075aa445510291d775fb3aea8f4411818c7b885ae51a0fe18c5f789ce",
"aarch64": "c5d783eefa5332db3d97a0e6a23917d72849e3eb45da3d16ce908a9b4e5a797d",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",
src=url,
sha256sum=sha256sum,
dest=self.bin_path,
mode="755",
).changed
def configure(self):
for service in self.services:
self.need_restart |= files.template(
src=get_resource(f"filtermail/{service}.service.j2"),
dest=f"/etc/systemd/system/{service}.service",
user="root",
group="root",
mode="644",
bin_path=self.bin_path,
config_path=self.config_path,
).changed
def activate(self):
for service in self.services:
systemd.service(
name=f"Start and enable {service}",
service=f"{service}.service",
running=True,
enabled=True,
restarted=self.need_restart,
daemon_reload=True,
)
self.need_restart = False

View File

@@ -44,37 +44,21 @@ counter warning_count
} }
counter filtered_outgoing_mail_count counter filtered_mail_count
counter outgoing_encrypted_mail_count counter encrypted_mail_count
/Outgoing: Filtering encrypted mail\./ { /Filtering encrypted mail\./ {
outgoing_encrypted_mail_count++ encrypted_mail_count++
filtered_outgoing_mail_count++ filtered_mail_count++
} }
counter outgoing_unencrypted_mail_count counter unencrypted_mail_count
/Outgoing: Filtering unencrypted mail\./ { /Filtering unencrypted mail\./ {
outgoing_unencrypted_mail_count++ unencrypted_mail_count++
filtered_outgoing_mail_count++ filtered_mail_count++
} }
counter filtered_incoming_mail_count
counter incoming_encrypted_mail_count
/Incoming: Filtering encrypted mail\./ {
incoming_encrypted_mail_count++
filtered_incoming_mail_count++
}
counter incoming_unencrypted_mail_count
/Incoming: Filtering unencrypted mail\./ {
incoming_unencrypted_mail_count++
filtered_incoming_mail_count++
}
counter rejected_unencrypted_mail_count counter rejected_unencrypted_mail_count
/Rejected unencrypted mail/ { /Rejected unencrypted mail\./ {
rejected_unencrypted_mail_count++ rejected_unencrypted_mail_count++
} }

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,41 @@
if odkim.internal_ip(ctx) == 1 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
@@ -59,9 +65,3 @@ PidFile /run/opendkim/opendkim.pid
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided # The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data. # by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key TrustAnchorFile /usr/share/dns/root.key
# Sign messages when `-o milter_macro_daemon_name=ORIGINATING` is set.
MTA ORIGINATING
# No hosts are treated as internal, ORIGINATING daemon name should be set explicitly.
InternalHosts -

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

@@ -1,4 +1,4 @@
from pyinfra.operations import apt, files, server, systemd from pyinfra.operations import apt, files, systemd
from cmdeploy.basedeploy import Deployer, get_resource from cmdeploy.basedeploy import Deployer, get_resource
@@ -52,29 +52,6 @@ class PostfixDeployer(Deployer):
) )
need_restart |= header_cleanup.changed need_restart |= header_cleanup.changed
lmtp_header_cleanup = files.put(
src=get_resource("postfix/lmtp_header_cleanup"),
dest="/etc/postfix/lmtp_header_cleanup",
user="root",
group="root",
mode="644",
)
need_restart |= lmtp_header_cleanup.changed
tls_policy_map = files.put(
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
src=get_resource("postfix/smtp_tls_policy_map"),
dest="/etc/postfix/smtp_tls_policy_map",
user="root",
group="root",
mode="644",
)
need_restart |= tls_policy_map.changed
if tls_policy_map.changed:
server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
)
# Login map that 1:1 maps email address to login. # Login map that 1:1 maps email address to login.
login_map = files.put( login_map = files.put(
src=get_resource("postfix/login_map"), src=get_resource("postfix/login_map"),
@@ -88,17 +65,9 @@ class PostfixDeployer(Deployer):
restart_conf = files.put( restart_conf = files.put(
name="postfix: restart automatically on failure", name="postfix: restart automatically on failure",
src=get_resource("service/10_restart.conf"), src=get_resource("service/10_restart.conf"),
dest="/etc/systemd/system/postfix@.service.d/10_restart.conf", dest="/etc/systemd/system/dovecot.service.d/10_restart.conf",
) )
self.daemon_reload = restart_conf.changed self.daemon_reload = restart_conf.changed
# Validate postfix configuration before restart
if need_restart:
server.shell(
name="Validate postfix configuration",
# Extract stderr and quit with error if non-zero
commands=["""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""],
)
self.need_restart = need_restart self.need_restart = need_restart
def activate(self): def activate(self):

View File

@@ -1,3 +0,0 @@
/^DKIM-Signature:/ IGNORE
/^Authentication-Results:/ IGNORE
/^Received:/ IGNORE

View File

@@ -15,17 +15,17 @@ 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
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = regexp:/etc/postfix/smtp_tls_policy_map smtp_tls_policy_maps = inline:{nauta.cu=may}
smtp_tls_protocols = >=TLSv1.2 smtp_tls_protocols = >=TLSv1.2
smtp_tls_mandatory_protocols = >=TLSv1.2 smtp_tls_mandatory_protocols = >=TLSv1.2
@@ -64,20 +64,7 @@ alias_database = hash:/etc/aliases
mydestination = mydestination =
relayhost = relayhost =
{% if disable_ipv6 %}
mynetworks = 127.0.0.0/8
{% else %}
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
{% endif %}
{% if config.addr_v4 %}
smtp_bind_address = {{ config.addr_v4 }}
{% endif %}
{% if config.addr_v6 %}
smtp_bind_address6 = {{ config.addr_v6 }}
{% endif %}
{% if config.addr_v4 or config.addr_v6 %}
smtp_bind_address_enforce = yes
{% endif %}
mailbox_size_limit = 0 mailbox_size_limit = 0
message_size_limit = {{config.max_message_size}} message_size_limit = {{config.max_message_size}}
recipient_delimiter = + recipient_delimiter = +
@@ -90,7 +77,6 @@ inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }} virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
mua_client_restrictions = permit_sasl_authenticated, reject mua_client_restrictions = permit_sasl_authenticated, reject
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject

View File

@@ -31,6 +31,7 @@ submission inet n - y - 5000 smtpd
-o smtpd_sender_restrictions=$mua_sender_restrictions -o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
smtps inet n - y - 5000 smtpd smtps inet n - y - 5000 smtpd
@@ -48,6 +49,7 @@ smtps inet n - y - 5000 smtpd
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
@@ -79,13 +81,13 @@ filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting outgoing filtered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd 127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock -o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
# 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 +0,0 @@
/^\[[^]]+\]$/ encrypt
/^_/ encrypt
/^nauta\.cu$/ may

View File

@@ -14,9 +14,8 @@ def main():
importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"), importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"),
) )
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL")) disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
deploy_chatmail(config_path, disable_mail, website_only) deploy_chatmail(config_path, disable_mail)
if pyinfra.is_cli: if pyinfra.is_cli:

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

@@ -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

@@ -2,10 +2,11 @@
Description=Incoming Chatmail Postfix before queue filter Description=Incoming Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={{ bin_path }} {{ config_path }} incoming ExecStart={execpath} {config_path} incoming
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -2,7 +2,7 @@
Description=Outgoing Chatmail Postfix before queue filter Description=Outgoing Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={{ bin_path }} {{ config_path }} outgoing ExecStart={execpath} {config_path} outgoing
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=vmail

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,26 +85,16 @@ 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):
if kwargs is None:
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)
@@ -190,14 +189,12 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
mail = maildata( mail = maildata(
"encrypted.eml", from_addr=user1.addr, to_addr=user2.addr "encrypted.eml", from_addr=user1.addr, to_addr=user2.addr
).as_string() ).as_string()
for i in range(chatmail_config.max_user_send_per_minute + 5):
start = time.time() print("Sending mail", str(i))
for i in range(chatmail_config.max_user_send_per_minute * 3):
print("Sending mail", str(i + 1), "at", time.time() - start, "s.")
try: try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail) user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e: except smtplib.SMTPException as e:
if i < chatmail_config.max_user_send_burst_size: if i < chatmail_config.max_user_send_per_minute:
pytest.fail(f"rate limit was exceeded too early with msg {i}") pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr] outcome = e.recipients[user2.addr]
assert outcome[0] == 450 assert outcome[0] == 450
@@ -219,7 +216,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,18 +7,16 @@ 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
return mailbox return mailbox
@@ -27,7 +25,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 +60,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,83 +89,66 @@ 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):
"""Test that if a DC address receives a message, it has no
DKIM-Signature and Authentication-Results headers."""
ac1 = cmfactory.get_online_account()
ac2 = cmfactory.get_online_account(domain=maildomain2)
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
chat.send_text("message0")
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
chat2.send_text("message1")
lp.sec("receive message with ac1...")
received = 0
while received < 2:
msgs = imap_mailbox.fetch()
for msg in msgs:
lp.sec(f"ac1 received msg from {msg.from_}")
received += 1
assert "authentication-results" 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 +156,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

@@ -1,4 +0,0 @@
# Managed by cmdeploy: disable IPv6 in unbound.
server:
interface: 127.0.0.1
do-ip6: no

View File

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

View File

@@ -16,16 +16,15 @@ You will need the following:
- Control over a domain through a DNS provider of your choice. - Control over a domain through a DNS provider of your choice.
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports. - A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available. Chatmail relay servers only require IPv6 is encouraged if available. Chatmail relay servers only require
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active 1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
chatmail addresses. chatmail addresses.
- A Linux or Unix **build machine** with key-based SSH access to the root - Key-based SSH authentication to the root user. You must add a
user of the deployment server. passphrase-protected private key to your local ssh-agent because you
You must add a passphrase-protected private key to your local ssh-agent because you cant type in your passphrase during deployment. (An ed25519 private
cant type in your passphrase during deployment. key is required due to an `upstream bug in
(An ed25519 private key is required due to an `upstream bug in
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_) paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
@@ -35,25 +34,16 @@ Setup with ``scripts/cmdeploy``
We use ``chat.example.org`` as the chatmail domain in the following We use ``chat.example.org`` as the chatmail domain in the following
steps. Please substitute it with your own domain. steps. Please substitute it with your own domain.
1. Setup the initial DNS records for your deployment server. 1. Setup the initial DNS records. The following is an example in the
The following is an example in the
familiar BIND zone file format with a TTL of 1 hour (3600 seconds). familiar BIND zone file format with a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses. Please substitute your domain and IP addresses.
:: ::
chat.example.org. 3600 IN A 198.51.100.5 chat.example.com. 3600 IN A 198.51.100.5
chat.example.org. 3600 IN AAAA 2001:db8::5 chat.example.com. 3600 IN AAAA 2001:db8::5
www.chat.example.org. 3600 IN CNAME chat.example.org. www.chat.example.com. 3600 IN CNAME chat.example.com.
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org. mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
.. 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.
@@ -64,30 +54,20 @@ steps. Please substitute it with your own domain.
cd relay cd relay
scripts/initenv.sh scripts/initenv.sh
3. On your local build machine (PC), create a chatmail configuration file 3. On your local PC, create chatmail configuration file
``chatmail.ini``: ``chatmail.ini``:
:: ::
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
To use self-signed TLS certificates 4. Verify that SSH root login to your remote server works:
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:
:: ::
ssh root@chat.example.org # <-- use your domain ssh root@chat.example.org # <-- use your domain
5. From your local build machine, setup and configure the remote deployment server: 5. From your local PC, deploy the remote chatmail relay server:
:: ::
@@ -98,16 +78,10 @@ steps. Please substitute it with your own domain.
configure at your DNS provider (it can take some time until they are configure at your DNS provider (it can take some time until they are
public). public).
Docker installation
-------------------
There is experimental support for running chatmail via Docker Compose.
See :doc:`docker` for full setup instructions.
Other helpful commands Other helpful commands
---------------------- ----------------------
To check the status of your deployment server running the chatmail service: To check the status of your remotely running chatmail service:
:: ::
@@ -184,7 +158,7 @@ Disable automatic address creation
-------------------------------------------------------- --------------------------------------------------------
If you need to stop address creation, e.g. because some script is wildly If you need to stop address creation, e.g. because some script is wildly
creating addresses, login with ssh to the deployment machine and run: creating addresses, login with ssh and run:
:: ::
@@ -193,72 +167,3 @@ 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
----------------------------------
To move or add a build machine,
clone the relay repository on the new build machine, and copy the ``chatmail.ini`` file from the old build machine.
Make sure ``rsync`` is installed, then initialize the environment:
::
./scripts/initenv.sh
Run safety checks before a new deployment:
::
./scripts/cmdeploy dns
./scripts/cmdeploy status
If you keep multiple build machines (ie laptop and desktop), keep ``chatmail.ini`` in sync between
them.

View File

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

View File

@@ -1,72 +1,77 @@
Migrating to a new machine Migrating to a new host
=========================== -----------------------
This migration tutorial provides a step-wise approach If you want to migrate chatmail relay from an old machine to a new
to safely migrate a chatmail relay from one remote machine to another. machine, you can use these steps. They were tested with a Linux laptop;
you might need to adjust some of the steps to your environment.
Preliminary notes and assumptions Lets assume that your ``mail_domain`` is ``mail.example.org``, all
--------------------------------- involved machines run Debian 12, your old sites IP version 4 address is
``$OLD_IP4``, and your new sites IP4 address is ``$NEW_IP4``.
- If the migration is a planned move, First of all, you should lower the Time To Live (TTL) of your DNS records
it's recommended to lower the Time To Live (TTL) of your DNS records to a value such as 300 (5 minutes), to a value such as 300 (5 minutes).
at best much earlier than the actual planned migration. Short TTL values allow to change DNS records during the migration more timely.
This speeds up propagation of DNS changes in the Internet after the migration is complete.
- The migration steps were tested with a Linux laptop; you might need to adjust some of the steps to your local environment. During the guide you might get a warning about changed SSH Host keys; in
this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
- Your ``mail_domain`` is ``mail.example.org``. 1. First, to make the downtime during the migration shorter,
let's transfer the current state of the mailboxes.
Login to your old machine (while forwarding your ssh-agent with ``ssh -A``)
so you can copy directly from the old to the new site with your SSH
key:
- All remote machines run Debian 12.
- The old sites IP version 4 address is ``$OLD_IP4``.
- The new sites IP addresses are ``$NEW_IP4`` and ``$NEW_IPV6``.
The six steps to migrate
------------------------
Note that during some of the following steps you might get a warning about changed SSH Host keys;
in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
1. **Initially transfer mailboxes from old to new site.**
Login to old site, forwarding your ssh-agent with ``ssh -A``
to allow using ssh to directly copy files from old to new site.
:: ::
ssh -A root@$OLD_IP4 ssh -A root@$OLD_IP4
tar c /home/vmail/mail | ssh root@$NEW_IP4 "tar x -C /" tar c /home/vmail/mail | ssh root@$NEW_IP4 "tar x -C /"
This saves us time during the downtime,
at least the mailboxes are there already.
They contain user passwords, encrypted push notification tokens,
messages which might not have been fetched by all devices of the user yet,
and dovecot indexes which track the state of the mailbox.
2. **Pre-configure the new site but keep it inactive until step 6** 2. Then, from your local machine, install chatmail on the new machine, but don't activate it yet:
::
CMDEPLOY_STAGES=install,configure scripts/cmdeploy run --ssh-host $NEW_IP4
3. **It's getting serious: disable mail services on the old site.**
Users will not be able to send or receive messages until all steps are completed.
Other relays and mail servers will retry delivering messages from time to time,
so nothing is lost for users.
:: ::
scripts/cmdeploy run --disable-mail --ssh-host $OLD_IP4 CMDEPLOY_STAGES=install,configure cmdeploy run --ssh-host $NEW_IP4
The services are disabled for now; we will enable them later.
We first need to make the new site fully operational.
3. Now it's getting serious: disable the mail services on the old site.
::
cmdeploy run --disable-mail --ssh-host $OLD_IP4
Your users will start to notice the migration and will not be able to send
or receive messages until the migration is completed.
Other relays and mail servers will wait with delivering messages
until your relay is reachable again.
4. Now we want to copy ``/home/vmail``, ``/var/lib/acme``,
``/etc/dkimkeys``, and ``/var/spool/postfix`` to
the new site. Let's forward the SSH agent again to copy the files directly.
This time, we copy ``/home/vmail/mail`` with rsync to only copy the recent changes:
4. **Final synchronization of TLS/DKIM secrets, mail queues and mailboxes.**
Again we use ssh-agent forwarding (``-A``) to allow transfering all important data directly
from the old to the new site.
:: ::
ssh -A root@$OLD_IP4 ssh -A root@$OLD_IP4
tar c /var/lib/acme /etc/dkimkeys /var/spool/postfix | ssh root@$NEW_IP4 "tar x -C /" tar c /var/lib/acme /etc/dkimkeys /var/spool/postfix | ssh root@$NEW_IP4 "tar x -C /"
rsync -azH /home/vmail/mail root@$NEW_IP4:/home/vmail/ rsync -azH /home/vmail/mail root@$NEW_IP4:/home/vmail/
Login to the new site and ensure file ownerships are correctly set: This transfers all messages which have not been fetched yet, the TLS certificate,
and DKIM keys (so DKIM DNS record remains valid).
It also preserves the Postfix mail spool so any messages
pending delivery will still be delivered.
5. Now login to the new site and run the following to ensure the ownership is correct
in case UIDs/GIDs changed:
:: ::
@@ -75,8 +80,7 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
chown opendkim: -R /etc/dkimkeys chown opendkim: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail chown vmail: -R /home/vmail/mail
6. Now, update the DNS entries.
5. **Update the DNS entries to point to the new site.**
You only need to change the ``A`` and ``AAAA`` records, for example: You only need to change the ``A`` and ``AAAA`` records, for example:
:: ::
@@ -84,15 +88,7 @@ in this case, just run ``ssh-keygen -R "mail.example.org"`` as recommended.
mail.example.org. IN A $NEW_IP4 mail.example.org. IN A $NEW_IP4
mail.example.org. IN AAAA $NEW_IP6 mail.example.org. IN AAAA $NEW_IP6
7. Finally, you can execute ``CMDEPLOY_STAGES=activate cmdeploy run --ssh-host $NEW_IP4`` to
6. **Activate chatmail relay on new site.** turn on chatmail on the new relay. Your users will be able to use the
chatmail relay as soon as the DNS changes have propagated. Voilà!
::
CMDEPLOY_STAGES=activate scripts/cmdeploy run --ssh-host $NEW_IP4
Voilà!
Users will be able to use the relay as soon as the DNS changes have propagated.
If you have lowered the Time-to-Live for DNS records in step 1,
better use a higher value again (between 14400 and 86400 seconds) once you are sure everything works.

View File

@@ -42,11 +42,6 @@ The deployed system components of a chatmail relay are:
- Dovecot_ is the Mail Delivery Agent (MDA) and - Dovecot_ is the Mail Delivery Agent (MDA) and
stores messages for users until they download them stores messages for users until they download them
- `filtermail <https://github.com/chatmail/filtermail>`_
prevents unencrypted email from leaving or entering the chatmail
service and is integrated into Postfixs outbound and inbound mail
pipelines.
- Nginx_ shows the web page with privacy policy and additional information - Nginx_ shows the web page with privacy policy and additional information
- `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS - `acmetool <https://hlandau.github.io/acmetool/>`_ manages TLS
@@ -90,6 +85,11 @@ short overview of ``chatmaild`` services:
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_ <https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
to authenticate logins. to authenticate logins.
- `filtermail <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/filtermail.py>`_
prevents unencrypted email from leaving or entering the chatmail
service and is integrated into Postfixs outbound and inbound mail
pipelines.
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_ - `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
is contacted by a `Dovecot lua is contacted by a `Dovecot lua
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_ script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_
@@ -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

@@ -14,10 +14,10 @@ We know of three work-in-progress alternative implementation efforts:
it to support all of the features and configuration settings required it to support all of the features and configuration settings required
to operate as a chatmail relay. to operate as a chatmail relay.
- `Madmail <https://github.com/themadorg/madmail>`_: an - `Maddy-Chatmail <https://github.com/sadraiiali/maddy_chatmail>`_: a
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified plugin for the `Maddy email server <https://maddy.email/>`_ which
for chatmail deployments. It provides a single binary solution aims to implement the chatmail relay features and configuration
for running a chatmail relay. options.
- `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_: - `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_:
A Chef Cookbook implementing a relay server. The project follows the A Chef Cookbook implementing a relay server. The project follows the

View File

@@ -1,44 +0,0 @@
# Local overrides: copy to docker-compose.override.yaml in the repo root.
# Compose automatically merges this with docker-compose.yaml.
#
# cp docker-compose.override.yaml.example docker-compose.override.yaml
#
# Volumes are APPENDED to the base file's volumes list, environment and other scalar keys are MERGED by key.
services:
chatmail:
volumes:
## Data paths — bind-mount to host directories for easy access/backup.
# - ./data/dkim:/etc/dkimkeys
# - ./data/certs:/var/lib/acme
# - ./data/mail:/home/vmail
## Or mount from an existing bare-metal install.
# - /home/vmail:/home/vmail
## Mount your own chatmail.ini (skips auto-generation):
# - ./chatmail.ini:/etc/chatmail/chatmail.ini
## Custom website:
# - ./custom/www:/opt/chatmail-www
## Debug — mount scripts from the repo for live editing:
# - ./docker/chatmail-init.sh:/chatmail-init.sh
# - ./docker/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

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

View File

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

View File

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

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,101 +0,0 @@
# syntax=docker/dockerfile:1
FROM jrei/systemd-debian:12 AS base
ENV LANG=en_US.UTF-8
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--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 && \
apt-get update && \
DEBIAN_FRONTEND=noninteractive TZ=UTC \
apt-get install -y \
ca-certificates \
gcc \
git \
python3 \
python3-dev \
python3-venv \
tzdata \
locales && \
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=$LANG
# --- Build-time: install cmdeploy venv and run install stage ---
# Editable install so importlib.resources reads directly from the source tree.
# On container start only "configure,activate" stages run.
# Copy 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/
# Minimal chatmail.ini
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
RUN CMDEPLOY_STAGES=install \
CHATMAIL_INI=/tmp/chatmail.ini \
CHATMAIL_NOSYSCTL=True \
CHATMAIL_NOPORTCHECK=True \
/opt/cmdeploy/bin/pyinfra @local \
/opt/chatmail/cmdeploy/src/cmdeploy/run.py -y
RUN cp -a www/ /opt/chatmail-www/
# Remove build-only packages and their deps — not needed at runtime
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).
# 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.
# 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
RUN echo "$GIT_HASH" > /etc/chatmail-image-version && \
echo "$GIT_HASH" > /etc/chatmail-version
# --- End build-time install ---
ENV TZ=:/etc/localtime
ENV PATH="/opt/cmdeploy/bin:${PATH}"
RUN ln -s /etc/chatmail/chatmail.ini /opt/chatmail/chatmail.ini
ARG CHATMAIL_INIT_SERVICE_PATH=/lib/systemd/system/chatmail-init.service
COPY ./docker/chatmail-init.service "$CHATMAIL_INIT_SERVICE_PATH"
RUN ln -sf "$CHATMAIL_INIT_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/chatmail-init.service"
# Remove default nginx site config at build time (not in entrypoint)
RUN rm -f /etc/nginx/sites-enabled/default
COPY --chmod=555 ./docker/chatmail-init.sh /chatmail-init.sh
COPY --chmod=555 ./docker/entrypoint.sh /entrypoint.sh
COPY --chmod=555 ./docker/healthcheck.sh /healthcheck.sh
HEALTHCHECK --interval=10s --start-period=180s --timeout=10s --retries=3 \
CMD /healthcheck.sh
STOPSIGNAL SIGRTMIN+3
ENTRYPOINT ["/entrypoint.sh"]
CMD [ "--default-standard-output=journal+console", \
"--default-standard-error=journal+console" ]

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

@@ -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

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

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