Compare commits

..
Author SHA1 Message Date
holger krekelandj4n b50019aa2a docker/lxc: replace CHATMAIL_NO* env vars with systemd-detect-virt detection
Cherry-pick runtime container detection from the hpk/lxcdeploy branch
for the dovecot sysctl block and apply the same pattern to the
port-availability check in deployers.py, and drop both CHATMAIL_NOSYSCTL
and CHATMAIL_NOPORTCHECK from the container startup script and
Dockerfile; the code now self-detects the context.
2026-03-17 17:16:09 +01:00
j4n 7606612091 docker: add Traefik reverse-proxy compose example (untested)
Add docker-compose-traefik.yaml as an example for running chatmail
behind a Traefik reverse proxy. This uses TLS_EXTERNAL_CERT_AND_KEY
to let Traefik handle TLS certificate management.
2026-03-17 15:45:03 +01:00
j4n 2b12ed1ca1 ci: add Docker CI steps to staging workflows
Append Docker build-and-test steps to the existing split CI workflows
(test-and-deploy.yaml and test-and-deploy-ipv4only.yaml)

Each workflow now has:
- build-docker job: builds image with buildx, pushes to GHCR on push
- Docker deploy section stops bare services, installs Docker on VPS,
  copies ACME/DKIM to bind mounts, reuses chatmail.ini from bare-metal
  step, pulls GHCR image, starts container with docker compose
- Tests run inside container via `docker exec chatmail cmdeploy ...
  --ssh-host @local` — no CHATMAIL_DOCKER env var needed
- id: wait-for-vps added to VPS wait step for conditional guards

The build-docker and deploy jobs run independently.
2026-03-17 15:45:03 +01:00
j4n 40051f7ac3 feat: add Docker Compose support
Add container-based deployment as an alternative to bare-metal pyinfra.

- systemd inside container reusing the existing deployer infrastructure
- chatmail-init.sh runs `cmdeploy run --ssh-host @local` on first boot,
  so the container self-deploys using the same code path as bare-metal
- Config via MAIL_DOMAIN env var (simple) or mounted chatmail.ini (advanced)
- External TLS support via TLS_EXTERNAL_CERT_AND_KEY for reverse proxy setups
- Image version tracking in /etc/chatmail-image-version for upgrade detection
- .git/ excluded, but version file mocked so git revparse still works
- Health check verifies postfix, dovecot, and nginx are listening

Files added:
- docker/chatmail_relay.dockerfile: multi-stage build (build + runtime)
- docker/chatmail-init.sh: first-boot deployment script
- docker/chatmail-init.service: systemd unit for init script
- docker/entrypoint.sh: container entrypoint (starts systemd)
- docker/healthcheck.sh: container health check
- docker/docker-compose.yaml: main compose config
- docker/docker-compose.ci.yaml: CI override (uses GHCR image)
- docker/docker-compose.override.yaml.example: customization template
- docker/build.sh: helper script
- doc/source/docker.rst: documentation
- .dockerignore: build context filter
2026-03-17 15:45:03 +01:00
j4n e45d2b99e4 cmdeploy/sshexec: remove dead @docker SSH host
The `@docker` SSH host was added in Docker development to route
cmdeploy commands into a running container from outside. This is no
longer needed because chatmail-init.sh deploys with `@local` inside
the container, and all cmdeploy commands (test, dns, status) work
natively inside via `docker exec chatmail cmdeploy <cmd> --ssh-host @local`.
2026-03-17 15:45:03 +01:00
121 changed files with 2884 additions and 3843 deletions
+18
View File
@@ -0,0 +1,18 @@
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
-40
View File
@@ -1,40 +0,0 @@
name: No-DNS
on:
# Triggers when a PR is merged into main or a direct push occurs
push:
branches: [ "main" ]
# Triggers for any PR (and its subsequent commits) targeting the main branch
pull_request:
branches: [ "main" ]
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
no-dns:
name: LXC deploy and test
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
with:
cmlxc_version: main
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0
cmlxc -v test-cmdeploy cm0
# cross cmdeploy relay test (two ipv4 relays)
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1
cmlxc -v test-cmdeploy cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini mad0 cm0
cmlxc -v test-mini cm0 mad0
+6 -43
View File
@@ -1,35 +1,21 @@
name: CI name: CI
on: on:
# Triggers when a PR is merged into main or a direct push occurs
push:
branches: [ "main" ]
# Triggers for any PR (and its subsequent commits) targeting the main branch
pull_request: pull_request:
branches: [ "main" ] push:
permissions: {}
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
tox: tox:
name: isolated chatmaild tests name: isolated chatmaild tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
# Checkout pull request HEAD commit instead of merge commit # Checkout pull request HEAD commit instead of merge commit
# 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 }}
persist-credentials: false
- name: download filtermail - name: download filtermail
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
@@ -38,10 +24,7 @@ jobs:
name: deploy-chatmail tests name: deploy-chatmail tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -55,25 +38,5 @@ jobs:
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
lxc-test: # all other cmdeploy commands require a staging server
name: LXC deploy and test # see https://github.com/deltachat/chatmail/issues/100
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
with:
cmlxc_version: main
cmlxc_commands: |
cmlxc init
# single cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo cm0
cmlxc -v test-mini cm0
cmlxc -v test-cmdeploy cm0
# cross cmdeploy relay test
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only cm1
cmlxc -v test-cmdeploy cm0 cm1
# cross cmdeploy/madmail relay tests
cmlxc -v deploy-madmail mad0
cmlxc -v test-cmdeploy cm0 mad0
cmlxc -v test-mini cm0 mad0
cmlxc -v test-mini mad0 cm0
-38
View File
@@ -1,38 +0,0 @@
# Notify the docker repo to build and test a new image after relay CI passes.
#
# Sends a repository_dispatch event to chatmail/docker with the relay ref
# and short SHA, which triggers docker-ci.yaml to build, push to GHCR,
# and run integration tests via cmlxc.
name: Trigger Docker build
on:
push:
branches: [main, j4n/dovecot-multidist]
tags: ['[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
permissions: {}
jobs:
dispatch:
name: Dispatch build to chatmail/docker
runs-on: ubuntu-latest
if: github.repository == 'chatmail/relay'
steps:
- name: Compute short SHA
id: sha
run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT"
- name: Send repository_dispatch
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
repository: chatmail/docker
event-type: relay-updated
client-payload: >-
{
"relay_ref": "${{ github.ref_name }}",
"relay_sha": "${{ github.sha }}",
"relay_sha_short": "${{ steps.sha.outputs.short }}"
}
+3 -16
View File
@@ -7,24 +7,15 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs-preview.yaml' - '.github/workflows/docs-preview.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Pin the repository links in the docs to this pull request's head commit
# so that linkcheck resolves files which only exist on the branch so far.
# see doc/conf.py
DOC_GITHUB_REF: ${{ github.event.pull_request.head.sha }}
environment: environment:
name: 'staging.chatmail.at/doc/relay/' name: 'staging.chatmail.at/doc/relay/'
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }} url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -43,22 +34,18 @@ jobs:
- name: Get Pullrequest ID - name: Get Pullrequest ID
id: prepare id: prepare
run: | run: |
export PULLREQUEST_ID=$(echo "${GITHUB_REF}" | cut -d "/" -f3) export PULLREQUEST_ID=$(echo "${{ github.ref }}" | cut -d "/" -f3)
echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT
if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi
- run: | - run: |
echo "baseurl: /${STEPS_PREPARE_OUTPUTS_PRID}" >> _config.yml echo "baseurl: /${{ steps.prepare.outputs.prid }}" >> _config.yml
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: Upload preview - name: Upload preview
run: | run: |
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:${STEPS_PREPARE_OUTPUTS_PRID}/" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}/"
env:
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
- name: check links - name: check links
working-directory: doc working-directory: doc
+1 -5
View File
@@ -10,8 +10,6 @@ on:
- 'scripts/build-docs.sh' - 'scripts/build-docs.sh'
- '.github/workflows/docs.yaml' - '.github/workflows/docs.yaml'
permissions: {}
jobs: jobs:
scripts: scripts:
name: build name: build
@@ -21,8 +19,6 @@ jobs:
url: https://chatmail.at/doc/relay/ url: https://chatmail.at/doc/relay/
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
persist-credentials: false
- name: initenv - name: initenv
run: scripts/initenv.sh run: scripts/initenv.sh
@@ -47,5 +43,5 @@ jobs:
mkdir -p "$HOME/.ssh" mkdir -p "$HOME/.ssh"
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key" echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
chmod 600 "$HOME/.ssh/key" chmod 600 "$HOME/.ssh/key"
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:" rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/"
@@ -0,0 +1,296 @@
name: deploy on staging-ipv4.testrun.org, and run tests
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 on staging-ipv4.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging-ipv4.testrun.org
url: https://staging-ipv4.testrun.org/
concurrency: staging-ipv4.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging-ipv4.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging-ipv4.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_IPV4_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
id: wait-for-vps
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
- name: setup dependencies
run: |
ssh root@staging-ipv4.testrun.org apt update
ssh root@staging-ipv4.testrun.org apt install -y git python3.11-venv python3-dev gcc
ssh root@staging-ipv4.testrun.org git clone https://github.com/chatmail/relay
ssh root@staging-ipv4.testrun.org "cd relay && git checkout " ${{ github.head_ref }}
ssh root@staging-ipv4.testrun.org "cd relay && scripts/initenv.sh"
- name: initialize config
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy init staging-ipv4.testrun.org"
ssh root@staging-ipv4.testrun.org "sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' relay/chatmail.ini"
ssh root@staging-ipv4.testrun.org "sed -i 's/#\s*mtail_address/mtail_address/' relay/chatmail.ini"
- run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy run --verbose --skip-dns-check --ssh-host localhost"
- name: set DNS entries
run: |
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
ssh root@staging-ipv4.testrun.org cat relay/staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
cat .github/workflows/staging-ipv4.testrun.org-default.zone
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: ssh root@staging-ipv4.testrun.org "cd relay && CHATMAIL_DOMAIN2=ci-chatmail.testrun.org scripts/cmdeploy test --slow --ssh-host localhost"
- name: cmdeploy dns
run: ssh root@staging-ipv4.testrun.org "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'
run: |
ssh root@staging-ipv4.testrun.org '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'
run: |
ssh root@staging-ipv4.testrun.org 'apt-get update && apt-get install -y ca-certificates curl'
ssh root@staging-ipv4.testrun.org 'install -m 0755 -d /etc/apt/keyrings'
ssh root@staging-ipv4.testrun.org '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@staging-ipv4.testrun.org '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@staging-ipv4.testrun.org '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'
run: |
ssh root@staging-ipv4.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim'
ssh root@staging-ipv4.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true
- name: upload chatmail.ini for Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reuse chatmail.ini already created by the bare-metal deploy steps
ssh root@staging-ipv4.testrun.org "cp relay/chatmail.ini /srv/chatmail/chatmail.ini"
- name: deploy with Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging-ipv4.testrun.org:/srv/chatmail/relay/
# Login to GHCR on VPS and pull pre-built image
echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging-ipv4.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin'
ssh root@staging-ipv4.testrun.org "docker pull ${GHCR_IMAGE}"
ssh root@staging-ipv4.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging-ipv4.testrun.org docker compose -f docker/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'
run: |
# Stream journald inside the container
ssh root@staging-ipv4.testrun.org '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@staging-ipv4.testrun.org '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@staging-ipv4.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true
echo "--- service logs ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true
echo "--- listening ports ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp' || true
echo "--- chatmail.ini ---"
ssh root@staging-ipv4.testrun.org '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'
run: |
echo "--- listening ports ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail ss -tlnp'
echo "--- chatmail.ini ---"
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini'
- name: Docker integration tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
- name: Docker DNS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reset zone file in case bare DNS already appended to it
git checkout .github/workflows/staging-ipv4.testrun.org-default.zone
ssh root@staging-ipv4.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys'
ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose'
ssh root@staging-ipv4.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone'
scp root@staging-ipv4.testrun.org:/tmp/staging.zone 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: Docker final DNS check
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging-ipv4.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
# --- Cleanup ---
- name: add SSH keys
if: >-
!cancelled()
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging-ipv4.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys'
+289
View File
@@ -0,0 +1,289 @@
name: deploy on staging2.testrun.org, and run tests
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 on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: staging2.testrun.org
url: https://staging2.testrun.org/
concurrency: staging2.testrun.org
steps:
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging2.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"image":"debian-12"}' \
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
- run: scripts/initenv.sh
- name: append venv/bin to PATH
run: echo venv/bin >>$GITHUB_PATH
- name: upload TLS cert after rebuilding
id: wait-for-vps
run: |
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: add hpk42 key to staging server
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: |
cmdeploy init staging2.testrun.org
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check
- name: set DNS entries
run: |
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
- name: cmdeploy dns
run: cmdeploy dns -v
# --- 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'
run: |
ssh root@staging2.testrun.org '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'
run: |
ssh root@staging2.testrun.org 'apt-get update && apt-get install -y ca-certificates curl'
ssh root@staging2.testrun.org 'install -m 0755 -d /etc/apt/keyrings'
ssh root@staging2.testrun.org '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@staging2.testrun.org '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@staging2.testrun.org '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'
run: |
ssh root@staging2.testrun.org 'mkdir -p /srv/chatmail/certs /srv/chatmail/dkim'
ssh root@staging2.testrun.org 'cp -a /var/lib/acme/. /srv/chatmail/certs/ && cp -a /etc/dkimkeys/. /srv/chatmail/dkim/' || true
- name: upload chatmail.ini for Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reuse chatmail.ini already created by the bare-metal deploy steps
scp chatmail.ini root@staging2.testrun.org:/srv/chatmail/chatmail.ini
- name: deploy with Docker
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
GHCR_IMAGE="${{ env.REGISTRY }}/$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]'):sha-${SHORT_SHA}"
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ root@staging2.testrun.org:/srv/chatmail/relay/
# Login to GHCR on VPS and pull pre-built image
echo "${{ secrets.GITHUB_TOKEN }}" | ssh root@staging2.testrun.org 'docker login ghcr.io -u ${{ github.actor }} --password-stdin'
ssh root@staging2.testrun.org "docker pull ${GHCR_IMAGE}"
ssh root@staging2.testrun.org "cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=staging2.testrun.org docker compose -f docker/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'
run: |
# Stream journald inside the container
ssh root@staging2.testrun.org '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@staging2.testrun.org '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@staging2.testrun.org 'docker exec chatmail systemctl --failed --no-pager' || true
echo "--- service logs ---"
ssh root@staging2.testrun.org 'docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50' || true
echo "--- listening ports ---"
ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp' || true
echo "--- chatmail.ini ---"
ssh root@staging2.testrun.org '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'
run: |
echo "--- listening ports ---"
ssh root@staging2.testrun.org 'docker exec chatmail ss -tlnp'
echo "--- chatmail.ini ---"
ssh root@staging2.testrun.org 'docker exec chatmail cat /etc/chatmail/chatmail.ini'
- name: Docker integration tests
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
- name: Docker DNS
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: |
# Reset zone file in case bare DNS already appended to it
git checkout .github/workflows/staging.testrun.org-default.zone
ssh root@staging2.testrun.org 'docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys'
ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose'
ssh root@staging2.testrun.org 'docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone'
scp root@staging2.testrun.org:/tmp/staging.zone staging-generated.zone
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: Docker final DNS check
if: >-
!cancelled() && github.event_name == 'push'
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging2.testrun.org 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
# --- Cleanup ---
- name: add SSH keys
if: >-
!cancelled()
&& steps.wait-for-vps.outcome == 'success'
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys https://github.com/j4n.keys >> .ssh/authorized_keys'
-26
View File
@@ -1,26 +0,0 @@
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
-7
View File
@@ -1,7 +0,0 @@
rules:
unpinned-uses:
config:
policies:
actions/*: ref-pin
dependabot/*: ref-pin
chatmail/*: ref-pin
+6
View File
@@ -164,3 +164,9 @@ cython_debug/
#.idea/ #.idea/
chatmail.zone chatmail.zone
# docker
/data/
/custom/
docker/docker-compose.override.yaml
docker/.env
-181
View File
@@ -1,186 +1,5 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## [1.12.0] - 2026-07-31
### Breaking Changes
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
Dovecot default connection limit lowered from 50k to 10k,
Postfix default connection limit lowered from 5k to 1k,
larger relays need to adjust their settings.
### Features
- Reduce maximal_queue_lifetime from 5d to 2d
- Disable negative cache in unbound (#992)
- *(mtail)* Add incoming_mailer_daemon_mail_count
- *(postfix)* Disable processing of MIME headers
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
### Bug Fixes
- Set relay restrictions per smtpd service with default reject
- Reduce maxproc for filtermail-transport LMTP client to 500
- Core 2.50.0 does not have delete_server_after config anymore.
- Check if all required ports are available for filtermail (#983)
- Always deploy unbound.conf.d/chatmail.conf (#993)
- Expire empty directories (#994)
- Crypt-r dependency was declared for wrong Python version
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
- Pass kwargs to files.put()
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
- Fix port discovery when ss -tulpn shows dovecot before stats
### Documentation
- Add scripts/initenv.sh to upgrade instructions
- Update overview diagrams (#995)
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
- *(README.md)* Clarify security enforcement (#1011)
### Miscellaneous Tasks
- *(ci)* Auto-trigger docker build on release tag push
- *(acmetool)* Update let's encrypt ToS link to 1.8
- *(ci)* Update doc staging upload path
- *(ci)* Fix docs upload path
### Refactor
- *(postfix)* Remove unused "filter" lmtp service
- Install dns-root-data instead of using unbound-anchor
- *(deps)* Remove domain-validator dependency
### Testing
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
## [1.11.0] - 2026-05-15
### Breaking Changes
- [**breaking**] Drop passthrough_sender and passthrough_recipients chatmail.ini options to eliminate one more source of unencrypted messages
### Features
- Use filtermail for delivery to remote MTAs
- Expose metadata "maxsmtprecipients" value
- Support setup without domain, with only an IPv4 address (#963)
- *(doc/docker)* Introduce docker images in documentation
- DKIM-sign bounce messages (mainly "user does not exist")
- *(config)* Load default values from Config(), not chatmail.ini.f (#853)
- Make turn_socket_path configurable, and cleanup tests and turnserver code.
- Warn about any unused chatmail.ini parameter at the end of "cmdeploy run"
### Bug Fixes
- Make www tests work with editable instead of just plain installs
- Use path with no leading slash for mxdeliv
- Increase filtermail-transport concurrency limit
- Fix #972 by increasing file descriptors for filtermail
- *(mtail)* Correct boot ordering and deploy restart logic
- *(cmdeploy)* Stop and disable unbound-resolvconf
- *(nginx)* Properly redirect www to mail_domain
- *(dns)* Query correct NS if MNAME server is hidden (#954)
- Legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all.
- *(logging)* Log all http requests to syslog
### Documentation
- Document how to upgrade to new version (#965)
### Other
- *(deps)* Upgrade to filtermail v0.6.4
### Refactor
- Introduce automated change-tracking across deployers
## 1.10.0 2026-04-30
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
* support specifying custom filtermail binary through environment variable <https://github.com/chatmail/relay/pull/941>
* add automated zizmor scanning of github workflows <https://github.com/chatmail/relay/pull/938>
* added dispatch for *automated builds of chatmail relay docker images* <https://github.com/chatmail/relay/pull/934>
* do not bind SMTP client sockets to public addresses <https://github.com/chatmail/relay/pull/932>
* underline in docs that scripts/initenv.sh should be used for building the docs <https://github.com/chatmail/relay/pull/933>
* automatic oldest-first message removal from mailboxes to always stay under max_mailbox_size <https://github.com/chatmail/relay/pull/929>
* remove --slow from cmdeploy test <https://github.com/chatmail/relay/pull/931>
* handle missing inotify sysctl keys in containers <https://github.com/chatmail/relay/pull/930>
* replace resolvconf with static resolv.conf <https://github.com/chatmail/relay/pull/928>
* disable fsync for LMTP and IMAP services <https://github.com/chatmail/relay/pull/925>
* re-use cmlxc workflow, replacing CI with hetzner staging servers with local lxc containers <https://github.com/chatmail/relay/pull/917>
* explicitly install resolvconf <https://github.com/chatmail/relay/pull/924>
* detect stale dovecot binary and force restart in activate() <https://github.com/chatmail/relay/pull/922>
* Rename filtermail_http_port to filtermail_http_port_incoming <https://github.com/chatmail/relay/pull/921>
* consolidated is_in_container() check https://github.com/chatmail/relay/pull/920>
* restart dovecot after package replacement (rebase, test condense) <https://github.com/chatmail/relay/pull/913>
* Set permissions on dovecot pin prefs <https://github.com/chatmail/relay/pull/915>
* Route `/mxdeliv/` to configurable port <https://github.com/chatmail/relay/pull/901>
* fix VM detection, automated testing fixes, use newer chatmail-turn and move to standard BIND DNS zone format <https://github.com/chatmail/relay/pull/912>
* Upgrade to filtermail 0.6.1 <https://github.com/chatmail/relay/pull/910>
* pin dovecot packages to prevent apt upgrades <https://github.com/chatmail/relay/pull/908>
* add rpc server to cmdeploy along with client <https://github.com/chatmail/relay/pull/906>
* remove unused deps from chatmaild <https://github.com/chatmail/relay/pull/905>
* set default smtp_tls_security_level to "verify" unconditionally <https://github.com/chatmail/relay/pull/902>
* featprefer IPv4 in SMTP client <https://github.com/chatmail/relay/pull/900>
* Install dovecot .deb packages atomically <https://github.com/chatmail/relay/pull/899>
* stop installing cron package <https://github.com/chatmail/relay/pull/898>
* Rewrite dovecot install logic, update <https://github.com/chatmail/relay/pull/862>
* fix a test and some linting fixes <https://github.com/chatmail/relay/pull/897>
* Disable IP verification on domain-literal addresses <https://github.com/chatmail/relay/pull/895>
* disable installing recommended packages globally on the relay <https://github.com/chatmail/relay/pull/887>
* multiple bug fixes across chatmaild and cmdeploy <https://github.com/chatmail/relay/pull/883>
* remove /metrics from the website <https://github.com/chatmail/relay/pull/703>
* add Prometheus textfile output to fsreport <https://github.com/chatmail/relay/pull/881>
* chown opendkim: private key <https://github.com/chatmail/relay/pull/879>
* make sure chatmail-metadata was started <https://github.com/chatmail/relay/pull/882>
* dovecot update url <https://github.com/chatmail/relay/pull/880>
* upgrade to filtermail v0.5.2 <https://github.com/chatmail/relay/pull/876>
* download dovecot packages from github release <https://github.com/chatmail/relay/pull/875>
* replace DKIM verification with filtermail v0.5 <https://github.com/chatmail/relay/pull/831>
* remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings <https://github.com/chatmail/relay/pull/872>
* prepare chatmaild/cmdeploy changes for Docker support <https://github.com/chatmail/relay/pull/857>
* stabilize online benchmark timing adding rate-limit-aware cooldown between iterations <https://github.com/chatmail/relay/pull/867>
* move rate-limit cooldown to benchmark fixture <https://github.com/chatmail/relay/pull/868>
* reconfigure acmetool from redirector to proxy mode <https://github.com/chatmail/relay/pull/861>
* make tests work with `--ssh-host localhost` <https://github.com/chatmail/relay/pull/856>
* mark f-string with f prefix in test_expunged <https://github.com/chatmail/relay/pull/863>
* install also if dovecot.service=False in SystemdEnabled Fact <https://github.com/chatmail/relay/pull/841>
* Introduce support for self-signed chatmail relays <https://github.com/chatmail/relay/pull/855>
* Strip Received headers before delivery <https://github.com/chatmail/relay/pull/849>
* upgrade to filtermail v0.3 <https://github.com/chatmail/relay/pull/850>
* fix link to Maddy and update madmail URL <https://github.com/chatmail/relay/pull/847>
* accept self-signed certificates for IP-only relays <https://github.com/chatmail/relay/pull/846>
* enforce sending from public IP addresses <https://github.com/chatmail/relay/pull/845>
* port check: check addresses, fix single services <https://github.com/chatmail/relay/pull/844>
* remediates issue with improper concat on resolver injection <https://github.com/chatmail/relay/pull/834>
* ipv6 boolean not being respected during operations <https://github.com/chatmail/relay/pull/832>
* upgrade to filtermail v0.2 by <https://github.com/chatmail/relay/pull/825>
* fix link to filtermail <https://github.com/chatmail/relay/pull/824>
* print timestamps when sending messages <https://github.com/chatmail/relay/pull/823>
* fix flaky test_exceed_rate_limit <https://github.com/chatmail/relay/pull/822>
* Replace filtermail with rust reimplementation <https://github.com/chatmail/relay/pull/808>
* Set default internal SMTP ports in Config <https://github.com/chatmail/relay/pull/819>
* separate metrics for incoming and outgoing messages <https://github.com/chatmail/relay/pull/820>
* disable appending the Received header <https://github.com/chatmail/relay/pull/815>
* fail on errors in postfix/dovecot config <https://github.com/chatmail/relay/pull/813>
* tweak idle/hibernate metrics some more <https://github.com/chatmail/relay/pull/811>
* add config flag to export statistics <https://github.com/chatmail/relay/pull/806>
* add --website-only option to run subcommand <https://github.com/chatmail/relay/pull/768>
* Strip DKIM-Signature header before LMTP <https://github.com/chatmail/relay/pull/803>
* properly make sure that postfix gets restarted on failure <https://github.com/chatmail/relay/pull/802>
* expire.py: use absolute path to maildirsize <https://github.com/chatmail/relay/pull/807>
* pin Dovecot documentation URLs to version 2.3 <https://github.com/chatmail/relay/pull/800>
* try to use "build machine" and "deployment server" consistently <https://github.com/chatmail/relay/pull/797>
* adds instructions for migrating control machines <https://github.com/chatmail/relay/pull/795>
* use consistent naming schema in getting started <https://github.com/chatmail/relay/pull/793>
* remove jsok/serialize-workflow-action dependency <https://github.com/chatmail/relay/pull/790>
* streamline migration guide wording, provide titled steps <https://github.com/chatmail/relay/pull/789>
* increases default max mailbox size <https://github.com/chatmail/relay/pull/792>
* use daemon_name for OpenDKIM sign-verify decision instead of IP <https://github.com/chatmail/relay/pull/784>
## 1.9.0 2025-12-18 ## 1.9.0 2025-12-18
### Documentation ### Documentation
-3
View File
@@ -5,6 +5,3 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
[Conventional Commits]: https://www.conventionalcommits.org/ [Conventional Commits]: https://www.conventionalcommits.org/
[git-cliff]: https://git-cliff.org/ [git-cliff]: https://git-cliff.org/
To update client app version information,
edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json).
+1 -6
View File
@@ -8,12 +8,7 @@ Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed f
- **Instant/Realtime:** sub-second message delivery, realtime P2P - **Instant/Realtime:** sub-second message delivery, realtime P2P
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei; streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
- **Security Enforcement**: Only connections with strict TLS are accepted; - **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
There are experimental exceptions for no-DNS relays,
which are allowed use self-signed TLS certificates
and which do not need to DKIM-sign their messages.
Unencrypted messages are allowed in neither case.
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating - **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
depends on established IETF standards and protocols. depends on established IETF standards and protocols.
+6 -4
View File
@@ -1,13 +1,15 @@
# Releasing a new version of chatmail relay # Releasing a new version of chatmail relay
For example, to release version 1.13.0 of chatmail relay, do the following steps. For example, to release version 1.9.0 of chatmail relay, do the following steps.
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`. 1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`.
2. Open the changelog in the editor, edit it if required. 2. Open the changelog in the editor, edit it if required.
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`. 3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
4. Open a PR with the new commit, merge it to main after review. 3. Tag the release: `git tag --annotate 1.9.0`.
5. In the web interface, create a GitHub release, tell it to create a new tag. 4. Push the release tag: `git push origin 1.9.0`.
5. Create a GitHub release: `gh release create 1.9.0`.
-1
View File
@@ -1,4 +1,3 @@
include src/chatmaild/defaults/*.json
include src/chatmaild/ini/*.ini.f include src/chatmaild/ini/*.ini.f
include src/chatmaild/ini/*.ini include src/chatmaild/ini/*.ini
include src/chatmaild/tests/mail-data/* include src/chatmaild/tests/mail-data/*
+9 -5
View File
@@ -6,11 +6,13 @@ build-backend = "setuptools.build_meta"
name = "chatmaild" name = "chatmaild"
version = "0.3" version = "0.3"
dependencies = [ dependencies = [
"aiosmtpd",
"iniconfig", "iniconfig",
"deltachat-rpc-server",
"deltachat-rpc-client",
"filelock", "filelock",
"psutil",
"requests", "requests",
"crypt-r >= 3.13.1 ; python_version >= '3.13'", "crypt-r >= 3.13.1 ; python_version >= '3.11'",
] ]
[tool.setuptools] [tool.setuptools]
@@ -22,10 +24,13 @@ where = ['src']
[project.scripts] [project.scripts]
doveauth = "chatmaild.doveauth:main" doveauth = "chatmaild.doveauth:main"
chatmail-metadata = "chatmaild.metadata:main" chatmail-metadata = "chatmaild.metadata:main"
chatmail-expire = "chatmaild.expire:daily_expire_main" chatmail-expire = "chatmaild.expire:main"
chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
chatmail-fsreport = "chatmaild.fsreport:main" chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
@@ -65,7 +70,6 @@ commands =
deps = pytest deps = pytest
pdbpp pdbpp
pytest-localserver pytest-localserver
aiosmtpd
execnet execnet
commands = pytest -v -rsXx {posargs} commands = pytest -v -rsXx {posargs}
""" """
+68 -95
View File
@@ -1,4 +1,4 @@
import ipaddress import os
from pathlib import Path from pathlib import Path
import iniconfig import iniconfig
@@ -9,89 +9,63 @@ from chatmaild.user import User
def read_config(inipath): def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
return Config(inipath, params=cfg.sections["params"]) params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"])
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
def __init__(self, inipath, params): def __init__(self, inipath, params):
self._inipath = inipath self._inipath = inipath
params = dict(params) self.mail_domain = params["mail_domain"]
raw_domain = params.pop("mail_domain") self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
self.mail_domain_bare = raw_domain self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
self.max_mailbox_size = params["max_mailbox_size"]
if is_valid_ipv4(raw_domain): self.max_message_size = int(params.get("max_message_size", "31457280"))
self.ipv4_relay = raw_domain self.delete_mails_after = params["delete_mails_after"]
self.mail_domain = f"[{raw_domain}]" self.delete_large_after = params["delete_large_after"]
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
else: self.username_min_length = int(params["username_min_length"])
self.ipv4_relay = None self.username_max_length = int(params["username_max_length"])
self.mail_domain = raw_domain self.password_min_length = int(params["password_min_length"])
self.postfix_myhostname = raw_domain self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.max_user_send_per_minute = int(params.pop("max_user_send_per_minute", 60)) self.www_folder = params.get("www_folder", "")
self.max_user_send_burst_size = int(params.pop("max_user_send_burst_size", 10)) self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
self.max_mailbox_size = params.pop("max_mailbox_size", "500M")
self.max_message_size = int(params.pop("max_message_size", 31457280))
self.delete_mails_after = params.pop("delete_mails_after", "20")
self.delete_large_after = params.pop("delete_large_after", "7")
self.delete_inactive_users_after = int(
params.pop("delete_inactive_users_after", 90)
)
self.username_min_length = int(params.pop("username_min_length", 9))
self.username_max_length = int(params.pop("username_max_length", 9))
self.password_min_length = int(params.pop("password_min_length", 9))
self.www_folder = params.pop("www_folder", "")
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
self.filtermail_smtp_port_incoming = int( self.filtermail_smtp_port_incoming = int(
params.pop("filtermail_smtp_port_incoming", "10081") params.get("filtermail_smtp_port_incoming", "10081")
) )
self.filtermail_http_port_incoming = int( self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
params.pop("filtermail_http_port_incoming", "10082")
)
self.filtermail_lmtp_port_transport = int(
params.pop("filtermail_lmtp_port_transport", "10083")
)
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025"))
self.postfix_reinject_port_incoming = int( self.postfix_reinject_port_incoming = int(
params.pop("postfix_reinject_port_incoming", "10026") params.get("postfix_reinject_port_incoming", "10026")
) )
self.doveauth_http_port = int(params.pop("doveauth_http_port", "10084")) self.mtail_address = params.get("mtail_address")
self.mtail_address = params.pop("mtail_address", None) self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true" self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
self.acme_email = params.pop("acme_email", "") self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true" self.acme_email = params.get("acme_email", "")
self.imap_compress = params.pop("imap_compress", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.turn_socket_path = params.pop( self.imap_compress = params.get("imap_compress", "false").lower() == "true"
"turn_socket_path", "/run/chatmail-turn/turn.socket" if "iroh_relay" not in params:
) self.iroh_relay = "https://" + params["mail_domain"]
iroh_relay = params.pop("iroh_relay", None)
if iroh_relay is None:
self.iroh_relay = "https://" + raw_domain
self.enable_iroh_relay = True self.enable_iroh_relay = True
else: else:
self.iroh_relay = iroh_relay.strip() self.iroh_relay = params["iroh_relay"].strip()
self.enable_iroh_relay = False self.enable_iroh_relay = False
self.privacy_postal = params.pop("privacy_postal", None) self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.pop("privacy_mail", None) self.privacy_mail = params.get("privacy_mail")
self.privacy_pdo = params.pop("privacy_pdo", None) self.privacy_pdo = params.get("privacy_pdo")
self.privacy_supervisor = params.pop("privacy_supervisor", None) self.privacy_supervisor = params.get("privacy_supervisor")
self.max_load_1m = float(params.pop("max_load_1m", 5))
self.min_available_memory_mb = parse_size_mb(
params.pop("min_available_memory", "200M")
)
self.min_free_disk_space_mb = parse_size_mb(
params.pop("min_free_disk_space", "1G")
)
self.max_imap_connections = int(params.pop("max_imap_connections", 10000))
self.max_smtp_connections = int(params.pop("max_smtp_connections", 1000))
# TLS certificate management. # TLS certificate management.
# If tls_external_cert_and_key is set, use externally managed certs. # If tls_external_cert_and_key is set, use externally managed certs.
# Otherwise derived from the domain name: # Otherwise derived from the domain name:
# - Domains starting with "_" use self-signed certificates # - Domains starting with "_" use self-signed certificates
# - All other domains use ACME. # - All other domains use ACME.
external = params.pop("tls_external_cert_and_key", "").strip() external = params.get("tls_external_cert_and_key", "").strip()
if external: if external:
parts = external.split() parts = external.split()
@@ -102,27 +76,21 @@ class Config:
) )
self.tls_cert_mode = "external" self.tls_cert_mode = "external"
self.tls_cert_path, self.tls_key_path = parts self.tls_cert_path, self.tls_key_path = parts
elif raw_domain.startswith("_") or self.ipv4_relay: elif self.mail_domain.startswith("_"):
self.tls_cert_mode = "self" self.tls_cert_mode = "self"
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem" self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
self.tls_key_path = "/etc/ssl/private/mailserver.key" self.tls_key_path = "/etc/ssl/private/mailserver.key"
else: else:
self.tls_cert_mode = "acme" self.tls_cert_mode = "acme"
self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain" self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey" self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
# deprecated option # deprecated option
mbdir = params.pop("mailboxes_dir", f"/home/vmail/mail/{raw_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())
# old unused option (except for first migration from sqlite to maildir store) # old unused option (except for first migration from sqlite to maildir store)
self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite")) self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
self._unused_keys = list(params)
@property
def max_mailbox_size_mb(self):
"""Return max_mailbox_size as an integer in megabytes."""
return parse_size_mb(self.max_mailbox_size)
def _getbytefile(self): def _getbytefile(self):
return open(self._inipath, "rb") return open(self._inipath, "rb")
@@ -137,16 +105,6 @@ class Config:
return User(maildir, addr, password_path, uid="vmail", gid="vmail") return User(maildir, addr, password_path, uid="vmail", gid="vmail")
def parse_size_mb(limit):
"""Parse a size string like ``500M`` or ``2G`` and return megabytes."""
value = limit.strip().upper().removesuffix("B")
if value.endswith("G"):
return int(value[:-1]) * 1024
if value.endswith("M"):
return int(value[:-1])
return int(value)
def write_initial_config(inipath, mail_domain, overrides): def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides.""" """Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides) content = get_default_config_content(mail_domain, **overrides)
@@ -174,13 +132,28 @@ def get_default_config_content(mail_domain, **overrides):
for name, value in extra.items(): for name, value in extra.items():
new_line = f"{name} = {value}" new_line = f"{name} = {value}"
new_lines.append(new_line) new_lines.append(new_line)
return "\n".join(new_lines)
content = "\n".join(new_lines)
def is_valid_ipv4(address: str) -> bool: # apply testrun privacy overrides
"""Check if a mail_domain is an IPv4 address."""
try: if mail_domain.endswith(".testrun.org"):
ipaddress.IPv4Address(address) override_inipath = inidir.joinpath("override-testrun.ini")
return True privacy = iniconfig.IniConfig(override_inipath)["privacy"]
except ValueError: lines = []
return False for line in content.split("\n"):
for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines:
continue
if len(value_lines) == 1:
lines.append(f"{key} = {value}")
else:
lines.append(f"{key} =")
for vl in value_lines:
lines.append(f" {vl}")
break
else:
lines.append(line)
content = "\n".join(lines)
return content
@@ -1,15 +0,0 @@
{
"clients": [
{
"clientId": "deltachat",
"sources": [
{
"sourceId": "gplay",
"versionInteger": 757,
"versionString": "2.59.1",
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.59.1/deltachat-gplay-release-2.59.1.apk"
}
]
}
]
}
+94 -111
View File
@@ -1,17 +1,10 @@
"""Create chatmail addresses on first login. import json
Dovecot only asks us about addresses it does not already find in the mailbox:
the auth.lua we deploy with dovecot (cmdeploy/src/cmdeploy/dovecot/auth.lua.j2)
verifies existing users itself against a mailbox password file,
and HTTP-POSTs everything else to the /create endpoint implemented in this module.
"""
import logging import logging
import os import os
import re import re
import sys import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import filelock
try: try:
import crypt_r import crypt_r
@@ -19,8 +12,8 @@ except ImportError:
import crypt as crypt_r import crypt as crypt_r
from .config import Config, read_config from .config import Config, read_config
from .dictproxy import DictProxy
from .migrate_db import migrate_from_db_to_maildir from .migrate_db import migrate_from_db_to_maildir
from .syslimits import has_sufficient_resources
NOCREATE_FILE = "/etc/chatmail-nocreate" NOCREATE_FILE = "/etc/chatmail-nocreate"
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$") VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
@@ -70,117 +63,107 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
return True return True
def verify_password(stored, cleartext_password) -> bool: def split_and_unescape(s):
if stored.startswith("{"): """Split strings using double quote as a separator and backslash as escape character
stored = stored.split("}", 1)[1] into parts."""
return crypt_r.crypt(cleartext_password, stored) == stored
out = ""
i = 0
while i < len(s):
c = s[i]
if c == "\\":
# Skip escape character.
i += 1
# This will raise IndexError if there is no character
# after escape character. This is expected
# as this is an invalid input.
out += s[i]
elif c == '"':
# Separator
yield out
out = ""
else:
out += c
i += 1
yield out
class DoveAuth: class AuthDictProxy(DictProxy):
def __init__(self, config): def __init__(self, config):
super().__init__()
self.config = config self.config = config
self.creation_lock = threading.Lock()
def create_user(self, addr, cleartext_password) -> bool: def handle_lookup(self, parts):
"""Create the address, or verify the password if it exists already.""" # Dovecot <2.3.17 has only one part,
# do not attempt to read any other parts for compatibility.
keyname = parts[0]
namespace, type, args = keyname.split("/", 2)
args = list(split_and_unescape(args))
config = self.config config = self.config
if not addr.endswith(f"@{config.mail_domain}"): reply_command = "F"
logging.warning("address not in mail domain: %r", addr) res = ""
return False if namespace == "shared":
try: if type == "userdb":
user = config.get_user(addr) user = args[0]
except ValueError: if user.endswith(f"@{config.mail_domain}"):
logging.warning("invalid address: %r", addr) res = self.lookup_userdb(user)
return False if res:
with self.creation_lock: reply_command = "O"
passhash = user.get_password_hash() else:
if passhash is not None: reply_command = "N"
# a concurrent first login may have just created the address elif type == "passdb":
return verify_password(passhash, cleartext_password) user = args[1]
if not is_allowed_to_create(config, addr, cleartext_password): if user.endswith(f"@{config.mail_domain}"):
return False res = self.lookup_passdb(user, cleartext_password=args[0])
if not has_sufficient_resources(config): if res:
return False reply_command = "O"
else:
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
def handle_iterate(self, parts):
# example: I0\t0\tshared/userdb/
if parts[2] == "shared/userdb/":
result = "".join(
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
)
return f"{result}\n"
def iter_userdb(self) -> list:
"""Get a list of all user addresses."""
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
def lookup_userdb(self, addr):
return self.config.get_user(addr).get_userdb_dict()
def lookup_passdb(self, addr, cleartext_password):
user = self.config.get_user(addr)
userdata = user.get_userdb_dict()
if userdata:
return userdata
if not is_allowed_to_create(self.config, addr, cleartext_password):
return
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
with lock:
userdata = user.get_userdb_dict()
if userdata:
return userdata
user.set_password(encrypt_password(cleartext_password)) user.set_password(encrypt_password(cleartext_password))
# mtail counts created_accounts off this exact line print(f"Created address: {addr}", file=sys.stderr)
print(f"Created address: {addr}", file=sys.stderr) return user.get_userdb_dict()
return True
class CreateHandler(BaseHTTPRequestHandler):
"""Answer POST /create requests from dovecot's auth.lua, body `addr\\tpassword`.
The body must be UTF-8 and only the first tab separates the fields,
so a password may itself contain tabs.
Any non-UTF8 or \\0 bytes in the body fail the request.
Addresses are ASCII: dovecot refuses any login name outside its
auth_username_chars before auth.lua ever sees it.
Dovecot hands auth.lua the exact password bytes the client sent;
decoding and re-encoding UTF-8 is byte-identical,
so dovecot's password_verify later recomputes the same hash crypt() stores here.
"""
protocol_version = "HTTP/1.1" # dovecot's HTTP client reuses connections
max_body_len = 512 # an address and a password
def do_POST(self):
if self.path != "/create":
self.reply(404)
return
length = self.body_length()
if length is None:
self.reply(400)
return
body = self.rfile.read(length)
try:
addr, _, password = body.decode("utf-8").partition("\t")
except UnicodeDecodeError:
self.reply(400)
return
if "\0" in addr or "\0" in password:
self.reply(400)
return
self.reply(200 if self.server.doveauth.create_user(addr, password) else 403)
def body_length(self):
try:
length = int(self.headers["Content-Length"])
except (TypeError, ValueError):
return None
return length if 0 <= length <= self.max_body_len else None
def reply(self, status):
self.send_response(status)
self.send_header("Content-Length", "0")
if status != 200:
# Just close on any failure, as body might not be fully read.
# It's anyway cheap to re-establish http localhost without TLS.
self.send_header("Connection", "close")
self.end_headers()
def log_message(self, format, *args):
# the per-request access log would only duplicate our own stderr lines
pass
class DoveAuthServer(ThreadingHTTPServer):
# a burst of first-time logins (e.g. from CI) must not overflow
# the accept queue, see https://github.com/chatmail/relay/issues/436
request_queue_size = 1000
def __init__(self, config, port):
super().__init__(("127.0.0.1", port), CreateHandler)
self.doveauth = DoveAuth(config)
def main(): def main():
(cfgpath,) = sys.argv[1:] socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath) config = read_config(cfgpath)
migrate_from_db_to_maildir(config) migrate_from_db_to_maildir(config)
server = DoveAuthServer(config, config.doveauth_http_port) dictproxy = AuthDictProxy(config=config)
server.serve_forever()
dictproxy.serve_forever_from_socket(socket)
+5 -101
View File
@@ -4,26 +4,17 @@ Expire old messages and addresses.
""" """
import os import os
import re
import shutil import shutil
import sys import sys
import time import time
from argparse import ArgumentParser from argparse import ArgumentParser
from collections import namedtuple from collections import namedtuple
from datetime import datetime from datetime import datetime
from pathlib import Path
from stat import S_ISREG 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", ("path", "mtime", "size"))
QuotaFileEntry = namedtuple("QuotaFileEntry", ("mtime", "quota_size", "path"))
# Quota cleanup factor of max_mailbox_size. The mailbox is reset to this size.
QUOTA_CLEANUP_FACTOR = 0.7
# e.g. "cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S"
_dovecot_fn_rex = re.compile(r".+/(\d+)\..+,S=(\d+)")
def iter_mailboxes(basedir, maxnum): def iter_mailboxes(basedir, maxnum):
@@ -83,42 +74,6 @@ class MailboxStat:
self.extrafiles.sort(key=lambda x: -x.size) self.extrafiles.sort(key=lambda x: -x.size)
def parse_dovecot_filename(relpath):
m = _dovecot_fn_rex.match(relpath)
if not m:
return None
return QuotaFileEntry(int(m.group(1)), int(m.group(2)), relpath)
def scan_mailbox_messages(mbox):
messages = []
for sub in ("cur", "new"):
for name in os_listdir_if_exists(mbox / sub):
if entry := parse_dovecot_filename(f"{sub}/{name}"):
messages.append(entry)
return messages
def expire_to_target(mbox, target_bytes):
messages = scan_mailbox_messages(mbox)
total_size = sum(m.quota_size for m in messages)
# Keep recent 24 hours of messages protected from expiry because
# likely something is wrong with interactions on that address
# and quota-full signal can help the address owner's device to notice it
undeletable_messages_cutoff = time.time() - (3600 * 24)
removed = 0
for entry in sorted(messages):
if total_size <= target_bytes:
break
if entry.mtime > undeletable_messages_cutoff:
break
(mbox / entry.path).unlink(missing_ok=True)
total_size -= entry.quota_size
removed += 1
return removed
def print_info(msg): def print_info(msg):
print(msg, file=sys.stderr) print(msg, file=sys.stderr)
@@ -168,16 +123,6 @@ class Expiry:
if mbox.last_login and mbox.last_login < cutoff_without_login: if mbox.last_login and mbox.last_login < cutoff_without_login:
self.remove_mailbox(mbox.basedir) self.remove_mailbox(mbox.basedir)
return return
elif mbox.last_login is None:
try:
if not self.dry:
os.rmdir(mbox.basedir)
self.del_mboxes += 1
except OSError:
print_info(
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
)
return
mboxname = os.path.basename(mbox.basedir) mboxname = os.path.basename(mbox.basedir)
if self.verbose: if self.verbose:
@@ -198,19 +143,6 @@ class Expiry:
else: else:
continue continue
changed = True changed = True
target_bytes = (
self.config.max_mailbox_size_mb * 1024 * 1024 * QUOTA_CLEANUP_FACTOR
)
removed = expire_to_target(Path(mbox.basedir), target_bytes)
if removed:
changed = True
self.del_files += removed
if self.verbose:
print_info(
f"quota-expire: removed {removed} message(s) from {mboxname}"
)
if changed: if changed:
self.remove_file(f"{mbox.basedir}/maildirsize") self.remove_file(f"{mbox.basedir}/maildirsize")
@@ -222,9 +154,9 @@ class Expiry:
) )
def daily_expire_main(args=None): def main(args=None):
"""Expire mailboxes and messages according to chatmail config""" """Expire mailboxes and messages according to chatmail config"""
parser = ArgumentParser(description=daily_expire_main.__doc__) parser = ArgumentParser(description=main.__doc__)
ini = "/usr/local/lib/chatmaild/chatmail.ini" ini = "/usr/local/lib/chatmaild/chatmail.ini"
parser.add_argument( parser.add_argument(
"chatmail_ini", "chatmail_ini",
@@ -259,7 +191,7 @@ def daily_expire_main(args=None):
args = parser.parse_args(args) args = parser.parse_args(args)
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = time.time() now = datetime.utcnow().timestamp()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
@@ -270,33 +202,5 @@ def daily_expire_main(args=None):
print(exp.get_summary()) print(exp.get_summary())
def quota_expire_main(args=None): if __name__ == "__main__":
"""Remove mailbox messages to stay within a megabyte target. main(sys.argv[1:])
This entry point is called by dovecot when a quota threshold is passed.
"""
parser = ArgumentParser(description=quota_expire_main.__doc__)
parser.add_argument(
"target_mb",
type=int,
help="target mailbox size in megabytes",
)
parser.add_argument(
"mailbox_path",
type=Path,
help="path to a user mailbox",
)
args = parser.parse_args(args)
target_bytes = args.target_mb * 1024 * 1024
removed_count = expire_to_target(args.mailbox_path, target_bytes)
if removed_count:
(args.mailbox_path / "maildirsize").unlink(missing_ok=True)
print(
f"quota-expire: removed {removed_count} message(s)"
f" from {args.mailbox_path.name}",
file=sys.stderr,
)
return 0
+1 -2
View File
@@ -27,7 +27,6 @@ to also write legacy metrics.py style output (default: /var/www/html/metrics):
import os import os
import tempfile import tempfile
import time
from argparse import ArgumentParser from argparse import ArgumentParser
from datetime import datetime from datetime import datetime
@@ -265,7 +264,7 @@ def main(args=None):
config = read_config(args.chatmail_ini) config = read_config(args.chatmail_ini)
now = time.time() now = datetime.utcnow().timestamp()
if args.days: if args.days:
now = now - 86400 * int(args.days) now = now - 86400 * int(args.days)
+27 -40
View File
@@ -12,62 +12,41 @@ mail_domain = {mail_domain}
# #
# email sending rate per user and minute # email sending rate per user and 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) # per-user max burst size for sending rate limiting (GCRA bucket capacity)
#max_user_send_burst_size = 10 max_user_send_burst_size = 10
# maximum mailbox size of a chatmail address # maximum mailbox size of a chatmail address
# (Oldest messages will be removed automatically, so mailboxes never run full) max_mailbox_size = 500M
#max_mailbox_size = 500M
# 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
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
#delete_mails_after = 20 delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted # days after which large messages (>200k) are unconditionally deleted
#delete_large_after = 7 delete_large_after = 7
# days after which users without a successful login are deleted (database and mails) # days after which users without a successful login are deleted (database and mails)
#delete_inactive_users_after = 90 delete_inactive_users_after = 90
# minimum length a username must have # minimum length a username must have
#username_min_length = 9 username_min_length = 9
# maximum length a username can have # maximum length a username can have
#username_max_length = 9 username_max_length = 9
# minimum length a password must have # minimum length a password must have
#password_min_length = 9 password_min_length = 9
# # list of chatmail addresses which can send outbound un-encrypted mail
# System resource limits passthrough_senders =
#
# The following three limits refuse creation of new addresses # list of e-mail recipients for which to accept outbound un-encrypted mails
# while existing addresses keep working. # (space-separated, item may start with "@" to whitelist whole recipient domains)
# Rejections are logged by the doveauth service. passthrough_recipients =
# Maximum 1-minute load average, as reported by "uptime";
# it counts processes waiting for disk I/O as well as for CPU.
#max_load_1m = 5
# Minimum memory available without swapping.
#min_available_memory = 200M
# Minimum free disk space on the file system holding the mailboxes.
#min_free_disk_space = 1G
# Maximum number of concurrent IMAP connections
# (the Dovecot imap process limit).
#max_imap_connections = 10000
# Maximum number of concurrent SMTP connections
# on each of the submission and smtps ports (the Postfix process limit).
# A single client IP may use up to a fifth of this.
#max_smtp_connections = 1000
# Use externally managed TLS certificates instead of built-in acmetool. # Use externally managed TLS certificates instead of built-in acmetool.
# Paths refer to files on the deployment server (not the build machine). # Paths refer to files on the deployment server (not the build machine).
@@ -83,11 +62,19 @@ mail_domain = {mail_domain}
# Deployment Details # Deployment Details
# #
# SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080
postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection
filtermail_smtp_port_incoming = 10081
postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
#disable_ipv6 = False disable_ipv6 = False
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates # Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
#acme_email = acme_email =
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail # Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service. # service.
@@ -120,13 +107,13 @@ mail_domain = {mail_domain}
# in per-maildir ".in/.out" files. # in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files # Note that you need to manually cleanup these files
# so use this option with caution on production servers. # so use this option with caution on production servers.
#imap_rawlog = false imap_rawlog = false
# set to true if you want to enable the IMAP COMPRESS Extension, # set to true if you want to enable the IMAP COMPRESS Extension,
# which allows IMAP connections to be efficiently compressed. # which allows IMAP connections to be efficiently compressed.
# WARNING: Enabling this makes it impossible to hibernate IMAP # WARNING: Enabling this makes it impossible to hibernate IMAP
# processes which will result in much higher memory/RAM usage. # processes which will result in much higher memory/RAM usage.
#imap_compress = false imap_compress = false
# #
@@ -0,0 +1,16 @@
[privacy]
passthrough_recipients = privacy@testrun.org echo@{mail_domain}
privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel,
Reichgrafen Str. 20, 79102 Freiburg, Germany
privacy_mail = privacy@testrun.org
privacy_pdo =
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
privacy_supervisor =
State Commissioner for Data Protection and Freedom of Information of
Baden-Württemberg in 70173 Stuttgart, Germany.
+29 -67
View File
@@ -1,35 +1,13 @@
import json
import logging import logging
import socket
import sys import sys
import time import time
from contextlib import contextmanager from contextlib import contextmanager
from importlib.resources import files
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier from .notifier import Notifier
from .turnserver import turn_credentials
def turn_credentials(turn_socket_path):
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect(turn_socket_path)
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
def read_appversions(path):
try:
data = json.loads(path.read_bytes())
except FileNotFoundError:
return None
except (OSError, ValueError):
logging.exception(f"failed to read {path}")
return None
# the dict protocol is line-based, keep the value single-line
return json.dumps(data, separators=(",", ":"))
def _is_valid_token_timestamp(timestamp, now): def _is_valid_token_timestamp(timestamp, now):
@@ -92,57 +70,44 @@ class Metadata:
# Some tokens have expired, remove them. # Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens: with self._modify_tokens(addr) as _tokens:
pass pass
elif isinstance(tokens, list):
with self._modify_tokens(addr) as tokens:
token_list = list(tokens.keys())
else: else:
token_list = [] token_list = []
return token_list return token_list
class MetadataDictProxy(DictProxy): class MetadataDictProxy(DictProxy):
def __init__( def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None):
self,
notifier,
metadata,
iroh_relay=None,
turn_hostname=None,
turn_socket_path=None,
):
super().__init__() super().__init__()
self.notifier = notifier self.notifier = notifier
self.metadata = metadata self.metadata = metadata
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname self.turn_hostname = turn_hostname
self.turn_socket_path = turn_socket_path
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
match parts[0].split("/", 2): keyparts = parts[0].split("/", 2)
case ["priv", _, keyname] if keyname == self.metadata.DEVICETOKEN_KEY: if keyparts[0] == "priv":
addr = parts[1] keyname = keyparts[2]
addr = parts[1]
if keyname == self.metadata.DEVICETOKEN_KEY:
res = " ".join(self.metadata.get_tokens_for_addr(addr)) res = " ".join(self.metadata.get_tokens_for_addr(addr))
return f"O{res}\n" return f"O{res}\n"
case ["shared", _, keyname]: elif keyparts[0] == "shared":
prefix = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/" keyname = keyparts[2]
if keyname.startswith(prefix): if (
match keyname[len(prefix) :]: keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
case "irohrelay" if self.iroh_relay: and self.iroh_relay
return f"O{self.iroh_relay}\n" ):
case "turn": # Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
try: return f"O{self.iroh_relay}\n"
res = turn_credentials(self.turn_socket_path) elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
except Exception: try:
logging.exception("failed to get TURN credentials") res = turn_credentials()
return "N\n" except Exception:
return f"O{self.turn_hostname}:3478:{res}\n" logging.exception("failed to get TURN credentials")
case "maxsmtprecipients": return "N\n"
# postfix default (see "postconf smtpd_recipient_limit") port = 3478
return "O1000\n" return f"O{self.turn_hostname}:{port}:{res}\n"
case "appversions":
value = read_appversions(self.appversions_path)
return f"O{value}\n" if value else "N\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -152,13 +117,12 @@ class MetadataDictProxy(DictProxy):
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
match keyname: if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY:
case ["priv", _, key] if key == self.metadata.DEVICETOKEN_KEY: self.metadata.add_token_to_addr(addr, value)
self.metadata.add_token_to_addr(addr, value) return True
return True elif keyname[0] == "priv" and keyname[2] == "messagenew":
case ["priv", _, "messagenew"]: self.notifier.new_message_for_addr(addr, self.metadata)
self.notifier.new_message_for_addr(addr, self.metadata) return True
return True
return False return False
@@ -169,7 +133,6 @@ def main():
config = read_config(config_path) config = read_config(config_path)
iroh_relay = config.iroh_relay iroh_relay = config.iroh_relay
mail_domain = config.mail_domain mail_domain = config.mail_domain
socket_path = config.turn_socket_path
vmail_dir = config.mailboxes_dir vmail_dir = config.mailboxes_dir
if not vmail_dir.exists(): if not vmail_dir.exists():
@@ -187,7 +150,6 @@ def main():
metadata=metadata, metadata=metadata,
iroh_relay=iroh_relay, iroh_relay=iroh_relay,
turn_hostname=mail_domain, turn_hostname=mail_domain,
turn_socket_path=socket_path,
) )
dictproxy.serve_forever_from_socket(socket) dictproxy.serve_forever_from_socket(socket)
+3 -11
View File
@@ -25,19 +25,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(config, email, password): def create_dclogin_url(email, password):
"""Build a dclogin: URL with credentials and self-signed cert acceptance. """Build a dclogin: URL with credentials and self-signed cert acceptance.
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
can connect to servers with self-signed TLS certificates. can connect to servers with self-signed TLS certificates.
""" """
if config.ipv4_relay: return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
imap_host = "&ih=" + config.ipv4_relay
smtp_host = "&sh=" + config.ipv4_relay
else:
imap_host = ""
smtp_host = ""
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
def print_new_account(): def print_new_account():
@@ -46,9 +40,7 @@ def print_new_account():
result = dict(email=creds["email"], password=creds["password"]) result = dict(email=creds["email"], password=creds["password"])
if config.tls_cert_mode == "self": if config.tls_cert_mode == "self":
result["dclogin_url"] = create_dclogin_url( result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
config, creds["email"], creds["password"]
)
print("Content-Type: application/json") print("Content-Type: application/json")
print("") print("")
-32
View File
@@ -1,32 +0,0 @@
"""Detect whether the system is at its limits."""
import logging
import psutil
MB = 1024 * 1024
def read_value(getter):
try:
return getter()
except Exception as e:
logging.warning("ignoring unreadable system limit: %s", e)
return None
def has_sufficient_resources(config):
"""Return False if load, memory or disk exceeds a configured limit."""
load = read_value(lambda: psutil.getloadavg()[0])
mem = read_value(lambda: psutil.virtual_memory().available // MB)
disk = read_value(lambda: psutil.disk_usage(str(config.mailboxes_dir)).free // MB)
if load is not None and load > config.max_load_1m:
msg = f"load avg {load:.2f} > {config.max_load_1m:.2f}"
elif mem is not None and mem < config.min_available_memory_mb:
msg = f"available memory {mem}MB < {config.min_available_memory_mb}MB"
elif disk is not None and disk < config.min_free_disk_space_mb:
msg = f"free disk {disk}MB < {config.min_free_disk_space_mb}MB"
else:
return True
logging.warning("registration rejected: %s", msg)
return False
@@ -1,3 +0,0 @@
"""Opt in to the chatmaild fixtures, which are not registered globally."""
from chatmaild.tests.plugin import * # noqa: F403
+5 -14
View File
@@ -20,10 +20,6 @@ def make_config(tmp_path):
basedir.mkdir(parents=True, exist_ok=True) basedir.mkdir(parents=True, exist_ok=True)
overrides = settings.copy() if settings else {} overrides = settings.copy() if settings else {}
overrides["mailboxes_dir"] = str(basedir) overrides["mailboxes_dir"] = str(basedir)
# permissive resource limits so tests never depend on host load/memory/disk
overrides.setdefault("max_load_1m", "99999")
overrides.setdefault("min_available_memory", "0")
overrides.setdefault("min_free_disk_space", "0")
write_initial_config(inipath, mail_domain, overrides=overrides) write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath) return read_config(inipath)
@@ -36,27 +32,22 @@ def example_config(make_config):
@pytest.fixture @pytest.fixture
def ipv4_config(make_config): def maildomain(example_config):
return make_config("1.3.3.7")
@pytest.fixture
def example_maildomain(example_config):
return example_config.mail_domain return example_config.mail_domain
@pytest.fixture @pytest.fixture
def testaddr(example_maildomain): def testaddr(maildomain):
return f"user.name@{example_maildomain}" return f"user.name@{maildomain}"
@pytest.fixture @pytest.fixture
def example_gencreds(example_maildomain): def gencreds(maildomain):
count = itertools.count() count = itertools.count()
next(count) next(count)
def gen(domain=None): def gen(domain=None):
domain = domain if domain else example_maildomain domain = domain if domain else maildomain
while 1: while 1:
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
@@ -1,96 +0,0 @@
import json
import pytest
from chatmaild.metadata import MetadataDictProxy
ALLOWED_URL_PREFIXES = (
"https://github.com/deltachat/",
"https://download.delta.chat/",
)
def check_string(value):
assert isinstance(value, str), value
assert value
def check_version_integer(value):
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
assert isinstance(value, int) and not isinstance(value, bool), value
assert 0 <= value < 2**32, value
def check_appversions(data):
"""Verifies the file the way core parses it.
core deserializes into typed structs and drops the whole payload
of a relay if a single value has an unexpected type,
while missing or misspelled keys silently turn into defaults.
"""
assert set(data) == {"clients"}, data
assert isinstance(data["clients"], list)
assert data["clients"]
client_ids = []
for client in data["clients"]:
assert set(client) == {"clientId", "sources"}, client
check_string(client["clientId"])
client_ids.append(client["clientId"])
assert isinstance(client["sources"], list)
assert client["sources"]
source_ids = []
for source in client["sources"]:
assert set(source) == {
"sourceId",
"versionInteger",
"versionString",
"downloadUrl",
}, source
check_string(source["sourceId"])
source_ids.append(source["sourceId"])
check_version_integer(source["versionInteger"])
check_string(source["versionString"])
check_string(source["downloadUrl"])
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
# core takes the first matching source, later duplicates never surface
assert len(set(source_ids)) == len(source_ids), source_ids
assert len(set(client_ids)) == len(client_ids), client_ids
@pytest.fixture
def appversions():
# check the file which chatmail-metadata actually serves
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
return json.loads(path.read_text())
def test_appversions_schema(appversions):
check_appversions(appversions)
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
def test_version_integer_rejected(appversions, value):
appversions["clients"][0]["sources"][0]["versionInteger"] = value
with pytest.raises(AssertionError):
check_appversions(appversions)
@pytest.mark.parametrize("key", ["clientId", "sources"])
def test_misspelled_client_key_rejected(appversions, key):
client = appversions["clients"][0]
client[key + "s"] = client.pop(key)
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_duplicate_source_id_rejected(appversions):
sources = appversions["clients"][0]["sources"]
sources.append(dict(sources[0]))
with pytest.raises(AssertionError):
check_appversions(appversions)
def test_foreign_download_url_rejected(appversions):
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
with pytest.raises(AssertionError):
check_appversions(appversions)
+22 -62
View File
@@ -1,10 +1,6 @@
import pytest import pytest
from chatmaild.config import ( from chatmaild.config import read_config
is_valid_ipv4,
parse_size_mb,
read_config,
)
def test_read_config_basic(example_config): def test_read_config_basic(example_config):
@@ -13,46 +9,38 @@ def test_read_config_basic(example_config):
assert not example_config.privacy_pdo and not example_config.privacy_postal assert not example_config.privacy_pdo and not example_config.privacy_postal
inipath = example_config._inipath inipath = example_config._inipath
inipath.write_text( inipath.write_text(inipath.read_text().replace("60", "37"))
inipath.read_text().replace(
"#max_user_send_per_minute = 60",
"max_user_send_per_minute = 37",
)
)
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 37 assert example_config.max_user_send_per_minute == 37
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
assert example_config.ipv4_relay is None
def test_read_config_ipv4(ipv4_config): def test_read_config_basic_using_defaults(tmp_path, maildomain):
assert ipv4_config.ipv4_relay == "1.3.3.7"
assert ipv4_config.mail_domain == "[1.3.3.7]"
def test_read_config_basic_using_defaults(tmp_path, example_maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
inipath.write_text(f"[params]\nmail_domain = {example_maildomain}") inipath.write_text(f"[params]\nmail_domain = {maildomain}")
example_config = read_config(inipath) example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60 assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081 assert example_config.filtermail_smtp_port_incoming == 10081
assert example_config.filtermail_smtp_port == 10080
assert example_config.postfix_reinject_port == 10025
assert example_config.max_user_send_per_minute == 60
assert example_config.max_mailbox_size == "500M"
assert example_config.delete_mails_after == "20"
assert example_config.delete_large_after == "7"
assert example_config.username_min_length == 9
assert example_config.username_max_length == 9
assert example_config.password_min_length == 9
assert example_config.max_imap_connections == 10000
assert example_config.max_smtp_connections == 1000
assert example_config._unused_keys == []
def test_config_unused_keys(make_config): def test_read_config_testrun(make_config):
config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"}) config = make_config("something.testrun.org")
assert config._unused_keys == ["passthrough_senders"] assert config.mail_domain == "something.testrun.org"
assert len(config.privacy_postal.split("\n")) > 1
assert len(config.privacy_supervisor.split("\n")) > 1
assert len(config.privacy_pdo.split("\n")) > 1
assert config.privacy_mail == "privacy@testrun.org"
assert config.filtermail_smtp_port == 10080
assert config.postfix_reinject_port == 10025
assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "500M"
assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9
assert config.username_max_length == 9
assert config.password_min_length == 9
assert "privacy@testrun.org" in config.passthrough_recipients
assert config.passthrough_senders == []
def test_config_userstate_paths(make_config, tmp_path): def test_config_userstate_paths(make_config, tmp_path):
@@ -133,31 +121,3 @@ def test_config_tls_external_bad_format(make_config):
"tls_external_cert_and_key": "/only/one/path.pem", "tls_external_cert_and_key": "/only/one/path.pem",
}, },
) )
def test_parse_size_mb():
assert parse_size_mb("500M") == 500
assert parse_size_mb("2G") == 2048
assert parse_size_mb(" 1g ") == 1024
assert parse_size_mb("100MB") == 100
assert parse_size_mb("256") == 256
def test_max_mailbox_size_mb(make_config):
config = make_config("chat.example.org")
assert config.max_mailbox_size == "500M"
assert config.max_mailbox_size_mb == 500
@pytest.mark.parametrize(
["input", "result"],
[
("example.org", False),
("1.3.3.7", True),
("fe::1", False),
("ad.1e.dag.adf", False),
("12394142", False),
],
)
def test_is_valid_ipv4(input, result):
assert result == is_valid_ipv4(input)
@@ -1,7 +1,7 @@
import time import time
from chatmaild.doveauth import DoveAuth from chatmaild.doveauth import AuthDictProxy
from chatmaild.expire import daily_expire_main as main_expire from chatmaild.expire import main as main_expire
def test_login_timestamps(example_config): def test_login_timestamps(example_config):
@@ -18,10 +18,10 @@ def test_login_timestamps(example_config):
def test_delete_inactive_users(example_config): def test_delete_inactive_users(example_config):
new = time.time() new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1 old = new - (example_config.delete_inactive_users_after * 86400) - 1
doveauth = DoveAuth(example_config) dictproxy = AuthDictProxy(example_config)
def create_user(addr, last_login): def create_user(addr, last_login):
doveauth.create_user(addr, "q9mr3faue") dictproxy.lookup_passdb(addr, "q9mr3faue")
user = example_config.get_user(addr) user = example_config.get_user(addr)
user.maildir.joinpath("cur").mkdir() user.maildir.joinpath("cur").mkdir()
user.maildir.joinpath("cur", "something").mkdir() user.maildir.joinpath("cur", "something").mkdir()
+130 -168
View File
@@ -1,37 +1,42 @@
import http.client import io
import json
import queue
import threading import threading
from concurrent.futures import ThreadPoolExecutor import traceback
import pytest import pytest
import chatmaild.doveauth import chatmaild.doveauth
from chatmaild.doveauth import ( from chatmaild.doveauth import (
CreateHandler, AuthDictProxy,
DoveAuth,
DoveAuthServer,
is_allowed_to_create, is_allowed_to_create,
) )
from chatmaild.newemail import create_newemail_dict from chatmaild.newemail import create_newemail_dict
@pytest.fixture @pytest.fixture
def doveauth(example_config): def dictproxy(example_config):
return DoveAuth(example_config) return AuthDictProxy(config=example_config)
def stored_hash(config, addr): def test_basic(dictproxy, gencreds):
return config.get_user(addr).get_password_hash() addr, password = gencreds()
dictproxy.lookup_passdb(addr, password)
data = dictproxy.lookup_userdb(addr)
assert data
data2 = dictproxy.lookup_passdb(addr, password)
assert data == data2
def test_basic(doveauth, example_config, example_gencreds): def test_iterate_addresses(dictproxy):
addr, password = example_gencreds() addresses = []
assert doveauth.create_user(addr, password)
passhash = stored_hash(example_config, addr)
assert passhash.startswith("{SHA512-CRYPT}")
# a second login verifies against the stored hash and rewrites nothing for i in range(10):
assert doveauth.create_user(addr, password) addresses.append(f"asdf1234{i}@chat.example.org")
assert stored_hash(example_config, addr) == passhash dictproxy.lookup_passdb(addresses[-1], "q9mr3faue")
res = dictproxy.iter_userdb()
assert set(res) == set(addresses)
def test_invalid_username_length(example_config): def test_invalid_username_length(example_config):
@@ -48,32 +53,75 @@ def test_invalid_username_length(example_config):
) )
def test_dont_overwrite_password_on_wrong_login(doveauth, example_config): def test_dont_overwrite_password_on_wrong_login(dictproxy):
addr = "newuser12@chat.example.org" """Test that logging in with a different password doesn't create a new user"""
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983") res = dictproxy.lookup_passdb(
passhash = stored_hash(example_config, addr) "newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
)
assert not doveauth.create_user(addr, "kajdslqwe") assert res["password"]
assert stored_hash(example_config, addr) == passhash res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe")
# this function always returns a password hash, which is actually compared by dovecot.
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983") assert res["password"] == res2["password"]
assert stored_hash(example_config, addr) == passhash
def test_foreign_domain_is_refused(doveauth): def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
assert not doveauth.create_user("newuser12@evil.example.org", "qlwkejqlwe12")
def test_nocreate_file(monkeypatch, tmpdir, doveauth, example_config):
p = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
p.write("") p.write("")
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p)) monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
addr = "newuser12@chat.example.org" dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik")
assert not doveauth.create_user(addr, "zequ0Aimuchoodaechik") assert not dictproxy.lookup_userdb("newuser12@chat.example.org")
assert stored_hash(example_config, addr) is None
def test_handle_dovecot_request(dictproxy):
transactions = {}
# Test that password can contain ", ', \ and /
msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org"
)
res = dictproxy.handle_dovecot_request(msg, transactions)
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b""
assert not caplog.messages
def test_handle_dovecot_protocol_user_not_exists(example_config):
dictproxy = AuthDictProxy(config=example_config)
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(gencreds, example_config):
dictproxy = AuthDictProxy(config=example_config)
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue")
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
lines = wfile.getvalue().decode("ascii").split("\n")
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
assert not lines[2]
def test_invalid_localpart_characters(make_config): def test_invalid_localpart_characters(make_config):
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
config = make_config("chat.example.org", {"username_min_length": "3"}) config = make_config("chat.example.org", {"username_min_length": "3"})
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
domain = config.mail_domain domain = config.mail_domain
@@ -93,150 +141,64 @@ def test_invalid_localpart_characters(make_config):
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password) assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
assert not is_allowed_to_create(config, f"abc/def@{domain}", password) assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password) assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
assert not is_allowed_to_create(config, f"üser123@{domain}", password)
def test_concurrent_creation_same_account(doveauth, example_config, capsys): def test_concurrent_creation_same_account(dictproxy):
"""Test that concurrent creation of the same account doesn't corrupt password."""
addr = "racetest1@chat.example.org" addr = "racetest1@chat.example.org"
password = "zequ0Aimuchoodaechik" password = "zequ0Aimuchoodaechik"
num_threads = 10
results = queue.Queue()
def create(_): def create():
ok = doveauth.create_user(addr, password) try:
return ok, stored_hash(example_config, addr) res = dictproxy.lookup_passdb(addr, password)
results.put(("ok", res))
except Exception:
results.put(("err", traceback.format_exc()))
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
passwords_seen = set()
for _ in range(num_threads):
status, res = results.get()
if status == "err":
pytest.fail(f"concurrent creation failed\n{res}")
passwords_seen.add(res["password"])
with ThreadPoolExecutor(10) as pool:
results = list(pool.map(create, range(10)))
assert all(ok for ok, _ in results)
# all threads must see the same password hash # all threads must see the same password hash
assert len({passhash for _, passhash in results}) == 1 assert len(passwords_seen) == 1
assert capsys.readouterr().err.count("Created address:") == 1
def test_insufficient_resources_block_creation_not_existing_logins( def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
doveauth, example_gencreds, monkeypatch num_threads = 50
): req_per_thread = 5
addr, password = example_gencreds() results = queue.Queue()
assert doveauth.create_user(addr, password)
monkeypatch.setattr( def lookup():
chatmaild.doveauth, "has_sufficient_resources", lambda config: False for i in range(req_per_thread):
) addr, password = gencreds()
newaddr, newpassword = example_gencreds()
assert not doveauth.create_user(newaddr, newpassword)
assert doveauth.create_user(addr, password)
class TestHttpPost:
@pytest.fixture
def doveauth_server(self, example_config):
server = DoveAuthServer(example_config, port=0)
threading.Thread(target=server.serve_forever, daemon=True).start()
yield f"127.0.0.1:{server.server_address[1]}"
server.shutdown()
server.server_close()
@pytest.fixture
def post(self, doveauth_server):
def post(path, data):
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
try: try:
return self.post_on(conn, path, data).status dictproxy.lookup_passdb(addr, password)
finally: except Exception:
conn.close() results.put(traceback.format_exc())
else:
results.put(None)
return post threads = []
for i in range(num_threads):
thread = threading.Thread(target=lookup, daemon=True)
threads.append(thread)
@pytest.fixture print(f"created {num_threads} threads, starting them and waiting for results")
def connection(self, doveauth_server): for thread in threads:
"""One kept-alive connection, which is all dovecot's HTTP client opens.""" thread.start()
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
yield conn
conn.close()
@staticmethod for i in range(num_threads * req_per_thread):
def post_on(conn, path, data): res = results.get()
conn.request("POST", path, body=data) if res is not None:
resp = conn.getresponse() pytest.fail(f"concurrent lookup failed\n{res}")
resp.read()
return resp
def test_create_and_verify(self, post, example_config, example_gencreds):
addr, password = example_gencreds()
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert stored_hash(example_config, addr).startswith("{SHA512-CRYPT}")
# second login with the same password verifies, a wrong one is refused
assert post("/create", f"{addr}\t{password}".encode()) == 200
assert post("/create", f"{addr}\twrong{password}".encode()) == 403
def test_password_special_chars_survive_transport(self, post, example_gencreds):
addr, _ = example_gencreds()
password = "laksjdlaksjdlak\\sjdlk\"12j'3l1/k2\tj3123"
body = f"{addr}\t{password}".encode()
assert post("/create", body) == 200
assert post("/create", body) == 200
assert post("/create", f"{addr}\totherpassword1".encode()) == 403
def test_password_must_be_utf8(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpässwort12".encode()) == 200
assert post("/create", addr.encode() + b"\tp\xe4sswort12") == 400
def test_nul_is_refused_before_crypt_sees_it(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tpass\0word12".encode()) == 400
assert (
post("/create", "us\0er12345@chat.example.org\tlongenough1".encode()) == 400
)
def test_refused_creation(self, post, example_gencreds):
addr, _ = example_gencreds()
assert post("/create", f"{addr}\tshort".encode()) == 403
assert post("/create", b"not-an-address\tlongenoughpassword") == 403
body = "bürger123@chat.example.org\tlongenoughpw".encode()
assert post("/create", body) == 403
assert post("/create", b"") == 403
def test_body_length_limit(self, post, example_gencreds):
addr, _ = example_gencreds()
fill = CreateHandler.max_body_len - len(addr) - len("\t")
body = f"{addr}\t{'x' * fill}".encode()
assert len(body) == CreateHandler.max_body_len
assert post("/create", body) == 200
body = f"{addr}\t{'x' * (fill + 1)}".encode()
assert len(body) == CreateHandler.max_body_len + 1
assert post("/create", body) == 400
def test_connection_is_reused_across_200_replies(
self, connection, example_gencreds
):
addr, password = example_gencreds()
body = f"{addr}\t{password}".encode()
# create, then verify the same password, on one connection
for _ in range(2):
resp = self.post_on(connection, "/create", body)
assert (resp.status, resp.will_close) == (200, False)
@pytest.mark.parametrize(
"path,data,status",
[
("/other", b"not read", 404),
("/create", b"x" * (CreateHandler.max_body_len + 1), 400),
("/create", b"not-an-address\tlongenoughpassword", 403),
],
)
def test_error_replies_close_the_connection(self, connection, path, data, status):
resp = self.post_on(connection, path, data)
assert (resp.status, resp.will_close) == (status, True)
@pytest.mark.parametrize("content_length", [None, "-1", "notanumber", "999999"])
def test_bad_content_length(self, connection, content_length):
# the fixture timeout turns a server that waits for the body into a failure
connection.putrequest("POST", "/create", skip_accept_encoding=True)
if content_length is not None:
connection.putheader("Content-Length", content_length)
connection.endheaders()
resp = connection.getresponse()
assert resp.status == 400
assert resp.will_close
+4 -85
View File
@@ -1,30 +1,21 @@
import itertools
import os import os
import random import random
import shutil from datetime import datetime
import time
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.expire import ( from chatmaild.expire import (
Expiry,
FileEntry, FileEntry,
MailboxStat, MailboxStat,
expire_to_target,
get_file_entry, get_file_entry,
iter_mailboxes, iter_mailboxes,
os_listdir_if_exists, os_listdir_if_exists,
parse_dovecot_filename,
quota_expire_main,
scan_mailbox_messages,
) )
from chatmaild.expire import daily_expire_main as expiry_main from chatmaild.expire import main as expiry_main
from chatmaild.fsreport import main as report_main from chatmaild.fsreport import main as report_main
MB = 1024 * 1024
def fill_mbox(folderdir): def fill_mbox(folderdir):
password = folderdir.joinpath("password") password = folderdir.joinpath("password")
@@ -40,7 +31,7 @@ def fill_mbox(folderdir):
def create_new_messages(basedir, relpaths, size=1000, days=0): def create_new_messages(basedir, relpaths, size=1000, days=0):
now = time.time() now = datetime.utcnow().timestamp()
for relpath in relpaths: for relpath in relpaths:
msg_path = Path(basedir).joinpath(relpath) msg_path = Path(basedir).joinpath(relpath)
@@ -105,30 +96,6 @@ def test_stats_mailbox(mbox1):
assert mbox3.last_login is None assert mbox3.last_login is None
def test_mbox_without_password(mbox1, example_config, capsys):
password = Path(mbox1.basedir).joinpath("password")
os.remove(password)
mbox_rescan = MailboxStat(mbox1.basedir)
assert mbox_rescan.last_login is None
exp = Expiry(example_config, dry=False, now=time.time(), verbose=False)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" in err
assert os.path.isdir(mbox_rescan.basedir)
for entry in os.scandir(mbox_rescan.basedir):
if os.path.isdir(entry):
shutil.rmtree(entry)
else:
os.remove(entry)
exp.process_mailbox_stat(mbox_rescan)
out, err = capsys.readouterr()
assert "doesn't have last_login but isn't empty" not in err
assert not os.path.isdir(mbox_rescan.basedir)
def test_report_no_mailboxes(example_config): def test_report_no_mailboxes(example_config):
args = (str(example_config._inipath),) args = (str(example_config._inipath),)
report_main(args) report_main(args)
@@ -149,7 +116,7 @@ def test_report_mdir_filters_by_path(mbox1, example_config):
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory.""" """Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
from chatmaild.fsreport import Report from chatmaild.fsreport import Report
now = time.time() now = datetime.utcnow().timestamp()
# Set password mtime to old enough so min_login_age check passes # Set password mtime to old enough so min_login_age check passes
password = Path(mbox1.basedir).joinpath("password") password = Path(mbox1.basedir).joinpath("password")
@@ -229,51 +196,3 @@ def test_os_listdir_if_exists(tmp_path):
tmp_path.joinpath("x").write_text("hello") tmp_path.joinpath("x").write_text("hello")
assert len(os_listdir_if_exists(str(tmp_path))) == 1 assert len(os_listdir_if_exists(str(tmp_path))) == 1
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0 assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0
# --- quota expire tests ---
_msg_counter = itertools.count(1)
def _create_message(basedir, sub, size, days_old=0, disk_size=None):
seq = next(_msg_counter)
mtime = int(time.time() - days_old * 86400)
name = f"{mtime}.M1P1Q{seq}.hostname,S={size},W={size}:2,S"
path = basedir / sub / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * (disk_size if disk_size is not None else size))
os.utime(path, (mtime, mtime))
return path
def test_parse_dovecot_filename():
e = parse_dovecot_filename("cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S")
assert e.path == "cur/1775324677.M448978P3029757.exam,S=3235,W=3305:2,S"
assert e.mtime == 1775324677
assert e.quota_size == 3235
assert parse_dovecot_filename("cur/msg_without_structure") is None
def test_expire_to_target(tmp_path):
_create_message(tmp_path, "cur", MB, days_old=10, disk_size=100)
_create_message(tmp_path, "new", MB, days_old=5)
_create_message(tmp_path, "cur", MB, days_old=0) # undeletable (<1 hour)
assert len(scan_mailbox_messages(tmp_path)) == 3
# removes oldest first, uses S= size not disk size
removed = expire_to_target(tmp_path, MB)
assert removed == 2
msgs = scan_mailbox_messages(tmp_path)
assert len(msgs) == 1
# the surviving message is the fresh undeletable one
assert msgs[0].mtime > time.time() - 3600
def test_quota_expire_main(tmp_path, capsys):
mbox = tmp_path / "user@example.org"
_create_message(mbox, "cur", 2 * MB, days_old=5)
(mbox / "maildirsize").write_text("x")
quota_expire_main([str(1), str(mbox)])
_, err = capsys.readouterr()
assert "quota-expire: removed 1 message(s) from user@example.org" in err
assert not (mbox / "maildirsize").exists()
@@ -1,6 +1,6 @@
import time import time
from chatmaild.doveauth import DoveAuth from chatmaild.doveauth import AuthDictProxy
from chatmaild.lastlogin import ( from chatmaild.lastlogin import (
LastLoginDictProxy, LastLoginDictProxy,
) )
@@ -9,8 +9,8 @@ from chatmaild.lastlogin import (
def test_handle_dovecot_request_last_login(testaddr, example_config): def test_handle_dovecot_request_last_login(testaddr, example_config):
dictproxy = LastLoginDictProxy(config=example_config) dictproxy = LastLoginDictProxy(config=example_config)
doveauth = DoveAuth(example_config) authproxy = AuthDictProxy(config=example_config)
doveauth.create_user(testaddr, "1l2k3j1l2k3jl123") authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {} dictproxy_transactions = {}
+11 -65
View File
@@ -1,5 +1,4 @@
import io import io
import json
import time import time
import pytest import pytest
@@ -8,7 +7,6 @@ import requests
from chatmaild.metadata import ( from chatmaild.metadata import (
Metadata, Metadata,
MetadataDictProxy, MetadataDictProxy,
read_appversions,
) )
from chatmaild.notifier import ( from chatmaild.notifier import (
Notifier, Notifier,
@@ -326,7 +324,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
def mock_turn_credentials(turn_socket_path): def mock_turn_credentials():
raise ConnectionRefusedError("socket not available") raise ConnectionRefusedError("socket not available")
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials) monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
@@ -350,9 +348,7 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
turn_hostname="turn.example.org", turn_hostname="turn.example.org",
) )
monkeypatch.setattr( monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
chatmaild.metadata, "turn_credentials", lambda path: "user:pass"
)
transactions = {} transactions = {}
res = dictproxy.handle_dovecot_request( res = dictproxy.handle_dovecot_request(
@@ -364,65 +360,15 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
def test_iroh_relay(dictproxy): def test_iroh_relay(dictproxy):
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org" rfile = io.BytesIO(
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO() b"\n".join(
[
b"H",
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
]
)
)
wfile = io.BytesIO()
dictproxy.iroh_relay = "https://example.org/" dictproxy.iroh_relay = "https://example.org/"
dictproxy.loop_forever(rfile, wfile) dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == b"Ohttps://example.org/\n" assert wfile.getvalue() == b"Ohttps://example.org/\n"
def test_read_appversions(tmp_path):
path = tmp_path.joinpath("appversions.json")
assert read_appversions(path) is None
path.write_text('{\n "clients": []\n}')
assert read_appversions(path) == '{"clients":[]}'
# the value travels as a single dict protocol line
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
path.write_text("bad json")
assert read_appversions(path) is None
def test_appversions_lookup(dictproxy):
# the version information shipped with chatmaild is served as a single line
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
key += b"\tuser@example.org"
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
value = wfile.getvalue()
assert value.startswith(b"O") and value.endswith(b"\n")
assert json.loads(value[1:])["clients"]
def test_legacy_token_migration(metadata, testaddr):
with metadata.get_metadata_dict(testaddr).modify() as data:
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
mdict = metadata.get_metadata_dict(testaddr).read()
tokens = mdict[metadata.DEVICETOKEN_KEY]
assert isinstance(tokens, dict)
assert "oldtoken1" in tokens and "oldtoken2" in tokens
@pytest.mark.parametrize(
"suffix, expected",
[
(b"vendor/deltachat/maxsmtprecipients", b"O1000\n"),
(b"wrong/prefix/key", b"N\n"),
(b"vendor/deltachat/unknown", b"N\n"),
],
ids=["maxsmtprecipients", "prefix_mismatch", "unknown_name"],
)
def test_shared_lookup(dictproxy, suffix, expected):
key = (
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/"
+ suffix
+ b"\tuser@example.org"
)
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
dictproxy.loop_forever(rfile, wfile)
assert wfile.getvalue() == expected
@@ -48,8 +48,6 @@ def test_migration(tmp_path, example_config, caplog):
assert passdb_path.stat().st_size > 10000 assert passdb_path.stat().st_size > 10000
example_config.passdb_path = passdb_path example_config.passdb_path = passdb_path
# ensure logging.info records are captured regardless of global configuration
caplog.set_level("INFO")
assert not caplog.records assert not caplog.records
@@ -63,7 +61,7 @@ def test_migration(tmp_path, example_config, caplog):
user = example_config.get_user(path.name) user = example_config.get_user(path.name)
if last_login: if last_login:
assert user.get_last_login_timestamp() == last_login assert user.get_last_login_timestamp() == last_login
assert password == user.get_password_hash() assert password == user.get_userdb_dict()["password"]
assert not all assert not all
assert not example_config.passdb_path.exists() assert not example_config.passdb_path.exists()
+4 -21
View File
@@ -19,36 +19,19 @@ def test_create_newemail_dict(example_config):
assert ac1["password"] != ac2["password"] assert ac1["password"] != ac2["password"]
def test_create_newemail_dict_ip(ipv4_config): def test_create_dclogin_url():
ac = create_newemail_dict(ipv4_config) url = create_dclogin_url("user@example.org", "p@ss w+rd")
assert ac["email"].endswith("@[1.3.3.7]")
def test_create_dclogin_url(example_config):
addr = "user@example.org"
password = "p@ss w+rd"
url = create_dclogin_url(example_config, addr, password)
assert url.startswith("dclogin:") assert url.startswith("dclogin:")
assert "v=1" in url assert "v=1" in url
assert "ic=3" in url assert "ic=3" in url
assert addr in url assert "user@example.org" in url
# password special chars must be encoded # password special chars must be encoded
assert "p%40ss" in url assert "p%40ss" in url
assert "w%2Brd" in url assert "w%2Brd" in url
def test_create_dclogin_url_ipv4(ipv4_config): def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
addr = "user@[1.3.3.7]"
password = "p@ss w+rd"
url = create_dclogin_url(ipv4_config, addr, password)
assert url.startswith("dclogin:")
assert "v=1" in url
assert "ic=3" in url
assert addr in url
def test_print_new_account(capsys, monkeypatch, 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()
out, err = capsys.readouterr() out, err = capsys.readouterr()
@@ -1,45 +0,0 @@
import shutil
import psutil
from chatmaild.syslimits import has_sufficient_resources
PERMISSIVE = {
"max_load_1m": "99999",
"min_available_memory": "0",
"min_free_disk_space": "0",
}
def test_rejects_constrained_system(make_config, caplog):
assert has_sufficient_resources(make_config("chat.example.org", PERMISSIVE))
for settings in (
{"max_load_1m": "-1.0"},
{"min_available_memory": "99999999G"},
{"min_free_disk_space": "99999999G"},
):
config = make_config("chat.example.org", PERMISSIVE | settings)
caplog.clear()
assert not has_sufficient_resources(config), settings
assert "registration rejected" in caplog.text
def test_unreadable_disk_does_not_reject(make_config, caplog):
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
shutil.rmtree(config.mailboxes_dir)
assert has_sufficient_resources(config)
assert "ignoring" in caplog.text
def test_one_unreadable_value_keeps_other_checks(make_config, monkeypatch, caplog):
def raise_error(*args):
raise psutil.Error("dud")
monkeypatch.setattr(psutil, "getloadavg", raise_error)
config = make_config(
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
)
assert not has_sufficient_resources(config)
assert "ignoring" in caplog.text
@@ -1,46 +0,0 @@
import socket
import threading
import pytest
from chatmaild.metadata import turn_credentials
@pytest.fixture
def turn_socket(tmp_path):
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def test_turn_credentials_timeout(turn_socket):
sock_path, server = turn_socket
with pytest.raises(socket.timeout):
# Inside turn_credentials the kernel listen backlog (1)
# completes connect() without accept()
# so the client blocks on readline() until the 5s timeout fires.
turn_credentials(sock_path)
def test_turn_credentials_connection_refused_on_not_existing_socket(tmp_path):
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
turn_credentials(missing)
def test_turn_credentials_socket_success(turn_socket):
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = turn_credentials(sock_path)
assert result == "testuser:testpass"
@@ -0,0 +1,73 @@
import socket
import threading
import time
from unittest.mock import patch
import pytest
from chatmaild.turnserver import turn_credentials
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
@pytest.fixture
def turn_socket(tmp_path):
"""Create a real Unix socket server at a temp path."""
sock_path = str(tmp_path / "turn.socket")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen(1)
yield sock_path, server
server.close()
def _call_turn_credentials(sock_path):
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
original_connect = socket.socket.connect
def patched_connect(self, address):
if address == SOCKET_PATH:
address = sock_path
return original_connect(self, address)
with patch.object(socket.socket, "connect", patched_connect):
return turn_credentials()
def test_turn_credentials_timeout(turn_socket):
"""Server accepts but never responds — must raise socket.timeout."""
sock_path, server = turn_socket
def accept_and_hang():
conn, _ = server.accept()
time.sleep(30)
conn.close()
t = threading.Thread(target=accept_and_hang, daemon=True)
t.start()
with pytest.raises(socket.timeout):
_call_turn_credentials(sock_path)
def test_turn_credentials_connection_refused(tmp_path):
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
missing = str(tmp_path / "nonexistent.socket")
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
_call_turn_credentials(missing)
def test_turn_credentials_success(turn_socket):
"""Server responds with credentials — must return stripped string."""
sock_path, server = turn_socket
def respond():
conn, _ = server.accept()
conn.sendall(b"testuser:testpass\n")
conn.close()
t = threading.Thread(target=respond, daemon=True)
t.start()
result = _call_turn_credentials(sock_path)
assert result == "testuser:testpass"
+11 -6
View File
@@ -8,23 +8,28 @@ def test_login_timestamp(testaddr, example_config):
assert user.get_last_login_timestamp() == 86400 * 2 assert user.get_last_login_timestamp() == 86400 * 2
def test_get_password_hash_not_set(testaddr, example_config, caplog): def test_get_user_dict_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr) user = example_config.get_user(testaddr)
assert not caplog.records assert not caplog.records
assert user.get_password_hash() is None assert user.get_userdb_dict() == {}
assert len(caplog.records) == 0 assert len(caplog.records) == 0
user.set_password("") user.set_password("")
assert user.get_password_hash() is None assert user.get_userdb_dict() == {}
assert len(caplog.records) == 1 assert len(caplog.records) == 1
def test_get_password_hash(make_config, tmp_path): def test_get_user_dict(make_config, tmp_path):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
user = config.get_user("user1@something.org") addr = "user1@something.org"
user = config.get_user(addr)
enc_password = "l1k2j31lk2j3l1k23j123" enc_password = "l1k2j31lk2j3l1k23j123"
user.set_password(enc_password) user.set_password(enc_password)
assert user.get_password_hash() == enc_password data = user.get_userdb_dict()
assert addr in str(data["home"])
assert data["uid"] == "vmail"
assert data["gid"] == "vmail"
assert data["password"] == enc_password
def test_no_mailboxes_dir(testaddr, example_config, tmp_path): def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env python3
import socket
def turn_credentials() -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.settimeout(5)
client_socket.connect("/run/chatmail-turn/turn.socket")
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8").strip()
+9 -6
View File
@@ -21,17 +21,20 @@ class User:
def can_track(self): def can_track(self):
return "@" in self.addr return "@" in self.addr
def get_password_hash(self): def get_userdb_dict(self):
"""Return a non-empty dovecot 'userdb' style dict
if the user has an existing non-empty password"""
try: try:
passhash = self.password_path.read_text() pw = self.password_path.read_text()
except FileNotFoundError: except FileNotFoundError:
return None return {}
if not passhash: if not pw:
logging.error(f"password is empty for: {self.addr}") logging.error(f"password is empty for: {self.addr}")
return None return {}
return passhash home = str(self.maildir)
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self): def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists() return not self.enforce_E2EE_path.exists()
+5 -3
View File
@@ -10,6 +10,7 @@ dependencies = [
"pillow", "pillow",
"qrcode", "qrcode",
"markdown", "markdown",
"pytest",
"setuptools>=68", "setuptools>=68",
"termcolor", "termcolor",
"build", "build",
@@ -19,15 +20,16 @@ dependencies = [
"pytest-xdist", "pytest-xdist",
"execnet", "execnet",
"imap_tools", "imap_tools",
"jinja2",
"lupa",
"deltachat-rpc-client", "deltachat-rpc-client",
"deltachat-rpc-server",
] ]
[project.scripts] [project.scripts]
cmdeploy = "cmdeploy.cmdeploy:main" cmdeploy = "cmdeploy.cmdeploy:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"
"cmdeploy.testplugin" = "cmdeploy.tests.plugin"
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = "-v -ra --strict-markers" addopts = "-v -ra --strict-markers"
-1
View File
@@ -1 +0,0 @@
+103 -24
View File
@@ -1,14 +1,17 @@
from pyinfra.operations import apt, server import importlib.resources
from pyinfra.operations import apt, files, server, systemd
from ..basedeploy import Deployer from ..basedeploy import Deployer
class AcmetoolDeployer(Deployer): class AcmetoolDeployer(Deployer):
bin_path = "/usr/bin/acmetool"
def __init__(self, email, domains): def __init__(self, email, domains):
self.domains = domains self.domains = domains
self.email = email self.email = email
self.need_restart_redirector = False
self.need_restart_reconcile_service = False
self.need_restart_reconcile_timer = False
def install(self): def install(self):
apt.packages( apt.packages(
@@ -16,47 +19,123 @@ class AcmetoolDeployer(Deployer):
packages=["acmetool"], packages=["acmetool"],
) )
self.remove_file("/etc/cron.d/acmetool") files.file(
name="Remove old acmetool cronjob, it is replaced with systemd timer.",
path="/etc/cron.d/acmetool",
present=False,
)
self.put_executable("acmetool/acmetool.hook", "/etc/acme/hooks/nginx") files.put(
self.remove_file("/usr/lib/acme/hooks/nginx") name="Install acmetool hook.",
src=importlib.resources.files(__package__)
.joinpath("acmetool.hook")
.open("rb"),
dest="/etc/acme/hooks/nginx",
user="root",
group="root",
mode="755",
)
files.file(
name="Remove acmetool hook from the wrong location where it was previously installed.",
path="/usr/lib/acme/hooks/nginx",
present=False,
)
def configure(self): def configure(self):
self.put_template( files.template(
"acmetool/response-file.yaml.j2", src=importlib.resources.files(__package__).joinpath(
"/var/lib/acme/conf/responses", "response-file.yaml.j2"
),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=self.email, email=self.email,
) )
self.put_template( files.template(
"acmetool/target.yaml.j2", src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
"/var/lib/acme/conf/target", dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
) )
server.shell( server.shell(
name=f"Remove old acmetool desired files for {self.domains[0]}", name=f"Remove old acmetool desired files for {self.domains[0]}",
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"], commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
) )
self.put_template( files.template(
"acmetool/desired.yaml.j2", src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
f"/var/lib/acme/desired/{self.domains[0]}", dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
user="root",
group="root",
mode="644",
domains=self.domains, domains=self.domains,
) )
self.ensure_systemd_unit( service_file = files.put(
"acmetool/acmetool-redirector.service.j2", bin_path=self.bin_path src=importlib.resources.files(__package__).joinpath(
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
) )
self.ensure_systemd_unit( self.need_restart_redirector = service_file.changed
"acmetool/acmetool-reconcile.service.j2", bin_path=self.bin_path
reconcile_service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.service"
),
dest="/etc/systemd/system/acmetool-reconcile.service",
user="root",
group="root",
mode="644",
) )
self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer") self.need_restart_reconcile_service = reconcile_service_file.changed
reconcile_timer_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-reconcile.timer"
),
dest="/etc/systemd/system/acmetool-reconcile.timer",
user="root",
group="root",
mode="644",
)
self.need_restart_reconcile_timer = reconcile_timer_file.changed
def activate(self): def activate(self):
self.ensure_service("acmetool-redirector.service") systemd.service(
self.ensure_service("acmetool-reconcile.service", running=False, enabled=False) name="Setup acmetool-redirector service",
self.ensure_service("acmetool-reconcile.timer") service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=self.need_restart_redirector,
)
self.need_restart_redirector = False
systemd.service(
name="Setup acmetool-reconcile service",
service="acmetool-reconcile.service",
running=False,
enabled=False,
daemon_reload=self.need_restart_reconcile_service,
)
self.need_restart_reconcile_service = False
systemd.service(
name="Setup acmetool-reconcile timer",
service="acmetool-reconcile.timer",
running=True,
enabled=True,
daemon_reload=self.need_restart_reconcile_timer,
)
self.need_restart_reconcile_timer = False
server.shell( server.shell(
name=f"Reconcile certificates for: {', '.join(self.domains)}", name=f"Reconcile certificates for: {', '.join(self.domains)}",
commands=[f"{self.bin_path} --batch --xlog.severity=debug reconcile"], commands=["acmetool --batch --xlog.severity=debug reconcile"],
) )
@@ -4,5 +4,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart={{ bin_path }} --batch reconcile ExecStart=/usr/bin/acmetool --batch reconcile
@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
[Service] [Service]
Type=notify Type=notify
ExecStart={{ bin_path }} redirector --service.uid=daemon --bind=127.0.0.1:402 ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
Restart=always Restart=always
RestartSec=30 RestartSec=30
@@ -1,2 +1,2 @@
"acme-enter-email": "{{ email }}" "acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true "acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
+14 -132
View File
@@ -3,9 +3,6 @@ import io
import os import os
from contextlib import contextmanager from contextlib import contextmanager
from pyinfra import host
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command
from pyinfra.operations import files, server, systemd from pyinfra.operations import files, server, systemd
@@ -14,17 +11,6 @@ def has_systemd():
return os.path.isdir("/run/systemd/system") return os.path.isdir("/run/systemd/system")
def is_in_container() -> bool:
"""Return True if running inside a container (Docker, LXC, etc.)."""
return (
host.get_fact(
Command,
"systemd-detect-virt --container --quiet 2>/dev/null && echo yes || true",
)
== "yes"
)
@contextmanager @contextmanager
def blocked_service_startup(): def blocked_service_startup():
"""Prevent services from auto-starting during package installation. """Prevent services from auto-starting during package installation.
@@ -51,10 +37,11 @@ def get_resource(arg, pkg=__package__):
return importlib.resources.files(pkg).joinpath(arg) return importlib.resources.files(pkg).joinpath(arg)
def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None: def configure_remote_units(mail_domain, units) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
# install systemd units # install systemd units
for fn in units: for fn in units:
@@ -63,7 +50,6 @@ def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None:
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,
**kwargs,
) )
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -71,13 +57,15 @@ def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None:
source_path = get_resource(f"service/{basename}.f") source_path = get_resource(f"service/{basename}.f")
content = source_path.read_text().format(**params).encode() content = source_path.read_text().format(**params).encode()
deployer.put_file( files.put(
name=f"Upload {basename}",
src=io.BytesIO(content), src=io.BytesIO(content),
dest=f"/etc/systemd/system/{basename}", dest=f"/etc/systemd/system/{basename}",
**root_owned,
) )
def activate_remote_units(deployer, units) -> None: def activate_remote_units(units) -> None:
# activate systemd units # activate systemd units
for fn in units: for fn in units:
basename = fn if "." in fn else f"{fn}.service" basename = fn if "." in fn else f"{fn}.service"
@@ -87,8 +75,14 @@ def activate_remote_units(deployer, units) -> None:
enabled = False enabled = False
else: else:
enabled = True enabled = True
systemd.service(
deployer.ensure_service(basename, running=enabled, enabled=enabled) name=f"Setup {basename}",
service=basename,
running=enabled,
enabled=enabled,
restarted=enabled,
daemon_reload=True,
)
class Deployment: class Deployment:
@@ -134,7 +128,6 @@ class Deployment:
class Deployer: class Deployer:
need_restart = False need_restart = False
daemon_reload = False
def install(self): def install(self):
pass pass
@@ -144,114 +137,3 @@ class Deployer:
def activate(self): def activate(self):
pass pass
def ensure_service(self, service, running=True, enabled=True):
if running:
verb = "Start and enable"
else:
verb = "Stop"
systemd.service(
name=f"{verb} {service}",
service=service,
running=running,
enabled=enabled,
restarted=self.need_restart if running else False,
daemon_reload=self.daemon_reload,
)
self.daemon_reload = False
def ensure_systemd_unit(self, src, **kwargs):
dest_name = src.split("/")[-1].replace(".j2", "")
dest = f"/etc/systemd/system/{dest_name}"
if src.endswith(".j2"):
return self.put_template(src, dest, **kwargs)
return self.put_file(src, dest)
def put_file(self, src, dest, mode="644", **kwargs):
if isinstance(src, str):
src = get_resource(src)
res = files.put(
name=f"Upload {dest}",
src=src,
dest=dest,
user="root",
group="root",
mode=mode,
**kwargs,
)
return self._update_restart_signals(dest, res)
def put_executable(self, src, dest):
return self.put_file(src, dest, mode="755")
def put_template(self, src, dest, owner="root", **kwargs):
if isinstance(src, str):
src = get_resource(src)
res = files.template(
name=f"Upload {dest}",
src=src,
dest=dest,
user=owner,
group=owner,
mode="644",
**kwargs,
)
return self._update_restart_signals(dest, res)
def remove_file(self, dest):
res = files.file(name=f"Remove {dest}", path=dest, present=False)
return self._update_restart_signals(dest, res)
def ensure_line(self, path, line, **kwargs):
name = kwargs.pop("name", f"Ensure line in {path}")
res = files.line(name=name, path=path, line=line, **kwargs)
return self._update_restart_signals(path, res)
def ensure_directory(self, path, owner="root", mode="755", **kwargs):
name = kwargs.pop("name", f"Ensure directory {path}")
res = files.directory(
name=name,
path=path,
user=owner,
group=owner,
mode=mode,
present=True,
**kwargs,
)
return self._update_restart_signals(path, res)
def remove_directory(self, path, **kwargs):
name = kwargs.pop("name", f"Remove directory {path}")
res = files.directory(name=name, path=path, present=False, **kwargs)
return self._update_restart_signals(path, res)
def download_executable(self, url, dest, sha256sum, extract=None, mode="755"):
existing = host.get_fact(Sha256File, dest)
if existing == sha256sum:
return
tmp = f"{dest}.new"
if extract:
dl_cmd = f"curl -fSL {url} | {extract} >{tmp}"
else:
dl_cmd = f"curl -fSL {url} -o {tmp}"
server.shell(
name=f"Download {dest}",
commands=[
f"({dl_cmd}"
f" && echo '{sha256sum} {tmp}' | sha256sum -c"
f" && mv {tmp} {dest})",
f"chmod {mode} {dest}",
],
)
self.need_restart = True
def _update_restart_signals(self, path, res):
if res.changed:
self.need_restart = True
if str(path).startswith("/etc/systemd/system/"):
self.daemon_reload = True
return res
+32
View File
@@ -0,0 +1,32 @@
;
; Required DNS entries for chatmail servers
;
{% if A %}
{{ mail_domain }}. A {{ A }}
{% endif %}
{% if AAAA %}
{{ mail_domain }}. AAAA {{ AAAA }}
{% endif %}
{{ mail_domain }}. MX 10 {{ mail_domain }}.
{% if strict_tls %}
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
{% endif %}
www.{{ mail_domain }}. CNAME {{ mail_domain }}.
{{ dkim_entry }}
;
; Recommended DNS entries for interoperability and security-hardening
;
{{ mail_domain }}. TXT "v=spf1 a ~all"
_dmarc.{{ mail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
{% if acme_account_url %}
{{ mail_domain }}. CAA 0 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
{% endif %}
_adsp._domainkey.{{ mail_domain }}. TXT "dkim=discardable"
_submission._tcp.{{ mail_domain }}. SRV 0 1 587 {{ mail_domain }}.
_submissions._tcp.{{ mail_domain }}. SRV 0 1 465 {{ mail_domain }}.
_imap._tcp.{{ mail_domain }}. SRV 0 1 143 {{ mail_domain }}.
_imaps._tcp.{{ mail_domain }}. SRV 0 1 993 {{ mail_domain }}.
+13 -21
View File
@@ -84,24 +84,13 @@ def run_cmd_options(parser):
add_ssh_host_option(parser) add_ssh_host_option(parser)
def _warn_unused_settings(unused_keys, out):
if unused_keys:
names = ", ".join(unused_keys)
out.red(
f"WARNING: chatmail.ini contains settings that have no effect: {names}\n"
"Please remove them from chatmail.ini."
)
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare 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" strict_tls = args.config.tls_cert_mode == "acme"
if args.config.ipv4_relay:
args.dns_check_disabled = True
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, strict_tls=strict_tls, print=out.red):
@@ -112,6 +101,9 @@ def run_cmd(args, out):
env["CHATMAIL_WEBSITE_ONLY"] = "True" if args.website_only else "" 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"
@@ -130,11 +122,8 @@ def run_cmd(args, out):
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]: elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured") out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again") out.red("Run 'cmdeploy run' again")
elif args.config.ipv4_relay:
out.green("Deploy completed.")
else: else:
out.green("Deploy completed, call `cmdeploy dns` next.") out.green("Deploy completed, call `cmdeploy dns` next.")
_warn_unused_settings(args.config._unused_keys, out)
return 0 return 0
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
@@ -154,10 +143,6 @@ def dns_cmd_options(parser):
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
if args.config.ipv4_relay:
ipv4 = args.config.ipv4_relay
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
return 0
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 tls_cert_mode = args.config.tls_cert_mode
@@ -195,7 +180,7 @@ def status_cmd_options(parser):
def status_cmd(args, out): def status_cmd(args, out):
"""Display status for online chatmail instance.""" """Display status for online chatmail instance."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare 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)
out.green(f"chatmail domain: {args.config.mail_domain}") out.green(f"chatmail domain: {args.config.mail_domain}")
@@ -209,6 +194,12 @@ def status_cmd(args, out):
def test_cmd_options(parser): def test_cmd_options(parser):
parser.add_argument(
"--slow",
dest="slow",
action="store_true",
help="also run slow tests",
)
add_ssh_host_option(parser) add_ssh_host_option(parser)
@@ -216,7 +207,6 @@ 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() env = os.environ.copy()
env["CHATMAIL_INI"] = str(args.inipath.absolute())
if args.ssh_host: if args.ssh_host:
env["CHATMAIL_SSH"] = args.ssh_host env["CHATMAIL_SSH"] = args.ssh_host
@@ -230,6 +220,8 @@ def test_cmd(args, out):
"-v", "-v",
"--durations=5", "--durations=5",
] ]
if args.slow:
pytest_args.append("--slow")
ret = out.run_ret(pytest_args, env=env) ret = out.run_ret(pytest_args, env=env)
return ret return ret
+236 -166
View File
@@ -12,6 +12,8 @@ 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 import hardware
from pyinfra.facts.files import Sha256File
from pyinfra.facts.server import Command
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
@@ -22,10 +24,9 @@ from .basedeploy import (
Deployer, Deployer,
Deployment, Deployment,
activate_remote_units, activate_remote_units,
blocked_service_startup,
configure_remote_units, configure_remote_units,
get_resource,
has_systemd, has_systemd,
is_in_container,
) )
from .dovecot.deployer import DovecotDeployer from .dovecot.deployer import DovecotDeployer
from .external.deployer import ExternalTlsDeployer from .external.deployer import ExternalTlsDeployer
@@ -33,7 +34,6 @@ 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 .pins import IROH_ARTIFACTS, TURN_ARTIFACTS
from .postfix.deployer import PostfixDeployer from .postfix.deployer import PostfixDeployer
from .selfsigned.deployer import SelfSignedTlsDeployer 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
@@ -81,39 +81,25 @@ def remove_legacy_artifacts():
) )
def _install_remote_venv_with_chatmaild(deployer) -> None: def _install_remote_venv_with_chatmaild() -> None:
remove_legacy_artifacts() remove_legacy_artifacts()
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist")) dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}" remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
remote_venv_dir = f"{remote_base_dir}/venv" remote_venv_dir = f"{remote_base_dir}/venv"
root_owned = dict(user="root", group="root", mode="644")
apt.packages( apt.packages(
name="apt install python3-virtualenv", name="apt install python3-virtualenv",
packages=["python3-virtualenv"], packages=["python3-virtualenv"],
) )
deployer.ensure_directory(f"{remote_base_dir}/dist") files.put(
deployer.put_file( name="Upload chatmaild source package",
src=dist_file.open("rb"), src=dist_file.open("rb"),
dest=remote_dist_file, dest=remote_dist_file,
) create_remote_dir=True,
**root_owned,
# Remove venv if its Python major.minor doesn't match the system Python
server.shell(
name="remove stale chatmaild venv if python version changed",
commands=[
"\n".join(
[
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
# an empty sys_version means we could not tell: keep the venv
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
f"|| rm -rf {remote_venv_dir}",
]
)
],
) )
pip.virtualenv( pip.virtualenv(
@@ -135,76 +121,90 @@ def _install_remote_venv_with_chatmaild(deployer) -> None:
) )
def _configure_remote_venv_with_chatmaild(deployer, config) -> None: def _configure_remote_venv_with_chatmaild(config) -> None:
remote_base_dir = "/usr/local/lib/chatmaild" remote_base_dir = "/usr/local/lib/chatmaild"
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini" remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
root_owned = dict(user="root", group="root", mode="644")
deployer.put_file( files.put(
name=f"Upload {remote_chatmail_inipath}",
src=config._getbytefile(), src=config._getbytefile(),
dest=remote_chatmail_inipath, dest=remote_chatmail_inipath,
**root_owned,
) )
deployer.remove_file("/etc/cron.d/chatmail-metrics") files.file(
deployer.remove_file("/var/www/html/metrics") path="/etc/cron.d/chatmail-metrics",
present=False,
)
files.file(
path="/var/www/html/metrics",
present=False,
)
class UnboundDeployer(Deployer): class UnboundDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.need_restart = False
def install(self): def install(self):
# On an IPv4-only system, if unbound is started but not configured, # Run local DNS resolver `unbound`.
# it causes subsequent steps to fail to resolve hosts. # `resolvconf` takes care of setting up /etc/resolv.conf
with blocked_service_startup(): # to use 127.0.0.1 as the resolver.
# dns-root-data is an optional package
# that contains /usr/share/dns/root.key #
# # On an IPv4-only system, if unbound is started but not
# This file is copied into /var/lib/unbound/root.key # configured, it causes subsequent steps to fail to resolve hosts.
# at the start of "unbound" systemd unit # Here, we use policy-rc.d to prevent unbound from starting up
# by /usr/libexec/unbound-helper shell script # on initial install. Later, we will configure it and start it.
# from the "unbound" package as of version 1.17.1-2+deb12u4 #
# # For documentation about policy-rc.d, see:
# The same /var/lib/unbound/root.key can be retrieved directly # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
# following the procedure from #
# <https://www.rfc-editor.org/info/rfc7958/#section-3.1> files.put(
# with "unbound-anchor -a /var/lib/unbound/root.key" src=get_resource("policy-rc.d"),
# We don't install and use "unbound-anchor". dest="/usr/sbin/policy-rc.d",
apt.packages( user="root",
name="Install unbound", group="root",
packages=["unbound", "dns-root-data", "dnsutils"], mode="755",
) )
apt.packages(
name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"],
)
files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self): def configure(self):
# Remove dynamic resolver managers that compete for /etc/resolv.conf.
apt.packages(
name="Purge resolvconf",
packages=["resolvconf"],
present=False,
extra_uninstall_args="--purge",
)
# systemd-resolved can't be purged due to dependencies; stop and mask.
server.shell( server.shell(
name="Stop and mask systemd-resolved", name="Generate root keys for validating DNSSEC",
commands=[ commands=[
"systemctl stop systemd-resolved.service || true", "unbound-anchor -a /var/lib/unbound/root.key || true",
"systemctl mask systemd-resolved.service",
], ],
) )
# Configure unbound resolver with Quad9 fallback and a trailing newline if self.config.disable_ipv6:
# (SolusVM bug). files.directory(
self.put_file( path="/etc/unbound/unbound.conf.d",
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"), present=True,
dest="/etc/resolv.conf", user="root",
force=True, group="root",
) mode="755",
self.ensure_directory( )
path="/etc/unbound/unbound.conf.d", conf = files.put(
) src=get_resource("unbound/unbound.conf.j2"),
self.put_template( dest="/etc/unbound/unbound.conf.d/chatmail.conf",
"unbound/unbound.conf.j2", user="root",
"/etc/unbound/unbound.conf.d/chatmail.conf", group="root",
disable_ipv6=self.config.disable_ipv6, 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(
@@ -214,25 +214,27 @@ class UnboundDeployer(Deployer):
], ],
) )
self.ensure_service("unbound.service") systemd.service(
name="Start and enable unbound",
self.ensure_service( service="unbound.service",
"unbound-resolvconf.service", running=True,
running=False, enabled=True,
enabled=False, restarted=self.need_restart,
) )
class MtastsDeployer(Deployer): class MtastsDeployer(Deployer):
def configure(self): def configure(self):
# Remove configuration. # Remove configuration.
self.remove_file("/etc/mta-sts-daemon.yml") files.file("/etc/mta-sts-daemon.yml", present=False)
self.remove_directory("/usr/local/lib/postfix-mta-sts-resolver") files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False)
self.remove_file("/etc/systemd/system/mta-sts-daemon.service") files.file("/etc/systemd/system/mta-sts-daemon.service", present=False)
def activate(self): def activate(self):
self.ensure_service( systemd.service(
"mta-sts-daemon.service", name="Stop MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=False, running=False,
enabled=False, enabled=False,
) )
@@ -243,7 +245,14 @@ class WebsiteDeployer(Deployer):
self.config = config self.config = config
def install(self): def install(self):
self.ensure_directory("/var/www") files.directory(
name="Ensure /var/www exists",
path="/var/www",
user="root",
group="root",
mode="755",
present=True,
)
def configure(self): def configure(self):
www_path, src_dir, build_dir = get_paths(self.config) www_path, src_dir, build_dir = get_paths(self.config)
@@ -271,20 +280,17 @@ class LegacyRemoveDeployer(Deployer):
def install(self): def install(self):
apt.packages(name="Remove rspamd", packages="rspamd", present=False) apt.packages(name="Remove rspamd", packages="rspamd", present=False)
# unbound-anchor was used to download /var/lib/unbound/root.key
# It is replaced by dns-root-data which contains /usr/share/dns/root.key.
# unbound systemd unit copies /usr/share/dns/root.key
# into /var/lib/unbound/root.key automatically on start
# as long as /usr/share/dns/root.key is present.
apt.packages(name="Remove unbound-anchor", packages="unbound-anchor", present=False)
# remove historic expunge script # remove historic expunge script
# which is now implemented through a systemd timer (chatmail-expire) # which is now implemented through a systemd timer (chatmail-expire)
self.remove_file("/etc/cron.d/expunge") files.file(
path="/etc/cron.d/expunge",
present=False,
)
# Remove OBS repository key that is no longer used. # Remove OBS repository key that is no longer used.
self.remove_file("/etc/apt/keyrings/obs-home-deltachat.gpg") files.file("/etc/apt/keyrings/obs-home-deltachat.gpg", present=False)
self.ensure_line( files.line(
name="Remove DeltaChat OBS home repository from sources.list",
path="/etc/apt/sources.list", path="/etc/apt/sources.list",
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./", line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True, escape_regex_characters=True,
@@ -292,7 +298,11 @@ class LegacyRemoveDeployer(Deployer):
) )
# prior relay versions used filelogging # prior relay versions used filelogging
self.remove_directory("/var/log/journal/") files.directory(
name="Ensure old logs on disk are deleted",
path="/var/log/journal/",
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 has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
systemd.service( systemd.service(
@@ -319,70 +329,126 @@ def check_config(config):
class TurnDeployer(Deployer): class TurnDeployer(Deployer):
bin_path = "/usr/local/bin/chatmail-turn"
def __init__(self, mail_domain): def __init__(self, mail_domain):
self.mail_domain = mail_domain self.mail_domain = mail_domain
self.units = ["turnserver"] self.units = ["turnserver"]
def install(self): def install(self):
(url, sha256sum) = TURN_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
self.download_executable(url, self.bin_path, sha256sum) "x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
),
"aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
),
}[host.get_fact(facts.server.Arch)]
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum:
server.shell(
name="Download chatmail-turn",
commands=[
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
"chmod 755 /usr/local/bin/chatmail-turn",
],
)
def configure(self): def configure(self):
configure_remote_units( configure_remote_units(self.mail_domain, self.units)
self, self.mail_domain, self.units, bin_path=self.bin_path
)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
class IrohDeployer(Deployer): class IrohDeployer(Deployer):
bin_path = "/usr/local/bin/iroh-relay"
config_path = "/etc/iroh-relay.toml"
def __init__(self, enable_iroh_relay): def __init__(self, enable_iroh_relay):
self.enable_iroh_relay = enable_iroh_relay self.enable_iroh_relay = enable_iroh_relay
def install(self): def install(self):
(url, sha256sum) = IROH_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
self.download_executable( "x86_64": (
url, "https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz",
self.bin_path, "45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
sha256sum, ),
extract="gunzip | tar -xf - ./iroh-relay -O", "aarch64": (
) "https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}[host.get_fact(facts.server.Arch)]
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
if existing_sha256sum != sha256sum:
server.shell(
name="Download iroh-relay",
commands=[
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
self.need_restart = True
def configure(self): def configure(self):
self.ensure_systemd_unit( systemd_unit = files.put(
"iroh-relay.service.j2", name="Upload iroh-relay systemd unit",
bin_path=self.bin_path, src=get_resource("iroh-relay.service"),
config_path=self.config_path, dest="/etc/systemd/system/iroh-relay.service",
user="root",
group="root",
mode="644",
) )
self.put_file("iroh-relay.toml", self.config_path) self.need_restart |= systemd_unit.changed
iroh_config = files.put(
name="Upload iroh-relay config",
src=get_resource("iroh-relay.toml"),
dest="/etc/iroh-relay.toml",
user="root",
group="root",
mode="644",
)
self.need_restart |= iroh_config.changed
def activate(self): def activate(self):
self.ensure_service( systemd.service(
"iroh-relay.service", name="Start and enable iroh-relay",
service="iroh-relay.service",
running=True,
enabled=self.enable_iroh_relay, enabled=self.enable_iroh_relay,
restarted=self.need_restart,
) )
self.need_restart = False
class JournaldDeployer(Deployer): class JournaldDeployer(Deployer):
def configure(self): def configure(self):
self.put_file("journald.conf", "/etc/systemd/journald.conf") journald_conf = files.put(
name="Configure journald",
src=get_resource("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
self.need_restart = journald_conf.changed
def activate(self): def activate(self):
self.ensure_service("systemd-journald.service") systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
class ChatmailVenvDeployer(Deployer): class ChatmailVenvDeployer(Deployer):
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.units = ( self.units = (
# doveauth must restart when chatmaild/ini file changes
"doveauth",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"chatmail-expire", "chatmail-expire",
@@ -392,14 +458,14 @@ class ChatmailVenvDeployer(Deployer):
) )
def install(self): def install(self):
_install_remote_venv_with_chatmaild(self) _install_remote_venv_with_chatmaild()
def configure(self): def configure(self):
_configure_remote_venv_with_chatmaild(self, self.config) _configure_remote_venv_with_chatmaild(self.config)
configure_remote_units(self, self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
class ChatmailDeployer(Deployer): class ChatmailDeployer(Deployer):
@@ -408,20 +474,17 @@ class ChatmailDeployer(Deployer):
("iroh", None, None), ("iroh", None, None),
] ]
def __init__(self, config): def __init__(self, mail_domain):
self.config = config self.mail_domain = mail_domain
self.mail_domain = config.mail_domain
def install(self): def install(self):
self.put_file( files.put(
name="Disable installing recommended packages globally",
src=BytesIO(b'APT::Install-Recommends "false";\n'), src=BytesIO(b'APT::Install-Recommends "false";\n'),
dest="/etc/apt/apt.conf.d/00InstallRecommends", dest="/etc/apt/apt.conf.d/00InstallRecommends",
) user="root",
# Pin dovecot-* to priority -1 before any apt operation, apt should group="root",
# never manage dovecot as our version might be lower than the distro's. mode="644",
self.put_file(
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
dest="/etc/apt/preferences.d/pin-dovecot",
) )
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -435,15 +498,12 @@ class ChatmailDeployer(Deployer):
name="Install rsync", name="Install rsync",
packages=["rsync"], packages=["rsync"],
) )
apt.packages(
def configure(self): name="Ensure cron is installed",
# metadata crashes if the mailboxes dir does not exist packages=["cron"],
self.ensure_directory(
str(self.config.mailboxes_dir),
owner="vmail",
mode="700",
) )
def configure(self):
# This file is used by auth proxy. # This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName # https://wiki.debian.org/EtcMailName
server.shell( server.shell(
@@ -462,7 +522,12 @@ class FcgiwrapDeployer(Deployer):
) )
def activate(self): def activate(self):
self.ensure_service("fcgiwrap.service") systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
class GithashDeployer(Deployer): class GithashDeployer(Deployer):
@@ -475,7 +540,12 @@ class GithashDeployer(Deployer):
git_diff = subprocess.check_output(["git", "diff"]).decode() git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception: except Exception:
git_diff = "" git_diff = ""
self.put_file(src=StringIO(git_hash + git_diff), dest="/etc/chatmail-version") files.put(
name="Upload chatmail relay git commit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
def get_tls_deployer(config, mail_domain): def get_tls_deployer(config, mail_domain):
@@ -501,27 +571,29 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
""" """
config = read_config(config_path) config = read_config(config_path)
check_config(config) check_config(config)
bare_host = config.mail_domain_bare mail_domain = config.mail_domain
if website_only: if website_only:
Deployment().perform_stages([WebsiteDeployer(config)]) Deployment().perform_stages([WebsiteDeployer(config)])
return return
if host.get_fact(Port, port=53) != "unbound":
files.line(
name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf",
# Guard against resolv.conf missing a trailing newline (SolusVM bug).
line="\nnameserver 9.9.9.9",
)
# Check if mtail_address interface is available (if configured) # Check if mtail_address interface is available (if configured)
if config.mtail_address and config.mtail_address not in ( if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
"127.0.0.1",
"::1",
"localhost",
):
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs) ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs] all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
if config.mtail_address not in all_addresses: if config.mtail_address not in all_addresses:
Out().red( Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
)
exit(1) exit(1)
if not is_in_container(): if host.get_fact(Command, "systemd-detect-virt -c || true") == "none":
port_services = [ port_services = [
(["master", "smtpd"], 25), (["master", "smtpd"], 25),
("unbound", 53), ("unbound", 53),
@@ -537,17 +609,15 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
("nginx", 443), ("nginx", 443),
(["master", "smtpd"], 465), (["master", "smtpd"], 465),
(["master", "smtpd"], 587), (["master", "smtpd"], 587),
(["dovecot", "imap-login"], 993), (["imap-login", "dovecot"], 993),
("iroh-relay", 3340), ("iroh-relay", 3340),
("mtail", 3903), ("mtail", 3903),
(["dovecot", "stats"], 3904), ("stats", 3904),
("nginx", 8443), ("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port), (["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming), (["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port), ("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming), ("filtermail", config.filtermail_smtp_port_incoming),
("filtermail", config.filtermail_http_port_incoming),
("filtermail", config.filtermail_lmtp_port_transport),
] ]
for service, port in port_services: for service, port in port_services:
print(f"Checking if port {port} is available for {service}...") print(f"Checking if port {port} is available for {service}...")
@@ -560,21 +630,21 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
) )
exit(1) exit(1)
tls_deployer = get_tls_deployer(config, bare_host) tls_deployer = get_tls_deployer(config, mail_domain)
all_deployers = [ all_deployers = [
ChatmailDeployer(config), ChatmailDeployer(mail_domain),
LegacyRemoveDeployer(), LegacyRemoveDeployer(),
FiltermailDeployer(), FiltermailDeployer(),
JournaldDeployer(), JournaldDeployer(),
UnboundDeployer(config), UnboundDeployer(config),
TurnDeployer(bare_host), TurnDeployer(mail_domain),
IrohDeployer(config.enable_iroh_relay), IrohDeployer(config.enable_iroh_relay),
tls_deployer, tls_deployer,
WebsiteDeployer(config), WebsiteDeployer(config),
ChatmailVenvDeployer(config), ChatmailVenvDeployer(config),
MtastsDeployer(), MtastsDeployer(),
*([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]), OpendkimDeployer(mail_domain),
# Dovecot should be started before Postfix # Dovecot should be started before Postfix
# because it creates authentication socket # because it creates authentication socket
# required by Postfix. # required by Postfix.
+10 -48
View File
@@ -1,22 +1,11 @@
import datetime import datetime
import importlib
from jinja2 import Template
from . import remote from . import remote
def parse_zone_records(text):
"""Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text."""
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith(";"):
continue
try:
name, ttl, _in, rtype, rdata = line.split(None, 4)
except ValueError:
raise ValueError(f"Bad zone record line: {line!r}") from None
name = name.rstrip(".")
yield name, ttl, rtype.upper(), rdata
def get_initial_remote_data(sshexec, mail_domain): def get_initial_remote_data(sshexec, mail_domain):
return sshexec.logged( return sshexec.logged(
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain) call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
@@ -42,39 +31,13 @@ def get_filled_zone_file(remote_data):
if not sts_id: if not sts_id:
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M") remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
d = remote_data["mail_domain"] template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
content = template.read_text()
def append_record(name, rtype, rdata, ttl=3600): zonefile = Template(content).render(**remote_data)
lines.append(f"{name:<40} {ttl:<6} IN {rtype:<5} {rdata}") lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
lines = ["; Required DNS entries"]
if remote_data.get("A"):
append_record(f"{d}.", "A", remote_data["A"])
if remote_data.get("AAAA"):
append_record(f"{d}.", "AAAA", remote_data["AAAA"])
append_record(f"{d}.", "MX", f"10 {d}.")
if remote_data.get("strict_tls"):
append_record(f"_mta-sts.{d}.", "TXT", f'"v=STSv1; id={remote_data["sts_id"]}"')
append_record(f"mta-sts.{d}.", "CNAME", f"{d}.")
append_record(f"www.{d}.", "CNAME", f"{d}.")
lines.append(remote_data["dkim_entry"])
lines.append("") lines.append("")
lines.append("; Recommended DNS entries") zonefile = "\n".join(lines)
append_record(f"{d}.", "TXT", '"v=spf1 a ~all"') return zonefile
append_record(f"_dmarc.{d}.", "TXT", '"v=DMARC1;p=reject;adkim=s;aspf=s"')
if remote_data.get("acme_account_url"):
append_record(
f"{d}.",
"CAA",
f'0 issue "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"',
)
append_record(f"_adsp._domainkey.{d}.", "TXT", '"dkim=discardable"')
append_record(f"_submission._tcp.{d}.", "SRV", f"0 1 587 {d}.")
append_record(f"_submissions._tcp.{d}.", "SRV", f"0 1 465 {d}.")
append_record(f"_imap._tcp.{d}.", "SRV", f"0 1 143 {d}.")
append_record(f"_imaps._tcp.{d}.", "SRV", f"0 1 993 {d}.")
lines.append("")
return "\n".join(lines)
def check_full_zone(sshexec, remote_data, out, zonefile) -> int: def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
@@ -95,8 +58,7 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
returncode = 1 returncode = 1
if remote_data.get("dkim_entry") in required_diff: if remote_data.get("dkim_entry") in required_diff:
out( out(
"If the DKIM entry above does not work with your DNS provider," "If the DKIM entry above does not work with your DNS provider, you can try this one:\n"
" you can try this one:\n"
) )
out(remote_data.get("web_dkim_entry") + "\n") out(remote_data.get("web_dkim_entry") + "\n")
if recommended_diff: if recommended_diff:
+12
View File
@@ -0,0 +1,12 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = no
iterate_prefix = userdb/
default_pass_scheme = plain
# %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>
# for documentation.
#
# We escape user-provided input and use double quote as a separator.
password_key = passdb/%Ew"%Eu
user_key = userdb/%Eu
-68
View File
@@ -1,68 +0,0 @@
-- Existing addresses are served from the maildir directly.
-- Unknown ones are offered to doveauth, which owns the creation policy.
local mailboxes_dir = "{{ config.mailboxes_dir }}"
local domain_suffix = "@{{ config.mail_domain }}"
local create_url = "http://127.0.0.1:{{ config.doveauth_http_port }}/create"
local http_client
local function is_ours(user)
return user:sub(-#domain_suffix) == domain_suffix
and not user:find("/", 1, true)
end
local function password_hash(user)
local fh = io.open(mailboxes_dir .. "/" .. user .. "/password", "r")
if not fh then
return nil
end
local hash, rest = fh:read("l", "a")
fh:close()
if hash == nil or hash == "" or rest ~= "" then
return nil
end
return hash
end
local function userdb_fields(user)
return {home = mailboxes_dir .. "/" .. user, uid = "vmail", gid = "vmail"}
end
local function create(user, password)
local request = http_client:request({url = create_url, method = "POST"})
request:set_payload(user .. "\t" .. password)
return request:submit():status()
end
-- Entry points called by dovecot
function script_init()
http_client = dovecot.http.client({request_timeout_msecs = 5000, max_attempts = 1})
return 0
end
function auth_userdb_lookup(req)
if not is_ours(req.user) or password_hash(req.user) == nil then
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, {}
end
return dovecot.auth.USERDB_RESULT_OK, userdb_fields(req.user)
end
function auth_password_verify(req, password)
if not is_ours(req.user) then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
local hash = password_hash(req.user)
if hash == nil then
-- doveauth refuses with 4xx; dovecot reports its own failures as 9000 and up
local status = create(req.user, password)
if status >= 500 then
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, {}
elseif status ~= 200 then
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
end
elseif req:password_verify(hash, password) ~= 1 then
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, {}
end
return dovecot.auth.PASSDB_RESULT_OK, userdb_fields(req.user)
end
+81 -99
View File
@@ -4,24 +4,26 @@ from chatmaild.config import Config
from pyinfra import host from pyinfra import host
from pyinfra.facts.deb import DebPackages from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Arch, Command, Sysctl from pyinfra.facts.server import Arch, Command, Sysctl
from pyinfra.operations import files, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
activate_remote_units, activate_remote_units,
blocked_service_startup, blocked_service_startup,
configure_remote_units, configure_remote_units,
is_in_container, get_resource,
) )
from cmdeploy.pins import DOVECOT_SHA256, DOVECOT_VERSION
VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release" DOVECOT_VERSION = "2.3.21+dfsg1-3"
DOVECOT_SHA256 = {
def _stamped_version(deb_release: int) -> str: ("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
"""Version as built, including the per-distro suffix stamped by ("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
chatmail/dovecot CI into package version and filename.""" ("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
return f"{DOVECOT_VERSION}+deb{deb_release}u1" ("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
}
class DovecotDeployer(Deployer): class DovecotDeployer(Deployer):
@@ -30,68 +32,35 @@ class DovecotDeployer(Deployer):
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
self.disable_mail = disable_mail self.disable_mail = disable_mail
self.units = [] self.units = ["doveauth"]
def install(self): def install(self):
arch = host.get_fact(Arch) arch = host.get_fact(Arch)
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
with blocked_service_startup(): with blocked_service_startup():
debs = [] _install_dovecot_package("core", arch)
for pkg in ("core", "imapd", "lmtpd", "auth-lua"): _install_dovecot_package("imapd", arch)
deb, changed = _download_dovecot_package(pkg, arch, deb_release) _install_dovecot_package("lmtpd", arch)
self.need_restart |= changed
if deb:
debs.append(deb)
if debs:
deb_list = " ".join(debs)
# apt-get install with local .deb paths resolves depends
# against the configured repos (e.g. pulls libwrap0),
# The pin file written earlier by ChatmailDeployer prevents apt
# from installing a 'wrong' version
server.shell(
name="Install dovecot packages",
commands=[
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
'-o Dpkg::Options::="--force-confdef" '
'-o Dpkg::Options::="--force-confold" '
f"--allow-downgrades {deb_list}",
],
)
self.need_restart = True
def configure(self): def configure(self):
configure_remote_units(self, self.config.mail_domain_bare, self.units) configure_remote_units(self.config.mail_domain, self.units)
_configure_dovecot(self, self.config) self.need_restart, self.daemon_reload = _configure_dovecot(self.config)
def activate(self): def activate(self):
activate_remote_units(self, self.units) activate_remote_units(self.units)
# Detect stale binary: package installed but service still runs old (deleted) binary. restart = False if self.disable_mail else self.need_restart
if not self.disable_mail and not self.need_restart:
stale = host.get_fact(
Command,
"pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
" && echo STALE || true",
)
if stale == "STALE":
self.need_restart = True
active = not self.disable_mail systemd.service(
self.ensure_service( name="Disable dovecot for now"
"dovecot.service", if self.disable_mail
running=active, else "Start and enable Dovecot",
enabled=active, service="dovecot.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False
def _parse_version_id(version_line: str) -> int:
"""Debian major release from an /etc/os-release VERSION_ID line."""
_, _, raw = (version_line or "").strip().partition("=")
try:
return int(raw.strip('"'))
except ValueError:
raise ValueError(f"cannot determine Debian release from {version_line!r}")
def _pick_url(primary, fallback): def _pick_url(primary, fallback):
@@ -103,36 +72,26 @@ def _pick_url(primary, fallback):
return fallback return fallback
def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]: def _install_dovecot_package(package: str, arch: str):
"""Download a dovecot .deb if needed, return (path, changed)."""
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
pkg_name = f"dovecot-{package}" pkg_name = f"dovecot-{package}"
try: sha256 = DOVECOT_SHA256.get((package, arch))
# never fall back to the distro package: it is pinned to -1 and would if sha256 is None:
# in any case be a version we did not build and do not support apt.packages(packages=[pkg_name])
sha256 = DOVECOT_SHA256[(arch, deb_release, package)] return
except KeyError:
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
stamped_version = _stamped_version(deb_release)
installed_versions = host.get_fact(DebPackages).get(pkg_name, []) installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
if f"1:{stamped_version}" in installed_versions: if DOVECOT_VERSION in installed_versions:
return None, False return
# Primary URL: flat structure with distro suffix in filename url_version = DOVECOT_VERSION.replace("+", "%2B")
primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb" deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
primary_url = f"https://download.delta.chat/dovecot/{primary_deb}" primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
# GitHub release files: escaped + in filename; the release tag stays fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
# distro-neutral, both distros ship in one combined release
tag_version = DOVECOT_VERSION.replace("+", "%2B")
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
fallback_url = (
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
)
url = _pick_url(primary_url, fallback_url) url = _pick_url(primary_url, fallback_url)
deb_filename = f"/root/{primary_deb}" deb_filename = f"/root/{deb_base}"
files.download( files.download(
name=f"Download {pkg_name}", name=f"Download {pkg_name}",
@@ -142,35 +101,53 @@ def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tupl
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
) )
return deb_filename, True apt.deb(name=f"Install {pkg_name}", src=deb_filename)
def _configure_dovecot(deployer, config: Config, debug: bool = False): def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
deployer.put_template( need_restart = False
"dovecot/dovecot.conf.j2", daemon_reload = False
"/etc/dovecot/dovecot.conf",
main_config = files.template(
src=get_resource("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
debug=debug, debug=debug,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
deployer.put_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config) need_restart |= main_config.changed
deployer.remove_file("/etc/dovecot/auth.conf") auth_config = files.put(
deployer.put_file( src=get_resource("dovecot/auth.conf"),
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua" dest="/etc/dovecot/auth.conf",
user="root",
group="root",
mode="644",
) )
need_restart |= auth_config.changed
lua_push_notification_script = files.put(
src=get_resource("dovecot/push_notification.lua"),
dest="/etc/dovecot/push_notification.lua",
user="root",
group="root",
mode="644",
)
need_restart |= lua_push_notification_script.changed
# as per https://doc.dovecot.org/2.3/configuration_manual/os/ # as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
can_modify = not is_in_container() can_modify = host.get_fact(Command, "systemd-detect-virt -c || true") == "none"
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}"
value = host.get_fact(Sysctl).get(key, 0) value = host.get_fact(Sysctl)[key]
if value > 65534: if value > 65534:
continue continue
if not can_modify: if not can_modify:
print( print(
"\n!!!! refusing to attempt sysctl setting in containers\n" "\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n"
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n" f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n"
"!!!!" "!!!!"
) )
@@ -182,20 +159,25 @@ def _configure_dovecot(deployer, config: Config, debug: bool = False):
persist=True, persist=True,
) )
deployer.ensure_line( timezone_env = files.line(
name="Set TZ environment variable", name="Set TZ environment variable",
path="/etc/environment", path="/etc/environment",
line="TZ=:/etc/localtime", line="TZ=:/etc/localtime",
) )
need_restart |= timezone_env.changed
deployer.put_file( restart_conf = files.put(
"service/10_restart_on_failure.conf", name="dovecot: restart automatically on failure",
"/etc/systemd/system/dovecot.service.d/10_restart.conf", src=get_resource("service/10_restart.conf"),
dest="/etc/systemd/system/dovecot.service.d/10_restart.conf",
) )
daemon_reload |= restart_conf.changed
# Validate dovecot configuration before restart # Validate dovecot configuration before restart
if deployer.need_restart: if need_restart:
server.shell( server.shell(
name="Validate dovecot configuration", name="Validate dovecot configuration",
commands=["doveconf -n >/dev/null"], commands=["doveconf -n >/dev/null"],
) )
return need_restart, daemon_reload
+10 -37
View File
@@ -7,7 +7,6 @@ listen = 0.0.0.0
protocols = imap lmtp protocols = imap lmtp
auth_mechanisms = plain auth_mechanisms = plain
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
{% if debug == true %} {% if debug == true %}
auth_verbose = yes auth_verbose = yes
@@ -37,15 +36,11 @@ default_client_limit = 20000
# the following warning will be logged: # the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped # Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap { service imap {
process_limit = {{ config.max_imap_connections }} process_limit = 50000
} }
{% if config.privacy_mail %} mail_server_admin = mailto:root@{{ config.mail_domain }}
# Advertised to clients as IMAP METADATA /shared/admin (RFC 5464). mail_server_comment = Chatmail server
# The privacy_mail contact from chatmail.ini is used because it is
# the only address set by an operator.
mail_server_admin = mailto:{{ config.privacy_mail }}
{% endif %}
# `zlib` enables compressing messages stored in the maildir. # `zlib` enables compressing messages stored in the maildir.
# See # See
@@ -61,12 +56,12 @@ imap_capability = +XDELTAPUSH XCHATMAIL
# Authentication for system users. # Authentication for system users.
passdb { passdb {
driver = lua driver = dict
args = file=/etc/dovecot/auth.lua blocking=yes args = /etc/dovecot/auth.conf
} }
userdb { userdb {
driver = lua driver = dict
args = file=/etc/dovecot/auth.lua blocking=yes args = /etc/dovecot/auth.conf
} }
## ##
## Mailbox locations and namespaces ## Mailbox locations and namespaces
@@ -138,11 +133,6 @@ protocol lmtp {
# 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/2.3/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
# Disable fsync for LMTP. May lose delivered message,
# but unlikely to cause problems with multiple relays.
# https://doc.dovecot.org/2.3/admin_manual/mailbox_formats/#fsyncing
mail_fsync = never
} }
plugin { plugin {
@@ -154,26 +144,12 @@ plugin {
} }
plugin { plugin {
# for now we define static quota-rules for all users
quota = maildir:User quota quota = maildir:User quota
quota_rule = *:storage={{ config.max_mailbox_size }}
quota_max_mail_size={{ config.max_message_size }} quota_max_mail_size={{ config.max_message_size }}
quota_grace = 0 quota_grace = 0
# quota_over_flag_value = TRUE
quota_rule = *:storage={{ config.max_mailbox_size_mb }}M
# Trigger at 75%% of quota, expire oldest messages down to 70%%.
# The percentages are chosen to prevent current Delta Chat users
# from seeing "quota warnings" which trigger at 80% and 95%.
quota_warning = storage=75%% quota-warning {{ config.max_mailbox_size_mb * 70 // 100 }} {{ config.mailboxes_dir }}/%u
}
service quota-warning {
executable = script /usr/local/lib/chatmaild/venv/bin/chatmail-quota-expire
user = vmail
unix_listener quota-warning {
user = vmail
mode = 0600
}
} }
# push_notification configuration # push_notification configuration
@@ -276,9 +252,6 @@ protocol imap {
# sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out) # sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out)
rawlog_dir = %h rawlog_dir = %h
# Disable fsync for IMAP. May lose IMAP changes like setting flags.
mail_fsync = never
} }
{% endif %} {% endif %}
+38 -12
View File
@@ -1,7 +1,10 @@
import io
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import files, systemd
from ..basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class ExternalTlsDeployer(Deployer): class ExternalTlsDeployer(Deployer):
@@ -20,22 +23,45 @@ class ExternalTlsDeployer(Deployer):
def configure(self): def configure(self):
# Verify cert and key exist on the remote host using pyinfra facts. # Verify cert and key exist on the remote host using pyinfra facts.
for path in (self.cert_path, self.key_path): for path in (self.cert_path, self.key_path):
if host.get_fact(File, path=path) is None: info = host.get_fact(File, path=path)
if info is None:
raise Exception(f"External TLS file not found on server: {path}") raise Exception(f"External TLS file not found on server: {path}")
self.ensure_systemd_unit( # Deploy the .path unit (templated with the cert path).
"external/tls-cert-reload.path.j2", # pkg=__package__ is required here because the resource files
cert_path=self.cert_path, # live in cmdeploy.external, not the default cmdeploy package.
) source = get_resource("tls-cert-reload.path.f", pkg=__package__)
self.ensure_systemd_unit( content = source.read_text().format(cert_path=self.cert_path).encode()
"external/tls-cert-reload.service",
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): def activate(self):
# No explicit reload needed here: dovecot/nginx read the cert systemd.service(
# on startup, and the .path watcher handles live changes. name="Enable tls-cert-reload path watcher",
self.ensure_service( service="tls-cert-reload.path",
"tls-cert-reload.path",
running=True, running=True,
enabled=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.
@@ -9,7 +9,7 @@
Description=Watch TLS certificate for changes Description=Watch TLS certificate for changes
[Path] [Path]
PathChanged={{ cert_path }} PathChanged={cert_path}
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+34 -19
View File
@@ -1,37 +1,52 @@
import os
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import files, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
from cmdeploy.pins import FILTERMAIL_ARTIFACTS
class FiltermailDeployer(Deployer): class FiltermailDeployer(Deployer):
services = ["filtermail", "filtermail-incoming", "filtermail-transport"] services = ["filtermail", "filtermail-incoming"]
bin_path = "/usr/local/bin/filtermail" bin_path = "/usr/local/bin/filtermail"
config_path = "/usr/local/lib/chatmaild/chatmail.ini" config_path = "/usr/local/lib/chatmaild/chatmail.ini"
def install(self): def __init__(self):
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY") self.need_restart = False
if local_bin:
self.put_executable(
src=local_bin,
dest=self.bin_path,
)
return
def install(self):
arch = host.get_fact(facts.server.Arch) arch = host.get_fact(facts.server.Arch)
url, sha256sum = FILTERMAIL_ARTIFACTS[arch] url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.0/filtermail-{arch}"
self.download_executable(url, self.bin_path, sha256sum) sha256sum = {
"x86_64": "3fd8b18282252c75a5bbfa603d8c1b65f6563e5e920bddf3e64e451b7cdb43ce",
"aarch64": "2bd191de205f7fd60158dd8e3516ab7e3efb14627696f3d7dc186bdcd9e10a43",
}[arch]
self.need_restart |= files.download(
name="Download filtermail",
src=url,
sha256sum=sha256sum,
dest=self.bin_path,
mode="755",
).changed
def configure(self): def configure(self):
for service in self.services: for service in self.services:
self.ensure_systemd_unit( self.need_restart |= files.template(
f"filtermail/{service}.service.j2", 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, bin_path=self.bin_path,
config_path=self.config_path, config_path=self.config_path,
) ).changed
def activate(self): def activate(self):
for service in self.services: for service in self.services:
self.ensure_service(f"{service}.service") 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
@@ -1,12 +0,0 @@
[Unit]
Description=Chatmail transport service
[Service]
ExecStart={{ bin_path }} {{ config_path }} transport
Restart=always
RestartSec=30
User=vmail
LimitNOFILE=524288
[Install]
WantedBy=multi-user.target
@@ -2,7 +2,7 @@
Description=Iroh relay Description=Iroh relay
[Service] [Service]
ExecStart={{ bin_path }} --config-path {{ config_path }} ExecStart=/usr/local/bin/iroh-relay --config-path /etc/iroh-relay.toml
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
User=iroh User=iroh
@@ -28,13 +28,6 @@ counter created_nonci_accounts
} }
} }
# doveauth refusing new addresses because a chatmail.ini
# system resource limit is exceeded.
counter rejected_registrations
/registration rejected: / {
rejected_registrations++
}
counter postfix_timeouts counter postfix_timeouts
/timeout after DATA/ { /timeout after DATA/ {
postfix_timeouts++ postfix_timeouts++
@@ -80,21 +73,8 @@ counter incoming_unencrypted_mail_count
filtered_incoming_mail_count++ filtered_incoming_mail_count++
} }
counter incoming_mailer_daemon_mail_count
/Incoming: Filtering mailer-daemon message from/ {
incoming_mailer_daemon_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++
} }
counter quota_expire_runs
counter quota_expire_removed_files
/quota-expire: removed (?P<count>\d+) message\(s\)/ {
quota_expire_runs++
quota_expire_removed_files += $count
}
+46 -40
View File
@@ -1,14 +1,13 @@
from pyinfra import facts, host from pyinfra import facts, host
from pyinfra.operations import apt, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import (
from cmdeploy.pins import FILTERMAIL_ARTIFACTS, MTAIL_ARTIFACTS Deployer,
get_resource,
)
class MtailDeployer(Deployer): class MtailDeployer(Deployer):
bin_path = "/usr/local/bin/mtail"
progs_dir = "/etc/mtail"
def __init__(self, mtail_address): def __init__(self, mtail_address):
self.mtail_address = mtail_address self.mtail_address = mtail_address
@@ -16,47 +15,54 @@ class MtailDeployer(Deployer):
# Uninstall mtail package to install a static binary. # Uninstall mtail package to install a static binary.
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False) apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
(url, sha256sum) = MTAIL_ARTIFACTS[host.get_fact(facts.server.Arch)] (url, sha256sum) = {
self.download_executable( "x86_64": (
url, "https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
self.bin_path, "123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
sha256sum, ),
extract="gunzip | tar -xf - mtail -O", "aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
),
}[host.get_fact(facts.server.Arch)]
server.shell(
name="Download mtail",
commands=[
f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)",
"chmod 755 /usr/local/bin/mtail",
],
) )
def configure(self): def configure(self):
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`. # Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
# This allows to read from journalctl instead of log files. # This allows to read from journalctl instead of log files.
self.ensure_systemd_unit( files.template(
"mtail/mtail.service.j2", src=get_resource("mtail/mtail.service.j2"),
dest="/etc/systemd/system/mtail.service",
user="root",
group="root",
mode="644",
address=self.mtail_address or "127.0.0.1", address=self.mtail_address or "127.0.0.1",
port=3903, port=3903,
bin_path=self.bin_path,
progs_dir=self.progs_dir,
) )
if self.mtail_address:
self.put_file( mtail_conf = files.put(
"mtail/delivered_mail.mtail", f"{self.progs_dir}/delivered_mail.mtail" name="Mtail configuration",
) src=get_resource("mtail/delivered_mail.mtail"),
url, sha256sum = FILTERMAIL_ARTIFACTS['mtail'] dest="/etc/mtail/delivered_mail.mtail",
self.download_executable( user="root",
url, group="root",
f"{self.progs_dir}/filtermail.mtail", mode="644",
sha256sum, )
mode="644", self.need_restart = mtail_conf.changed
)
if self.need_restart:
# Check if all installed mtail rules compile or fail early
# --one_shot to exit, --port 0 to not clash with running mtail.
server.shell(
name="Validate mtail programs",
commands=[
f"timeout 30 {self.bin_path} --compile_only --one_shot"
f" --progs {self.progs_dir} --logs /dev/null"
" --address 127.0.0.1 --port 0"
],
)
def activate(self): def activate(self):
active = bool(self.mtail_address) systemd.service(
self.ensure_service("mtail.service", running=active, enabled=active) name="Start and enable mtail",
service="mtail.service",
running=bool(self.mtail_address),
enabled=bool(self.mtail_address),
restarted=self.need_restart,
)
self.need_restart = False
+1 -4
View File
@@ -1,13 +1,10 @@
[Unit] [Unit]
Description=mtail Description=mtail
After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | {{ bin_path }} --address={{ address }} --port={{ port }} --progs {{ progs_dir }} --logtostderr --logs -" ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
Restart=on-failure Restart=on-failure
RestartSec=2s
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+54 -17
View File
@@ -1,5 +1,5 @@
from chatmaild.config import Config from chatmaild.config import Config
from pyinfra.operations import apt from pyinfra.operations import apt, files, systemd
from cmdeploy.basedeploy import ( from cmdeploy.basedeploy import (
Deployer, Deployer,
@@ -31,50 +31,87 @@ class NginxDeployer(Deployer):
# For documentation about policy-rc.d, see: # For documentation about policy-rc.d, see:
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt # https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
# #
self.put_executable(src="policy-rc.d", dest="/usr/sbin/policy-rc.d") files.put(
src=get_resource("policy-rc.d"),
dest="/usr/sbin/policy-rc.d",
user="root",
group="root",
mode="755",
)
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
packages=["nginx", "libnginx-mod-stream"], packages=["nginx", "libnginx-mod-stream"],
) )
self.remove_file("/usr/sbin/policy-rc.d") files.file("/usr/sbin/policy-rc.d", present=False)
def configure(self): def configure(self):
_configure_nginx(self, self.config) self.need_restart = _configure_nginx(self.config)
def activate(self): def activate(self):
self.ensure_service("nginx.service") systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
def _configure_nginx(deployer, config: Config, debug: bool = False): def _configure_nginx(config: Config, debug: bool = False) -> bool:
"""Configures nginx HTTP server.""" """Configures nginx HTTP server."""
need_restart = False
deployer.put_template( main_config = files.template(
"nginx/nginx.conf.j2", src=get_resource("nginx/nginx.conf.j2"),
"/etc/nginx/nginx.conf", dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
deployer.put_template( autoconfig = files.template(
"nginx/autoconfig.xml.j2", src=get_resource("nginx/autoconfig.xml.j2"),
"/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml", dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= autoconfig.changed
deployer.put_template( mta_sts_config = files.template(
"nginx/mta-sts.txt.j2", src=get_resource("nginx/mta-sts.txt.j2"),
"/var/www/html/.well-known/mta-sts.txt", dest="/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config=config, config=config,
) )
need_restart |= mta_sts_config.changed
# install CGI newemail script # install CGI newemail script
# #
cgi_dir = "/usr/lib/cgi-bin" cgi_dir = "/usr/lib/cgi-bin"
deployer.ensure_directory(cgi_dir) files.directory(
name=f"Ensure {cgi_dir} exists",
path=cgi_dir,
user="root",
group="root",
)
deployer.put_executable( files.put(
name="Upload cgi newemail.py script",
src=get_resource("newemail.py", pkg="chatmaild").open("rb"), src=get_resource("newemail.py", pkg="chatmaild").open("rb"),
dest=f"{cgi_dir}/newemail.py", dest=f"{cgi_dir}/newemail.py",
user="root",
group="root",
mode="755",
) )
return need_restart
+4 -11
View File
@@ -42,9 +42,6 @@ stream {
} }
http { http {
# access_log setting is inherited by all server sections
access_log syslog:server=unix:/dev/log,facility=local7;
{% if config.tls_cert_mode == "self" %} {% if config.tls_cert_mode == "self" %}
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s; limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
{% endif %} {% endif %}
@@ -72,11 +69,9 @@ http {
index index.html index.htm; index index.html index.htm;
server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }}; server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
location /mxdeliv { access_log syslog:server=unix:/dev/log,facility=local7;
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
}
location / { location / {
# First attempt to serve request as file, then # First attempt to serve request as file, then
@@ -128,10 +123,7 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
# Endpoints Iroh uses for net_report probes and that clients location /relay/probe {
# probe to tell whether this relay works. Both paths are served
# by iroh-relay 0.35 and by the 1.0 line.
location /ping {
proxy_pass http://127.0.0.1:3340; proxy_pass http://127.0.0.1:3340;
proxy_http_version 1.1; proxy_http_version 1.1;
} }
@@ -147,6 +139,7 @@ http {
listen 127.0.0.1:8443 ssl; listen 127.0.0.1:8443 ssl;
server_name www.{{ config.mail_domain }}; server_name www.{{ config.mail_domain }};
return 301 $scheme://{{ config.mail_domain }}$request_uri; return 301 $scheme://{{ config.mail_domain }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;
} }
server { server {
+63 -25
View File
@@ -4,9 +4,9 @@ Installs OpenDKIM
from pyinfra import host from pyinfra import host
from pyinfra.facts.files import File from pyinfra.facts.files import File
from pyinfra.operations import apt, files, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class OpendkimDeployer(Deployer): class OpendkimDeployer(Deployer):
@@ -25,39 +25,65 @@ class OpendkimDeployer(Deployer):
domain = self.mail_domain domain = self.mail_domain
dkim_selector = "opendkim" dkim_selector = "opendkim"
"""Configures OpenDKIM""" """Configures OpenDKIM"""
need_restart = False
self.put_template( main_config = files.template(
"opendkim/opendkim.conf", src=get_resource("opendkim/opendkim.conf"),
"/etc/opendkim.conf", dest="/etc/opendkim.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= main_config.changed
self.remove_file("/etc/opendkim/screen.lua") screen_script = files.file(
self.remove_file("/etc/opendkim/final.lua") path="/etc/opendkim/screen.lua",
present=False,
)
need_restart |= screen_script.changed
self.ensure_directory( final_script = files.file(
"/etc/opendkim", path="/etc/opendkim/final.lua",
owner="opendkim", present=False,
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
self.put_template( keytable = files.template(
"opendkim/KeyTable", src=get_resource("opendkim/KeyTable"),
"/etc/dkimkeys/KeyTable", dest="/etc/dkimkeys/KeyTable",
owner="opendkim", user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= keytable.changed
self.put_template( signing_table = files.template(
"opendkim/SigningTable", src=get_resource("opendkim/SigningTable"),
"/etc/dkimkeys/SigningTable", dest="/etc/dkimkeys/SigningTable",
owner="opendkim", user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
self.ensure_directory( need_restart |= signing_table.changed
"/var/spool/postfix/opendkim", files.directory(
owner="opendkim", name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
user="opendkim",
group="opendkim",
mode="750", mode="750",
present=True,
) )
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"): if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
@@ -70,10 +96,12 @@ class OpendkimDeployer(Deployer):
_su_user="opendkim", _su_user="opendkim",
) )
self.put_file( service_file = files.put(
"opendkim/systemd.conf", name="Configure opendkim to restart once a day",
"/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf", src=get_resource("opendkim/systemd.conf"),
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
) )
need_restart |= service_file.changed
files.file( files.file(
name="chown opendkim: /etc/dkimkeys/opendkim.private", name="chown opendkim: /etc/dkimkeys/opendkim.private",
@@ -82,5 +110,15 @@ class OpendkimDeployer(Deployer):
group="opendkim", group="opendkim",
) )
self.need_restart = need_restart
def activate(self): def activate(self):
self.ensure_service("opendkim.service") systemd.service(
name="Start and enable OpenDKIM",
service="opendkim.service",
running=True,
enabled=True,
daemon_reload=self.need_restart,
restarted=self.need_restart,
)
self.need_restart = False
-73
View File
@@ -1,73 +0,0 @@
"""Versions, hashes, and download URLs for pre-built artifacts fetched during deploy."""
FILTERMAIL_VERSION = "v0.7.4"
FILTERMAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-x86_64",
"484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
),
"aarch64": (
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-aarch64",
"66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
),
"mtail": (
f"https://raw.githubusercontent.com/chatmail/filtermail/{FILTERMAIL_VERSION}/contrib/filtermail.mtail",
"948f688bb89ad47e6eb0fc8fa107e201a689f5adc264ff926be487a2a8562b51",
),
}
MTAIL_VERSION = "3.4.9"
MTAIL_ARTIFACTS = {
"x86_64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_amd64.tar.gz",
"55f64a87f71955bb871c724b4aadf19fe9d854e6327196919c7fe44943427eab",
),
"aarch64": (
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_arm64.tar.gz",
"e0a2b66b372ca257d7daeb7ba10f9233a2192a1f9057618fccc6be5c854a2a3c",
),
}
# distro-neutral base version, as committed in chatmail/dovecot debian/changelog
DOVECOT_VERSION = "2.3.21+dfsg1-3+chatmail2"
DOVECOT_SHA256 = {
("amd64", 12, "auth-lua"): "ef1b8e1db45147a74b48d63125bd61b2cc2f250e1006656ba1c58b9c12f5cde6",
("arm64", 12, "auth-lua"): "c1a06ee9374439893e397ba3b0cacf532a733290c184f4f30ea39df8699be329",
("amd64", 13, "auth-lua"): "6c0946d2516efcbcaa09a27df9b8ea701861cce270d71941056b3c69831a5ea2",
("arm64", 13, "auth-lua"): "5e6c9cfe47f7f3b8aa0d68a3e607161b6abaee1ad696cb1419e997b3099b2985",
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
}
TURN_VERSION = "v0.4"
TURN_ARTIFACTS = {
"x86_64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-x86_64-linux",
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
),
"aarch64": (
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-aarch64-linux",
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
),
}
IROH_VERSION = "v0.35.0"
IROH_ARTIFACTS = {
"x86_64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-x86_64-unknown-linux-musl.tar.gz",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
),
"aarch64": (
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
),
}
+71 -27
View File
@@ -1,10 +1,11 @@
from pyinfra.operations import apt, server from pyinfra.operations import apt, files, server, systemd
from cmdeploy.basedeploy import Deployer from cmdeploy.basedeploy import Deployer, get_resource
class PostfixDeployer(Deployer): class PostfixDeployer(Deployer):
required_users = [("postfix", None, ["opendkim"])] required_users = [("postfix", None, ["opendkim"])]
daemon_reload = False
def __init__(self, config, disable_mail): def __init__(self, config, disable_mail):
self.config = config self.config = config
@@ -18,46 +19,81 @@ class PostfixDeployer(Deployer):
def configure(self): def configure(self):
config = self.config config = self.config
need_restart = False
self.put_template( main_config = files.template(
"postfix/main.cf.j2", src=get_resource("postfix/main.cf.j2"),
"/etc/postfix/main.cf", dest="/etc/postfix/main.cf",
user="root",
group="root",
mode="644",
config=config, config=config,
disable_ipv6=config.disable_ipv6, disable_ipv6=config.disable_ipv6,
) )
need_restart |= main_config.changed
self.put_template( master_config = files.template(
"postfix/master.cf.j2", src=get_resource("postfix/master.cf.j2"),
"/etc/postfix/master.cf", dest="/etc/postfix/master.cf",
user="root",
group="root",
mode="644",
debug=False, debug=False,
config=config, config=config,
) )
need_restart |= master_config.changed
self.put_file( header_cleanup = files.put(
"postfix/submission_header_cleanup", src=get_resource("postfix/submission_header_cleanup"),
"/etc/postfix/submission_header_cleanup", dest="/etc/postfix/submission_header_cleanup",
user="root",
group="root",
mode="644",
) )
self.put_file("postfix/lmtp_header_cleanup", "/etc/postfix/lmtp_header_cleanup") need_restart |= header_cleanup.changed
res = self.put_file( lmtp_header_cleanup = files.put(
"postfix/smtp_tls_policy_map", "/etc/postfix/smtp_tls_policy_map" src=get_resource("postfix/lmtp_header_cleanup"),
dest="/etc/postfix/lmtp_header_cleanup",
user="root",
group="root",
mode="644",
) )
tls_policy_changed = res.changed need_restart |= lmtp_header_cleanup.changed
if tls_policy_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( server.shell(
commands=["postmap /etc/postfix/smtp_tls_policy_map"], 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.
self.put_file("postfix/login_map", "/etc/postfix/login_map") login_map = files.put(
src=get_resource("postfix/login_map"),
self.put_file( dest="/etc/postfix/login_map",
"service/10_restart_on_failure.conf", user="root",
"/etc/systemd/system/postfix@.service.d/10_restart.conf", group="root",
mode="644",
) )
need_restart |= login_map.changed
restart_conf = files.put(
name="postfix: restart automatically on failure",
src=get_resource("service/10_restart.conf"),
dest="/etc/systemd/system/postfix@.service.d/10_restart.conf",
)
self.daemon_reload = restart_conf.changed
# Validate postfix configuration before restart # Validate postfix configuration before restart
if self.need_restart: if need_restart:
server.shell( server.shell(
name="Validate postfix configuration", name="Validate postfix configuration",
# Extract stderr and quit with error if non-zero # Extract stderr and quit with error if non-zero
@@ -65,11 +101,19 @@ class PostfixDeployer(Deployer):
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'""" """bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
], ],
) )
self.need_restart = need_restart
def activate(self): def activate(self):
active = not self.disable_mail restart = False if self.disable_mail else self.need_restart
self.ensure_service(
"postfix.service", systemd.service(
running=active, name="disable postfix for now"
enabled=active, if self.disable_mail
else "Start and enable Postfix",
service="postfix.service",
running=False if self.disable_mail else True,
enabled=False if self.disable_mail else True,
restarted=restart,
daemon_reload=self.daemon_reload,
) )
self.need_restart = False
+18 -51
View File
@@ -20,7 +20,7 @@ smtpd_tls_key_file={{ config.tls_key_path }}
smtpd_tls_security_level=may smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level={{ "verify" if config.tls_cert_mode == "acme" else "encrypt" }}
# Send SNI extension when connecting to other servers. # Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername> # <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname smtp_tls_servername = hostname
@@ -53,19 +53,15 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>. # See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes tls_preempt_cipherlist = yes
# Reject by default, override per smtpd in master.cf smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
smtpd_relay_restrictions = reject myhostname = {{ config.mail_domain }}
myhostname = {{ config.postfix_myhostname }}
alias_maps = hash:/etc/aliases alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases alias_database = hash:/etc/aliases
# When postfix receives mail for $mydestination, # Postfix does not deliver mail for any domain by itself.
# it hands it over to dovecot via $local_transport. # Primary domain is listed in `virtual_mailbox_domains` instead
# Note: IP literals must be handled via local delivery / mydestination. # and handed over to Dovecot.
mydestination = {{ config.mail_domain }} mydestination =
local_transport = lmtp:unix:private/dovecot-lmtp
# postfix doesn't check whether local users exist or not:
local_recipient_maps =
relayhost = relayhost =
{% if disable_ipv6 %} {% if disable_ipv6 %}
@@ -73,6 +69,15 @@ mynetworks = 127.0.0.0/8
{% else %} {% 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 %} {% 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 = +
@@ -83,16 +88,10 @@ inet_protocols = ipv4
inet_protocols = all inet_protocols = all
{% endif %} {% endif %}
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.mail_domain }}
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
# Do not apply header checks to MIME headers
# and other headers that are actually part of the message body.
# Reference:
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
# <https://www.postfix.org/header_checks.5.html>
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
disable_mime_input_processing = yes
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
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
@@ -103,35 +102,3 @@ smtpd_sender_login_maps = regexp:/etc/postfix/login_map
# Do not lookup SMTP client hostnames to reduce delays # Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests. # and avoid unnecessary DNS requests.
smtpd_peername_lookup = no smtpd_peername_lookup = no
# Use filtermail-transport to relay messages.
# We can't force postfix to split messages per destination,
# when specifying a custom next-hop,
# so instead this is handled in filtermail.
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
# All deliveries over lmtp-filtermail are treated
# as having the same destination [127.0.0.1],
# so it is not possible to limit per-destination concurrency here,
# it is a job for filtermail-transport.
# Total number of parallel deliveries is limited
# by "maxproc" column in /etc/postfix/master.cf for lmtp-filtermail.
# Settings below are to prevent Postfix queue manager
# from limiting the number of LMTP connections to filtermail-transport.
# Read <https://www.postfix.org/TUNING_README.html#rope> and
# <https://www.postfix.org/SCHEDULER_README.html> for the details
# of the Postfix algorithm that we effectively disable here.
lmtp-filtermail_initial_destination_concurrency=10000
lmtp-filtermail_destination_concurrency_limit=10000
# Do not try to deliver messages for more than 2 days.
maximal_queue_lifetime = 2d
{% if not config.ipv4_relay %}
# DKIM-sign locally generated mail (bounces, DSNs).
# These bypass smtpd, so they need explicit milter configuration.
non_smtpd_milters = unix:opendkim/opendkim.sock
internal_mail_filter_classes = bounce
milter_macro_daemon_name = ORIGINATING
{% endif %}
+6 -22
View File
@@ -17,8 +17,7 @@ smtp inet n - y - - smtpd
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.2 -o smtpd_tls_mandatory_protocols=>=TLSv1.2
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
-o smtpd_relay_restrictions=reject_unauth_destination submission inet n - y - 5000 smtpd
submission inet n - y - {{ config.max_smtp_connections }} smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
-o smtpd_tls_mandatory_protocols=>=TLSv1.3 -o smtpd_tls_mandatory_protocols=>=TLSv1.3
@@ -32,9 +31,9 @@ submission inet n - y - {{ config.max_smtp_connections }
-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 smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -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 - {{ config.max_smtp_connections }} smtpd smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -48,7 +47,7 @@ smtps inet n - y - {{ config.max_smtp_connections }
-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 smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }} -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 }}
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
@@ -76,19 +75,17 @@ lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
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 milter_macro_daemon_name=ORIGINATING
-o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean -o cleanup_service_name=authclean
-o smtpd_relay_restrictions=permit_mynetworks,reject
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
{% endif %}
# 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_relay_restrictions=reject_unauth_destination
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.
@@ -103,16 +100,3 @@ postlog unix-dgram n - n - 1 postlogd
# cannot send unprotected Subject. # cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup
# Reducing `maxproc` here may result in a head of line blocking
# when there are many messages sent to unreachable destinations
# at the same time.
# LMTP clients here talk to filtermail-transport.
# LMTP has no pipelining,
# so while filtermail-transport tries to deliver the message,
# possibly waiting for a long connection timeout
# or talking to a slow server, LMTP client cannot be reused.
lmtp-filtermail unix - - y - 500 lmtp
-o syslog_name=postfix/lmtp-filtermail
-o lmtp_header_checks=
-o lmtp_tls_security_level=none
+11 -16
View File
@@ -57,32 +57,27 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s" dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw)) dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw)) web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
name = f"{dkim_selector}._domainkey.{mail_domain}."
return ( return (
f'{name:<40} 3600 IN TXT "{dkim_value}"', f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
f'{name:<40} 3600 IN TXT "{web_dkim_value}"', f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"',
) )
def get_authoritative_ns(domain): def query_dns(typ, domain):
ns_replies = [ # Get autoritative nameserver from the SOA record.
soa_answers = [
x.split() x.split()
for x in shell( for x in shell(
f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress
).split("\n") ).split("\n")
] ]
filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"] soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"]
if not filtered_replies: if not soa:
return return
return filtered_replies[0][4] ns = soa[0][4]
def query_dns(typ, domain):
ns = get_authoritative_ns(domain)
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
direct_ns = f"@{ns}" if ns else "" res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
res = shell(f"dig {direct_ns} -r -q {domain} -t {typ} +short", print=log_progress)
return next((line for line in res.split("\n") if not line.startswith(";")), "") return next((line for line in res.split("\n") if not line.startswith(";")), "")
@@ -99,7 +94,7 @@ def check_zonefile(zonefile, verbose=True):
if not zf_line.strip() or zf_line.startswith(";"): if not zf_line.strip() or zf_line.startswith(";"):
continue continue
print(f"dns-checking {zf_line!r}") if verbose else log_progress("") print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
zf_domain, _ttl, _in, zf_typ, zf_value = zf_line.split(None, 4) zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".") zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip() zf_value = zf_value.strip()
query_value = query_dns(zf_typ, zf_domain) query_value = query_dns(zf_typ, zf_domain)
+7 -7
View File
@@ -1,8 +1,8 @@
import shlex import shlex
from pyinfra.operations import server from pyinfra.operations import apt, server
from ..basedeploy import Deployer from cmdeploy.basedeploy import Deployer
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500): def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
@@ -18,8 +18,6 @@ def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
"-keyout", str(key_path), "-keyout", str(key_path),
"-out", str(cert_path), "-out", str(cert_path),
"-subj", f"/CN={domain}", "-subj", f"/CN={domain}",
# Mark as end-entity cert so it cannot be used as a CA to sign others.
"-addext", "basicConstraints=critical,CA:FALSE",
"-addext", "extendedKeyUsage=serverAuth,clientAuth", "-addext", "extendedKeyUsage=serverAuth,clientAuth",
"-addext", "-addext",
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}", f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
@@ -34,7 +32,11 @@ class SelfSignedTlsDeployer(Deployer):
self.cert_path = "/etc/ssl/certs/mailserver.pem" self.cert_path = "/etc/ssl/certs/mailserver.pem"
self.key_path = "/etc/ssl/private/mailserver.key" self.key_path = "/etc/ssl/private/mailserver.key"
def install(self):
apt.packages(
name="Install openssl",
packages=["openssl"],
)
def configure(self): def configure(self):
args = openssl_selfsigned_args( args = openssl_selfsigned_args(
@@ -48,5 +50,3 @@ class SelfSignedTlsDeployer(Deployer):
def activate(self): def activate(self):
pass pass
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart={execpath} {config_path} -v --remove ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove
@@ -5,5 +5,5 @@ After=network.target
[Service] [Service]
Type=oneshot Type=oneshot
User=vmail User=vmail
ExecStart={execpath} {config_path} ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
@@ -1,11 +1,12 @@
[Unit] [Unit]
Description=Chatmail HTTP authentication service for dovecot Description=Chatmail dict authentication proxy for dovecot
[Service] [Service]
ExecStart={execpath} {config_path} ExecStart={execpath} /run/doveauth/doveauth.socket {config_path}
Restart=always Restart=always
RestartSec=5 RestartSec=30
User=vmail User=vmail
RuntimeDirectory=doveauth
UMask=0077 UMask=0077
[Install] [Install]
@@ -5,7 +5,7 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
Restart=always Restart=always
ExecStart={bin_path} --realm {mail_domain} --socket /run/chatmail-turn/turn.socket ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
# Create /run/chatmail-turn # Create /run/chatmail-turn
RuntimeDirectory=chatmail-turn RuntimeDirectory=chatmail-turn
-27
View File
@@ -1,27 +0,0 @@
"""Run the lua scripts we ship under lupa, which bundles Lua 5.4 like dovecot."""
import pytest
from chatmaild.tests.plugin import * # noqa: F403
from lupa import lua54
from cmdeploy.basedeploy import get_resource
from cmdeploy.tests.plugin import * # noqa: F403
class Lua:
"""A Lua runtime to load shipped scripts and mocks into."""
def __init__(self):
self.rt = lua54.LuaRuntime(unpack_returned_tuples=True)
self.g = self.rt.globals()
def load(self, path):
self.rt.execute(get_resource(path).read_text())
def table(self, **kwargs):
return self.rt.table(**kwargs)
@pytest.fixture
def lua():
return Lua()
+16 -17
View File
@@ -1,18 +1,17 @@
; Required DNS entries ; Required DNS entries for chatmail servers
zftest.testrun.org. 3600 IN A 135.181.204.127 zftest.testrun.org. A 135.181.204.127
zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1 zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org. zftest.testrun.org. MX 10 zftest.testrun.org.
_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706" _mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
www.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org. www.zftest.testrun.org. CNAME zftest.testrun.org.
opendkim._domainkey.zftest.testrun.org. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s" opendkim._domainkey.zftest.testrun.org. TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoYt82CVUyz2ouaqjX2kB+5J80knAyoOU3MGU5aWppmwUwwTvj/oSTSpkc5JMtVTRmKKr8NUDWAL1Yw7dfGqqPHdHfwwjS3BIvDzYx+hzgtz62RnfNgV+/2MAoNpfX7cAFIHdRzEHNtwugc3RDLquqPoupAE3Y2YRw2T5zG5fILh4vwIcJZL5Uq6B92j8wwJqOex" "33n+vm1NKQ9rxo/UsHAmZlJzpooXcG/4igTBxJyJlamVSRR6N7Nul1v//YJb7J6v2o0iPHW6uE0StzKaPPNC2IVosSRFbD9H2oqppltptFSNPlI0E+t0JBWHem6YK7xcugiO3ImMCaaU8g6Jt/wIDAQAB;s=email;t=s"
; Recommended DNS entries ; Recommended DNS entries
zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all" _submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s" _submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956" _imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable" _imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org. zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org. zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org. _dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org. _adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"
@@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini") inipath = tmp_path.joinpath("chatmail.ini")
main(["init", "--config", str(inipath), maildomain]) main(["init", "--config", str(inipath), maildomain])
config = read_config(inipath) config = read_config(inipath)
assert config.mail_domain_bare == maildomain assert config.mail_domain == maildomain
def test_capabilities(imap): def test_capabilities(imap):
@@ -89,11 +89,12 @@ def test_concurrent_logins_same_account(
assert login_results.get() assert login_results.get()
def test_no_vrfy(cmfactory, chatmail_config, maildomain): def test_no_vrfy(cmfactory, chatmail_config):
ac = cmfactory.get_online_account() ac = cmfactory.get_online_account()
addr = ac.get_config("addr") addr = ac.get_config("addr")
domain = chatmail_config.mail_domain
s = smtplib.SMTP(maildomain) s = smtplib.SMTP(domain)
s.starttls() s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}") s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
@@ -20,17 +20,17 @@ def test_fastcgi_working(maildomain, chatmail_config):
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning") @pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
def test_newemail_configure(maildomain, cmrpc, chatmail_config): 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 = cmrpc.add_account() account_id = rpc.add_account()
if chatmail_config.tls_cert_mode == "self": if chatmail_config.tls_cert_mode == "self":
# deltachat core's rustls rejects self-signed HTTPS certs during # deltachat core's rustls rejects self-signed HTTPS certs during
# set_config_from_qr, so fetch credentials via requests instead # set_config_from_qr, so fetch credentials via requests instead
res = requests.post(f"https://{maildomain}/new", verify=False) res = requests.post(f"https://{maildomain}/new", verify=False)
data = res.json() data = res.json()
cmrpc.add_or_update_transport(account_id, { rpc.add_or_update_transport(account_id, {
"addr": data["email"], "addr": data["email"],
"password": data["password"], "password": data["password"],
"imapServer": maildomain, "imapServer": maildomain,
@@ -38,4 +38,4 @@ def test_newemail_configure(maildomain, cmrpc, chatmail_config):
"certificateChecks": "acceptInvalidCertificates", "certificateChecks": "acceptInvalidCertificates",
}) })
else: else:
cmrpc.add_transport_from_qr(account_id, url) rpc.add_transport_from_qr(account_id, url)
@@ -5,7 +5,6 @@ import subprocess
import time import time
import pytest import pytest
from chatmaild.config import is_valid_ipv4
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
@@ -22,8 +21,6 @@ class TestSSHExecutor:
assert out == out2 assert out == out2
def test_perform_initial(self, sshexec, maildomain): def test_perform_initial(self, sshexec, maildomain):
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is not a domain")
res = sshexec( res = sshexec(
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain) remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
) )
@@ -64,10 +61,8 @@ class TestSSHExecutor:
else: else:
pytest.fail("didn't raise exception") pytest.fail("didn't raise exception")
def test_opendkim_restarted(self, sshexec, maildomain): def test_opendkim_restarted(self, sshexec):
"""check that opendkim is not running for longer than a day.""" """check that opendkim is not running for longer than a day."""
if is_valid_ipv4(maildomain):
pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed")
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
datestring = out.split("=")[1] datestring = out.split("=")[1]
@@ -76,44 +71,6 @@ class TestSSHExecutor:
assert (now - since_date).total_seconds() < 60 * 60 * 51 assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_dovecot_main_process_matches_installed_binary(sshdomain):
sshexec = get_sshexec(sshdomain)
main_pid = int(
sshexec(
call=remote.rshell.shell,
kwargs=dict(
command="timeout 10 systemctl show -p MainPID --value dovecot.service"
),
).strip()
)
assert main_pid != 0, "dovecot.service MainPID is 0 -- service not running?"
exe = sshexec(
call=remote.rshell.shell,
kwargs=dict(command=f"timeout 10 readlink /proc/{main_pid}/exe"),
).strip()
status_text = sshexec(
call=remote.rshell.shell,
kwargs=dict(
command="timeout 10 systemctl show -p StatusText --value dovecot.service"
),
).strip()
installed_version = sshexec(
call=remote.rshell.shell, kwargs=dict(command="timeout 10 dovecot --version")
).strip()
assert not exe.endswith("(deleted)"), (
f"running dovecot binary was deleted (stale after upgrade): {exe}"
)
expected_status_text = f"v{installed_version}"
assert status_text == expected_status_text or status_text.startswith(
f"{expected_status_text} "
), (
f"dovecot status version mismatch: "
f"StatusText={status_text!r}, installed={installed_version!r}"
)
def test_timezone_env(remote): def test_timezone_env(remote):
for line in remote.iter_output("env"): for line in remote.iter_output("env"):
print(line) print(line)
@@ -194,34 +151,6 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
def test_bounces_are_dkim_signed(cmsetup, cmsetup2, maildata, maildomain):
# we send a message to non-existant user and expect a bounce message
# which will only get through if the bounce message was DKIM-signed
if is_valid_ipv4(maildomain):
pytest.skip("DKIM is not configured on IPv4-only relays")
sender = cmsetup2.gen_users(1)[0]
nonexistent = f"nosuchuser_test42@{cmsetup.maildomain}"
msg = maildata(
"encrypted.eml",
from_addr=sender.addr,
to_addr=nonexistent,
).as_string()
sender.smtp.sendmail(sender.addr, [nonexistent], msg)
def bounce_in_inbox():
messages = sender.imap.fetch_all_messages()
for m in messages:
if "mail delivery" in m.lower() or "undelivered" in m.lower():
return m
raise ValueError("bounce not yet in inbox")
bounce = try_n_times(30, bounce_in_inbox)
assert "nosuchuser_test42" in bounce
def try_n_times(n, f): def try_n_times(n, f):
for _ in range(n - 1): for _ in range(n - 1):
try: try:
@@ -254,6 +183,7 @@ def test_rewrite_subject(cmsetup, maildata):
assert "Subject: Unencrypted subject" not in rcvd_msg assert "Subject: Unencrypted subject" not in rcvd_msg
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config): def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
"""Test that the per-account send-mail limit is exceeded.""" """Test that the per-account send-mail limit is exceeded."""
user1, user2 = cmsetup.gen_users(2) user1, user2 = cmsetup.gen_users(2)
@@ -276,6 +206,7 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
pytest.fail("Rate limit was not exceeded") pytest.fail("Rate limit was not exceeded")
@pytest.mark.slow
def test_expunged(remote, chatmail_config): def test_expunged(remote, chatmail_config):
outdated_days = int(chatmail_config.delete_mails_after) + 1 outdated_days = int(chatmail_config.delete_mails_after) + 1
find_cmds = [ find_cmds = [
@@ -314,15 +245,3 @@ def test_deployed_state(remote):
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output() # assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)): for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes." assert git_status[i] == remote_version[i], "You have undeployed changes."
def test_nginx_access_log_only_defined_once(sshdomain):
sshexec = get_sshexec(sshdomain)
conf = sshexec(
call=remote.rshell.shell,
kwargs=dict(command="nginx -T 2>/dev/null"),
)
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
assert len(access_logs) == 1, (
f"expected 1 access_log, found {len(access_logs)}: {access_logs}"
)
@@ -1,12 +1,10 @@
import ipaddress import ipaddress
import json
import re import re
import time import time
import imap_tools import imap_tools
import pytest import pytest
import requests import requests
from chatmaild.tests.test_appversions import check_appversions
from cmdeploy.cmdeploy import get_sshexec from cmdeploy.cmdeploy import get_sshexec
from cmdeploy.remote import rshell from cmdeploy.remote import rshell
@@ -17,7 +15,7 @@ def imap_mailbox(cmfactory, ssl_context):
(ac1,) = cmfactory.get_online_accounts(1) (ac1,) = cmfactory.get_online_accounts(1)
user = ac1.get_config("addr") user = ac1.get_config("addr")
password = ac1.get_config("mail_pw") password = ac1.get_config("mail_pw")
host = user.split("@")[1].strip("[").strip("]") host = user.split("@")[1]
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(user, password) mailbox.login(user, password)
mailbox.dc_ac = ac1 mailbox.dc_ac = ac1
@@ -53,17 +51,6 @@ class TestMetadataTokens:
assert res == b"1111 2222" assert res == b"1111 2222"
assert b"Getmetadata completed" in client.readline() assert b"Getmetadata completed" in client.readline()
def test_get_appversions(self, imap_mailbox):
"get app version information shipped with the relay"
client = imap_mailbox.client
client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n')
res = client.readline()
assert res[:1] == b"*"
res = client.readline().strip().rstrip(b")")
# the served value is a single line and passes the shipped file's schema
check_appversions(json.loads(res))
assert b"Getmetadata completed" in client.readline()
class TestEndToEndDeltaChat: class TestEndToEndDeltaChat:
"Tests that use Delta Chat accounts on the chat mail instance." "Tests that use Delta Chat accounts on the chat mail instance."
@@ -191,7 +178,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2.wait_for_incoming_msg() user2.wait_for_incoming_msg()
addr = user2.get_config("addr") addr = user2.get_config("addr")
host = addr.split("@")[1].strip("[").strip("]") host = addr.split("@")[1]
pw = user2.get_config("mail_pw") pw = user2.get_config("mail_pw")
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context) mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
mailbox.login(addr, pw) mailbox.login(addr, pw)
+45 -78
View File
@@ -9,25 +9,32 @@ import time
from pathlib import Path from pathlib import Path
import pytest import pytest
from chatmaild.config import is_valid_ipv4, read_config from chatmaild.config import read_config
conftestdir = Path(__file__).parent
def format_mail_domain(raw_domain: str) -> str: def pytest_addoption(parser):
if is_valid_ipv4(raw_domain): parser.addoption(
return f"[{raw_domain}]" "--slow", action="store_true", default=False, help="also run slow tests"
return raw_domain )
def pytest_configure(config): def pytest_configure(config):
config._benchresults = {} config._benchresults = {}
config.addinivalue_line(
"markers", "slow: mark test to require --slow option to run"
)
def pytest_runtest_setup(item):
markers = list(item.iter_markers(name="slow"))
if markers:
if not item.config.getoption("--slow"):
pytest.skip("skipping slow test, use --slow to run")
def _get_chatmail_config(): def _get_chatmail_config():
inipath = os.environ.get("CHATMAIL_INI")
if inipath:
path = Path(inipath).resolve()
return read_config(path), path
current = Path().resolve() current = Path().resolve()
while 1: while 1:
path = current.joinpath("chatmail.ini").resolve() path = current.joinpath("chatmail.ini").resolve()
@@ -50,7 +57,7 @@ def chatmail_config(pytestconfig):
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def maildomain(chatmail_config): def maildomain(chatmail_config):
return chatmail_config.mail_domain_bare return chatmail_config.mail_domain
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -205,7 +212,7 @@ class ImapConn:
status, res = self.conn.select() status, res = self.conn.select()
if int(res[0]) == 0: if int(res[0]) == 0:
raise ValueError("no messages in imap folder") raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(BODY.PEEK[])") status, results = self.conn.fetch("1:*", "(RFC822)")
assert status == "OK" assert status == "OK"
return results return results
@@ -308,8 +315,7 @@ class ChatmailACFactory:
def _make_transport(self, domain): def _make_transport(self, domain):
"""Build a transport config dict for the given domain.""" """Build a transport config dict for the given domain."""
domain_deliverable = format_mail_domain(domain) addr, password = self.gencreds(domain)
addr, password = self.gencreds(domain_deliverable)
transport = { transport = {
"addr": addr, "addr": addr,
"password": password, "password": password,
@@ -318,7 +324,7 @@ class ChatmailACFactory:
"imapServer": domain, "imapServer": domain,
"smtpServer": domain, "smtpServer": domain,
} }
if domain.startswith("_") or is_valid_ipv4(domain): if self.chatmail_config.tls_cert_mode == "self":
transport["certificateChecks"] = "acceptInvalidCertificates" transport["certificateChecks"] = "acceptInvalidCertificates"
return transport return transport
@@ -333,28 +339,14 @@ class ChatmailACFactory:
accounts = [] accounts = []
for _ in range(num): for _ in range(num):
account = self.dc.add_account() account = self.dc.add_account()
domain_deliverable = format_mail_domain(domain) future = account.add_or_update_transport.future(
addr, password = self.gencreds(domain_deliverable) self._make_transport(domain)
if is_valid_ipv4(domain): )
# Use DCLOGIN scheme with explicit server hosts,
# matching how madmail presents its addresses to users.
qr = (
f"dclogin:{addr}"
f"?p={password}&v=1"
f"&ih={domain}&ip=993&is=ssl"
f"&sh={domain}&sp=465&ss=ssl"
f"&ic=3"
)
future = account.add_transport_from_qr.future(qr)
else:
future = account.add_or_update_transport.future(
self._make_transport(domain)
)
futures.append(future) futures.append(future)
# ensure messages stay in INBOX so that they can be # ensure messages stay in INBOX so that they can be
# concurrently fetched via extra IMAP connections during tests # concurrently fetched via extra IMAP connections during tests
account.set_config("bcc_self", "1") account.set_config("delete_server_after", "10")
accounts.append(account) accounts.append(account)
for future in futures: for future in futures:
@@ -371,12 +363,8 @@ class ChatmailACFactory:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def cmrpc(tmp_path_factory): def rpc(tmp_path_factory):
"""Start a deltachat-rpc-server process for the test session. """Start a deltachat-rpc-server process for the test session."""
Not named "rpc": the deltachat-rpc-client pytest plugin registers a
function-scoped fixture under that name and would shadow this one.
"""
# NB: accounts_dir must NOT already exist as directory -- # NB: accounts_dir must NOT already exist as directory --
# core-rust only creates accounts.toml if the dir doesn't exist yet. # core-rust only creates accounts.toml if the dir doesn't exist yet.
@@ -388,10 +376,10 @@ def cmrpc(tmp_path_factory):
@pytest.fixture @pytest.fixture
def cmfactory(cmrpc, gencreds, maildomain, chatmail_config): def cmfactory(rpc, gencreds, maildomain, chatmail_config):
"""Return a ChatmailACFactory for creating online Delta Chat accounts.""" """Return a ChatmailACFactory for creating online Delta Chat accounts."""
return ChatmailACFactory( return ChatmailACFactory(
rpc=cmrpc, rpc=rpc,
maildomain=maildomain, maildomain=maildomain,
gencreds=gencreds, gencreds=gencreds,
chatmail_config=chatmail_config, chatmail_config=chatmail_config,
@@ -400,50 +388,34 @@ def cmfactory(cmrpc, gencreds, maildomain, chatmail_config):
@pytest.fixture @pytest.fixture
def remote(sshdomain): def remote(sshdomain):
r = Remote(sshdomain) return Remote(sshdomain)
yield r
r.close()
class Remote: class Remote:
def __init__(self, sshdomain): def __init__(self, sshdomain):
self.sshdomain = sshdomain self.sshdomain = sshdomain
self._procs = []
def iter_output(self, logcmd="", ready=None): def iter_output(self, logcmd="", ready=None):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = "journalctl -f" if not logcmd else logcmd
print(self.sshdomain) print(self.sshdomain)
if self.sshdomain in ("@local", "localhost"): match self.sshdomain:
command = [] case "@local": command = []
else: case "localhost": command = []
command = ["ssh", f"root@{self.sshdomain}"] case _: command = ["ssh", f"root@{self.sshdomain}"]
[command.append(arg) for arg in getjournal.split()] [command.append(arg) for arg in getjournal.split()]
popen = subprocess.Popen( self.popen = subprocess.Popen(
command, command,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
) )
self._procs.append(popen) while 1:
try: line = self.popen.stdout.readline()
while 1: res = line.decode().strip().lower()
line = popen.stdout.readline() if not res:
res = line.decode().strip().lower() break
if not res: if ready is not None:
break ready()
if ready is not None: ready = None
ready() yield res
ready = None
yield res
finally:
popen.terminate()
popen.wait()
def close(self):
while self._procs:
proc = self._procs.pop()
proc.kill()
proc.wait()
@pytest.fixture @pytest.fixture
@@ -463,11 +435,6 @@ def cmsetup(maildomain, gencreds, ssl_context):
return CMSetup(maildomain, gencreds, ssl_context) return CMSetup(maildomain, gencreds, ssl_context)
@pytest.fixture
def cmsetup2(maildomain2, gencreds, ssl_context):
return CMSetup(maildomain2, gencreds, ssl_context)
class CMSetup: class CMSetup:
def __init__(self, maildomain, gencreds, ssl_context): def __init__(self, maildomain, gencreds, ssl_context):
self.maildomain = maildomain self.maildomain = maildomain
@@ -478,7 +445,7 @@ class CMSetup:
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(format_mail_domain(self.maildomain)) addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password, self.ssl_context) user = CMUser(self.maildomain, addr, password, self.ssl_context)
assert user.smtp assert user.smtp
users.append(user) users.append(user)
+2
View File
@@ -0,0 +1,2 @@
[pytest]
addopts = -vrsx --strict-markers
-216
View File
@@ -1,216 +0,0 @@
"""Test auth.lua script against mocked dovecot auth API."""
import jinja2
import pytest
from chatmaild.doveauth import encrypt_password, verify_password
from cmdeploy.basedeploy import get_resource
USER1 = "user12345@chat.example.org"
USER2 = "newuser12@chat.example.org"
OK, UNKNOWN, MISMATCH, INTERNAL = 1, -2, -3, -4
DOVECOT_MOCKS = """
create_status = 200
dovecot = {
auth = {
PASSDB_RESULT_OK = OK,
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
PASSDB_RESULT_INTERNAL_FAILURE = INTERNAL,
USERDB_RESULT_OK = OK,
USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
},
http = {
client = function(options)
client_options = options
return {request = function(_, options)
create_request = options
return {
set_payload = function(_, payload) create_payload = payload end,
submit = function()
return {status = function() return create_status end}
end,
}
end}
end,
},
}
"""
def load_authlua(lua, config):
lua.g.OK, lua.g.UNKNOWN = OK, UNKNOWN
lua.g.MISMATCH, lua.g.INTERNAL = MISMATCH, INTERNAL
lua.rt.execute(DOVECOT_MOCKS)
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
lua.rt.execute(template.render(config=config))
assert lua.g.script_init() == 0
return lua
@pytest.fixture
def authlua(lua, example_config):
return load_authlua(lua, example_config)
@pytest.fixture
def request_for(lua):
def request_for(addr):
def password_verify(_self, hashed, plain):
return 1 if verify_password(hashed, plain) else 0
return lua.table(user=addr, password_verify=password_verify)
return request_for
@pytest.fixture
def create_user(example_config):
def create_user(addr, password):
example_config.get_user(addr).set_password(encrypt_password(password))
return create_user
@pytest.fixture
def write_password_file(example_config):
def write_password_file(addr, content):
maildir = example_config.mailboxes_dir / addr
maildir.mkdir(parents=True, exist_ok=True)
maildir.joinpath("password").write_text(content)
return write_password_file
def test_http_client_uses_dovecot_setting_names(authlua):
"""dovecot's lua http binding silently ignores keys it does not know."""
assert dict(authlua.g.client_options) == {
"request_timeout_msecs": 5000,
"max_attempts": 1,
}
def test_existing_address_correct_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert fields["uid"] == fields["gid"] == "vmail"
assert fields["home"].endswith(USER1)
assert authlua.g.create_payload is None
def test_existing_address_wrong_password(authlua, request_for, create_user):
create_user(USER1, "correctgoose")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "wronghorse")
assert res == MISMATCH
def test_foreign_domain_is_refused_without_calling_out(
authlua, request_for, create_user
):
create_user("user12345@evil.example.org", "correctgoose")
request = request_for("user12345@evil.example.org")
res, _ = authlua.g.auth_password_verify(request, "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_name_shorter_than_the_domain_is_refused(authlua, request_for):
for name in ("x", "", "chat.example.org"):
res, _ = authlua.g.auth_password_verify(request_for(name), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(name))[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_slash_in_username_is_refused(authlua, request_for):
request = request_for("../../etc/shadow@chat.example.org")
res, _ = authlua.g.auth_password_verify(request, "somepassword")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
assert authlua.g.create_payload is None
def test_localpart_policy_is_left_to_doveauth(authlua, request_for):
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for("@chat.example.org"), "somepw")
assert res == UNKNOWN
assert authlua.g.create_payload == "@chat.example.org\tsomepw"
def test_unknown_address_is_created_via_endpoint(authlua, request_for):
res, fields = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == OK
assert fields["home"].endswith(USER2)
assert authlua.g.create_payload == f"{USER2}\tbrandnewpass"
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
# doveauth refusing is the user's problem, doveauth failing is ours
@pytest.mark.parametrize("status", [400, 403, 404])
def test_creation_refused_by_doveauth_is_user_unknown(authlua, request_for, status):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == UNKNOWN
# 9003 is dovecot's own CONNECT_FAILED, what a stopped doveauth actually yields
@pytest.mark.parametrize("status", [500, 502, 9003, 9005])
def test_creation_that_doveauth_could_not_answer_is_internal_failure(
authlua, request_for, status
):
authlua.g.create_status = status
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
assert res == INTERNAL
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):
request = request_for(USER1)
res, _ = authlua.g.auth_userdb_lookup(request)
assert res == UNKNOWN
# a userdb lookup must never create anything
assert authlua.g.create_payload is None
create_user(USER1, "correctgoose")
res, fields = authlua.g.auth_userdb_lookup(request)
assert res == OK
assert fields["home"].endswith(USER1)
assert fields["uid"] == fields["gid"] == "vmail"
def test_empty_password_file_is_unknown(authlua, request_for, write_password_file):
write_password_file(USER1, "")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
write_password_file(USER1, "\n")
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_password_file_format_checks(authlua, request_for, write_password_file):
write_password_file(USER1, encrypt_password("correctgoose") + "\n")
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == OK
passhash = encrypt_password("correctgoose")
write_password_file(USER1, passhash + "\ntrailing junk")
authlua.g.create_status = 403
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
assert res == UNKNOWN
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
def test_ipv4_relay_uses_bracketed_domain(lua, ipv4_config, request_for):
# mail_domain is "[1.3.3.7]" here, and is_ours must not read it as a pattern
authlua = load_authlua(lua, ipv4_config)
addr = f"user12345@{ipv4_config.mail_domain}"
ipv4_config.get_user(addr).set_password(encrypt_password("correctgoose"))
res, fields = authlua.g.auth_password_verify(request_for(addr), "correctgoose")
assert res == OK
assert fields["home"].endswith(addr)
assert authlua.g.auth_userdb_lookup(request_for(addr))[0] == OK
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
@@ -1,118 +0,0 @@
from unittest.mock import MagicMock, patch
from cmdeploy.basedeploy import Deployer
def test_put_file_restart_and_reload():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res):
deployer.put_file("foo.conf", "/etc/foo.conf")
assert deployer.need_restart is True
assert deployer.daemon_reload is False
deployer = Deployer()
deployer.put_file("test.service", "/etc/systemd/system/test.service")
assert deployer.need_restart is True
assert deployer.daemon_reload is True
def test_remove_file():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
with patch("cmdeploy.basedeploy.files.file", return_value=mock_res) as mock_file:
deployer.remove_file("/etc/foo.conf")
mock_file.assert_called_once_with(
name="Remove /etc/foo.conf", path="/etc/foo.conf", present=False
)
assert deployer.need_restart is True
def test_ensure_systemd_unit():
deployer = Deployer()
mock_res = MagicMock()
mock_res.changed = True
# Plain service file
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit("iroh-relay.service")
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/iroh-relay.service"
)
assert deployer.need_restart is True
assert deployer.daemon_reload is True
deployer = Deployer()
# Template (.j2) dispatches to put_template and strips .j2 suffix
with patch("cmdeploy.basedeploy.files.template", return_value=mock_res) as mock_tpl:
deployer.ensure_systemd_unit(
"filtermail/chatmaild.service.j2",
bin_path="/usr/local/bin/filtermail",
)
assert (
mock_tpl.call_args.kwargs["dest"] == "/etc/systemd/system/chatmaild.service"
)
deployer = Deployer()
# Explicit dest_name override
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
deployer.ensure_systemd_unit(
"acmetool/acmetool-reconcile.timer",
dest_name="acmetool-reconcile.timer",
)
assert (
mock_put.call_args.kwargs["dest"]
== "/etc/systemd/system/acmetool-reconcile.timer"
)
def test_ensure_service():
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("nginx.service")
mock_svc.assert_called_once_with(
name="Start and enable nginx.service",
service="nginx.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
# daemon_reload is cleared to avoid multiple systemctl daemon-reload calls
# need_restart is kept to ensure all subsequent services also restart
assert deployer.need_restart is True
assert deployer.daemon_reload is False
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Stopping suppresses restarted even when need_restart is True
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service(
"mta-sts-daemon.service",
running=False,
enabled=False,
)
assert mock_svc.call_args.kwargs["restarted"] is False
assert deployer.need_restart is True
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
# Multiple calls: daemon_reload resets after first, need_restart persists
deployer = Deployer()
deployer.need_restart = True
deployer.daemon_reload = True
deployer.ensure_service("chatmaild.service")
deployer.ensure_service("chatmaild-metadata.service")
second_call = mock_svc.call_args_list[1]
assert second_call.kwargs["restarted"] is True
assert second_call.kwargs["daemon_reload"] is False
+4 -16
View File
@@ -23,30 +23,18 @@ class TestCmdline:
run = parser.parse_args(["run"]) run = parser.parse_args(["run"])
assert init and run assert init and run
def test_init_not_overwrite(self, capsys, tmp_path, monkeypatch): def test_init_not_overwrite(self, capsys):
monkeypatch.delenv("CHATMAIL_INI", raising=False) assert main(["init", "chat.example.org"]) == 0
inipath = tmp_path / "chatmail.ini"
args = ["init", "--config", str(inipath), "chat.example.org"]
assert main(args) == 0
capsys.readouterr() capsys.readouterr()
assert main(args) == 1 assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "path exists" in out.lower() assert "path exists" in out.lower()
args.insert(1, "--force") assert main(["init", "chat.example.org", "--force"]) == 0
assert main(args) == 0
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "deleting config file" in out.lower() assert "deleting config file" in out.lower()
def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch):
monkeypatch.delenv("CHATMAIL_INI", raising=False)
inipath = tmp_path / "chatmail.ini"
assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0
assert main(["dns", "--config", str(inipath)]) == 0
out, err = capsys.readouterr()
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
def test_www_folder(example_config, tmp_path): def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
+15 -62
View File
@@ -3,8 +3,7 @@ from copy import deepcopy
import pytest import pytest
from cmdeploy import remote from cmdeploy import remote
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records from cmdeploy.dns import check_full_zone, check_initial_remote_data
from cmdeploy.remote.rdns import get_authoritative_ns
@pytest.fixture @pytest.fixture
@@ -15,15 +14,11 @@ def mockdns_base(monkeypatch):
if command.startswith("dig"): if command.startswith("dig"):
if command == "dig": if command == "dig":
return "." return "."
if "with.public.soa" in command and "NS" in command: if "SOA" in command:
return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
if "with.hidden.soa" in command and "NS" in command:
return ( return (
"domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n" "delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
"domain.with.hidden.soa. 2137 IN NS ns2.desec.org." " 2025102800 14400 1800 604800 3600"
) )
if "NS" in command:
return "delta.chat. 21600 IN NS ns1.first-ns.de."
command_chunks = command.split() command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6] domain, typ = command_chunks[4], command_chunks[6]
try: try:
@@ -130,60 +125,18 @@ class TestPerformInitialChecks:
assert not l assert not l
@pytest.mark.parametrize(
("domain", "ns"),
[
("domain.with.public.soa", "ns1.first-ns.de."),
("domain.with.hidden.soa", "ns1.desec.io."),
],
)
def test_get_authoritative_ns(domain, ns, mockdns):
assert get_authoritative_ns(domain) == ns
def test_parse_zone_records():
text = """
; This is a comment
some.domain. 3600 IN A 1.1.1.1
; Another comment
www.some.domain. 3600 IN CNAME some.domain.
; Multi-word rdata
some.domain. 3600 IN MX 10 mail.some.domain.
; DKIM record (single line, multi-word TXT rdata)
dkim._domainkey.some.domain. 3600 IN TXT "v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"
; Another TXT record
_dmarc.some.domain. 3600 IN TXT "v=DMARC1;p=reject"
"""
records = list(parse_zone_records(text))
assert records == [
("some.domain", "3600", "A", "1.1.1.1"),
("www.some.domain", "3600", "CNAME", "some.domain."),
("some.domain", "3600", "MX", "10 mail.some.domain."),
(
"dkim._domainkey.some.domain",
"3600",
"TXT",
'"v=DKIM1;k=rsa;p=MIIBIjANBgkqhkiG" "9w0BAQEFAAOCAQ8AMIIBCgKCAQEA"',
),
("_dmarc.some.domain", "3600", "TXT", '"v=DMARC1;p=reject"'),
]
def test_parse_zone_records_invalid_line():
text = "invalid line"
with pytest.raises(ValueError, match="Bad zone record line"):
list(parse_zone_records(text))
def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False): def parse_zonefile_into_dict(zonefile, mockdns_base, only_required=False):
if only_required: for zf_line in zonefile.split("\n"):
zonefile = zonefile.split("; Recommended")[0] if zf_line.startswith("#"):
for name, ttl, rtype, rdata in parse_zone_records(zonefile): if "Recommended" in zf_line and only_required:
mockdns_base.setdefault(rtype, {})[name] = rdata return
continue
if not zf_line.strip():
continue
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
mockdns_base.setdefault(zf_typ, {})[zf_domain] = zf_value
class MockSSHExec: class MockSSHExec:
@@ -1,285 +0,0 @@
from contextlib import nullcontext
from types import SimpleNamespace
import pytest
from pyinfra.facts.deb import DebPackages
from pyinfra.facts.server import Command
from cmdeploy.dovecot import deployer as dovecot_deployer
def _fact_name(key):
if isinstance(key, tuple):
return f"{key[0].__name__}{key[1:]!r}"
return key.__name__
def make_host(*fact_pairs):
"""Build a mock host; get_fact() dispatches to the provided facts mapping.
Args:
*fact_pairs: (fact_class, value) to match any call of that fact, or
((fact_class, *args), value) to match one specific call. Needed
for Command, which install() and check_restart() invoke with
different scripts; a bare Command entry would serve both.
Returns:
SimpleNamespace with get_fact that raises a clear error if an
unregistered fact is requested.
"""
facts = dict(fact_pairs)
def get_fact(cls, *args):
for key in ((cls, *args), cls):
if key in facts:
return facts[key]
registered = ", ".join(_fact_name(k) for k in facts)
raise LookupError(
f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}"
)
return SimpleNamespace(get_fact=get_fact)
@pytest.fixture
def deployer():
return dovecot_deployer.DovecotDeployer(
SimpleNamespace(mail_domain="chat.example.org"),
disable_mail=False,
)
@pytest.fixture
def patch_blocked(monkeypatch):
monkeypatch.setattr(dovecot_deployer, "blocked_service_startup", nullcontext)
@pytest.fixture
def mock_files_put(monkeypatch):
monkeypatch.setattr(
dovecot_deployer.files,
"put",
lambda **kwargs: SimpleNamespace(changed=False),
)
@pytest.fixture
def track_shell(monkeypatch):
calls = []
monkeypatch.setattr(
dovecot_deployer.server,
"shell",
lambda **kwargs: calls.append(kwargs) or SimpleNamespace(changed=False),
)
return calls
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
# what dpkg reports after installing our deb: epoch + the +debNu1 suffix
# that chatmail/dovecot CI stamps via dch before building
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
downloads = []
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host((DebPackages, {"dovecot-core": [epoch_version]})),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
assert changed is False, "should not flag changed when version already installed"
assert downloads == [], "should not download when version already installed"
@pytest.mark.parametrize("deb_release", [12, 13])
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
monkeypatch, deb_release, arch
):
downloads = []
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host((DebPackages, {})),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deb, changed = dovecot_deployer._download_dovecot_package(
"core", arch, deb_release=deb_release
)
stamped = dovecot_deployer._stamped_version(deb_release)
expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb"
# path uses the stamped version, and deb filenames never carry the epoch
assert changed is True, "should flag changed when package not yet installed"
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}"
assert len(downloads) == 1, "files.download should be called exactly once"
# the checksum is the security boundary: verify the right table row is used
assert (
downloads[0]["sha256sum"]
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
), "must pass the sha256 matching (arch, release, package)"
assert f"deb{deb_release}u1" in downloads[0]["src"], (
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
)
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(
dovecot_deployer.DebPackages,
{
"dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"],
"dovecot-auth-lua": [f"1:{dovecot_deployer._stamped_version(12)}"],
},
),
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
)
downloads = []
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: downloads.append(kwargs),
)
deployer.install()
assert downloads == [], "should not download when all packages epoch-matched"
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
assert deployer.need_restart is False, "need_restart should be False when nothing changed"
def test_install_unsupported_arch_raises(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "riscv64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
)
# we never fall back to the pinned distro package
with pytest.raises(ValueError, match="no dovecot build for dovecot-core"):
deployer.install()
assert track_shell == [], "should not run apt-get for unsupported arch"
def test_install_runs_dpkg_when_packages_need_download(
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.DebPackages, {}),
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
),
)
monkeypatch.setattr(
dovecot_deployer,
"_pick_url",
lambda primary, fallback: primary,
)
monkeypatch.setattr(
dovecot_deployer.files,
"download",
lambda **kwargs: SimpleNamespace(changed=True),
)
deployer.install()
assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
cmds = track_shell[0]["commands"]
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
assert "apt-get install -y" in cmds[0]
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
assert "--allow-downgrades" in cmds[0]
assert ".deb" in cmds[0]
assert deployer.need_restart is True, "need_restart should be True after dpkg install"
def test_pick_url_falls_back_on_primary_error(monkeypatch):
def raise_error(req, timeout):
raise OSError("connection timeout")
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
monkeypatch.setattr(
dovecot_deployer,
"host",
make_host(
(dovecot_deployer.Arch, "x86_64"),
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
),
)
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
deployer.install()
@pytest.mark.parametrize(
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
)
def test_parse_version_id_raises_without_version_id(version_line):
with pytest.raises(ValueError, match="cannot determine Debian release"):
dovecot_deployer._parse_version_id(version_line)
@pytest.mark.parametrize("deb_release", [12, 13])
def test_parse_version_id(deb_release):
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
assert parsed == deb_release
def test_dovecot_sha256_covers_all_packages_per_release():
"""Every release in the table needs all four packages on both arches."""
table = dovecot_deployer.DOVECOT_SHA256
expected = {
(arch, pkg)
for arch in ("amd64", "arm64")
for pkg in ("core", "imapd", "lmtpd", "auth-lua")
}
for release in {r for _, r, _ in table}:
got = {(arch, pkg) for arch, r, pkg in table if r == release}
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
+3 -2
View File
@@ -1,10 +1,11 @@
from pathlib import Path import importlib.resources
from cmdeploy.www import build_webpages from cmdeploy.www import build_webpages
def test_build_webpages(tmp_path, make_config): def test_build_webpages(tmp_path, make_config):
src_dir = (Path(__file__).resolve() / "../../../../../www/src").resolve() pkgroot = importlib.resources.files("cmdeploy")
src_dir = pkgroot.joinpath("../../../www/src").resolve()
assert src_dir.exists(), src_dir assert src_dir.exists(), src_dir
config = make_config("chat.example.org") config = make_config("chat.example.org")
build_dir = tmp_path.joinpath("build") build_dir = tmp_path.joinpath("build")
@@ -1,85 +0,0 @@
"""Test the push_notification.lua we ship, against a mocked dovecot mail API."""
import textwrap
import pytest
USER1 = "user12345@chat.example.org"
USER2 = "user67890@chat.example.org"
DOVECOT_MOCKS = textwrap.dedent("""
function make_user(username)
local function mailbox(_, name)
record("mailbox " .. name)
return {
sync = function() record("sync") end,
metadata_set = function(_, k, v)
record("metadata_set " .. k .. "=" .. v)
end,
free = function() record("free") end,
}
end
return {username = username, mailbox = mailbox}
end
""")
@pytest.fixture
def script(lua):
lua.rt.execute(DOVECOT_MOCKS)
lua.load("dovecot/push_notification.lua")
return lua
@pytest.fixture
def deliver(script):
def deliver(recipient, sender):
calls = []
script.g.record = calls.append
user = script.g.make_user(recipient)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
event = script.table(mailbox="INBOX", from_address=sender)
script.g.dovecot_lua_notify_event_message_new(ctx, event)
script.g.dovecot_lua_notify_end_txn(ctx, True)
return calls
return deliver
def test_entry_points_have_the_names_dovecot_calls(script):
assert script.g.dovecot_lua_notify_begin_txn is not None
assert script.g.dovecot_lua_notify_event_message_new is not None
assert script.g.dovecot_lua_notify_end_txn is not None
def test_begin_txn_returns_the_user_as_event_context(script):
user = script.g.make_user(USER1)
ctx = script.g.dovecot_lua_notify_begin_txn(user)
ctx.marker = "seen"
assert user.marker == "seen"
def test_incoming_message_notifies_metadata_server(deliver):
assert deliver(USER1, sender=USER2) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
def test_own_message_does_not_wake_the_sending_device(deliver):
assert deliver(USER1, sender=USER1) == [
"mailbox INBOX",
"sync",
"free",
]
def test_message_without_from_address_is_notified(deliver):
assert deliver(USER1, sender=None) == [
"mailbox INBOX",
"sync",
"metadata_set /private/messagenew=",
"free",
]
@@ -1,7 +1,4 @@
# Managed by cmdeploy # Managed by cmdeploy: disable IPv6 in unbound.
server: server:
{% if disable_ipv6 %}
interface: 127.0.0.1 interface: 127.0.0.1
do-ip6: no do-ip6: no
{% endif %}
cache-max-negative-ttl: 0
+4 -2
View File
@@ -1,4 +1,5 @@
import hashlib import hashlib
import importlib.resources
import re import re
import time import time
import traceback import traceback
@@ -36,7 +37,7 @@ def prepare_template(source):
def get_paths(config) -> (Path, Path, Path): def get_paths(config) -> (Path, Path, Path):
reporoot = (Path(__file__).resolve() / "../../../../").resolve() reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
www_path = Path(config.www_folder) www_path = Path(config.www_folder)
# if www_folder was not set, use default directory # if www_folder was not set, use default directory
if config.www_folder == "": if config.www_folder == "":
@@ -132,7 +133,8 @@ def find_merge_conflict(src_dir) -> Path:
def main(): def main():
reporoot = (Path(__file__).resolve() / "../../../../").resolve() path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()
inipath = reporoot.joinpath("chatmail.ini") inipath = reporoot.joinpath("chatmail.ini")
config = read_config(inipath) config = read_config(inipath)
config.webdev = True config.webdev = True
+3 -5
View File
@@ -4,14 +4,12 @@
You can use the `make` command and `make html` to build web pages. You can use the `make` command and `make html` to build web pages.
You need a Python environment with `sphinx` and other You need a Python environment where the following install was excuted:
dependencies, you can create it by running `scripts/initenv.sh`
from the repository root. pip install furo sphinx-autobuild
To develop/change documentation, you can then do: To develop/change documentation, you can then do:
. venv/bin/activate
cd doc
make auto make auto
A page will open at https://127.0.0.1:8000/ serving the docs and it will A page will open at https://127.0.0.1:8000/ serving the docs and it will
-14
View File
@@ -3,8 +3,6 @@
# For the full list of built-in configuration values, see the documentation: # For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html # https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
@@ -18,24 +16,12 @@ author = 'chatmail collective'
extensions = [ extensions = [
#'sphinx.ext.autodoc', #'sphinx.ext.autodoc',
#'sphinx.ext.viewdoc', #'sphinx.ext.viewdoc',
'sphinx.ext.extlinks',
'sphinxcontrib.mermaid', 'sphinxcontrib.mermaid',
] ]
templates_path = ['_templates'] templates_path = ['_templates']
exclude_patterns = [] exclude_patterns = []
# Repository links go through the roles below.
# CI sets DOC_GITHUB_REF to the head commit of a pull request,
gh_ref = os.environ.get("DOC_GITHUB_REF", "main")
extlinks = {
"repofile": (f"https://github.com/chatmail/relay/blob/{gh_ref}/%s", "%s"),
"repodir": (f"https://github.com/chatmail/relay/tree/{gh_ref}/%s", "%s"),
}
# Warn about repository links spelled out in full instead of using the roles.
extlinks_detect_hardcoded_links = True
# -- Options for HTML output ------------------------------------------------- # -- Options for HTML output -------------------------------------------------

Some files were not shown because too many files have changed in this diff Show More