mirror of
https://github.com/chatmail/relay.git
synced 2026-05-11 00:14:36 +00:00
Compare commits
69 Commits
docker-reb
...
hpk/lxc-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc36902c8 | ||
|
|
8a1c1d9288 | ||
|
|
c3fd52e5dd | ||
|
|
233d442bca | ||
|
|
8aabb9a955 | ||
|
|
9fc05646e0 | ||
|
|
155c1221b8 | ||
|
|
3393d071e5 | ||
|
|
52c73658f9 | ||
|
|
b022a61955 | ||
|
|
2e383a8e94 | ||
|
|
9908a4c88c | ||
|
|
c04f4a4b44 | ||
|
|
6d0ce061bc | ||
|
|
a48c525455 | ||
|
|
2786b60658 | ||
|
|
fe46b573a6 | ||
|
|
674e496a53 | ||
|
|
86e5708709 | ||
|
|
04ac2cf700 | ||
|
|
e2ec0cf2c5 | ||
|
|
ee9d54f7d6 | ||
|
|
1604321d5b | ||
|
|
4ab04fa6c4 | ||
|
|
8b6829b906 | ||
|
|
cf2cb57cca | ||
|
|
693c3f8555 | ||
|
|
371efdfafb | ||
|
|
23765a5ed8 | ||
|
|
0adeefbdd7 | ||
|
|
624838eedd | ||
|
|
1abdc407af | ||
|
|
ff541b81ea | ||
|
|
ed9b4092a8 | ||
|
|
1b8ad3ca12 | ||
|
|
f85d304e65 | ||
|
|
4d1856d8f1 | ||
|
|
ae2ab52aa9 | ||
|
|
d0c396538b | ||
|
|
78a4e28408 | ||
|
|
2432d4f498 | ||
|
|
31301abb42 | ||
|
|
6b4edd8502 | ||
|
|
9c467ab3e8 | ||
|
|
774350778b | ||
|
|
06d53503e5 | ||
|
|
b128935940 | ||
|
|
2e38c61ca2 | ||
|
|
9dd8ce8ce1 | ||
|
|
0ae3f94ecc | ||
|
|
4481a12369 | ||
|
|
a47016e9f2 | ||
|
|
4e6ba7378d | ||
|
|
e428c646d1 | ||
|
|
dbd5cd16f5 | ||
|
|
e21f2a0fa2 | ||
|
|
8ca0909fa5 | ||
|
|
2c99cc84aa | ||
|
|
73309778c2 | ||
|
|
50ecc2b315 | ||
|
|
7b5b180b4b | ||
|
|
193624e522 | ||
|
|
437287fadc | ||
|
|
0ad679997a | ||
|
|
38cc1c7cd6 | ||
|
|
7a6ed8340e | ||
|
|
2ce9e5fe78 | ||
|
|
cf96be2cbb | ||
|
|
36eb63faa1 |
@@ -1,7 +0,0 @@
|
||||
.git
|
||||
data/
|
||||
venv/
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.orig
|
||||
.pytest_cache
|
||||
90
.github/workflows/ci.yaml
vendored
90
.github/workflows/ci.yaml
vendored
@@ -15,28 +15,98 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: download filtermail
|
||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||
- name: run chatmaild tests
|
||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
run: pipx run tox
|
||||
|
||||
scripts:
|
||||
name: deploy-chatmail tests
|
||||
name: deploy-chatmail tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: initenv
|
||||
- name: initenv
|
||||
run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: run formatting checks
|
||||
run: cmdeploy fmt -v
|
||||
- name: run formatting checks
|
||||
run: cmdeploy fmt -v
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
# all other cmdeploy commands require a staging server
|
||||
# see https://github.com/deltachat/chatmail/issues/100
|
||||
lxc-test:
|
||||
name: LXC deploy and test
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: install incus
|
||||
run: sudo apt-get update && sudo apt-get install -y incus
|
||||
|
||||
- name: initialise incus
|
||||
run: |
|
||||
sudo systemctl stop docker.socket docker || true
|
||||
sudo iptables -P FORWARD ACCEPT
|
||||
sudo sysctl -w fs.inotify.max_user_instances=65535
|
||||
sudo sysctl -w fs.inotify.max_user_watches=65535
|
||||
sudo incus admin init --minimal --quiet
|
||||
sudo usermod -aG incus-admin "$USER"
|
||||
|
||||
- name: initenv
|
||||
run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: restore cached images
|
||||
id: cache-images
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/tmp/localchat-base.tar.gz
|
||||
/tmp/localchat-ns.tar.gz
|
||||
/tmp/localchat-test0.tar.gz
|
||||
/tmp/localchat-test1.tar.gz
|
||||
lxconfigs/id_localchat*
|
||||
key: incus-contain-v2-${{ runner.os }}-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
incus-contain-v2-${{ runner.os }}-${{ github.ref_name }}-
|
||||
incus-contain-v2-${{ runner.os }}-main-
|
||||
incus-contain-v2-${{ runner.os }}-
|
||||
|
||||
- name: import cached images
|
||||
run: |
|
||||
for alias in localchat-base localchat-ns localchat-test0 localchat-test1; do
|
||||
if [ -f /tmp/$alias.tar.gz ]; then
|
||||
sg incus-admin -c "incus --quiet image import /tmp/$alias.tar.gz --alias $alias" || true
|
||||
fi
|
||||
done
|
||||
|
||||
- name: cmdeploy lxc-test
|
||||
run: sg incus-admin -c 'cmdeploy lxc-test -vv'
|
||||
|
||||
- name: show container logs on failure
|
||||
if: failure() || cancelled()
|
||||
run: |
|
||||
for c in test0-localchat test1-localchat; do
|
||||
echo "::group::$c journal (warnings+errors)"
|
||||
sg incus-admin -c "incus exec $c -- journalctl -p warning --no-pager -n200" 2>/dev/null || echo "no log"
|
||||
echo "::endgroup::"
|
||||
echo "::group::$c failed services"
|
||||
sg incus-admin -c "incus exec $c -- systemctl --no-pager --failed" || true
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
- name: export images for cache
|
||||
if: always()
|
||||
run: |
|
||||
for alias in localchat-base localchat-ns localchat-test0 localchat-test1; do
|
||||
sg incus-admin -c "incus --quiet image export $alias /tmp/$alias" || true
|
||||
done
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
;; Zone file for staging-ipv4.testrun.org
|
||||
|
||||
$ORIGIN staging-ipv4.testrun.org.
|
||||
$TTL 300
|
||||
|
||||
@ IN SOA ns.testrun.org. root.nine.testrun.org (
|
||||
2023010101 ; Serial
|
||||
7200 ; Refresh
|
||||
3600 ; Retry
|
||||
1209600 ; Expire
|
||||
3600 ; Negative response caching TTL
|
||||
)
|
||||
|
||||
;; Nameservers.
|
||||
@ IN NS ns.testrun.org.
|
||||
|
||||
;; DNS records.
|
||||
@ IN A 37.27.95.249
|
||||
mta-sts.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.
|
||||
www.staging-ipv4.testrun.org. CNAME staging-ipv4.testrun.org.
|
||||
@@ -1,21 +0,0 @@
|
||||
;; Zone file for staging2.testrun.org
|
||||
|
||||
$ORIGIN staging2.testrun.org.
|
||||
$TTL 300
|
||||
|
||||
@ IN SOA ns.testrun.org. root.nine.testrun.org (
|
||||
2023010101 ; Serial
|
||||
7200 ; Refresh
|
||||
3600 ; Retry
|
||||
1209600 ; Expire
|
||||
3600 ; Negative response caching TTL
|
||||
)
|
||||
|
||||
;; Nameservers.
|
||||
@ IN NS ns.testrun.org.
|
||||
|
||||
;; DNS records.
|
||||
@ IN A 37.27.24.139
|
||||
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
www.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
|
||||
96
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
96
.github/workflows/test-and-deploy-ipv4only.yaml
vendored
@@ -1,96 +0,0 @@
|
||||
name: deploy on staging-ipv4.testrun.org, and run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '**/README.md'
|
||||
- 'CHANGELOG.md'
|
||||
- 'LICENSE'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy on staging-ipv4.testrun.org, and run tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: staging-ipv4.testrun.org
|
||||
url: https://staging-ipv4.testrun.org/
|
||||
concurrency: staging-ipv4.testrun.org
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: prepare SSH
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan staging-ipv4.testrun.org > ~/.ssh/known_hosts
|
||||
# save previous acme & dkim state
|
||||
rsync -avz root@staging-ipv4.testrun.org:/var/lib/acme acme-ipv4 || true
|
||||
rsync -avz root@staging-ipv4.testrun.org:/etc/dkimkeys dkimkeys-ipv4 || true
|
||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
||||
if [ -f dkimkeys-ipv4/dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys-ipv4 root@ns.testrun.org:/tmp/ || true; fi
|
||||
if [ "$(ls -A acme-ipv4/acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme-ipv4 root@ns.testrun.org:/tmp/ || true; fi
|
||||
# make sure CAA record isn't set
|
||||
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
|
||||
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging-ipv4.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: rebuild staging-ipv4.testrun.org to have a clean VPS
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"debian-12"}' \
|
||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_IPV4_SERVER_ID }}/actions/rebuild"
|
||||
|
||||
- run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: upload TLS cert after rebuilding
|
||||
run: |
|
||||
echo " --- wait until staging-ipv4.testrun.org VPS is rebuilt --- "
|
||||
rm ~/.ssh/known_hosts
|
||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u ; do sleep 1 ; done
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org id -u
|
||||
# download acme & dkim state from ns.testrun.org
|
||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme-ipv4/acme acme-restore || true
|
||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys-ipv4/dkimkeys dkimkeys-restore || true
|
||||
# restore acme & dkim state to staging2.testrun.org
|
||||
rsync -avz acme-restore/acme root@staging-ipv4.testrun.org:/var/lib/ || true
|
||||
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
- run: |
|
||||
cmdeploy init staging-ipv4.testrun.org
|
||||
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
|
||||
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
||||
|
||||
- run: cmdeploy run --verbose --skip-dns-check
|
||||
|
||||
- name: set DNS entries
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||
cmdeploy dns --zonefile staging-generated.zone
|
||||
cat staging-generated.zone >> .github/workflows/staging-ipv4.testrun.org-default.zone
|
||||
cat .github/workflows/staging-ipv4.testrun.org-default.zone
|
||||
scp .github/workflows/staging-ipv4.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging-ipv4.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging-ipv4.testrun.org /etc/nsd/staging-ipv4.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: cmdeploy test
|
||||
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
|
||||
|
||||
- name: cmdeploy dns
|
||||
run: cmdeploy dns -v
|
||||
|
||||
98
.github/workflows/test-and-deploy.yaml
vendored
98
.github/workflows/test-and-deploy.yaml
vendored
@@ -1,98 +0,0 @@
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '**/README.md'
|
||||
- 'CHANGELOG.md'
|
||||
- 'LICENSE'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: staging2.testrun.org
|
||||
url: https://staging2.testrun.org/
|
||||
concurrency: staging2.testrun.org
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: prepare SSH
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
|
||||
# save previous acme & dkim state
|
||||
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
|
||||
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
|
||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
||||
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
|
||||
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
|
||||
# make sure CAA record isn't set
|
||||
scp -o StrictHostKeyChecking=accept-new .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: rebuild staging2.testrun.org to have a clean VPS
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"debian-12"}' \
|
||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
|
||||
|
||||
- run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: upload TLS cert after rebuilding
|
||||
run: |
|
||||
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
|
||||
rm ~/.ssh/known_hosts
|
||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
|
||||
# download acme & dkim state from ns.testrun.org
|
||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
|
||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
|
||||
# restore acme & dkim state to staging2.testrun.org
|
||||
rsync -avz acme-restore/acme root@staging2.testrun.org:/var/lib/ || true
|
||||
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
|
||||
|
||||
- name: add hpk42 key to staging server
|
||||
run: ssh root@staging2.testrun.org 'curl -s https://github.com/hpk42.keys >> .ssh/authorized_keys'
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
- run: |
|
||||
cmdeploy init staging2.testrun.org
|
||||
sed -i 's/#\s*mtail_address/mtail_address/' chatmail.ini
|
||||
|
||||
- run: cmdeploy run --verbose --skip-dns-check
|
||||
|
||||
- name: set DNS entries
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||
cmdeploy dns --zonefile staging-generated.zone --verbose
|
||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
||||
cat .github/workflows/staging.testrun.org-default.zone
|
||||
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: cmdeploy test
|
||||
run: CHATMAIL_DOMAIN2=ci-chatmail.testrun.org cmdeploy test --slow
|
||||
|
||||
- name: cmdeploy dns
|
||||
run: cmdeploy dns -v
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -4,7 +4,8 @@ __pycache__/
|
||||
*$py.class
|
||||
*.swp
|
||||
*qr-*.png
|
||||
chatmail.ini
|
||||
chatmail*.ini
|
||||
lxconfigs/
|
||||
|
||||
|
||||
# C extensions
|
||||
@@ -164,8 +165,3 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
chatmail.zone
|
||||
|
||||
# docker
|
||||
/data/
|
||||
/custom/
|
||||
.env
|
||||
|
||||
@@ -121,13 +121,6 @@
|
||||
Provide an "fsreport" CLI for more fine grained analysis of message files.
|
||||
([#637](https://github.com/chatmail/relay/pull/637))
|
||||
|
||||
- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
|
||||
([#614](https://github.com/chatmail/relay/pull/614))
|
||||
|
||||
- Add configuration parameters
|
||||
([#614](https://github.com/chatmail/relay/pull/614)):
|
||||
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
|
||||
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)
|
||||
|
||||
## 1.7.0 2025-09-11
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ where = ['src']
|
||||
[project.scripts]
|
||||
doveauth = "chatmaild.doveauth:main"
|
||||
chatmail-metadata = "chatmaild.metadata:main"
|
||||
chatmail-metrics = "chatmaild.metrics:main"
|
||||
chatmail-expire = "chatmaild.expire:main"
|
||||
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||
lastlogin = "chatmaild.lastlogin:main"
|
||||
|
||||
@@ -47,12 +47,6 @@ class Config:
|
||||
self.addr_v4 = os.environ.get("CHATMAIL_ADDR_V4", "")
|
||||
self.addr_v6 = os.environ.get("CHATMAIL_ADDR_V6", "")
|
||||
self.acme_email = params.get("acme_email", "")
|
||||
self.change_kernel_settings = (
|
||||
params.get("change_kernel_settings", "true").lower() == "true"
|
||||
)
|
||||
self.fs_inotify_max_user_instances_and_watchers = int(
|
||||
params["fs_inotify_max_user_instances_and_watchers"]
|
||||
)
|
||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
||||
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
||||
if "iroh_relay" not in params:
|
||||
@@ -66,6 +60,31 @@ class Config:
|
||||
self.privacy_pdo = params.get("privacy_pdo")
|
||||
self.privacy_supervisor = params.get("privacy_supervisor")
|
||||
|
||||
# TLS certificate management.
|
||||
# If tls_external_cert_and_key is set, use externally managed certs.
|
||||
# Otherwise derived from the domain name:
|
||||
# - Domains starting with "_" use self-signed certificates
|
||||
# - All other domains use ACME.
|
||||
external = params.get("tls_external_cert_and_key", "").strip()
|
||||
|
||||
if external:
|
||||
parts = external.split()
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
"tls_external_cert_and_key must have two space-separated"
|
||||
" paths: CERT_PATH KEY_PATH"
|
||||
)
|
||||
self.tls_cert_mode = "external"
|
||||
self.tls_cert_path, self.tls_key_path = parts
|
||||
elif self.mail_domain.startswith("_"):
|
||||
self.tls_cert_mode = "self"
|
||||
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
|
||||
self.tls_key_path = "/etc/ssl/private/mailserver.key"
|
||||
else:
|
||||
self.tls_cert_mode = "acme"
|
||||
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
|
||||
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
|
||||
|
||||
# deprecated option
|
||||
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
|
||||
self.mailboxes_dir = Path(mbdir.strip())
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
import filelock
|
||||
|
||||
try:
|
||||
import crypt_r
|
||||
except ImportError:
|
||||
@@ -13,6 +16,7 @@ from .dictproxy import DictProxy
|
||||
from .migrate_db import migrate_from_db_to_maildir
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
||||
|
||||
|
||||
def encrypt_password(password: str):
|
||||
@@ -52,6 +56,10 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
)
|
||||
return False
|
||||
|
||||
if not VALID_LOCALPART_RE.match(localpart):
|
||||
logging.warning("localpart %r contains invalid characters", localpart)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -140,8 +148,13 @@ class AuthDictProxy(DictProxy):
|
||||
if not is_allowed_to_create(self.config, addr, cleartext_password):
|
||||
return
|
||||
|
||||
user.set_password(encrypt_password(cleartext_password))
|
||||
print(f"Created address: {addr}", file=sys.stderr)
|
||||
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
|
||||
with lock:
|
||||
userdata = user.get_userdb_dict()
|
||||
if userdata:
|
||||
return userdata
|
||||
user.set_password(encrypt_password(cleartext_password))
|
||||
print(f"Created address: {addr}", file=sys.stderr)
|
||||
return user.get_userdb_dict()
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,20 @@ to show storage summaries only for first 1000 mailboxes
|
||||
|
||||
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 tempfile
|
||||
from argparse import ArgumentParser
|
||||
from datetime import datetime
|
||||
|
||||
@@ -48,7 +59,19 @@ class Report:
|
||||
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.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
|
||||
KiB = 1024
|
||||
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):
|
||||
# categorize login times
|
||||
@@ -68,9 +91,10 @@ class Report:
|
||||
for size in self.message_buckets:
|
||||
for msg in mailbox.messages:
|
||||
if msg.size >= size:
|
||||
if self.mdir and not msg.relpath.startswith(self.mdir):
|
||||
if self.mdir and f"/{self.mdir}/" not in msg.path:
|
||||
continue
|
||||
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_extra += sum(entry.size for entry in mailbox.extrafiles)
|
||||
@@ -93,9 +117,10 @@ class Report:
|
||||
|
||||
pref = f"[{self.mdir}] " if self.mdir else ""
|
||||
for minsize, sumsize in self.message_buckets.items():
|
||||
count = self.message_count_buckets[minsize]
|
||||
percent = (sumsize / all_messages * 100) if all_messages else 0
|
||||
print(
|
||||
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
|
||||
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%), {count} msgs"
|
||||
)
|
||||
|
||||
user_logins = self.num_all_logins - self.num_ci_logins
|
||||
@@ -111,6 +136,75 @@ class Report:
|
||||
for days, active in self.login_buckets.items():
|
||||
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):
|
||||
"""Report about filesystem storage usage of all mailboxes and messages"""
|
||||
@@ -127,19 +221,21 @@ def main(args=None):
|
||||
"--days",
|
||||
default=0,
|
||||
action="store",
|
||||
help="assume date to be days older than now",
|
||||
help="assume date to be DAYS older than now",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-login-age",
|
||||
default=0,
|
||||
metavar="DAYS",
|
||||
dest="min_login_age",
|
||||
action="store",
|
||||
help="only sum up message size if last login is at least min-login-age days old",
|
||||
help="only sum up message size if last login is at least DAYS days old",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mdir",
|
||||
metavar="{cur,new,tmp}",
|
||||
action="store",
|
||||
help="only consider 'cur' or 'new' or 'tmp' messages for summary",
|
||||
help="only consider messages in specified Maildir subdirectory for summary",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -148,6 +244,21 @@ def main(args=None):
|
||||
action="store",
|
||||
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)
|
||||
|
||||
@@ -161,7 +272,15 @@ def main(args=None):
|
||||
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):
|
||||
rep.process_mailbox_stat(mbox)
|
||||
rep.dump_summary()
|
||||
if args.textfile:
|
||||
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__":
|
||||
|
||||
@@ -48,6 +48,13 @@ passthrough_senders =
|
||||
# (space-separated, item may start with "@" to whitelist whole recipient domains)
|
||||
passthrough_recipients =
|
||||
|
||||
# Use externally managed TLS certificates instead of built-in acmetool.
|
||||
# Paths refer to files on the deployment server (not the build machine).
|
||||
# Both files must already exist before running cmdeploy.
|
||||
# Certificate renewal is your responsibility; changed files are
|
||||
# picked up automatically by all relay services.
|
||||
# tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
|
||||
|
||||
# path to www directory - documented here: https://chatmail.at/doc/relay/getting_started.html#custom-web-pages
|
||||
#www_folder = www
|
||||
|
||||
@@ -69,16 +76,6 @@ disable_ipv6 = False
|
||||
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
||||
acme_email =
|
||||
|
||||
#
|
||||
# Kernel settings
|
||||
#
|
||||
|
||||
# if you set "True", the kernel settings will be configured according to the values below
|
||||
change_kernel_settings = True
|
||||
|
||||
# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings
|
||||
fs_inotify_max_user_instances_and_watchers = 65535
|
||||
|
||||
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
|
||||
# service.
|
||||
# If you set it to anything else, the service will be disabled
|
||||
|
||||
@@ -101,7 +101,11 @@ class MetadataDictProxy(DictProxy):
|
||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
||||
return f"O{self.iroh_relay}\n"
|
||||
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
|
||||
res = turn_credentials()
|
||||
try:
|
||||
res = turn_credentials()
|
||||
except Exception:
|
||||
logging.exception("failed to get TURN credentials")
|
||||
return "N\n"
|
||||
port = 3478
|
||||
return f"O{self.turn_hostname}:{port}:{res}\n"
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
#!/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,9 +3,9 @@
|
||||
"""CGI script for creating new accounts."""
|
||||
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from urllib.parse import quote
|
||||
|
||||
from chatmaild.config import Config, read_config
|
||||
|
||||
@@ -15,7 +15,9 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
||||
|
||||
|
||||
def create_newemail_dict(config: Config):
|
||||
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length))
|
||||
user = "".join(
|
||||
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
||||
)
|
||||
password = "".join(
|
||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||
for _ in range(config.password_min_length + 3)
|
||||
@@ -23,13 +25,26 @@ def create_newemail_dict(config: Config):
|
||||
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
|
||||
|
||||
|
||||
def create_dclogin_url(email, password):
|
||||
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
|
||||
|
||||
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
|
||||
can connect to servers with self-signed TLS certificates.
|
||||
"""
|
||||
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
|
||||
|
||||
|
||||
def print_new_account():
|
||||
config = read_config(CONFIG_PATH)
|
||||
creds = create_newemail_dict(config)
|
||||
|
||||
result = dict(email=creds["email"], password=creds["password"])
|
||||
if config.tls_cert_mode == "self":
|
||||
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
|
||||
|
||||
print("Content-Type: application/json")
|
||||
print("")
|
||||
print(json.dumps(creds))
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -85,13 +85,13 @@ def mockout():
|
||||
captured_green = []
|
||||
captured_plain = []
|
||||
|
||||
def red(self, msg):
|
||||
def red(self, msg, **kw):
|
||||
self.captured_red.append(msg)
|
||||
|
||||
def green(self, msg):
|
||||
def green(self, msg, **kw):
|
||||
self.captured_green.append(msg)
|
||||
|
||||
def __call__(self, msg):
|
||||
def print(self, msg="", **kw):
|
||||
self.captured_plain.append(msg)
|
||||
|
||||
return MockOut()
|
||||
|
||||
@@ -73,3 +73,51 @@ def test_config_userstate_paths(make_config, tmp_path):
|
||||
def test_config_max_message_size(make_config, tmp_path):
|
||||
config = make_config("something.testrun.org", dict(max_message_size="10000"))
|
||||
assert config.max_message_size == 10000
|
||||
|
||||
|
||||
def test_config_tls_default_acme(make_config):
|
||||
config = make_config("chat.example.org")
|
||||
assert config.tls_cert_mode == "acme"
|
||||
assert config.tls_cert_path == "/var/lib/acme/live/chat.example.org/fullchain"
|
||||
assert config.tls_key_path == "/var/lib/acme/live/chat.example.org/privkey"
|
||||
|
||||
|
||||
def test_config_tls_self(make_config):
|
||||
config = make_config("_test.example.org")
|
||||
assert config.tls_cert_mode == "self"
|
||||
assert config.tls_cert_path == "/etc/ssl/certs/mailserver.pem"
|
||||
assert config.tls_key_path == "/etc/ssl/private/mailserver.key"
|
||||
|
||||
|
||||
def test_config_tls_external(make_config):
|
||||
config = make_config(
|
||||
"chat.example.org",
|
||||
{
|
||||
"tls_external_cert_and_key": "/custom/fullchain.pem /custom/privkey.pem",
|
||||
},
|
||||
)
|
||||
assert config.tls_cert_mode == "external"
|
||||
assert config.tls_cert_path == "/custom/fullchain.pem"
|
||||
assert config.tls_key_path == "/custom/privkey.pem"
|
||||
|
||||
|
||||
def test_config_tls_external_overrides_underscore(make_config):
|
||||
config = make_config(
|
||||
"_test.example.org",
|
||||
{
|
||||
"tls_external_cert_and_key": "/certs/fullchain.pem /certs/privkey.pem",
|
||||
},
|
||||
)
|
||||
assert config.tls_cert_mode == "external"
|
||||
assert config.tls_cert_path == "/certs/fullchain.pem"
|
||||
assert config.tls_key_path == "/certs/privkey.pem"
|
||||
|
||||
|
||||
def test_config_tls_external_bad_format(make_config):
|
||||
with pytest.raises(ValueError, match="two space-separated"):
|
||||
make_config(
|
||||
"chat.example.org",
|
||||
{
|
||||
"tls_external_cert_and_key": "/only/one/path.pem",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -120,6 +120,60 @@ def test_handle_dovecot_protocol_iterate(gencreds, example_config):
|
||||
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):
|
||||
num_threads = 50
|
||||
req_per_thread = 5
|
||||
|
||||
@@ -112,6 +112,43 @@ def test_report(mbox1, example_config):
|
||||
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):
|
||||
args = (str(example_config._inipath),)
|
||||
expiry_main(args)
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import shutil
|
||||
import smtplib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("filtermail") is None,
|
||||
reason="filtermail binary not found",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtpserver():
|
||||
@@ -41,6 +47,8 @@ def test_one_mail(
|
||||
make_config, make_popen, smtpserver, maildata, filtermail_mode, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("PYTHONUNBUFFERED", "1")
|
||||
# DKIM is tested by cmdeploy tests.
|
||||
monkeypatch.setenv("FILTERMAIL_SKIP_DKIM", "1")
|
||||
smtp_inject_port = 20025
|
||||
if filtermail_mode == "outgoing":
|
||||
settings = dict(
|
||||
@@ -58,6 +66,10 @@ def test_one_mail(
|
||||
|
||||
popen = make_popen(["filtermail", path, filtermail_mode])
|
||||
line = popen.stderr.readline().strip()
|
||||
|
||||
# skip a warning that FILTERMAIL_SKIP_DKIM shouldn't be used in prod
|
||||
if b"DKIM verification DISABLED!" in line:
|
||||
line = popen.stderr.readline().strip()
|
||||
if b"loop" not in line:
|
||||
print(line.decode("ascii"), file=sys.stderr)
|
||||
pytest.fail("starting filtermail failed")
|
||||
|
||||
@@ -314,6 +314,51 @@ def test_persistent_queue_items(tmp_path, testaddr, token):
|
||||
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):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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,7 +1,11 @@
|
||||
import json
|
||||
|
||||
import chatmaild
|
||||
from chatmaild.newemail import create_newemail_dict, print_new_account
|
||||
from chatmaild.newemail import (
|
||||
create_dclogin_url,
|
||||
create_newemail_dict,
|
||||
print_new_account,
|
||||
)
|
||||
|
||||
|
||||
def test_create_newemail_dict(example_config):
|
||||
@@ -15,6 +19,18 @@ def test_create_newemail_dict(example_config):
|
||||
assert ac1["password"] != ac2["password"]
|
||||
|
||||
|
||||
def test_create_dclogin_url():
|
||||
url = create_dclogin_url("user@example.org", "p@ss w+rd")
|
||||
assert url.startswith("dclogin:")
|
||||
assert "v=1" in url
|
||||
assert "ic=3" in url
|
||||
|
||||
assert "user@example.org" in url
|
||||
# password special chars must be encoded
|
||||
assert "p%40ss" in url
|
||||
assert "w%2Brd" in url
|
||||
|
||||
|
||||
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
|
||||
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
|
||||
print_new_account()
|
||||
@@ -25,3 +41,20 @@ def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_conf
|
||||
dic = json.loads(lines[2])
|
||||
assert dic["email"].endswith(f"@{example_config.mail_domain}")
|
||||
assert len(dic["password"]) >= 10
|
||||
# default tls_cert=acme should not include dclogin_url
|
||||
assert "dclogin_url" not in dic
|
||||
|
||||
|
||||
def test_print_new_account_self_signed(capsys, monkeypatch, make_config):
|
||||
config = make_config("_test.example.org")
|
||||
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(config._inipath))
|
||||
print_new_account()
|
||||
out, err = capsys.readouterr()
|
||||
lines = out.split("\n")
|
||||
dic = json.loads(lines[2])
|
||||
assert "dclogin_url" in dic
|
||||
url = dic["dclogin_url"]
|
||||
assert url.startswith("dclogin:")
|
||||
assert "ic=3" in url
|
||||
|
||||
assert dic["email"].split("@")[0] in url
|
||||
|
||||
73
chatmaild/src/chatmaild/tests/test_turnserver.py
Normal file
73
chatmaild/src/chatmaild/tests/test_turnserver.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from chatmaild.turnserver import turn_credentials
|
||||
|
||||
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def turn_socket(tmp_path):
|
||||
"""Create a real Unix socket server at a temp path."""
|
||||
sock_path = str(tmp_path / "turn.socket")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(sock_path)
|
||||
server.listen(1)
|
||||
yield sock_path, server
|
||||
server.close()
|
||||
|
||||
|
||||
def _call_turn_credentials(sock_path):
|
||||
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
|
||||
original_connect = socket.socket.connect
|
||||
|
||||
def patched_connect(self, address):
|
||||
if address == SOCKET_PATH:
|
||||
address = sock_path
|
||||
return original_connect(self, address)
|
||||
|
||||
with patch.object(socket.socket, "connect", patched_connect):
|
||||
return turn_credentials()
|
||||
|
||||
|
||||
def test_turn_credentials_timeout(turn_socket):
|
||||
"""Server accepts but never responds — must raise socket.timeout."""
|
||||
sock_path, server = turn_socket
|
||||
|
||||
def accept_and_hang():
|
||||
conn, _ = server.accept()
|
||||
time.sleep(30)
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=accept_and_hang, daemon=True)
|
||||
t.start()
|
||||
|
||||
with pytest.raises(socket.timeout):
|
||||
_call_turn_credentials(sock_path)
|
||||
|
||||
|
||||
def test_turn_credentials_connection_refused(tmp_path):
|
||||
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
|
||||
missing = str(tmp_path / "nonexistent.socket")
|
||||
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
|
||||
_call_turn_credentials(missing)
|
||||
|
||||
|
||||
def test_turn_credentials_success(turn_socket):
|
||||
"""Server responds with credentials — must return stripped string."""
|
||||
sock_path, server = turn_socket
|
||||
|
||||
def respond():
|
||||
conn, _ = server.accept()
|
||||
conn.sendall(b"testuser:testpass\n")
|
||||
conn.close()
|
||||
|
||||
t = threading.Thread(target=respond, daemon=True)
|
||||
t.start()
|
||||
|
||||
result = _call_turn_credentials(sock_path)
|
||||
assert result == "testuser:testpass"
|
||||
@@ -4,6 +4,7 @@ import socket
|
||||
|
||||
def turn_credentials() -> str:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
||||
client_socket.settimeout(5)
|
||||
client_socket.connect("/run/chatmail-turn/turn.socket")
|
||||
with client_socket.makefile("rb") as file:
|
||||
return file.readline().decode("utf-8").strip()
|
||||
|
||||
@@ -20,6 +20,7 @@ dependencies = [
|
||||
"pytest-xdist",
|
||||
"execnet",
|
||||
"imap_tools",
|
||||
"deltachat-rpc-client",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -67,7 +67,7 @@ class AcmetoolDeployer(Deployer):
|
||||
)
|
||||
files.template(
|
||||
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",
|
||||
group="root",
|
||||
mode="644",
|
||||
|
||||
@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
|
||||
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import importlib.resources
|
||||
import io
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
from pyinfra.operations import files, server, systemd
|
||||
|
||||
@@ -10,6 +11,28 @@ def has_systemd():
|
||||
return os.path.isdir("/run/systemd/system")
|
||||
|
||||
|
||||
@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__):
|
||||
return importlib.resources.files(pkg).joinpath(arg)
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
;
|
||||
; 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 }}.
|
||||
_mta-sts.{{ mail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
|
||||
mta-sts.{{ mail_domain }}. CNAME {{ mail_domain }}.
|
||||
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 }}.
|
||||
@@ -5,7 +5,6 @@ along with command line option and subcommand parsing.
|
||||
|
||||
import argparse
|
||||
import importlib.resources
|
||||
import importlib.util
|
||||
import os
|
||||
import pathlib
|
||||
import shutil
|
||||
@@ -16,10 +15,27 @@ from pathlib import Path
|
||||
import pyinfra
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
from packaging import version
|
||||
from termcolor import colored
|
||||
|
||||
from . import dns, remote
|
||||
from .sshexec import LocalExec, SSHExec
|
||||
from .lxc.cli import (
|
||||
lxc_start_cmd,
|
||||
lxc_start_cmd_options,
|
||||
lxc_status_cmd,
|
||||
lxc_status_cmd_options,
|
||||
lxc_stop_cmd,
|
||||
lxc_stop_cmd_options,
|
||||
lxc_test_cmd,
|
||||
lxc_test_cmd_options,
|
||||
)
|
||||
from .lxc.incus import DNSConfigurationError
|
||||
from .sshexec import (
|
||||
LocalExec,
|
||||
SSHExec,
|
||||
resolve_host_from_ssh_config,
|
||||
resolve_key_from_ssh_config,
|
||||
)
|
||||
from .util import Out
|
||||
from .www import main as webdev_main
|
||||
|
||||
#
|
||||
# cmdeploy sub commands and options
|
||||
@@ -83,17 +99,21 @@ def run_cmd_options(parser):
|
||||
help="disable checks nslookup for dns",
|
||||
)
|
||||
add_ssh_host_option(parser)
|
||||
add_ssh_config_option(parser)
|
||||
|
||||
|
||||
def run_cmd(args, out):
|
||||
"""Deploy chatmail services on the remote server."""
|
||||
|
||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
||||
sshexec = get_sshexec(ssh_host)
|
||||
sshexec = get_sshexec(ssh_host, ssh_config=args.ssh_config)
|
||||
require_iroh = args.config.enable_iroh_relay
|
||||
strict_tls = args.config.tls_cert_mode == "acme"
|
||||
if not args.dns_check_disabled:
|
||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||
if not dns.check_initial_remote_data(remote_data, print=out.red):
|
||||
if not dns.check_initial_remote_data(
|
||||
remote_data, strict_tls=strict_tls, print=out.red
|
||||
):
|
||||
return 1
|
||||
|
||||
env = os.environ.copy()
|
||||
@@ -104,13 +124,28 @@ def run_cmd(args, out):
|
||||
if not args.dns_check_disabled:
|
||||
env["CHATMAIL_ADDR_V4"] = remote_data.get("A") or ""
|
||||
env["CHATMAIL_ADDR_V6"] = remote_data.get("AAAA") or ""
|
||||
env["DEBIAN_FRONTEND"] = "noninteractive"
|
||||
env["TERM"] = "linux"
|
||||
deploy_path = importlib.resources.files(__package__).joinpath("run.py").resolve()
|
||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||
|
||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
|
||||
ssh_config = args.ssh_config
|
||||
if ssh_config:
|
||||
ssh_config = str(Path(ssh_config).resolve())
|
||||
|
||||
# Use pyinfra's native SSH data keys to configure the connection directly
|
||||
# rather than relying on paramiko config parsing (see also sshexec.py)
|
||||
ip = resolve_host_from_ssh_config(ssh_host, ssh_config)
|
||||
key = resolve_key_from_ssh_config(ssh_host, ssh_config)
|
||||
data_args = f"--data ssh_hostname={ip} --data ssh_known_hosts_file=/dev/null"
|
||||
if key:
|
||||
data_args += f" --data ssh_key={key}"
|
||||
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y {data_args}"
|
||||
if ssh_host in ["localhost", "@docker"]:
|
||||
if ssh_host == "@docker":
|
||||
env["CHATMAIL_DOCKER"] = "True"
|
||||
env["CHATMAIL_NOPORTCHECK"] = "True"
|
||||
env["CHATMAIL_NOSYSCTL"] = "True"
|
||||
cmd = f"{pyinf} @local {deploy_path} -y"
|
||||
|
||||
if version.parse(pyinfra.__version__) < version.parse("3"):
|
||||
@@ -118,24 +153,25 @@ def run_cmd(args, out):
|
||||
return 1
|
||||
|
||||
try:
|
||||
retcode = out.check_call(cmd, env=env)
|
||||
ret = out.shell(cmd, env=env)
|
||||
if ret:
|
||||
out.red("Deploy failed")
|
||||
return 1
|
||||
if args.website_only:
|
||||
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 not remote_data["acme_account_url"]:
|
||||
out.green("Website deployment completed.")
|
||||
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("Run 'cmdeploy run' again")
|
||||
retcode = 0
|
||||
else:
|
||||
out.red("Deploy failed")
|
||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||
return 0
|
||||
except subprocess.CalledProcessError:
|
||||
out.red("Deploy failed")
|
||||
retcode = 1
|
||||
return retcode
|
||||
return 1
|
||||
|
||||
|
||||
def dns_cmd_options(parser):
|
||||
@@ -144,20 +180,23 @@ def dns_cmd_options(parser):
|
||||
dest="zonefile",
|
||||
type=pathlib.Path,
|
||||
default=None,
|
||||
help="write out a zonefile",
|
||||
help="write DNS records in standard BIND format to the given file",
|
||||
)
|
||||
add_ssh_host_option(parser)
|
||||
add_ssh_config_option(parser)
|
||||
|
||||
|
||||
def dns_cmd(args, out):
|
||||
"""Check DNS entries and optionally generate dns zone file."""
|
||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
|
||||
tls_cert_mode = args.config.tls_cert_mode
|
||||
strict_tls = tls_cert_mode == "acme"
|
||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||
if not remote_data:
|
||||
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls):
|
||||
return 1
|
||||
|
||||
if not remote_data["acme_account_url"]:
|
||||
if strict_tls and not remote_data["acme_account_url"]:
|
||||
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
|
||||
return 1
|
||||
|
||||
@@ -165,6 +204,7 @@ def dns_cmd(args, out):
|
||||
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
|
||||
return 1
|
||||
|
||||
remote_data["strict_tls"] = strict_tls
|
||||
zonefile = dns.get_filled_zone_file(remote_data)
|
||||
|
||||
if args.zonefile:
|
||||
@@ -180,13 +220,14 @@ def dns_cmd(args, out):
|
||||
|
||||
def status_cmd_options(parser):
|
||||
add_ssh_host_option(parser)
|
||||
add_ssh_config_option(parser)
|
||||
|
||||
|
||||
def status_cmd(args, out):
|
||||
"""Display status for online chatmail instance."""
|
||||
|
||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose, ssh_config=args.ssh_config)
|
||||
|
||||
out.green(f"chatmail domain: {args.config.mail_domain}")
|
||||
if args.config.privacy_mail:
|
||||
@@ -205,31 +246,33 @@ def test_cmd_options(parser):
|
||||
action="store_true",
|
||||
help="also run slow tests",
|
||||
)
|
||||
add_ssh_host_option(parser)
|
||||
add_ssh_config_option(parser)
|
||||
|
||||
|
||||
def test_cmd(args, out):
|
||||
"""Run local and online tests for chatmail deployment.
|
||||
"""Run local and online tests for chatmail deployment."""
|
||||
|
||||
This will automatically pip-install 'deltachat' if it's not available.
|
||||
"""
|
||||
|
||||
x = importlib.util.find_spec("deltachat")
|
||||
if x is None:
|
||||
out.check_call(f"{sys.executable} -m pip install deltachat")
|
||||
env = os.environ.copy()
|
||||
env["CHATMAIL_INI"] = str(args.inipath.resolve())
|
||||
|
||||
pytest_path = shutil.which("pytest")
|
||||
pytest_args = [
|
||||
pytest_path,
|
||||
"cmdeploy/src/",
|
||||
"-n4",
|
||||
"-rs",
|
||||
"-x",
|
||||
"-v",
|
||||
"-vv" if args.verbose > 1 else "-v",
|
||||
"-s",
|
||||
"--durations=5",
|
||||
]
|
||||
if args.slow:
|
||||
pytest_args.append("--slow")
|
||||
ret = out.run_ret(pytest_args)
|
||||
if args.ssh_host:
|
||||
pytest_args.extend(["--ssh-host", args.ssh_host])
|
||||
if args.ssh_config:
|
||||
pytest_args.extend(["--ssh-config", str(Path(args.ssh_config).resolve())])
|
||||
ret = out.shell(" ".join(pytest_args), env=env)
|
||||
return ret
|
||||
|
||||
|
||||
@@ -266,8 +309,8 @@ def fmt_cmd(args, out):
|
||||
format_args.extend(sources)
|
||||
check_args.extend(sources)
|
||||
|
||||
out.check_call(" ".join(format_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(check_args), quiet=not args.verbose)
|
||||
out.shell(" ".join(format_args), quiet=not args.verbose)
|
||||
out.shell(" ".join(check_args), quiet=not args.verbose)
|
||||
|
||||
|
||||
def bench_cmd(args, out):
|
||||
@@ -280,9 +323,7 @@ def bench_cmd(args, out):
|
||||
|
||||
def webdev_cmd(args, out):
|
||||
"""Run local web development loop for static web pages."""
|
||||
from .www import main
|
||||
|
||||
main()
|
||||
webdev_main()
|
||||
|
||||
|
||||
#
|
||||
@@ -290,32 +331,6 @@ def webdev_cmd(args, out):
|
||||
#
|
||||
|
||||
|
||||
class Out:
|
||||
"""Convenience output printer providing coloring."""
|
||||
|
||||
def red(self, msg, file=sys.stderr):
|
||||
print(colored(msg, "red"), file=file)
|
||||
|
||||
def green(self, msg, file=sys.stderr):
|
||||
print(colored(msg, "green"), file=file)
|
||||
|
||||
def __call__(self, msg, red=False, green=False, file=sys.stdout):
|
||||
color = "red" if red else ("green" if green else None)
|
||||
print(colored(msg, color), file=file)
|
||||
|
||||
def check_call(self, arg, env=None, quiet=False):
|
||||
if not quiet:
|
||||
self(f"[$ {arg}]", file=sys.stderr)
|
||||
return subprocess.check_call(arg, shell=True, env=env)
|
||||
|
||||
def run_ret(self, args, env=None, quiet=False):
|
||||
if not quiet:
|
||||
cmdstring = " ".join(args)
|
||||
self(f"[$ {cmdstring}]", file=sys.stderr)
|
||||
proc = subprocess.run(args, env=env, check=False)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def add_ssh_host_option(parser):
|
||||
parser.add_argument(
|
||||
"--ssh-host",
|
||||
@@ -325,34 +340,45 @@ def add_ssh_host_option(parser):
|
||||
)
|
||||
|
||||
|
||||
def add_ssh_config_option(parser):
|
||||
parser.add_argument(
|
||||
"--ssh-config",
|
||||
dest="ssh_config",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Path to an SSH config file (e.g. lxconfigs/ssh-config).",
|
||||
)
|
||||
|
||||
|
||||
def add_config_option(parser):
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
dest="inipath",
|
||||
action="store",
|
||||
default=Path("chatmail.ini"),
|
||||
default=Path(os.environ.get("CHATMAIL_INI", "chatmail.ini")),
|
||||
type=Path,
|
||||
help="path to the chatmail.ini file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
dest="verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="provide verbose logging",
|
||||
)
|
||||
|
||||
|
||||
def add_subcommand(subparsers, func):
|
||||
def add_subcommand(subparsers, func, add_config=True):
|
||||
name = func.__name__
|
||||
assert name.endswith("_cmd")
|
||||
name = name[:-4]
|
||||
name = name[:-4].replace("_", "-")
|
||||
doc = func.__doc__.strip()
|
||||
help = doc.split("\n")[0].strip(".")
|
||||
p = subparsers.add_parser(name, description=doc, help=help)
|
||||
p.set_defaults(func=func)
|
||||
add_config_option(p)
|
||||
if add_config:
|
||||
add_config_option(p)
|
||||
p.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
dest="verbose",
|
||||
action="count",
|
||||
default=0,
|
||||
help="increase verbosity (can be repeated: -v, -vv)",
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
@@ -361,45 +387,60 @@ Setup your chatmail server configuration and
|
||||
deploy it via SSH to your remote location.
|
||||
"""
|
||||
|
||||
# Explicit subcommand registry: (cmd_func, options_func_or_None, needs_config).
|
||||
# LXC commands don't need a chatmail.ini (no config); all others do.
|
||||
SUBCOMMANDS = [
|
||||
(init_cmd, init_cmd_options, True),
|
||||
(run_cmd, run_cmd_options, True),
|
||||
(dns_cmd, dns_cmd_options, True),
|
||||
(status_cmd, status_cmd_options, True),
|
||||
(test_cmd, test_cmd_options, True),
|
||||
(fmt_cmd, fmt_cmd_options, True),
|
||||
(bench_cmd, None, True),
|
||||
(webdev_cmd, None, True),
|
||||
(lxc_start_cmd, lxc_start_cmd_options, False),
|
||||
(lxc_stop_cmd, lxc_stop_cmd_options, False),
|
||||
(lxc_status_cmd, lxc_status_cmd_options, False),
|
||||
(lxc_test_cmd, lxc_test_cmd_options, False),
|
||||
]
|
||||
|
||||
|
||||
def get_parser():
|
||||
"""Return an ArgumentParser for the 'cmdeploy' CLI"""
|
||||
|
||||
parser = argparse.ArgumentParser(description=description.strip())
|
||||
parser.set_defaults(func=None, inipath=None)
|
||||
subparsers = parser.add_subparsers(title="subcommands")
|
||||
|
||||
# find all subcommands in the module namespace
|
||||
glob = globals()
|
||||
for name, func in glob.items():
|
||||
if name.endswith("_cmd"):
|
||||
subparser = add_subcommand(subparsers, func)
|
||||
addopts = glob.get(name + "_options")
|
||||
if addopts is not None:
|
||||
addopts(subparser)
|
||||
for func, addopts, needs_config in SUBCOMMANDS:
|
||||
subparser = add_subcommand(subparsers, func, add_config=needs_config)
|
||||
if addopts is not None:
|
||||
addopts(subparser)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_sshexec(ssh_host: str, verbose=True):
|
||||
def get_sshexec(ssh_host: str, verbose=True, ssh_config=None):
|
||||
if ssh_host in ["localhost", "@local"]:
|
||||
return LocalExec(verbose, docker=False)
|
||||
elif ssh_host == "@docker":
|
||||
return LocalExec(verbose, docker=True)
|
||||
if verbose:
|
||||
print(f"[ssh] login to {ssh_host}")
|
||||
return SSHExec(ssh_host, verbose=verbose)
|
||||
return SSHExec(ssh_host, verbose=verbose, ssh_config=ssh_config)
|
||||
|
||||
|
||||
def main(args=None):
|
||||
"""Provide main entry point for 'cmdeploy' CLI invocation."""
|
||||
parser = get_parser()
|
||||
args = parser.parse_args(args=args)
|
||||
if not hasattr(args, "func"):
|
||||
if args.func is None:
|
||||
return parser.parse_args(["-h"])
|
||||
|
||||
out = Out()
|
||||
out = Out(verbosity=args.verbose)
|
||||
kwargs = {}
|
||||
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
||||
|
||||
if args.inipath is not None and args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
||||
if not args.inipath.exists():
|
||||
out.red(f"expecting {args.inipath} to exist, run init first?")
|
||||
raise SystemExit(1)
|
||||
@@ -414,6 +455,9 @@ def main(args=None):
|
||||
if res is None:
|
||||
res = 0
|
||||
return res
|
||||
except DNSConfigurationError as exc:
|
||||
out.red(str(exc))
|
||||
return 1
|
||||
except KeyboardInterrupt:
|
||||
out.red("KeyboardInterrupt")
|
||||
sys.exit(130)
|
||||
|
||||
@@ -2,37 +2,40 @@
|
||||
Chat Mail pyinfra deploy.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from io import StringIO
|
||||
from io import BytesIO, StringIO
|
||||
from pathlib import Path
|
||||
|
||||
from chatmaild.config import read_config
|
||||
from pyinfra import facts, host, logger
|
||||
from pyinfra.facts import hardware
|
||||
from pyinfra.api import FactBase
|
||||
from pyinfra.facts import hardware
|
||||
from pyinfra.facts.files import Sha256File
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
from pyinfra.operations import apt, files, pip, server, systemd
|
||||
|
||||
from cmdeploy.cmdeploy import Out
|
||||
|
||||
from .acmetool import AcmetoolDeployer
|
||||
from .basedeploy import (
|
||||
Deployer,
|
||||
Deployment,
|
||||
activate_remote_units,
|
||||
blocked_service_startup,
|
||||
configure_remote_units,
|
||||
get_resource,
|
||||
has_systemd,
|
||||
)
|
||||
from .dovecot.deployer import DovecotDeployer
|
||||
from .external.deployer import ExternalTlsDeployer
|
||||
from .filtermail.deployer import FiltermailDeployer
|
||||
from .mtail.deployer import MtailDeployer
|
||||
from .nginx.deployer import NginxDeployer
|
||||
from .opendkim.deployer import OpendkimDeployer
|
||||
from .postfix.deployer import PostfixDeployer
|
||||
from .selfsigned.deployer import SelfSignedTlsDeployer
|
||||
from .util import Out, get_version_string
|
||||
from .www import build_webpages, find_merge_conflict, get_paths
|
||||
|
||||
|
||||
@@ -120,7 +123,6 @@ def _install_remote_venv_with_chatmaild() -> None:
|
||||
|
||||
def _configure_remote_venv_with_chatmaild(config) -> None:
|
||||
remote_base_dir = "/usr/local/lib/chatmaild"
|
||||
remote_venv_dir = f"{remote_base_dir}/venv"
|
||||
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
||||
root_owned = dict(user="root", group="root", mode="644")
|
||||
|
||||
@@ -131,16 +133,13 @@ def _configure_remote_venv_with_chatmaild(config) -> None:
|
||||
**root_owned,
|
||||
)
|
||||
|
||||
files.template(
|
||||
src=get_resource("metrics.cron.j2"),
|
||||
dest="/etc/cron.d/chatmail-metrics",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={
|
||||
"mailboxes_dir": config.mailboxes_dir,
|
||||
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
|
||||
},
|
||||
files.file(
|
||||
path="/etc/cron.d/chatmail-metrics",
|
||||
present=False,
|
||||
)
|
||||
files.file(
|
||||
path="/var/www/html/metrics",
|
||||
present=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -150,33 +149,16 @@ class UnboundDeployer(Deployer):
|
||||
self.need_restart = False
|
||||
|
||||
def install(self):
|
||||
# Run local DNS resolver `unbound`.
|
||||
# `resolvconf` takes care of setting up /etc/resolv.conf
|
||||
# to use 127.0.0.1 as the resolver.
|
||||
# Run local DNS resolver `unbound`. `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.
|
||||
# Here, we use policy-rc.d to prevent unbound from starting up
|
||||
# on initial install. Later, we will configure it and start it.
|
||||
#
|
||||
# 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)
|
||||
# On an IPv4-only system, if unbound is started but not configured,
|
||||
# it causes subsequent steps to fail to resolve hosts.
|
||||
with blocked_service_startup():
|
||||
apt.packages(
|
||||
name="Install unbound",
|
||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||
)
|
||||
|
||||
def configure(self):
|
||||
server.shell(
|
||||
@@ -268,9 +250,18 @@ class WebsiteDeployer(Deployer):
|
||||
# if www_folder is a hugo page, build it
|
||||
if build_dir:
|
||||
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
|
||||
files.rsync(
|
||||
f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"]
|
||||
# pyinfra files.rsync (experimental) causes problems with ssh-config configuration
|
||||
# the stable files.sync should do
|
||||
files.sync(
|
||||
src=str(www_path),
|
||||
dest="/var/www/html",
|
||||
user="www-data",
|
||||
group="www-data",
|
||||
delete=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -334,12 +325,12 @@ class TurnDeployer(Deployer):
|
||||
def install(self):
|
||||
(url, sha256sum) = {
|
||||
"x86_64": (
|
||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
|
||||
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
|
||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-x86_64-linux",
|
||||
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
|
||||
),
|
||||
"aarch64": (
|
||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
|
||||
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
|
||||
"https://github.com/chatmail/chatmail-turn/releases/download/v0.4/chatmail-turn-aarch64-linux",
|
||||
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
|
||||
),
|
||||
}[host.get_fact(facts.server.Arch)]
|
||||
|
||||
@@ -472,10 +463,19 @@ class ChatmailDeployer(Deployer):
|
||||
("iroh", None, None),
|
||||
]
|
||||
|
||||
def __init__(self, mail_domain):
|
||||
self.mail_domain = mail_domain
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
self.mail_domain = config.mail_domain
|
||||
|
||||
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.upgrade(name="upgrade apt packages", auto_remove=True)
|
||||
|
||||
@@ -494,6 +494,17 @@ class ChatmailDeployer(Deployer):
|
||||
)
|
||||
|
||||
def configure(self):
|
||||
# Ensure the per-domain mailbox directory exists before
|
||||
# chatmail-metadata starts (it crashes without it).
|
||||
files.directory(
|
||||
name="Ensure vmail mailbox directory exists",
|
||||
path=f"/home/vmail/mail/{self.mail_domain}",
|
||||
user="vmail",
|
||||
group="vmail",
|
||||
mode="700",
|
||||
present=True,
|
||||
)
|
||||
|
||||
# This file is used by auth proxy.
|
||||
# https://wiki.debian.org/EtcMailName
|
||||
server.shell(
|
||||
@@ -503,6 +514,15 @@ class ChatmailDeployer(Deployer):
|
||||
],
|
||||
)
|
||||
|
||||
files.directory(
|
||||
name=f"Ensure mailboxes directory {self.config.mailboxes_dir} exists",
|
||||
path=str(self.config.mailboxes_dir),
|
||||
user="vmail",
|
||||
group="vmail",
|
||||
mode="700",
|
||||
present=True,
|
||||
)
|
||||
|
||||
|
||||
class FcgiwrapDeployer(Deployer):
|
||||
def install(self):
|
||||
@@ -522,29 +542,34 @@ class FcgiwrapDeployer(Deployer):
|
||||
|
||||
class GithashDeployer(Deployer):
|
||||
def activate(self):
|
||||
try:
|
||||
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
|
||||
except Exception:
|
||||
git_hash = "unknown\n"
|
||||
try:
|
||||
git_diff = subprocess.check_output(["git", "diff"]).decode()
|
||||
except Exception:
|
||||
git_diff = ""
|
||||
files.put(
|
||||
name="Upload chatmail relay git commit hash",
|
||||
src=StringIO(git_hash + git_diff),
|
||||
src=StringIO(get_version_string()),
|
||||
dest="/etc/chatmail-version",
|
||||
mode="700",
|
||||
)
|
||||
|
||||
|
||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, docker: bool) -> None:
|
||||
def get_tls_deployer(config, mail_domain):
|
||||
"""Select the appropriate TLS deployer based on config."""
|
||||
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
||||
|
||||
if config.tls_cert_mode == "acme":
|
||||
return AcmetoolDeployer(config.acme_email, tls_domains)
|
||||
elif config.tls_cert_mode == "self":
|
||||
return SelfSignedTlsDeployer(mail_domain)
|
||||
elif config.tls_cert_mode == "external":
|
||||
return ExternalTlsDeployer(config.tls_cert_path, config.tls_key_path)
|
||||
else:
|
||||
raise ValueError(f"Unknown tls_cert_mode: {config.tls_cert_mode}")
|
||||
|
||||
|
||||
def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -> None:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
:param config_path: path to chatmail.ini
|
||||
:param disable_mail: whether to disable postfix & dovecot
|
||||
:param website_only: if True, only deploy the website
|
||||
:param docker: whether it is running in a docker container
|
||||
"""
|
||||
config = read_config(config_path)
|
||||
check_config(config)
|
||||
@@ -563,19 +588,32 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, d
|
||||
)
|
||||
|
||||
# Check if mtail_address interface is available (if configured)
|
||||
if config.mtail_address and config.mtail_address not in ('127.0.0.1', '::1', 'localhost'):
|
||||
if config.mtail_address and config.mtail_address not in (
|
||||
"127.0.0.1",
|
||||
"::1",
|
||||
"localhost",
|
||||
):
|
||||
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
|
||||
if config.mtail_address not in all_addresses:
|
||||
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)
|
||||
|
||||
if not docker:
|
||||
if not os.environ.get("CHATMAIL_NOPORTCHECK"):
|
||||
port_services = [
|
||||
(["master", "smtpd"], 25),
|
||||
("unbound", 53),
|
||||
("acmetool", 80),
|
||||
]
|
||||
if config.tls_cert_mode == "acme":
|
||||
port_services.append(("acmetool", 402))
|
||||
port_services += [
|
||||
(["imap-login", "dovecot"], 143),
|
||||
# acmetool previously listened on port 80,
|
||||
# so don't complain during upgrade that moved it to port 402
|
||||
# and gave the port to nginx.
|
||||
(["acmetool", "nginx"], 80),
|
||||
("nginx", 443),
|
||||
(["master", "smtpd"], 465),
|
||||
(["master", "smtpd"], 587),
|
||||
@@ -600,17 +638,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool, d
|
||||
)
|
||||
exit(1)
|
||||
|
||||
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
|
||||
tls_deployer = get_tls_deployer(config, mail_domain)
|
||||
|
||||
all_deployers = [
|
||||
ChatmailDeployer(mail_domain),
|
||||
ChatmailDeployer(config),
|
||||
LegacyRemoveDeployer(),
|
||||
FiltermailDeployer(),
|
||||
JournaldDeployer(),
|
||||
UnboundDeployer(config),
|
||||
TurnDeployer(mail_domain),
|
||||
IrohDeployer(config.enable_iroh_relay),
|
||||
AcmetoolDeployer(config.acme_email, tls_domains),
|
||||
tls_deployer,
|
||||
WebsiteDeployer(config),
|
||||
ChatmailVenvDeployer(config),
|
||||
MtastsDeployer(),
|
||||
|
||||
@@ -1,25 +1,40 @@
|
||||
import datetime
|
||||
import importlib
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from . import remote
|
||||
|
||||
|
||||
def parse_zone_records(text):
|
||||
"""Yield ``(name, ttl, rtype, rdata)`` from standard BIND-format text.
|
||||
|
||||
Skips comment lines (starting with ``;``) and blank lines.
|
||||
Each record line must have the format ``name TTL IN type rdata``.
|
||||
"""
|
||||
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):
|
||||
return sshexec.logged(
|
||||
call=remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||
)
|
||||
|
||||
|
||||
def check_initial_remote_data(remote_data, *, print=print):
|
||||
def check_initial_remote_data(remote_data, *, strict_tls=True, print=print):
|
||||
mail_domain = remote_data["mail_domain"]
|
||||
if not remote_data["A"] and not remote_data["AAAA"]:
|
||||
print(f"Missing A and/or AAAA DNS records for {mail_domain}!")
|
||||
elif remote_data["MTA_STS"] != f"{mail_domain}.":
|
||||
elif strict_tls and remote_data["MTA_STS"] != f"{mail_domain}.":
|
||||
print("Missing MTA-STS CNAME record:")
|
||||
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}.")
|
||||
elif remote_data["WWW"] != f"{mail_domain}.":
|
||||
elif strict_tls and remote_data["WWW"] != f"{mail_domain}.":
|
||||
print("Missing www CNAME record:")
|
||||
print(f"www.{mail_domain}. CNAME {mail_domain}.")
|
||||
else:
|
||||
@@ -31,13 +46,36 @@ def get_filled_zone_file(remote_data):
|
||||
if not sts_id:
|
||||
remote_data["sts_id"] = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
||||
|
||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
|
||||
content = template.read_text()
|
||||
zonefile = Template(content).render(**remote_data)
|
||||
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
|
||||
d = remote_data["mail_domain"]
|
||||
lines = ["; Required DNS entries"]
|
||||
if remote_data.get("A"):
|
||||
lines.append(f"{d}. 3600 IN A {remote_data['A']}")
|
||||
if remote_data.get("AAAA"):
|
||||
lines.append(f"{d}. 3600 IN AAAA {remote_data['AAAA']}")
|
||||
lines.append(f"{d}. 3600 IN MX 10 {d}.")
|
||||
if remote_data.get("strict_tls"):
|
||||
lines.append(
|
||||
f'_mta-sts.{d}. 3600 IN TXT "v=STSv1; id={remote_data["sts_id"]}"'
|
||||
)
|
||||
lines.append(f"mta-sts.{d}. 3600 IN CNAME {d}.")
|
||||
lines.append(f"www.{d}. 3600 IN CNAME {d}.")
|
||||
lines.append(remote_data["dkim_entry"])
|
||||
lines.append("")
|
||||
zonefile = "\n".join(lines)
|
||||
return zonefile
|
||||
lines.append("; Recommended DNS entries")
|
||||
lines.append(f'{d}. 3600 IN TXT "v=spf1 a ~all"')
|
||||
lines.append(f'_dmarc.{d}. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"')
|
||||
if remote_data.get("acme_account_url"):
|
||||
lines.append(
|
||||
f"{d}. 3600 IN CAA 0 issue"
|
||||
f' "letsencrypt.org;accounturi={remote_data["acme_account_url"]}"'
|
||||
)
|
||||
lines.append(f'_adsp._domainkey.{d}. 3600 IN TXT "dkim=discardable"')
|
||||
lines.append(f"_submission._tcp.{d}. 3600 IN SRV 0 1 587 {d}.")
|
||||
lines.append(f"_submissions._tcp.{d}. 3600 IN SRV 0 1 465 {d}.")
|
||||
lines.append(f"_imap._tcp.{d}. 3600 IN SRV 0 1 143 {d}.")
|
||||
lines.append(f"_imaps._tcp.{d}. 3600 IN SRV 0 1 993 {d}.")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
||||
@@ -53,18 +91,19 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
|
||||
if required_diff:
|
||||
out.red("Please set required DNS entries at your DNS provider:\n")
|
||||
for line in required_diff:
|
||||
out(line)
|
||||
out("")
|
||||
out.print(line)
|
||||
out.print()
|
||||
returncode = 1
|
||||
if remote_data.get("dkim_entry") in required_diff:
|
||||
out(
|
||||
"If the DKIM entry above does not work with your DNS provider, you can try this one:\n"
|
||||
out.print(
|
||||
"If the DKIM entry above does not work with your DNS provider,"
|
||||
" you can try this one:\n"
|
||||
)
|
||||
out(remote_data.get("web_dkim_entry") + "\n")
|
||||
out.print(remote_data.get("web_dkim_entry") + "\n")
|
||||
if recommended_diff:
|
||||
out("WARNING: these recommended DNS entries are not set:\n")
|
||||
out.print("WARNING: these recommended DNS entries are not set:\n")
|
||||
for line in recommended_diff:
|
||||
out(line)
|
||||
out.print(line)
|
||||
|
||||
if not (recommended_diff or required_diff):
|
||||
out.green("Great! All your DNS entries are verified and correct.")
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import urllib.request
|
||||
|
||||
from chatmaild.config import Config
|
||||
from pyinfra import host
|
||||
from pyinfra.facts.server import Arch, Sysctl
|
||||
from pyinfra.facts.server import Arch, Command, Sysctl
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
from pyinfra.operations import apt, files, server, systemd
|
||||
|
||||
from cmdeploy.basedeploy import (
|
||||
Deployer,
|
||||
activate_remote_units,
|
||||
blocked_service_startup,
|
||||
configure_remote_units,
|
||||
get_resource,
|
||||
has_systemd,
|
||||
@@ -25,9 +28,11 @@ class DovecotDeployer(Deployer):
|
||||
arch = host.get_fact(Arch)
|
||||
if has_systemd() and "dovecot.service" in host.get_fact(SystemdEnabled):
|
||||
return # already installed and running
|
||||
_install_dovecot_package("core", arch)
|
||||
_install_dovecot_package("imapd", arch)
|
||||
_install_dovecot_package("lmtpd", arch)
|
||||
|
||||
with blocked_service_startup():
|
||||
_install_dovecot_package("core", arch)
|
||||
_install_dovecot_package("imapd", arch)
|
||||
_install_dovecot_package("lmtpd", arch)
|
||||
|
||||
def configure(self):
|
||||
configure_remote_units(self.config.mail_domain, self.units)
|
||||
@@ -39,7 +44,9 @@ class DovecotDeployer(Deployer):
|
||||
restart = False if self.disable_mail else self.need_restart
|
||||
|
||||
systemd.service(
|
||||
name="Disable dovecot for now" if self.disable_mail else "Start and enable Dovecot",
|
||||
name="Disable dovecot for now"
|
||||
if self.disable_mail
|
||||
else "Start and enable Dovecot",
|
||||
service="dovecot.service",
|
||||
running=False if self.disable_mail else True,
|
||||
enabled=False if self.disable_mail else True,
|
||||
@@ -49,10 +56,21 @@ class DovecotDeployer(Deployer):
|
||||
self.need_restart = False
|
||||
|
||||
|
||||
def _pick_url(primary, fallback):
|
||||
try:
|
||||
req = urllib.request.Request(primary, method="HEAD")
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
return primary
|
||||
except Exception:
|
||||
return fallback
|
||||
|
||||
|
||||
def _install_dovecot_package(package: str, arch: str):
|
||||
arch = "amd64" if arch == "x86_64" 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"
|
||||
primary_url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
|
||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F2.3.21%2Bdfsg1/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
|
||||
url = _pick_url(primary_url, fallback_url)
|
||||
deb_filename = "/root/" + url.split("/")[-1]
|
||||
|
||||
match (package, arch):
|
||||
@@ -118,19 +136,25 @@ def _configure_dovecot(config: Config, debug: bool = False) -> (bool, bool):
|
||||
|
||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||
# it is recommended to set the following inotify limits
|
||||
if config.change_kernel_settings:
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
if host.get_fact(Sysctl)[key] > 65535:
|
||||
# Skip updating limits if already sufficient
|
||||
# (enables running in incus containers where sysctl readonly)
|
||||
continue
|
||||
server.sysctl(
|
||||
name=f"Change {key}",
|
||||
key=key,
|
||||
value=65535,
|
||||
persist=True,
|
||||
can_modify = host.get_fact(Command, "systemd-detect-virt -c || true") == "none"
|
||||
for name in ("max_user_instances", "max_user_watches"):
|
||||
key = f"fs.inotify.{name}"
|
||||
value = host.get_fact(Sysctl)[key]
|
||||
if value > 65534:
|
||||
continue
|
||||
if not can_modify:
|
||||
print(
|
||||
"\n!!!! refusing to attempt sysctl setting in shared-kernel containers\n"
|
||||
f"!!!! dovecot: sysctl {key!r}={value}, should be >65535 for production setups\n"
|
||||
"!!!!"
|
||||
)
|
||||
continue
|
||||
server.sysctl(
|
||||
name=f"Change {key}",
|
||||
key=key,
|
||||
value=65535,
|
||||
persist=True,
|
||||
)
|
||||
|
||||
timezone_env = files.line(
|
||||
name="Set TZ environment variable",
|
||||
|
||||
@@ -228,8 +228,8 @@ service anvil {
|
||||
}
|
||||
|
||||
ssl = required
|
||||
ssl_cert = </var/lib/acme/live/{{ config.mail_domain }}/fullchain
|
||||
ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
|
||||
ssl_cert = <{{ config.tls_cert_path }}
|
||||
ssl_key = <{{ config.tls_key_path }}
|
||||
ssl_dh = </usr/share/dovecot/dh.pem
|
||||
ssl_min_protocol = TLSv1.3
|
||||
ssl_prefer_server_ciphers = yes
|
||||
|
||||
67
cmdeploy/src/cmdeploy/external/deployer.py
vendored
Normal file
67
cmdeploy/src/cmdeploy/external/deployer.py
vendored
Normal file
@@ -0,0 +1,67 @@
|
||||
import io
|
||||
|
||||
from pyinfra import host
|
||||
from pyinfra.facts.files import File
|
||||
from pyinfra.operations import files, systemd
|
||||
|
||||
from cmdeploy.basedeploy import Deployer, get_resource
|
||||
|
||||
|
||||
class ExternalTlsDeployer(Deployer):
|
||||
"""Expects TLS certificates to be managed on the server.
|
||||
|
||||
Validates that the configured certificate and key files
|
||||
exist on the remote host. Installs a systemd path unit
|
||||
that watches the certificate file and automatically
|
||||
restarts/reloads affected services when it changes.
|
||||
"""
|
||||
|
||||
def __init__(self, cert_path, key_path):
|
||||
self.cert_path = cert_path
|
||||
self.key_path = key_path
|
||||
|
||||
def configure(self):
|
||||
# Verify cert and key exist on the remote host using pyinfra facts.
|
||||
for path in (self.cert_path, self.key_path):
|
||||
info = host.get_fact(File, path=path)
|
||||
if info is None:
|
||||
raise Exception(f"External TLS file not found on server: {path}")
|
||||
|
||||
# Deploy the .path unit (templated with the cert path).
|
||||
# pkg=__package__ is required here because the resource files
|
||||
# live in cmdeploy.external, not the default cmdeploy package.
|
||||
source = get_resource("tls-cert-reload.path.f", pkg=__package__)
|
||||
content = source.read_text().format(cert_path=self.cert_path).encode()
|
||||
|
||||
path_unit = files.put(
|
||||
name="Upload tls-cert-reload.path",
|
||||
src=io.BytesIO(content),
|
||||
dest="/etc/systemd/system/tls-cert-reload.path",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
|
||||
service_unit = files.put(
|
||||
name="Upload tls-cert-reload.service",
|
||||
src=get_resource("tls-cert-reload.service", pkg=__package__),
|
||||
dest="/etc/systemd/system/tls-cert-reload.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
|
||||
if path_unit.changed or service_unit.changed:
|
||||
self.need_restart = True
|
||||
|
||||
def activate(self):
|
||||
systemd.service(
|
||||
name="Enable tls-cert-reload path watcher",
|
||||
service="tls-cert-reload.path",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=self.need_restart,
|
||||
daemon_reload=self.need_restart,
|
||||
)
|
||||
# No explicit reload needed here: dovecot/nginx read the cert
|
||||
# on startup, and the .path watcher handles live changes.
|
||||
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f
vendored
Normal file
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.path.f
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Watch the TLS certificate file for changes.
|
||||
# When the cert is updated (e.g. renewed by an external process),
|
||||
# this triggers tls-cert-reload.service to reload the affected services.
|
||||
#
|
||||
# NOTE: changes to the certificates are not detected if they cross bind-mount boundaries.
|
||||
# After cert renewal, you must then trigger the reload explicitly:
|
||||
# systemctl start tls-cert-reload.service
|
||||
[Unit]
|
||||
Description=Watch TLS certificate for changes
|
||||
|
||||
[Path]
|
||||
PathChanged={cert_path}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.service
vendored
Normal file
15
cmdeploy/src/cmdeploy/external/tls-cert-reload.service
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# Reload services that cache the TLS certificate.
|
||||
#
|
||||
# dovecot: caches the cert at startup; reload re-reads SSL certs
|
||||
# without dropping existing connections.
|
||||
# nginx: caches the cert at startup; reload gracefully picks up
|
||||
# the new cert for new connections.
|
||||
# postfix: reads the cert fresh on each TLS handshake,
|
||||
# does NOT need a reload/restart.
|
||||
[Unit]
|
||||
Description=Reload TLS services after certificate change
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/systemctl try-reload-or-restart dovecot
|
||||
ExecStart=/bin/systemctl try-reload-or-restart nginx
|
||||
@@ -14,10 +14,10 @@ class FiltermailDeployer(Deployer):
|
||||
|
||||
def install(self):
|
||||
arch = host.get_fact(facts.server.Arch)
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.3.0/filtermail-{arch}"
|
||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.5.2/filtermail-{arch}"
|
||||
sha256sum = {
|
||||
"x86_64": "f14a31323ae2dad3b59d3fdafcde507521da2f951a9478cd1f2fe2b4463df71d",
|
||||
"aarch64": "933770d75046c4fd7084ce8d43f905f8748333426ad839154f0fc654755ef09f",
|
||||
"x86_64": "ce24ca0075aa445510291d775fb3aea8f4411818c7b885ae51a0fe18c5f789ce",
|
||||
"aarch64": "c5d783eefa5332db3d97a0e6a23917d72849e3eb45da3d16ce908a9b4e5a797d",
|
||||
}[arch]
|
||||
self.need_restart |= files.download(
|
||||
name="Download filtermail",
|
||||
|
||||
456
cmdeploy/src/cmdeploy/lxc/cli.py
Normal file
456
cmdeploy/src/cmdeploy/lxc/cli.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""lxc-start/stop/status/test subcommands for testing with local containers."""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..util import get_git_hash, get_version_string, shell
|
||||
from .incus import Incus, RelayContainer
|
||||
|
||||
RELAY_NAMES = ("test0", "test1")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# lxc-start
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def lxc_start_cmd_options(parser):
|
||||
_add_name_args(
|
||||
parser,
|
||||
help_text="User relay name(s) to create (default: test0).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ipv4-only",
|
||||
dest="ipv4_only",
|
||||
action="store_true",
|
||||
help="Create an IPv4-only container.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--run",
|
||||
action="store_true",
|
||||
help="Run 'cmdeploy run' on each container after starting it.",
|
||||
)
|
||||
|
||||
|
||||
def lxc_start_cmd(args, out):
|
||||
"""Create/Ensure and start LXC relay and DNS containers."""
|
||||
|
||||
with out.section("Preparing container setup"):
|
||||
_lxc_start_cmd(args, out)
|
||||
|
||||
|
||||
def _lxc_start_cmd(args, out):
|
||||
ix = Incus(out)
|
||||
sub = out.new_prefixed_out()
|
||||
out.green("Ensuring base image ...")
|
||||
ix.ensure_base_image()
|
||||
out.green("Ensuring DNS container (ns-localchat) ...")
|
||||
dns_ct = ix.get_dns_container()
|
||||
dns_ct.ensure()
|
||||
dns_ct.ensure_cached_as_image()
|
||||
sub.print(f"DNS container IP: {dns_ct.ipv4}")
|
||||
|
||||
names = args.names if args.names else RELAY_NAMES
|
||||
relays = list(ix.get_container(n) for n in names)
|
||||
for ct in relays:
|
||||
out.green(f"Ensuring container {ct.name!r} ({ct.domain}) ...")
|
||||
ct.ensure()
|
||||
ip = ct.ipv4
|
||||
|
||||
sub.print("Configuring container hostname ...")
|
||||
ct.configure_hosts(ip)
|
||||
|
||||
sub.print(f"Writing {ct.ini.name} ...")
|
||||
ct.write_ini(disable_ipv6=args.ipv4_only)
|
||||
sub.print(f"Config: {ct.ini}")
|
||||
if args.ipv4_only:
|
||||
ct.disable_ipv6()
|
||||
ipv6 = None
|
||||
else:
|
||||
output = ct.bash(
|
||||
"ip -6 addr show scope global -deprecated"
|
||||
" | grep -oP '(?<=inet6 )[^/]+'",
|
||||
check=False,
|
||||
)
|
||||
ipv6 = output.strip() if output else None
|
||||
sub.print(f"{_format_addrs(ip, ipv6)}")
|
||||
|
||||
sub.green(f"Container {ct.name!r} ready: {ct.domain} -> {ip}")
|
||||
out.print()
|
||||
|
||||
# Reset DNS zones only for the containers we just started
|
||||
started_cnames = {ct.name for ct in relays}
|
||||
managed = ix.list_managed()
|
||||
started = [c for c in managed if c["name"] in started_cnames]
|
||||
|
||||
if started:
|
||||
out.print(
|
||||
f"Resetting DNS zones for {len(started)} domain(s) (A + AAAA records) ..."
|
||||
)
|
||||
dns_ct.reset_dns_records(dns_ct.ipv4, started)
|
||||
|
||||
for ct in relays:
|
||||
if ct.name in started_cnames:
|
||||
sub.print(f"Configuring DNS in {ct.name} ...")
|
||||
ct.configure_dns(dns_ct.ipv4)
|
||||
|
||||
# Generate the unified SSH config
|
||||
out.green("Writing ssh-config ...")
|
||||
ssh_cfg = ix.write_ssh_config()
|
||||
sub.print(f"{ssh_cfg}")
|
||||
|
||||
# Verify SSH via the generated config
|
||||
for ct in relays:
|
||||
sub.print(f"Verifying SSH to {ct.name} via ssh-config ...")
|
||||
if ct.verify_ssh(ssh_cfg):
|
||||
sub.print(f"SSH OK: ssh -F lxconfigs/ssh-config {ct.domain}")
|
||||
else:
|
||||
sub.red(f"WARNING: SSH verification failed for {ct.name}")
|
||||
|
||||
# Print integration suggestions
|
||||
ssh_cfg = ix.ssh_config_path
|
||||
if not ix.check_ssh_include():
|
||||
sub.green(
|
||||
"\n(Optional) To use containers from any SSH client, add to ~/.ssh/config:"
|
||||
)
|
||||
sub.green(f" Include {ssh_cfg}")
|
||||
|
||||
# Optionally run cmdeploy run + dns on each relay
|
||||
if args.run:
|
||||
local_hash = get_git_hash()
|
||||
for ct in relays:
|
||||
status = _deploy_status(ct, local_hash, ix)
|
||||
with out.section(f"cmdeploy run: {ct.sname} ({ct.domain})"):
|
||||
if "IN-SYNC" in status:
|
||||
out.print(f"{ct.sname} is {status}, skipping")
|
||||
else:
|
||||
ret = _run_cmdeploy("run", ct, ix, out, extra=["--skip-dns-check"])
|
||||
if ret:
|
||||
out.red(f"Deploy to {ct.sname} failed (exit {ret})")
|
||||
return ret
|
||||
# Cache a per-relay image after each successful deploy
|
||||
# so the next run can launch directly from the deployed state.
|
||||
with out.section(f"lxc-test: caching {ct.sname} image"):
|
||||
ct.ensure_cached_as_image()
|
||||
|
||||
# Restart mail services to flush stale DNS state.
|
||||
# Cached container images boot with a resolv.conf
|
||||
# pointing to the previous run's DNS IP;
|
||||
# configure_dns() already restarted unbound,
|
||||
# but postfix/dovecot may hold stale results
|
||||
# from the window between boot and DNS fix.
|
||||
for ct in relays:
|
||||
out.print(f"Restarting mail services on {ct.name} ...")
|
||||
ct.bash("systemctl restart postfix dovecot opendkim")
|
||||
|
||||
for ct in relays:
|
||||
with out.section(f"cmdeploy dns: {ct.sname} ({ct.domain})"):
|
||||
ret = _run_cmdeploy(
|
||||
"dns",
|
||||
ct,
|
||||
ix,
|
||||
out,
|
||||
extra=["--zonefile", str(ct.zone)],
|
||||
)
|
||||
if ret:
|
||||
out.red(f"DNS for {ct.sname} failed (exit {ret})")
|
||||
return ret
|
||||
if ct.zone.exists():
|
||||
dns_ct.set_dns_records(ct.zone.read_text())
|
||||
# Restart filtermail so its in-process DNS cache
|
||||
# does not hold stale negative DKIM responses
|
||||
# from before the zones were loaded.
|
||||
out.print(f"Restarting filtermail-incoming on {ct.name} ...")
|
||||
ct.bash("systemctl restart filtermail-incoming")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# lxc-stop
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def lxc_stop_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--destroy",
|
||||
action="store_true",
|
||||
help="Delete containers and their config files after stopping.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--destroy-all",
|
||||
dest="destroy_all",
|
||||
action="store_true",
|
||||
help="Like --destroy, but also remove the ns-localchat DNS container.",
|
||||
)
|
||||
_add_name_args(
|
||||
parser,
|
||||
help_text="Container name(s) to stop (default: test0 + test1).",
|
||||
)
|
||||
|
||||
|
||||
def lxc_stop_cmd(args, out):
|
||||
"""Stop (and optionally destroy) local LXC relay containers."""
|
||||
ix = Incus(out)
|
||||
names = args.names or RELAY_NAMES
|
||||
destroy = args.destroy or args.destroy_all
|
||||
|
||||
for ct in map(ix.get_container, names):
|
||||
if destroy:
|
||||
out.green(f"Destroying container {ct.name!r} ...")
|
||||
ct.destroy()
|
||||
else:
|
||||
out.green(f"Stopping container {ct.name!r} ...")
|
||||
ct.stop(force=True)
|
||||
|
||||
if args.destroy_all:
|
||||
dns_ct = ix.get_dns_container()
|
||||
out.green(f"Destroying DNS container {dns_ct.name!r} ...")
|
||||
dns_ct.destroy()
|
||||
ix.delete_images()
|
||||
|
||||
if destroy:
|
||||
ix.write_ssh_config()
|
||||
out.green("LXC containers destroyed.")
|
||||
else:
|
||||
out.green("LXC containers stopped.")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# lxc-test
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def lxc_test_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--one",
|
||||
action="store_true",
|
||||
help="Only deploy and test against test0 (skip test1).",
|
||||
)
|
||||
|
||||
|
||||
def lxc_test_cmd(args, out):
|
||||
"""Run full LXC pipeline: start, deploy, DNS, zone files, and tests.
|
||||
|
||||
All commands run directly on the host using
|
||||
``--ssh-config lxconfigs/ssh-config`` for SSH access.
|
||||
"""
|
||||
ix = Incus(out)
|
||||
t_total = time.time()
|
||||
v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else ""
|
||||
|
||||
ret = out.shell(f"cmdeploy lxc-start{v_flag} --run test0", cwd=str(ix.project_root))
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
if not args.one:
|
||||
ret = out.shell(
|
||||
f"cmdeploy lxc-start{v_flag} --run test1 --ipv4-only",
|
||||
cwd=str(ix.project_root),
|
||||
)
|
||||
if ret:
|
||||
return ret
|
||||
|
||||
with out.section("cmdeploy test"):
|
||||
first = ix.get_container("test0")
|
||||
env = None
|
||||
if not args.one:
|
||||
env = os.environ.copy()
|
||||
env["CHATMAIL_DOMAIN2"] = ix.get_container("test1").domain
|
||||
ret = _run_cmdeploy("test", first, ix, out, **({"env": env} if env else {}))
|
||||
if ret:
|
||||
out.red(f"Tests failed (exit {ret})")
|
||||
return ret
|
||||
|
||||
elapsed = time.time() - t_total
|
||||
out.section_line(f"lxc-test complete ({elapsed:.1f}s)")
|
||||
if out.section_timings:
|
||||
out.print("Section timings:")
|
||||
for name, secs in out.section_timings:
|
||||
out.print(f" {name:.<50s} {secs:5.1f}s")
|
||||
out.print(f" {'total':.<50s} {elapsed:5.1f}s")
|
||||
out.section_timings.clear()
|
||||
return 0
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# lxc-status
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def lxc_status_cmd_options(parser):
|
||||
pass
|
||||
|
||||
|
||||
def lxc_status_cmd(args, out):
|
||||
"""Show status of local LXC chatmail containers."""
|
||||
ix = Incus(out)
|
||||
containers = ix.list_managed()
|
||||
if not containers:
|
||||
out.red("No LXC containers found. Run 'cmdeploy lxc-start' first.")
|
||||
return 1
|
||||
|
||||
local_hash = get_git_hash()
|
||||
|
||||
# Get storage pool path for display
|
||||
storage_path = None
|
||||
data = ix.run_json(["storage", "show", "default"], check=False)
|
||||
if data:
|
||||
storage_path = data.get("config", {}).get("source")
|
||||
msg = "Container status"
|
||||
if storage_path:
|
||||
msg += f": {storage_path}"
|
||||
out.section_line(msg)
|
||||
|
||||
dns_ip = None
|
||||
for c in containers:
|
||||
_print_container_status(out, c, ix, local_hash)
|
||||
if c["name"] == ix.get_dns_container().name:
|
||||
dns_ip = c["ip"]
|
||||
|
||||
out.section_line("Host ssh and DNS configuration")
|
||||
_print_ssh_status(out, ix)
|
||||
_print_dns_forwarding_status(out, dns_ip)
|
||||
return 0
|
||||
|
||||
|
||||
def _print_container_status(out, c, ix, local_hash):
|
||||
"""Print name/status, domain/IPs, and RAM for one container."""
|
||||
cname = c["name"]
|
||||
is_running = c.get("status") == "Running"
|
||||
ct = ix.get_container(cname)
|
||||
|
||||
# First line: name + running/STOPPED + deploy status
|
||||
if not is_running:
|
||||
tag = "STOPPED"
|
||||
elif not isinstance(ct, RelayContainer):
|
||||
tag = "running"
|
||||
else:
|
||||
tag = f"running {_deploy_status(ct, local_hash, ix)}"
|
||||
out.print(f"{cname:20s} {tag}")
|
||||
|
||||
# Second line: domain, IPv4, IPv6
|
||||
domain = c.get("domain", "")
|
||||
ip = c.get("ip") or "?"
|
||||
ipv6 = c.get("ipv6")
|
||||
out.print(f"{domain:20s} {_format_addrs(ip, ipv6)}")
|
||||
|
||||
# Third line: RAM (RSS), config
|
||||
detail_out = out.new_prefixed_out(" " * 21)
|
||||
try:
|
||||
used, total = ct.rss_mib()
|
||||
except Exception:
|
||||
ram_str = "RSS ?"
|
||||
else:
|
||||
ram_str = f"RSS {used}/{total} MiB ({used * 100 // total}%)"
|
||||
|
||||
if isinstance(ct, RelayContainer):
|
||||
detail = f"{ram_str}, config: {os.path.relpath(ct.ini)}"
|
||||
else:
|
||||
detail = ram_str
|
||||
|
||||
detail_out.print(detail)
|
||||
out.print()
|
||||
|
||||
|
||||
def _print_ssh_status(out, ix):
|
||||
"""Print SSH integration status."""
|
||||
ssh_cfg = ix.ssh_config_path
|
||||
if ix.check_ssh_include():
|
||||
out.green("SSH: ~/.ssh/config includes lxconfigs/ssh-config ✓")
|
||||
else:
|
||||
out.red("SSH: ~/.ssh/config does NOT include lxconfigs/ssh-config")
|
||||
sub = out.new_prefixed_out()
|
||||
sub.print("Add to ~/.ssh/config:")
|
||||
sub.print(f" Include {ssh_cfg}")
|
||||
|
||||
|
||||
def _print_dns_forwarding_status(out, dns_ip):
|
||||
"""Print host DNS forwarding status for .localchat."""
|
||||
sub = out.new_prefixed_out()
|
||||
if not dns_ip:
|
||||
out.red("DNS: ns-localchat container not found")
|
||||
return
|
||||
try:
|
||||
rv = shell("resolvectl status incusbr0")
|
||||
dns_ok = dns_ip in rv.stdout and "localchat" in rv.stdout
|
||||
except Exception:
|
||||
dns_ok = None
|
||||
if dns_ok is True:
|
||||
out.green(f"DNS: .localchat forwarding to {dns_ip} ✓")
|
||||
elif dns_ok is False:
|
||||
out.red("DNS: .localchat forwarding NOT configured")
|
||||
sub.print("Run:")
|
||||
sub.print(f" sudo resolvectl dns incusbr0 {dns_ip}")
|
||||
sub.print(" sudo resolvectl domain incusbr0 ~localchat")
|
||||
else:
|
||||
sub.print("DNS: .localchat forwarding status UNKNOWN")
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_addrs(ip, ipv6=None):
|
||||
parts = [f"IPv4 {ip}"]
|
||||
if ipv6:
|
||||
parts.append(f"IPv6 {ipv6}")
|
||||
return ", ".join(parts)
|
||||
|
||||
|
||||
def _deploy_status(ct, local_hash, ix):
|
||||
"""Return a human-readable deploy status string.
|
||||
|
||||
Compares the full deployed version (hash + diff) against
|
||||
the local state built by :func:`~cmdeploy.util.get_version_string`.
|
||||
"""
|
||||
deployed = ct.deployed_version()
|
||||
if deployed is None:
|
||||
return "NOT DEPLOYED"
|
||||
|
||||
# A container launched from the relay image has the same
|
||||
# git hash but a different domain — always redeploy.
|
||||
deployed_domain = ct.deployed_domain()
|
||||
if deployed_domain and deployed_domain != ct.domain:
|
||||
return f"DOMAIN-MISMATCH (deployed: {deployed_domain})"
|
||||
|
||||
deployed_lines = deployed.splitlines()
|
||||
deployed_hash = deployed_lines[0] if deployed_lines else ""
|
||||
short = deployed_hash[:12]
|
||||
|
||||
if not local_hash:
|
||||
return f"UNKNOWN (deployed: {short})"
|
||||
|
||||
local_short = local_hash[:12]
|
||||
if deployed_hash != local_hash:
|
||||
return f"STALE (deployed: {short}, local: {local_short})"
|
||||
|
||||
# Hash matches — check for uncommitted diffs
|
||||
local_version = get_version_string()
|
||||
if deployed != local_version:
|
||||
return f"DIRTY ({local_short}, undeployed changes)"
|
||||
|
||||
return f"IN-SYNC ({short})"
|
||||
|
||||
|
||||
def _add_name_args(parser, help_text):
|
||||
parser.add_argument("names", nargs="*", metavar="NAME", help=help_text)
|
||||
|
||||
|
||||
def _run_cmdeploy(subcmd, ct, ix, out, extra=None, **kwargs):
|
||||
"""Run ``cmdeploy <subcmd>`` with standard --config/--ssh flags.
|
||||
|
||||
*ct* is a Container (uses ``ct.ini`` and ``ct.domain``).
|
||||
Returns the subprocess exit code.
|
||||
"""
|
||||
extra_str = " ".join(extra) if extra else ""
|
||||
v_flag = " -" + "v" * out.verbosity if out.verbosity > 0 else ""
|
||||
cmd = f"""
|
||||
cmdeploy {subcmd}{v_flag}
|
||||
--config {ct.ini}
|
||||
--ssh-config {ix.ssh_config_path}
|
||||
--ssh-host {ct.domain}
|
||||
{extra_str}
|
||||
"""
|
||||
if "cwd" not in kwargs:
|
||||
kwargs["cwd"] = str(ix.project_root)
|
||||
return out.shell(cmd, **kwargs)
|
||||
803
cmdeploy/src/cmdeploy/lxc/incus.py
Normal file
803
cmdeploy/src/cmdeploy/lxc/incus.py
Normal file
@@ -0,0 +1,803 @@
|
||||
"""Core Incus operations for local chatmail LXC containers."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from ..util import shell
|
||||
|
||||
LABEL_KEY = "user.localchat-managed"
|
||||
SSH_KEY_NAME = "id_localchat"
|
||||
DOMAIN_SUFFIX = ".localchat"
|
||||
UPSTREAM_IMAGE = "images:debian/12"
|
||||
BASE_IMAGE_ALIAS = "localchat-base"
|
||||
BASE_SETUP_NAME = "localchat-base-setup"
|
||||
|
||||
DNS_CONTAINER_NAME = "ns-localchat"
|
||||
DNS_IMAGE_ALIAS = "localchat-ns"
|
||||
DNS_DOMAIN = "ns.localchat"
|
||||
|
||||
|
||||
class DNSConfigurationError(Exception):
|
||||
"""Raised when the DNS container is not reachable or not answering."""
|
||||
|
||||
|
||||
def _extract_ip(net_data, family="inet"):
|
||||
"""Extract the first global-scope IP of *family* from network state data.
|
||||
|
||||
*net_data* is the ``state.network`` dict from ``incus list --format=json``.
|
||||
*family* is ``"inet"`` for IPv4 or ``"inet6"`` for IPv6.
|
||||
Returns the address string, or None.
|
||||
"""
|
||||
for iface_name, iface in net_data.items():
|
||||
if iface_name == "lo":
|
||||
continue
|
||||
for addr in iface.get("addresses", []):
|
||||
if addr["family"] == family and addr["scope"] == "global":
|
||||
return addr["address"]
|
||||
return None
|
||||
|
||||
|
||||
class Incus:
|
||||
"""Gateway for all Incus container operations.
|
||||
|
||||
Instantiated once per CLI command and passed around so that
|
||||
all modules share a single entry point for Incus interactions.
|
||||
"""
|
||||
|
||||
def __init__(self, out):
|
||||
self.out = out
|
||||
self.project_root = Path(__file__).resolve().parent.parent.parent.parent.parent
|
||||
self.lxconfigs_dir = self.project_root / "lxconfigs"
|
||||
self.lxconfigs_dir.mkdir(exist_ok=True)
|
||||
self.ssh_key_path = self.lxconfigs_dir / SSH_KEY_NAME
|
||||
if not self.ssh_key_path.exists():
|
||||
shell(
|
||||
f"ssh-keygen -t ed25519 -f {self.ssh_key_path} -N '' -C localchat",
|
||||
check=True,
|
||||
)
|
||||
self.ssh_config_path = self.lxconfigs_dir / "ssh-config"
|
||||
|
||||
def write_ssh_config(self):
|
||||
"""Write ``lxconfigs/ssh-config`` mapping all containers to their IPs.
|
||||
|
||||
Each Host block maps the container name, the domain name, and the
|
||||
short relay name (e.g. ``_test0``) to the container's IP, using the
|
||||
shared localchat SSH key. Returns the path to the file.
|
||||
"""
|
||||
containers = self.list_managed()
|
||||
key_path = self.ssh_key_path
|
||||
lines = ["# Auto-generated by cmdeploy lxc-start — do not edit\n"]
|
||||
for c in containers:
|
||||
hosts = [c["name"]]
|
||||
domain = c.get("domain", "")
|
||||
if domain and domain != c["name"]:
|
||||
hosts.append(domain)
|
||||
short = domain.split(".")[0]
|
||||
if short and short not in hosts:
|
||||
hosts.append(short)
|
||||
lines.append(f"\nHost {' '.join(hosts)}\n")
|
||||
lines.append(f" Hostname {c['ip']}\n")
|
||||
lines.append(" User root\n")
|
||||
lines.append(f" IdentityFile {key_path}\n")
|
||||
lines.append(" IdentitiesOnly yes\n")
|
||||
lines.append(" StrictHostKeyChecking accept-new\n")
|
||||
lines.append(" UserKnownHostsFile /dev/null\n")
|
||||
lines.append(" LogLevel ERROR\n")
|
||||
path = self.ssh_config_path
|
||||
path.write_text("".join(lines))
|
||||
return path
|
||||
|
||||
def check_ssh_include(self):
|
||||
"""Check if the user's ~/.ssh/config already includes our ssh-config."""
|
||||
user_ssh_config = Path.home() / ".ssh" / "config"
|
||||
if not user_ssh_config.exists():
|
||||
return False
|
||||
lines = user_ssh_config.read_text().splitlines()
|
||||
target = f"include {self.ssh_config_path}".lower()
|
||||
return any(line.strip().lower() == target for line in lines)
|
||||
|
||||
def get_host_nameservers(self):
|
||||
"""Return upstream nameservers found on the host."""
|
||||
ns = []
|
||||
for path in ["/run/systemd/resolve/resolv.conf", "/etc/resolv.conf"]:
|
||||
p = Path(path)
|
||||
if p.exists():
|
||||
for line in p.read_text().splitlines():
|
||||
if line.strip().startswith("nameserver "):
|
||||
addr = line.split()[1]
|
||||
if addr not in ("127.0.0.1", "127.0.0.53", "::1"):
|
||||
if addr not in ns:
|
||||
ns.append(addr)
|
||||
if ns:
|
||||
break
|
||||
return ns
|
||||
|
||||
def run(self, args, check=True, capture=True, input=None):
|
||||
"""Run an incus command.
|
||||
|
||||
When *capture* is True and *verbosity* >= 1, output is streamed
|
||||
to the terminal line-by-line while also being captured for
|
||||
later return via result.stdout.
|
||||
"""
|
||||
cmd = ["incus", "--quiet"] + list(args)
|
||||
sub = self.out.new_prefixed_out(" ")
|
||||
|
||||
if not capture:
|
||||
# Simple case: let subprocess handle streams (no capture)
|
||||
if self.out.verbosity >= 1:
|
||||
sub.print(f"$ {' '.join(cmd)}")
|
||||
return subprocess.run(
|
||||
cmd, text=True, input=input, check=check, stdout=None, stderr=None
|
||||
)
|
||||
|
||||
# Capture case: we may need to stream while capturing
|
||||
if sub.verbosity >= 1:
|
||||
cmd_lines = " ".join(cmd).splitlines()
|
||||
sub.print(f"$ {cmd_lines.pop(0)}")
|
||||
if sub.verbosity >= 2:
|
||||
for line in cmd_lines:
|
||||
sub.print(f" {line}")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
text=True,
|
||||
stdin=subprocess.PIPE if input else subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
|
||||
stdout_lines = []
|
||||
if input:
|
||||
proc.stdin.write(input)
|
||||
proc.stdin.close()
|
||||
|
||||
for line in proc.stdout:
|
||||
stdout_lines.append(line)
|
||||
if sub.verbosity >= 2:
|
||||
sub.print(f" > {line.rstrip()}")
|
||||
|
||||
stderr = proc.stderr.read()
|
||||
ret = proc.wait()
|
||||
stdout = "".join(stdout_lines)
|
||||
if check and ret != 0:
|
||||
full_output = stdout + stderr
|
||||
for line in full_output.splitlines():
|
||||
if sub.verbosity < 1: # and we haven't printed it yet
|
||||
sub.red(line)
|
||||
raise subprocess.CalledProcessError(ret, cmd, output=stdout, stderr=stderr)
|
||||
|
||||
return subprocess.CompletedProcess(cmd, ret, stdout=stdout, stderr=stderr)
|
||||
|
||||
def run_json(self, args, check=True):
|
||||
"""Run an incus command with ``--format=json``.
|
||||
|
||||
Returns the parsed JSON on success.
|
||||
When *check* is True raises ``subprocess.CalledProcessError``
|
||||
on non-zero exit; when False returns *None* instead.
|
||||
"""
|
||||
result = self.run(
|
||||
list(args) + ["--format=json"],
|
||||
check=check,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
msg = f"Incus JSON processing failed for {args!r}: {e!s}"
|
||||
self.out.red(msg)
|
||||
self.out.red(f"Captured stdout: {result.stdout!r}")
|
||||
self.out.red(f"Captured stderr: {result.stderr!r}")
|
||||
if check:
|
||||
raise
|
||||
return None
|
||||
|
||||
def run_output(self, args, check=True):
|
||||
"""Run an incus command and return its stripped stdout.
|
||||
|
||||
When *check* is False, returns *None* on non-zero exit
|
||||
instead of raising.
|
||||
"""
|
||||
result = self.run(args, check=check)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return result.stdout.strip()
|
||||
|
||||
def find_image(self, aliases):
|
||||
"""Return the first alias from *aliases* that exists, else None."""
|
||||
images = self.run_json(["image", "list"], check=False) or []
|
||||
existing = {a.get("name") for img in images for a in img.get("aliases", [])}
|
||||
for alias in aliases:
|
||||
if alias in existing:
|
||||
return alias
|
||||
return None
|
||||
|
||||
def delete_images(self):
|
||||
"""Delete localchat-base and per-container images."""
|
||||
for alias in [
|
||||
BASE_IMAGE_ALIAS,
|
||||
DNS_IMAGE_ALIAS,
|
||||
"localchat-test0",
|
||||
"localchat-test1",
|
||||
]:
|
||||
self.run(["image", "delete", alias], check=False) # ok if absent
|
||||
|
||||
def list_managed(self):
|
||||
"""Return list of dicts with name, ip, ipv6, domain, status, memory_usage."""
|
||||
containers = []
|
||||
for ct in self.run_json(["list"]):
|
||||
config = ct.get("config", {})
|
||||
if config.get(LABEL_KEY) != "true":
|
||||
continue
|
||||
name = ct["name"]
|
||||
state = ct.get("state", {})
|
||||
net = state.get("network") or {}
|
||||
containers.append(
|
||||
{
|
||||
"name": name,
|
||||
"ip": _extract_ip(net, "inet"),
|
||||
"ipv6": _extract_ip(net, "inet6"),
|
||||
"domain": config.get(
|
||||
"user.localchat-domain", f"{name}{DOMAIN_SUFFIX}"
|
||||
),
|
||||
"status": ct.get("status", "Unknown"),
|
||||
"memory_usage": state.get("memory", {}).get("usage", 0),
|
||||
}
|
||||
)
|
||||
return containers
|
||||
|
||||
def ensure_base_image(self):
|
||||
"""Build and cache a base image with openssh and the SSH key.
|
||||
|
||||
The image is cached as a local incus image with alias
|
||||
'localchat-base'. Subsequent container launches use this
|
||||
image instead of the upstream Debian 12, skipping the
|
||||
slow apt-get install step.
|
||||
Returns the image alias.
|
||||
"""
|
||||
if self.find_image([BASE_IMAGE_ALIAS]):
|
||||
self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' already cached.")
|
||||
return BASE_IMAGE_ALIAS
|
||||
|
||||
self.out.print(" Building base image (one-time setup) ...")
|
||||
|
||||
self.run(["delete", BASE_SETUP_NAME, "--force"], check=False)
|
||||
self.run(["image", "delete", BASE_IMAGE_ALIAS], check=False)
|
||||
self.run(["launch", UPSTREAM_IMAGE, BASE_SETUP_NAME])
|
||||
|
||||
ct = Container(self, BASE_SETUP_NAME)
|
||||
ct.wait_ready()
|
||||
|
||||
key_path = self.ssh_key_path
|
||||
pub_key = key_path.with_suffix(".pub").read_text().strip()
|
||||
host_ns = self.get_host_nameservers()
|
||||
ns_lines = "\n".join(f"nameserver {n}" for n in host_ns)
|
||||
ct.bash(f"""
|
||||
printf '{ns_lines}\n' > /etc/resolv.conf
|
||||
apt-get -o DPkg::Lock::Timeout=60 update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y openssh-server python3
|
||||
systemctl enable ssh
|
||||
apt-get clean
|
||||
mkdir -p /root/.ssh
|
||||
chmod 700 /root/.ssh
|
||||
echo '{pub_key}' > /root/.ssh/authorized_keys
|
||||
chmod 600 /root/.ssh/authorized_keys
|
||||
""")
|
||||
|
||||
self.run(["stop", BASE_SETUP_NAME])
|
||||
self.run(["publish", BASE_SETUP_NAME, f"--alias={BASE_IMAGE_ALIAS}"])
|
||||
self.run(["delete", BASE_SETUP_NAME, "--force"])
|
||||
self.out.print(f" Base image '{BASE_IMAGE_ALIAS}' ready.")
|
||||
return BASE_IMAGE_ALIAS
|
||||
|
||||
def get_container(self, name):
|
||||
"""Return a container handle for the given name.
|
||||
|
||||
Accepts both short relay names (``test0``) and full Incus
|
||||
container names (``test0-localchat``). Returns
|
||||
``DNSContainer`` for the DNS container and
|
||||
``RelayContainer`` for everything else.
|
||||
"""
|
||||
if name == DNS_CONTAINER_NAME:
|
||||
return DNSContainer(self)
|
||||
return RelayContainer(self, name.removesuffix("-localchat"))
|
||||
|
||||
def get_dns_container(self):
|
||||
"""Return a DNSContainer handle."""
|
||||
return DNSContainer(self)
|
||||
|
||||
|
||||
class Container:
|
||||
"""The base container handle wraps all interactions with incus."""
|
||||
|
||||
def __init__(self, incus, name, domain=None):
|
||||
self.incus = incus
|
||||
self.out = incus.out
|
||||
self.name = name
|
||||
self.domain = domain or f"{name}{DOMAIN_SUFFIX}"
|
||||
self.ipv4 = None
|
||||
self.ipv6 = None
|
||||
|
||||
def bash(self, script, check=True):
|
||||
"""Returns stdout from executing ``bash -ec <script>`` inside this container.
|
||||
|
||||
*script* is dedented and stripped so callers can use triple-quoted strings.
|
||||
When *check* is False, returns *None* on non-zero exit instead of raising.
|
||||
"""
|
||||
script = textwrap.dedent(script).strip()
|
||||
cmd = ["exec", self.name, "--", "bash", "-ec", script]
|
||||
return self.incus.run_output(cmd, check=check)
|
||||
|
||||
def run_cmd(self, *args, check=True):
|
||||
"""Return stdout from running a command directly in the container (no shell).
|
||||
|
||||
When *check* is False, returns *None* on non-zero exit instead of raising.
|
||||
"""
|
||||
return self.incus.run_output(
|
||||
["exec", self.name, "--", *args],
|
||||
check=check,
|
||||
)
|
||||
|
||||
def start(self):
|
||||
self.incus.run(["start", self.name])
|
||||
|
||||
def stop(self, force=False):
|
||||
cmd = ["stop", self.name]
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
self.incus.run(cmd, check=False)
|
||||
|
||||
def launch(self):
|
||||
"""Launch from the best available image, return the alias used."""
|
||||
image = self.incus.find_image([BASE_IMAGE_ALIAS])
|
||||
if not image:
|
||||
raise RuntimeError(
|
||||
f"No base image '{BASE_IMAGE_ALIAS}' found. "
|
||||
"Call ensure_base_image() before launching containers."
|
||||
)
|
||||
self.out.print(f" Launching from '{image}' image ...")
|
||||
cfg = []
|
||||
cfg += ("-c", f"{LABEL_KEY}=true")
|
||||
cfg += ("-c", f"user.localchat-domain={self.domain}")
|
||||
self.incus.run(["launch", image, self.name, *cfg])
|
||||
return image
|
||||
|
||||
def ensure(self):
|
||||
"""Create/start this container from the cached base image.
|
||||
|
||||
On first call, builds the base image (~30s).
|
||||
Subsequent containers launch in ~2s from the cached image.
|
||||
Returns ``self`` for chaining.
|
||||
"""
|
||||
data = self.incus.run_json(["list", self.name], check=False) or []
|
||||
|
||||
existing = [c for c in data if c["name"] == self.name]
|
||||
if existing:
|
||||
if existing[0]["status"] != "Running":
|
||||
self.start()
|
||||
else:
|
||||
self.launch()
|
||||
self.wait_ready()
|
||||
return self
|
||||
|
||||
def destroy(self):
|
||||
"""Stop, delete, and clean up config files."""
|
||||
self.stop(force=True)
|
||||
self.incus.run(["delete", self.name, "--force"], check=False)
|
||||
|
||||
def push_file_content(self, dest_path, content):
|
||||
"""Write *content* to *dest_path* inside the container.
|
||||
|
||||
*content* is dedented and stripped so callers can use
|
||||
indented triple-quoted strings.
|
||||
"""
|
||||
content = textwrap.dedent(content).strip() + "\n"
|
||||
self.incus.run(
|
||||
["file", "push", "-", f"{self.name}{dest_path}"],
|
||||
input=content,
|
||||
)
|
||||
self.bash(f"chmod 644 {dest_path}")
|
||||
|
||||
def wait_ready(self, timeout=60):
|
||||
"""Wait until the container is running with an IPv4 address.
|
||||
|
||||
Sets ``self.ipv4`` and ``self.ipv6`` (may be *None*),
|
||||
or raises ``TimeoutError``.
|
||||
"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
data = self.incus.run_json(
|
||||
["list", self.name],
|
||||
check=False,
|
||||
)
|
||||
if data and data[0].get("status") == "Running":
|
||||
net = data[0].get("state", {}).get("network", {})
|
||||
self.ipv4 = _extract_ip(net, "inet")
|
||||
self.ipv6 = _extract_ip(net, "inet6")
|
||||
if self.ipv4:
|
||||
return
|
||||
time.sleep(1)
|
||||
raise TimeoutError(
|
||||
f"Container {self.name!r} did not become ready within {timeout}s"
|
||||
)
|
||||
|
||||
def rss_mib(self):
|
||||
"""Return ``(used, total)`` memory from container (or None if unobtainable)."""
|
||||
output = self.bash("free -m", check=False)
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("Mem:"):
|
||||
parts = line.split()
|
||||
return int(parts[2]), int(parts[1])
|
||||
|
||||
def ensure_cached_as_image(self):
|
||||
"""Cache this container as a respective image."""
|
||||
alias = self.image_alias
|
||||
if self.incus.find_image([alias]):
|
||||
return
|
||||
self.out.print(" Cleaning apt cache before caching image ...")
|
||||
self.bash("apt-get clean")
|
||||
self.out.print(f" Caching {self.name!r} as '{alias}' ...")
|
||||
self.incus.run(["publish", self.name, f"--alias={alias}", "--force"])
|
||||
self.out.print(f" Image '{alias}' cached.")
|
||||
self.wait_ready()
|
||||
|
||||
|
||||
class RelayContainer(Container):
|
||||
"""Container handle for a chatmail relay.
|
||||
|
||||
Accepts the short relay name (e.g. ``test0``) and derives
|
||||
the Incus container name and mail domain automatically.
|
||||
"""
|
||||
|
||||
def __init__(self, incus, name):
|
||||
super().__init__(
|
||||
incus,
|
||||
f"{name}-localchat",
|
||||
domain=f"_{name}{DOMAIN_SUFFIX}",
|
||||
)
|
||||
self.sname = name
|
||||
self.image_alias = f"localchat-{name}"
|
||||
self.ini = incus.lxconfigs_dir / f"chatmail-{name}.ini"
|
||||
self.zone = incus.lxconfigs_dir / f"{name}.zone"
|
||||
|
||||
def launch(self):
|
||||
"""Launch from localchat-{sname} if cached, else localchat-base."""
|
||||
|
||||
candidates = [self.image_alias]
|
||||
candidates.append(BASE_IMAGE_ALIAS)
|
||||
image = self.incus.find_image(candidates)
|
||||
assert image, f"No deployment base, candidates: {','.join(candidates)}"
|
||||
self.out.print(f" Launching from '{image}' image ...")
|
||||
cfg = []
|
||||
cfg += ("-c", f"{LABEL_KEY}=true")
|
||||
cfg += ("-c", f"user.localchat-domain={self.domain}")
|
||||
self.incus.run(["launch", image, self.name, *cfg])
|
||||
self.bash("rm -f /etc/chatmail-version")
|
||||
return image
|
||||
|
||||
def destroy(self):
|
||||
"""Stop, delete, and clean up config files."""
|
||||
super().destroy()
|
||||
if self.ini.exists():
|
||||
self.ini.unlink()
|
||||
|
||||
def disable_ipv6(self):
|
||||
"""Disable IPv6 inside the container via sysctl."""
|
||||
# incus provides net.* virtualization for LXC containers so that
|
||||
# these sysctls only affect the container's network namespace.
|
||||
self.bash("""
|
||||
sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||
sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||
""")
|
||||
self.push_file_content(
|
||||
"/etc/sysctl.d/99-disable-ipv6.conf",
|
||||
"""
|
||||
net.ipv6.conf.all.disable_ipv6=1
|
||||
net.ipv6.conf.default.disable_ipv6=1
|
||||
""",
|
||||
)
|
||||
|
||||
def configure_hosts(self, ip):
|
||||
"""Set hostname and /etc/hosts inside the container."""
|
||||
self.bash(f"""
|
||||
echo '{self.name}' > /etc/hostname
|
||||
hostname {self.name}
|
||||
sed -i '/ {self.domain}$/d' /etc/hosts
|
||||
echo '{ip} {self.name} {self.domain}' >> /etc/hosts
|
||||
""")
|
||||
|
||||
def deployed_version(self):
|
||||
"""Read /etc/chatmail-version, or None if absent."""
|
||||
return self.bash("cat /etc/chatmail-version", check=False)
|
||||
|
||||
def deployed_domain(self):
|
||||
"""Read the domain deployed on the container (postfix myhostname)."""
|
||||
return self.bash(
|
||||
"postconf -h myhostname 2>/dev/null",
|
||||
check=False,
|
||||
)
|
||||
|
||||
def verify_ssh(self, ssh_config):
|
||||
"""Verify SSH connectivity to this container."""
|
||||
cmd = f"ssh -F {ssh_config} -o ConnectTimeout=60 root@{self.domain} hostname"
|
||||
return shell(cmd, timeout=60).returncode == 0
|
||||
|
||||
def configure_dns(self, dns_ip):
|
||||
"""Point this container's resolver at *dns_ip* and verify DNS is reachable."""
|
||||
self.bash(f"""
|
||||
systemctl disable --now systemd-resolved 2>/dev/null || true
|
||||
rm -f /etc/resolv.conf
|
||||
printf 'nameserver {dns_ip}\\n' >/etc/resolv.conf
|
||||
mkdir -p /etc/unbound/unbound.conf.d
|
||||
""")
|
||||
self.push_file_content(
|
||||
"/etc/unbound/unbound.conf.d/localchat-forward.conf",
|
||||
f"""
|
||||
server:
|
||||
domain-insecure: "localchat"
|
||||
|
||||
forward-zone:
|
||||
name: "localchat"
|
||||
forward-addr: {dns_ip}
|
||||
""",
|
||||
)
|
||||
self.bash("systemctl restart unbound 2>/dev/null || true")
|
||||
self._wait_dns_reachable(dns_ip)
|
||||
|
||||
def _wait_dns_reachable(self, dns_ip, timeout=10):
|
||||
"""Poll until *dns_ip* answers a DNS query from this container."""
|
||||
if self.bash("which dig", check=False) is None:
|
||||
self.bash(
|
||||
"DEBIAN_FRONTEND=noninteractive "
|
||||
"apt-get install -y dnsutils 2>/dev/null || true"
|
||||
)
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
result = self.bash(
|
||||
f"dig @{dns_ip} . SOA +short +time=1 +tries=1",
|
||||
check=False,
|
||||
)
|
||||
if result and result.strip():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise DNSConfigurationError(
|
||||
f"DNS at {dns_ip} not reachable from {self.name} after {timeout}s"
|
||||
)
|
||||
|
||||
def write_ini(self, disable_ipv6=False):
|
||||
"""Generate a chatmail.ini config file in lxconfigs/."""
|
||||
from chatmaild.config import write_initial_config
|
||||
|
||||
overrides = {
|
||||
"max_user_send_per_minute": 600,
|
||||
"max_user_send_burst_size": 100,
|
||||
"mtail_address": "127.0.0.1",
|
||||
}
|
||||
if disable_ipv6:
|
||||
overrides["disable_ipv6"] = "True"
|
||||
write_initial_config(self.ini, self.domain, overrides)
|
||||
return self.ini
|
||||
|
||||
|
||||
class DNSContainer(Container):
|
||||
"""Container handle for the PowerDNS name server.
|
||||
|
||||
Manages the authoritative and recursive DNS services required for
|
||||
name resolution in the local testing environment.
|
||||
"""
|
||||
|
||||
def __init__(self, incus):
|
||||
super().__init__(incus, DNS_CONTAINER_NAME, domain=DNS_DOMAIN)
|
||||
self.image_alias = DNS_IMAGE_ALIAS
|
||||
|
||||
def launch(self):
|
||||
"""Launch from localchat-ns if cached, else localchat-base."""
|
||||
image = self.incus.find_image([DNS_IMAGE_ALIAS, BASE_IMAGE_ALIAS])
|
||||
if not image:
|
||||
raise RuntimeError(
|
||||
f"No base image '{BASE_IMAGE_ALIAS}' found. "
|
||||
"Call ensure_base_image() before launching containers."
|
||||
)
|
||||
self.out.print(f" Launching from '{image}' image ...")
|
||||
cfg = []
|
||||
cfg += ("-c", f"{LABEL_KEY}=true")
|
||||
cfg += ("-c", f"user.localchat-domain={self.domain}")
|
||||
self.incus.run(["launch", image, self.name, *cfg])
|
||||
|
||||
def pdnsutil(self, *args, check=True):
|
||||
"""Run ``pdnsutil <args>`` inside the DNS container."""
|
||||
return self.run_cmd("pdnsutil", *args, check=check)
|
||||
|
||||
def replace_rrset(self, zone, name, rtype, ttl, rdata):
|
||||
"""Shortcut for ``pdnsutil replace-rrset``."""
|
||||
self.pdnsutil("replace-rrset", zone, name, rtype, ttl, rdata)
|
||||
|
||||
def restart_services(self):
|
||||
"""Restart pdns and pdns-recursor, then wait until DNS is answering."""
|
||||
self.bash("""
|
||||
systemctl restart pdns
|
||||
systemctl restart pdns-recursor || true
|
||||
""")
|
||||
self._wait_dns_ready()
|
||||
|
||||
def _wait_dns_ready(self, timeout=60):
|
||||
"""Poll until the recursor answers a query on port 53."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
result = self.bash(
|
||||
"dig @127.0.0.1 . SOA +short +time=1 +tries=1",
|
||||
check=False,
|
||||
)
|
||||
if result and result.strip():
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise DNSConfigurationError(f"DNS recursor not answering after {timeout}s")
|
||||
|
||||
def ensure(self):
|
||||
"""Create the DNS container with PowerDNS if needed.
|
||||
|
||||
Calls ``super().ensure()`` to create/start the container
|
||||
and set up SSH, then installs PowerDNS and configures
|
||||
the Incus bridge to use this container as DNS.
|
||||
"""
|
||||
super().ensure()
|
||||
self._install_powerdns()
|
||||
self.incus.run(
|
||||
["network", "set", "incusbr0", "dns.mode=none"],
|
||||
check=False,
|
||||
)
|
||||
self.incus.run(
|
||||
["network", "set", "incusbr0", f"raw.dnsmasq=dhcp-option=6,{self.ipv4}"],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def destroy(self):
|
||||
"""Stop, delete, and reset bridge DNS config."""
|
||||
super().destroy()
|
||||
self.incus.run(["network", "unset", "incusbr0", "dns.mode"], check=False)
|
||||
self.incus.run(["network", "unset", "incusbr0", "raw.dnsmasq"], check=False)
|
||||
|
||||
def _install_powerdns(self):
|
||||
"""Install and configure PowerDNS if not already present."""
|
||||
if self.run_cmd("which", "pdns_server", check=False) is not None:
|
||||
return
|
||||
|
||||
host_ns = self.incus.get_host_nameservers()
|
||||
ns_lines = "\n".join(f"nameserver {n}" for n in host_ns)
|
||||
|
||||
self.bash(f"""
|
||||
systemctl disable --now systemd-resolved 2>/dev/null || true
|
||||
rm -f /etc/resolv.conf
|
||||
printf '{ns_lines}\n' > /etc/resolv.conf
|
||||
|
||||
# Block automatic service startup during package installation
|
||||
printf '#!/bin/sh\\nexit 101\\n' > /usr/sbin/policy-rc.d
|
||||
chmod +x /usr/sbin/policy-rc.d
|
||||
|
||||
apt-get -o DPkg::Lock::Timeout=60 update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
pdns-server pdns-backend-sqlite3 sqlite3 pdns-recursor dnsutils
|
||||
|
||||
# Remove the startup block
|
||||
rm /usr/sbin/policy-rc.d
|
||||
|
||||
systemctl stop pdns pdns-recursor || true
|
||||
mkdir -p /var/lib/powerdns
|
||||
sqlite3 /var/lib/powerdns/pdns.sqlite3 \
|
||||
</usr/share/doc/pdns-backend-sqlite3/schema.sqlite3.sql
|
||||
chown -R pdns:pdns /var/lib/powerdns
|
||||
""")
|
||||
|
||||
self.push_file_content(
|
||||
"/etc/powerdns/pdns.conf",
|
||||
"""
|
||||
launch=gsqlite3
|
||||
gsqlite3-database=/var/lib/powerdns/pdns.sqlite3
|
||||
local-address=127.0.0.1
|
||||
local-port=5353
|
||||
""",
|
||||
)
|
||||
|
||||
self.push_file_content(
|
||||
"/etc/powerdns/recursor.conf",
|
||||
"""
|
||||
local-address=0.0.0.0
|
||||
local-port=53
|
||||
forward-zones=localchat=127.0.0.1:5353
|
||||
allow-from=0.0.0.0/0
|
||||
dont-query=
|
||||
dnssec=off
|
||||
""",
|
||||
)
|
||||
|
||||
self.bash("""
|
||||
systemctl start pdns
|
||||
systemctl start pdns-recursor
|
||||
echo 'nameserver 127.0.0.1' > /etc/resolv.conf
|
||||
""")
|
||||
self._wait_dns_ready()
|
||||
|
||||
def reset_dns_records(self, dns_ip, domains):
|
||||
"""Create DNS zones with initial A records via pdnsutil.
|
||||
|
||||
Only sets SOA, NS, and A records as the minimal set
|
||||
needed for SSH connectivity. Full records (MX, TXT, SRV,
|
||||
CNAME, DKIM) are added later by ``cmdeploy dns``.
|
||||
|
||||
Args:
|
||||
dns_ip: IP of the DNS container
|
||||
domains: list of dicts with 'name', 'domain', 'ip'
|
||||
"""
|
||||
for d in domains:
|
||||
domain = d["domain"]
|
||||
ip = d["ip"]
|
||||
self.out.print(f" {domain} -> {ip}")
|
||||
|
||||
# Delete and recreate zone fresh (removes stale records)
|
||||
self.pdnsutil("delete-zone", domain, check=False)
|
||||
self.pdnsutil("create-zone", domain, f"ns.{domain}")
|
||||
|
||||
serial = str(int(time.time()))
|
||||
soa = f"ns.{domain} hostmaster.{domain} {serial} 3600 900 604800 300"
|
||||
self.replace_rrset(domain, ".", "SOA", "3600", soa)
|
||||
self.replace_rrset(domain, ".", "NS", "3600", f"ns.{domain}.")
|
||||
self.replace_rrset(domain, ".", "A", "3600", ip)
|
||||
self.replace_rrset(domain, "ns", "A", "3600", dns_ip)
|
||||
|
||||
# AAAA (domain -> container IPv6, if available)
|
||||
ipv6 = d.get("ipv6")
|
||||
if ipv6:
|
||||
self.replace_rrset(domain, ".", "AAAA", "3600", ipv6)
|
||||
self.out.print(f" zone reset: SOA, NS, A, AAAA ({ip}, {ipv6})")
|
||||
else:
|
||||
# Remove any stale AAAA record
|
||||
self.pdnsutil("delete-rrset", domain, ".", "AAAA", check=False)
|
||||
self.out.print(f" zone reset: SOA, NS, A ({ip}, IPv4-only)")
|
||||
|
||||
self.restart_services()
|
||||
|
||||
def set_dns_records(self, text):
|
||||
"""Add or overwrite DNS records from standard BIND format.
|
||||
|
||||
Uses ``cmdeploy.dns.parse_zone_records`` to parse.
|
||||
Zones are created automatically from the record names.
|
||||
"""
|
||||
from ..dns import parse_zone_records
|
||||
|
||||
zones_seen = set()
|
||||
|
||||
for name, ttl, rtype, rdata in parse_zone_records(text):
|
||||
# Derive zone from name: find top-level .localchat domain
|
||||
name_parts = name.split(".")
|
||||
zone = name # fallback
|
||||
for i in range(len(name_parts) - 1):
|
||||
if name_parts[i + 1 :] == ["localchat"]:
|
||||
zone = ".".join(name_parts[i:])
|
||||
break
|
||||
|
||||
# Create zone if first time seeing it
|
||||
if zone not in zones_seen:
|
||||
self.pdnsutil(
|
||||
"create-zone",
|
||||
zone,
|
||||
f"ns.{zone}",
|
||||
check=False,
|
||||
)
|
||||
zones_seen.add(zone)
|
||||
|
||||
# Figure out the record name relative to zone
|
||||
if name == zone:
|
||||
relative = "."
|
||||
elif name.endswith(f".{zone}"):
|
||||
relative = name[: -(len(zone) + 1)]
|
||||
else:
|
||||
relative = name
|
||||
|
||||
self.replace_rrset(zone, relative, rtype, ttl, rdata)
|
||||
|
||||
if zones_seen:
|
||||
self.restart_services()
|
||||
@@ -1 +0,0 @@
|
||||
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics
|
||||
@@ -1,47 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="{{ config.domain_name }}">
|
||||
<domain>{{ config.domain_name }}</domain>
|
||||
<displayName>{{ config.domain_name }} chatmail</displayName>
|
||||
<displayShortName>{{ config.domain_name }}</displayShortName>
|
||||
<emailProvider id="{{ config.mail_domain }}">
|
||||
<domain>{{ config.mail_domain }}</domain>
|
||||
<displayName>{{ config.mail_domain }} chatmail</displayName>
|
||||
<displayShortName>{{ config.mail_domain }}</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>143</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>443</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>465</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<hostname>{{ config.mail_domain }}</hostname>
|
||||
<port>443</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
|
||||
@@ -70,7 +70,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": config.mail_domain},
|
||||
config=config,
|
||||
disable_ipv6=config.disable_ipv6,
|
||||
)
|
||||
need_restart |= main_config.changed
|
||||
@@ -81,7 +81,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": config.mail_domain},
|
||||
config=config,
|
||||
)
|
||||
need_restart |= autoconfig.changed
|
||||
|
||||
@@ -91,7 +91,7 @@ def _configure_nginx(config: Config, debug: bool = False) -> bool:
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={"domain_name": config.mail_domain},
|
||||
config=config,
|
||||
)
|
||||
need_restart |= mta_sts_config.changed
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version: STSv1
|
||||
mode: enforce
|
||||
mx: {{ config.domain_name }}
|
||||
mx: {{ config.mail_domain }}
|
||||
max_age: 2419200
|
||||
|
||||
@@ -42,6 +42,9 @@ stream {
|
||||
}
|
||||
|
||||
http {
|
||||
{% if config.tls_cert_mode == "self" %}
|
||||
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
|
||||
{% endif %}
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
|
||||
@@ -51,10 +54,10 @@ http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
|
||||
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
|
||||
ssl_certificate {{ config.tls_cert_path }};
|
||||
ssl_certificate_key {{ config.tls_key_path }};
|
||||
|
||||
gzip on;
|
||||
|
||||
@@ -66,7 +69,7 @@ http {
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }};
|
||||
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
|
||||
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
|
||||
@@ -76,16 +79,16 @@ http {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location /new {
|
||||
{% if config.tls_cert_mode != "self" %}
|
||||
if ($request_method = GET) {
|
||||
# Redirect to Delta Chat,
|
||||
# which will in turn do a POST request.
|
||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
||||
return 301 dcaccount:https://{{ config.mail_domain }}/new;
|
||||
}
|
||||
{% else %}
|
||||
limit_req zone=newaccount burst=5 nodelay;
|
||||
{% endif %}
|
||||
|
||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
@@ -99,9 +102,11 @@ http {
|
||||
#
|
||||
# Redirects are only for browsers.
|
||||
location /cgi-bin/newemail.py {
|
||||
{% if config.tls_cert_mode != "self" %}
|
||||
if ($request_method = GET) {
|
||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
||||
return 301 dcaccount:https://{{ config.mail_domain }}/new;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
@@ -132,8 +137,29 @@ http {
|
||||
# Redirect www. to non-www
|
||||
server {
|
||||
listen 127.0.0.1:8443 ssl;
|
||||
server_name www.{{ config.domain_name }};
|
||||
return 301 $scheme://{{ config.domain_name }}$request_uri;
|
||||
server_name www.{{ config.mail_domain }};
|
||||
return 301 $scheme://{{ config.mail_domain }}$request_uri;
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
{% if not disable_ipv6 %}
|
||||
listen [::]:80;
|
||||
{% endif %}
|
||||
|
||||
{% if config.tls_cert_mode == "acme" %}
|
||||
location /.well-known/acme-challenge/ {
|
||||
proxy_pass http://acmetool;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
{% if config.tls_cert_mode == "acme" %}
|
||||
upstream acmetool {
|
||||
server 127.0.0.1:402;
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
@@ -37,21 +37,15 @@ class OpendkimDeployer(Deployer):
|
||||
)
|
||||
need_restart |= main_config.changed
|
||||
|
||||
screen_script = files.put(
|
||||
src=get_resource("opendkim/screen.lua"),
|
||||
dest="/etc/opendkim/screen.lua",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
screen_script = files.file(
|
||||
path="/etc/opendkim/screen.lua",
|
||||
present=False,
|
||||
)
|
||||
need_restart |= screen_script.changed
|
||||
|
||||
final_script = files.put(
|
||||
src=get_resource("opendkim/final.lua"),
|
||||
dest="/etc/opendkim/final.lua",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
final_script = files.file(
|
||||
path="/etc/opendkim/final.lua",
|
||||
present=False,
|
||||
)
|
||||
need_restart |= final_script.changed
|
||||
|
||||
@@ -109,6 +103,13 @@ class OpendkimDeployer(Deployer):
|
||||
)
|
||||
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
|
||||
|
||||
def activate(self):
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
mtaname = odkim.get_mtasymbol(ctx, "{daemon_name}")
|
||||
if mtaname == "ORIGINATING" then
|
||||
-- Outgoing message will be signed,
|
||||
-- no need to look for signatures.
|
||||
return nil
|
||||
end
|
||||
|
||||
nsigs = odkim.get_sigcount(ctx)
|
||||
if nsigs == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
local valid = false
|
||||
local error_msg = "No valid DKIM signature found."
|
||||
for i = 1, nsigs do
|
||||
sig = odkim.get_sighandle(ctx, i - 1)
|
||||
sigres = odkim.sig_result(sig)
|
||||
|
||||
-- All signatures that do not correspond to From:
|
||||
-- were ignored in screen.lua and return sigres -1.
|
||||
--
|
||||
-- Any valid signature that was not ignored like this
|
||||
-- means the message is acceptable.
|
||||
if sigres == 0 then
|
||||
valid = true
|
||||
else
|
||||
error_msg = "DKIM signature is invalid, error code " .. tostring(sigres) .. ", search https://github.com/trusteddomainproject/OpenDKIM/blob/master/libopendkim/dkim.h#L108"
|
||||
end
|
||||
end
|
||||
|
||||
if valid then
|
||||
-- Strip all DKIM-Signature headers after successful validation
|
||||
-- Delete in reverse order to avoid index shifting.
|
||||
for i = nsigs, 1, -1 do
|
||||
odkim.del_header(ctx, "DKIM-Signature", i)
|
||||
end
|
||||
else
|
||||
odkim.set_reply(ctx, "554", "5.7.1", error_msg)
|
||||
odkim.set_result(ctx, SMFIS_REJECT)
|
||||
end
|
||||
|
||||
return nil
|
||||
@@ -45,12 +45,6 @@ SignHeaders *,+autocrypt,+content-type
|
||||
# Default is empty.
|
||||
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
|
||||
|
||||
# Script to ignore signatures that do not correspond to the From: domain.
|
||||
ScreenPolicyScript /etc/opendkim/screen.lua
|
||||
|
||||
# Script to reject mails without a valid DKIM signature.
|
||||
FinalPolicyScript /etc/opendkim/final.lua
|
||||
|
||||
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
|
||||
# using a local socket with MTAs that access the socket as a non-privileged
|
||||
# user (for example, Postfix). You may need to add user "postfix" to group
|
||||
@@ -66,6 +60,10 @@ PidFile /run/opendkim/opendkim.pid
|
||||
# by the package dns-root-data.
|
||||
TrustAnchorFile /usr/share/dns/root.key
|
||||
|
||||
# Use the local unbound resolver rather than querying root servers directly.
|
||||
# on IPv4-only hosts opendkim may otherwise try ipv6 requests and time out.
|
||||
Nameservers 127.0.0.1
|
||||
|
||||
# Sign messages when `-o milter_macro_daemon_name=ORIGINATING` is set.
|
||||
MTA ORIGINATING
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Ignore signatures that do not correspond to the From: domain.
|
||||
|
||||
from_domain = odkim.get_fromdomain(ctx)
|
||||
if from_domain == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
n = odkim.get_sigcount(ctx)
|
||||
if n == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i = 1, n do
|
||||
sig = odkim.get_sighandle(ctx, i - 1)
|
||||
sig_domain = odkim.sig_getdomain(sig)
|
||||
if from_domain ~= sig_domain then
|
||||
odkim.sig_ignore(sig)
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
@@ -97,7 +97,9 @@ class PostfixDeployer(Deployer):
|
||||
server.shell(
|
||||
name="Validate postfix configuration",
|
||||
# Extract stderr and quit with error if non-zero
|
||||
commands=["""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""],
|
||||
commands=[
|
||||
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
|
||||
],
|
||||
)
|
||||
self.need_restart = need_restart
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/^DKIM-Signature:/ IGNORE
|
||||
/^Authentication-Results:/ IGNORE
|
||||
/^Received:/ IGNORE
|
||||
|
||||
@@ -15,12 +15,12 @@ readme_directory = no
|
||||
compatibility_level = 3.6
|
||||
|
||||
# TLS parameters
|
||||
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
|
||||
smtpd_tls_key_file=/var/lib/acme/live/{{ config.mail_domain }}/privkey
|
||||
smtpd_tls_cert_file={{ config.tls_cert_path }}
|
||||
smtpd_tls_key_file={{ config.tls_key_path }}
|
||||
smtpd_tls_security_level=may
|
||||
|
||||
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.
|
||||
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
|
||||
smtp_tls_servername = hostname
|
||||
|
||||
@@ -86,7 +86,6 @@ filter unix - n n - - lmtp
|
||||
# Local SMTP server for reinjecting incoming filtered mail
|
||||
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
||||
-o syslog_name=postfix/reinject_incoming
|
||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||
|
||||
# Cleanup `Received` headers for authenticated mail
|
||||
# to avoid leaking client IP.
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/^\[[^]]+\]$/ encrypt
|
||||
/^_/ encrypt
|
||||
/^nauta\.cu$/ may
|
||||
|
||||
@@ -53,13 +53,13 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
||||
print=log_progress,
|
||||
)
|
||||
except CalledProcessError:
|
||||
return
|
||||
return None, None
|
||||
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))
|
||||
web_dkim_value = "".join(re.findall(".{1,255}", dkim_value_raw))
|
||||
return (
|
||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"',
|
||||
f'{dkim_selector}._domainkey.{mail_domain}. TXT "{web_dkim_value}"',
|
||||
f'{dkim_selector}._domainkey.{mail_domain}. 3600 IN TXT "{dkim_value}"',
|
||||
f'{dkim_selector}._domainkey.{mail_domain}. 3600 IN TXT "{web_dkim_value}"',
|
||||
)
|
||||
|
||||
|
||||
@@ -94,9 +94,11 @@ def check_zonefile(zonefile, verbose=True):
|
||||
if not zf_line.strip() or zf_line.startswith(";"):
|
||||
continue
|
||||
print(f"dns-checking {zf_line!r}") if verbose else log_progress("")
|
||||
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
||||
zf_domain = zf_domain.rstrip(".")
|
||||
zf_value = zf_value.strip()
|
||||
parts = zf_line.split(None, 4)
|
||||
zf_domain = parts[0].rstrip(".")
|
||||
# parts[1]=TTL, parts[2]=IN, parts[3]=type, parts[4]=rdata
|
||||
zf_typ = parts[3]
|
||||
zf_value = parts[4].strip()
|
||||
query_value = query_dns(zf_typ, zf_domain)
|
||||
if zf_value != query_value:
|
||||
assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line
|
||||
|
||||
@@ -40,5 +40,5 @@ def dovecot_recalc_quota(user):
|
||||
#
|
||||
for line in output.split("\n"):
|
||||
parts = line.split()
|
||||
if parts[2] == "STORAGE":
|
||||
if len(parts) >= 6 and parts[2] == "STORAGE":
|
||||
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))
|
||||
|
||||
@@ -15,9 +15,8 @@ def main():
|
||||
)
|
||||
disable_mail = bool(os.environ.get("CHATMAIL_DISABLE_MAIL"))
|
||||
website_only = bool(os.environ.get("CHATMAIL_WEBSITE_ONLY"))
|
||||
docker = bool(os.environ.get("CHATMAIL_DOCKER"))
|
||||
|
||||
deploy_chatmail(config_path, disable_mail, website_only, docker)
|
||||
deploy_chatmail(config_path, disable_mail, website_only)
|
||||
|
||||
|
||||
if pyinfra.is_cli:
|
||||
|
||||
68
cmdeploy/src/cmdeploy/selfsigned/deployer.py
Normal file
68
cmdeploy/src/cmdeploy/selfsigned/deployer.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import shlex
|
||||
|
||||
from pyinfra.operations import apt, server
|
||||
|
||||
from cmdeploy.basedeploy import Deployer
|
||||
|
||||
|
||||
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
|
||||
"""Return the openssl argument list for a self-signed certificate.
|
||||
|
||||
The certificate uses an EC P-256 key with SAN entries for *domain*,
|
||||
``www.<domain>`` and ``mta-sts.<domain>``.
|
||||
"""
|
||||
return [
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"ec",
|
||||
"-pkeyopt",
|
||||
"ec_paramgen_curve:P-256",
|
||||
"-noenc",
|
||||
"-days",
|
||||
str(days),
|
||||
"-keyout",
|
||||
str(key_path),
|
||||
"-out",
|
||||
str(cert_path),
|
||||
"-subj",
|
||||
f"/CN={domain}",
|
||||
# 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",
|
||||
f"subjectAltName=DNS:{domain},DNS:www.{domain},DNS:mta-sts.{domain}",
|
||||
]
|
||||
|
||||
|
||||
class SelfSignedTlsDeployer(Deployer):
|
||||
"""Generates a self-signed TLS certificate for all chatmail endpoints."""
|
||||
|
||||
def __init__(self, mail_domain):
|
||||
self.mail_domain = mail_domain
|
||||
self.cert_path = "/etc/ssl/certs/mailserver.pem"
|
||||
self.key_path = "/etc/ssl/private/mailserver.key"
|
||||
|
||||
def install(self):
|
||||
apt.packages(
|
||||
name="Install openssl",
|
||||
packages=["openssl"],
|
||||
)
|
||||
|
||||
def configure(self):
|
||||
args = openssl_selfsigned_args(
|
||||
self.mail_domain,
|
||||
self.cert_path,
|
||||
self.key_path,
|
||||
)
|
||||
cmd = shlex.join(args)
|
||||
server.shell(
|
||||
name="Generate self-signed TLS certificate if not present",
|
||||
commands=[f"[ -f {self.cert_path} ] || {cmd}"],
|
||||
)
|
||||
|
||||
def activate(self):
|
||||
pass
|
||||
@@ -5,5 +5,5 @@ After=network.target
|
||||
[Service]
|
||||
Type=oneshot
|
||||
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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Description=Chatmail dict proxy for IMAP METADATA
|
||||
[Service]
|
||||
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
RestartSec=5
|
||||
User=vmail
|
||||
RuntimeDirectory=chatmail-metadata
|
||||
UMask=0077
|
||||
|
||||
@@ -49,8 +49,13 @@ class SSHExec:
|
||||
RemoteError = execnet.RemoteError
|
||||
FuncError = FuncError
|
||||
|
||||
def __init__(self, host, verbose=False, python="python3", timeout=60):
|
||||
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
|
||||
def __init__(
|
||||
self, host, verbose=False, python="python3", timeout=60, ssh_config=None
|
||||
):
|
||||
spec = f"ssh=root@{host}//python={python}"
|
||||
if ssh_config:
|
||||
spec += f"//ssh_config={ssh_config}"
|
||||
self.gateway = execnet.makegateway(spec)
|
||||
self._remote_cmdloop_channel = bootstrap_remote(self.gateway, remote)
|
||||
self.timeout = timeout
|
||||
self.verbose = verbose
|
||||
@@ -85,16 +90,74 @@ class SSHExec:
|
||||
|
||||
|
||||
class LocalExec:
|
||||
FuncError = FuncError
|
||||
|
||||
def __init__(self, verbose=False, docker=False):
|
||||
self.verbose = verbose
|
||||
self.docker = docker
|
||||
|
||||
def __call__(self, call, kwargs=None, log_callback=None):
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
return call(**kwargs)
|
||||
|
||||
def logged(self, call, kwargs: dict):
|
||||
title = call.__doc__
|
||||
if not title:
|
||||
title = call.__name__
|
||||
where = "locally"
|
||||
if self.docker:
|
||||
if call == remote.rdns.perform_initial_checks:
|
||||
kwargs["pre_command"] = "docker exec chatmail "
|
||||
where = "in docker"
|
||||
if self.verbose:
|
||||
print(f"Running {where}: {call.__name__}(**{kwargs})")
|
||||
return call(**kwargs)
|
||||
print_stderr(f"Running {where}: {title}(**{kwargs})")
|
||||
return self(call, kwargs, log_callback=print_stderr)
|
||||
else:
|
||||
print_stderr(title, end="")
|
||||
res = self(call, kwargs, log_callback=remote.rshell.log_progress)
|
||||
print_stderr()
|
||||
return res
|
||||
|
||||
|
||||
# pyinfra exposes a ``ssh_config_file`` data key that *should* let
|
||||
# paramiko parse an SSH config file directly. In practice it silently
|
||||
# fails to connect (zero hosts / zero operations), so we resolve the
|
||||
# hostname and identity-file ourselves and pass them via
|
||||
# ``--data ssh_hostname`` / ``--data ssh_key`` instead.
|
||||
# Execnet uses ssh natively (and not paramiko) and doesn't have this problem.
|
||||
|
||||
|
||||
def _get_from_ssh_config(host, ssh_config_path, key):
|
||||
"""Internal helper to parse a value for a specific key from ssh-config."""
|
||||
current_hosts = []
|
||||
found_value = None
|
||||
with open(ssh_config_path) as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
parts = line.split(None, 1)
|
||||
if not parts:
|
||||
continue
|
||||
directive = parts[0].lower()
|
||||
if directive == "host":
|
||||
if host in current_hosts and found_value:
|
||||
return found_value
|
||||
current_hosts = parts[1].split()
|
||||
found_value = None
|
||||
elif directive == key.lower():
|
||||
found_value = parts[1]
|
||||
if host in current_hosts and found_value:
|
||||
return found_value
|
||||
return None
|
||||
|
||||
|
||||
def resolve_host_from_ssh_config(host, ssh_config_path):
|
||||
"""Resolve a host alias to its IP from an ssh-config file."""
|
||||
return _get_from_ssh_config(host, ssh_config_path, "Hostname") or host
|
||||
|
||||
|
||||
def resolve_key_from_ssh_config(host, ssh_config_path):
|
||||
"""Resolve a host alias to its IdentityFile from an ssh-config file."""
|
||||
return _get_from_ssh_config(host, ssh_config_path, "IdentityFile")
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
; Required DNS entries for chatmail servers
|
||||
zftest.testrun.org. A 135.181.204.127
|
||||
zftest.testrun.org. AAAA 2a01:4f9:c012:52f4::1
|
||||
zftest.testrun.org. MX 10 zftest.testrun.org.
|
||||
_mta-sts.zftest.testrun.org. TXT "v=STSv1; id=202403211706"
|
||||
mta-sts.zftest.testrun.org. CNAME zftest.testrun.org.
|
||||
www.zftest.testrun.org. CNAME zftest.testrun.org.
|
||||
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"
|
||||
; Required DNS entries
|
||||
zftest.testrun.org. 3600 IN A 135.181.204.127
|
||||
zftest.testrun.org. 3600 IN AAAA 2a01:4f9:c012:52f4::1
|
||||
zftest.testrun.org. 3600 IN MX 10 zftest.testrun.org.
|
||||
_mta-sts.zftest.testrun.org. 3600 IN TXT "v=STSv1; id=202403211706"
|
||||
mta-sts.zftest.testrun.org. 3600 IN CNAME zftest.testrun.org.
|
||||
www.zftest.testrun.org. 3600 IN 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"
|
||||
|
||||
; Recommended DNS entries
|
||||
_submission._tcp.zftest.testrun.org. SRV 0 1 587 zftest.testrun.org.
|
||||
_submissions._tcp.zftest.testrun.org. SRV 0 1 465 zftest.testrun.org.
|
||||
_imap._tcp.zftest.testrun.org. SRV 0 1 143 zftest.testrun.org.
|
||||
_imaps._tcp.zftest.testrun.org. SRV 0 1 993 zftest.testrun.org.
|
||||
zftest.testrun.org. CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||
zftest.testrun.org. TXT "v=spf1 a:zftest.testrun.org ~all"
|
||||
_dmarc.zftest.testrun.org. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||
_adsp._domainkey.zftest.testrun.org. TXT "dkim=discardable"
|
||||
zftest.testrun.org. 3600 IN TXT "v=spf1 a ~all"
|
||||
_dmarc.zftest.testrun.org. 3600 IN TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||
zftest.testrun.org. 3600 IN CAA 0 issue "letsencrypt.org;accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1371472956"
|
||||
_adsp._domainkey.zftest.testrun.org. 3600 IN TXT "dkim=discardable"
|
||||
_submission._tcp.zftest.testrun.org. 3600 IN SRV 0 1 587 zftest.testrun.org.
|
||||
_submissions._tcp.zftest.testrun.org. 3600 IN SRV 0 1 465 zftest.testrun.org.
|
||||
_imap._tcp.zftest.testrun.org. 3600 IN SRV 0 1 143 zftest.testrun.org.
|
||||
_imaps._tcp.zftest.testrun.org. 3600 IN SRV 0 1 993 zftest.testrun.org.
|
||||
|
||||
@@ -41,9 +41,9 @@ class TestDC:
|
||||
|
||||
def dc_ping_pong():
|
||||
chat.send_text("ping")
|
||||
msg = ac2._evtracker.wait_next_incoming_message()
|
||||
msg.chat.send_text("pong")
|
||||
ac1._evtracker.wait_next_incoming_message()
|
||||
msg = ac2.wait_for_incoming_msg()
|
||||
msg.get_snapshot().chat.send_text("pong")
|
||||
ac1.wait_for_incoming_msg()
|
||||
|
||||
benchmark(dc_ping_pong, 5)
|
||||
|
||||
@@ -55,6 +55,6 @@ class TestDC:
|
||||
for i in range(10):
|
||||
chat.send_text(f"hello {i}")
|
||||
for i in range(10):
|
||||
ac2._evtracker.wait_next_incoming_message()
|
||||
ac2.wait_for_incoming_msg()
|
||||
|
||||
benchmark(dc_send_10_receive_10, 5)
|
||||
benchmark(dc_send_10_receive_10, 5, cooldown="auto")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from cmdeploy.genqr import gen_qr_png_data
|
||||
@@ -8,18 +9,36 @@ def test_gen_qr_png_data(maildomain):
|
||||
assert data
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
|
||||
def test_fastcgi_working(maildomain, chatmail_config):
|
||||
url = f"https://{maildomain}/new"
|
||||
print(url)
|
||||
res = requests.post(url)
|
||||
verify = chatmail_config.tls_cert_mode == "acme"
|
||||
res = requests.post(url, verify=verify)
|
||||
assert maildomain in res.json().get("email")
|
||||
assert len(res.json().get("password")) > chatmail_config.password_min_length
|
||||
|
||||
|
||||
def test_newemail_configure(maildomain, rpc):
|
||||
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
|
||||
def test_newemail_configure(maildomain, maildomain_ip, rpc, chatmail_config):
|
||||
"""Test configuring accounts by scanning a QR code works."""
|
||||
url = f"DCACCOUNT:https://{maildomain}/new"
|
||||
for i in range(3):
|
||||
account_id = rpc.add_account()
|
||||
rpc.set_config_from_qr(account_id, url)
|
||||
rpc.configure(account_id)
|
||||
if chatmail_config.tls_cert_mode == "self":
|
||||
# deltachat core's rustls rejects self-signed HTTPS certs during
|
||||
# set_config_from_qr, so fetch credentials via requests instead
|
||||
res = requests.post(f"https://{maildomain}/new", verify=False)
|
||||
data = res.json()
|
||||
rpc.add_or_update_transport(
|
||||
account_id,
|
||||
{
|
||||
"addr": data["email"],
|
||||
"password": data["password"],
|
||||
"imapServer": maildomain_ip,
|
||||
"smtpServer": maildomain_ip,
|
||||
"certificateChecks": "acceptInvalidCertificates",
|
||||
},
|
||||
)
|
||||
else:
|
||||
rpc.add_transport_from_qr(account_id, url)
|
||||
|
||||
@@ -7,13 +7,14 @@ import time
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote
|
||||
from cmdeploy.sshexec import SSHExec
|
||||
from cmdeploy.cmdeploy import get_sshexec
|
||||
|
||||
|
||||
class TestSSHExecutor:
|
||||
@pytest.fixture(scope="class")
|
||||
def sshexec(self, sshdomain):
|
||||
return SSHExec(sshdomain)
|
||||
def sshexec(self, sshdomain, pytestconfig):
|
||||
ssh_config = pytestconfig.getoption("ssh_config")
|
||||
return get_sshexec(sshdomain, ssh_config=ssh_config)
|
||||
|
||||
def test_ls(self, sshexec):
|
||||
out = sshexec(call=remote.rdns.shell, kwargs=dict(command="ls"))
|
||||
@@ -27,6 +28,7 @@ class TestSSHExecutor:
|
||||
assert res["A"] or res["AAAA"]
|
||||
|
||||
def test_logged(self, sshexec, maildomain, capsys):
|
||||
sshexec.verbose = False
|
||||
sshexec.logged(
|
||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
@@ -52,6 +54,8 @@ class TestSSHExecutor:
|
||||
remote.rdns.perform_initial_checks,
|
||||
kwargs=dict(mail_domain=None),
|
||||
)
|
||||
except AssertionError:
|
||||
pass
|
||||
except sshexec.FuncError as e:
|
||||
assert "rdns.py" in str(e)
|
||||
assert "AssertionError" in str(e)
|
||||
@@ -83,10 +87,8 @@ def test_remote(remote, imap_or_smtp):
|
||||
|
||||
|
||||
def test_use_two_chatmailservers(cmfactory, maildomain2):
|
||||
ac1 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.switch_maildomain(maildomain2)
|
||||
ac2 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.bring_accounts_online()
|
||||
ac1 = cmfactory.get_online_account()
|
||||
ac2 = cmfactory.get_online_account(domain=maildomain2)
|
||||
cmfactory.get_accepted_chat(ac1, ac2)
|
||||
domain1 = ac1.get_config("addr").split("@")[1]
|
||||
domain2 = ac2.get_config("addr").split("@")[1]
|
||||
@@ -131,11 +133,10 @@ def test_authenticated_from(cmsetup, maildata):
|
||||
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
|
||||
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
||||
domain = cmsetup.maildomain
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
try:
|
||||
sock.connect((domain, 25))
|
||||
except socket.timeout:
|
||||
sock = socket.create_connection((domain, 25), timeout=10)
|
||||
sock.close()
|
||||
except (socket.timeout, OSError):
|
||||
pytest.skip(f"port 25 not reachable for {domain}")
|
||||
|
||||
recipient = cmsetup.gen_users(1)[0]
|
||||
@@ -146,7 +147,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
||||
conn.starttls()
|
||||
|
||||
with conn as s:
|
||||
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
||||
with pytest.raises(smtplib.SMTPDataError, match="No DKIM signature found"):
|
||||
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
|
||||
|
||||
|
||||
@@ -218,7 +219,7 @@ def test_expunged(remote, chatmail_config):
|
||||
]
|
||||
outdated_days = int(chatmail_config.delete_large_after) + 1
|
||||
find_cmds.append(
|
||||
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
|
||||
)
|
||||
for cmd in find_cmds:
|
||||
for line in remote.iter_output(cmd):
|
||||
|
||||
@@ -6,16 +6,17 @@ import imap_tools
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from cmdeploy.cmdeploy import get_sshexec
|
||||
from cmdeploy.remote import rshell
|
||||
from cmdeploy.sshexec import SSHExec
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imap_mailbox(cmfactory):
|
||||
def imap_mailbox(cmfactory, ssl_context):
|
||||
(ac1,) = cmfactory.get_online_accounts(1)
|
||||
user = ac1.get_config("addr")
|
||||
password = ac1.get_config("mail_pw")
|
||||
mailbox = imap_tools.MailBox(user.split("@")[1])
|
||||
host = user.split("@")[1]
|
||||
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
||||
mailbox.login(user, password)
|
||||
mailbox.dc_ac = ac1
|
||||
return mailbox
|
||||
@@ -26,6 +27,7 @@ class TestMetadataTokens:
|
||||
|
||||
def test_set_get_metadata(self, imap_mailbox):
|
||||
"set and get metadata token for an account"
|
||||
time.sleep(5) # make sure Metadata service had a chance to restart
|
||||
client = imap_mailbox.client
|
||||
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
|
||||
res = client.readline()
|
||||
@@ -61,11 +63,11 @@ class TestEndToEndDeltaChat:
|
||||
chat.send_text("message0")
|
||||
|
||||
lp.sec("wait for ac2 to receive message")
|
||||
msg2 = ac2._evtracker.wait_next_incoming_message()
|
||||
assert msg2.text == "message0"
|
||||
msg2 = ac2.wait_for_incoming_msg()
|
||||
assert msg2.get_snapshot().text == "message0"
|
||||
|
||||
def test_exceed_quota(
|
||||
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain
|
||||
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain, pytestconfig
|
||||
):
|
||||
"""This is a very slow test as it needs to upload >100MB of mail data
|
||||
before quota is exceeded, and thus depends on the speed of the upload.
|
||||
@@ -90,45 +92,43 @@ class TestEndToEndDeltaChat:
|
||||
lp.sec(f"filling remote inbox for {user}")
|
||||
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2,"
|
||||
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn)
|
||||
sshexec = SSHExec(sshdomain)
|
||||
sshexec = get_sshexec(
|
||||
sshdomain, ssh_config=pytestconfig.getoption("ssh_config")
|
||||
)
|
||||
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
|
||||
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
|
||||
assert res["percent"] >= 100
|
||||
|
||||
lp.sec("ac2: check quota is triggered")
|
||||
|
||||
starting = True
|
||||
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
|
||||
if starting:
|
||||
chat.send_text("hello")
|
||||
starting = False
|
||||
def send_hello():
|
||||
chat.send_text("hello")
|
||||
|
||||
for line in remote.iter_output(
|
||||
"journalctl -n1 -f -u dovecot", ready=send_hello
|
||||
):
|
||||
if user not in line:
|
||||
# print(line)
|
||||
continue
|
||||
if "quota exceeded" in line:
|
||||
return
|
||||
|
||||
def test_securejoin(self, cmfactory, lp, maildomain2):
|
||||
ac1 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.switch_maildomain(maildomain2)
|
||||
ac2 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.bring_accounts_online()
|
||||
ac1 = cmfactory.get_online_account()
|
||||
ac2 = cmfactory.get_online_account(domain=maildomain2)
|
||||
|
||||
lp.sec("ac1: create QR code and let ac2 scan it, starting the securejoin")
|
||||
qr = ac1.get_setup_contact_qr()
|
||||
qr = ac1.get_qr_code()
|
||||
|
||||
lp.sec("ac2: start QR-code based setup contact protocol")
|
||||
ch = ac2.qr_setup_contact(qr)
|
||||
ch = ac2.secure_join(qr)
|
||||
assert ch.id >= 10
|
||||
ac1._evtracker.wait_securejoin_inviter_progress(1000)
|
||||
ac1.wait_for_securejoin_inviter_success()
|
||||
|
||||
def test_dkim_header_stripped(self, cmfactory, maildomain2, lp, imap_mailbox):
|
||||
"""Test that if a DC address receives a message, it has no
|
||||
DKIM-Signature and Authentication-Results headers."""
|
||||
ac1 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.switch_maildomain(maildomain2)
|
||||
ac2 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.bring_accounts_online()
|
||||
ac1 = cmfactory.get_online_account()
|
||||
ac2 = cmfactory.get_online_account(domain=maildomain2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, imap_mailbox.dc_ac)
|
||||
chat.send_text("message0")
|
||||
chat2 = cmfactory.get_accepted_chat(ac2, imap_mailbox.dc_ac)
|
||||
@@ -145,33 +145,32 @@ class TestEndToEndDeltaChat:
|
||||
assert "dkim-signature" not in msg.headers
|
||||
|
||||
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
|
||||
ac1 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.switch_maildomain(maildomain2)
|
||||
ac2 = cmfactory.new_online_configuring_account(cache=False)
|
||||
cmfactory.bring_accounts_online()
|
||||
ac1 = cmfactory.get_online_account()
|
||||
ac2 = cmfactory.get_online_account(domain=maildomain2)
|
||||
|
||||
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
|
||||
qr = ac1.get_setup_contact_qr()
|
||||
ch = ac2.qr_setup_contact(qr)
|
||||
qr = ac1.get_qr_code()
|
||||
ch = ac2.secure_join(qr)
|
||||
assert ch.id >= 10
|
||||
ac1._evtracker.wait_securejoin_inviter_progress(1000)
|
||||
ac1.wait_for_securejoin_inviter_success()
|
||||
|
||||
lp.sec("ac1 sends a message and ac2 marks it as seen")
|
||||
chat = ac1.create_chat(ac2)
|
||||
msg = chat.send_text("hi")
|
||||
m = ac2._evtracker.wait_next_incoming_message()
|
||||
m = ac2.wait_for_incoming_msg()
|
||||
m.mark_seen()
|
||||
# we can only indirectly wait for mark-seen to cause an smtp-error
|
||||
lp.sec("try to wait for markseen to complete and check error states")
|
||||
deadline = time.time() + 3.1
|
||||
while time.time() < deadline:
|
||||
msgs = m.chat.get_messages()
|
||||
m_snap = m.get_snapshot()
|
||||
msgs = m_snap.chat.get_messages()
|
||||
for msg in msgs:
|
||||
assert "error" not in m.get_message_info()
|
||||
assert "error" not in m.get_info()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_hide_senders_ip_address(cmfactory):
|
||||
def test_hide_senders_ip_address(cmfactory, ssl_context):
|
||||
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
|
||||
assert ipaddress.ip_address(public_ip)
|
||||
|
||||
@@ -179,7 +178,12 @@ def test_hide_senders_ip_address(cmfactory):
|
||||
chat = cmfactory.get_accepted_chat(user1, user2)
|
||||
|
||||
chat.send_text("testing submission header cleanup")
|
||||
user2._evtracker.wait_next_incoming_message()
|
||||
user2.direct_imap.select_folder("Inbox")
|
||||
msg = user2.direct_imap.get_all_messages()[0]
|
||||
assert public_ip not in msg.obj.as_string()
|
||||
user2.wait_for_incoming_msg()
|
||||
addr = user2.get_config("addr")
|
||||
host = addr.split("@")[1]
|
||||
pw = user2.get_config("mail_pw")
|
||||
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
||||
mailbox.login(addr, pw)
|
||||
msgs = list(mailbox.fetch(mark_seen=False))
|
||||
assert msgs, "expected at least one message"
|
||||
assert public_ip not in msgs[0].obj.as_string()
|
||||
|
||||
@@ -3,9 +3,16 @@ import os
|
||||
from cmdeploy.cmdeploy import main
|
||||
|
||||
|
||||
def test_status_cmd(chatmail_config, capsys, request):
|
||||
def test_status_cmd(chatmail_config, capsys, request, pytestconfig):
|
||||
os.chdir(request.config.invocation_params.dir)
|
||||
assert main(["status"]) == 0
|
||||
command = ["status"]
|
||||
ssh_host = pytestconfig.getoption("ssh_host")
|
||||
if ssh_host:
|
||||
command.extend(["--ssh-host", ssh_host])
|
||||
ssh_config = pytestconfig.getoption("ssh_config")
|
||||
if ssh_config:
|
||||
command.extend(["--ssh-config", ssh_config])
|
||||
assert main(command) == 0
|
||||
status_out = capsys.readouterr()
|
||||
print(status_out.out)
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import imaplib
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import smtplib
|
||||
import socket
|
||||
import ssl
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -18,6 +20,76 @@ def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--slow", action="store_true", default=False, help="also run slow tests"
|
||||
)
|
||||
parser.addoption(
|
||||
"--ssh-host",
|
||||
dest="ssh_host",
|
||||
default=None,
|
||||
help="SSH host (overrides mail_domain for SSH operations).",
|
||||
)
|
||||
parser.addoption(
|
||||
"--ssh-config",
|
||||
dest="ssh_config",
|
||||
default=None,
|
||||
help="Path to an SSH config file (e.g. lxconfigs/ssh-config).",
|
||||
)
|
||||
|
||||
|
||||
def _parse_ssh_config_hosts(path):
|
||||
"""Parse an OpenSSH config file and return a dict of hostname -> IP."""
|
||||
mapping = {}
|
||||
current_names = []
|
||||
for ln in Path(path).read_text().splitlines():
|
||||
line = ln.strip()
|
||||
m = re.match(r"^Host\s+(.+)", line)
|
||||
if m:
|
||||
current_names = m.group(1).split()
|
||||
continue
|
||||
m = re.match(r"^Hostname\s+(\S+)", line)
|
||||
if m and current_names:
|
||||
ip = m.group(1)
|
||||
for name in current_names:
|
||||
mapping[name] = ip
|
||||
current_names = []
|
||||
return mapping
|
||||
|
||||
|
||||
_original_getaddrinfo = socket.getaddrinfo
|
||||
|
||||
|
||||
def _make_patched_getaddrinfo(host_map):
|
||||
"""Return a getaddrinfo that resolves hosts in host_map to their IPs."""
|
||||
|
||||
def patched_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
||||
if host in host_map:
|
||||
ip = host_map[host]
|
||||
return _original_getaddrinfo(ip, port, family, type, proto, flags)
|
||||
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
||||
|
||||
return patched_getaddrinfo
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="session")
|
||||
def _setup_localchat_dns(pytestconfig):
|
||||
"""Monkey-patch socket.getaddrinfo to resolve .localchat via ssh-config."""
|
||||
ssh_config = pytestconfig.getoption("ssh_config")
|
||||
if not ssh_config or not Path(ssh_config).exists():
|
||||
yield {}
|
||||
return
|
||||
host_map = _parse_ssh_config_hosts(ssh_config)
|
||||
if not host_map:
|
||||
yield {}
|
||||
return
|
||||
socket.getaddrinfo = _make_patched_getaddrinfo(host_map)
|
||||
try:
|
||||
yield host_map
|
||||
finally:
|
||||
socket.getaddrinfo = _original_getaddrinfo
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ssh_config_host_map(_setup_localchat_dns):
|
||||
"""Return the host-name → IP map parsed from ssh-config."""
|
||||
return _setup_localchat_dns
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
@@ -34,17 +106,29 @@ def pytest_runtest_setup(item):
|
||||
pytest.skip("skipping slow test, use --slow to run")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def chatmail_config(pytestconfig):
|
||||
current = basedir = Path().resolve()
|
||||
def _get_chatmail_config():
|
||||
ini = os.environ.get("CHATMAIL_INI")
|
||||
if ini:
|
||||
path = Path(ini).resolve()
|
||||
if path.exists():
|
||||
return read_config(path), path
|
||||
current = Path().resolve()
|
||||
while 1:
|
||||
path = current.joinpath("chatmail.ini").resolve()
|
||||
if path.exists():
|
||||
return read_config(path)
|
||||
return read_config(path), path
|
||||
if current == current.parent:
|
||||
break
|
||||
current = current.parent
|
||||
return None, None
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def chatmail_config(pytestconfig):
|
||||
config, path = _get_chatmail_config()
|
||||
if config:
|
||||
return config
|
||||
basedir = Path().resolve()
|
||||
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
|
||||
|
||||
|
||||
@@ -54,8 +138,14 @@ def maildomain(chatmail_config):
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def sshdomain(maildomain):
|
||||
return os.environ.get("CHATMAIL_SSH", maildomain)
|
||||
def sshdomain(maildomain, pytestconfig):
|
||||
return pytestconfig.getoption("ssh_host") or maildomain
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def maildomain_ip(maildomain, ssh_config_host_map):
|
||||
"""Return the IP for maildomain from ssh-config, or maildomain itself."""
|
||||
return ssh_config_host_map.get(maildomain, maildomain)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -72,10 +162,17 @@ def sshdomain2(maildomain2):
|
||||
|
||||
|
||||
def pytest_report_header():
|
||||
domain = os.environ.get("CHATMAIL_DOMAIN")
|
||||
if domain:
|
||||
text = f"chatmail test instance: {domain}"
|
||||
return ["-" * len(text), text, "-" * len(text)]
|
||||
config, path = _get_chatmail_config()
|
||||
domain2 = os.environ.get("CHATMAIL_DOMAIN2", "NOT SET")
|
||||
domain = config.mail_domain if config else "NOT SET"
|
||||
path = path if path else "NOT SET"
|
||||
|
||||
lines = [
|
||||
f"chatmail.ini {domain} location: {path}",
|
||||
f"chatmail2: {domain2}",
|
||||
]
|
||||
sep = "-" * max(map(len, lines))
|
||||
return [sep, *lines, sep]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -90,15 +187,22 @@ def cm_data(request):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def benchmark(request):
|
||||
def bench(func, num, name=None, reportfunc=None):
|
||||
def benchmark(request, chatmail_config):
|
||||
def bench(func, num, name=None, reportfunc=None, cooldown=0.0):
|
||||
if name is None:
|
||||
name = func.__name__
|
||||
if cooldown == "auto":
|
||||
per_minute = max(chatmail_config.max_user_send_per_minute, 1)
|
||||
cooldown = chatmail_config.max_user_send_burst_size * 60 / per_minute
|
||||
|
||||
durations = []
|
||||
for i in range(num):
|
||||
now = time.time()
|
||||
func()
|
||||
durations.append(time.time() - now)
|
||||
if cooldown > 0 and i + 1 < num:
|
||||
# Keep post-run cooldown out of measured benchmark duration.
|
||||
time.sleep(cooldown)
|
||||
durations.sort()
|
||||
request.config._benchresults[name] = (reportfunc, durations)
|
||||
|
||||
@@ -144,15 +248,25 @@ def pytest_terminal_summary(terminalreporter):
|
||||
tr.write_line(line)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imap(maildomain):
|
||||
return ImapConn(maildomain)
|
||||
@pytest.fixture(scope="session")
|
||||
def ssl_context(chatmail_config):
|
||||
if chatmail_config.tls_cert_mode == "self":
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
return ctx
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_imap_connection(maildomain):
|
||||
def imap(maildomain, ssl_context):
|
||||
return ImapConn(maildomain, ssl_context=ssl_context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_imap_connection(maildomain, ssl_context):
|
||||
def make_imap_connection():
|
||||
conn = ImapConn(maildomain)
|
||||
conn = ImapConn(maildomain, ssl_context=ssl_context)
|
||||
conn.connect()
|
||||
return conn
|
||||
|
||||
@@ -164,12 +278,13 @@ class ImapConn:
|
||||
logcmd = "journalctl -f -u dovecot"
|
||||
name = "dovecot"
|
||||
|
||||
def __init__(self, host):
|
||||
def __init__(self, host, ssl_context=None):
|
||||
self.host = host
|
||||
self.ssl_context = ssl_context
|
||||
|
||||
def connect(self):
|
||||
print(f"imap-connect {self.host}")
|
||||
self.conn = imaplib.IMAP4_SSL(self.host)
|
||||
self.conn = imaplib.IMAP4_SSL(self.host, ssl_context=self.ssl_context)
|
||||
|
||||
def login(self, user, password):
|
||||
print(f"imap-login {user!r} {password!r}")
|
||||
@@ -195,14 +310,14 @@ class ImapConn:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def smtp(maildomain):
|
||||
return SmtpConn(maildomain)
|
||||
def smtp(maildomain, ssl_context):
|
||||
return SmtpConn(maildomain, ssl_context=ssl_context)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_smtp_connection(maildomain):
|
||||
def make_smtp_connection(maildomain, ssl_context):
|
||||
def make_smtp_connection():
|
||||
conn = SmtpConn(maildomain)
|
||||
conn = SmtpConn(maildomain, ssl_context=ssl_context)
|
||||
conn.connect()
|
||||
return conn
|
||||
|
||||
@@ -214,12 +329,14 @@ class SmtpConn:
|
||||
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
|
||||
name = "postfix"
|
||||
|
||||
def __init__(self, host):
|
||||
def __init__(self, host, ssl_context=None):
|
||||
self.host = host
|
||||
self.ssl_context = ssl_context
|
||||
|
||||
def connect(self):
|
||||
print(f"smtp-connect {self.host}")
|
||||
self.conn = smtplib.SMTP_SSL(self.host)
|
||||
context = self.ssl_context or ssl.create_default_context()
|
||||
self.conn = smtplib.SMTP_SSL(self.host, context=context)
|
||||
|
||||
def login(self, user, password):
|
||||
print(f"smtp-login {user!r} {password!r}")
|
||||
@@ -262,92 +379,161 @@ def gencreds(chatmail_config):
|
||||
|
||||
|
||||
#
|
||||
# Delta Chat testplugin re-use
|
||||
# Delta Chat RPC-based test support
|
||||
# use the cmfactory fixture to get chatmail instance accounts
|
||||
#
|
||||
|
||||
from deltachat_rpc_client import DeltaChat, Rpc
|
||||
|
||||
class ChatmailTestProcess:
|
||||
"""Provider for chatmail instance accounts as used by deltachat.testplugin.acfactory"""
|
||||
|
||||
def __init__(self, pytestconfig, maildomain, gencreds):
|
||||
self.pytestconfig = pytestconfig
|
||||
self.maildomain = maildomain
|
||||
assert "." in self.maildomain, maildomain
|
||||
class ChatmailACFactory:
|
||||
"""RPC-based account factory for chatmail testing."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rpc,
|
||||
maildomain,
|
||||
maildomain_ip,
|
||||
gencreds,
|
||||
chatmail_config,
|
||||
ssh_config_host_map,
|
||||
):
|
||||
self.dc = DeltaChat(rpc)
|
||||
self.rpc = rpc
|
||||
self._maildomain = maildomain
|
||||
self._maildomain_ip = maildomain_ip
|
||||
self.gencreds = gencreds
|
||||
self._addr2files = {}
|
||||
self.chatmail_config = chatmail_config
|
||||
self._ssh_config_host_map = ssh_config_host_map
|
||||
|
||||
def get_liveconfig_producer(self):
|
||||
while 1:
|
||||
user, password = self.gencreds(self.maildomain)
|
||||
config = {
|
||||
"addr": user,
|
||||
"mail_pw": password,
|
||||
}
|
||||
# speed up account configuration
|
||||
config["mail_server"] = self.maildomain
|
||||
config["send_server"] = self.maildomain
|
||||
yield config
|
||||
def _make_transport(self, domain):
|
||||
"""Build a transport config dict for the given domain."""
|
||||
addr, password = self.gencreds(domain)
|
||||
transport = {
|
||||
"addr": addr,
|
||||
"password": password,
|
||||
}
|
||||
# To support running against local relays without host DNS resolution
|
||||
# we attempt resolving the domain via ssh-config
|
||||
# because otherwise core fails to find the address
|
||||
server = self._ssh_config_host_map.get(domain)
|
||||
if server is not None:
|
||||
transport.update({"imapServer": server, "smtpServer": server})
|
||||
if self.chatmail_config.tls_cert_mode == "self":
|
||||
transport["certificateChecks"] = "acceptInvalidCertificates"
|
||||
return transport
|
||||
|
||||
def cache_maybe_retrieve_configured_db_files(self, cache_addr, db_target_path):
|
||||
pass
|
||||
def get_online_account(self, domain=None):
|
||||
"""Create, configure and bring online a single account."""
|
||||
return self.get_online_accounts(1, domain)[0]
|
||||
|
||||
def cache_maybe_store_configured_db_files(self, acc):
|
||||
pass
|
||||
def get_online_accounts(self, num, domain=None):
|
||||
"""Create multiple online accounts in parallel."""
|
||||
domain = domain or self._maildomain
|
||||
futures = []
|
||||
accounts = []
|
||||
for _ in range(num):
|
||||
account = self.dc.add_account()
|
||||
future = account.add_or_update_transport.future(
|
||||
self._make_transport(domain)
|
||||
)
|
||||
futures.append(future)
|
||||
|
||||
# ensure messages stay in INBOX so that they can be
|
||||
# concurrently fetched via extra IMAP connections during tests
|
||||
account.set_config("delete_server_after", "10")
|
||||
accounts.append(account)
|
||||
|
||||
for future in futures:
|
||||
future()
|
||||
|
||||
for account in accounts:
|
||||
account.bring_online()
|
||||
return accounts
|
||||
|
||||
def get_accepted_chat(self, ac1, ac2):
|
||||
"""Create a 1:1 chat between ac1 and ac2 accepted on both sides."""
|
||||
ac2.create_chat(ac1)
|
||||
return ac1.create_chat(ac2)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def rpc(tmp_path_factory):
|
||||
"""Start a deltachat-rpc-server process for the test session."""
|
||||
|
||||
# NB: accounts_dir must NOT already exist as directory --
|
||||
# core-rust only creates accounts.toml if the dir doesn't exist yet.
|
||||
accounts_dir = str(tmp_path_factory.mktemp("dc") / "accounts")
|
||||
rpc = Rpc(accounts_dir=accounts_dir)
|
||||
rpc.start()
|
||||
yield rpc
|
||||
rpc.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmfactory(request, gencreds, tmpdir, maildomain):
|
||||
# cloned from deltachat.testplugin.amfactory
|
||||
pytest.importorskip("deltachat")
|
||||
from deltachat.testplugin import ACFactory
|
||||
|
||||
testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
|
||||
|
||||
class Data:
|
||||
def read_path(self, path):
|
||||
return
|
||||
|
||||
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
|
||||
|
||||
# nb. a bit hacky
|
||||
# would probably be better if deltachat's test machinery grows native support
|
||||
def switch_maildomain(maildomain2):
|
||||
am.testprocess.maildomain = maildomain2
|
||||
|
||||
am.switch_maildomain = switch_maildomain
|
||||
|
||||
yield am
|
||||
if hasattr(request.node, "rep_call") and request.node.rep_call.failed:
|
||||
if testproc.pytestconfig.getoption("--extra-info"):
|
||||
logfile = io.StringIO()
|
||||
am.dump_imap_summary(logfile=logfile)
|
||||
print(logfile.getvalue())
|
||||
# request.node.add_report_section("call", "imap-server-state", s)
|
||||
def cmfactory(
|
||||
rpc, gencreds, maildomain, maildomain_ip, chatmail_config, ssh_config_host_map
|
||||
):
|
||||
"""Return a ChatmailACFactory for creating online Delta Chat accounts."""
|
||||
return ChatmailACFactory(
|
||||
rpc=rpc,
|
||||
maildomain=maildomain,
|
||||
maildomain_ip=maildomain_ip,
|
||||
gencreds=gencreds,
|
||||
chatmail_config=chatmail_config,
|
||||
ssh_config_host_map=ssh_config_host_map,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def remote(sshdomain):
|
||||
return Remote(sshdomain)
|
||||
def remote(sshdomain, pytestconfig):
|
||||
r = Remote(sshdomain, ssh_config=pytestconfig.getoption("ssh_config"))
|
||||
yield r
|
||||
r.close()
|
||||
|
||||
|
||||
class Remote:
|
||||
def __init__(self, sshdomain):
|
||||
def __init__(self, sshdomain, ssh_config=None):
|
||||
self.sshdomain = sshdomain
|
||||
self.ssh_config = ssh_config
|
||||
self._procs = []
|
||||
|
||||
def iter_output(self, logcmd=""):
|
||||
def iter_output(self, logcmd="", ready=None):
|
||||
getjournal = "journalctl -f" if not logcmd else logcmd
|
||||
self.popen = subprocess.Popen(
|
||||
["ssh", f"root@{self.sshdomain}", getjournal],
|
||||
print(self.sshdomain)
|
||||
match self.sshdomain:
|
||||
case "@local":
|
||||
command = []
|
||||
case "localhost":
|
||||
command = []
|
||||
case _:
|
||||
command = ["ssh"]
|
||||
if self.ssh_config:
|
||||
command.extend(["-F", self.ssh_config])
|
||||
command.append(f"root@{self.sshdomain}")
|
||||
[command.append(arg) for arg in getjournal.split()]
|
||||
popen = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._procs.append(popen)
|
||||
while 1:
|
||||
line = self.popen.stdout.readline()
|
||||
line = popen.stdout.readline()
|
||||
res = line.decode().strip().lower()
|
||||
if res:
|
||||
yield res
|
||||
else:
|
||||
if not res:
|
||||
break
|
||||
if ready is not None:
|
||||
ready()
|
||||
ready = None
|
||||
yield res
|
||||
|
||||
def close(self):
|
||||
while self._procs:
|
||||
proc = self._procs.pop()
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -363,38 +549,40 @@ def lp(request):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmsetup(maildomain, gencreds):
|
||||
return CMSetup(maildomain, gencreds)
|
||||
def cmsetup(maildomain, gencreds, ssl_context):
|
||||
return CMSetup(maildomain, gencreds, ssl_context)
|
||||
|
||||
|
||||
class CMSetup:
|
||||
def __init__(self, maildomain, gencreds):
|
||||
def __init__(self, maildomain, gencreds, ssl_context):
|
||||
self.maildomain = maildomain
|
||||
self.gencreds = gencreds
|
||||
self.ssl_context = ssl_context
|
||||
|
||||
def gen_users(self, num):
|
||||
print(f"Creating {num} online users")
|
||||
users = []
|
||||
for i in range(num):
|
||||
addr, password = self.gencreds()
|
||||
user = CMUser(self.maildomain, addr, password)
|
||||
user = CMUser(self.maildomain, addr, password, self.ssl_context)
|
||||
assert user.smtp
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
|
||||
class CMUser:
|
||||
def __init__(self, maildomain, addr, password):
|
||||
def __init__(self, maildomain, addr, password, ssl_context=None):
|
||||
self.maildomain = maildomain
|
||||
self.addr = addr
|
||||
self.password = password
|
||||
self.ssl_context = ssl_context
|
||||
self._smtp = None
|
||||
self._imap = None
|
||||
|
||||
@property
|
||||
def smtp(self):
|
||||
if not self._smtp:
|
||||
handle = SmtpConn(self.maildomain)
|
||||
handle = SmtpConn(self.maildomain, ssl_context=self.ssl_context)
|
||||
handle.connect()
|
||||
handle.login(self.addr, self.password)
|
||||
self._smtp = handle
|
||||
@@ -403,7 +591,7 @@ class CMUser:
|
||||
@property
|
||||
def imap(self):
|
||||
if not self._imap:
|
||||
imap = ImapConn(self.maildomain)
|
||||
imap = ImapConn(self.maildomain, ssl_context=self.ssl_context)
|
||||
imap.connect()
|
||||
imap.login(self.addr, self.password)
|
||||
self._imap = imap
|
||||
|
||||
@@ -23,7 +23,10 @@ class TestCmdline:
|
||||
run = parser.parse_args(["run"])
|
||||
assert init and run
|
||||
|
||||
def test_init_not_overwrite(self, capsys):
|
||||
def test_init_not_overwrite(self, tmp_path, capsys, monkeypatch):
|
||||
monkeypatch.delenv("CHATMAIL_INI", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
assert main(["init", "chat.example.org"]) == 0
|
||||
capsys.readouterr()
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from copy import deepcopy
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote
|
||||
from cmdeploy.dns import check_full_zone, check_initial_remote_data
|
||||
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -60,6 +60,29 @@ def mockdns(request, mockdns_base, mockdns_expected):
|
||||
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:
|
||||
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
@@ -91,19 +114,44 @@ class TestPerformInitialChecks:
|
||||
assert not res
|
||||
assert len(l) == 2
|
||||
|
||||
def test_perform_initial_checks_no_mta_sts_self_signed(self, mockdns):
|
||||
del mockdns["CNAME"]["mta-sts.some.domain"]
|
||||
remote_data = remote.rdns.perform_initial_checks("some.domain")
|
||||
assert not remote_data["MTA_STS"]
|
||||
|
||||
l = []
|
||||
res = check_initial_remote_data(remote_data, strict_tls=False, print=l.append)
|
||||
assert res
|
||||
assert not l
|
||||
|
||||
|
||||
def 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.
|
||||
"""
|
||||
records = list(parse_zone_records(text))
|
||||
assert records == [
|
||||
("some.domain", "3600", "A", "1.1.1.1"),
|
||||
("www.some.domain", "3600", "CNAME", "some.domain."),
|
||||
]
|
||||
|
||||
|
||||
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):
|
||||
for zf_line in zonefile.split("\n"):
|
||||
if zf_line.startswith("#"):
|
||||
if "Recommended" in zf_line and only_required:
|
||||
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
|
||||
if only_required:
|
||||
# Only take records before the "; Recommended" section
|
||||
zonefile = zonefile.split("; Recommended")[0]
|
||||
for name, ttl, rtype, rdata in parse_zone_records(zonefile):
|
||||
mockdns_base.setdefault(rtype, {})[name] = rdata
|
||||
|
||||
|
||||
class MockSSHExec:
|
||||
|
||||
78
cmdeploy/src/cmdeploy/tests/test_external_tls.py
Normal file
78
cmdeploy/src/cmdeploy/tests/test_external_tls.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Functional tests for tls_external_cert_and_key option."""
|
||||
|
||||
import json
|
||||
|
||||
import chatmaild.newemail
|
||||
import pytest
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
|
||||
def make_external_config(tmp_path, cert_key=None):
|
||||
inipath = tmp_path / "chatmail.ini"
|
||||
overrides = {}
|
||||
if cert_key is not None:
|
||||
overrides["tls_external_cert_and_key"] = cert_key
|
||||
write_initial_config(inipath, "chat.example.org", overrides=overrides)
|
||||
return inipath
|
||||
|
||||
|
||||
def test_external_tls_config_reads_paths(tmp_path):
|
||||
inipath = make_external_config(
|
||||
tmp_path,
|
||||
cert_key=(
|
||||
"/etc/letsencrypt/live/chat.example.org/fullchain.pem"
|
||||
" /etc/letsencrypt/live/chat.example.org/privkey.pem"
|
||||
),
|
||||
)
|
||||
config = read_config(inipath)
|
||||
assert config.tls_cert_mode == "external"
|
||||
assert (
|
||||
config.tls_cert_path == "/etc/letsencrypt/live/chat.example.org/fullchain.pem"
|
||||
)
|
||||
assert config.tls_key_path == "/etc/letsencrypt/live/chat.example.org/privkey.pem"
|
||||
|
||||
|
||||
def test_external_tls_missing_option_uses_acme(tmp_path):
|
||||
config = read_config(make_external_config(tmp_path))
|
||||
assert config.tls_cert_mode == "acme"
|
||||
|
||||
|
||||
def test_external_tls_bad_format_raises(tmp_path):
|
||||
inipath = make_external_config(tmp_path, cert_key="/only/one/path.pem")
|
||||
with pytest.raises(ValueError, match="two space-separated"):
|
||||
read_config(inipath)
|
||||
|
||||
|
||||
def test_external_tls_three_paths_raises(tmp_path):
|
||||
inipath = make_external_config(tmp_path, cert_key="/a /b /c")
|
||||
with pytest.raises(ValueError, match="two space-separated"):
|
||||
read_config(inipath)
|
||||
|
||||
|
||||
def test_external_tls_no_dclogin_url(tmp_path, capsys, monkeypatch):
|
||||
inipath = make_external_config(
|
||||
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
|
||||
)
|
||||
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(inipath))
|
||||
chatmaild.newemail.print_new_account()
|
||||
out, _ = capsys.readouterr()
|
||||
lines = out.split("\n")
|
||||
dic = json.loads(lines[2])
|
||||
assert "dclogin_url" not in dic
|
||||
|
||||
|
||||
def test_external_tls_selects_correct_deployer(tmp_path):
|
||||
from cmdeploy.deployers import get_tls_deployer
|
||||
from cmdeploy.external.deployer import ExternalTlsDeployer
|
||||
from cmdeploy.selfsigned.deployer import SelfSignedTlsDeployer
|
||||
|
||||
inipath = make_external_config(
|
||||
tmp_path, cert_key="/certs/fullchain.pem /certs/privkey.pem"
|
||||
)
|
||||
config = read_config(inipath)
|
||||
deployer = get_tls_deployer(config, "chat.example.org")
|
||||
|
||||
assert isinstance(deployer, ExternalTlsDeployer)
|
||||
assert not isinstance(deployer, SelfSignedTlsDeployer)
|
||||
assert deployer.cert_path == "/certs/fullchain.pem"
|
||||
assert deployer.key_path == "/certs/privkey.pem"
|
||||
174
cmdeploy/src/cmdeploy/tests/test_lxc.py
Normal file
174
cmdeploy/src/cmdeploy/tests/test_lxc.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for cmdeploy lxc-* subcommands."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdeploy.lxc import cli
|
||||
from cmdeploy.lxc.incus import Incus
|
||||
from cmdeploy.util import Out
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not shutil.which("incus"),
|
||||
reason="incus not installed",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ix():
|
||||
out = Out()
|
||||
return Incus(out)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def lxc_setup():
|
||||
out = Out()
|
||||
ix = Incus(out)
|
||||
ix.get_dns_container().ensure()
|
||||
return ix.list_managed()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def relay_container(lxc_setup):
|
||||
test_names = {f"{n}-localchat" for n in cli.RELAY_NAMES}
|
||||
relays = [c for c in lxc_setup if c["name"] in test_names and c.get("ip")]
|
||||
if not relays:
|
||||
pytest.skip("no test relay containers running")
|
||||
return relays[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cmdeploy():
|
||||
def run(*args):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "cmdeploy.cmdeploy", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
return run
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subcmd, expected, absent",
|
||||
[
|
||||
(None, ["lxc-start", "lxc-stop", "lxc-test", "lxc-status"], ["lxc-destroy"]),
|
||||
("lxc-start", ["--ipv4-only", "--run"], ["--config"]),
|
||||
("lxc-stop", ["--destroy", "--destroy-all"], ["--config"]),
|
||||
("lxc-test", ["--one"], ["--config"]),
|
||||
("lxc-status", [], ["--config"]),
|
||||
("run", ["--ssh-config"], ["--lxc"]),
|
||||
("dns", ["--ssh-config"], []),
|
||||
("test", ["--ssh-config"], []),
|
||||
("status", ["--ssh-config"], []),
|
||||
],
|
||||
)
|
||||
def test_help_options(cmdeploy, subcmd, expected, absent):
|
||||
args = [subcmd, "--help"] if subcmd else ["--help"]
|
||||
result = cmdeploy(*args)
|
||||
output = result.stdout + result.stderr
|
||||
assert result.returncode == 0
|
||||
for flag in expected:
|
||||
assert flag in output
|
||||
for flag in absent:
|
||||
assert flag not in output
|
||||
|
||||
|
||||
class TestSSHConfig:
|
||||
def test_lxconfigs(self, ix, lxc_setup):
|
||||
d = ix.lxconfigs_dir
|
||||
assert d.name == "lxconfigs"
|
||||
assert d.exists()
|
||||
path = ix.ssh_config_path
|
||||
assert path.name == "ssh-config"
|
||||
assert path.parent.name == "lxconfigs"
|
||||
|
||||
def test_write_ssh_config(self, ix, lxc_setup):
|
||||
path = ix.write_ssh_config()
|
||||
assert path.exists()
|
||||
text = path.read_text()
|
||||
|
||||
for c in lxc_setup:
|
||||
if c.get("ip"):
|
||||
assert c["name"] in text
|
||||
assert f"Hostname {c['ip']}" in text
|
||||
|
||||
assert "User root" in text
|
||||
assert "IdentityFile" in text
|
||||
assert "StrictHostKeyChecking accept-new" in text
|
||||
|
||||
|
||||
def test_dns(ix, relay_container):
|
||||
def dig(qname, qtype):
|
||||
ct = ix.get_dns_container()
|
||||
return ct.bash(f"dig @127.0.0.1 {qname} {qtype} +short").strip()
|
||||
|
||||
domain = relay_container["domain"]
|
||||
assert dig(domain, "A") == relay_container["ip"]
|
||||
assert domain in dig(domain, "MX")
|
||||
assert "587" in dig(f"_submission._tcp.{domain}", "SRV")
|
||||
|
||||
|
||||
class TestLxcStatus:
|
||||
def test_cli_lxc_status_help(self, cmdeploy):
|
||||
result = cmdeploy("lxc-status", "--help")
|
||||
assert result.returncode == 0
|
||||
assert "status" in result.stdout.lower()
|
||||
|
||||
def test_shows_containers(self, lxc_setup, capsys):
|
||||
class QuietOut(Out):
|
||||
def red(self, msg, **kw):
|
||||
pass
|
||||
|
||||
def green(self, msg, **kw):
|
||||
pass
|
||||
|
||||
ret = cli.lxc_status_cmd(None, QuietOut())
|
||||
assert ret == 0
|
||||
captured = capsys.readouterr().out
|
||||
assert "ns-localchat" in captured
|
||||
assert "running" in captured
|
||||
|
||||
def test_deploy_freshness(self, ix, monkeypatch):
|
||||
ct = ix.get_container("x")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cmdeploy.lxc.incus.RelayContainer.deployed_version",
|
||||
lambda _self: "abc123def456",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"cmdeploy.lxc.incus.RelayContainer.deployed_domain",
|
||||
lambda _self: ct.domain,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"cmdeploy.lxc.cli.get_version_string",
|
||||
lambda: "abc123def456",
|
||||
)
|
||||
assert "IN-SYNC" in cli._deploy_status(ct, "abc123def456", ix)
|
||||
assert "STALE" in cli._deploy_status(ct, "other_hash_here", ix)
|
||||
|
||||
# Hash matches but local has uncommitted changes
|
||||
monkeypatch.setattr(
|
||||
"cmdeploy.lxc.cli.get_version_string",
|
||||
lambda: "abc123def456\ndiff --git a/foo",
|
||||
)
|
||||
assert "DIRTY" in cli._deploy_status(ct, "abc123def456", ix)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"cmdeploy.lxc.incus.RelayContainer.deployed_version",
|
||||
lambda _self: None,
|
||||
)
|
||||
assert "NOT DEPLOYED" in cli._deploy_status(ct, "abc123", ix)
|
||||
68
cmdeploy/src/cmdeploy/tests/test_rshell.py
Normal file
68
cmdeploy/src/cmdeploy/tests/test_rshell.py
Normal file
@@ -0,0 +1,68 @@
|
||||
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
|
||||
120
cmdeploy/src/cmdeploy/tests/test_util.py
Normal file
120
cmdeploy/src/cmdeploy/tests/test_util.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import sys
|
||||
|
||||
from cmdeploy.util import Out, collapse, get_git_hash, get_version_string, shell
|
||||
|
||||
|
||||
class TestOut:
|
||||
def test_prefix_default(self, capsys):
|
||||
out = Out()
|
||||
out.print("hello")
|
||||
assert capsys.readouterr().out == "hello\n"
|
||||
|
||||
def test_prefix_custom(self, capsys):
|
||||
out = Out(prefix=">> ")
|
||||
out.print("hello")
|
||||
assert capsys.readouterr().out == ">> hello\n"
|
||||
|
||||
def test_prefix_print_file(self):
|
||||
import io
|
||||
|
||||
buf = io.StringIO()
|
||||
out = Out(prefix=":: ")
|
||||
out.print("msg", file=buf)
|
||||
assert ":: msg" in buf.getvalue()
|
||||
|
||||
def test_new_prefixed_out(self, capsys):
|
||||
parent = Out(prefix="A")
|
||||
child = parent.new_prefixed_out("B")
|
||||
child.print("x")
|
||||
assert capsys.readouterr().out == "ABx\n"
|
||||
# shares section_timings
|
||||
assert child.section_timings is parent.section_timings
|
||||
|
||||
def test_section_no_auto_indent(self, capsys):
|
||||
out = Out(prefix="")
|
||||
with out.section("test"):
|
||||
out.print("inside")
|
||||
captured = capsys.readouterr().out
|
||||
# "inside" should NOT be indented by section()
|
||||
lines = captured.strip().splitlines()
|
||||
inside_line = [l for l in lines if "inside" in l][0]
|
||||
assert inside_line == "inside"
|
||||
|
||||
def test_section_records_timing(self):
|
||||
out = Out()
|
||||
with out.section("s1"):
|
||||
pass
|
||||
assert len(out.section_timings) == 1
|
||||
assert out.section_timings[0][0] == "s1"
|
||||
|
||||
def test_shell_failure_shows_output(self):
|
||||
"""When a shell command fails, its output and exit code are shown."""
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"from cmdeploy.util import Out; Out(prefix='').shell("
|
||||
"\"echo 'boom on stderr' >&2; exit 42\")",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
# the command's stderr is merged into stdout by Popen
|
||||
assert "boom on stderr" in result.stdout
|
||||
# Out.red() prints the failure notice to stderr
|
||||
assert "exit code 42" in result.stderr
|
||||
|
||||
|
||||
def test_collapse():
|
||||
text = """
|
||||
line 1
|
||||
line 2
|
||||
"""
|
||||
assert collapse(text) == "line 1 line 2"
|
||||
assert collapse(" single line ") == "single line"
|
||||
|
||||
|
||||
def test_git_helpers_no_git(tmp_path):
|
||||
# Not a git repo
|
||||
assert get_git_hash(root=tmp_path) is None
|
||||
assert get_version_string(root=tmp_path) == "unknown"
|
||||
|
||||
|
||||
def test_git_helpers_empty_repo(tmp_path):
|
||||
shell("git init", cwd=tmp_path, check=True)
|
||||
# No commits yet
|
||||
assert get_git_hash(root=tmp_path) is None
|
||||
assert get_version_string(root=tmp_path) == "unknown"
|
||||
|
||||
|
||||
def test_git_helpers_with_commits_and_diffs(tmp_path):
|
||||
shell("git init", cwd=tmp_path, check=True)
|
||||
shell("git config user.email you@example.com", cwd=tmp_path, check=True)
|
||||
shell("git config user.name 'Your Name'", cwd=tmp_path, check=True)
|
||||
|
||||
# First commit
|
||||
path = tmp_path / "file.txt"
|
||||
path.write_text("content")
|
||||
shell("git add file.txt", cwd=tmp_path, check=True)
|
||||
shell("git commit -m initial", cwd=tmp_path, check=True)
|
||||
|
||||
git_hash = get_git_hash(root=tmp_path)
|
||||
assert len(git_hash) >= 7 # usually 40, but git is git
|
||||
assert get_version_string(root=tmp_path) == git_hash
|
||||
|
||||
# Create a diff
|
||||
path.write_text("new content")
|
||||
v = get_version_string(root=tmp_path)
|
||||
assert v.startswith(git_hash + "\n")
|
||||
assert "new content" in v
|
||||
assert not v.endswith("\n")
|
||||
|
||||
# Commit again -> no diff
|
||||
shell("git add file.txt", cwd=tmp_path, check=True)
|
||||
shell("git commit -m second", cwd=tmp_path, check=True)
|
||||
new_hash = get_git_hash(root=tmp_path)
|
||||
assert new_hash != git_hash
|
||||
assert get_version_string(root=tmp_path) == new_hash
|
||||
169
cmdeploy/src/cmdeploy/util.py
Normal file
169
cmdeploy/src/cmdeploy/util.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Shared utility functions for cmdeploy."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from termcolor import colored
|
||||
|
||||
|
||||
class Out:
|
||||
"""Convenience output printer providing coloring and section formatting."""
|
||||
|
||||
def __init__(self, prefix="", verbosity=0):
|
||||
self.section_timings = []
|
||||
self.prefix = prefix
|
||||
self.sepchar = "\u2501"
|
||||
self.verbosity = verbosity
|
||||
env_width = os.environ.get("_CMDEPLOY_WIDTH")
|
||||
if env_width:
|
||||
self.section_width = int(env_width)
|
||||
else:
|
||||
self.section_width = shutil.get_terminal_size((80, 24)).columns
|
||||
|
||||
def new_prefixed_out(self, newprefix=" "):
|
||||
"""Return a new Out with an extended prefix,
|
||||
sharing section_timings with the parent.
|
||||
"""
|
||||
out = Out(
|
||||
prefix=self.prefix + newprefix,
|
||||
verbosity=self.verbosity,
|
||||
)
|
||||
out.section_timings = self.section_timings
|
||||
return out
|
||||
|
||||
def red(self, msg, file=sys.stderr):
|
||||
print(colored(self.prefix + msg, "red"), file=file, flush=True)
|
||||
|
||||
def green(self, msg, file=sys.stderr):
|
||||
print(colored(self.prefix + msg, "green"), file=file, flush=True)
|
||||
|
||||
def print(self, msg="", **kwargs):
|
||||
"""Print to stdout with automatic flush."""
|
||||
if msg:
|
||||
msg = self.prefix + msg
|
||||
print(msg, flush=True, **kwargs)
|
||||
|
||||
def _format_header(self, title):
|
||||
"""Return a formatted section header string."""
|
||||
width = self.section_width - len(self.prefix)
|
||||
bar = self.sepchar * (width - len(title) - 5)
|
||||
return f"{self.sepchar * 3} {title} {bar}"
|
||||
|
||||
@contextmanager
|
||||
def section(self, title):
|
||||
"""Context manager that prints a section header and records elapsed time."""
|
||||
self.green(self._format_header(title))
|
||||
t0 = time.time()
|
||||
yield
|
||||
elapsed = time.time() - t0
|
||||
self.section_timings.append((title, elapsed))
|
||||
|
||||
def section_line(self, title):
|
||||
"""Print a section header without timing."""
|
||||
self.green(self._format_header(title))
|
||||
|
||||
def shell(self, cmd, quiet=False, **kwargs):
|
||||
"""Print *cmd*, run it, and re-print its output with the current prefix.
|
||||
|
||||
*cmd* is passed through :func:`collapse`, so callers
|
||||
can use triple-quoted f-strings freely.
|
||||
Stdout and stderr are merged, read line-by-line,
|
||||
and each line is printed with ``self.prefix`` prepended.
|
||||
When the command exits non-zero, a red error line is printed.
|
||||
"""
|
||||
cmd = collapse(cmd)
|
||||
if not quiet:
|
||||
self.print(f"$ {cmd}")
|
||||
indent = self.prefix + " "
|
||||
env = kwargs.pop("env", None)
|
||||
if env is None:
|
||||
env = os.environ.copy()
|
||||
env["_CMDEPLOY_WIDTH"] = str(self.section_width - len(indent))
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
shell=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=env,
|
||||
**kwargs,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
sys.stdout.write(indent + line)
|
||||
sys.stdout.flush()
|
||||
ret = proc.wait()
|
||||
if ret:
|
||||
self.red(f"command failed with exit code {ret}: {cmd}")
|
||||
return ret
|
||||
|
||||
|
||||
def _project_root():
|
||||
"""Return the project root directory."""
|
||||
return Path(__file__).resolve().parent.parent.parent.parent
|
||||
|
||||
|
||||
def collapse(text):
|
||||
"""Dedent, join lines, and strip a (triple-quoted) string.
|
||||
|
||||
Handy for writing shell commands across multiple lines::
|
||||
|
||||
cmd = collapse(f\"""
|
||||
cmdeploy run
|
||||
--config {ct.ini}
|
||||
--ssh-host {ct.domain}
|
||||
\""")
|
||||
"""
|
||||
return textwrap.dedent(text).replace("\n", " ").strip()
|
||||
|
||||
|
||||
def shell(cmd, check=False, **kwargs):
|
||||
"""Run a shell command string with sensible defaults.
|
||||
|
||||
*cmd* is passed through :func:`collapse` first, so callers
|
||||
can use triple-quoted f-strings freely.
|
||||
Captures stdout/stderr by default; pass ``capture_output=False``
|
||||
to stream output to the terminal instead.
|
||||
"""
|
||||
if "capture_output" not in kwargs and "stdout" not in kwargs:
|
||||
kwargs["capture_output"] = True
|
||||
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
||||
return subprocess.run(collapse(cmd), shell=True, text=True, check=check, **kwargs)
|
||||
|
||||
|
||||
def get_git_hash(root=None):
|
||||
"""Return the local HEAD commit hash, or None."""
|
||||
if root is None:
|
||||
root = _project_root()
|
||||
result = shell(
|
||||
"git rev-parse HEAD",
|
||||
cwd=str(root),
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_version_string(root=None):
|
||||
"""Return ``git_hash\\ngit_diff`` for the local working tree.
|
||||
|
||||
Used by :class:`~cmdeploy.deployers.GithashDeployer` to write
|
||||
``/etc/chatmail-version`` and by ``lxc-status`` to compare
|
||||
the deployed state against the local checkout.
|
||||
"""
|
||||
if root is None:
|
||||
root = _project_root()
|
||||
git_hash = get_git_hash(root=root) or "unknown"
|
||||
try:
|
||||
git_diff = shell("git diff", cwd=str(root)).stdout.strip()
|
||||
except Exception:
|
||||
git_diff = ""
|
||||
if git_diff:
|
||||
return f"{git_hash}\n{git_diff}"
|
||||
return git_hash
|
||||
@@ -15,7 +15,7 @@ author = 'chatmail collective'
|
||||
|
||||
extensions = [
|
||||
#'sphinx.ext.autodoc',
|
||||
#'sphinx.ext.viewdoc',
|
||||
#'sphinx.ext.viewcode',
|
||||
'sphinxcontrib.mermaid',
|
||||
]
|
||||
|
||||
|
||||
@@ -47,6 +47,14 @@ steps. Please substitute it with your own domain.
|
||||
www.chat.example.org. 3600 IN CNAME chat.example.org.
|
||||
mta-sts.chat.example.org. 3600 IN CNAME chat.example.org.
|
||||
|
||||
.. note::
|
||||
|
||||
For experimental deployments using self-signed certificates,
|
||||
use a domain name starting with ``_``
|
||||
(e.g. ``_chat.example.org``).
|
||||
The ``mta-sts`` CNAME and ``_mta-sts`` TXT records
|
||||
are not needed for such domains.
|
||||
|
||||
2. On your local PC, clone the repository and bootstrap the Python
|
||||
virtualenv.
|
||||
|
||||
@@ -63,6 +71,16 @@ steps. Please substitute it with your own domain.
|
||||
|
||||
scripts/cmdeploy init chat.example.org # <-- use your domain
|
||||
|
||||
To use self-signed TLS certificates
|
||||
instead of Let's Encrypt,
|
||||
use a domain name starting with ``_``
|
||||
(e.g. ``scripts/cmdeploy init _chat.example.org``).
|
||||
Domains starting with ``_`` cannot obtain WebPKI certificates,
|
||||
so self-signed mode is derived automatically.
|
||||
This is useful for private or test deployments.
|
||||
See the :doc:`overview`
|
||||
for details on certificate provisioning.
|
||||
|
||||
4. Verify that SSH root login to the deployment server server works:
|
||||
|
||||
::
|
||||
@@ -80,13 +98,6 @@ steps. Please substitute it with your own domain.
|
||||
configure at your DNS provider (it can take some time until they are
|
||||
public).
|
||||
|
||||
Docker installation
|
||||
-------------------
|
||||
|
||||
We have experimental support for `docker compose <https://github.com/chatmail/relay/blob/docker-rebase/docs/DOCKER_INSTALLATION_EN.md>`_,
|
||||
but it is not covered by automated tests yet,
|
||||
so don't expect everything to work.
|
||||
|
||||
Other helpful commands
|
||||
----------------------
|
||||
|
||||
@@ -176,6 +187,55 @@ creating addresses, login with ssh to the deployment machine and run:
|
||||
Chatmail address creation will be denied while this file is present.
|
||||
|
||||
|
||||
Running a relay with self-signed certificates
|
||||
----------------------------------------------
|
||||
|
||||
Use a domain name starting with ``_`` (e.g. ``_chat.example.org``)
|
||||
to run a relay with self-signed certificates.
|
||||
Domains starting with ``_`` cannot obtain WebPKI certificates
|
||||
so the relay automatically uses self-signed certificates
|
||||
and all other relays will accept connections from it
|
||||
without requiring certificate verification.
|
||||
This is useful for experimental setups and testing.
|
||||
|
||||
.. _external-tls:
|
||||
|
||||
Running a relay with externally managed certificates
|
||||
-----------------------------------------------------
|
||||
|
||||
If you already have a TLS certificate manager
|
||||
(e.g. Traefik, certbot, or another ACME client)
|
||||
running on the deployment server,
|
||||
you can configure the relay to use those certificates
|
||||
instead of the built-in ``acmetool``.
|
||||
|
||||
Set the following in ``chatmail.ini``::
|
||||
|
||||
tls_external_cert_and_key = /path/to/fullchain.pem /path/to/privkey.pem
|
||||
|
||||
The paths must point to certificate and key files
|
||||
on the deployment server.
|
||||
During ``cmdeploy run``, these paths are written into
|
||||
the Postfix, Dovecot, and Nginx configurations.
|
||||
No certificate files are transferred from the build machine —
|
||||
they must already exist on the server,
|
||||
managed by your external certificate tool.
|
||||
|
||||
The deploy will verify that both files exist on the server.
|
||||
``acmetool`` is **not** installed or run in this mode.
|
||||
|
||||
.. note::
|
||||
|
||||
You are responsible for certificate renewal.
|
||||
When the certificate file changes on disk,
|
||||
all relay services pick up the new certificate automatically
|
||||
via a systemd path watcher installed during deploy.
|
||||
The watcher uses inotify, which does not cross bind-mount boundaries.
|
||||
If you use such a setup, you must trigger the reload explicitly after renewal::
|
||||
|
||||
systemctl start tls-cert-reload.service
|
||||
|
||||
|
||||
Migrating to a new build machine
|
||||
----------------------------------
|
||||
|
||||
|
||||
@@ -16,5 +16,6 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
|
||||
proxy
|
||||
migrate
|
||||
overview
|
||||
lxc
|
||||
related
|
||||
faq
|
||||
|
||||
271
doc/source/lxc.rst
Normal file
271
doc/source/lxc.rst
Normal file
@@ -0,0 +1,271 @@
|
||||
Local testing with LXC/Incus
|
||||
============================
|
||||
|
||||
The ``cmdeploy`` tool includes support for running
|
||||
chatmail relays inside local
|
||||
`Incus <https://linuxcontainers.org/incus/>`_ LXC containers.
|
||||
This is meant for development, testing, and CI
|
||||
without requiring a remote server.
|
||||
LXC system containers are lightweight virtual machines
|
||||
that share the host's kernel but run their own init system,
|
||||
package manager, and network stack,
|
||||
so the cmdeploy deployment scripts work pretty much
|
||||
as they would on a real Debian server or cloud VPS.
|
||||
|
||||
Prerequisites
|
||||
-------------
|
||||
|
||||
Install `Incus <https://linuxcontainers.org/incus/>`_
|
||||
(LXC container manager).
|
||||
See the `official installation guide
|
||||
<https://linuxcontainers.org/incus/docs/main/installing/>`_
|
||||
for full details.
|
||||
|
||||
After installing incus, initialise and grant yourself access::
|
||||
|
||||
sudo incus admin init --minimal
|
||||
sudo usermod -aG incus-admin $USER
|
||||
|
||||
|
||||
.. caution::
|
||||
|
||||
Adding yourself to ``incus-admin`` grants effective root access
|
||||
to the host: any member can mount host directories into a container
|
||||
and manipulate them as root.
|
||||
This is fine for local testing of your own relay branches,
|
||||
but do **not** use it for production setups
|
||||
or for testing untrusted relay branches from others.
|
||||
|
||||
.. warning::
|
||||
|
||||
You **must now log out and back in** (or run ``newgrp incus-admin``)
|
||||
after adding yourself to the group.
|
||||
Without this, all ``cmdeploy lxc-*`` commands
|
||||
will fail with permission errors.
|
||||
|
||||
Verify the installation works by running ``incus list``,
|
||||
which should print an empty table without errors.
|
||||
|
||||
|
||||
Quick start
|
||||
-----------
|
||||
|
||||
::
|
||||
|
||||
cd relay
|
||||
scripts/initenv.sh # bootstrap venv
|
||||
source venv/bin/activate # activate venv
|
||||
cmdeploy lxc-test # create containers, deploy, test
|
||||
|
||||
The ``lxc-test`` command provides an automated way
|
||||
to run the full deployment and test pipeline.
|
||||
It executes several ``cmdeploy`` subcommands in sequential steps.
|
||||
If a step fails, you can copy-paste the printed command
|
||||
and run it manually to debug.
|
||||
No host DNS delegation or ``~/.ssh/config`` changes are needed
|
||||
because ``lxc-test`` passes the required SSH and DNS options directly.
|
||||
|
||||
|
||||
CLI reference
|
||||
--------------
|
||||
|
||||
``lxc-start [--ipv4-only] [--run] [NAME ...]``
|
||||
Create and start containers.
|
||||
Without arguments, creates ``test0-localchat`` and ``ns-localchat`` (DNS).
|
||||
Pass one or more ``NAME`` arguments to create user relay containers instead
|
||||
(e.g. ``cmdeploy lxc-start myrelay``).
|
||||
Use ``--ipv4-only`` to set ``disable_ipv6 = True`` in the generated ``chatmail.ini``,
|
||||
producing an IPv4-only relay.
|
||||
Use ``--run`` to automatically run ``cmdeploy run`` on each container after starting it.
|
||||
Generates ``lxconfigs/ssh-config``.
|
||||
It reuses existing containers and resets DNS zones to minimal records.
|
||||
|
||||
``lxc-stop [--destroy] [--destroy-all] [NAME ...]``
|
||||
Stop relay containers.
|
||||
Without arguments, stops ``test0-localchat`` and ``test1-localchat``.
|
||||
Pass ``NAME`` to stop specific containers.
|
||||
Use ``--destroy`` to also delete the containers and their config files.
|
||||
Use ``--destroy-all`` to additionally destroy
|
||||
the ``ns-localchat`` DNS container **and** remove
|
||||
the cached ``localchat-base`` and ``localchat-relay``
|
||||
images, giving a fully clean slate for the next ``lxc-test``.
|
||||
User containers are **never** destroyed unless named explicitly.
|
||||
|
||||
``lxc-test [--one]``
|
||||
By default creates, deploys, and tests both ``test0`` and ``test1``
|
||||
for dual-domain federation testing (sets ``CHATMAIL_DOMAIN2=_test1.localchat``).
|
||||
test0 runs dual-stack (IPv4 + IPv6) while test1 runs IPv4-only (``disable_ipv6 = True``).
|
||||
Pass ``--one`` to only deploy and test against ``test0``
|
||||
(skips ``test1``, does not set ``CHATMAIL_DOMAIN2``).
|
||||
|
||||
``lxc-status``
|
||||
Show live status of all LXC containers (including the DNS container),
|
||||
deploy freshness (comparing ``/etc/chatmail-version``
|
||||
against local ``git rev-parse HEAD`` and ``git diff``),
|
||||
SSH config inclusion, and host DNS forwarding for ``.localchat``.
|
||||
Reports **IN-SYNC**, **DIRTY** (hash matches but uncommitted changes exist),
|
||||
**STALE** (different commit), or **NOT DEPLOYED**.
|
||||
|
||||
|
||||
Container types
|
||||
-----------------
|
||||
|
||||
**Test relay containers** (``test0-localchat``, ``test1-localchat``)
|
||||
Created automatically by ``lxc-test``.
|
||||
**test0** has IPv4 and IPv6 configured,
|
||||
**test1** is IPv4-only (``disable_ipv6 = True``).
|
||||
|
||||
**User relay containers** (``<name>-localchat``)
|
||||
Created by ``cmdeploy lxc-start <name>``
|
||||
where ``<name>`` does not start with ``test``.
|
||||
These are personal development instances,
|
||||
never touched by ``lxc-stop --destroy`` unless named explicitly.
|
||||
|
||||
**DNS container** (``ns-localchat``)
|
||||
Singleton container running PowerDNS.
|
||||
Created automatically when any relay is started.
|
||||
|
||||
|
||||
.. _lxc-ssh-config:
|
||||
|
||||
SSH configuration
|
||||
-----------------
|
||||
|
||||
``cmdeploy lxc-start`` generates ``lxconfigs/ssh-config``,
|
||||
a standard OpenSSH config file mapping every container name,
|
||||
its domain, and a short alias to the container's IP address::
|
||||
|
||||
Host test0-localchat _test0.localchat _test0
|
||||
Hostname 10.204.0.42
|
||||
User root
|
||||
IdentityFile /path/to/relay/lxconfigs/id_localchat
|
||||
IdentitiesOnly yes
|
||||
StrictHostKeyChecking accept-new
|
||||
UserKnownHostsFile /dev/null
|
||||
LogLevel ERROR
|
||||
|
||||
All ``cmdeploy`` commands (``run``, ``dns``, ``status``, ``test``)
|
||||
accept ``--ssh-config lxconfigs/ssh-config`` to use this file.
|
||||
``lxc-test`` passes it automatically.
|
||||
|
||||
**Using containers from the host shell:**
|
||||
|
||||
To make ``ssh _test0`` work from any terminal, add one line to ``~/.ssh/config``::
|
||||
|
||||
Include /absolute/path/to/relay/lxconfigs/ssh-config
|
||||
|
||||
|
||||
.. _lxc-dns-setup:
|
||||
.. _localchat-tld:
|
||||
|
||||
``.localchat`` DNS and name resolution
|
||||
---------------------------------------
|
||||
|
||||
All LXC-managed chatmail domains use the ``.localchat`` pseudo-TLD
|
||||
(e.g. ``_test0.localchat``, ``_test1.localchat``),
|
||||
a non-delegated suffix that exists only within the local PowerDNS infrastructure.
|
||||
A dedicated DNS container (``ns-localchat``)
|
||||
is created so that local test relays interact
|
||||
with DNS similar to a regular public Internet setup.
|
||||
On first start, ``cmdeploy lxc-start`` creates this container
|
||||
running two `PowerDNS <https://www.powerdns.com/>`_ services:
|
||||
|
||||
* **pdns-server** (authoritative) serves ``.localchat``
|
||||
zones from a local SQLite database.
|
||||
|
||||
* **pdns-recursor** (recursive) listens on the Incus
|
||||
bridge so all containers can use it.
|
||||
Forwards ``.localchat`` queries to the local
|
||||
authoritative server and resolves everything else recursively.
|
||||
|
||||
After the DNS container is up, ``lxc-start`` configures the Incus bridge
|
||||
to advertise its IP via DHCP and disables Incus's own DNS.
|
||||
DNS records are then created in two phases matching the "cmdeploy run" deployment flow:
|
||||
|
||||
1. **``lxc-start``** resets each relay zone to
|
||||
**SOA, NS, and A** records (plus **AAAA** for dual-stack containers).
|
||||
If host DNS resolution is configured, users can
|
||||
afterwards run ``cmdeploy run --config lxconfigs/chatmail-test0.ini
|
||||
--ssh-config lxconfigs/ssh-config --ssh-host _test0.localchat``.
|
||||
LXC subcommands do not depend on host DNS resolution
|
||||
and resolve addresses via ``lxconfigs/ssh-config``.
|
||||
|
||||
2. **``cmdeploy dns --zonefile``** generates a standard
|
||||
BIND-format zone file (MX, TXT/SPF, TXT/DMARC,
|
||||
TXT/MTA-STS, SRV, CNAME, DKIM) and loads it
|
||||
into PowerDNS.
|
||||
|
||||
This two-phase approach prevents premature configuration of mail records
|
||||
before the relay is actually deployed and running.
|
||||
Once ``cmdeploy run`` deploys `Unbound <https://nlnetlabs.nl/projects/unbound/>`_
|
||||
inside a relay container, Unbound has a configuration plugin snippet
|
||||
that forwards all ``.localchat`` queries to the PowerDNS recursor,
|
||||
and lets all other queries go through normal recursive resolution.
|
||||
|
||||
|
||||
State outside the repository
|
||||
-----------------------------
|
||||
|
||||
All generated configuration by lxc subcommands live in ``lxconfigs/``
|
||||
(git-ignored), including the SSH key pair (``id_localchat``),
|
||||
per-container ``chatmail-*.ini`` files, zone files, and ``ssh-config``.
|
||||
|
||||
The only state *outside* the repository is the Incus containers and images themselves
|
||||
(managed via the ``incus`` CLI, labelled with ``user.localchat-managed=true``).
|
||||
The Incus image store retains the following snapshot images:
|
||||
|
||||
* ``localchat-base``: Debian 12 with openssh-server and Python (built on first run)
|
||||
|
||||
* ``localchat-relay``: fully deployed relay snapshot,
|
||||
cached after the first successful ``cmdeploy run``.
|
||||
Subsequent relay containers launch from this image
|
||||
so the deploy step is mostly no-ops (roughly 3× faster than a fresh deploy).
|
||||
|
||||
|
||||
.. _lxc-tls:
|
||||
|
||||
TLS handling and underscore domains
|
||||
------------------------------------
|
||||
|
||||
Container domains start with ``_`` (e.g. ``_test0.localchat``).
|
||||
As described in :doc:`getting_started` ("Running a relay with self-signed certificates"),
|
||||
underscore domains automatically use self-signed TLS
|
||||
and ``smtp_tls_security_level = encrypt``.
|
||||
This permits cross-relay federation between LXC containers
|
||||
without any external certificate authority.
|
||||
Delta Chat clients connecting to these relays
|
||||
must be configured with
|
||||
``certificateChecks = acceptInvalidCertificates``
|
||||
(the test fixtures handle this automatically).
|
||||
`PR #7926 on chatmail-core <https://github.com/chatmail/core/pull/7926>`_
|
||||
is meant to make this special setting unnecessary for chatmail clients
|
||||
that are connecting to underscore domains.
|
||||
|
||||
|
||||
Known limitations
|
||||
------------------
|
||||
|
||||
The LXC environment differs from a production
|
||||
deployment in several ways:
|
||||
|
||||
**No ACME / Let's Encrypt**:
|
||||
Self-signed TLS only (see :ref:`lxc-tls`);
|
||||
ACME code paths are never exercised locally.
|
||||
|
||||
**No inbound connections from the internet**:
|
||||
Containers sit on a private Incus bridge and are not port-forwarded.
|
||||
Only the host and other containers on the same bridge can reach them.
|
||||
|
||||
**Local federation only**:
|
||||
Cross-relay mail delivery (e.g. test0 → test1) works between containers on the same host,
|
||||
but these relays are invisible to any external mail server.
|
||||
|
||||
**DNS is local only**:
|
||||
The ``.localchat`` pseudo-TLD is not resolvable from the wider internet
|
||||
(see :ref:`lxc-dns-setup`).
|
||||
|
||||
**IPv6 is ULA-only**:
|
||||
Containers receive IPv6 addresses from the ``fd42:...`` ULA range on the Incus bridge.
|
||||
These are not globally routable, but are sufficient for testing IPv6 service binding
|
||||
(Postfix, Dovecot, Nginx) and DNS AAAA records inside the local environment.
|
||||
test1 runs with ``disable_ipv6 = True`` to exercise the IPv4-only deployment path.
|
||||
@@ -109,10 +109,6 @@ short overview of ``chatmaild`` services:
|
||||
is contacted by Dovecot when a user logs in and stores the date of
|
||||
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/``
|
||||
~~~~~~~~~
|
||||
|
||||
@@ -142,11 +138,9 @@ Chatmail relay dependency diagram
|
||||
nginx-internal --- autoconfig.xml;
|
||||
certs-nginx[("`TLS certs
|
||||
/var/lib/acme`")] --> nginx-internal;
|
||||
systemd-timer --- chatmail-metrics;
|
||||
systemd-timer --- acmetool;
|
||||
systemd-timer --- chatmail-expire-daily;
|
||||
systemd-timer --- chatmail-fsreport-daily;
|
||||
chatmail-metrics --- website;
|
||||
acmetool --> certs[("`TLS certs
|
||||
/var/lib/acme`")];
|
||||
nginx-external --- |993|dovecot;
|
||||
@@ -297,8 +291,7 @@ TLS requirements
|
||||
|
||||
Postfix is configured to require valid TLS by setting
|
||||
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
|
||||
to ``verify``. If emails don’t arrive at your chatmail relay server, the
|
||||
problem is likely that your relay does not have a valid TLS certificate.
|
||||
to ``verify``.
|
||||
|
||||
You can test it by resolving ``MX`` records of your relay domain and
|
||||
then connecting to MX relays (e.g ``mx.example.org``) with
|
||||
@@ -309,6 +302,11 @@ When providing a TLS certificate to your chatmail relay server, make
|
||||
sure to provide the full certificate chain and not just the last
|
||||
certificate.
|
||||
|
||||
If you use an external certificate manager (e.g. Traefik or certbot),
|
||||
set ``tls_external_cert_and_key`` in ``chatmail.ini``
|
||||
to provide the certificate and key paths.
|
||||
See :ref:`external-tls` for details.
|
||||
|
||||
If you are running an Exim server and don’t see incoming connections
|
||||
from a chatmail relay server in the logs, make sure ``smtp_no_mail`` log
|
||||
item is enabled in the config with ``log_selector = +smtp_no_mail``. By
|
||||
@@ -317,6 +315,14 @@ default Exim does not log sessions that are closed before sending the
|
||||
by Postfix, so you might think that connection is not established while
|
||||
actually it is a problem with your TLS certificate.
|
||||
|
||||
If emails don’t arrive at your chatmail relay server, the
|
||||
problem is likely that your relay does not have a valid TLS certificate.
|
||||
|
||||
Note that connections to relays with underscore-prefixed test domains
|
||||
(e.g. ``_chat.example.org``) use ``encrypt`` tls security level,
|
||||
because such domains cannot obtain valid Let's Encrypt certificates
|
||||
and run with self-signed certificates.
|
||||
|
||||
|
||||
.. _dovecot: https://dovecot.org
|
||||
.. _postfix: https://www.postfix.org
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
services:
|
||||
chatmail:
|
||||
build:
|
||||
context: ./
|
||||
dockerfile: docker/chatmail_relay.dockerfile
|
||||
image: chatmail-relay:latest
|
||||
restart: unless-stopped
|
||||
container_name: chatmail
|
||||
# Required for systemd — use only one of the following:
|
||||
cgroup: host # compose v2 only
|
||||
# privileged: true # compose v1 (not tested)
|
||||
tty: true # required for logs
|
||||
tmpfs: # required for systemd
|
||||
- /tmp
|
||||
- /run
|
||||
- /run/lock
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
environment:
|
||||
CHANGE_KERNEL_SETTINGS: "False"
|
||||
MAIL_DOMAIN: $MAIL_DOMAIN
|
||||
ACME_EMAIL: $ACME_EMAIL
|
||||
RECREATE_VENV: $RECREATE_VENV
|
||||
MAX_MESSAGE_SIZE: $MAX_MESSAGE_SIZE
|
||||
DEBUG_COMMANDS_ENABLED: $DEBUG_COMMANDS_ENABLED
|
||||
FORCE_REINIT_INI_FILE: $FORCE_REINIT_INI_FILE
|
||||
USE_FOREIGN_CERT_MANAGER: $USE_FOREIGN_CERT_MANAGER
|
||||
ENABLE_CERTS_MONITORING: $ENABLE_CERTS_MONITORING
|
||||
CERTS_MONITORING_TIMEOUT: $CERTS_MONITORING_TIMEOUT
|
||||
IS_DEVELOPMENT_INSTANCE: $IS_DEVELOPMENT_INSTANCE
|
||||
CMDEPLOY_STAGES: ${CMDEPLOY_STAGES:-}
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
## system
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw # required for systemd
|
||||
- ./:/opt/chatmail
|
||||
|
||||
## data
|
||||
- ./data/chatmail:/home
|
||||
- ./data/chatmail-dkimkeys:/etc/dkimkeys
|
||||
- ./data/chatmail-acme:/var/lib/acme
|
||||
|
||||
## custom resources
|
||||
# - ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||
|
||||
## debug
|
||||
# - ./docker/files/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
|
||||
# - ./docker/files/entrypoint.sh:/entrypoint.sh
|
||||
# - ./docker/files/update_ini.sh:/update_ini.sh
|
||||
@@ -1,100 +0,0 @@
|
||||
FROM jrei/systemd-debian:12 AS base
|
||||
|
||||
ENV LANG=en_US.UTF-8
|
||||
|
||||
RUN echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/01norecommend && \
|
||||
echo 'APT::Install-Suggests "0";' >> /etc/apt/apt.conf.d/01norecommend && \
|
||||
apt-get update && \
|
||||
apt-get install -y \
|
||||
ca-certificates && \
|
||||
DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Europe/London \
|
||||
apt-get install -y tzdata && \
|
||||
apt-get install -y locales && \
|
||||
sed -i -e "s/# $LANG.*/$LANG UTF-8/" /etc/locale.gen && \
|
||||
dpkg-reconfigure --frontend=noninteractive locales && \
|
||||
update-locale LANG=$LANG \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
git \
|
||||
python3 \
|
||||
python3-venv \
|
||||
python3-virtualenv \
|
||||
gcc \
|
||||
python3-dev \
|
||||
opendkim \
|
||||
opendkim-tools \
|
||||
curl \
|
||||
rsync \
|
||||
unbound \
|
||||
unbound-anchor \
|
||||
dnsutils \
|
||||
postfix \
|
||||
acl \
|
||||
nginx \
|
||||
libnginx-mod-stream \
|
||||
fcgiwrap \
|
||||
cron \
|
||||
&& for pkg in core imapd lmtpd; do \
|
||||
case "$pkg" in \
|
||||
core) sha256="43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587" ;; \
|
||||
imapd) sha256="8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86" ;; \
|
||||
lmtpd) sha256="2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab" ;; \
|
||||
esac; \
|
||||
url="https://download.delta.chat/dovecot/dovecot-${pkg}_2.3.21%2Bdfsg1-3_amd64.deb"; \
|
||||
file="/tmp/$(basename "$url")"; \
|
||||
curl -fsSL "$url" -o "$file"; \
|
||||
echo "$sha256 $file" | sha256sum -c -; \
|
||||
apt-get install -y "$file"; \
|
||||
rm -f "$file"; \
|
||||
done \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/chatmail
|
||||
|
||||
# --- Build-time install stage ---
|
||||
# Bake the "install" deployer stage into the image; we can't use
|
||||
# scripts/initenv.sh because /opt/chatmail is empty at build time as
|
||||
# source arrives at runtime via volume mount., so we use a throwaway venv.
|
||||
# On container start only "configure,activate" stages run.
|
||||
COPY . /tmp/chatmail-src/
|
||||
WORKDIR /tmp/chatmail-src
|
||||
|
||||
# Dummy config — deploy_chatmail() needs a parseable ini to instantiate deployers
|
||||
RUN printf '[params]\nmail_domain = build.local\n' > /tmp/chatmail.ini
|
||||
|
||||
# Do what initenv.sh would do without the docs
|
||||
RUN python3 -m venv /tmp/build-venv && \
|
||||
/tmp/build-venv/bin/pip install --no-cache-dir \
|
||||
-e chatmaild -e cmdeploy
|
||||
|
||||
RUN CMDEPLOY_STAGES=install \
|
||||
CHATMAIL_INI=/tmp/chatmail.ini \
|
||||
CHATMAIL_DOCKER=True \
|
||||
/tmp/build-venv/bin/pyinfra @local \
|
||||
/tmp/chatmail-src/cmdeploy/src/cmdeploy/run.py -y
|
||||
|
||||
RUN rm -rf /tmp/chatmail-src /tmp/build-venv /tmp/chatmail.ini
|
||||
|
||||
WORKDIR /opt/chatmail
|
||||
# --- End build-time install stage ---
|
||||
|
||||
ARG SETUP_CHATMAIL_SERVICE_PATH=/lib/systemd/system/setup_chatmail.service
|
||||
COPY ./docker/files/setup_chatmail.service "$SETUP_CHATMAIL_SERVICE_PATH"
|
||||
RUN ln -sf "$SETUP_CHATMAIL_SERVICE_PATH" "/etc/systemd/system/multi-user.target.wants/setup_chatmail.service"
|
||||
|
||||
COPY --chmod=555 ./docker/files/setup_chatmail_docker.sh /setup_chatmail_docker.sh
|
||||
COPY --chmod=555 ./docker/files/update_ini.sh /update_ini.sh
|
||||
COPY --chmod=555 ./docker/files/entrypoint.sh /entrypoint.sh
|
||||
|
||||
VOLUME ["/sys/fs/cgroup", "/home"]
|
||||
|
||||
STOPSIGNAL SIGRTMIN+3
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
CMD [ "--default-standard-output=journal+console", \
|
||||
"--default-standard-error=journal+console" ]
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a chatmail.ini to a Docker .env file.
|
||||
|
||||
Usage: python docker/cm_ini_to_env.py [chatmail.ini] [.env]
|
||||
|
||||
Reads the ini file, extracts all non-default key=value pairs,
|
||||
and writes them as UPPER_CASE env vars suitable for docker-compose.
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Keys that only make sense for bare-metal deploys or are handled
|
||||
# separately by the Docker setup and should not appear in .env.
|
||||
SKIP_KEYS = set()
|
||||
|
||||
# Keys that exist in .env but have a different name than the ini key.
|
||||
# ini_key -> env_key
|
||||
RENAMES = {}
|
||||
|
||||
|
||||
def read_ini(path):
|
||||
"""Return dict of key=value from [params] section."""
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(path)
|
||||
if not cp.has_section("params"):
|
||||
sys.exit(f"Error: {path} has no [params] section")
|
||||
return dict(cp.items("params"))
|
||||
|
||||
|
||||
def read_defaults():
|
||||
"""Return dict of default values from the ini template."""
|
||||
template = Path(__file__).resolve().parent.parent / "chatmaild/src/chatmaild/ini/chatmail.ini.f"
|
||||
if not template.exists():
|
||||
return {}
|
||||
cp = configparser.ConfigParser()
|
||||
cp.read(template)
|
||||
if not cp.has_section("params"):
|
||||
return {}
|
||||
defaults = {}
|
||||
for key, value in cp.items("params"):
|
||||
# Template placeholders like {mail_domain} aren't real defaults.
|
||||
if "{" not in value:
|
||||
defaults[key] = value
|
||||
return defaults
|
||||
|
||||
|
||||
def ini_to_env(ini_path, only_non_default=True):
|
||||
"""Yield (ENV_KEY, value) pairs from an ini file."""
|
||||
params = read_ini(ini_path)
|
||||
defaults = read_defaults() if only_non_default else {}
|
||||
|
||||
for key, value in sorted(params.items()):
|
||||
if key in SKIP_KEYS:
|
||||
continue
|
||||
if only_non_default and key in defaults and value.strip() == defaults[key].strip():
|
||||
continue
|
||||
env_key = RENAMES.get(key, key.upper())
|
||||
yield env_key, value.strip()
|
||||
|
||||
|
||||
def main():
|
||||
ini_path = sys.argv[1] if len(sys.argv) > 1 else "chatmail.ini"
|
||||
env_path = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
|
||||
if not Path(ini_path).exists():
|
||||
sys.exit(f"Error: {ini_path} not found")
|
||||
|
||||
lines = []
|
||||
for env_key, value in ini_to_env(ini_path):
|
||||
lines.append(f'{env_key}="{value}"')
|
||||
|
||||
output = "\n".join(lines) + "\n"
|
||||
|
||||
if env_path:
|
||||
Path(env_path).write_text(output)
|
||||
print(f"Wrote {len(lines)} variables to {env_path}")
|
||||
else:
|
||||
print(output, end="")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,11 +0,0 @@
|
||||
MAIL_DOMAIN="chat.example.com"
|
||||
# ACME_EMAIL=""
|
||||
# RECREATE_VENV="false"
|
||||
# MAX_MESSAGE_SIZE="50M"
|
||||
# DEBUG_COMMANDS_ENABLED="true"
|
||||
# FORCE_REINIT_INI_FILE="true"
|
||||
# USE_FOREIGN_CERT_MANAGER="True"
|
||||
# ENABLE_CERTS_MONITORING="true"
|
||||
# CERTS_MONITORING_TIMEOUT=10
|
||||
# IS_DEVELOPMENT_INSTANCE="True"
|
||||
# CMDEPLOY_STAGES - default: "configure,activate". Set to "install,configure,activate" to force full reinstall.
|
||||
@@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
unlink /etc/nginx/sites-enabled/default || true
|
||||
|
||||
SETUP_CHATMAIL_SERVICE_PATH="${SETUP_CHATMAIL_SERVICE_PATH:-/lib/systemd/system/setup_chatmail.service}"
|
||||
|
||||
env_vars=$(printenv | cut -d= -f1 | xargs)
|
||||
sed -i "s|<envs_list>|$env_vars|g" $SETUP_CHATMAIL_SERVICE_PATH
|
||||
|
||||
exec /lib/systemd/systemd $@
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=Run container setup commands
|
||||
After=multi-user.target
|
||||
ConditionPathExists=/setup_chatmail_docker.sh
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/bin/bash /setup_chatmail_docker.sh
|
||||
RemainAfterExit=true
|
||||
WorkingDirectory=/opt/chatmail
|
||||
PassEnvironment=<envs_list>
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
export INI_FILE="${INI_FILE:-chatmail.ini}"
|
||||
export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}"
|
||||
export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}"
|
||||
export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}"
|
||||
export CHANGE_KERNEL_SETTINGS=${CHANGE_KERNEL_SETTINGS:-"False"}
|
||||
export RECREATE_VENV=${RECREATE_VENV:-"false"}
|
||||
|
||||
if [ -z "$MAIL_DOMAIN" ]; then
|
||||
echo "ERROR: Environment variable 'MAIL_DOMAIN' must be set!" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
debug_commands() {
|
||||
echo "Executing debug commands"
|
||||
# git config --global --add safe.directory /opt/chatmail
|
||||
# ./scripts/initenv.sh
|
||||
}
|
||||
|
||||
calculate_hash() {
|
||||
find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
monitor_certificates() {
|
||||
if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then
|
||||
echo "Certs monitoring disabled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_hash=$(calculate_hash)
|
||||
previous_hash=$current_hash
|
||||
|
||||
while true; do
|
||||
current_hash=$(calculate_hash)
|
||||
if [[ "$current_hash" != "$previous_hash" ]]; then
|
||||
# TODO: add an option to restart at a specific time interval
|
||||
echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services."
|
||||
systemctl reload nginx.service
|
||||
systemctl reload dovecot.service
|
||||
systemctl reload postfix.service
|
||||
previous_hash=$current_hash
|
||||
fi
|
||||
sleep $CERTS_MONITORING_TIMEOUT
|
||||
done
|
||||
}
|
||||
|
||||
### MAIN
|
||||
|
||||
if [ "$DEBUG_COMMANDS_ENABLED" = true ]; then
|
||||
debug_commands
|
||||
fi
|
||||
|
||||
if [ "$FORCE_REINIT_INI_FILE" = true ]; then
|
||||
INI_CMD_ARGS=--force
|
||||
fi
|
||||
|
||||
if [ ! -f /etc/dkimkeys/opendkim.private ]; then
|
||||
/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d $MAIL_DOMAIN -s opendkim
|
||||
fi
|
||||
chown opendkim:opendkim /etc/dkimkeys/opendkim.private
|
||||
chown opendkim:opendkim /etc/dkimkeys/opendkim.txt
|
||||
|
||||
# TODO: Move to debug_commands after git clone is moved to dockerfile.
|
||||
git config --global --add safe.directory /opt/chatmail
|
||||
if [ "$RECREATE_VENV" = true ]; then
|
||||
rm -rf venv
|
||||
fi
|
||||
# Skip venv creation if it already exists
|
||||
if [ ! -x venv/bin/python ] || [ ! -x venv/bin/cmdeploy ]; then
|
||||
./scripts/initenv.sh
|
||||
fi
|
||||
|
||||
./scripts/cmdeploy init --config "${INI_FILE}" $INI_CMD_ARGS $MAIL_DOMAIN || true
|
||||
bash /update_ini.sh
|
||||
|
||||
export CMDEPLOY_STAGES="${CMDEPLOY_STAGES:-configure,activate}"
|
||||
./scripts/cmdeploy run --ssh-host @docker
|
||||
|
||||
echo "ForwardToConsole=yes" >> /etc/systemd/journald.conf
|
||||
systemctl restart systemd-journald
|
||||
|
||||
monitor_certificates &
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
INI_FILE="${INI_FILE:-chatmail.ini}"
|
||||
|
||||
if [ ! -f "$INI_FILE" ]; then
|
||||
echo "Error: file $INI_FILE not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TMP_FILE="$(mktemp)"
|
||||
|
||||
convert_to_bytes() {
|
||||
local value="$1"
|
||||
if [[ "$value" =~ ^([0-9]+)([KkMmGgTt])$ ]]; then
|
||||
local num="${BASH_REMATCH[1]}"
|
||||
local unit="${BASH_REMATCH[2]}"
|
||||
case "$unit" in
|
||||
[Kk]) echo $((num * 1024)) ;;
|
||||
[Mm]) echo $((num * 1024 * 1024)) ;;
|
||||
[Gg]) echo $((num * 1024 * 1024 * 1024)) ;;
|
||||
[Tt]) echo $((num * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
esac
|
||||
elif [[ "$value" =~ ^[0-9]+$ ]]; then
|
||||
echo "$value"
|
||||
else
|
||||
echo "Error: incorrect size format: $value." >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
process_specific_params() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
local destination_file=$3
|
||||
|
||||
if [[ "$key" == "max_message_size" ]]; then
|
||||
converted=$(convert_to_bytes "$value") || exit 1
|
||||
if grep -q -e "## .* = .* bytes" "$destination_file"; then
|
||||
sed "s|## .* = .* bytes|## $value = $converted bytes|g" "$destination_file";
|
||||
else
|
||||
echo "## $value = $converted bytes" >> "$destination_file"
|
||||
fi
|
||||
echo "$key = $converted" >> "$destination_file"
|
||||
else
|
||||
echo "$key = $value" >> "$destination_file"
|
||||
fi
|
||||
}
|
||||
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^[[:space:]]*#.* || "$line" =~ ^[[:space:]]*$ ]]; then
|
||||
echo "$line" >> "$TMP_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^([a-z0-9_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
current_value="${BASH_REMATCH[2]}"
|
||||
env_var_name=$(echo "$key" | tr 'a-z' 'A-Z')
|
||||
env_value="${!env_var_name}"
|
||||
|
||||
if [[ -n "$env_value" ]]; then
|
||||
process_specific_params "$key" "$env_value" "$TMP_FILE"
|
||||
else
|
||||
echo "$line" >> "$TMP_FILE"
|
||||
fi
|
||||
else
|
||||
echo "$line" >> "$TMP_FILE"
|
||||
fi
|
||||
done < "$INI_FILE"
|
||||
|
||||
PERMS=$(stat -c %a "$INI_FILE")
|
||||
OWNER=$(stat -c %u "$INI_FILE")
|
||||
GROUP=$(stat -c %g "$INI_FILE")
|
||||
|
||||
chmod "$PERMS" "$TMP_FILE"
|
||||
chown "$OWNER":"$GROUP" "$TMP_FILE"
|
||||
|
||||
mv "$TMP_FILE" "$INI_FILE"
|
||||
@@ -1,185 +0,0 @@
|
||||
# Known issues and limitations
|
||||
|
||||
- Requires cgroups v2 configured in the system. Operation with cgroups v1 has not been tested.
|
||||
- Yes, of course, using systemd inside a container is a hack, and it would be better to split it into several services, but since this is an MVP, it turned out to be easier to do it this way initially than to rewrite the entire deployment system.
|
||||
- The Docker image is only suitable for amd64. If you need to run it on a different architecture, try modifying the Dockerfile (specifically the part responsible for installing dovecot).
|
||||
|
||||
# Docker installation
|
||||
This section provides instructions for installing Chatmail using Docker Compose.
|
||||
|
||||
**Note:** Docker Compose v2 is required (`docker compose`, not `docker-compose`) for its support of the `cgroup: host` option in `docker-compose.yaml` is only supported by Compose v2.
|
||||
[see documentation](https://docs.docker.com/engine/install/debian/#install-using-the-repository)
|
||||
```shell
|
||||
apt install docker-ce docker-compose-plugin docker.io- docker-compose-
|
||||
```
|
||||
|
||||
## Preliminary setup
|
||||
We use `chat.example.org` as the Chatmail domain in the following steps.
|
||||
Please substitute it with your own domain.
|
||||
|
||||
1. Setup the initial DNS records.
|
||||
The following is an example in the familiar BIND zone file format with
|
||||
a TTL of 1 hour (3600 seconds).
|
||||
Please substitute your domain and IP addresses.
|
||||
|
||||
```
|
||||
chat.example.com. 3600 IN A 198.51.100.5
|
||||
chat.example.com. 3600 IN AAAA 2001:db8::5
|
||||
www.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||
```
|
||||
|
||||
2. clone the repository on your server.
|
||||
|
||||
```shell
|
||||
git clone https://github.com/chatmail/relay
|
||||
cd relay
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Configure kernel parameters because they cannot be changed inside the container, specifically `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches`. Run the following:
|
||||
|
||||
```shell
|
||||
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
2. Copy `./docker/example.env` and rename it to `.env`. This file stores variables used in `docker-compose.yaml`.
|
||||
|
||||
```shell
|
||||
cp ./docker/example.env .env
|
||||
```
|
||||
|
||||
3. Configure environment variables in the `.env` file. These variables are used in the `docker-compose.yaml` file to pass repeated values.
|
||||
Below is the list of variables used during deployment:
|
||||
|
||||
- `MAIL_DOMAIN` – The domain name of the future server. (required)
|
||||
- `DEBUG_COMMANDS_ENABLED` – Run debug commands before installation. (default: `false`)
|
||||
- `FORCE_REINIT_INI_FILE` – Recreate the ini configuration file on startup. (default: `false`)
|
||||
- `USE_FOREIGN_CERT_MANAGER` – Use a third-party certificate manager. (default: `false`)
|
||||
- `RECREATE_VENV` - Recreate the virtual environment (venv). If set to `true`, the environment will be recreated when the container starts, which will increase the startup time of the service but can help avoid certain errors. (default: `false`)
|
||||
- `INI_FILE` – Path to the ini configuration file. (default: `./chatmail.ini`)
|
||||
- `PATH_TO_SSL` – Path to where the certificates are stored. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`)
|
||||
- `ENABLE_CERTS_MONITORING` – Enable certificate monitoring if `USE_FOREIGN_CERT_MANAGER=true`. If certificates change, services will be automatically restarted. (default: `false`)
|
||||
- `CERTS_MONITORING_TIMEOUT` – Interval in seconds to check if certificates have changed. (default: `'60'`)
|
||||
- `CMDEPLOY_STAGES` – Deployment stages to run on container start. (default: `"configure,activate"`). Set to `"install,configure,activate"` to force a full reinstall.
|
||||
|
||||
You can also use any variables from the [ini configuration file](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f); they must be in uppercase.
|
||||
|
||||
4. Build the Docker image:
|
||||
|
||||
```shell
|
||||
docker compose build chatmail
|
||||
```
|
||||
|
||||
5. Start docker compose and wait for the installation to finish:
|
||||
|
||||
```shell
|
||||
docker compose up -d # start service
|
||||
docker compose logs -f chatmail # view container logs, press CTRL+C to exit
|
||||
```
|
||||
|
||||
### venv creation
|
||||
The first container start takes longer because it creates the cmdeploy Python virtualenv at `/opt/chatmail/venv` (persisted on the host via volume mount). Subsequent starts reuse the existing venv. Set `RECREATE_VENV=true` in `.env` to force a rebuild if needed.
|
||||
|
||||
6. After installation is complete, you can open `https://<your_domain_name>` in your browser.
|
||||
|
||||
## Using custom files
|
||||
|
||||
When using Docker, you can apply modified configuration files to make the installation more personalized. This is usually needed for the `www/src` section so that the Chatmail landing page is customized to your taste, but it can be used for any other cases as well.
|
||||
|
||||
To replace files correctly:
|
||||
|
||||
1. Create the `./custom` directory. It is in `.gitignore`, so it won’t cause conflicts when updating.
|
||||
|
||||
```shell
|
||||
mkdir -p ./custom
|
||||
```
|
||||
|
||||
2. Modify the required file. For example, `index.md`:
|
||||
|
||||
```shell
|
||||
mkdir -p ./custom/www/src
|
||||
nano ./custom/www/src/index.md
|
||||
```
|
||||
|
||||
3. In `docker-compose.yaml`, add the file mount in the `volumes` section:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
chatmail:
|
||||
volumes:
|
||||
...
|
||||
## custom resources
|
||||
- ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||
```
|
||||
|
||||
4. Restart the service:
|
||||
|
||||
```shell
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Migrating from a bare-metal install
|
||||
|
||||
If you have an existing bare-metal Chatmail installation and want to switch to Docker:
|
||||
|
||||
1. Stop all existing services:
|
||||
|
||||
```shell
|
||||
systemctl stop postfix dovecot doveauth nginx opendkim unbound acmetool-redirector \
|
||||
filtermail filtermail-incoming chatmail-turn iroh-relay chatmail-metadata \
|
||||
lastlogin mtail
|
||||
systemctl disable postfix dovecot doveauth nginx opendkim unbound acmetool-redirector \
|
||||
filtermail filtermail-incoming chatmail-turn iroh-relay chatmail-metadata \
|
||||
lastlogin mtail
|
||||
```
|
||||
|
||||
2. Convert your existing `chatmail.ini` to the Docker `.env` format:
|
||||
|
||||
```shell
|
||||
python3 docker/cm_ini_to_env.py /usr/local/lib/chatmaild/chatmail.ini .env
|
||||
```
|
||||
|
||||
3. Copy persistent data into the `./data/` subdirectories:
|
||||
|
||||
```shell
|
||||
mkdir -p data/chatmail-dkimkeys data/chatmail-acme data/chatmail
|
||||
|
||||
# DKIM keys
|
||||
cp -a /etc/dkimkeys/* data/chatmail-dkimkeys/
|
||||
|
||||
# ACME certificates and account
|
||||
rsync -a /var/lib/acme/ data/chatmail-acme/
|
||||
|
||||
# Mail data
|
||||
rsync -a /home/ data/chatmail/
|
||||
```
|
||||
|
||||
Alternatively, you can mount `/home/vmail` directly by changing the volume in `docker-compose.yaml`:
|
||||
|
||||
```yaml
|
||||
- /home/vmail:/home/vmail
|
||||
```
|
||||
|
||||
The three `./data/` subdirectories cover all persistent state. Everything else is regenerated by the `configure` and `activate` stages on container start.
|
||||
|
||||
## Forcing a full reinstall
|
||||
|
||||
The Docker image bakes the install stage (binary downloads, package setup, chatmaild venv) into the image at build time. On container start, only the `configure` and `activate` stages run by default.
|
||||
|
||||
To force a full reinstall (e.g., after updating the source), either rebuild the image:
|
||||
|
||||
```shell
|
||||
docker compose build chatmail
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or override the stages at runtime without rebuilding:
|
||||
|
||||
```shell
|
||||
CMDEPLOY_STAGES="install,configure,activate" docker compose up -d
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
# Известные проблемы и ограничения
|
||||
- Chatmail будет переустановлен при каждом запуске контейнера (при первом - долго, при последующих быстрее). Так устроен изначальный установщик, потому что он не был заточен под docker. В конце документации [представлено](#фиксирование-версии-chatmail) возможное решение
|
||||
- Требуется настроенный в системе cgroups v2. Работа с cgroups v1 не тестировалась.
|
||||
- Да, понятно дело что systemd использовать в контейнере костыль и надо это всё разнести на несколько сервисов, но это MVP и в первом приближении оказалось сделать проще так, чем переписывать всю систему развертывания.
|
||||
- docker образ подходит только для amd64, если нужно запустить на другой архитектуре, попробуйте изменить dockerfile (конкретно ту часть что ответсвенна за установку dovecot)
|
||||
|
||||
# Docker installation
|
||||
Здесь представлена инструкция по установке chatmail с помощью docker-compose.
|
||||
|
||||
## Предварительная настройка
|
||||
We use `chat.example.org` as the chatmail domain in the following steps.
|
||||
Please substitute it with your own domain.
|
||||
|
||||
1. Настройте начальные записи DNS.Ниже приведен пример в привычном формате файла зоны BIND сTTL 1 час (3600 секунд).
|
||||
Замените домен и IP-адреса на свои.
|
||||
|
||||
```
|
||||
chat.example.com. 3600 IN A 198.51.100.5
|
||||
chat.example.com. 3600 IN AAAA 2001:db8::5
|
||||
www.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
|
||||
```
|
||||
|
||||
2. Склонируйте репозиторий на свой сервер.
|
||||
|
||||
```shell
|
||||
git clone https://github.com/chatmail/relay
|
||||
cd relay
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
1. Настроить параметры ядра, потому что внутри контейнера их нельзя изменить, а конкретно `fs.inotify.max_user_instances` и `fs.inotify.max_user_watches`. Для этого выполнить следующее:
|
||||
```shell
|
||||
echo "fs.inotify.max_user_instances=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||
echo "fs.inotify.max_user_watches=65536" | sudo tee -a /etc/sysctl.d/99-inotify.conf
|
||||
sudo sysctl --system
|
||||
```
|
||||
|
||||
2. Скопировать `./docker/example.env` и переименовать в `.env`. Здесь хранятся переменные, которые используются в `docker-compose.yaml`.
|
||||
```shell
|
||||
cp ./docker/example.env .env
|
||||
```
|
||||
|
||||
3. Настроить переменные окружения в `.env` файле. Эти переменные используются в `docker-compose.yaml` файле, чтобы передавать повторяющиеся значения.
|
||||
Ниже перечислен список переменных учавствующих при развертывании:
|
||||
|
||||
- `MAIL_DOMAIN` - Доменное имя будущего сервера. (required)
|
||||
- `DEBUG_COMMANDS_ENABLED` - Выполнить debug команды перед установкой. (default: `false`)
|
||||
- `FORCE_REINIT_INI_FILE` - Пересоздавать ini файл конфигурации при запуске. (default: `false`)
|
||||
- `USE_FOREIGN_CERT_MANAGER` - Использовать сторонний менеджер сертификатов. (default: `false`)
|
||||
- `RECREATE_VENV` - Пересоздать виртуальное окружение (venv). Если выставлено `true`, то окружение будет пересоздано при запуске контейнера, из-за чего включение сервиса займет больше времени, но поможет избежать ряда ошибок. (default: `false`)
|
||||
- `INI_FILE` - путь к ini файлу конфигурации. (default: `./chatmail.ini`)
|
||||
- `PATH_TO_SSL` - Путь где располагаются сертификаты. (default: `/var/lib/acme/live/${MAIL_DOMAIN}`)
|
||||
- `ENABLE_CERTS_MONITORING` - Включить мониторинг сертификатов, если `USE_FOREIGN_CERT_MANAGER=true`. Если сертфикаты изменятся сервисы будут автоматически перезапущены. (default: `false`)
|
||||
- `CERTS_MONITORING_TIMEOUT` - Раз во сколько секунд проверять что изменились сертификаты. (default: `'60'`)
|
||||
|
||||
Также могут быть использованы все переменные из [ini файла конфигурации](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/ini/chatmail.ini.f), они обязаны быть в uppercase формате.
|
||||
|
||||
4. Собрать docker образ
|
||||
```shell
|
||||
docker compose build chatmail
|
||||
```
|
||||
|
||||
5. Запустить docker compose и дождаться завершения установки
|
||||
```shell
|
||||
docker compose up -d # запуск сервиса
|
||||
docker compose logs -f chatmail # просмотр логов контейнера. Для выхода нажать CTRL+C
|
||||
```
|
||||
|
||||
6. По окончанию установки можно открыть в браузер `https://<your_domain_name>`
|
||||
|
||||
## Использование кастомных файлов
|
||||
При использовании docker есть возможность использовать измененые файлы конфигурации, чтобы сделать установку более персонализированной. Обычно это требуется для секции `www/src`, чтобы ознакомительная страница Chatmail была сделана на ваш вкус. Но также это можно использовать и для любых других случаев.
|
||||
|
||||
Для того чтобы корректно выполнить подмену файлов необходимо
|
||||
1. создать каталог `./custom`, он находится в `.gitignore`, поэтому при обновлении не вызовет конфликтов.
|
||||
```shell
|
||||
mkdir -p ./custom
|
||||
```
|
||||
|
||||
2. Изменить нужный файл. Для примера возьмем `index.md`
|
||||
```shell
|
||||
mkdir -p ./custom/www/src
|
||||
nano ./custom/www/src/index.md
|
||||
```
|
||||
|
||||
3. В `docker-compose.yaml` добавить монтирование файла с помощью секции `volumes`
|
||||
```yaml
|
||||
services:
|
||||
chatmail:
|
||||
volumes:
|
||||
...
|
||||
## custom resources
|
||||
- ./custom/www/src/index.md:/opt/chatmail/www/src/index.md
|
||||
```
|
||||
|
||||
4. Перезапустить сервис
|
||||
```shell
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Фиксирование версии Chatmail
|
||||
> [!note]
|
||||
> Это опциональные шаги, их делать требуется только если вас не устраивает что сервис устанавливается каждый раз при запуске
|
||||
|
||||
Поскольку в текущей версии docker chatmail сервис устанавливается каждый раз запуске контейнера, чтобы этого не происходило можно зафиксировать версию контейнера после установки. Делается это следующим образом:
|
||||
|
||||
1. Зафиксировать текущее состояние сконфигурированного контейнера
|
||||
```shell
|
||||
docker container commit chatmail configured-chatmail:$(date +'%Y-%m-%d')
|
||||
docker image ls | grep configured-chatmail
|
||||
```
|
||||
|
||||
2. Изменить entrypoint для контейнера в `docker-compose.yaml` на
|
||||
```yaml
|
||||
services:
|
||||
chatmail:
|
||||
image: <image name from step 1>
|
||||
volumes:
|
||||
...
|
||||
## custom resources
|
||||
- ./custom/setup_chatmail_docker.sh:/setup_chatmail_docker.sh
|
||||
```
|
||||
|
||||
3. Создать файл `./custom/setup_chatmail_docker.sh` с новым файлом конфигурации
|
||||
```shell
|
||||
mkdir -p ./custom
|
||||
cat > ./custom/setup_chatmail_docker.sh << 'EOF'
|
||||
#!/bin/bash
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
export ENABLE_CERTS_MONITORING="${ENABLE_CERTS_MONITORING:-true}"
|
||||
export CERTS_MONITORING_TIMEOUT="${CERTS_MONITORING_TIMEOUT:-60}"
|
||||
export PATH_TO_SSL="${PATH_TO_SSL:-/var/lib/acme/live/${MAIL_DOMAIN}}"
|
||||
|
||||
calculate_hash() {
|
||||
find "$PATH_TO_SSL" -type f -exec sha1sum {} \; | sort | sha1sum | awk '{print $1}'
|
||||
}
|
||||
|
||||
monitor_certificates() {
|
||||
if [ "$ENABLE_CERTS_MONITORING" != "true" ]; then
|
||||
echo "Certs monitoring disabled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
current_hash=$(calculate_hash)
|
||||
previous_hash=$current_hash
|
||||
|
||||
while true; do
|
||||
current_hash=$(calculate_hash)
|
||||
if [[ "$current_hash" != "$previous_hash" ]]; then
|
||||
# TODO: add an option to restart at a specific time interval
|
||||
echo "[INFO] Certificate's folder hash was changed, reloading nginx, dovecot and postfix services."
|
||||
systemctl reload nginx.service
|
||||
systemctl reload dovecot.service
|
||||
systemctl reload postfix.service
|
||||
previous_hash=$current_hash
|
||||
fi
|
||||
sleep $CERTS_MONITORING_TIMEOUT
|
||||
done
|
||||
}
|
||||
|
||||
monitor_certificates &
|
||||
EOF
|
||||
```
|
||||
|
||||
4. Перезапустить сервис
|
||||
```shell
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
21
www/src/dclogin.js
Normal file
21
www/src/dclogin.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/* dclogin profile generator for self-signed chatmail relays.
|
||||
* Fetches credentials from /new and generates a dclogin: QR code.
|
||||
* Requires qrcode-svg.min.js to be loaded first.
|
||||
*/
|
||||
(function () {
|
||||
function generateProfile() {
|
||||
fetch('/new')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var url = data.dclogin_url;
|
||||
var link = document.getElementById('dclogin-link');
|
||||
link.href = url;
|
||||
var qrLink = document.getElementById('qr-link');
|
||||
qrLink.href = url;
|
||||
var qrCode = document.getElementById('qr-code');
|
||||
var qr = new QRCode({ content: url, width: 300, height: 300, padding: 1, join: true });
|
||||
qrCode.innerHTML = qr.svg();
|
||||
});
|
||||
}
|
||||
generateProfile();
|
||||
})();
|
||||
@@ -11,6 +11,18 @@ for Delta Chat users. For details how it avoids storing personal information
|
||||
please see our [privacy policy](privacy.html).
|
||||
{% endif %}
|
||||
|
||||
{% if config.tls_cert_mode == "self" %}
|
||||
<a class="cta-button" id="dclogin-link" href="#">Get a {{config.mail_domain}} chat profile</a>
|
||||
|
||||
If you are viewing this page on a different device
|
||||
without a Delta Chat app,
|
||||
you can also **scan this QR code** with Delta Chat:
|
||||
|
||||
<a id="qr-link" href="#"><div id="qr-code"></div></a>
|
||||
|
||||
<script src="qrcode-svg.min.js"></script>
|
||||
<script src="dclogin.js"></script>
|
||||
{% else %}
|
||||
<a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
|
||||
|
||||
If you are viewing this page on a different device
|
||||
@@ -19,6 +31,7 @@ you can also **scan this QR code** with Delta Chat:
|
||||
|
||||
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
|
||||
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
|
||||
{% endif %}
|
||||
|
||||
🐣 **Choose** your Avatar and Name
|
||||
|
||||
|
||||
9
www/src/qrcode-svg.min.js
vendored
Normal file
9
www/src/qrcode-svg.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user