mirror of
https://github.com/chatmail/relay.git
synced 2026-05-13 09:24:43 +00:00
Compare commits
1 Commits
j4n/docker
...
fix-metada
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84e731034a |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
- name: download filtermail
|
- name: download filtermail
|
||||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/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.5.1/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
|
||||||
|
|||||||
125
.github/workflows/docker-deploy.yaml
vendored
125
.github/workflows/docker-deploy.yaml
vendored
@@ -1,125 +0,0 @@
|
|||||||
name: Docker deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
inputs:
|
|
||||||
staging_host:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: 'SSH hostname (e.g. staging2.testrun.org)'
|
|
||||||
mail_domain:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: 'MAIL_DOMAIN for docker compose'
|
|
||||||
zone_file:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: 'Default zone file basename (e.g. staging.testrun.org-default.zone)'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy-docker:
|
|
||||||
name: Docker deploy on ${{ inputs.staging_host }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
environment:
|
|
||||||
name: ${{ inputs.staging_host }}
|
|
||||||
url: https://${{ inputs.staging_host }}/
|
|
||||||
concurrency: ${{ inputs.staging_host }}
|
|
||||||
env:
|
|
||||||
VPS: root@${{ inputs.staging_host }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
- name: Setup SSH
|
|
||||||
run: |
|
|
||||||
mkdir ~/.ssh
|
|
||||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan ${{ inputs.staging_host }} > ~/.ssh/known_hosts
|
|
||||||
# Reuse TCP connection for all subsequent ssh/scp calls
|
|
||||||
echo -e "Host ${{ inputs.staging_host }}\n ControlMaster auto\n ControlPath ~/.ssh/ctrl-%r@%h:%p\n ControlPersist 10m" >> ~/.ssh/config
|
|
||||||
|
|
||||||
- name: stop bare services, install Docker, prepare mounts
|
|
||||||
run: |
|
|
||||||
ssh $VPS bash -s <<'EOF'
|
|
||||||
systemctl stop postfix dovecot nginx opendkim unbound filtermail doveauth chatmail-metadata iroh-relay mtail fcgiwrap acmetool 2>/dev/null || true
|
|
||||||
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc && chmod a+r /etc/apt/keyrings/docker.asc
|
|
||||||
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
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
|
||||||
mkdir -p /srv/chatmail/certs /srv/chatmail/dkim
|
|
||||||
cp -a /var/lib/acme/. /srv/chatmail/certs/ || true
|
|
||||||
cp -a /etc/dkimkeys/. /srv/chatmail/dkim/ || true
|
|
||||||
cp /etc/chatmail/chatmail.ini /srv/chatmail/chatmail.ini
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: deploy with Docker
|
|
||||||
run: |
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
||||||
GHCR_IMAGE="ghcr.io/chatmail/docker:sha-${SHORT_SHA}"
|
|
||||||
rsync -avz --exclude='.git' --exclude='venv' --exclude='__pycache__' ./ $VPS:/srv/chatmail/relay/
|
|
||||||
echo "${{ secrets.GITHUB_TOKEN }}" | ssh $VPS "docker login ghcr.io -u ${{ github.actor }} --password-stdin && \
|
|
||||||
docker pull ${GHCR_IMAGE} && \
|
|
||||||
cd /srv/chatmail/relay && CHATMAIL_IMAGE=${GHCR_IMAGE} MAIL_DOMAIN=${{ inputs.mail_domain }} docker compose -f docker/docker-compose.yaml -f docker/docker-compose.ci.yaml up -d"
|
|
||||||
|
|
||||||
- name: wait for container healthy
|
|
||||||
run: |
|
|
||||||
ssh $VPS '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 $VPS '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
|
|
||||||
ssh $VPS bash -s <<'EOF'
|
|
||||||
echo "--- failed units ---"
|
|
||||||
docker exec chatmail systemctl --failed --no-pager || true
|
|
||||||
echo "--- service logs ---"
|
|
||||||
docker exec chatmail journalctl -u dovecot -u postfix -u nginx -u unbound --no-pager -n 50 || true
|
|
||||||
echo "--- listening ports ---"
|
|
||||||
docker exec chatmail ss -tlnp || true
|
|
||||||
echo "--- chatmail.ini ---"
|
|
||||||
docker exec chatmail cat /etc/chatmail/chatmail.ini || true
|
|
||||||
EOF
|
|
||||||
exit 1
|
|
||||||
|
|
||||||
- name: show container state
|
|
||||||
run: |
|
|
||||||
ssh $VPS bash -s <<'EOF'
|
|
||||||
echo "--- listening ports ---"
|
|
||||||
docker exec chatmail ss -tlnp
|
|
||||||
echo "--- chatmail.ini ---"
|
|
||||||
docker exec chatmail cat /etc/chatmail/chatmail.ini
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Docker integration tests
|
|
||||||
run: ssh $VPS 'docker exec chatmail cmdeploy test --slow --ssh-host @local'
|
|
||||||
|
|
||||||
- name: Docker DNS
|
|
||||||
run: |
|
|
||||||
git checkout .github/workflows/${{ inputs.zone_file }}
|
|
||||||
ssh $VPS bash -s <<'EOF'
|
|
||||||
docker exec chatmail chown opendkim:opendkim -R /etc/dkimkeys
|
|
||||||
docker exec chatmail cmdeploy dns --ssh-host @local --zonefile /opt/chatmail/staging.zone --verbose
|
|
||||||
docker cp chatmail:/opt/chatmail/staging.zone /tmp/staging.zone
|
|
||||||
EOF
|
|
||||||
scp $VPS:/tmp/staging.zone staging-generated.zone
|
|
||||||
cat staging-generated.zone >> .github/workflows/${{ inputs.zone_file }}
|
|
||||||
cat .github/workflows/${{ inputs.zone_file }}
|
|
||||||
scp .github/workflows/${{ inputs.zone_file }} root@ns.testrun.org:/etc/nsd/${{ inputs.staging_host }}.zone
|
|
||||||
ssh root@ns.testrun.org "nsd-checkzone ${{ inputs.staging_host }} /etc/nsd/${{ inputs.staging_host }}.zone && systemctl reload nsd"
|
|
||||||
|
|
||||||
- name: Docker final DNS check
|
|
||||||
run: ssh $VPS 'docker exec chatmail cmdeploy dns -v --ssh-host @local'
|
|
||||||
24
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
24
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- j4n/docker-pr
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'scripts/**'
|
- 'scripts/**'
|
||||||
@@ -13,11 +12,6 @@ on:
|
|||||||
- 'LICENSE'
|
- 'LICENSE'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger-docker-build:
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: ./.github/workflows/trigger-docker-build.yaml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: deploy on staging-ipv4.testrun.org, and run tests
|
name: deploy on staging-ipv4.testrun.org, and run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -28,8 +22,6 @@ jobs:
|
|||||||
concurrency: staging-ipv4.testrun.org
|
concurrency: staging-ipv4.testrun.org
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
submodules: true
|
|
||||||
|
|
||||||
- name: prepare SSH
|
- name: prepare SSH
|
||||||
run: |
|
run: |
|
||||||
@@ -71,13 +63,13 @@ jobs:
|
|||||||
# download acme & dkim state from ns.testrun.org
|
# 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 -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
|
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
|
||||||
# restore acme & dkim state to staging-ipv4.testrun.org
|
# restore acme & dkim state to staging2.testrun.org
|
||||||
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
|
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
|
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
|
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
|
- name: run deploy-chatmail offline tests
|
||||||
run: pytest --pyargs cmdeploy
|
run: pytest --pyargs cmdeploy
|
||||||
|
|
||||||
- name: setup dependencies
|
- name: setup dependencies
|
||||||
run: |
|
run: |
|
||||||
@@ -97,6 +89,7 @@ jobs:
|
|||||||
|
|
||||||
- name: set DNS entries
|
- name: set DNS entries
|
||||||
run: |
|
run: |
|
||||||
|
ssh root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||||
ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns --zonefile staging-generated.zone --ssh-host localhost"
|
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
|
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
|
cat .github/workflows/staging-ipv4.testrun.org-default.zone
|
||||||
@@ -110,12 +103,3 @@ jobs:
|
|||||||
- name: cmdeploy dns
|
- name: cmdeploy dns
|
||||||
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
|
run: ssh root@staging-ipv4.testrun.org "cd relay && scripts/cmdeploy dns -v --ssh-host localhost"
|
||||||
|
|
||||||
deploy-docker:
|
|
||||||
needs: [deploy, trigger-docker-build]
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: ./.github/workflows/docker-deploy.yaml
|
|
||||||
with:
|
|
||||||
staging_host: staging-ipv4.testrun.org
|
|
||||||
mail_domain: staging-ipv4.testrun.org
|
|
||||||
zone_file: staging-ipv4.testrun.org-default.zone
|
|
||||||
secrets: inherit
|
|
||||||
|
|||||||
16
.github/workflows/test-and-deploy.yaml
vendored
16
.github/workflows/test-and-deploy.yaml
vendored
@@ -4,7 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- j4n/docker-pr
|
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'scripts/**'
|
- 'scripts/**'
|
||||||
@@ -13,11 +12,6 @@ on:
|
|||||||
- 'LICENSE'
|
- 'LICENSE'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
trigger-docker-build:
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: ./.github/workflows/trigger-docker-build.yaml
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: deploy on staging2.testrun.org, and run tests
|
name: deploy on staging2.testrun.org, and run tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -88,6 +82,7 @@ jobs:
|
|||||||
|
|
||||||
- name: set DNS entries
|
- name: set DNS entries
|
||||||
run: |
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||||
cmdeploy dns --zonefile staging-generated.zone --verbose
|
cmdeploy dns --zonefile staging-generated.zone --verbose
|
||||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
||||||
cat .github/workflows/staging.testrun.org-default.zone
|
cat .github/workflows/staging.testrun.org-default.zone
|
||||||
@@ -101,12 +96,3 @@ jobs:
|
|||||||
- name: cmdeploy dns
|
- name: cmdeploy dns
|
||||||
run: cmdeploy dns -v
|
run: cmdeploy dns -v
|
||||||
|
|
||||||
deploy-docker:
|
|
||||||
needs: [deploy, trigger-docker-build]
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
uses: ./.github/workflows/docker-deploy.yaml
|
|
||||||
with:
|
|
||||||
staging_host: staging2.testrun.org
|
|
||||||
mail_domain: staging2.testrun.org
|
|
||||||
zone_file: staging.testrun.org-default.zone
|
|
||||||
secrets: inherit
|
|
||||||
|
|||||||
24
.github/workflows/trigger-docker-build.yaml
vendored
24
.github/workflows/trigger-docker-build.yaml
vendored
@@ -1,24 +0,0 @@
|
|||||||
name: Trigger Docker image build
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_call:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
trigger-docker-build:
|
|
||||||
name: Trigger Docker image build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
|
|
||||||
script: |
|
|
||||||
await github.rest.repos.createDispatchEvent({
|
|
||||||
owner: 'chatmail',
|
|
||||||
repo: 'docker',
|
|
||||||
event_type: 'relay-updated',
|
|
||||||
client_payload: {
|
|
||||||
relay_ref: context.ref,
|
|
||||||
relay_sha: context.sha,
|
|
||||||
relay_sha_short: context.sha.slice(0, 7)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -6,7 +6,10 @@ 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",
|
||||||
"requests",
|
"requests",
|
||||||
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
||||||
@@ -21,6 +24,7 @@ where = ['src']
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
doveauth = "chatmaild.doveauth:main"
|
doveauth = "chatmaild.doveauth:main"
|
||||||
chatmail-metadata = "chatmaild.metadata:main"
|
chatmail-metadata = "chatmaild.metadata:main"
|
||||||
|
chatmail-metrics = "chatmaild.metrics:main"
|
||||||
chatmail-expire = "chatmaild.expire:main"
|
chatmail-expire = "chatmaild.expire:main"
|
||||||
chatmail-fsreport = "chatmaild.fsreport:main"
|
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||||
lastlogin = "chatmaild.lastlogin:main"
|
lastlogin = "chatmaild.lastlogin:main"
|
||||||
@@ -67,7 +71,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}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ class Config:
|
|||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.get("filtermail_smtp_port_incoming", "10081")
|
params.get("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
self.filtermail_http_port = int(params.get("filtermail_http_port", "10082"))
|
|
||||||
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params.get("postfix_reinject_port_incoming", "10026")
|
params.get("postfix_reinject_port_incoming", "10026")
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import filelock
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import crypt_r
|
import crypt_r
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -16,7 +13,6 @@ from .dictproxy import DictProxy
|
|||||||
from .migrate_db import migrate_from_db_to_maildir
|
from .migrate_db import migrate_from_db_to_maildir
|
||||||
|
|
||||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||||
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def encrypt_password(password: str):
|
def encrypt_password(password: str):
|
||||||
@@ -56,10 +52,6 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not VALID_LOCALPART_RE.match(localpart):
|
|
||||||
logging.warning("localpart %r contains invalid characters", localpart)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -148,13 +140,8 @@ class AuthDictProxy(DictProxy):
|
|||||||
if not is_allowed_to_create(self.config, addr, cleartext_password):
|
if not is_allowed_to_create(self.config, addr, cleartext_password):
|
||||||
return
|
return
|
||||||
|
|
||||||
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
|
user.set_password(encrypt_password(cleartext_password))
|
||||||
with lock:
|
print(f"Created address: {addr}", file=sys.stderr)
|
||||||
userdata = user.get_userdb_dict()
|
|
||||||
if userdata:
|
|
||||||
return userdata
|
|
||||||
user.set_password(encrypt_password(cleartext_password))
|
|
||||||
print(f"Created address: {addr}", file=sys.stderr)
|
|
||||||
return user.get_userdb_dict()
|
return user.get_userdb_dict()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,9 @@ to show storage summaries only for first 1000 mailboxes
|
|||||||
|
|
||||||
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
|
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
|
||||||
|
|
||||||
to write Prometheus textfile for node_exporter
|
|
||||||
|
|
||||||
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/
|
|
||||||
|
|
||||||
writes to /var/lib/prometheus/node-exporter/fsreport.prom
|
|
||||||
|
|
||||||
to also write legacy metrics.py style output (default: /var/www/html/metrics):
|
|
||||||
|
|
||||||
python -m chatmaild.fsreport --textfile /var/lib/prometheus/node-exporter/ --legacy-metrics
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -59,19 +48,7 @@ class Report:
|
|||||||
self.num_ci_logins = self.num_all_logins = 0
|
self.num_ci_logins = self.num_all_logins = 0
|
||||||
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
|
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
|
||||||
|
|
||||||
KiB = 1024
|
self.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
|
||||||
MiB = 1024 * KiB
|
|
||||||
self.message_size_thresholds = (
|
|
||||||
0,
|
|
||||||
100 * KiB,
|
|
||||||
MiB // 2,
|
|
||||||
1 * MiB,
|
|
||||||
2 * MiB,
|
|
||||||
5 * MiB,
|
|
||||||
10 * MiB,
|
|
||||||
)
|
|
||||||
self.message_buckets = {x: 0 for x in self.message_size_thresholds}
|
|
||||||
self.message_count_buckets = {x: 0 for x in self.message_size_thresholds}
|
|
||||||
|
|
||||||
def process_mailbox_stat(self, mailbox):
|
def process_mailbox_stat(self, mailbox):
|
||||||
# categorize login times
|
# categorize login times
|
||||||
@@ -91,10 +68,9 @@ class Report:
|
|||||||
for size in self.message_buckets:
|
for size in self.message_buckets:
|
||||||
for msg in mailbox.messages:
|
for msg in mailbox.messages:
|
||||||
if msg.size >= size:
|
if msg.size >= size:
|
||||||
if self.mdir and f"/{self.mdir}/" not in msg.path:
|
if self.mdir and not msg.relpath.startswith(self.mdir):
|
||||||
continue
|
continue
|
||||||
self.message_buckets[size] += msg.size
|
self.message_buckets[size] += msg.size
|
||||||
self.message_count_buckets[size] += 1
|
|
||||||
|
|
||||||
self.size_messages += sum(entry.size for entry in mailbox.messages)
|
self.size_messages += sum(entry.size for entry in mailbox.messages)
|
||||||
self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
|
self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
|
||||||
@@ -117,10 +93,9 @@ class Report:
|
|||||||
|
|
||||||
pref = f"[{self.mdir}] " if self.mdir else ""
|
pref = f"[{self.mdir}] " if self.mdir else ""
|
||||||
for minsize, sumsize in self.message_buckets.items():
|
for minsize, sumsize in self.message_buckets.items():
|
||||||
count = self.message_count_buckets[minsize]
|
|
||||||
percent = (sumsize / all_messages * 100) if all_messages else 0
|
percent = (sumsize / all_messages * 100) if all_messages else 0
|
||||||
print(
|
print(
|
||||||
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%), {count} msgs"
|
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
|
||||||
)
|
)
|
||||||
|
|
||||||
user_logins = self.num_all_logins - self.num_ci_logins
|
user_logins = self.num_all_logins - self.num_ci_logins
|
||||||
@@ -136,75 +111,6 @@ class Report:
|
|||||||
for days, active in self.login_buckets.items():
|
for days, active in self.login_buckets.items():
|
||||||
print(f"last {days:3} days: {HSize(active)} {p(active)}")
|
print(f"last {days:3} days: {HSize(active)} {p(active)}")
|
||||||
|
|
||||||
def _write_atomic(self, filepath, content):
|
|
||||||
"""Atomically write content to filepath via tmp+rename."""
|
|
||||||
dirpath = os.path.dirname(os.path.abspath(filepath))
|
|
||||||
fd, tmppath = tempfile.mkstemp(dir=dirpath, suffix=".tmp")
|
|
||||||
try:
|
|
||||||
with os.fdopen(fd, "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
os.chmod(tmppath, 0o644)
|
|
||||||
os.rename(tmppath, filepath)
|
|
||||||
except BaseException:
|
|
||||||
try:
|
|
||||||
os.unlink(tmppath)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
def dump_textfile(self, filepath):
|
|
||||||
"""Dump metrics in Prometheus exposition format."""
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
lines.append("# HELP chatmail_storage_bytes Mailbox storage in bytes.")
|
|
||||||
lines.append("# TYPE chatmail_storage_bytes gauge")
|
|
||||||
lines.append(f'chatmail_storage_bytes{{kind="messages"}} {self.size_messages}')
|
|
||||||
lines.append(f'chatmail_storage_bytes{{kind="extra"}} {self.size_extra}')
|
|
||||||
total = self.size_extra + self.size_messages
|
|
||||||
lines.append(f'chatmail_storage_bytes{{kind="total"}} {total}')
|
|
||||||
|
|
||||||
lines.append("# HELP chatmail_messages_bytes Sum of msg bytes >= threshold.")
|
|
||||||
lines.append("# TYPE chatmail_messages_bytes gauge")
|
|
||||||
for minsize, sumsize in self.message_buckets.items():
|
|
||||||
lines.append(f'chatmail_messages_bytes{{min_size="{minsize}"}} {sumsize}')
|
|
||||||
|
|
||||||
lines.append("# HELP chatmail_messages_count Number of msgs >= size threshold.")
|
|
||||||
lines.append("# TYPE chatmail_messages_count gauge")
|
|
||||||
for minsize, count in self.message_count_buckets.items():
|
|
||||||
lines.append(f'chatmail_messages_count{{min_size="{minsize}"}} {count}')
|
|
||||||
|
|
||||||
lines.append("# HELP chatmail_accounts Number of accounts.")
|
|
||||||
lines.append("# TYPE chatmail_accounts gauge")
|
|
||||||
user_logins = self.num_all_logins - self.num_ci_logins
|
|
||||||
lines.append(f'chatmail_accounts{{kind="all"}} {self.num_all_logins}')
|
|
||||||
lines.append(f'chatmail_accounts{{kind="ci"}} {self.num_ci_logins}')
|
|
||||||
lines.append(f'chatmail_accounts{{kind="user"}} {user_logins}')
|
|
||||||
|
|
||||||
lines.append(
|
|
||||||
"# HELP chatmail_accounts_active Non-CI accounts active within N days."
|
|
||||||
)
|
|
||||||
lines.append("# TYPE chatmail_accounts_active gauge")
|
|
||||||
for days, active in self.login_buckets.items():
|
|
||||||
lines.append(f'chatmail_accounts_active{{days="{days}"}} {active}')
|
|
||||||
|
|
||||||
self._write_atomic(filepath, "\n".join(lines) + "\n")
|
|
||||||
|
|
||||||
def dump_compat_textfile(self, filepath):
|
|
||||||
"""Dump legacy metrics.py style metrics."""
|
|
||||||
user_logins = self.num_all_logins - self.num_ci_logins
|
|
||||||
lines = [
|
|
||||||
"# HELP total number of accounts",
|
|
||||||
"# TYPE accounts gauge",
|
|
||||||
f"accounts {self.num_all_logins}",
|
|
||||||
"# HELP number of CI accounts",
|
|
||||||
"# TYPE ci_accounts gauge",
|
|
||||||
f"ci_accounts {self.num_ci_logins}",
|
|
||||||
"# HELP number of non-CI accounts",
|
|
||||||
"# TYPE nonci_accounts gauge",
|
|
||||||
f"nonci_accounts {user_logins}",
|
|
||||||
]
|
|
||||||
self._write_atomic(filepath, "\n".join(lines) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def main(args=None):
|
def main(args=None):
|
||||||
"""Report about filesystem storage usage of all mailboxes and messages"""
|
"""Report about filesystem storage usage of all mailboxes and messages"""
|
||||||
@@ -221,21 +127,19 @@ def main(args=None):
|
|||||||
"--days",
|
"--days",
|
||||||
default=0,
|
default=0,
|
||||||
action="store",
|
action="store",
|
||||||
help="assume date to be DAYS older than now",
|
help="assume date to be days older than now",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--min-login-age",
|
"--min-login-age",
|
||||||
default=0,
|
default=0,
|
||||||
metavar="DAYS",
|
|
||||||
dest="min_login_age",
|
dest="min_login_age",
|
||||||
action="store",
|
action="store",
|
||||||
help="only sum up message size if last login is at least DAYS days old",
|
help="only sum up message size if last login is at least min-login-age days old",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--mdir",
|
"--mdir",
|
||||||
metavar="{cur,new,tmp}",
|
|
||||||
action="store",
|
action="store",
|
||||||
help="only consider messages in specified Maildir subdirectory for summary",
|
help="only consider 'cur' or 'new' or 'tmp' messages for summary",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -244,21 +148,6 @@ def main(args=None):
|
|||||||
action="store",
|
action="store",
|
||||||
help="maximum number of mailboxes to iterate on",
|
help="maximum number of mailboxes to iterate on",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--textfile",
|
|
||||||
metavar="PATH",
|
|
||||||
default=None,
|
|
||||||
help="write Prometheus textfile to PATH (directory or file); "
|
|
||||||
"if PATH is a directory, writes 'fsreport.prom' inside it",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--legacy-metrics",
|
|
||||||
metavar="FILENAME",
|
|
||||||
nargs="?",
|
|
||||||
const="/var/www/html/metrics",
|
|
||||||
default=None,
|
|
||||||
help="write legacy metrics.py textfile (default: /var/www/html/metrics)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
@@ -272,15 +161,7 @@ def main(args=None):
|
|||||||
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
|
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
|
||||||
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
|
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
|
||||||
rep.process_mailbox_stat(mbox)
|
rep.process_mailbox_stat(mbox)
|
||||||
if args.textfile:
|
rep.dump_summary()
|
||||||
path = args.textfile
|
|
||||||
if os.path.isdir(path):
|
|
||||||
path = os.path.join(path, "fsreport.prom")
|
|
||||||
rep.dump_textfile(path)
|
|
||||||
if args.legacy_metrics:
|
|
||||||
rep.dump_compat_textfile(args.legacy_metrics)
|
|
||||||
if not args.textfile and not args.legacy_metrics:
|
|
||||||
rep.dump_summary()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -101,11 +101,7 @@ class MetadataDictProxy(DictProxy):
|
|||||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
||||||
return f"O{self.iroh_relay}\n"
|
return f"O{self.iroh_relay}\n"
|
||||||
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
|
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
|
||||||
try:
|
res = turn_credentials()
|
||||||
res = turn_credentials()
|
|
||||||
except Exception:
|
|
||||||
logging.exception("failed to get TURN credentials")
|
|
||||||
return "N\n"
|
|
||||||
port = 3478
|
port = 3478
|
||||||
return f"O{self.turn_hostname}:{port}:{res}\n"
|
return f"O{self.turn_hostname}:{port}:{res}\n"
|
||||||
|
|
||||||
|
|||||||
32
chatmaild/src/chatmaild/metrics.py
Normal file
32
chatmaild/src/chatmaild/metrics.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main(vmail_dir=None):
|
||||||
|
if vmail_dir is None:
|
||||||
|
vmail_dir = sys.argv[1]
|
||||||
|
|
||||||
|
accounts = 0
|
||||||
|
ci_accounts = 0
|
||||||
|
|
||||||
|
for path in Path(vmail_dir).iterdir():
|
||||||
|
if not path.joinpath("cur").is_dir():
|
||||||
|
continue
|
||||||
|
accounts += 1
|
||||||
|
if path.name[:3] in ("ci-", "ac_"):
|
||||||
|
ci_accounts += 1
|
||||||
|
|
||||||
|
print("# HELP total number of accounts")
|
||||||
|
print("# TYPE accounts gauge")
|
||||||
|
print(f"accounts {accounts}")
|
||||||
|
print("# HELP number of CI accounts")
|
||||||
|
print("# TYPE ci_accounts gauge")
|
||||||
|
print(f"ci_accounts {ci_accounts}")
|
||||||
|
print("# HELP number of non-CI accounts")
|
||||||
|
print("# TYPE nonci_accounts gauge")
|
||||||
|
print(f"nonci_accounts {accounts - ci_accounts}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"""CGI script for creating new accounts."""
|
"""CGI script for creating new accounts."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@@ -15,9 +16,7 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
|||||||
|
|
||||||
|
|
||||||
def create_newemail_dict(config: Config):
|
def create_newemail_dict(config: Config):
|
||||||
user = "".join(
|
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length))
|
||||||
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
|
||||||
)
|
|
||||||
password = "".join(
|
password = "".join(
|
||||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||||
for _ in range(config.password_min_length + 3)
|
for _ in range(config.password_min_length + 3)
|
||||||
|
|||||||
@@ -120,60 +120,6 @@ def test_handle_dovecot_protocol_iterate(gencreds, example_config):
|
|||||||
assert not lines[2]
|
assert not lines[2]
|
||||||
|
|
||||||
|
|
||||||
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"})
|
|
||||||
password = "zequ0Aimuchoodaechik"
|
|
||||||
domain = config.mail_domain
|
|
||||||
|
|
||||||
# valid localparts
|
|
||||||
assert is_allowed_to_create(config, f"abc123@{domain}", password)
|
|
||||||
assert is_allowed_to_create(config, f"a.b-c_d@{domain}", password)
|
|
||||||
|
|
||||||
# uppercase rejected
|
|
||||||
assert not is_allowed_to_create(config, f"Abc123@{domain}", password)
|
|
||||||
assert not is_allowed_to_create(config, f"ABCDEFG@{domain}", password)
|
|
||||||
|
|
||||||
# spaces and special chars rejected
|
|
||||||
assert not is_allowed_to_create(config, f"a b cde@{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"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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_creation_same_account(dictproxy):
|
|
||||||
"""Test that concurrent creation of the same account doesn't corrupt password."""
|
|
||||||
addr = "racetest1@chat.example.org"
|
|
||||||
password = "zequ0Aimuchoodaechik"
|
|
||||||
num_threads = 10
|
|
||||||
results = queue.Queue()
|
|
||||||
|
|
||||||
def create():
|
|
||||||
try:
|
|
||||||
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"])
|
|
||||||
|
|
||||||
# all threads must see the same password hash
|
|
||||||
assert len(passwords_seen) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
|
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
|
||||||
num_threads = 50
|
num_threads = 50
|
||||||
req_per_thread = 5
|
req_per_thread = 5
|
||||||
|
|||||||
@@ -112,43 +112,6 @@ def test_report(mbox1, example_config):
|
|||||||
report_main(args)
|
report_main(args)
|
||||||
|
|
||||||
|
|
||||||
def test_report_mdir_filters_by_path(mbox1, example_config):
|
|
||||||
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
|
|
||||||
from chatmaild.fsreport import Report
|
|
||||||
|
|
||||||
now = datetime.utcnow().timestamp()
|
|
||||||
|
|
||||||
# Set password mtime to old enough so min_login_age check passes
|
|
||||||
password = Path(mbox1.basedir).joinpath("password")
|
|
||||||
old_time = now - 86400 * 10 # 10 days ago
|
|
||||||
os.utime(password, (old_time, old_time))
|
|
||||||
|
|
||||||
# Reload mailbox with updated mtime
|
|
||||||
from chatmaild.expire import MailboxStat
|
|
||||||
|
|
||||||
mbox = MailboxStat(mbox1.basedir)
|
|
||||||
|
|
||||||
# Report without mdir — should count all messages
|
|
||||||
rep_all = Report(now=now, min_login_age=1, mdir=None)
|
|
||||||
rep_all.process_mailbox_stat(mbox)
|
|
||||||
total_all = rep_all.message_buckets[0]
|
|
||||||
|
|
||||||
# Report with mdir='cur' — should only count cur/ messages
|
|
||||||
rep_cur = Report(now=now, min_login_age=1, mdir="cur")
|
|
||||||
rep_cur.process_mailbox_stat(mbox)
|
|
||||||
total_cur = rep_cur.message_buckets[0]
|
|
||||||
|
|
||||||
# Report with mdir='new' — should only count new/ messages
|
|
||||||
rep_new = Report(now=now, min_login_age=1, mdir="new")
|
|
||||||
rep_new.process_mailbox_stat(mbox)
|
|
||||||
total_new = rep_new.message_buckets[0]
|
|
||||||
|
|
||||||
# cur has 500-byte msg, new has 600-byte msg (from fill_mbox)
|
|
||||||
assert total_cur == 500
|
|
||||||
assert total_new == 600
|
|
||||||
assert total_all == 500 + 600
|
|
||||||
|
|
||||||
|
|
||||||
def test_expiry_cli_basic(example_config, mbox1):
|
def test_expiry_cli_basic(example_config, mbox1):
|
||||||
args = (str(example_config._inipath),)
|
args = (str(example_config._inipath),)
|
||||||
expiry_main(args)
|
expiry_main(args)
|
||||||
|
|||||||
@@ -314,51 +314,6 @@ def test_persistent_queue_items(tmp_path, testaddr, token):
|
|||||||
assert not queue_item < item2 and not item2 < queue_item
|
assert not queue_item < item2 and not item2 < queue_item
|
||||||
|
|
||||||
|
|
||||||
def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
|
|
||||||
"""Test that turn_credentials() failure returns N\\n instead of crashing."""
|
|
||||||
import chatmaild.metadata
|
|
||||||
|
|
||||||
dictproxy = MetadataDictProxy(
|
|
||||||
notifier=notifier,
|
|
||||||
metadata=metadata,
|
|
||||||
turn_hostname="turn.example.org",
|
|
||||||
)
|
|
||||||
|
|
||||||
def mock_turn_credentials():
|
|
||||||
raise ConnectionRefusedError("socket not available")
|
|
||||||
|
|
||||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
|
|
||||||
|
|
||||||
transactions = {}
|
|
||||||
res = dictproxy.handle_dovecot_request(
|
|
||||||
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
|
|
||||||
"\tuser@example.org",
|
|
||||||
transactions,
|
|
||||||
)
|
|
||||||
assert res == "N\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_credentials_success(notifier, metadata, monkeypatch):
|
|
||||||
"""Test that valid turn_credentials() returns TURN URI."""
|
|
||||||
import chatmaild.metadata
|
|
||||||
|
|
||||||
dictproxy = MetadataDictProxy(
|
|
||||||
notifier=notifier,
|
|
||||||
metadata=metadata,
|
|
||||||
turn_hostname="turn.example.org",
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
|
|
||||||
|
|
||||||
transactions = {}
|
|
||||||
res = dictproxy.handle_dovecot_request(
|
|
||||||
"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn"
|
|
||||||
"\tuser@example.org",
|
|
||||||
transactions,
|
|
||||||
)
|
|
||||||
assert res == "Oturn.example.org:3478:user:pass\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_iroh_relay(dictproxy):
|
def test_iroh_relay(dictproxy):
|
||||||
rfile = io.BytesIO(
|
rfile = io.BytesIO(
|
||||||
b"\n".join(
|
b"\n".join(
|
||||||
|
|||||||
24
chatmaild/src/chatmaild/tests/test_metrics.py
Normal file
24
chatmaild/src/chatmaild/tests/test_metrics.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from chatmaild.metrics import main
|
||||||
|
|
||||||
|
|
||||||
|
def test_main(tmp_path, capsys):
|
||||||
|
paths = []
|
||||||
|
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
|
||||||
|
p = tmp_path.joinpath(x)
|
||||||
|
p.mkdir()
|
||||||
|
p.joinpath("cur").mkdir()
|
||||||
|
paths.append(p)
|
||||||
|
|
||||||
|
tmp_path.joinpath("nomailbox").mkdir()
|
||||||
|
|
||||||
|
main(tmp_path)
|
||||||
|
out, _ = capsys.readouterr()
|
||||||
|
d = {}
|
||||||
|
for line in out.split("\n"):
|
||||||
|
if line.strip() and not line.startswith("#"):
|
||||||
|
name, num = line.split()
|
||||||
|
d[name] = int(num)
|
||||||
|
|
||||||
|
assert d["accounts"] == 4
|
||||||
|
assert d["ci_accounts"] == 3
|
||||||
|
assert d["nonci_accounts"] == 1
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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"
|
|
||||||
@@ -4,7 +4,6 @@ import socket
|
|||||||
|
|
||||||
def turn_credentials() -> str:
|
def turn_credentials() -> str:
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
||||||
client_socket.settimeout(5)
|
|
||||||
client_socket.connect("/run/chatmail-turn/turn.socket")
|
client_socket.connect("/run/chatmail-turn/turn.socket")
|
||||||
with client_socket.makefile("rb") as file:
|
with client_socket.makefile("rb") as file:
|
||||||
return file.readline().decode("utf-8").strip()
|
return file.readline().decode("utf-8").strip()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ dependencies = [
|
|||||||
"pillow",
|
"pillow",
|
||||||
"qrcode",
|
"qrcode",
|
||||||
"markdown",
|
"markdown",
|
||||||
|
"pytest",
|
||||||
"setuptools>=68",
|
"setuptools>=68",
|
||||||
"termcolor",
|
"termcolor",
|
||||||
"build",
|
"build",
|
||||||
@@ -20,7 +21,6 @@ dependencies = [
|
|||||||
"execnet",
|
"execnet",
|
||||||
"imap_tools",
|
"imap_tools",
|
||||||
"deltachat-rpc-client",
|
"deltachat-rpc-client",
|
||||||
"deltachat-rpc-server",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class AcmetoolDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
files.template(
|
files.template(
|
||||||
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
|
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
|
||||||
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
|
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
|
||||||
user="root",
|
user="root",
|
||||||
group="root",
|
group="root",
|
||||||
mode="644",
|
mode="644",
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import importlib.resources
|
import importlib.resources
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
from contextlib import contextmanager
|
|
||||||
|
|
||||||
from pyinfra import host
|
|
||||||
from pyinfra.facts.server import Command
|
|
||||||
from pyinfra.operations import files, server, systemd
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
|
|
||||||
@@ -13,39 +10,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
|
|
||||||
def blocked_service_startup():
|
|
||||||
"""Prevent services from auto-starting during package installation.
|
|
||||||
|
|
||||||
Installs a ``/usr/sbin/policy-rc.d`` that exits 101, blocking any
|
|
||||||
service from being started by the package manager. This avoids bind
|
|
||||||
conflicts and CPU/RAM spikes during initial setup. The file is removed
|
|
||||||
when the context exits.
|
|
||||||
"""
|
|
||||||
# For documentation about policy-rc.d, see:
|
|
||||||
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
|
|
||||||
files.put(
|
|
||||||
src=get_resource("policy-rc.d"),
|
|
||||||
dest="/usr/sbin/policy-rc.d",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
)
|
|
||||||
yield
|
|
||||||
files.file("/usr/sbin/policy-rc.d", present=False)
|
|
||||||
|
|
||||||
|
|
||||||
def get_resource(arg, pkg=__package__):
|
def get_resource(arg, pkg=__package__):
|
||||||
return importlib.resources.files(pkg).joinpath(arg)
|
return importlib.resources.files(pkg).joinpath(arg)
|
||||||
|
|
||||||
|
|||||||
32
cmdeploy/src/cmdeploy/chatmail.zone.j2
Normal file
32
cmdeploy/src/cmdeploy/chatmail.zone.j2
Normal 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 }}.
|
||||||
@@ -108,7 +108,10 @@ def run_cmd(args, out):
|
|||||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||||
|
|
||||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||||
if ssh_host == "localhost":
|
if ssh_host in ["localhost", "@docker"]:
|
||||||
|
if ssh_host == "@docker":
|
||||||
|
env["CHATMAIL_NOPORTCHECK"] = "True"
|
||||||
|
env["CHATMAIL_NOSYSCTL"] = "True"
|
||||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||||
|
|
||||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||||
@@ -116,18 +119,24 @@ def run_cmd(args, out):
|
|||||||
return 1
|
return 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
out.check_call(cmd, env=env)
|
retcode = out.check_call(cmd, env=env)
|
||||||
if args.website_only:
|
if args.website_only:
|
||||||
out.green("Website deployment completed.")
|
if retcode == 0:
|
||||||
|
out.green("Website deployment completed.")
|
||||||
|
else:
|
||||||
|
out.red("Website deployment failed.")
|
||||||
|
elif retcode == 0:
|
||||||
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
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")
|
||||||
|
retcode = 0
|
||||||
else:
|
else:
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
out.red("Deploy failed")
|
||||||
return 0
|
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
out.red("Deploy failed")
|
out.red("Deploy failed")
|
||||||
return 1
|
retcode = 1
|
||||||
|
return retcode
|
||||||
|
|
||||||
|
|
||||||
def dns_cmd_options(parser):
|
def dns_cmd_options(parser):
|
||||||
@@ -207,7 +216,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
|
||||||
|
|
||||||
@@ -314,7 +322,7 @@ def add_ssh_host_option(parser):
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--ssh-host",
|
"--ssh-host",
|
||||||
dest="ssh_host",
|
dest="ssh_host",
|
||||||
help="Run commands on 'localhost' or on a specific SSH host "
|
help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
|
||||||
"instead of chatmail.ini's mail_domain.",
|
"instead of chatmail.ini's mail_domain.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,7 +384,9 @@ def get_parser():
|
|||||||
|
|
||||||
def get_sshexec(ssh_host: str, verbose=True):
|
def get_sshexec(ssh_host: str, verbose=True):
|
||||||
if ssh_host in ["localhost", "@local"]:
|
if ssh_host in ["localhost", "@local"]:
|
||||||
return LocalExec(verbose)
|
return LocalExec(verbose, docker=False)
|
||||||
|
elif ssh_host == "@docker":
|
||||||
|
return LocalExec(verbose, docker=True)
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f"[ssh] login to {ssh_host}")
|
print(f"[ssh] login to {ssh_host}")
|
||||||
return SSHExec(ssh_host, verbose=verbose)
|
return SSHExec(ssh_host, verbose=verbose)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
Chat Mail pyinfra deploy.
|
Chat Mail pyinfra deploy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from io import BytesIO, StringIO
|
from io import StringIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
@@ -23,11 +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,
|
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
|
||||||
@@ -124,6 +123,7 @@ def _install_remote_venv_with_chatmaild() -> None:
|
|||||||
|
|
||||||
def _configure_remote_venv_with_chatmaild(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_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")
|
root_owned = dict(user="root", group="root", mode="644")
|
||||||
|
|
||||||
@@ -134,13 +134,16 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
|
|||||||
**root_owned,
|
**root_owned,
|
||||||
)
|
)
|
||||||
|
|
||||||
files.file(
|
files.template(
|
||||||
path="/etc/cron.d/chatmail-metrics",
|
src=get_resource("metrics.cron.j2"),
|
||||||
present=False,
|
dest="/etc/cron.d/chatmail-metrics",
|
||||||
)
|
user="root",
|
||||||
files.file(
|
group="root",
|
||||||
path="/var/www/html/metrics",
|
mode="644",
|
||||||
present=False,
|
config={
|
||||||
|
"mailboxes_dir": config.mailboxes_dir,
|
||||||
|
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,16 +153,33 @@ class UnboundDeployer(Deployer):
|
|||||||
self.need_restart = False
|
self.need_restart = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# Run local DNS resolver `unbound`. `resolvconf` takes care of
|
# Run local DNS resolver `unbound`.
|
||||||
# setting up /etc/resolv.conf to use 127.0.0.1 as the resolver.
|
# `resolvconf` takes care of setting up /etc/resolv.conf
|
||||||
|
# to use 127.0.0.1 as the resolver.
|
||||||
|
|
||||||
# On an IPv4-only system, if unbound is started but not configured,
|
#
|
||||||
# it causes subsequent steps to fail to resolve hosts.
|
# On an IPv4-only system, if unbound is started but not
|
||||||
with blocked_service_startup():
|
# configured, it causes subsequent steps to fail to resolve hosts.
|
||||||
apt.packages(
|
# Here, we use policy-rc.d to prevent unbound from starting up
|
||||||
name="Install unbound",
|
# on initial install. Later, we will configure it and start it.
|
||||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
#
|
||||||
)
|
# For documentation about policy-rc.d, see:
|
||||||
|
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
|
||||||
|
#
|
||||||
|
files.put(
|
||||||
|
src=get_resource("policy-rc.d"),
|
||||||
|
dest="/usr/sbin/policy-rc.d",
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
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):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -251,9 +271,6 @@ class WebsiteDeployer(Deployer):
|
|||||||
# if www_folder is a hugo page, build it
|
# if www_folder is a hugo page, build it
|
||||||
if build_dir:
|
if build_dir:
|
||||||
www_path = build_webpages(src_dir, build_dir, self.config)
|
www_path = build_webpages(src_dir, build_dir, self.config)
|
||||||
if www_path is None:
|
|
||||||
logger.warning("Web page build failed, skipping website deployment")
|
|
||||||
return
|
|
||||||
# if it is not a hugo page, upload it as is
|
# if it is not a hugo page, upload it as is
|
||||||
files.rsync(
|
files.rsync(
|
||||||
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
|
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
|
||||||
@@ -320,12 +337,12 @@ class TurnDeployer(Deployer):
|
|||||||
def install(self):
|
def install(self):
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = {
|
||||||
"x86_64": (
|
"x86_64": (
|
||||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux",
|
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
|
||||||
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
|
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
|
||||||
),
|
),
|
||||||
"aarch64": (
|
"aarch64": (
|
||||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux",
|
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
|
||||||
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
|
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
|
||||||
),
|
),
|
||||||
}[host.get_fact(facts.server.Arch)]
|
}[host.get_fact(facts.server.Arch)]
|
||||||
|
|
||||||
@@ -458,19 +475,10 @@ 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):
|
||||||
files.put(
|
|
||||||
name="Disable installing recommended packages globally",
|
|
||||||
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
|
||||||
dest="/etc/apt/apt.conf.d/00InstallRecommends",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
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)
|
||||||
|
|
||||||
@@ -483,18 +491,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"],
|
||||||
files.directory(
|
|
||||||
name="Ensure vmail mailbox directory exists",
|
|
||||||
path=str(self.config.mailboxes_dir),
|
|
||||||
user="vmail",
|
|
||||||
group="vmail",
|
|
||||||
mode="700",
|
|
||||||
present=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -584,7 +586,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if not is_in_container():
|
if not os.environ.get("CHATMAIL_NOPORTCHECK"):
|
||||||
port_services = [
|
port_services = [
|
||||||
(["master", "smtpd"], 25),
|
(["master", "smtpd"], 25),
|
||||||
("unbound", 53),
|
("unbound", 53),
|
||||||
@@ -624,7 +626,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
tls_deployer = get_tls_deployer(config, mail_domain)
|
tls_deployer = get_tls_deployer(config, mail_domain)
|
||||||
|
|
||||||
all_deployers = [
|
all_deployers = [
|
||||||
ChatmailDeployer(config),
|
ChatmailDeployer(mail_domain),
|
||||||
LegacyRemoveDeployer(),
|
LegacyRemoveDeployer(),
|
||||||
FiltermailDeployer(),
|
FiltermailDeployer(),
|
||||||
JournaldDeployer(),
|
JournaldDeployer(),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import io
|
import os
|
||||||
import urllib.request
|
|
||||||
|
|
||||||
from chatmaild.config import Config
|
from chatmaild.config import Config
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
from pyinfra.facts.deb import DebPackages
|
|
||||||
from pyinfra.facts.server import Arch, Sysctl
|
from pyinfra.facts.server import Arch, Sysctl
|
||||||
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
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,
|
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
get_resource,
|
||||||
is_in_container,
|
has_systemd,
|
||||||
)
|
)
|
||||||
|
|
||||||
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
|
|
||||||
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
|
|
||||||
|
|
||||||
DOVECOT_SHA256 = {
|
|
||||||
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
|
||||||
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
|
||||||
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
|
||||||
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
|
||||||
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
|
||||||
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DovecotDeployer(Deployer):
|
class DovecotDeployer(Deployer):
|
||||||
daemon_reload = False
|
daemon_reload = False
|
||||||
@@ -39,43 +25,15 @@ class DovecotDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(Arch)
|
arch = host.get_fact(Arch)
|
||||||
with blocked_service_startup():
|
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
|
||||||
debs = []
|
return # already installed and running
|
||||||
for pkg in ("core", "imapd", "lmtpd"):
|
_install_dovecot_package("core", arch)
|
||||||
deb, changed = _download_dovecot_package(pkg, arch)
|
_install_dovecot_package("imapd", arch)
|
||||||
self.need_restart |= changed
|
_install_dovecot_package("lmtpd", arch)
|
||||||
if deb:
|
|
||||||
debs.append(deb)
|
|
||||||
if debs:
|
|
||||||
deb_list = " ".join(debs)
|
|
||||||
# First dpkg may fail on missing dependencies (stderr suppressed);
|
|
||||||
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
|
|
||||||
server.shell(
|
|
||||||
name="Install dovecot packages",
|
|
||||||
commands=[
|
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
|
|
||||||
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
|
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
self.need_restart = True
|
|
||||||
files.put(
|
|
||||||
name="Pin dovecot packages to block Debian dist-upgrades",
|
|
||||||
src=io.StringIO(
|
|
||||||
"Package: dovecot-*\n"
|
|
||||||
"Pin: version *\n"
|
|
||||||
"Pin-Priority: -1\n"
|
|
||||||
),
|
|
||||||
dest="/etc/apt/preferences.d/pin-dovecot",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self.config.mail_domain, self.units)
|
||||||
config_restart, self.daemon_reload = _configure_dovecot(self.config)
|
self.need_restart, self.daemon_reload = _configure_dovecot(self.config)
|
||||||
self.need_restart |= config_restart
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
activate_remote_units(self.units)
|
activate_remote_units(self.units)
|
||||||
@@ -83,9 +41,7 @@ class DovecotDeployer(Deployer):
|
|||||||
restart = False if self.disable_mail else self.need_restart
|
restart = False if self.disable_mail else self.need_restart
|
||||||
|
|
||||||
systemd.service(
|
systemd.service(
|
||||||
name="Disable dovecot for now"
|
name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot",
|
||||||
if self.disable_mail
|
|
||||||
else "Start and enable Dovecot",
|
|
||||||
service="dovecot.service",
|
service="dovecot.service",
|
||||||
running=False if self.disable_mail else True,
|
running=False if self.disable_mail else True,
|
||||||
enabled=False if self.disable_mail else True,
|
enabled=False if self.disable_mail else True,
|
||||||
@@ -95,49 +51,41 @@ class DovecotDeployer(Deployer):
|
|||||||
self.need_restart = False
|
self.need_restart = False
|
||||||
|
|
||||||
|
|
||||||
def _pick_url(primary, fallback):
|
def _install_dovecot_package(package: str, arch: str):
|
||||||
try:
|
|
||||||
req = urllib.request.Request(primary, method="HEAD")
|
|
||||||
urllib.request.urlopen(req, timeout=10)
|
|
||||||
return primary
|
|
||||||
except Exception:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
|
||||||
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
|
|
||||||
"""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
|
||||||
|
url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
|
||||||
|
deb_filename = "/root/" + url.split("/")[-1]
|
||||||
|
|
||||||
pkg_name = f"dovecot-{package}"
|
match (package, arch):
|
||||||
sha256 = DOVECOT_SHA256.get((package, arch))
|
case ("core", "amd64"):
|
||||||
if sha256 is None:
|
sha256 = "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d"
|
||||||
op = apt.packages(packages=[pkg_name])
|
case ("core", "arm64"):
|
||||||
return None, bool(getattr(op, "changed", False))
|
sha256 = "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9"
|
||||||
|
case ("imapd", "amd64"):
|
||||||
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
|
||||||
if DOVECOT_PACKAGE_VERSION in installed_versions:
|
case ("imapd", "arm64"):
|
||||||
return None, False
|
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
|
||||||
|
case ("lmtpd", "amd64"):
|
||||||
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
|
||||||
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
case ("lmtpd", "arm64"):
|
||||||
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
|
||||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
case _:
|
||||||
url = _pick_url(primary_url, fallback_url)
|
apt.packages(packages=[f"dovecot-{package}"])
|
||||||
deb_filename = f"/root/{deb_base}"
|
return
|
||||||
|
|
||||||
files.download(
|
files.download(
|
||||||
name=f"Download {pkg_name}",
|
name=f"Download dovecot-{package}",
|
||||||
src=url,
|
src=url,
|
||||||
dest=deb_filename,
|
dest=deb_filename,
|
||||||
sha256sum=sha256,
|
sha256sum=sha256,
|
||||||
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 dovecot-{package}", src=deb_filename)
|
||||||
|
|
||||||
|
|
||||||
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
|
def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
||||||
"""Configures Dovecot IMAP server."""
|
"""Configures Dovecot IMAP server."""
|
||||||
need_restart = False
|
need_restart = False
|
||||||
daemon_reload = False
|
daemon_reload = False
|
||||||
@@ -172,25 +120,19 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]
|
|||||||
|
|
||||||
# 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()
|
if not os.environ.get("CHATMAIL_NOSYSCTL"):
|
||||||
for name in ("max_user_instances", "max_user_watches"):
|
for name in ("max_user_instances", "max_user_watches"):
|
||||||
key = f"fs.inotify.{name}"
|
key = f"fs.inotify.{name}"
|
||||||
value = host.get_fact(Sysctl)[key]
|
if host.get_fact(Sysctl)[key] > 65535:
|
||||||
if value > 65534:
|
# Skip updating limits if already sufficient
|
||||||
continue
|
# (enables running in incus containers where sysctl readonly)
|
||||||
if not can_modify:
|
continue
|
||||||
print(
|
server.sysctl(
|
||||||
"\n!!!! refusing to attempt sysctl setting in containers\n"
|
name=f"Change {key}",
|
||||||
f"!!!! dovecot: sysctl {key!r}={value}, should be >65534 for production setups\n"
|
key=key,
|
||||||
"!!!!"
|
value=65535,
|
||||||
|
persist=True,
|
||||||
)
|
)
|
||||||
continue
|
|
||||||
server.sysctl(
|
|
||||||
name=f"Change {key}",
|
|
||||||
key=key,
|
|
||||||
value=65535,
|
|
||||||
persist=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
timezone_env = files.line(
|
timezone_env = files.line(
|
||||||
name="Set TZ environment variable",
|
name="Set TZ environment variable",
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
|
|||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(facts.server.Arch)
|
arch = host.get_fact(facts.server.Arch)
|
||||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}"
|
url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.1/filtermail-{arch}"
|
||||||
sha256sum = {
|
sha256sum = {
|
||||||
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
|
"x86_64": "adce2ddb461c5fd744df699f3b0b3c33b6d52413c641f18695b93826e5e0d234",
|
||||||
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
|
"aarch64": "b51cf4248c6c443308f21b1811da1cc919b98b719a2138f4b60940ea093a5422",
|
||||||
}[arch]
|
}[arch]
|
||||||
self.need_restart |= files.download(
|
self.need_restart |= files.download(
|
||||||
name="Download filtermail",
|
name="Download filtermail",
|
||||||
|
|||||||
1
cmdeploy/src/cmdeploy/metrics.cron.j2
Normal file
1
cmdeploy/src/cmdeploy/metrics.cron.j2
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics
|
||||||
@@ -54,7 +54,7 @@ http {
|
|||||||
include /etc/nginx/mime.types;
|
include /etc/nginx/mime.types;
|
||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||||
ssl_prefer_server_ciphers on;
|
ssl_prefer_server_ciphers on;
|
||||||
ssl_certificate {{ config.tls_cert_path }};
|
ssl_certificate {{ config.tls_cert_path }};
|
||||||
ssl_certificate_key {{ config.tls_key_path }};
|
ssl_certificate_key {{ config.tls_key_path }};
|
||||||
@@ -73,16 +73,16 @@ http {
|
|||||||
|
|
||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||||
|
|
||||||
location /mxdeliv/ {
|
|
||||||
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port }};
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
# First attempt to serve request as file, then
|
# First attempt to serve request as file, then
|
||||||
# as directory, then fall back to displaying a 404.
|
# as directory, then fall back to displaying a 404.
|
||||||
try_files $uri $uri/ =404;
|
try_files $uri $uri/ =404;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /metrics {
|
||||||
|
default_type text/plain;
|
||||||
|
}
|
||||||
|
|
||||||
location /new {
|
location /new {
|
||||||
{% if config.tls_cert_mode != "self" %}
|
{% if config.tls_cert_mode != "self" %}
|
||||||
if ($request_method = GET) {
|
if ($request_method = GET) {
|
||||||
|
|||||||
@@ -103,13 +103,6 @@ class OpendkimDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
need_restart |= service_file.changed
|
need_restart |= service_file.changed
|
||||||
|
|
||||||
files.file(
|
|
||||||
name="chown opendkim: /etc/dkimkeys/opendkim.private",
|
|
||||||
path="/etc/dkimkeys/opendkim.private",
|
|
||||||
user="opendkim",
|
|
||||||
group="opendkim",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.need_restart = need_restart
|
self.need_restart = need_restart
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ class PostfixDeployer(Deployer):
|
|||||||
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
|
||||||
commands=[
|
commands=["""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
|
self.need_restart = need_restart
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -88,22 +88,6 @@ inet_protocols = ipv4
|
|||||||
inet_protocols = all
|
inet_protocols = all
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# Postfix does not try IPv4 and IPv6 connections
|
|
||||||
# concurrently as of version 3.7.11.
|
|
||||||
#
|
|
||||||
# When relay has both A (IPv4) and AAAA (IPv6) records,
|
|
||||||
# but broken IPv6 connectivity,
|
|
||||||
# every second message is delayed by the connection timeout
|
|
||||||
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
|
|
||||||
# which defaults to 30 seconds. Reducing timeouts is not a solution
|
|
||||||
# as this will result in a failure to connect to slow servers.
|
|
||||||
#
|
|
||||||
# As a workaround we always prefer IPv4 when it is available.
|
|
||||||
#
|
|
||||||
# The setting is documented at
|
|
||||||
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
|
|
||||||
smtp_address_preference=ipv4
|
|
||||||
|
|
||||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||||
virtual_mailbox_domains = {{ config.mail_domain }}
|
virtual_mailbox_domains = {{ config.mail_domain }}
|
||||||
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
||||||
|
|||||||
@@ -53,14 +53,13 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
|||||||
print=log_progress,
|
print=log_progress,
|
||||||
)
|
)
|
||||||
except CalledProcessError:
|
except CalledProcessError:
|
||||||
return None, None
|
return
|
||||||
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}"',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -95,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)
|
||||||
|
|||||||
@@ -40,5 +40,5 @@ def dovecot_recalc_quota(user):
|
|||||||
#
|
#
|
||||||
for line in output.split("\n"):
|
for line in output.split("\n"):
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) >= 6 and parts[2] == "STORAGE":
|
if parts[2] == "STORAGE":
|
||||||
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))
|
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=vmail
|
User=vmail
|
||||||
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
|
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,9 @@ class SSHExec:
|
|||||||
class LocalExec:
|
class LocalExec:
|
||||||
FuncError = FuncError
|
FuncError = FuncError
|
||||||
|
|
||||||
def __init__(self, verbose=False):
|
def __init__(self, verbose=False, docker=False):
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
self.docker = docker
|
||||||
|
|
||||||
def __call__(self, call, kwargs=None, log_callback=None):
|
def __call__(self, call, kwargs=None, log_callback=None):
|
||||||
if kwargs is None:
|
if kwargs is None:
|
||||||
@@ -100,6 +101,10 @@ class LocalExec:
|
|||||||
if not title:
|
if not title:
|
||||||
title = call.__name__
|
title = call.__name__
|
||||||
where = "locally"
|
where = "locally"
|
||||||
|
if self.docker:
|
||||||
|
if call == remote.rdns.perform_initial_checks:
|
||||||
|
kwargs["pre_command"] = "docker exec chatmail "
|
||||||
|
where = "in docker"
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
||||||
return self(call, kwargs, log_callback=print_stderr)
|
return self(call, kwargs, log_callback=print_stderr)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import time
|
||||||
def test_tls_imap(benchmark, imap):
|
def test_tls_imap(benchmark, imap):
|
||||||
def imap_connect():
|
def imap_connect():
|
||||||
imap.connect()
|
imap.connect()
|
||||||
|
|||||||
@@ -89,9 +89,7 @@ def test_concurrent_logins_same_account(
|
|||||||
assert login_results.get()
|
assert login_results.get()
|
||||||
|
|
||||||
|
|
||||||
def test_no_vrfy(cmfactory, chatmail_config):
|
def test_no_vrfy(chatmail_config):
|
||||||
ac = cmfactory.get_online_account()
|
|
||||||
addr = ac.get_config("addr")
|
|
||||||
domain = chatmail_config.mail_domain
|
domain = chatmail_config.mail_domain
|
||||||
|
|
||||||
s = smtplib.SMTP(domain)
|
s = smtplib.SMTP(domain)
|
||||||
@@ -100,7 +98,7 @@ def test_no_vrfy(cmfactory, chatmail_config):
|
|||||||
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
||||||
result = s.getreply()
|
result = s.getreply()
|
||||||
print(result)
|
print(result)
|
||||||
s.putcmd("vrfy", addr)
|
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
|
||||||
result2 = s.getreply()
|
result2 = s.getreply()
|
||||||
print(result2)
|
print(result2)
|
||||||
assert result[0] == result2[0] == 252
|
assert result[0] == result2[0] == 252
|
||||||
|
|||||||
@@ -71,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)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import imap_tools
|
|||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from cmdeploy.cmdeploy import get_sshexec
|
|
||||||
from cmdeploy.remote import rshell
|
from cmdeploy.remote import rshell
|
||||||
|
from cmdeploy.cmdeploy import get_sshexec
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|||||||
@@ -35,11 +35,6 @@ def pytest_runtest_setup(item):
|
|||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -393,15 +388,12 @@ def cmfactory(rpc, 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
|
||||||
@@ -411,32 +403,19 @@ class Remote:
|
|||||||
case "localhost": command = []
|
case "localhost": command = []
|
||||||
case _: 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
|
||||||
|
|||||||
@@ -23,19 +23,15 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +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
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -60,29 +60,6 @@ def mockdns(request, mockdns_base, mockdns_expected):
|
|||||||
return mockdns_base
|
return mockdns_base
|
||||||
|
|
||||||
|
|
||||||
class TestGetDkimEntry:
|
|
||||||
def test_dkim_entry_returns_tuple_on_success(self, mockdns):
|
|
||||||
entry, web_entry = remote.rdns.get_dkim_entry(
|
|
||||||
"some.domain", "", dkim_selector="opendkim"
|
|
||||||
)
|
|
||||||
# May return None,None if openssl not available, but should never crash
|
|
||||||
if entry is not None:
|
|
||||||
assert "opendkim._domainkey.some.domain" in entry
|
|
||||||
assert "opendkim._domainkey.some.domain" in web_entry
|
|
||||||
|
|
||||||
def test_dkim_entry_returns_none_tuple_on_error(self, monkeypatch):
|
|
||||||
"""CalledProcessError must return (None, None), not bare None."""
|
|
||||||
from subprocess import CalledProcessError
|
|
||||||
|
|
||||||
def failing_shell(command, fail_ok=False, print=print):
|
|
||||||
raise CalledProcessError(1, command)
|
|
||||||
|
|
||||||
monkeypatch.setattr(remote.rdns, "shell", failing_shell)
|
|
||||||
result = remote.rdns.get_dkim_entry("some.domain", "", dkim_selector="opendkim")
|
|
||||||
assert result == (None, None)
|
|
||||||
assert result[0] is None and result[1] is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestPerformInitialChecks:
|
class TestPerformInitialChecks:
|
||||||
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
|
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
|
||||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||||
@@ -125,49 +102,18 @@ class TestPerformInitialChecks:
|
|||||||
assert not l
|
assert not l
|
||||||
|
|
||||||
|
|
||||||
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,238 +0,0 @@
|
|||||||
from contextlib import nullcontext
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from cmdeploy.dovecot import deployer as dovecot_deployer
|
|
||||||
from pyinfra.facts.deb import DebPackages
|
|
||||||
|
|
||||||
|
|
||||||
def make_host(*fact_pairs):
|
|
||||||
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
*fact_pairs: tuples of (fact_class, fact_value) to register
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SimpleNamespace with get_fact that raises a clear error if an
|
|
||||||
unexpected fact type is requested.
|
|
||||||
"""
|
|
||||||
facts = dict(fact_pairs)
|
|
||||||
|
|
||||||
def get_fact(cls):
|
|
||||||
if cls not in facts:
|
|
||||||
registered = ", ".join(c.__name__ for c in facts)
|
|
||||||
raise LookupError(
|
|
||||||
f"unexpected get_fact({cls.__name__}); "
|
|
||||||
f"only registered: {registered}"
|
|
||||||
)
|
|
||||||
return facts[cls]
|
|
||||||
|
|
||||||
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):
|
|
||||||
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
|
|
||||||
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")
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
|
||||||
monkeypatch,
|
|
||||||
):
|
|
||||||
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", "amd64")
|
|
||||||
|
|
||||||
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
|
||||||
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
|
||||||
|
|
||||||
# Verify the returned path uses archive version, not package version (with 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 dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
|
|
||||||
f"deb path should use archive version (no epoch), got {deb!r}"
|
|
||||||
)
|
|
||||||
assert len(downloads) == 1, "files.download should be called exactly once"
|
|
||||||
|
|
||||||
|
|
||||||
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": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
|
||||||
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
|
||||||
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
(dovecot_deployer.Arch, "x86_64"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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_falls_back_to_apt(
|
|
||||||
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
|
||||||
):
|
|
||||||
# For unsupported architectures, all fact lookups return the arch string.
|
|
||||||
monkeypatch.setattr(
|
|
||||||
dovecot_deployer,
|
|
||||||
"host",
|
|
||||||
SimpleNamespace(get_fact=lambda cls: "riscv64"),
|
|
||||||
)
|
|
||||||
apt_calls = []
|
|
||||||
|
|
||||||
# Mirrors apt.packages() return value: OperationMeta with .changed property.
|
|
||||||
# Only lmtpd triggers a change to verify |= accumulation of changed flags.
|
|
||||||
def fake_apt(**kwargs):
|
|
||||||
apt_calls.append(kwargs)
|
|
||||||
changed = "lmtpd" in kwargs["packages"][0]
|
|
||||||
return SimpleNamespace(changed=changed)
|
|
||||||
|
|
||||||
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
|
|
||||||
|
|
||||||
deployer.install()
|
|
||||||
|
|
||||||
actual_pkgs = [c["packages"] for c in apt_calls]
|
|
||||||
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
|
|
||||||
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
|
|
||||||
)
|
|
||||||
assert track_shell == [], "should not run dpkg for unsupported arch"
|
|
||||||
assert deployer.need_restart is True, (
|
|
||||||
"need_restart should be True when apt installed a package"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
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) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
|
|
||||||
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
|
|
||||||
assert "apt-get -y --fix-broken install" in cmds[1]
|
|
||||||
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from cmdeploy.remote.rshell import dovecot_recalc_quota
|
|
||||||
|
|
||||||
|
|
||||||
def test_dovecot_recalc_quota_normal_output():
|
|
||||||
"""Normal doveadm output returns parsed dict."""
|
|
||||||
normal_output = (
|
|
||||||
"Quota name Type Value Limit %\n"
|
|
||||||
"User quota STORAGE 5 102400 0\n"
|
|
||||||
"User quota MESSAGE 2 - 0\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("cmdeploy.remote.rshell.shell", return_value=normal_output):
|
|
||||||
result = dovecot_recalc_quota("user@example.org")
|
|
||||||
|
|
||||||
# shell is called twice (recalc + get), patch returns same for both
|
|
||||||
assert result == {"value": 5, "limit": 102400, "percent": 0}
|
|
||||||
|
|
||||||
|
|
||||||
def test_dovecot_recalc_quota_empty_output():
|
|
||||||
"""Empty doveadm output (trailing newline) must not IndexError."""
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def mock_shell(cmd):
|
|
||||||
call_count[0] += 1
|
|
||||||
if "recalc" in cmd:
|
|
||||||
return ""
|
|
||||||
# quota get returns only empty lines
|
|
||||||
return "\n\n"
|
|
||||||
|
|
||||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
|
||||||
result = dovecot_recalc_quota("user@example.org")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_dovecot_recalc_quota_malformed_output():
|
|
||||||
"""Malformed output with too few columns must not crash."""
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def mock_shell(cmd):
|
|
||||||
call_count[0] += 1
|
|
||||||
if "recalc" in cmd:
|
|
||||||
return ""
|
|
||||||
# partial line, fewer than 6 parts
|
|
||||||
return "Quota name\nUser quota STORAGE\n"
|
|
||||||
|
|
||||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
|
||||||
result = dovecot_recalc_quota("user@example.org")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_dovecot_recalc_quota_header_only():
|
|
||||||
"""Only header line, no data rows."""
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def mock_shell(cmd):
|
|
||||||
call_count[0] += 1
|
|
||||||
if "recalc" in cmd:
|
|
||||||
return ""
|
|
||||||
return "Quota name Type Value Limit %\n"
|
|
||||||
|
|
||||||
with patch("cmdeploy.remote.rshell.shell", side_effect=mock_shell):
|
|
||||||
result = dovecot_recalc_quota("user@example.org")
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
@@ -98,13 +98,6 @@ steps. Please substitute it with your own domain.
|
|||||||
configure at your DNS provider (it can take some time until they are
|
configure at your DNS provider (it can take some time until they are
|
||||||
public).
|
public).
|
||||||
|
|
||||||
Docker installation
|
|
||||||
-------------------
|
|
||||||
|
|
||||||
There is experimental support for running chatmail via Docker Compose.
|
|
||||||
See the `chatmail/docker README <https://github.com/chatmail/docker>`_
|
|
||||||
for full setup instructions.
|
|
||||||
|
|
||||||
Other helpful commands
|
Other helpful commands
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,10 @@ short overview of ``chatmaild`` services:
|
|||||||
is contacted by Dovecot when a user logs in and stores the date of
|
is contacted by Dovecot when a user logs in and stores the date of
|
||||||
the login.
|
the login.
|
||||||
|
|
||||||
|
- `metrics <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metrics.py>`_
|
||||||
|
collects some metrics and displays them at
|
||||||
|
``https://example.org/metrics``.
|
||||||
|
|
||||||
``www/``
|
``www/``
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
|
|
||||||
@@ -138,9 +142,11 @@ Chatmail relay dependency diagram
|
|||||||
nginx-internal --- autoconfig.xml;
|
nginx-internal --- autoconfig.xml;
|
||||||
certs-nginx[("`TLS certs
|
certs-nginx[("`TLS certs
|
||||||
/var/lib/acme`")] --> nginx-internal;
|
/var/lib/acme`")] --> nginx-internal;
|
||||||
|
systemd-timer --- chatmail-metrics;
|
||||||
systemd-timer --- acmetool;
|
systemd-timer --- acmetool;
|
||||||
systemd-timer --- chatmail-expire-daily;
|
systemd-timer --- chatmail-expire-daily;
|
||||||
systemd-timer --- chatmail-fsreport-daily;
|
systemd-timer --- chatmail-fsreport-daily;
|
||||||
|
chatmail-metrics --- website;
|
||||||
acmetool --> certs[("`TLS certs
|
acmetool --> certs[("`TLS certs
|
||||||
/var/lib/acme`")];
|
/var/lib/acme`")];
|
||||||
nginx-external --- |993|dovecot;
|
nginx-external --- |993|dovecot;
|
||||||
|
|||||||
Reference in New Issue
Block a user