mirror of
https://github.com/chatmail/relay.git
synced 2026-09-06 07:23:13 +00:00
Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
361cc64583 | ||
|
|
66c6a77b3a | ||
|
|
284fe5c422 | ||
|
|
b569ae69e4 | ||
|
|
9a5b17cbfc | ||
|
|
2eb0ef5f9e | ||
|
|
2d0fc2e70e | ||
|
|
051f831518 | ||
|
|
fa24dd17de | ||
|
|
ae6b89f936 | ||
|
|
d25e8a8ee8 | ||
|
|
cae03e2714 | ||
|
|
7db16cc716 | ||
|
|
4cdccee63b | ||
|
|
12664d9188 | ||
|
|
1f0ddb7e5b | ||
|
|
2af8d0e7b5 | ||
|
|
e489a1ea29 | ||
|
|
455da45d36 | ||
|
|
48ad92bf24 | ||
|
|
33f9cddb1b | ||
|
|
dc8e0a34a2 | ||
|
|
efc24fcdf3 | ||
|
|
9a9bda80b1 | ||
|
|
74f4721f2b | ||
|
|
14f829003d | ||
|
|
5da9aaeb37 | ||
|
|
d068052f0c | ||
|
|
fa5afadaaa | ||
|
|
eb862f1645 | ||
|
|
6f3039509c | ||
|
|
504fb249a9 | ||
|
|
deca1ef2a3 | ||
|
|
0323e757a9 | ||
|
|
752957dae8 | ||
|
|
3a553f2286 | ||
|
|
cb1e4ff5bb | ||
|
|
8d7870db55 | ||
|
|
5a31ff475f | ||
|
|
8c18aea18e | ||
|
|
ebf5a51964 | ||
|
|
f596d4b56d | ||
|
|
8e3c18019b | ||
|
|
9da3f5c235 | ||
|
|
6def189d16 | ||
|
|
24612e9121 | ||
|
|
a9dd9fe3e0 | ||
|
|
aa846c3478 | ||
|
|
921080125f | ||
|
|
d898f41064 | ||
|
|
e9e012234b | ||
|
|
bb40c5bb21 | ||
|
|
a229f1bc45 | ||
|
|
4ba19b0031 | ||
|
|
5eab3a5a25 | ||
|
|
30729d9be0 | ||
|
|
4b04aae83b | ||
|
|
0eed92171c | ||
|
|
a5b9a98baa | ||
|
|
ab2d807084 | ||
|
|
ce05b26c77 | ||
|
|
77ed93fb7a | ||
|
|
39d1ecaa03 | ||
|
|
a266ffd060 | ||
|
|
a47bb94143 | ||
|
|
43ae9fee5c | ||
|
|
42dc781d7d | ||
|
|
ed664cd9cd | ||
|
|
26a13fbc26 | ||
|
|
d054fbb5aa | ||
|
|
def08c52f4 | ||
|
|
32cfa9c76c | ||
|
|
c0b207c320 | ||
|
|
4ebde2825d | ||
|
|
6a7e6ce9e7 | ||
|
|
8db668c037 | ||
|
|
45fafa10a9 | ||
|
|
ee435a7ef7 | ||
|
|
8fafd4e79f | ||
|
|
129b8a20bc | ||
|
|
a1f64ebd96 | ||
|
|
fb64be97b5 | ||
|
|
b05e26819f | ||
|
|
1db586b3eb | ||
|
|
44fe2dc08f | ||
|
|
8721600d13 | ||
|
|
dfed2b4681 | ||
|
|
f5fd286663 | ||
|
|
16b00da373 | ||
|
|
75606f5eb8 | ||
|
|
d256538f81 | ||
|
|
fdf8e5e345 | ||
|
|
81a161d433 |
@@ -0,0 +1,40 @@
|
|||||||
|
name: No-DNS
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Triggers when a PR is merged into main or a direct push occurs
|
||||||
|
push:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
# Triggers for any PR (and its subsequent commits) targeting the main branch
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
no-dns:
|
||||||
|
name: LXC deploy and test
|
||||||
|
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||||
|
with:
|
||||||
|
cmlxc_version: main
|
||||||
|
cmlxc_commands: |
|
||||||
|
cmlxc init
|
||||||
|
# single cmdeploy relay test
|
||||||
|
cmlxc -v deploy-cmdeploy --source ./repo --type ipv4 cm0
|
||||||
|
cmlxc -v test-cmdeploy cm0
|
||||||
|
|
||||||
|
# cross cmdeploy relay test (two ipv4 relays)
|
||||||
|
cmlxc -v deploy-cmdeploy --source ./repo --ipv4-only --type ipv4 cm1
|
||||||
|
cmlxc -v test-cmdeploy cm0 cm1
|
||||||
|
|
||||||
|
# cross cmdeploy/madmail relay tests
|
||||||
|
cmlxc -v deploy-madmail mad0
|
||||||
|
cmlxc -v test-cmdeploy cm0 mad0
|
||||||
|
cmlxc -v test-mini mad0 cm0
|
||||||
|
cmlxc -v test-mini cm0 mad0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Run unit-tests and container-based deploy+test verification
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
# Triggers when a PR is merged into main or a direct push occurs
|
# Triggers when a PR is merged into main or a direct push occurs
|
||||||
@@ -9,6 +9,8 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
|
# Newest push wins: Prevents multiple runs from clashing and wasting runner efforts
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
@@ -25,8 +27,9 @@ jobs:
|
|||||||
# Otherwise `test_deployed_state` will be unhappy.
|
# Otherwise `test_deployed_state` will be unhappy.
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
persist-credentials: false
|
||||||
- name: download filtermail
|
- name: download filtermail
|
||||||
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
run: curl -L https://github.com/chatmail/filtermail/releases/download/v0.7.4/filtermail-x86_64 -o /usr/local/bin/filtermail && chmod +x /usr/local/bin/filtermail
|
||||||
- name: run chatmaild tests
|
- name: run chatmaild tests
|
||||||
working-directory: chatmaild
|
working-directory: chatmaild
|
||||||
run: pipx run tox
|
run: pipx run tox
|
||||||
@@ -38,6 +41,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.event.pull_request.head.sha }}
|
ref: ${{ github.event.pull_request.head.sha }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: initenv
|
- name: initenv
|
||||||
run: scripts/initenv.sh
|
run: scripts/initenv.sh
|
||||||
@@ -53,8 +57,9 @@ jobs:
|
|||||||
|
|
||||||
lxc-test:
|
lxc-test:
|
||||||
name: LXC deploy and test
|
name: LXC deploy and test
|
||||||
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@v0.10.0
|
uses: chatmail/cmlxc/.github/workflows/lxc-test.yml@main
|
||||||
with:
|
with:
|
||||||
|
cmlxc_version: main
|
||||||
cmlxc_commands: |
|
cmlxc_commands: |
|
||||||
cmlxc init
|
cmlxc init
|
||||||
# single cmdeploy relay test
|
# single cmdeploy relay test
|
||||||
@@ -71,3 +76,4 @@ jobs:
|
|||||||
cmlxc -v test-cmdeploy cm0 mad0
|
cmlxc -v test-cmdeploy cm0 mad0
|
||||||
cmlxc -v test-mini cm0 mad0
|
cmlxc -v test-mini cm0 mad0
|
||||||
cmlxc -v test-mini mad0 cm0
|
cmlxc -v test-mini mad0 cm0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Notify the docker repo to build and test a new image after relay CI passes.
|
||||||
|
#
|
||||||
|
# Sends a repository_dispatch event to chatmail/docker with the relay ref
|
||||||
|
# and short SHA, which triggers docker-ci.yaml to build, push to GHCR,
|
||||||
|
# and run integration tests via cmlxc.
|
||||||
|
|
||||||
|
name: Trigger Docker build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, j4n/dovecot-multidist]
|
||||||
|
tags: ['[0-9]+.[0-9]+.[0-9]+']
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dispatch:
|
||||||
|
name: Dispatch build to chatmail/docker
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.repository == 'chatmail/relay'
|
||||||
|
steps:
|
||||||
|
- name: Compute short SHA
|
||||||
|
id: sha
|
||||||
|
run: echo "short=$(echo '${{ github.sha }}' | cut -c1-7)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Send repository_dispatch
|
||||||
|
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.CHATMAIL_DOCKER_DISPATCH_TOKEN }}
|
||||||
|
repository: chatmail/docker
|
||||||
|
event-type: relay-updated
|
||||||
|
client-payload: >-
|
||||||
|
{
|
||||||
|
"relay_ref": "${{ github.ref_name }}",
|
||||||
|
"relay_sha": "${{ github.sha }}",
|
||||||
|
"relay_sha_short": "${{ steps.sha.outputs.short }}"
|
||||||
|
}
|
||||||
@@ -7,15 +7,24 @@ on:
|
|||||||
- 'scripts/build-docs.sh'
|
- 'scripts/build-docs.sh'
|
||||||
- '.github/workflows/docs-preview.yaml'
|
- '.github/workflows/docs-preview.yaml'
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scripts:
|
scripts:
|
||||||
name: build
|
name: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# Pin the repository links in the docs to this pull request's head commit
|
||||||
|
# so that linkcheck resolves files which only exist on the branch so far.
|
||||||
|
# see doc/conf.py
|
||||||
|
DOC_GITHUB_REF: ${{ github.event.pull_request.head.sha }}
|
||||||
environment:
|
environment:
|
||||||
name: 'staging.chatmail.at/doc/relay/'
|
name: 'staging.chatmail.at/doc/relay/'
|
||||||
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
|
url: https://staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: initenv
|
- name: initenv
|
||||||
run: scripts/initenv.sh
|
run: scripts/initenv.sh
|
||||||
@@ -34,18 +43,22 @@ jobs:
|
|||||||
- name: Get Pullrequest ID
|
- name: Get Pullrequest ID
|
||||||
id: prepare
|
id: prepare
|
||||||
run: |
|
run: |
|
||||||
export PULLREQUEST_ID=$(echo "${{ github.ref }}" | cut -d "/" -f3)
|
export PULLREQUEST_ID=$(echo "${GITHUB_REF}" | cut -d "/" -f3)
|
||||||
echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT
|
echo "prid=$PULLREQUEST_ID" >> $GITHUB_OUTPUT
|
||||||
if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi
|
if [ $(expr length "${{ secrets.USERNAME }}") -gt "1" ]; then echo "uploadtoserver=true" >> $GITHUB_OUTPUT; fi
|
||||||
- run: |
|
- run: |
|
||||||
echo "baseurl: /${{ steps.prepare.outputs.prid }}" >> _config.yml
|
echo "baseurl: /${STEPS_PREPARE_OUTPUTS_PRID}" >> _config.yml
|
||||||
|
env:
|
||||||
|
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
|
||||||
|
|
||||||
- name: Upload preview
|
- name: Upload preview
|
||||||
run: |
|
run: |
|
||||||
mkdir -p "$HOME/.ssh"
|
mkdir -p "$HOME/.ssh"
|
||||||
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
|
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
|
||||||
chmod 600 "$HOME/.ssh/key"
|
chmod 600 "$HOME/.ssh/key"
|
||||||
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/staging.chatmail.at/doc/relay/${{ steps.prepare.outputs.prid }}/"
|
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:${STEPS_PREPARE_OUTPUTS_PRID}/"
|
||||||
|
env:
|
||||||
|
STEPS_PREPARE_OUTPUTS_PRID: ${{ steps.prepare.outputs.prid }}
|
||||||
|
|
||||||
- name: check links
|
- name: check links
|
||||||
working-directory: doc
|
working-directory: doc
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ on:
|
|||||||
- 'scripts/build-docs.sh'
|
- 'scripts/build-docs.sh'
|
||||||
- '.github/workflows/docs.yaml'
|
- '.github/workflows/docs.yaml'
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
scripts:
|
scripts:
|
||||||
name: build
|
name: build
|
||||||
@@ -19,6 +21,8 @@ jobs:
|
|||||||
url: https://chatmail.at/doc/relay/
|
url: https://chatmail.at/doc/relay/
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: initenv
|
- name: initenv
|
||||||
run: scripts/initenv.sh
|
run: scripts/initenv.sh
|
||||||
@@ -43,5 +47,5 @@ jobs:
|
|||||||
mkdir -p "$HOME/.ssh"
|
mkdir -p "$HOME/.ssh"
|
||||||
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
|
echo "${{ secrets.CHATMAIL_STAGING_SSHKEY }}" > "$HOME/.ssh/key"
|
||||||
chmod 600 "$HOME/.ssh/key"
|
chmod 600 "$HOME/.ssh/key"
|
||||||
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:/var/www/html/chatmail.at/doc/relay/"
|
rsync -rILvh -e "ssh -i $HOME/.ssh/key -o StrictHostKeyChecking=no" $GITHUB_WORKSPACE/doc/build/ "${{ secrets.USERNAME }}@chatmail.at:"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
name: GitHub Actions Security Analysis with zizmor
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
branches: ["**"]
|
||||||
|
|
||||||
|
permissions: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
zizmor:
|
||||||
|
name: Run zizmor
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||||
|
contents: read
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Run zizmor
|
||||||
|
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
rules:
|
||||||
|
unpinned-uses:
|
||||||
|
config:
|
||||||
|
policies:
|
||||||
|
actions/*: ref-pin
|
||||||
|
dependabot/*: ref-pin
|
||||||
|
chatmail/*: ref-pin
|
||||||
+182
-1
@@ -1,4 +1,185 @@
|
|||||||
# Changelog for chatmail deployment
|
# Changelog for chatmail deployment
|
||||||
|
|
||||||
|
## [1.12.0] - 2026-07-31
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- [**breaking**] Introduce configurable system limits to reject new address creation and limit imap/smtp connections.
|
||||||
|
Dovecot default connection limit lowered from 50k to 10k,
|
||||||
|
Postfix default connection limit lowered from 5k to 1k,
|
||||||
|
larger relays need to adjust their settings.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Reduce maximal_queue_lifetime from 5d to 2d
|
||||||
|
- Disable negative cache in unbound (#992)
|
||||||
|
- *(mtail)* Add incoming_mailer_daemon_mail_count
|
||||||
|
- *(postfix)* Disable processing of MIME headers
|
||||||
|
- *(dovecot)* Advertise privacy_mail as admin contact, drop server comment
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Set relay restrictions per smtpd service with default reject
|
||||||
|
- Reduce maxproc for filtermail-transport LMTP client to 500
|
||||||
|
- Core 2.50.0 does not have delete_server_after config anymore.
|
||||||
|
- Check if all required ports are available for filtermail (#983)
|
||||||
|
- Always deploy unbound.conf.d/chatmail.conf (#993)
|
||||||
|
- Expire empty directories (#994)
|
||||||
|
- Crypt-r dependency was declared for wrong Python version
|
||||||
|
- Always overwrite /etc/resolv.conf, even if it is a symbolic link
|
||||||
|
- Pass kwargs to files.put()
|
||||||
|
- List Iroh proxy endpoints used by 0.35 and 1.0, drop stale /relay/probe from earlier versions
|
||||||
|
- Fix port discovery when ss -tulpn shows dovecot before stats
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Add scripts/initenv.sh to upgrade instructions
|
||||||
|
- Update overview diagrams (#995)
|
||||||
|
- *(overview)* Remove mermaid styles from 'Accepting and delivering mail' (#1009)
|
||||||
|
- *(README.md)* Clarify security enforcement (#1011)
|
||||||
|
|
||||||
|
### Miscellaneous Tasks
|
||||||
|
|
||||||
|
- *(ci)* Auto-trigger docker build on release tag push
|
||||||
|
- *(acmetool)* Update let's encrypt ToS link to 1.8
|
||||||
|
- *(ci)* Update doc staging upload path
|
||||||
|
- *(ci)* Fix docs upload path
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- *(postfix)* Remove unused "filter" lmtp service
|
||||||
|
- Install dns-root-data instead of using unbound-anchor
|
||||||
|
- *(deps)* Remove domain-validator dependency
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Set socket security for IMAP and SMTP to "TLS" in "dclogin"
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-05-15
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- [**breaking**] Drop passthrough_sender and passthrough_recipients chatmail.ini options to eliminate one more source of unencrypted messages
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Use filtermail for delivery to remote MTAs
|
||||||
|
- Expose metadata "maxsmtprecipients" value
|
||||||
|
- Support setup without domain, with only an IPv4 address (#963)
|
||||||
|
- *(doc/docker)* Introduce docker images in documentation
|
||||||
|
- DKIM-sign bounce messages (mainly "user does not exist")
|
||||||
|
- *(config)* Load default values from Config(), not chatmail.ini.f (#853)
|
||||||
|
- Make turn_socket_path configurable, and cleanup tests and turnserver code.
|
||||||
|
- Warn about any unused chatmail.ini parameter at the end of "cmdeploy run"
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make www tests work with editable instead of just plain installs
|
||||||
|
- Use path with no leading slash for mxdeliv
|
||||||
|
- Increase filtermail-transport concurrency limit
|
||||||
|
- Fix #972 by increasing file descriptors for filtermail
|
||||||
|
- *(mtail)* Correct boot ordering and deploy restart logic
|
||||||
|
- *(cmdeploy)* Stop and disable unbound-resolvconf
|
||||||
|
- *(nginx)* Properly redirect www to mail_domain
|
||||||
|
- *(dns)* Query correct NS if MNAME server is hidden (#954)
|
||||||
|
- Legacy token metadata storage used list type, but if no new setmetadata happened, the user would not be notified at all.
|
||||||
|
- *(logging)* Log all http requests to syslog
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Document how to upgrade to new version (#965)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- *(deps)* Upgrade to filtermail v0.6.4
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Introduce automated change-tracking across deployers
|
||||||
|
|
||||||
|
## 1.10.0 2026-04-30
|
||||||
|
|
||||||
|
* start mtail after networking is fully up <https://github.com/chatmail/relay/pull/942>
|
||||||
|
* support specifying custom filtermail binary through environment variable <https://github.com/chatmail/relay/pull/941>
|
||||||
|
* add automated zizmor scanning of github workflows <https://github.com/chatmail/relay/pull/938>
|
||||||
|
* added dispatch for *automated builds of chatmail relay docker images* <https://github.com/chatmail/relay/pull/934>
|
||||||
|
* do not bind SMTP client sockets to public addresses <https://github.com/chatmail/relay/pull/932>
|
||||||
|
* underline in docs that scripts/initenv.sh should be used for building the docs <https://github.com/chatmail/relay/pull/933>
|
||||||
|
* automatic oldest-first message removal from mailboxes to always stay under max_mailbox_size <https://github.com/chatmail/relay/pull/929>
|
||||||
|
* remove --slow from cmdeploy test <https://github.com/chatmail/relay/pull/931>
|
||||||
|
* handle missing inotify sysctl keys in containers <https://github.com/chatmail/relay/pull/930>
|
||||||
|
* replace resolvconf with static resolv.conf <https://github.com/chatmail/relay/pull/928>
|
||||||
|
* disable fsync for LMTP and IMAP services <https://github.com/chatmail/relay/pull/925>
|
||||||
|
* re-use cmlxc workflow, replacing CI with hetzner staging servers with local lxc containers <https://github.com/chatmail/relay/pull/917>
|
||||||
|
* explicitly install resolvconf <https://github.com/chatmail/relay/pull/924>
|
||||||
|
* detect stale dovecot binary and force restart in activate() <https://github.com/chatmail/relay/pull/922>
|
||||||
|
* Rename filtermail_http_port to filtermail_http_port_incoming <https://github.com/chatmail/relay/pull/921>
|
||||||
|
* consolidated is_in_container() check https://github.com/chatmail/relay/pull/920>
|
||||||
|
* restart dovecot after package replacement (rebase, test condense) <https://github.com/chatmail/relay/pull/913>
|
||||||
|
* Set permissions on dovecot pin prefs <https://github.com/chatmail/relay/pull/915>
|
||||||
|
* Route `/mxdeliv/` to configurable port <https://github.com/chatmail/relay/pull/901>
|
||||||
|
* fix VM detection, automated testing fixes, use newer chatmail-turn and move to standard BIND DNS zone format <https://github.com/chatmail/relay/pull/912>
|
||||||
|
* Upgrade to filtermail 0.6.1 <https://github.com/chatmail/relay/pull/910>
|
||||||
|
* pin dovecot packages to prevent apt upgrades <https://github.com/chatmail/relay/pull/908>
|
||||||
|
* add rpc server to cmdeploy along with client <https://github.com/chatmail/relay/pull/906>
|
||||||
|
* remove unused deps from chatmaild <https://github.com/chatmail/relay/pull/905>
|
||||||
|
* set default smtp_tls_security_level to "verify" unconditionally <https://github.com/chatmail/relay/pull/902>
|
||||||
|
* featprefer IPv4 in SMTP client <https://github.com/chatmail/relay/pull/900>
|
||||||
|
* Install dovecot .deb packages atomically <https://github.com/chatmail/relay/pull/899>
|
||||||
|
* stop installing cron package <https://github.com/chatmail/relay/pull/898>
|
||||||
|
* Rewrite dovecot install logic, update <https://github.com/chatmail/relay/pull/862>
|
||||||
|
* fix a test and some linting fixes <https://github.com/chatmail/relay/pull/897>
|
||||||
|
* Disable IP verification on domain-literal addresses <https://github.com/chatmail/relay/pull/895>
|
||||||
|
* disable installing recommended packages globally on the relay <https://github.com/chatmail/relay/pull/887>
|
||||||
|
* multiple bug fixes across chatmaild and cmdeploy <https://github.com/chatmail/relay/pull/883>
|
||||||
|
* remove /metrics from the website <https://github.com/chatmail/relay/pull/703>
|
||||||
|
* add Prometheus textfile output to fsreport <https://github.com/chatmail/relay/pull/881>
|
||||||
|
* chown opendkim: private key <https://github.com/chatmail/relay/pull/879>
|
||||||
|
* make sure chatmail-metadata was started <https://github.com/chatmail/relay/pull/882>
|
||||||
|
* dovecot update url <https://github.com/chatmail/relay/pull/880>
|
||||||
|
* upgrade to filtermail v0.5.2 <https://github.com/chatmail/relay/pull/876>
|
||||||
|
* download dovecot packages from github release <https://github.com/chatmail/relay/pull/875>
|
||||||
|
* replace DKIM verification with filtermail v0.5 <https://github.com/chatmail/relay/pull/831>
|
||||||
|
* remove CFFI deltachat bindings usage, and consolidate test support with rpc-bindings <https://github.com/chatmail/relay/pull/872>
|
||||||
|
* prepare chatmaild/cmdeploy changes for Docker support <https://github.com/chatmail/relay/pull/857>
|
||||||
|
* stabilize online benchmark timing adding rate-limit-aware cooldown between iterations <https://github.com/chatmail/relay/pull/867>
|
||||||
|
* move rate-limit cooldown to benchmark fixture <https://github.com/chatmail/relay/pull/868>
|
||||||
|
* reconfigure acmetool from redirector to proxy mode <https://github.com/chatmail/relay/pull/861>
|
||||||
|
* make tests work with `--ssh-host localhost` <https://github.com/chatmail/relay/pull/856>
|
||||||
|
* mark f-string with f prefix in test_expunged <https://github.com/chatmail/relay/pull/863>
|
||||||
|
* install also if dovecot.service=False in SystemdEnabled Fact <https://github.com/chatmail/relay/pull/841>
|
||||||
|
* Introduce support for self-signed chatmail relays <https://github.com/chatmail/relay/pull/855>
|
||||||
|
* Strip Received headers before delivery <https://github.com/chatmail/relay/pull/849>
|
||||||
|
* upgrade to filtermail v0.3 <https://github.com/chatmail/relay/pull/850>
|
||||||
|
* fix link to Maddy and update madmail URL <https://github.com/chatmail/relay/pull/847>
|
||||||
|
* accept self-signed certificates for IP-only relays <https://github.com/chatmail/relay/pull/846>
|
||||||
|
* enforce sending from public IP addresses <https://github.com/chatmail/relay/pull/845>
|
||||||
|
* port check: check addresses, fix single services <https://github.com/chatmail/relay/pull/844>
|
||||||
|
* remediates issue with improper concat on resolver injection <https://github.com/chatmail/relay/pull/834>
|
||||||
|
* ipv6 boolean not being respected during operations <https://github.com/chatmail/relay/pull/832>
|
||||||
|
* upgrade to filtermail v0.2 by <https://github.com/chatmail/relay/pull/825>
|
||||||
|
* fix link to filtermail <https://github.com/chatmail/relay/pull/824>
|
||||||
|
* print timestamps when sending messages <https://github.com/chatmail/relay/pull/823>
|
||||||
|
* fix flaky test_exceed_rate_limit <https://github.com/chatmail/relay/pull/822>
|
||||||
|
* Replace filtermail with rust reimplementation <https://github.com/chatmail/relay/pull/808>
|
||||||
|
* Set default internal SMTP ports in Config <https://github.com/chatmail/relay/pull/819>
|
||||||
|
* separate metrics for incoming and outgoing messages <https://github.com/chatmail/relay/pull/820>
|
||||||
|
* disable appending the Received header <https://github.com/chatmail/relay/pull/815>
|
||||||
|
* fail on errors in postfix/dovecot config <https://github.com/chatmail/relay/pull/813>
|
||||||
|
* tweak idle/hibernate metrics some more <https://github.com/chatmail/relay/pull/811>
|
||||||
|
* add config flag to export statistics <https://github.com/chatmail/relay/pull/806>
|
||||||
|
* add --website-only option to run subcommand <https://github.com/chatmail/relay/pull/768>
|
||||||
|
* Strip DKIM-Signature header before LMTP <https://github.com/chatmail/relay/pull/803>
|
||||||
|
* properly make sure that postfix gets restarted on failure <https://github.com/chatmail/relay/pull/802>
|
||||||
|
* expire.py: use absolute path to maildirsize <https://github.com/chatmail/relay/pull/807>
|
||||||
|
* pin Dovecot documentation URLs to version 2.3 <https://github.com/chatmail/relay/pull/800>
|
||||||
|
* try to use "build machine" and "deployment server" consistently <https://github.com/chatmail/relay/pull/797>
|
||||||
|
* adds instructions for migrating control machines <https://github.com/chatmail/relay/pull/795>
|
||||||
|
* use consistent naming schema in getting started <https://github.com/chatmail/relay/pull/793>
|
||||||
|
* remove jsok/serialize-workflow-action dependency <https://github.com/chatmail/relay/pull/790>
|
||||||
|
* streamline migration guide wording, provide titled steps <https://github.com/chatmail/relay/pull/789>
|
||||||
|
* increases default max mailbox size <https://github.com/chatmail/relay/pull/792>
|
||||||
|
* use daemon_name for OpenDKIM sign-verify decision instead of IP <https://github.com/chatmail/relay/pull/784>
|
||||||
|
|
||||||
## 1.9.0 2025-12-18
|
## 1.9.0 2025-12-18
|
||||||
|
|
||||||
|
|||||||
@@ -5,3 +5,6 @@ We use [git-cliff] to generate the changelog from commit messages before the rel
|
|||||||
|
|
||||||
[Conventional Commits]: https://www.conventionalcommits.org/
|
[Conventional Commits]: https://www.conventionalcommits.org/
|
||||||
[git-cliff]: https://git-cliff.org/
|
[git-cliff]: https://git-cliff.org/
|
||||||
|
|
||||||
|
To update client app version information,
|
||||||
|
edit [chatmaild/src/chatmaild/defaults/appversions.json](chatmaild/src/chatmaild/defaults/appversions.json).
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
|
|
||||||
# Chatmail relays for end-to-end encrypted email
|
# Chatmail relays for end-to-end encrypted email
|
||||||
|
|
||||||
Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed for:
|
Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed for:
|
||||||
|
|
||||||
- **Zero State:** no private data or metadata collected, messages are auto-deleted, low disk usage
|
- **Zero State:** no private data or metadata collected, messages are auto-deleted, low disk usage
|
||||||
|
|
||||||
- **Instant/Realtime:** sub-second message delivery, realtime P2P
|
- **Instant/Realtime:** sub-second message delivery, realtime P2P
|
||||||
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
|
streaming, privacy-preserving Push Notifications for Apple, Google, and Huawei;
|
||||||
|
|
||||||
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
|
- **Security Enforcement**: Only connections with strict TLS are accepted;
|
||||||
|
all messages must be correctly signed with DKIM and OpenPGP-encrypted with minimized metadata.
|
||||||
|
There are experimental exceptions for no-DNS relays,
|
||||||
|
which are allowed use self-signed TLS certificates
|
||||||
|
and which do not need to DKIM-sign their messages.
|
||||||
|
Unencrypted messages are allowed in neither case.
|
||||||
|
|
||||||
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
|
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
|
||||||
depends on established IETF standards and protocols.
|
depends on established IETF standards and protocols.
|
||||||
|
|
||||||
This repository contains everything needed to setup a ready-to-use chatmail relay on an ssh-reachable host.
|
This repository contains everything needed to setup a ready-to-use chatmail relay on an ssh-reachable host.
|
||||||
For getting started and more information please refer to the web version of this repositories' documentation at
|
For getting started and more information please refer to the web version of this repositories' documentation at
|
||||||
|
|
||||||
[https://chatmail.at/doc/relay](https://chatmail.at/doc/relay)
|
[https://chatmail.at/doc/relay](https://chatmail.at/doc/relay)
|
||||||
|
|||||||
+4
-6
@@ -1,15 +1,13 @@
|
|||||||
# Releasing a new version of chatmail relay
|
# Releasing a new version of chatmail relay
|
||||||
|
|
||||||
For example, to release version 1.9.0 of chatmail relay, do the following steps.
|
For example, to release version 1.13.0 of chatmail relay, do the following steps.
|
||||||
|
|
||||||
1. Update the changelog: `git cliff --unreleased --tag 1.9.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.9.0 -p CHANGELOG.md`.
|
1. Update the changelog: `git cliff --unreleased --tag 1.13.0 --prepend CHANGELOG.md` or `git cliff -u -t 1.13.0 -p CHANGELOG.md`.
|
||||||
|
|
||||||
2. Open the changelog in the editor, edit it if required.
|
2. Open the changelog in the editor, edit it if required.
|
||||||
|
|
||||||
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
|
3. Commit the changes to the changelog with a commit message `chore(release): prepare for 1.9.0`.
|
||||||
|
|
||||||
3. Tag the release: `git tag --annotate 1.9.0`.
|
4. Open a PR with the new commit, merge it to main after review.
|
||||||
|
|
||||||
4. Push the release tag: `git push origin 1.9.0`.
|
5. In the web interface, create a GitHub release, tell it to create a new tag.
|
||||||
|
|
||||||
5. Create a GitHub release: `gh release create 1.9.0`.
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
include src/chatmaild/defaults/*.json
|
||||||
include src/chatmaild/ini/*.ini.f
|
include src/chatmaild/ini/*.ini.f
|
||||||
include src/chatmaild/ini/*.ini
|
include src/chatmaild/ini/*.ini
|
||||||
include src/chatmaild/tests/mail-data/*
|
include src/chatmaild/tests/mail-data/*
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ version = "0.3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"iniconfig",
|
"iniconfig",
|
||||||
"filelock",
|
"filelock",
|
||||||
|
"psutil",
|
||||||
"requests",
|
"requests",
|
||||||
"crypt-r >= 3.13.1 ; python_version >= '3.11'",
|
"crypt-r >= 3.13.1 ; python_version >= '3.13'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
@@ -25,10 +26,6 @@ chatmail-expire = "chatmaild.expire:daily_expire_main"
|
|||||||
chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
|
chatmail-quota-expire = "chatmaild.expire:quota_expire_main"
|
||||||
chatmail-fsreport = "chatmaild.fsreport:main"
|
chatmail-fsreport = "chatmaild.fsreport:main"
|
||||||
lastlogin = "chatmaild.lastlogin:main"
|
lastlogin = "chatmaild.lastlogin:main"
|
||||||
turnserver = "chatmaild.turnserver:main"
|
|
||||||
|
|
||||||
[project.entry-points.pytest11]
|
|
||||||
"chatmaild.testplugin" = "chatmaild.tests.plugin"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-v -ra --strict-markers"
|
addopts = "-v -ra --strict-markers"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import ipaddress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import iniconfig
|
import iniconfig
|
||||||
@@ -8,64 +9,89 @@ from chatmaild.user import User
|
|||||||
def read_config(inipath):
|
def read_config(inipath):
|
||||||
assert Path(inipath).exists(), inipath
|
assert Path(inipath).exists(), inipath
|
||||||
cfg = iniconfig.IniConfig(inipath)
|
cfg = iniconfig.IniConfig(inipath)
|
||||||
params = cfg.sections["params"]
|
return Config(inipath, params=cfg.sections["params"])
|
||||||
default_config_content = get_default_config_content(params["mail_domain"])
|
|
||||||
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
|
|
||||||
new_params = dict(df_params.items())
|
|
||||||
new_params.update(params)
|
|
||||||
return Config(inipath, params=new_params)
|
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
def __init__(self, inipath, params):
|
def __init__(self, inipath, params):
|
||||||
self._inipath = inipath
|
self._inipath = inipath
|
||||||
self.mail_domain = params["mail_domain"]
|
params = dict(params)
|
||||||
self.max_user_send_per_minute = int(params.get("max_user_send_per_minute", 60))
|
raw_domain = params.pop("mail_domain")
|
||||||
self.max_user_send_burst_size = int(params.get("max_user_send_burst_size", 10))
|
self.mail_domain_bare = raw_domain
|
||||||
self.max_mailbox_size = params["max_mailbox_size"]
|
|
||||||
self.max_message_size = int(params.get("max_message_size", "31457280"))
|
if is_valid_ipv4(raw_domain):
|
||||||
self.delete_mails_after = params["delete_mails_after"]
|
self.ipv4_relay = raw_domain
|
||||||
self.delete_large_after = params["delete_large_after"]
|
self.mail_domain = f"[{raw_domain}]"
|
||||||
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
|
self.postfix_myhostname = ipaddress.IPv4Address(raw_domain).reverse_pointer
|
||||||
self.username_min_length = int(params["username_min_length"])
|
else:
|
||||||
self.username_max_length = int(params["username_max_length"])
|
self.ipv4_relay = None
|
||||||
self.password_min_length = int(params["password_min_length"])
|
self.mail_domain = raw_domain
|
||||||
self.passthrough_senders = params["passthrough_senders"].split()
|
self.postfix_myhostname = raw_domain
|
||||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
|
||||||
self.www_folder = params.get("www_folder", "")
|
self.max_user_send_per_minute = int(params.pop("max_user_send_per_minute", 60))
|
||||||
self.filtermail_smtp_port = int(params.get("filtermail_smtp_port", "10080"))
|
self.max_user_send_burst_size = int(params.pop("max_user_send_burst_size", 10))
|
||||||
|
self.max_mailbox_size = params.pop("max_mailbox_size", "500M")
|
||||||
|
self.max_message_size = int(params.pop("max_message_size", 31457280))
|
||||||
|
self.delete_mails_after = params.pop("delete_mails_after", "20")
|
||||||
|
self.delete_large_after = params.pop("delete_large_after", "7")
|
||||||
|
self.delete_inactive_users_after = int(
|
||||||
|
params.pop("delete_inactive_users_after", 90)
|
||||||
|
)
|
||||||
|
self.username_min_length = int(params.pop("username_min_length", 9))
|
||||||
|
self.username_max_length = int(params.pop("username_max_length", 9))
|
||||||
|
self.password_min_length = int(params.pop("password_min_length", 9))
|
||||||
|
self.www_folder = params.pop("www_folder", "")
|
||||||
|
self.filtermail_smtp_port = int(params.pop("filtermail_smtp_port", "10080"))
|
||||||
self.filtermail_smtp_port_incoming = int(
|
self.filtermail_smtp_port_incoming = int(
|
||||||
params.get("filtermail_smtp_port_incoming", "10081")
|
params.pop("filtermail_smtp_port_incoming", "10081")
|
||||||
)
|
)
|
||||||
self.filtermail_http_port_incoming = int(
|
self.filtermail_http_port_incoming = int(
|
||||||
params.get("filtermail_http_port_incoming", "10082")
|
params.pop("filtermail_http_port_incoming", "10082")
|
||||||
)
|
)
|
||||||
self.postfix_reinject_port = int(params.get("postfix_reinject_port", "10025"))
|
self.filtermail_lmtp_port_transport = int(
|
||||||
|
params.pop("filtermail_lmtp_port_transport", "10083")
|
||||||
|
)
|
||||||
|
self.postfix_reinject_port = int(params.pop("postfix_reinject_port", "10025"))
|
||||||
self.postfix_reinject_port_incoming = int(
|
self.postfix_reinject_port_incoming = int(
|
||||||
params.get("postfix_reinject_port_incoming", "10026")
|
params.pop("postfix_reinject_port_incoming", "10026")
|
||||||
)
|
)
|
||||||
self.mtail_address = params.get("mtail_address")
|
self.doveauth_http_port = int(params.pop("doveauth_http_port", "10084"))
|
||||||
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
|
self.mtail_address = params.pop("mtail_address", None)
|
||||||
self.acme_email = params.get("acme_email", "")
|
self.disable_ipv6 = params.pop("disable_ipv6", "false").lower() == "true"
|
||||||
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
|
self.acme_email = params.pop("acme_email", "")
|
||||||
self.imap_compress = params.get("imap_compress", "false").lower() == "true"
|
self.imap_rawlog = params.pop("imap_rawlog", "false").lower() == "true"
|
||||||
if "iroh_relay" not in params:
|
self.imap_compress = params.pop("imap_compress", "false").lower() == "true"
|
||||||
self.iroh_relay = "https://" + params["mail_domain"]
|
self.turn_socket_path = params.pop(
|
||||||
|
"turn_socket_path", "/run/chatmail-turn/turn.socket"
|
||||||
|
)
|
||||||
|
iroh_relay = params.pop("iroh_relay", None)
|
||||||
|
if iroh_relay is None:
|
||||||
|
self.iroh_relay = "https://" + raw_domain
|
||||||
self.enable_iroh_relay = True
|
self.enable_iroh_relay = True
|
||||||
else:
|
else:
|
||||||
self.iroh_relay = params["iroh_relay"].strip()
|
self.iroh_relay = iroh_relay.strip()
|
||||||
self.enable_iroh_relay = False
|
self.enable_iroh_relay = False
|
||||||
self.privacy_postal = params.get("privacy_postal")
|
self.privacy_postal = params.pop("privacy_postal", None)
|
||||||
self.privacy_mail = params.get("privacy_mail")
|
self.privacy_mail = params.pop("privacy_mail", None)
|
||||||
self.privacy_pdo = params.get("privacy_pdo")
|
self.privacy_pdo = params.pop("privacy_pdo", None)
|
||||||
self.privacy_supervisor = params.get("privacy_supervisor")
|
self.privacy_supervisor = params.pop("privacy_supervisor", None)
|
||||||
|
|
||||||
|
self.max_load_1m = float(params.pop("max_load_1m", 5))
|
||||||
|
self.min_available_memory_mb = parse_size_mb(
|
||||||
|
params.pop("min_available_memory", "200M")
|
||||||
|
)
|
||||||
|
self.min_free_disk_space_mb = parse_size_mb(
|
||||||
|
params.pop("min_free_disk_space", "1G")
|
||||||
|
)
|
||||||
|
self.max_imap_connections = int(params.pop("max_imap_connections", 10000))
|
||||||
|
self.max_smtp_connections = int(params.pop("max_smtp_connections", 1000))
|
||||||
|
|
||||||
# TLS certificate management.
|
# TLS certificate management.
|
||||||
# If tls_external_cert_and_key is set, use externally managed certs.
|
# If tls_external_cert_and_key is set, use externally managed certs.
|
||||||
# Otherwise derived from the domain name:
|
# Otherwise derived from the domain name:
|
||||||
# - Domains starting with "_" use self-signed certificates
|
# - Domains starting with "_" use self-signed certificates
|
||||||
# - All other domains use ACME.
|
# - All other domains use ACME.
|
||||||
external = params.get("tls_external_cert_and_key", "").strip()
|
external = params.pop("tls_external_cert_and_key", "").strip()
|
||||||
|
|
||||||
if external:
|
if external:
|
||||||
parts = external.split()
|
parts = external.split()
|
||||||
@@ -76,21 +102,22 @@ class Config:
|
|||||||
)
|
)
|
||||||
self.tls_cert_mode = "external"
|
self.tls_cert_mode = "external"
|
||||||
self.tls_cert_path, self.tls_key_path = parts
|
self.tls_cert_path, self.tls_key_path = parts
|
||||||
elif self.mail_domain.startswith("_"):
|
elif raw_domain.startswith("_") or self.ipv4_relay:
|
||||||
self.tls_cert_mode = "self"
|
self.tls_cert_mode = "self"
|
||||||
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
|
self.tls_cert_path = "/etc/ssl/certs/mailserver.pem"
|
||||||
self.tls_key_path = "/etc/ssl/private/mailserver.key"
|
self.tls_key_path = "/etc/ssl/private/mailserver.key"
|
||||||
else:
|
else:
|
||||||
self.tls_cert_mode = "acme"
|
self.tls_cert_mode = "acme"
|
||||||
self.tls_cert_path = f"/var/lib/acme/live/{self.mail_domain}/fullchain"
|
self.tls_cert_path = f"/var/lib/acme/live/{raw_domain}/fullchain"
|
||||||
self.tls_key_path = f"/var/lib/acme/live/{self.mail_domain}/privkey"
|
self.tls_key_path = f"/var/lib/acme/live/{raw_domain}/privkey"
|
||||||
|
|
||||||
# deprecated option
|
# deprecated option
|
||||||
mbdir = params.get("mailboxes_dir", f"/home/vmail/mail/{self.mail_domain}")
|
mbdir = params.pop("mailboxes_dir", f"/home/vmail/mail/{raw_domain}")
|
||||||
self.mailboxes_dir = Path(mbdir.strip())
|
self.mailboxes_dir = Path(mbdir.strip())
|
||||||
|
|
||||||
# old unused option (except for first migration from sqlite to maildir store)
|
# old unused option (except for first migration from sqlite to maildir store)
|
||||||
self.passdb_path = Path(params.get("passdb_path", "/home/vmail/passdb.sqlite"))
|
self.passdb_path = Path(params.pop("passdb_path", "/home/vmail/passdb.sqlite"))
|
||||||
|
self._unused_keys = list(params)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_mailbox_size_mb(self):
|
def max_mailbox_size_mb(self):
|
||||||
@@ -147,28 +174,13 @@ def get_default_config_content(mail_domain, **overrides):
|
|||||||
for name, value in extra.items():
|
for name, value in extra.items():
|
||||||
new_line = f"{name} = {value}"
|
new_line = f"{name} = {value}"
|
||||||
new_lines.append(new_line)
|
new_lines.append(new_line)
|
||||||
|
return "\n".join(new_lines)
|
||||||
|
|
||||||
content = "\n".join(new_lines)
|
|
||||||
|
|
||||||
# apply testrun privacy overrides
|
def is_valid_ipv4(address: str) -> bool:
|
||||||
|
"""Check if a mail_domain is an IPv4 address."""
|
||||||
if mail_domain.endswith(".testrun.org"):
|
try:
|
||||||
override_inipath = inidir.joinpath("override-testrun.ini")
|
ipaddress.IPv4Address(address)
|
||||||
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
|
return True
|
||||||
lines = []
|
except ValueError:
|
||||||
for line in content.split("\n"):
|
return False
|
||||||
for key, value in privacy.items():
|
|
||||||
value_lines = value.format(mail_domain=mail_domain).strip().split("\n")
|
|
||||||
if not line.startswith(f"{key} =") or not value_lines:
|
|
||||||
continue
|
|
||||||
if len(value_lines) == 1:
|
|
||||||
lines.append(f"{key} = {value}")
|
|
||||||
else:
|
|
||||||
lines.append(f"{key} =")
|
|
||||||
for vl in value_lines:
|
|
||||||
lines.append(f" {vl}")
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
lines.append(line)
|
|
||||||
content = "\n".join(lines)
|
|
||||||
return content
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "deltachat",
|
||||||
|
"sources": [
|
||||||
|
{
|
||||||
|
"sourceId": "gplay",
|
||||||
|
"versionInteger": 757,
|
||||||
|
"versionString": "2.59.1",
|
||||||
|
"downloadUrl": "https://github.com/deltachat/deltachat-android/releases/download/v2.59.1/deltachat-gplay-release-2.59.1.apk"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import json
|
"""Create chatmail addresses on first login.
|
||||||
|
|
||||||
|
Dovecot only asks us about addresses it does not already find in the mailbox:
|
||||||
|
the auth.lua we deploy with dovecot (cmdeploy/src/cmdeploy/dovecot/auth.lua.j2)
|
||||||
|
verifies existing users itself against a mailbox password file,
|
||||||
|
and HTTP-POSTs everything else to the /create endpoint implemented in this module.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
import filelock
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import crypt_r
|
import crypt_r
|
||||||
@@ -12,8 +19,8 @@ except ImportError:
|
|||||||
import crypt as crypt_r
|
import crypt as crypt_r
|
||||||
|
|
||||||
from .config import Config, read_config
|
from .config import Config, read_config
|
||||||
from .dictproxy import DictProxy
|
|
||||||
from .migrate_db import migrate_from_db_to_maildir
|
from .migrate_db import migrate_from_db_to_maildir
|
||||||
|
from .syslimits import has_sufficient_resources
|
||||||
|
|
||||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||||
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
VALID_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
|
||||||
@@ -63,107 +70,117 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def split_and_unescape(s):
|
def verify_password(stored, cleartext_password) -> bool:
|
||||||
"""Split strings using double quote as a separator and backslash as escape character
|
if stored.startswith("{"):
|
||||||
into parts."""
|
stored = stored.split("}", 1)[1]
|
||||||
|
return crypt_r.crypt(cleartext_password, stored) == stored
|
||||||
out = ""
|
|
||||||
i = 0
|
|
||||||
while i < len(s):
|
|
||||||
c = s[i]
|
|
||||||
if c == "\\":
|
|
||||||
# Skip escape character.
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
# This will raise IndexError if there is no character
|
|
||||||
# after escape character. This is expected
|
|
||||||
# as this is an invalid input.
|
|
||||||
out += s[i]
|
|
||||||
elif c == '"':
|
|
||||||
# Separator
|
|
||||||
yield out
|
|
||||||
out = ""
|
|
||||||
else:
|
|
||||||
out += c
|
|
||||||
i += 1
|
|
||||||
yield out
|
|
||||||
|
|
||||||
|
|
||||||
class AuthDictProxy(DictProxy):
|
class DoveAuth:
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
super().__init__()
|
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.creation_lock = threading.Lock()
|
||||||
|
|
||||||
def handle_lookup(self, parts):
|
def create_user(self, addr, cleartext_password) -> bool:
|
||||||
# Dovecot <2.3.17 has only one part,
|
"""Create the address, or verify the password if it exists already."""
|
||||||
# do not attempt to read any other parts for compatibility.
|
|
||||||
keyname = parts[0]
|
|
||||||
|
|
||||||
namespace, type, args = keyname.split("/", 2)
|
|
||||||
args = list(split_and_unescape(args))
|
|
||||||
|
|
||||||
config = self.config
|
config = self.config
|
||||||
reply_command = "F"
|
if not addr.endswith(f"@{config.mail_domain}"):
|
||||||
res = ""
|
logging.warning("address not in mail domain: %r", addr)
|
||||||
if namespace == "shared":
|
return False
|
||||||
if type == "userdb":
|
try:
|
||||||
user = args[0]
|
user = config.get_user(addr)
|
||||||
if user.endswith(f"@{config.mail_domain}"):
|
except ValueError:
|
||||||
res = self.lookup_userdb(user)
|
logging.warning("invalid address: %r", addr)
|
||||||
if res:
|
return False
|
||||||
reply_command = "O"
|
with self.creation_lock:
|
||||||
else:
|
passhash = user.get_password_hash()
|
||||||
reply_command = "N"
|
if passhash is not None:
|
||||||
elif type == "passdb":
|
# a concurrent first login may have just created the address
|
||||||
user = args[1]
|
return verify_password(passhash, cleartext_password)
|
||||||
if user.endswith(f"@{config.mail_domain}"):
|
if not is_allowed_to_create(config, addr, cleartext_password):
|
||||||
res = self.lookup_passdb(user, cleartext_password=args[0])
|
return False
|
||||||
if res:
|
if not has_sufficient_resources(config):
|
||||||
reply_command = "O"
|
return False
|
||||||
else:
|
|
||||||
reply_command = "N"
|
|
||||||
json_res = json.dumps(res) if res else ""
|
|
||||||
return f"{reply_command}{json_res}\n"
|
|
||||||
|
|
||||||
def handle_iterate(self, parts):
|
|
||||||
# example: I0\t0\tshared/userdb/
|
|
||||||
if parts[2] == "shared/userdb/":
|
|
||||||
result = "".join(
|
|
||||||
f"Oshared/userdb/{user}\t\n" for user in self.iter_userdb()
|
|
||||||
)
|
|
||||||
return f"{result}\n"
|
|
||||||
|
|
||||||
def iter_userdb(self) -> list:
|
|
||||||
"""Get a list of all user addresses."""
|
|
||||||
return [x for x in os.listdir(self.config.mailboxes_dir) if "@" in x]
|
|
||||||
|
|
||||||
def lookup_userdb(self, addr):
|
|
||||||
return self.config.get_user(addr).get_userdb_dict()
|
|
||||||
|
|
||||||
def lookup_passdb(self, addr, cleartext_password):
|
|
||||||
user = self.config.get_user(addr)
|
|
||||||
userdata = user.get_userdb_dict()
|
|
||||||
if userdata:
|
|
||||||
return userdata
|
|
||||||
if not is_allowed_to_create(self.config, addr, cleartext_password):
|
|
||||||
return
|
|
||||||
|
|
||||||
lock = filelock.FileLock(str(user.password_path) + ".lock", timeout=5)
|
|
||||||
with lock:
|
|
||||||
userdata = user.get_userdb_dict()
|
|
||||||
if userdata:
|
|
||||||
return userdata
|
|
||||||
user.set_password(encrypt_password(cleartext_password))
|
user.set_password(encrypt_password(cleartext_password))
|
||||||
print(f"Created address: {addr}", file=sys.stderr)
|
# mtail counts created_accounts off this exact line
|
||||||
return user.get_userdb_dict()
|
print(f"Created address: {addr}", file=sys.stderr)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class CreateHandler(BaseHTTPRequestHandler):
|
||||||
|
"""Answer POST /create requests from dovecot's auth.lua, body `addr\\tpassword`.
|
||||||
|
|
||||||
|
The body must be UTF-8 and only the first tab separates the fields,
|
||||||
|
so a password may itself contain tabs.
|
||||||
|
Any non-UTF8 or \\0 bytes in the body fail the request.
|
||||||
|
|
||||||
|
Addresses are ASCII: dovecot refuses any login name outside its
|
||||||
|
auth_username_chars before auth.lua ever sees it.
|
||||||
|
|
||||||
|
Dovecot hands auth.lua the exact password bytes the client sent;
|
||||||
|
decoding and re-encoding UTF-8 is byte-identical,
|
||||||
|
so dovecot's password_verify later recomputes the same hash crypt() stores here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
protocol_version = "HTTP/1.1" # dovecot's HTTP client reuses connections
|
||||||
|
|
||||||
|
max_body_len = 512 # an address and a password
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != "/create":
|
||||||
|
self.reply(404)
|
||||||
|
return
|
||||||
|
length = self.body_length()
|
||||||
|
if length is None:
|
||||||
|
self.reply(400)
|
||||||
|
return
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
try:
|
||||||
|
addr, _, password = body.decode("utf-8").partition("\t")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
self.reply(400)
|
||||||
|
return
|
||||||
|
if "\0" in addr or "\0" in password:
|
||||||
|
self.reply(400)
|
||||||
|
return
|
||||||
|
self.reply(200 if self.server.doveauth.create_user(addr, password) else 403)
|
||||||
|
|
||||||
|
def body_length(self):
|
||||||
|
try:
|
||||||
|
length = int(self.headers["Content-Length"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return length if 0 <= length <= self.max_body_len else None
|
||||||
|
|
||||||
|
def reply(self, status):
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Length", "0")
|
||||||
|
if status != 200:
|
||||||
|
# Just close on any failure, as body might not be fully read.
|
||||||
|
# It's anyway cheap to re-establish http localhost without TLS.
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format, *args):
|
||||||
|
# the per-request access log would only duplicate our own stderr lines
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class DoveAuthServer(ThreadingHTTPServer):
|
||||||
|
# a burst of first-time logins (e.g. from CI) must not overflow
|
||||||
|
# the accept queue, see https://github.com/chatmail/relay/issues/436
|
||||||
|
request_queue_size = 1000
|
||||||
|
|
||||||
|
def __init__(self, config, port):
|
||||||
|
super().__init__(("127.0.0.1", port), CreateHandler)
|
||||||
|
self.doveauth = DoveAuth(config)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
socket, cfgpath = sys.argv[1:]
|
(cfgpath,) = sys.argv[1:]
|
||||||
config = read_config(cfgpath)
|
config = read_config(cfgpath)
|
||||||
|
|
||||||
migrate_from_db_to_maildir(config)
|
migrate_from_db_to_maildir(config)
|
||||||
|
|
||||||
dictproxy = AuthDictProxy(config=config)
|
server = DoveAuthServer(config, config.doveauth_http_port)
|
||||||
|
server.serve_forever()
|
||||||
dictproxy.serve_forever_from_socket(socket)
|
|
||||||
|
|||||||
@@ -168,6 +168,16 @@ class Expiry:
|
|||||||
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
if mbox.last_login and mbox.last_login < cutoff_without_login:
|
||||||
self.remove_mailbox(mbox.basedir)
|
self.remove_mailbox(mbox.basedir)
|
||||||
return
|
return
|
||||||
|
elif mbox.last_login is None:
|
||||||
|
try:
|
||||||
|
if not self.dry:
|
||||||
|
os.rmdir(mbox.basedir)
|
||||||
|
self.del_mboxes += 1
|
||||||
|
except OSError:
|
||||||
|
print_info(
|
||||||
|
f"Skipped deleting {mbox.basedir}, doesn't have last_login but isn't empty"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
mboxname = os.path.basename(mbox.basedir)
|
mboxname = os.path.basename(mbox.basedir)
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
@@ -249,7 +259,7 @@ def daily_expire_main(args=None):
|
|||||||
args = parser.parse_args(args)
|
args = parser.parse_args(args)
|
||||||
|
|
||||||
config = read_config(args.chatmail_ini)
|
config = read_config(args.chatmail_ini)
|
||||||
now = datetime.utcnow().timestamp()
|
now = time.time()
|
||||||
if args.days:
|
if args.days:
|
||||||
now = now - 86400 * int(args.days)
|
now = now - 86400 * int(args.days)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ to also write legacy metrics.py style output (default: /var/www/html/metrics):
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import time
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -264,7 +265,7 @@ def main(args=None):
|
|||||||
|
|
||||||
config = read_config(args.chatmail_ini)
|
config = read_config(args.chatmail_ini)
|
||||||
|
|
||||||
now = datetime.utcnow().timestamp()
|
now = time.time()
|
||||||
if args.days:
|
if args.days:
|
||||||
now = now - 86400 * int(args.days)
|
now = now - 86400 * int(args.days)
|
||||||
|
|
||||||
|
|||||||
@@ -12,42 +12,62 @@ mail_domain = {mail_domain}
|
|||||||
#
|
#
|
||||||
|
|
||||||
# email sending rate per user and minute
|
# email sending rate per user and minute
|
||||||
max_user_send_per_minute = 60
|
#max_user_send_per_minute = 60
|
||||||
|
|
||||||
# per-user max burst size for sending rate limiting (GCRA bucket capacity)
|
# per-user max burst size for sending rate limiting (GCRA bucket capacity)
|
||||||
max_user_send_burst_size = 10
|
#max_user_send_burst_size = 10
|
||||||
|
|
||||||
# maximum mailbox size of a chatmail address
|
# maximum mailbox size of a chatmail address
|
||||||
# Oldest messages will be removed automatically, so mailboxes never run full.
|
# (Oldest messages will be removed automatically, so mailboxes never run full)
|
||||||
max_mailbox_size = 500M
|
#max_mailbox_size = 500M
|
||||||
|
|
||||||
# maximum message size for an e-mail in bytes
|
# maximum message size for an e-mail in bytes
|
||||||
max_message_size = 31457280
|
#max_message_size = 31457280
|
||||||
|
|
||||||
# days after which mails are unconditionally deleted
|
# days after which mails are unconditionally deleted
|
||||||
delete_mails_after = 20
|
#delete_mails_after = 20
|
||||||
|
|
||||||
# days after which large messages (>200k) are unconditionally deleted
|
# days after which large messages (>200k) are unconditionally deleted
|
||||||
delete_large_after = 7
|
#delete_large_after = 7
|
||||||
|
|
||||||
# days after which users without a successful login are deleted (database and mails)
|
# days after which users without a successful login are deleted (database and mails)
|
||||||
delete_inactive_users_after = 90
|
#delete_inactive_users_after = 90
|
||||||
|
|
||||||
# minimum length a username must have
|
# minimum length a username must have
|
||||||
username_min_length = 9
|
#username_min_length = 9
|
||||||
|
|
||||||
# maximum length a username can have
|
# maximum length a username can have
|
||||||
username_max_length = 9
|
#username_max_length = 9
|
||||||
|
|
||||||
# minimum length a password must have
|
# minimum length a password must have
|
||||||
password_min_length = 9
|
#password_min_length = 9
|
||||||
|
|
||||||
# list of chatmail addresses which can send outbound un-encrypted mail
|
#
|
||||||
passthrough_senders =
|
# System resource limits
|
||||||
|
#
|
||||||
|
|
||||||
# list of e-mail recipients for which to accept outbound un-encrypted mails
|
# The following three limits refuse creation of new addresses
|
||||||
# (space-separated, item may start with "@" to whitelist whole recipient domains)
|
# while existing addresses keep working.
|
||||||
passthrough_recipients =
|
# Rejections are logged by the doveauth service.
|
||||||
|
|
||||||
|
# Maximum 1-minute load average, as reported by "uptime";
|
||||||
|
# it counts processes waiting for disk I/O as well as for CPU.
|
||||||
|
#max_load_1m = 5
|
||||||
|
|
||||||
|
# Minimum memory available without swapping.
|
||||||
|
#min_available_memory = 200M
|
||||||
|
|
||||||
|
# Minimum free disk space on the file system holding the mailboxes.
|
||||||
|
#min_free_disk_space = 1G
|
||||||
|
|
||||||
|
# Maximum number of concurrent IMAP connections
|
||||||
|
# (the Dovecot imap process limit).
|
||||||
|
#max_imap_connections = 10000
|
||||||
|
|
||||||
|
# Maximum number of concurrent SMTP connections
|
||||||
|
# on each of the submission and smtps ports (the Postfix process limit).
|
||||||
|
# A single client IP may use up to a fifth of this.
|
||||||
|
#max_smtp_connections = 1000
|
||||||
|
|
||||||
# Use externally managed TLS certificates instead of built-in acmetool.
|
# Use externally managed TLS certificates instead of built-in acmetool.
|
||||||
# Paths refer to files on the deployment server (not the build machine).
|
# Paths refer to files on the deployment server (not the build machine).
|
||||||
@@ -63,19 +83,11 @@ passthrough_recipients =
|
|||||||
# Deployment Details
|
# Deployment Details
|
||||||
#
|
#
|
||||||
|
|
||||||
# SMTP outgoing filtermail and reinjection
|
|
||||||
filtermail_smtp_port = 10080
|
|
||||||
postfix_reinject_port = 10025
|
|
||||||
|
|
||||||
# SMTP incoming filtermail and reinjection
|
|
||||||
filtermail_smtp_port_incoming = 10081
|
|
||||||
postfix_reinject_port_incoming = 10026
|
|
||||||
|
|
||||||
# if set to "True" IPv6 is disabled
|
# if set to "True" IPv6 is disabled
|
||||||
disable_ipv6 = False
|
#disable_ipv6 = False
|
||||||
|
|
||||||
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
|
||||||
acme_email =
|
#acme_email =
|
||||||
|
|
||||||
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
|
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
|
||||||
# service.
|
# service.
|
||||||
@@ -108,13 +120,13 @@ acme_email =
|
|||||||
# in per-maildir ".in/.out" files.
|
# in per-maildir ".in/.out" files.
|
||||||
# Note that you need to manually cleanup these files
|
# Note that you need to manually cleanup these files
|
||||||
# so use this option with caution on production servers.
|
# so use this option with caution on production servers.
|
||||||
imap_rawlog = false
|
#imap_rawlog = false
|
||||||
|
|
||||||
# set to true if you want to enable the IMAP COMPRESS Extension,
|
# set to true if you want to enable the IMAP COMPRESS Extension,
|
||||||
# which allows IMAP connections to be efficiently compressed.
|
# which allows IMAP connections to be efficiently compressed.
|
||||||
# WARNING: Enabling this makes it impossible to hibernate IMAP
|
# WARNING: Enabling this makes it impossible to hibernate IMAP
|
||||||
# processes which will result in much higher memory/RAM usage.
|
# processes which will result in much higher memory/RAM usage.
|
||||||
imap_compress = false
|
#imap_compress = false
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
|
|
||||||
[privacy]
|
|
||||||
|
|
||||||
passthrough_recipients = privacy@testrun.org echo@{mail_domain}
|
|
||||||
|
|
||||||
privacy_postal =
|
|
||||||
Merlinux GmbH, Represented by the managing director H. Krekel,
|
|
||||||
Reichgrafen Str. 20, 79102 Freiburg, Germany
|
|
||||||
|
|
||||||
privacy_mail = privacy@testrun.org
|
|
||||||
privacy_pdo =
|
|
||||||
Prof. Dr. Fabian Schmieder, lexICT UG (limited), Ostfeldstr. 49, 30559 Hannover.
|
|
||||||
You can contact him at *delta-privacy@merlinux.eu* (Keyword: DPO)
|
|
||||||
privacy_supervisor =
|
|
||||||
State Commissioner for Data Protection and Freedom of Information of
|
|
||||||
Baden-Württemberg in 70173 Stuttgart, Germany.
|
|
||||||
@@ -1,13 +1,35 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from importlib.resources import files
|
||||||
|
|
||||||
from .config import read_config
|
from .config import read_config
|
||||||
from .dictproxy import DictProxy
|
from .dictproxy import DictProxy
|
||||||
from .filedict import FileDict
|
from .filedict import FileDict
|
||||||
from .notifier import Notifier
|
from .notifier import Notifier
|
||||||
from .turnserver import turn_credentials
|
|
||||||
|
|
||||||
|
def turn_credentials(turn_socket_path):
|
||||||
|
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
||||||
|
client_socket.settimeout(5)
|
||||||
|
client_socket.connect(turn_socket_path)
|
||||||
|
with client_socket.makefile("rb") as file:
|
||||||
|
return file.readline().decode("utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_appversions(path):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_bytes())
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
logging.exception(f"failed to read {path}")
|
||||||
|
return None
|
||||||
|
# the dict protocol is line-based, keep the value single-line
|
||||||
|
return json.dumps(data, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_token_timestamp(timestamp, now):
|
def _is_valid_token_timestamp(timestamp, now):
|
||||||
@@ -70,44 +92,57 @@ class Metadata:
|
|||||||
# Some tokens have expired, remove them.
|
# Some tokens have expired, remove them.
|
||||||
with self._modify_tokens(addr) as _tokens:
|
with self._modify_tokens(addr) as _tokens:
|
||||||
pass
|
pass
|
||||||
|
elif isinstance(tokens, list):
|
||||||
|
with self._modify_tokens(addr) as tokens:
|
||||||
|
token_list = list(tokens.keys())
|
||||||
else:
|
else:
|
||||||
token_list = []
|
token_list = []
|
||||||
return token_list
|
return token_list
|
||||||
|
|
||||||
|
|
||||||
class MetadataDictProxy(DictProxy):
|
class MetadataDictProxy(DictProxy):
|
||||||
def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
notifier,
|
||||||
|
metadata,
|
||||||
|
iroh_relay=None,
|
||||||
|
turn_hostname=None,
|
||||||
|
turn_socket_path=None,
|
||||||
|
):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.notifier = notifier
|
self.notifier = notifier
|
||||||
self.metadata = metadata
|
self.metadata = metadata
|
||||||
self.iroh_relay = iroh_relay
|
self.iroh_relay = iroh_relay
|
||||||
self.turn_hostname = turn_hostname
|
self.turn_hostname = turn_hostname
|
||||||
|
self.turn_socket_path = turn_socket_path
|
||||||
|
self.appversions_path = files(__package__).joinpath("defaults/appversions.json")
|
||||||
|
|
||||||
def handle_lookup(self, parts):
|
def handle_lookup(self, parts):
|
||||||
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
||||||
keyparts = parts[0].split("/", 2)
|
match parts[0].split("/", 2):
|
||||||
if keyparts[0] == "priv":
|
case ["priv", _, keyname] if keyname == self.metadata.DEVICETOKEN_KEY:
|
||||||
keyname = keyparts[2]
|
addr = parts[1]
|
||||||
addr = parts[1]
|
|
||||||
if keyname == self.metadata.DEVICETOKEN_KEY:
|
|
||||||
res = " ".join(self.metadata.get_tokens_for_addr(addr))
|
res = " ".join(self.metadata.get_tokens_for_addr(addr))
|
||||||
return f"O{res}\n"
|
return f"O{res}\n"
|
||||||
elif keyparts[0] == "shared":
|
case ["shared", _, keyname]:
|
||||||
keyname = keyparts[2]
|
prefix = "vendor/vendor.dovecot/pvt/server/vendor/deltachat/"
|
||||||
if (
|
if keyname.startswith(prefix):
|
||||||
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
|
match keyname[len(prefix) :]:
|
||||||
and self.iroh_relay
|
case "irohrelay" if self.iroh_relay:
|
||||||
):
|
return f"O{self.iroh_relay}\n"
|
||||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
case "turn":
|
||||||
return f"O{self.iroh_relay}\n"
|
try:
|
||||||
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
|
res = turn_credentials(self.turn_socket_path)
|
||||||
try:
|
except Exception:
|
||||||
res = turn_credentials()
|
logging.exception("failed to get TURN credentials")
|
||||||
except Exception:
|
return "N\n"
|
||||||
logging.exception("failed to get TURN credentials")
|
return f"O{self.turn_hostname}:3478:{res}\n"
|
||||||
return "N\n"
|
case "maxsmtprecipients":
|
||||||
port = 3478
|
# postfix default (see "postconf smtpd_recipient_limit")
|
||||||
return f"O{self.turn_hostname}:{port}:{res}\n"
|
return "O1000\n"
|
||||||
|
case "appversions":
|
||||||
|
value = read_appversions(self.appversions_path)
|
||||||
|
return f"O{value}\n" if value else "N\n"
|
||||||
|
|
||||||
logging.warning(f"lookup ignored: {parts!r}")
|
logging.warning(f"lookup ignored: {parts!r}")
|
||||||
return "N\n"
|
return "N\n"
|
||||||
@@ -117,12 +152,13 @@ class MetadataDictProxy(DictProxy):
|
|||||||
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
|
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
|
||||||
keyname = parts[1].split("/")
|
keyname = parts[1].split("/")
|
||||||
value = parts[2] if len(parts) > 2 else ""
|
value = parts[2] if len(parts) > 2 else ""
|
||||||
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY:
|
match keyname:
|
||||||
self.metadata.add_token_to_addr(addr, value)
|
case ["priv", _, key] if key == self.metadata.DEVICETOKEN_KEY:
|
||||||
return True
|
self.metadata.add_token_to_addr(addr, value)
|
||||||
elif keyname[0] == "priv" and keyname[2] == "messagenew":
|
return True
|
||||||
self.notifier.new_message_for_addr(addr, self.metadata)
|
case ["priv", _, "messagenew"]:
|
||||||
return True
|
self.notifier.new_message_for_addr(addr, self.metadata)
|
||||||
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -133,6 +169,7 @@ def main():
|
|||||||
config = read_config(config_path)
|
config = read_config(config_path)
|
||||||
iroh_relay = config.iroh_relay
|
iroh_relay = config.iroh_relay
|
||||||
mail_domain = config.mail_domain
|
mail_domain = config.mail_domain
|
||||||
|
socket_path = config.turn_socket_path
|
||||||
|
|
||||||
vmail_dir = config.mailboxes_dir
|
vmail_dir = config.mailboxes_dir
|
||||||
if not vmail_dir.exists():
|
if not vmail_dir.exists():
|
||||||
@@ -150,6 +187,7 @@ def main():
|
|||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
iroh_relay=iroh_relay,
|
iroh_relay=iroh_relay,
|
||||||
turn_hostname=mail_domain,
|
turn_hostname=mail_domain,
|
||||||
|
turn_socket_path=socket_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
dictproxy.serve_forever_from_socket(socket)
|
dictproxy.serve_forever_from_socket(socket)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
"""CGI script for creating new accounts."""
|
"""CGI script for creating new accounts."""
|
||||||
|
|
||||||
import ipaddress
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@@ -15,16 +14,6 @@ ALPHANUMERIC = string.ascii_lowercase + string.digits
|
|||||||
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
||||||
|
|
||||||
|
|
||||||
def wrap_ip(host):
|
|
||||||
if host.startswith("[") and host.endswith("]"):
|
|
||||||
return host
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(host)
|
|
||||||
return f"[{host}]"
|
|
||||||
except ValueError:
|
|
||||||
return host
|
|
||||||
|
|
||||||
|
|
||||||
def create_newemail_dict(config: Config):
|
def create_newemail_dict(config: Config):
|
||||||
user = "".join(
|
user = "".join(
|
||||||
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
secrets.choice(ALPHANUMERIC) for _ in range(config.username_max_length)
|
||||||
@@ -33,16 +22,22 @@ def create_newemail_dict(config: Config):
|
|||||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||||
for _ in range(config.password_min_length + 3)
|
for _ in range(config.password_min_length + 3)
|
||||||
)
|
)
|
||||||
return dict(email=f"{user}@{wrap_ip(config.mail_domain)}", password=f"{password}")
|
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
|
||||||
|
|
||||||
|
|
||||||
def create_dclogin_url(email, password):
|
def create_dclogin_url(config, email, password):
|
||||||
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
|
"""Build a dclogin: URL with credentials and self-signed cert acceptance.
|
||||||
|
|
||||||
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
|
Uses ic=3 (AcceptInvalidCertificates) so chatmail clients
|
||||||
can connect to servers with self-signed TLS certificates.
|
can connect to servers with self-signed TLS certificates.
|
||||||
"""
|
"""
|
||||||
return f"dclogin:{quote(email, safe='@')}?p={quote(password, safe='')}&v=1&ic=3"
|
if config.ipv4_relay:
|
||||||
|
imap_host = "&ih=" + config.ipv4_relay
|
||||||
|
smtp_host = "&sh=" + config.ipv4_relay
|
||||||
|
else:
|
||||||
|
imap_host = ""
|
||||||
|
smtp_host = ""
|
||||||
|
return f"dclogin:{quote(email, safe='@[]')}?p={quote(password, safe='')}&v=1{imap_host}{smtp_host}&ic=3"
|
||||||
|
|
||||||
|
|
||||||
def print_new_account():
|
def print_new_account():
|
||||||
@@ -51,7 +46,9 @@ def print_new_account():
|
|||||||
|
|
||||||
result = dict(email=creds["email"], password=creds["password"])
|
result = dict(email=creds["email"], password=creds["password"])
|
||||||
if config.tls_cert_mode == "self":
|
if config.tls_cert_mode == "self":
|
||||||
result["dclogin_url"] = create_dclogin_url(creds["email"], creds["password"])
|
result["dclogin_url"] = create_dclogin_url(
|
||||||
|
config, creds["email"], creds["password"]
|
||||||
|
)
|
||||||
|
|
||||||
print("Content-Type: application/json")
|
print("Content-Type: application/json")
|
||||||
print("")
|
print("")
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Detect whether the system is at its limits."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
MB = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def read_value(getter):
|
||||||
|
try:
|
||||||
|
return getter()
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning("ignoring unreadable system limit: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_sufficient_resources(config):
|
||||||
|
"""Return False if load, memory or disk exceeds a configured limit."""
|
||||||
|
load = read_value(lambda: psutil.getloadavg()[0])
|
||||||
|
mem = read_value(lambda: psutil.virtual_memory().available // MB)
|
||||||
|
disk = read_value(lambda: psutil.disk_usage(str(config.mailboxes_dir)).free // MB)
|
||||||
|
if load is not None and load > config.max_load_1m:
|
||||||
|
msg = f"load avg {load:.2f} > {config.max_load_1m:.2f}"
|
||||||
|
elif mem is not None and mem < config.min_available_memory_mb:
|
||||||
|
msg = f"available memory {mem}MB < {config.min_available_memory_mb}MB"
|
||||||
|
elif disk is not None and disk < config.min_free_disk_space_mb:
|
||||||
|
msg = f"free disk {disk}MB < {config.min_free_disk_space_mb}MB"
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
logging.warning("registration rejected: %s", msg)
|
||||||
|
return False
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Opt in to the chatmaild fixtures, which are not registered globally."""
|
||||||
|
|
||||||
|
from chatmaild.tests.plugin import * # noqa: F403
|
||||||
@@ -20,6 +20,10 @@ def make_config(tmp_path):
|
|||||||
basedir.mkdir(parents=True, exist_ok=True)
|
basedir.mkdir(parents=True, exist_ok=True)
|
||||||
overrides = settings.copy() if settings else {}
|
overrides = settings.copy() if settings else {}
|
||||||
overrides["mailboxes_dir"] = str(basedir)
|
overrides["mailboxes_dir"] = str(basedir)
|
||||||
|
# permissive resource limits so tests never depend on host load/memory/disk
|
||||||
|
overrides.setdefault("max_load_1m", "99999")
|
||||||
|
overrides.setdefault("min_available_memory", "0")
|
||||||
|
overrides.setdefault("min_free_disk_space", "0")
|
||||||
write_initial_config(inipath, mail_domain, overrides=overrides)
|
write_initial_config(inipath, mail_domain, overrides=overrides)
|
||||||
return read_config(inipath)
|
return read_config(inipath)
|
||||||
|
|
||||||
@@ -32,22 +36,27 @@ def example_config(make_config):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def maildomain(example_config):
|
def ipv4_config(make_config):
|
||||||
|
return make_config("1.3.3.7")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def example_maildomain(example_config):
|
||||||
return example_config.mail_domain
|
return example_config.mail_domain
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def testaddr(maildomain):
|
def testaddr(example_maildomain):
|
||||||
return f"user.name@{maildomain}"
|
return f"user.name@{example_maildomain}"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def gencreds(maildomain):
|
def example_gencreds(example_maildomain):
|
||||||
count = itertools.count()
|
count = itertools.count()
|
||||||
next(count)
|
next(count)
|
||||||
|
|
||||||
def gen(domain=None):
|
def gen(domain=None):
|
||||||
domain = domain if domain else maildomain
|
domain = domain if domain else example_maildomain
|
||||||
while 1:
|
while 1:
|
||||||
num = next(count)
|
num = next(count)
|
||||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chatmaild.metadata import MetadataDictProxy
|
||||||
|
|
||||||
|
ALLOWED_URL_PREFIXES = (
|
||||||
|
"https://github.com/deltachat/",
|
||||||
|
"https://download.delta.chat/",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def check_string(value):
|
||||||
|
assert isinstance(value, str), value
|
||||||
|
assert value
|
||||||
|
|
||||||
|
|
||||||
|
def check_version_integer(value):
|
||||||
|
# core parses this as u32, see https://github.com/chatmail/core/pull/8557
|
||||||
|
assert isinstance(value, int) and not isinstance(value, bool), value
|
||||||
|
assert 0 <= value < 2**32, value
|
||||||
|
|
||||||
|
|
||||||
|
def check_appversions(data):
|
||||||
|
"""Verifies the file the way core parses it.
|
||||||
|
|
||||||
|
core deserializes into typed structs and drops the whole payload
|
||||||
|
of a relay if a single value has an unexpected type,
|
||||||
|
while missing or misspelled keys silently turn into defaults.
|
||||||
|
"""
|
||||||
|
assert set(data) == {"clients"}, data
|
||||||
|
assert isinstance(data["clients"], list)
|
||||||
|
assert data["clients"]
|
||||||
|
client_ids = []
|
||||||
|
for client in data["clients"]:
|
||||||
|
assert set(client) == {"clientId", "sources"}, client
|
||||||
|
check_string(client["clientId"])
|
||||||
|
client_ids.append(client["clientId"])
|
||||||
|
assert isinstance(client["sources"], list)
|
||||||
|
assert client["sources"]
|
||||||
|
source_ids = []
|
||||||
|
for source in client["sources"]:
|
||||||
|
assert set(source) == {
|
||||||
|
"sourceId",
|
||||||
|
"versionInteger",
|
||||||
|
"versionString",
|
||||||
|
"downloadUrl",
|
||||||
|
}, source
|
||||||
|
check_string(source["sourceId"])
|
||||||
|
source_ids.append(source["sourceId"])
|
||||||
|
check_version_integer(source["versionInteger"])
|
||||||
|
check_string(source["versionString"])
|
||||||
|
check_string(source["downloadUrl"])
|
||||||
|
assert source["downloadUrl"].startswith(ALLOWED_URL_PREFIXES)
|
||||||
|
# core takes the first matching source, later duplicates never surface
|
||||||
|
assert len(set(source_ids)) == len(source_ids), source_ids
|
||||||
|
assert len(set(client_ids)) == len(client_ids), client_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def appversions():
|
||||||
|
# check the file which chatmail-metadata actually serves
|
||||||
|
path = MetadataDictProxy(notifier=None, metadata=None).appversions_path
|
||||||
|
return json.loads(path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def test_appversions_schema(appversions):
|
||||||
|
check_appversions(appversions)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", [True, -1, 2**32, "754", 754.0, None])
|
||||||
|
def test_version_integer_rejected(appversions, value):
|
||||||
|
appversions["clients"][0]["sources"][0]["versionInteger"] = value
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
check_appversions(appversions)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("key", ["clientId", "sources"])
|
||||||
|
def test_misspelled_client_key_rejected(appversions, key):
|
||||||
|
client = appversions["clients"][0]
|
||||||
|
client[key + "s"] = client.pop(key)
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
check_appversions(appversions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_source_id_rejected(appversions):
|
||||||
|
sources = appversions["clients"][0]["sources"]
|
||||||
|
sources.append(dict(sources[0]))
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
check_appversions(appversions)
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_download_url_rejected(appversions):
|
||||||
|
appversions["clients"][0]["sources"][0]["downloadUrl"] = "https://example.org/x.apk"
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
check_appversions(appversions)
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from chatmaild.config import parse_size_mb, read_config
|
from chatmaild.config import (
|
||||||
|
is_valid_ipv4,
|
||||||
|
parse_size_mb,
|
||||||
|
read_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_read_config_basic(example_config):
|
def test_read_config_basic(example_config):
|
||||||
@@ -9,38 +13,46 @@ def test_read_config_basic(example_config):
|
|||||||
assert not example_config.privacy_pdo and not example_config.privacy_postal
|
assert not example_config.privacy_pdo and not example_config.privacy_postal
|
||||||
|
|
||||||
inipath = example_config._inipath
|
inipath = example_config._inipath
|
||||||
inipath.write_text(inipath.read_text().replace("60", "37"))
|
inipath.write_text(
|
||||||
|
inipath.read_text().replace(
|
||||||
|
"#max_user_send_per_minute = 60",
|
||||||
|
"max_user_send_per_minute = 37",
|
||||||
|
)
|
||||||
|
)
|
||||||
example_config = read_config(inipath)
|
example_config = read_config(inipath)
|
||||||
assert example_config.max_user_send_per_minute == 37
|
assert example_config.max_user_send_per_minute == 37
|
||||||
assert example_config.mail_domain == "chat.example.org"
|
assert example_config.mail_domain == "chat.example.org"
|
||||||
|
assert example_config.ipv4_relay is None
|
||||||
|
|
||||||
|
|
||||||
def test_read_config_basic_using_defaults(tmp_path, maildomain):
|
def test_read_config_ipv4(ipv4_config):
|
||||||
|
assert ipv4_config.ipv4_relay == "1.3.3.7"
|
||||||
|
assert ipv4_config.mail_domain == "[1.3.3.7]"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_config_basic_using_defaults(tmp_path, example_maildomain):
|
||||||
inipath = tmp_path.joinpath("chatmail.ini")
|
inipath = tmp_path.joinpath("chatmail.ini")
|
||||||
inipath.write_text(f"[params]\nmail_domain = {maildomain}")
|
inipath.write_text(f"[params]\nmail_domain = {example_maildomain}")
|
||||||
example_config = read_config(inipath)
|
example_config = read_config(inipath)
|
||||||
assert example_config.max_user_send_per_minute == 60
|
assert example_config.max_user_send_per_minute == 60
|
||||||
assert example_config.filtermail_smtp_port_incoming == 10081
|
assert example_config.filtermail_smtp_port_incoming == 10081
|
||||||
|
assert example_config.filtermail_smtp_port == 10080
|
||||||
|
assert example_config.postfix_reinject_port == 10025
|
||||||
|
assert example_config.max_user_send_per_minute == 60
|
||||||
|
assert example_config.max_mailbox_size == "500M"
|
||||||
|
assert example_config.delete_mails_after == "20"
|
||||||
|
assert example_config.delete_large_after == "7"
|
||||||
|
assert example_config.username_min_length == 9
|
||||||
|
assert example_config.username_max_length == 9
|
||||||
|
assert example_config.password_min_length == 9
|
||||||
|
assert example_config.max_imap_connections == 10000
|
||||||
|
assert example_config.max_smtp_connections == 1000
|
||||||
|
assert example_config._unused_keys == []
|
||||||
|
|
||||||
|
|
||||||
def test_read_config_testrun(make_config):
|
def test_config_unused_keys(make_config):
|
||||||
config = make_config("something.testrun.org")
|
config = make_config("chat.example.org", {"passthrough_senders": "x@y.org"})
|
||||||
assert config.mail_domain == "something.testrun.org"
|
assert config._unused_keys == ["passthrough_senders"]
|
||||||
assert len(config.privacy_postal.split("\n")) > 1
|
|
||||||
assert len(config.privacy_supervisor.split("\n")) > 1
|
|
||||||
assert len(config.privacy_pdo.split("\n")) > 1
|
|
||||||
assert config.privacy_mail == "privacy@testrun.org"
|
|
||||||
assert config.filtermail_smtp_port == 10080
|
|
||||||
assert config.postfix_reinject_port == 10025
|
|
||||||
assert config.max_user_send_per_minute == 60
|
|
||||||
assert config.max_mailbox_size == "500M"
|
|
||||||
assert config.delete_mails_after == "20"
|
|
||||||
assert config.delete_large_after == "7"
|
|
||||||
assert config.username_min_length == 9
|
|
||||||
assert config.username_max_length == 9
|
|
||||||
assert config.password_min_length == 9
|
|
||||||
assert "privacy@testrun.org" in config.passthrough_recipients
|
|
||||||
assert config.passthrough_senders == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_userstate_paths(make_config, tmp_path):
|
def test_config_userstate_paths(make_config, tmp_path):
|
||||||
@@ -135,3 +147,17 @@ def test_max_mailbox_size_mb(make_config):
|
|||||||
config = make_config("chat.example.org")
|
config = make_config("chat.example.org")
|
||||||
assert config.max_mailbox_size == "500M"
|
assert config.max_mailbox_size == "500M"
|
||||||
assert config.max_mailbox_size_mb == 500
|
assert config.max_mailbox_size_mb == 500
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
["input", "result"],
|
||||||
|
[
|
||||||
|
("example.org", False),
|
||||||
|
("1.3.3.7", True),
|
||||||
|
("fe::1", False),
|
||||||
|
("ad.1e.dag.adf", False),
|
||||||
|
("12394142", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_valid_ipv4(input, result):
|
||||||
|
assert result == is_valid_ipv4(input)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from chatmaild.doveauth import AuthDictProxy
|
from chatmaild.doveauth import DoveAuth
|
||||||
from chatmaild.expire import daily_expire_main as main_expire
|
from chatmaild.expire import daily_expire_main as main_expire
|
||||||
|
|
||||||
|
|
||||||
@@ -18,10 +18,10 @@ def test_login_timestamps(example_config):
|
|||||||
def test_delete_inactive_users(example_config):
|
def test_delete_inactive_users(example_config):
|
||||||
new = time.time()
|
new = time.time()
|
||||||
old = new - (example_config.delete_inactive_users_after * 86400) - 1
|
old = new - (example_config.delete_inactive_users_after * 86400) - 1
|
||||||
dictproxy = AuthDictProxy(example_config)
|
doveauth = DoveAuth(example_config)
|
||||||
|
|
||||||
def create_user(addr, last_login):
|
def create_user(addr, last_login):
|
||||||
dictproxy.lookup_passdb(addr, "q9mr3faue")
|
doveauth.create_user(addr, "q9mr3faue")
|
||||||
user = example_config.get_user(addr)
|
user = example_config.get_user(addr)
|
||||||
user.maildir.joinpath("cur").mkdir()
|
user.maildir.joinpath("cur").mkdir()
|
||||||
user.maildir.joinpath("cur", "something").mkdir()
|
user.maildir.joinpath("cur", "something").mkdir()
|
||||||
|
|||||||
@@ -1,42 +1,37 @@
|
|||||||
import io
|
import http.client
|
||||||
import json
|
|
||||||
import queue
|
|
||||||
import threading
|
import threading
|
||||||
import traceback
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import chatmaild.doveauth
|
import chatmaild.doveauth
|
||||||
from chatmaild.doveauth import (
|
from chatmaild.doveauth import (
|
||||||
AuthDictProxy,
|
CreateHandler,
|
||||||
|
DoveAuth,
|
||||||
|
DoveAuthServer,
|
||||||
is_allowed_to_create,
|
is_allowed_to_create,
|
||||||
)
|
)
|
||||||
from chatmaild.newemail import create_newemail_dict
|
from chatmaild.newemail import create_newemail_dict
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def dictproxy(example_config):
|
def doveauth(example_config):
|
||||||
return AuthDictProxy(config=example_config)
|
return DoveAuth(example_config)
|
||||||
|
|
||||||
|
|
||||||
def test_basic(dictproxy, gencreds):
|
def stored_hash(config, addr):
|
||||||
addr, password = gencreds()
|
return config.get_user(addr).get_password_hash()
|
||||||
dictproxy.lookup_passdb(addr, password)
|
|
||||||
data = dictproxy.lookup_userdb(addr)
|
|
||||||
assert data
|
|
||||||
data2 = dictproxy.lookup_passdb(addr, password)
|
|
||||||
assert data == data2
|
|
||||||
|
|
||||||
|
|
||||||
def test_iterate_addresses(dictproxy):
|
def test_basic(doveauth, example_config, example_gencreds):
|
||||||
addresses = []
|
addr, password = example_gencreds()
|
||||||
|
assert doveauth.create_user(addr, password)
|
||||||
|
passhash = stored_hash(example_config, addr)
|
||||||
|
assert passhash.startswith("{SHA512-CRYPT}")
|
||||||
|
|
||||||
for i in range(10):
|
# a second login verifies against the stored hash and rewrites nothing
|
||||||
addresses.append(f"asdf1234{i}@chat.example.org")
|
assert doveauth.create_user(addr, password)
|
||||||
dictproxy.lookup_passdb(addresses[-1], "q9mr3faue")
|
assert stored_hash(example_config, addr) == passhash
|
||||||
|
|
||||||
res = dictproxy.iter_userdb()
|
|
||||||
assert set(res) == set(addresses)
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_username_length(example_config):
|
def test_invalid_username_length(example_config):
|
||||||
@@ -53,75 +48,32 @@ def test_invalid_username_length(example_config):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_dont_overwrite_password_on_wrong_login(dictproxy):
|
def test_dont_overwrite_password_on_wrong_login(doveauth, example_config):
|
||||||
"""Test that logging in with a different password doesn't create a new user"""
|
addr = "newuser12@chat.example.org"
|
||||||
res = dictproxy.lookup_passdb(
|
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
|
||||||
"newuser12@chat.example.org", "kajdlkajsldk12l3kj1983"
|
passhash = stored_hash(example_config, addr)
|
||||||
)
|
|
||||||
assert res["password"]
|
assert not doveauth.create_user(addr, "kajdslqwe")
|
||||||
res2 = dictproxy.lookup_passdb("newuser12@chat.example.org", "kajdslqwe")
|
assert stored_hash(example_config, addr) == passhash
|
||||||
# this function always returns a password hash, which is actually compared by dovecot.
|
|
||||||
assert res["password"] == res2["password"]
|
assert doveauth.create_user(addr, "kajdlkajsldk12l3kj1983")
|
||||||
|
assert stored_hash(example_config, addr) == passhash
|
||||||
|
|
||||||
|
|
||||||
def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
|
def test_foreign_domain_is_refused(doveauth):
|
||||||
|
assert not doveauth.create_user("newuser12@evil.example.org", "qlwkejqlwe12")
|
||||||
|
|
||||||
|
|
||||||
|
def test_nocreate_file(monkeypatch, tmpdir, doveauth, example_config):
|
||||||
p = tmpdir.join("nocreate")
|
p = tmpdir.join("nocreate")
|
||||||
p.write("")
|
p.write("")
|
||||||
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
|
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
|
||||||
dictproxy.lookup_passdb("newuser12@chat.example.org", "zequ0Aimuchoodaechik")
|
addr = "newuser12@chat.example.org"
|
||||||
assert not dictproxy.lookup_userdb("newuser12@chat.example.org")
|
assert not doveauth.create_user(addr, "zequ0Aimuchoodaechik")
|
||||||
|
assert stored_hash(example_config, addr) is None
|
||||||
|
|
||||||
def test_handle_dovecot_request(dictproxy):
|
|
||||||
transactions = {}
|
|
||||||
# Test that password can contain ", ', \ and /
|
|
||||||
msg = (
|
|
||||||
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
|
|
||||||
"some42123@chat.example.org\tsome42123@chat.example.org"
|
|
||||||
)
|
|
||||||
res = dictproxy.handle_dovecot_request(msg, transactions)
|
|
||||||
assert res
|
|
||||||
assert res[0] == "O" and res.endswith("\n")
|
|
||||||
userdata = json.loads(res[1:].strip())
|
|
||||||
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
|
|
||||||
assert userdata["uid"] == userdata["gid"] == "vmail"
|
|
||||||
assert userdata["password"].startswith("{SHA512-CRYPT}")
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_protocol_hello_is_skipped(example_config, caplog):
|
|
||||||
dictproxy = AuthDictProxy(config=example_config)
|
|
||||||
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
|
|
||||||
wfile = io.BytesIO()
|
|
||||||
dictproxy.loop_forever(rfile, wfile)
|
|
||||||
assert wfile.getvalue() == b""
|
|
||||||
assert not caplog.messages
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_protocol_user_not_exists(example_config):
|
|
||||||
dictproxy = AuthDictProxy(config=example_config)
|
|
||||||
rfile = io.BytesIO(
|
|
||||||
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
|
|
||||||
)
|
|
||||||
wfile = io.BytesIO()
|
|
||||||
dictproxy.loop_forever(rfile, wfile)
|
|
||||||
assert wfile.getvalue() == b"N\n"
|
|
||||||
|
|
||||||
|
|
||||||
def test_handle_dovecot_protocol_iterate(gencreds, example_config):
|
|
||||||
dictproxy = AuthDictProxy(config=example_config)
|
|
||||||
dictproxy.lookup_passdb("asdf00000@chat.example.org", "q9mr3faue")
|
|
||||||
dictproxy.lookup_passdb("asdf11111@chat.example.org", "q9mr3faue")
|
|
||||||
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
|
|
||||||
wfile = io.BytesIO()
|
|
||||||
dictproxy.loop_forever(rfile, wfile)
|
|
||||||
lines = wfile.getvalue().decode("ascii").split("\n")
|
|
||||||
assert "Oshared/userdb/asdf00000@chat.example.org\t" in lines
|
|
||||||
assert "Oshared/userdb/asdf11111@chat.example.org\t" in lines
|
|
||||||
assert not lines[2]
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_localpart_characters(make_config):
|
def test_invalid_localpart_characters(make_config):
|
||||||
"""Test that is_allowed_to_create rejects localparts with invalid characters."""
|
|
||||||
config = make_config("chat.example.org", {"username_min_length": "3"})
|
config = make_config("chat.example.org", {"username_min_length": "3"})
|
||||||
password = "zequ0Aimuchoodaechik"
|
password = "zequ0Aimuchoodaechik"
|
||||||
domain = config.mail_domain
|
domain = config.mail_domain
|
||||||
@@ -141,64 +93,150 @@ def test_invalid_localpart_characters(make_config):
|
|||||||
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
|
assert not is_allowed_to_create(config, f"ab@cdef@{domain}", password)
|
||||||
assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
|
assert not is_allowed_to_create(config, f"abc/def@{domain}", password)
|
||||||
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
|
assert not is_allowed_to_create(config, f"abc\\def@{domain}", password)
|
||||||
|
assert not is_allowed_to_create(config, f"üser123@{domain}", password)
|
||||||
|
|
||||||
|
|
||||||
def test_concurrent_creation_same_account(dictproxy):
|
def test_concurrent_creation_same_account(doveauth, example_config, capsys):
|
||||||
"""Test that concurrent creation of the same account doesn't corrupt password."""
|
|
||||||
addr = "racetest1@chat.example.org"
|
addr = "racetest1@chat.example.org"
|
||||||
password = "zequ0Aimuchoodaechik"
|
password = "zequ0Aimuchoodaechik"
|
||||||
num_threads = 10
|
|
||||||
results = queue.Queue()
|
|
||||||
|
|
||||||
def create():
|
def create(_):
|
||||||
try:
|
ok = doveauth.create_user(addr, password)
|
||||||
res = dictproxy.lookup_passdb(addr, password)
|
return ok, stored_hash(example_config, addr)
|
||||||
results.put(("ok", res))
|
|
||||||
except Exception:
|
|
||||||
results.put(("err", traceback.format_exc()))
|
|
||||||
|
|
||||||
threads = [threading.Thread(target=create, daemon=True) for _ in range(num_threads)]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join(timeout=10)
|
|
||||||
|
|
||||||
passwords_seen = set()
|
|
||||||
for _ in range(num_threads):
|
|
||||||
status, res = results.get()
|
|
||||||
if status == "err":
|
|
||||||
pytest.fail(f"concurrent creation failed\n{res}")
|
|
||||||
passwords_seen.add(res["password"])
|
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(10) as pool:
|
||||||
|
results = list(pool.map(create, range(10)))
|
||||||
|
assert all(ok for ok, _ in results)
|
||||||
# all threads must see the same password hash
|
# all threads must see the same password hash
|
||||||
assert len(passwords_seen) == 1
|
assert len({passhash for _, passhash in results}) == 1
|
||||||
|
assert capsys.readouterr().err.count("Created address:") == 1
|
||||||
|
|
||||||
|
|
||||||
def test_50_concurrent_lookups_different_accounts(gencreds, dictproxy):
|
def test_insufficient_resources_block_creation_not_existing_logins(
|
||||||
num_threads = 50
|
doveauth, example_gencreds, monkeypatch
|
||||||
req_per_thread = 5
|
):
|
||||||
results = queue.Queue()
|
addr, password = example_gencreds()
|
||||||
|
assert doveauth.create_user(addr, password)
|
||||||
|
|
||||||
def lookup():
|
monkeypatch.setattr(
|
||||||
for i in range(req_per_thread):
|
chatmaild.doveauth, "has_sufficient_resources", lambda config: False
|
||||||
addr, password = gencreds()
|
)
|
||||||
|
newaddr, newpassword = example_gencreds()
|
||||||
|
assert not doveauth.create_user(newaddr, newpassword)
|
||||||
|
assert doveauth.create_user(addr, password)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHttpPost:
|
||||||
|
@pytest.fixture
|
||||||
|
def doveauth_server(self, example_config):
|
||||||
|
server = DoveAuthServer(example_config, port=0)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
yield f"127.0.0.1:{server.server_address[1]}"
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def post(self, doveauth_server):
|
||||||
|
def post(path, data):
|
||||||
|
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
|
||||||
try:
|
try:
|
||||||
dictproxy.lookup_passdb(addr, password)
|
return self.post_on(conn, path, data).status
|
||||||
except Exception:
|
finally:
|
||||||
results.put(traceback.format_exc())
|
conn.close()
|
||||||
else:
|
|
||||||
results.put(None)
|
|
||||||
|
|
||||||
threads = []
|
return post
|
||||||
for i in range(num_threads):
|
|
||||||
thread = threading.Thread(target=lookup, daemon=True)
|
|
||||||
threads.append(thread)
|
|
||||||
|
|
||||||
print(f"created {num_threads} threads, starting them and waiting for results")
|
@pytest.fixture
|
||||||
for thread in threads:
|
def connection(self, doveauth_server):
|
||||||
thread.start()
|
"""One kept-alive connection, which is all dovecot's HTTP client opens."""
|
||||||
|
conn = http.client.HTTPConnection(doveauth_server, timeout=10)
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
for i in range(num_threads * req_per_thread):
|
@staticmethod
|
||||||
res = results.get()
|
def post_on(conn, path, data):
|
||||||
if res is not None:
|
conn.request("POST", path, body=data)
|
||||||
pytest.fail(f"concurrent lookup failed\n{res}")
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def test_create_and_verify(self, post, example_config, example_gencreds):
|
||||||
|
addr, password = example_gencreds()
|
||||||
|
assert post("/create", f"{addr}\t{password}".encode()) == 200
|
||||||
|
assert stored_hash(example_config, addr).startswith("{SHA512-CRYPT}")
|
||||||
|
|
||||||
|
# second login with the same password verifies, a wrong one is refused
|
||||||
|
assert post("/create", f"{addr}\t{password}".encode()) == 200
|
||||||
|
assert post("/create", f"{addr}\twrong{password}".encode()) == 403
|
||||||
|
|
||||||
|
def test_password_special_chars_survive_transport(self, post, example_gencreds):
|
||||||
|
addr, _ = example_gencreds()
|
||||||
|
password = "laksjdlaksjdlak\\sjdlk\"12j'3l1/k2\tj3123"
|
||||||
|
body = f"{addr}\t{password}".encode()
|
||||||
|
assert post("/create", body) == 200
|
||||||
|
assert post("/create", body) == 200
|
||||||
|
assert post("/create", f"{addr}\totherpassword1".encode()) == 403
|
||||||
|
|
||||||
|
def test_password_must_be_utf8(self, post, example_gencreds):
|
||||||
|
addr, _ = example_gencreds()
|
||||||
|
assert post("/create", f"{addr}\tpässwort12".encode()) == 200
|
||||||
|
assert post("/create", addr.encode() + b"\tp\xe4sswort12") == 400
|
||||||
|
|
||||||
|
def test_nul_is_refused_before_crypt_sees_it(self, post, example_gencreds):
|
||||||
|
addr, _ = example_gencreds()
|
||||||
|
assert post("/create", f"{addr}\tpass\0word12".encode()) == 400
|
||||||
|
assert (
|
||||||
|
post("/create", "us\0er12345@chat.example.org\tlongenough1".encode()) == 400
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refused_creation(self, post, example_gencreds):
|
||||||
|
addr, _ = example_gencreds()
|
||||||
|
assert post("/create", f"{addr}\tshort".encode()) == 403
|
||||||
|
assert post("/create", b"not-an-address\tlongenoughpassword") == 403
|
||||||
|
body = "bürger123@chat.example.org\tlongenoughpw".encode()
|
||||||
|
assert post("/create", body) == 403
|
||||||
|
assert post("/create", b"") == 403
|
||||||
|
|
||||||
|
def test_body_length_limit(self, post, example_gencreds):
|
||||||
|
addr, _ = example_gencreds()
|
||||||
|
fill = CreateHandler.max_body_len - len(addr) - len("\t")
|
||||||
|
body = f"{addr}\t{'x' * fill}".encode()
|
||||||
|
assert len(body) == CreateHandler.max_body_len
|
||||||
|
assert post("/create", body) == 200
|
||||||
|
|
||||||
|
body = f"{addr}\t{'x' * (fill + 1)}".encode()
|
||||||
|
assert len(body) == CreateHandler.max_body_len + 1
|
||||||
|
assert post("/create", body) == 400
|
||||||
|
|
||||||
|
def test_connection_is_reused_across_200_replies(
|
||||||
|
self, connection, example_gencreds
|
||||||
|
):
|
||||||
|
addr, password = example_gencreds()
|
||||||
|
body = f"{addr}\t{password}".encode()
|
||||||
|
# create, then verify the same password, on one connection
|
||||||
|
for _ in range(2):
|
||||||
|
resp = self.post_on(connection, "/create", body)
|
||||||
|
assert (resp.status, resp.will_close) == (200, False)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"path,data,status",
|
||||||
|
[
|
||||||
|
("/other", b"not read", 404),
|
||||||
|
("/create", b"x" * (CreateHandler.max_body_len + 1), 400),
|
||||||
|
("/create", b"not-an-address\tlongenoughpassword", 403),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_error_replies_close_the_connection(self, connection, path, data, status):
|
||||||
|
resp = self.post_on(connection, path, data)
|
||||||
|
assert (resp.status, resp.will_close) == (status, True)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("content_length", [None, "-1", "notanumber", "999999"])
|
||||||
|
def test_bad_content_length(self, connection, content_length):
|
||||||
|
# the fixture timeout turns a server that waits for the body into a failure
|
||||||
|
connection.putrequest("POST", "/create", skip_accept_encoding=True)
|
||||||
|
if content_length is not None:
|
||||||
|
connection.putheader("Content-Length", content_length)
|
||||||
|
connection.endheaders()
|
||||||
|
resp = connection.getresponse()
|
||||||
|
assert resp.status == 400
|
||||||
|
assert resp.will_close
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
from fnmatch import fnmatch
|
from fnmatch import fnmatch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from chatmaild.expire import (
|
from chatmaild.expire import (
|
||||||
|
Expiry,
|
||||||
FileEntry,
|
FileEntry,
|
||||||
MailboxStat,
|
MailboxStat,
|
||||||
expire_to_target,
|
expire_to_target,
|
||||||
@@ -39,7 +40,7 @@ def fill_mbox(folderdir):
|
|||||||
|
|
||||||
|
|
||||||
def create_new_messages(basedir, relpaths, size=1000, days=0):
|
def create_new_messages(basedir, relpaths, size=1000, days=0):
|
||||||
now = datetime.utcnow().timestamp()
|
now = time.time()
|
||||||
|
|
||||||
for relpath in relpaths:
|
for relpath in relpaths:
|
||||||
msg_path = Path(basedir).joinpath(relpath)
|
msg_path = Path(basedir).joinpath(relpath)
|
||||||
@@ -104,6 +105,30 @@ def test_stats_mailbox(mbox1):
|
|||||||
assert mbox3.last_login is None
|
assert mbox3.last_login is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mbox_without_password(mbox1, example_config, capsys):
|
||||||
|
password = Path(mbox1.basedir).joinpath("password")
|
||||||
|
os.remove(password)
|
||||||
|
mbox_rescan = MailboxStat(mbox1.basedir)
|
||||||
|
assert mbox_rescan.last_login is None
|
||||||
|
|
||||||
|
exp = Expiry(example_config, dry=False, now=time.time(), verbose=False)
|
||||||
|
exp.process_mailbox_stat(mbox_rescan)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert "doesn't have last_login but isn't empty" in err
|
||||||
|
assert os.path.isdir(mbox_rescan.basedir)
|
||||||
|
|
||||||
|
for entry in os.scandir(mbox_rescan.basedir):
|
||||||
|
if os.path.isdir(entry):
|
||||||
|
shutil.rmtree(entry)
|
||||||
|
else:
|
||||||
|
os.remove(entry)
|
||||||
|
|
||||||
|
exp.process_mailbox_stat(mbox_rescan)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert "doesn't have last_login but isn't empty" not in err
|
||||||
|
assert not os.path.isdir(mbox_rescan.basedir)
|
||||||
|
|
||||||
|
|
||||||
def test_report_no_mailboxes(example_config):
|
def test_report_no_mailboxes(example_config):
|
||||||
args = (str(example_config._inipath),)
|
args = (str(example_config._inipath),)
|
||||||
report_main(args)
|
report_main(args)
|
||||||
@@ -124,7 +149,7 @@ def test_report_mdir_filters_by_path(mbox1, example_config):
|
|||||||
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
|
"""Test that Report with mdir='cur' only counts messages in cur/ subdirectory."""
|
||||||
from chatmaild.fsreport import Report
|
from chatmaild.fsreport import Report
|
||||||
|
|
||||||
now = datetime.utcnow().timestamp()
|
now = time.time()
|
||||||
|
|
||||||
# Set password mtime to old enough so min_login_age check passes
|
# Set password mtime to old enough so min_login_age check passes
|
||||||
password = Path(mbox1.basedir).joinpath("password")
|
password = Path(mbox1.basedir).joinpath("password")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from chatmaild.doveauth import AuthDictProxy
|
from chatmaild.doveauth import DoveAuth
|
||||||
from chatmaild.lastlogin import (
|
from chatmaild.lastlogin import (
|
||||||
LastLoginDictProxy,
|
LastLoginDictProxy,
|
||||||
)
|
)
|
||||||
@@ -9,8 +9,8 @@ from chatmaild.lastlogin import (
|
|||||||
def test_handle_dovecot_request_last_login(testaddr, example_config):
|
def test_handle_dovecot_request_last_login(testaddr, example_config):
|
||||||
dictproxy = LastLoginDictProxy(config=example_config)
|
dictproxy = LastLoginDictProxy(config=example_config)
|
||||||
|
|
||||||
authproxy = AuthDictProxy(config=example_config)
|
doveauth = DoveAuth(example_config)
|
||||||
authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123")
|
doveauth.create_user(testaddr, "1l2k3j1l2k3jl123")
|
||||||
|
|
||||||
dictproxy_transactions = {}
|
dictproxy_transactions = {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -7,6 +8,7 @@ import requests
|
|||||||
from chatmaild.metadata import (
|
from chatmaild.metadata import (
|
||||||
Metadata,
|
Metadata,
|
||||||
MetadataDictProxy,
|
MetadataDictProxy,
|
||||||
|
read_appversions,
|
||||||
)
|
)
|
||||||
from chatmaild.notifier import (
|
from chatmaild.notifier import (
|
||||||
Notifier,
|
Notifier,
|
||||||
@@ -324,7 +326,7 @@ def test_turn_credentials_exception_returns_N(notifier, metadata, monkeypatch):
|
|||||||
turn_hostname="turn.example.org",
|
turn_hostname="turn.example.org",
|
||||||
)
|
)
|
||||||
|
|
||||||
def mock_turn_credentials():
|
def mock_turn_credentials(turn_socket_path):
|
||||||
raise ConnectionRefusedError("socket not available")
|
raise ConnectionRefusedError("socket not available")
|
||||||
|
|
||||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
|
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", mock_turn_credentials)
|
||||||
@@ -348,7 +350,9 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
|
|||||||
turn_hostname="turn.example.org",
|
turn_hostname="turn.example.org",
|
||||||
)
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(chatmaild.metadata, "turn_credentials", lambda: "user:pass")
|
monkeypatch.setattr(
|
||||||
|
chatmaild.metadata, "turn_credentials", lambda path: "user:pass"
|
||||||
|
)
|
||||||
|
|
||||||
transactions = {}
|
transactions = {}
|
||||||
res = dictproxy.handle_dovecot_request(
|
res = dictproxy.handle_dovecot_request(
|
||||||
@@ -360,15 +364,65 @@ def test_turn_credentials_success(notifier, metadata, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_iroh_relay(dictproxy):
|
def test_iroh_relay(dictproxy):
|
||||||
rfile = io.BytesIO(
|
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org"
|
||||||
b"\n".join(
|
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
|
||||||
[
|
|
||||||
b"H",
|
|
||||||
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
wfile = io.BytesIO()
|
|
||||||
dictproxy.iroh_relay = "https://example.org/"
|
dictproxy.iroh_relay = "https://example.org/"
|
||||||
dictproxy.loop_forever(rfile, wfile)
|
dictproxy.loop_forever(rfile, wfile)
|
||||||
assert wfile.getvalue() == b"Ohttps://example.org/\n"
|
assert wfile.getvalue() == b"Ohttps://example.org/\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_appversions(tmp_path):
|
||||||
|
path = tmp_path.joinpath("appversions.json")
|
||||||
|
assert read_appversions(path) is None
|
||||||
|
|
||||||
|
path.write_text('{\n "clients": []\n}')
|
||||||
|
assert read_appversions(path) == '{"clients":[]}'
|
||||||
|
|
||||||
|
# the value travels as a single dict protocol line
|
||||||
|
path.write_text('{"clients": [{"clientId": "one\\ntwo"}]}')
|
||||||
|
assert read_appversions(path) == '{"clients":[{"clientId":"one\\ntwo"}]}'
|
||||||
|
|
||||||
|
path.write_text("bad json")
|
||||||
|
assert read_appversions(path) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_appversions_lookup(dictproxy):
|
||||||
|
# the version information shipped with chatmaild is served as a single line
|
||||||
|
key = b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/appversions"
|
||||||
|
key += b"\tuser@example.org"
|
||||||
|
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
|
||||||
|
dictproxy.loop_forever(rfile, wfile)
|
||||||
|
value = wfile.getvalue()
|
||||||
|
assert value.startswith(b"O") and value.endswith(b"\n")
|
||||||
|
assert json.loads(value[1:])["clients"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_token_migration(metadata, testaddr):
|
||||||
|
with metadata.get_metadata_dict(testaddr).modify() as data:
|
||||||
|
data[metadata.DEVICETOKEN_KEY] = ["oldtoken1", "oldtoken2"]
|
||||||
|
|
||||||
|
assert metadata.get_tokens_for_addr(testaddr) == ["oldtoken1", "oldtoken2"]
|
||||||
|
mdict = metadata.get_metadata_dict(testaddr).read()
|
||||||
|
tokens = mdict[metadata.DEVICETOKEN_KEY]
|
||||||
|
assert isinstance(tokens, dict)
|
||||||
|
assert "oldtoken1" in tokens and "oldtoken2" in tokens
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"suffix, expected",
|
||||||
|
[
|
||||||
|
(b"vendor/deltachat/maxsmtprecipients", b"O1000\n"),
|
||||||
|
(b"wrong/prefix/key", b"N\n"),
|
||||||
|
(b"vendor/deltachat/unknown", b"N\n"),
|
||||||
|
],
|
||||||
|
ids=["maxsmtprecipients", "prefix_mismatch", "unknown_name"],
|
||||||
|
)
|
||||||
|
def test_shared_lookup(dictproxy, suffix, expected):
|
||||||
|
key = (
|
||||||
|
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/"
|
||||||
|
+ suffix
|
||||||
|
+ b"\tuser@example.org"
|
||||||
|
)
|
||||||
|
rfile, wfile = io.BytesIO(b"H\n" + key), io.BytesIO()
|
||||||
|
dictproxy.loop_forever(rfile, wfile)
|
||||||
|
assert wfile.getvalue() == expected
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ def test_migration(tmp_path, example_config, caplog):
|
|||||||
assert passdb_path.stat().st_size > 10000
|
assert passdb_path.stat().st_size > 10000
|
||||||
|
|
||||||
example_config.passdb_path = passdb_path
|
example_config.passdb_path = passdb_path
|
||||||
|
# ensure logging.info records are captured regardless of global configuration
|
||||||
|
caplog.set_level("INFO")
|
||||||
|
|
||||||
assert not caplog.records
|
assert not caplog.records
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ def test_migration(tmp_path, example_config, caplog):
|
|||||||
user = example_config.get_user(path.name)
|
user = example_config.get_user(path.name)
|
||||||
if last_login:
|
if last_login:
|
||||||
assert user.get_last_login_timestamp() == last_login
|
assert user.get_last_login_timestamp() == last_login
|
||||||
assert password == user.get_userdb_dict()["password"]
|
assert password == user.get_password_hash()
|
||||||
|
|
||||||
assert not all
|
assert not all
|
||||||
assert not example_config.passdb_path.exists()
|
assert not example_config.passdb_path.exists()
|
||||||
|
|||||||
@@ -19,25 +19,36 @@ def test_create_newemail_dict(example_config):
|
|||||||
assert ac1["password"] != ac2["password"]
|
assert ac1["password"] != ac2["password"]
|
||||||
|
|
||||||
|
|
||||||
def test_create_newemail_dict_ip(make_config):
|
def test_create_newemail_dict_ip(ipv4_config):
|
||||||
config = make_config("1.2.3.4")
|
ac = create_newemail_dict(ipv4_config)
|
||||||
ac = create_newemail_dict(config)
|
assert ac["email"].endswith("@[1.3.3.7]")
|
||||||
assert ac["email"].endswith("@[1.2.3.4]")
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_dclogin_url():
|
def test_create_dclogin_url(example_config):
|
||||||
url = create_dclogin_url("user@example.org", "p@ss w+rd")
|
addr = "user@example.org"
|
||||||
|
password = "p@ss w+rd"
|
||||||
|
url = create_dclogin_url(example_config, addr, password)
|
||||||
assert url.startswith("dclogin:")
|
assert url.startswith("dclogin:")
|
||||||
assert "v=1" in url
|
assert "v=1" in url
|
||||||
assert "ic=3" in url
|
assert "ic=3" in url
|
||||||
|
|
||||||
assert "user@example.org" in url
|
assert addr in url
|
||||||
# password special chars must be encoded
|
# password special chars must be encoded
|
||||||
assert "p%40ss" in url
|
assert "p%40ss" in url
|
||||||
assert "w%2Brd" in url
|
assert "w%2Brd" in url
|
||||||
|
|
||||||
|
|
||||||
def test_print_new_account(capsys, monkeypatch, maildomain, tmpdir, example_config):
|
def test_create_dclogin_url_ipv4(ipv4_config):
|
||||||
|
addr = "user@[1.3.3.7]"
|
||||||
|
password = "p@ss w+rd"
|
||||||
|
url = create_dclogin_url(ipv4_config, addr, password)
|
||||||
|
assert url.startswith("dclogin:")
|
||||||
|
assert "v=1" in url
|
||||||
|
assert "ic=3" in url
|
||||||
|
assert addr in url
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_new_account(capsys, monkeypatch, tmpdir, example_config):
|
||||||
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
|
monkeypatch.setattr(chatmaild.newemail, "CONFIG_PATH", str(example_config._inipath))
|
||||||
print_new_account()
|
print_new_account()
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import shutil
|
||||||
|
|
||||||
|
import psutil
|
||||||
|
|
||||||
|
from chatmaild.syslimits import has_sufficient_resources
|
||||||
|
|
||||||
|
PERMISSIVE = {
|
||||||
|
"max_load_1m": "99999",
|
||||||
|
"min_available_memory": "0",
|
||||||
|
"min_free_disk_space": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_constrained_system(make_config, caplog):
|
||||||
|
assert has_sufficient_resources(make_config("chat.example.org", PERMISSIVE))
|
||||||
|
for settings in (
|
||||||
|
{"max_load_1m": "-1.0"},
|
||||||
|
{"min_available_memory": "99999999G"},
|
||||||
|
{"min_free_disk_space": "99999999G"},
|
||||||
|
):
|
||||||
|
config = make_config("chat.example.org", PERMISSIVE | settings)
|
||||||
|
caplog.clear()
|
||||||
|
assert not has_sufficient_resources(config), settings
|
||||||
|
assert "registration rejected" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_disk_does_not_reject(make_config, caplog):
|
||||||
|
config = make_config(
|
||||||
|
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
|
||||||
|
)
|
||||||
|
shutil.rmtree(config.mailboxes_dir)
|
||||||
|
assert has_sufficient_resources(config)
|
||||||
|
assert "ignoring" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_unreadable_value_keeps_other_checks(make_config, monkeypatch, caplog):
|
||||||
|
def raise_error(*args):
|
||||||
|
raise psutil.Error("dud")
|
||||||
|
|
||||||
|
monkeypatch.setattr(psutil, "getloadavg", raise_error)
|
||||||
|
config = make_config(
|
||||||
|
"chat.example.org", PERMISSIVE | {"min_free_disk_space": "99999999G"}
|
||||||
|
)
|
||||||
|
assert not has_sufficient_resources(config)
|
||||||
|
assert "ignoring" in caplog.text
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chatmaild.metadata import turn_credentials
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def turn_socket(tmp_path):
|
||||||
|
sock_path = str(tmp_path / "turn.socket")
|
||||||
|
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
server.bind(sock_path)
|
||||||
|
server.listen(1)
|
||||||
|
yield sock_path, server
|
||||||
|
server.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_credentials_timeout(turn_socket):
|
||||||
|
sock_path, server = turn_socket
|
||||||
|
with pytest.raises(socket.timeout):
|
||||||
|
# Inside turn_credentials the kernel listen backlog (1)
|
||||||
|
# completes connect() without accept()
|
||||||
|
# so the client blocks on readline() until the 5s timeout fires.
|
||||||
|
turn_credentials(sock_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_credentials_connection_refused_on_not_existing_socket(tmp_path):
|
||||||
|
missing = str(tmp_path / "nonexistent.socket")
|
||||||
|
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
|
||||||
|
turn_credentials(missing)
|
||||||
|
|
||||||
|
|
||||||
|
def test_turn_credentials_socket_success(turn_socket):
|
||||||
|
sock_path, server = turn_socket
|
||||||
|
|
||||||
|
def respond():
|
||||||
|
conn, _ = server.accept()
|
||||||
|
conn.sendall(b"testuser:testpass\n")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=respond, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
result = turn_credentials(sock_path)
|
||||||
|
assert result == "testuser:testpass"
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from chatmaild.turnserver import turn_credentials
|
|
||||||
|
|
||||||
SOCKET_PATH = "/run/chatmail-turn/turn.socket"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def turn_socket(tmp_path):
|
|
||||||
"""Create a real Unix socket server at a temp path."""
|
|
||||||
sock_path = str(tmp_path / "turn.socket")
|
|
||||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
server.bind(sock_path)
|
|
||||||
server.listen(1)
|
|
||||||
yield sock_path, server
|
|
||||||
server.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _call_turn_credentials(sock_path):
|
|
||||||
"""Call turn_credentials but connect to sock_path instead of hardcoded path."""
|
|
||||||
original_connect = socket.socket.connect
|
|
||||||
|
|
||||||
def patched_connect(self, address):
|
|
||||||
if address == SOCKET_PATH:
|
|
||||||
address = sock_path
|
|
||||||
return original_connect(self, address)
|
|
||||||
|
|
||||||
with patch.object(socket.socket, "connect", patched_connect):
|
|
||||||
return turn_credentials()
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_credentials_timeout(turn_socket):
|
|
||||||
"""Server accepts but never responds — must raise socket.timeout."""
|
|
||||||
sock_path, server = turn_socket
|
|
||||||
|
|
||||||
def accept_and_hang():
|
|
||||||
conn, _ = server.accept()
|
|
||||||
time.sleep(30)
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
t = threading.Thread(target=accept_and_hang, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
with pytest.raises(socket.timeout):
|
|
||||||
_call_turn_credentials(sock_path)
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_credentials_connection_refused(tmp_path):
|
|
||||||
"""Socket file doesn't exist — must raise ConnectionRefusedError or FileNotFoundError."""
|
|
||||||
missing = str(tmp_path / "nonexistent.socket")
|
|
||||||
with pytest.raises((ConnectionRefusedError, FileNotFoundError)):
|
|
||||||
_call_turn_credentials(missing)
|
|
||||||
|
|
||||||
|
|
||||||
def test_turn_credentials_success(turn_socket):
|
|
||||||
"""Server responds with credentials — must return stripped string."""
|
|
||||||
sock_path, server = turn_socket
|
|
||||||
|
|
||||||
def respond():
|
|
||||||
conn, _ = server.accept()
|
|
||||||
conn.sendall(b"testuser:testpass\n")
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
t = threading.Thread(target=respond, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
result = _call_turn_credentials(sock_path)
|
|
||||||
assert result == "testuser:testpass"
|
|
||||||
@@ -8,28 +8,23 @@ def test_login_timestamp(testaddr, example_config):
|
|||||||
assert user.get_last_login_timestamp() == 86400 * 2
|
assert user.get_last_login_timestamp() == 86400 * 2
|
||||||
|
|
||||||
|
|
||||||
def test_get_user_dict_not_set(testaddr, example_config, caplog):
|
def test_get_password_hash_not_set(testaddr, example_config, caplog):
|
||||||
user = example_config.get_user(testaddr)
|
user = example_config.get_user(testaddr)
|
||||||
assert not caplog.records
|
assert not caplog.records
|
||||||
assert user.get_userdb_dict() == {}
|
assert user.get_password_hash() is None
|
||||||
assert len(caplog.records) == 0
|
assert len(caplog.records) == 0
|
||||||
|
|
||||||
user.set_password("")
|
user.set_password("")
|
||||||
assert user.get_userdb_dict() == {}
|
assert user.get_password_hash() is None
|
||||||
assert len(caplog.records) == 1
|
assert len(caplog.records) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_get_user_dict(make_config, tmp_path):
|
def test_get_password_hash(make_config, tmp_path):
|
||||||
config = make_config("something.testrun.org")
|
config = make_config("something.testrun.org")
|
||||||
addr = "user1@something.org"
|
user = config.get_user("user1@something.org")
|
||||||
user = config.get_user(addr)
|
|
||||||
enc_password = "l1k2j31lk2j3l1k23j123"
|
enc_password = "l1k2j31lk2j3l1k23j123"
|
||||||
user.set_password(enc_password)
|
user.set_password(enc_password)
|
||||||
data = user.get_userdb_dict()
|
assert user.get_password_hash() == enc_password
|
||||||
assert addr in str(data["home"])
|
|
||||||
assert data["uid"] == "vmail"
|
|
||||||
assert data["gid"] == "vmail"
|
|
||||||
assert data["password"] == enc_password
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
|
def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import socket
|
|
||||||
|
|
||||||
|
|
||||||
def turn_credentials() -> str:
|
|
||||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
|
|
||||||
client_socket.settimeout(5)
|
|
||||||
client_socket.connect("/run/chatmail-turn/turn.socket")
|
|
||||||
with client_socket.makefile("rb") as file:
|
|
||||||
return file.readline().decode("utf-8").strip()
|
|
||||||
@@ -21,20 +21,17 @@ class User:
|
|||||||
def can_track(self):
|
def can_track(self):
|
||||||
return "@" in self.addr
|
return "@" in self.addr
|
||||||
|
|
||||||
def get_userdb_dict(self):
|
def get_password_hash(self):
|
||||||
"""Return a non-empty dovecot 'userdb' style dict
|
|
||||||
if the user has an existing non-empty password"""
|
|
||||||
try:
|
try:
|
||||||
pw = self.password_path.read_text()
|
passhash = self.password_path.read_text()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
if not pw:
|
if not passhash:
|
||||||
logging.error(f"password is empty for: {self.addr}")
|
logging.error(f"password is empty for: {self.addr}")
|
||||||
return {}
|
return None
|
||||||
|
|
||||||
home = str(self.maildir)
|
return passhash
|
||||||
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
|
|
||||||
|
|
||||||
def is_incoming_cleartext_ok(self):
|
def is_incoming_cleartext_ok(self):
|
||||||
return not self.enforce_E2EE_path.exists()
|
return not self.enforce_E2EE_path.exists()
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ dependencies = [
|
|||||||
"pytest-xdist",
|
"pytest-xdist",
|
||||||
"execnet",
|
"execnet",
|
||||||
"imap_tools",
|
"imap_tools",
|
||||||
|
"jinja2",
|
||||||
|
"lupa",
|
||||||
"deltachat-rpc-client",
|
"deltachat-rpc-client",
|
||||||
"deltachat-rpc-server",
|
"deltachat-rpc-server",
|
||||||
]
|
]
|
||||||
@@ -26,10 +28,6 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
cmdeploy = "cmdeploy.cmdeploy:main"
|
cmdeploy = "cmdeploy.cmdeploy:main"
|
||||||
|
|
||||||
[project.entry-points.pytest11]
|
|
||||||
"chatmaild.testplugin" = "chatmaild.tests.plugin"
|
|
||||||
"cmdeploy.testplugin" = "cmdeploy.tests.plugin"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
addopts = "-v -ra --strict-markers"
|
addopts = "-v -ra --strict-markers"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import importlib.resources
|
from pyinfra.operations import apt, server
|
||||||
|
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
|
||||||
|
|
||||||
from ..basedeploy import Deployer
|
from ..basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
class AcmetoolDeployer(Deployer):
|
class AcmetoolDeployer(Deployer):
|
||||||
|
bin_path = "/usr/bin/acmetool"
|
||||||
|
|
||||||
def __init__(self, email, domains):
|
def __init__(self, email, domains):
|
||||||
self.domains = domains
|
self.domains = domains
|
||||||
self.email = email
|
self.email = email
|
||||||
self.need_restart_redirector = False
|
|
||||||
self.need_restart_reconcile_service = False
|
|
||||||
self.need_restart_reconcile_timer = False
|
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
apt.packages(
|
apt.packages(
|
||||||
@@ -19,123 +16,47 @@ class AcmetoolDeployer(Deployer):
|
|||||||
packages=["acmetool"],
|
packages=["acmetool"],
|
||||||
)
|
)
|
||||||
|
|
||||||
files.file(
|
self.remove_file("/etc/cron.d/acmetool")
|
||||||
name="Remove old acmetool cronjob, it is replaced with systemd timer.",
|
|
||||||
path="/etc/cron.d/acmetool",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
files.put(
|
self.put_executable("acmetool/acmetool.hook", "/etc/acme/hooks/nginx")
|
||||||
name="Install acmetool hook.",
|
self.remove_file("/usr/lib/acme/hooks/nginx")
|
||||||
src=importlib.resources.files(__package__)
|
|
||||||
.joinpath("acmetool.hook")
|
|
||||||
.open("rb"),
|
|
||||||
dest="/etc/acme/hooks/nginx",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
)
|
|
||||||
files.file(
|
|
||||||
name="Remove acmetool hook from the wrong location where it was previously installed.",
|
|
||||||
path="/usr/lib/acme/hooks/nginx",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
files.template(
|
self.put_template(
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
"acmetool/response-file.yaml.j2",
|
||||||
"response-file.yaml.j2"
|
"/var/lib/acme/conf/responses",
|
||||||
),
|
|
||||||
dest="/var/lib/acme/conf/responses",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
email=self.email,
|
email=self.email,
|
||||||
)
|
)
|
||||||
|
|
||||||
files.template(
|
self.put_template(
|
||||||
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
|
"acmetool/target.yaml.j2",
|
||||||
dest="/var/lib/acme/conf/target",
|
"/var/lib/acme/conf/target",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
server.shell(
|
server.shell(
|
||||||
name=f"Remove old acmetool desired files for {self.domains[0]}",
|
name=f"Remove old acmetool desired files for {self.domains[0]}",
|
||||||
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
|
commands=[f"rm -f /var/lib/acme/desired/{self.domains[0]}-*"],
|
||||||
)
|
)
|
||||||
files.template(
|
self.put_template(
|
||||||
src=importlib.resources.files(__package__).joinpath("desired.yaml.j2"),
|
"acmetool/desired.yaml.j2",
|
||||||
dest=f"/var/lib/acme/desired/{self.domains[0]}", # 0 is mailhost TLD
|
f"/var/lib/acme/desired/{self.domains[0]}",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
domains=self.domains,
|
domains=self.domains,
|
||||||
)
|
)
|
||||||
|
|
||||||
service_file = files.put(
|
self.ensure_systemd_unit(
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
"acmetool/acmetool-redirector.service.j2", bin_path=self.bin_path
|
||||||
"acmetool-redirector.service"
|
|
||||||
),
|
|
||||||
dest="/etc/systemd/system/acmetool-redirector.service",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
self.need_restart_redirector = service_file.changed
|
self.ensure_systemd_unit(
|
||||||
|
"acmetool/acmetool-reconcile.service.j2", bin_path=self.bin_path
|
||||||
reconcile_service_file = files.put(
|
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
|
||||||
"acmetool-reconcile.service"
|
|
||||||
),
|
|
||||||
dest="/etc/systemd/system/acmetool-reconcile.service",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
self.need_restart_reconcile_service = reconcile_service_file.changed
|
self.ensure_systemd_unit("acmetool/acmetool-reconcile.timer")
|
||||||
|
|
||||||
reconcile_timer_file = files.put(
|
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
|
||||||
"acmetool-reconcile.timer"
|
|
||||||
),
|
|
||||||
dest="/etc/systemd/system/acmetool-reconcile.timer",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
self.need_restart_reconcile_timer = reconcile_timer_file.changed
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service("acmetool-redirector.service")
|
||||||
name="Setup acmetool-redirector service",
|
self.ensure_service("acmetool-reconcile.service", running=False, enabled=False)
|
||||||
service="acmetool-redirector.service",
|
self.ensure_service("acmetool-reconcile.timer")
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
restarted=self.need_restart_redirector,
|
|
||||||
)
|
|
||||||
self.need_restart_redirector = False
|
|
||||||
|
|
||||||
systemd.service(
|
|
||||||
name="Setup acmetool-reconcile service",
|
|
||||||
service="acmetool-reconcile.service",
|
|
||||||
running=False,
|
|
||||||
enabled=False,
|
|
||||||
daemon_reload=self.need_restart_reconcile_service,
|
|
||||||
)
|
|
||||||
self.need_restart_reconcile_service = False
|
|
||||||
|
|
||||||
systemd.service(
|
|
||||||
name="Setup acmetool-reconcile timer",
|
|
||||||
service="acmetool-reconcile.timer",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
daemon_reload=self.need_restart_reconcile_timer,
|
|
||||||
)
|
|
||||||
self.need_restart_reconcile_timer = False
|
|
||||||
|
|
||||||
server.shell(
|
server.shell(
|
||||||
name=f"Reconcile certificates for: {', '.join(self.domains)}",
|
name=f"Reconcile certificates for: {', '.join(self.domains)}",
|
||||||
commands=["acmetool --batch --xlog.severity=debug reconcile"],
|
commands=[f"{self.bin_path} --batch --xlog.severity=debug reconcile"],
|
||||||
)
|
)
|
||||||
|
|||||||
+1
-1
@@ -4,5 +4,5 @@ After=network.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/bin/acmetool --batch reconcile
|
ExecStart={{ bin_path }} --batch reconcile
|
||||||
|
|
||||||
+1
-1
@@ -3,7 +3,7 @@ Description=acmetool HTTP redirector
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=notify
|
||||||
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon --bind=127.0.0.1:402
|
ExecStart={{ bin_path }} redirector --service.uid=daemon --bind=127.0.0.1:402
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=30
|
||||||
|
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
"acme-enter-email": "{{ email }}"
|
"acme-enter-email": "{{ email }}"
|
||||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.6-August-18-2025.pdf": true
|
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.8-July-06-2026.pdf": true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import os
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
|
from pyinfra.facts.files import Sha256File
|
||||||
from pyinfra.facts.server import Command
|
from pyinfra.facts.server import Command
|
||||||
from pyinfra.operations import files, server, systemd
|
from pyinfra.operations import files, server, systemd
|
||||||
|
|
||||||
@@ -50,11 +51,10 @@ def get_resource(arg, pkg=__package__):
|
|||||||
return importlib.resources.files(pkg).joinpath(arg)
|
return importlib.resources.files(pkg).joinpath(arg)
|
||||||
|
|
||||||
|
|
||||||
def configure_remote_units(mail_domain, units) -> None:
|
def configure_remote_units(deployer, mail_domain, units, **kwargs) -> None:
|
||||||
remote_base_dir = "/usr/local/lib/chatmaild"
|
remote_base_dir = "/usr/local/lib/chatmaild"
|
||||||
remote_venv_dir = f"{remote_base_dir}/venv"
|
remote_venv_dir = f"{remote_base_dir}/venv"
|
||||||
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
||||||
root_owned = dict(user="root", group="root", mode="644")
|
|
||||||
|
|
||||||
# install systemd units
|
# install systemd units
|
||||||
for fn in units:
|
for fn in units:
|
||||||
@@ -63,6 +63,7 @@ def configure_remote_units(mail_domain, units) -> None:
|
|||||||
config_path=remote_chatmail_inipath,
|
config_path=remote_chatmail_inipath,
|
||||||
remote_venv_dir=remote_venv_dir,
|
remote_venv_dir=remote_venv_dir,
|
||||||
mail_domain=mail_domain,
|
mail_domain=mail_domain,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
basename = fn if "." in fn else f"{fn}.service"
|
basename = fn if "." in fn else f"{fn}.service"
|
||||||
@@ -70,15 +71,13 @@ def configure_remote_units(mail_domain, units) -> None:
|
|||||||
source_path = get_resource(f"service/{basename}.f")
|
source_path = get_resource(f"service/{basename}.f")
|
||||||
content = source_path.read_text().format(**params).encode()
|
content = source_path.read_text().format(**params).encode()
|
||||||
|
|
||||||
files.put(
|
deployer.put_file(
|
||||||
name=f"Upload {basename}",
|
|
||||||
src=io.BytesIO(content),
|
src=io.BytesIO(content),
|
||||||
dest=f"/etc/systemd/system/{basename}",
|
dest=f"/etc/systemd/system/{basename}",
|
||||||
**root_owned,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def activate_remote_units(units) -> None:
|
def activate_remote_units(deployer, units) -> None:
|
||||||
# activate systemd units
|
# activate systemd units
|
||||||
for fn in units:
|
for fn in units:
|
||||||
basename = fn if "." in fn else f"{fn}.service"
|
basename = fn if "." in fn else f"{fn}.service"
|
||||||
@@ -88,14 +87,8 @@ def activate_remote_units(units) -> None:
|
|||||||
enabled = False
|
enabled = False
|
||||||
else:
|
else:
|
||||||
enabled = True
|
enabled = True
|
||||||
systemd.service(
|
|
||||||
name=f"Setup {basename}",
|
deployer.ensure_service(basename, running=enabled, enabled=enabled)
|
||||||
service=basename,
|
|
||||||
running=enabled,
|
|
||||||
enabled=enabled,
|
|
||||||
restarted=enabled,
|
|
||||||
daemon_reload=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Deployment:
|
class Deployment:
|
||||||
@@ -141,6 +134,7 @@ class Deployment:
|
|||||||
|
|
||||||
class Deployer:
|
class Deployer:
|
||||||
need_restart = False
|
need_restart = False
|
||||||
|
daemon_reload = False
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
pass
|
pass
|
||||||
@@ -150,3 +144,114 @@ class Deployer:
|
|||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def ensure_service(self, service, running=True, enabled=True):
|
||||||
|
if running:
|
||||||
|
verb = "Start and enable"
|
||||||
|
else:
|
||||||
|
verb = "Stop"
|
||||||
|
systemd.service(
|
||||||
|
name=f"{verb} {service}",
|
||||||
|
service=service,
|
||||||
|
running=running,
|
||||||
|
enabled=enabled,
|
||||||
|
restarted=self.need_restart if running else False,
|
||||||
|
daemon_reload=self.daemon_reload,
|
||||||
|
)
|
||||||
|
self.daemon_reload = False
|
||||||
|
|
||||||
|
def ensure_systemd_unit(self, src, **kwargs):
|
||||||
|
dest_name = src.split("/")[-1].replace(".j2", "")
|
||||||
|
dest = f"/etc/systemd/system/{dest_name}"
|
||||||
|
if src.endswith(".j2"):
|
||||||
|
return self.put_template(src, dest, **kwargs)
|
||||||
|
return self.put_file(src, dest)
|
||||||
|
|
||||||
|
def put_file(self, src, dest, mode="644", **kwargs):
|
||||||
|
if isinstance(src, str):
|
||||||
|
src = get_resource(src)
|
||||||
|
res = files.put(
|
||||||
|
name=f"Upload {dest}",
|
||||||
|
src=src,
|
||||||
|
dest=dest,
|
||||||
|
user="root",
|
||||||
|
group="root",
|
||||||
|
mode=mode,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._update_restart_signals(dest, res)
|
||||||
|
|
||||||
|
def put_executable(self, src, dest):
|
||||||
|
return self.put_file(src, dest, mode="755")
|
||||||
|
|
||||||
|
def put_template(self, src, dest, owner="root", **kwargs):
|
||||||
|
if isinstance(src, str):
|
||||||
|
src = get_resource(src)
|
||||||
|
res = files.template(
|
||||||
|
name=f"Upload {dest}",
|
||||||
|
src=src,
|
||||||
|
dest=dest,
|
||||||
|
user=owner,
|
||||||
|
group=owner,
|
||||||
|
mode="644",
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self._update_restart_signals(dest, res)
|
||||||
|
|
||||||
|
def remove_file(self, dest):
|
||||||
|
res = files.file(name=f"Remove {dest}", path=dest, present=False)
|
||||||
|
return self._update_restart_signals(dest, res)
|
||||||
|
|
||||||
|
def ensure_line(self, path, line, **kwargs):
|
||||||
|
name = kwargs.pop("name", f"Ensure line in {path}")
|
||||||
|
res = files.line(name=name, path=path, line=line, **kwargs)
|
||||||
|
return self._update_restart_signals(path, res)
|
||||||
|
|
||||||
|
def ensure_directory(self, path, owner="root", mode="755", **kwargs):
|
||||||
|
name = kwargs.pop("name", f"Ensure directory {path}")
|
||||||
|
res = files.directory(
|
||||||
|
name=name,
|
||||||
|
path=path,
|
||||||
|
user=owner,
|
||||||
|
group=owner,
|
||||||
|
mode=mode,
|
||||||
|
present=True,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return self._update_restart_signals(path, res)
|
||||||
|
|
||||||
|
def remove_directory(self, path, **kwargs):
|
||||||
|
name = kwargs.pop("name", f"Remove directory {path}")
|
||||||
|
res = files.directory(name=name, path=path, present=False, **kwargs)
|
||||||
|
return self._update_restart_signals(path, res)
|
||||||
|
|
||||||
|
def download_executable(self, url, dest, sha256sum, extract=None, mode="755"):
|
||||||
|
existing = host.get_fact(Sha256File, dest)
|
||||||
|
if existing == sha256sum:
|
||||||
|
return
|
||||||
|
|
||||||
|
tmp = f"{dest}.new"
|
||||||
|
if extract:
|
||||||
|
dl_cmd = f"curl -fSL {url} | {extract} >{tmp}"
|
||||||
|
else:
|
||||||
|
dl_cmd = f"curl -fSL {url} -o {tmp}"
|
||||||
|
|
||||||
|
server.shell(
|
||||||
|
name=f"Download {dest}",
|
||||||
|
commands=[
|
||||||
|
f"({dl_cmd}"
|
||||||
|
f" && echo '{sha256sum} {tmp}' | sha256sum -c"
|
||||||
|
f" && mv {tmp} {dest})",
|
||||||
|
f"chmod {mode} {dest}",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.need_restart = True
|
||||||
|
|
||||||
|
def _update_restart_signals(self, path, res):
|
||||||
|
if res.changed:
|
||||||
|
self.need_restart = True
|
||||||
|
if str(path).startswith("/etc/systemd/system/"):
|
||||||
|
self.daemon_reload = True
|
||||||
|
return res
|
||||||
|
|||||||
@@ -84,13 +84,24 @@ def run_cmd_options(parser):
|
|||||||
add_ssh_host_option(parser)
|
add_ssh_host_option(parser)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_unused_settings(unused_keys, out):
|
||||||
|
if unused_keys:
|
||||||
|
names = ", ".join(unused_keys)
|
||||||
|
out.red(
|
||||||
|
f"WARNING: chatmail.ini contains settings that have no effect: {names}\n"
|
||||||
|
"Please remove them from chatmail.ini."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args, out):
|
def run_cmd(args, out):
|
||||||
"""Deploy chatmail services on the remote server."""
|
"""Deploy chatmail services on the remote server."""
|
||||||
|
|
||||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
|
||||||
sshexec = get_sshexec(ssh_host)
|
sshexec = get_sshexec(ssh_host)
|
||||||
require_iroh = args.config.enable_iroh_relay
|
require_iroh = args.config.enable_iroh_relay
|
||||||
strict_tls = args.config.tls_cert_mode == "acme"
|
strict_tls = args.config.tls_cert_mode == "acme"
|
||||||
|
if args.config.ipv4_relay:
|
||||||
|
args.dns_check_disabled = True
|
||||||
if not args.dns_check_disabled:
|
if not args.dns_check_disabled:
|
||||||
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
|
||||||
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
|
if not dns.check_initial_remote_data(remote_data, strict_tls=strict_tls, print=out.red):
|
||||||
@@ -119,8 +130,11 @@ def run_cmd(args, out):
|
|||||||
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
|
elif not args.dns_check_disabled and strict_tls and not remote_data["acme_account_url"]:
|
||||||
out.red("Deploy completed but letsencrypt not configured")
|
out.red("Deploy completed but letsencrypt not configured")
|
||||||
out.red("Run 'cmdeploy run' again")
|
out.red("Run 'cmdeploy run' again")
|
||||||
|
elif args.config.ipv4_relay:
|
||||||
|
out.green("Deploy completed.")
|
||||||
else:
|
else:
|
||||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||||
|
_warn_unused_settings(args.config._unused_keys, out)
|
||||||
return 0
|
return 0
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
out.red("Deploy failed")
|
out.red("Deploy failed")
|
||||||
@@ -140,6 +154,10 @@ def dns_cmd_options(parser):
|
|||||||
|
|
||||||
def dns_cmd(args, out):
|
def dns_cmd(args, out):
|
||||||
"""Check DNS entries and optionally generate dns zone file."""
|
"""Check DNS entries and optionally generate dns zone file."""
|
||||||
|
if args.config.ipv4_relay:
|
||||||
|
ipv4 = args.config.ipv4_relay
|
||||||
|
print(f"[WARNING] {ipv4} is not a domain, skipping DNS checks.")
|
||||||
|
return 0
|
||||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
||||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||||
tls_cert_mode = args.config.tls_cert_mode
|
tls_cert_mode = args.config.tls_cert_mode
|
||||||
@@ -177,7 +195,7 @@ def status_cmd_options(parser):
|
|||||||
def status_cmd(args, out):
|
def status_cmd(args, out):
|
||||||
"""Display status for online chatmail instance."""
|
"""Display status for online chatmail instance."""
|
||||||
|
|
||||||
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain
|
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain_bare
|
||||||
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
|
||||||
|
|
||||||
out.green(f"chatmail domain: {args.config.mail_domain}")
|
out.green(f"chatmail domain: {args.config.mail_domain}")
|
||||||
|
|||||||
+132
-207
@@ -12,7 +12,6 @@ from chatmaild.config import read_config
|
|||||||
from pyinfra import facts, host, logger
|
from pyinfra import facts, host, logger
|
||||||
from pyinfra.api import FactBase
|
from pyinfra.api import FactBase
|
||||||
from pyinfra.facts import hardware
|
from pyinfra.facts import hardware
|
||||||
from pyinfra.facts.files import Sha256File
|
|
||||||
from pyinfra.facts.systemd import SystemdEnabled
|
from pyinfra.facts.systemd import SystemdEnabled
|
||||||
from pyinfra.operations import apt, files, pip, server, systemd
|
from pyinfra.operations import apt, files, pip, server, systemd
|
||||||
|
|
||||||
@@ -25,7 +24,6 @@ from .basedeploy import (
|
|||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
blocked_service_startup,
|
blocked_service_startup,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
|
||||||
has_systemd,
|
has_systemd,
|
||||||
is_in_container,
|
is_in_container,
|
||||||
)
|
)
|
||||||
@@ -35,6 +33,7 @@ from .filtermail.deployer import FiltermailDeployer
|
|||||||
from .mtail.deployer import MtailDeployer
|
from .mtail.deployer import MtailDeployer
|
||||||
from .nginx.deployer import NginxDeployer
|
from .nginx.deployer import NginxDeployer
|
||||||
from .opendkim.deployer import OpendkimDeployer
|
from .opendkim.deployer import OpendkimDeployer
|
||||||
|
from .pins import IROH_ARTIFACTS, TURN_ARTIFACTS
|
||||||
from .postfix.deployer import PostfixDeployer
|
from .postfix.deployer import PostfixDeployer
|
||||||
from .selfsigned.deployer import SelfSignedTlsDeployer
|
from .selfsigned.deployer import SelfSignedTlsDeployer
|
||||||
from .www import build_webpages, find_merge_conflict, get_paths
|
from .www import build_webpages, find_merge_conflict, get_paths
|
||||||
@@ -82,25 +81,39 @@ def remove_legacy_artifacts():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _install_remote_venv_with_chatmaild() -> None:
|
def _install_remote_venv_with_chatmaild(deployer) -> None:
|
||||||
remove_legacy_artifacts()
|
remove_legacy_artifacts()
|
||||||
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
|
dist_file = _build_chatmaild(dist_dir=Path("chatmaild/dist"))
|
||||||
remote_base_dir = "/usr/local/lib/chatmaild"
|
remote_base_dir = "/usr/local/lib/chatmaild"
|
||||||
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
|
remote_dist_file = f"{remote_base_dir}/dist/{dist_file.name}"
|
||||||
remote_venv_dir = f"{remote_base_dir}/venv"
|
remote_venv_dir = f"{remote_base_dir}/venv"
|
||||||
root_owned = dict(user="root", group="root", mode="644")
|
|
||||||
|
|
||||||
apt.packages(
|
apt.packages(
|
||||||
name="apt install python3-virtualenv",
|
name="apt install python3-virtualenv",
|
||||||
packages=["python3-virtualenv"],
|
packages=["python3-virtualenv"],
|
||||||
)
|
)
|
||||||
|
|
||||||
files.put(
|
deployer.ensure_directory(f"{remote_base_dir}/dist")
|
||||||
name="Upload chatmaild source package",
|
deployer.put_file(
|
||||||
src=dist_file.open("rb"),
|
src=dist_file.open("rb"),
|
||||||
dest=remote_dist_file,
|
dest=remote_dist_file,
|
||||||
create_remote_dir=True,
|
)
|
||||||
**root_owned,
|
|
||||||
|
# Remove venv if its Python major.minor doesn't match the system Python
|
||||||
|
server.shell(
|
||||||
|
name="remove stale chatmaild venv if python version changed",
|
||||||
|
commands=[
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
r"re='[0-9]+\.[0-9]+'", # major.minor out of 'Python X.Y.Z'
|
||||||
|
'sys_version=$(python3 --version 2>/dev/null | grep -oE "$re")',
|
||||||
|
f'venv_version=$({remote_venv_dir}/bin/python --version 2>/dev/null | grep -oE "$re")',
|
||||||
|
# an empty sys_version means we could not tell: keep the venv
|
||||||
|
f'[ -z "$sys_version" ] || [ "$sys_version" = "$venv_version" ] '
|
||||||
|
f"|| rm -rf {remote_venv_dir}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
pip.virtualenv(
|
pip.virtualenv(
|
||||||
@@ -122,40 +135,43 @@ def _install_remote_venv_with_chatmaild() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configure_remote_venv_with_chatmaild(config) -> None:
|
def _configure_remote_venv_with_chatmaild(deployer, config) -> None:
|
||||||
remote_base_dir = "/usr/local/lib/chatmaild"
|
remote_base_dir = "/usr/local/lib/chatmaild"
|
||||||
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
remote_chatmail_inipath = f"{remote_base_dir}/chatmail.ini"
|
||||||
root_owned = dict(user="root", group="root", mode="644")
|
|
||||||
|
|
||||||
files.put(
|
deployer.put_file(
|
||||||
name=f"Upload {remote_chatmail_inipath}",
|
|
||||||
src=config._getbytefile(),
|
src=config._getbytefile(),
|
||||||
dest=remote_chatmail_inipath,
|
dest=remote_chatmail_inipath,
|
||||||
**root_owned,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
files.file(
|
deployer.remove_file("/etc/cron.d/chatmail-metrics")
|
||||||
path="/etc/cron.d/chatmail-metrics",
|
deployer.remove_file("/var/www/html/metrics")
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
files.file(
|
|
||||||
path="/var/www/html/metrics",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class UnboundDeployer(Deployer):
|
class UnboundDeployer(Deployer):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
# On an IPv4-only system, if unbound is started but not configured,
|
# On an IPv4-only system, if unbound is started but not configured,
|
||||||
# it causes subsequent steps to fail to resolve hosts.
|
# it causes subsequent steps to fail to resolve hosts.
|
||||||
with blocked_service_startup():
|
with blocked_service_startup():
|
||||||
|
# dns-root-data is an optional package
|
||||||
|
# that contains /usr/share/dns/root.key
|
||||||
|
#
|
||||||
|
# This file is copied into /var/lib/unbound/root.key
|
||||||
|
# at the start of "unbound" systemd unit
|
||||||
|
# by /usr/libexec/unbound-helper shell script
|
||||||
|
# from the "unbound" package as of version 1.17.1-2+deb12u4
|
||||||
|
#
|
||||||
|
# The same /var/lib/unbound/root.key can be retrieved directly
|
||||||
|
# following the procedure from
|
||||||
|
# <https://www.rfc-editor.org/info/rfc7958/#section-3.1>
|
||||||
|
# with "unbound-anchor -a /var/lib/unbound/root.key"
|
||||||
|
# We don't install and use "unbound-anchor".
|
||||||
apt.packages(
|
apt.packages(
|
||||||
name="Install unbound",
|
name="Install unbound",
|
||||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
packages=["unbound", "dns-root-data", "dnsutils"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
@@ -176,41 +192,19 @@ class UnboundDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
# Configure unbound resolver with Quad9 fallback and a trailing newline
|
# Configure unbound resolver with Quad9 fallback and a trailing newline
|
||||||
# (SolusVM bug).
|
# (SolusVM bug).
|
||||||
files.put(
|
self.put_file(
|
||||||
name="Write static resolv.conf",
|
|
||||||
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
src=BytesIO(b"nameserver 127.0.0.1\nnameserver 9.9.9.9\n"),
|
||||||
dest="/etc/resolv.conf",
|
dest="/etc/resolv.conf",
|
||||||
user="root",
|
force=True,
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
server.shell(
|
self.ensure_directory(
|
||||||
name="Generate root keys for validating DNSSEC",
|
path="/etc/unbound/unbound.conf.d",
|
||||||
commands=[
|
)
|
||||||
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
self.put_template(
|
||||||
],
|
"unbound/unbound.conf.j2",
|
||||||
|
"/etc/unbound/unbound.conf.d/chatmail.conf",
|
||||||
|
disable_ipv6=self.config.disable_ipv6,
|
||||||
)
|
)
|
||||||
if self.config.disable_ipv6:
|
|
||||||
files.directory(
|
|
||||||
path="/etc/unbound/unbound.conf.d",
|
|
||||||
present=True,
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
)
|
|
||||||
conf = files.put(
|
|
||||||
src=get_resource("unbound/unbound.conf.j2"),
|
|
||||||
dest="/etc/unbound/unbound.conf.d/chatmail.conf",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
conf = files.file(
|
|
||||||
path="/etc/unbound/unbound.conf.d/chatmail.conf",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
self.need_restart |= conf.changed
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
server.shell(
|
server.shell(
|
||||||
@@ -220,27 +214,25 @@ class UnboundDeployer(Deployer):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
systemd.service(
|
self.ensure_service("unbound.service")
|
||||||
name="Start and enable unbound",
|
|
||||||
service="unbound.service",
|
self.ensure_service(
|
||||||
running=True,
|
"unbound-resolvconf.service",
|
||||||
enabled=True,
|
running=False,
|
||||||
restarted=self.need_restart,
|
enabled=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MtastsDeployer(Deployer):
|
class MtastsDeployer(Deployer):
|
||||||
def configure(self):
|
def configure(self):
|
||||||
# Remove configuration.
|
# Remove configuration.
|
||||||
files.file("/etc/mta-sts-daemon.yml", present=False)
|
self.remove_file("/etc/mta-sts-daemon.yml")
|
||||||
files.directory("/usr/local/lib/postfix-mta-sts-resolver", present=False)
|
self.remove_directory("/usr/local/lib/postfix-mta-sts-resolver")
|
||||||
files.file("/etc/systemd/system/mta-sts-daemon.service", present=False)
|
self.remove_file("/etc/systemd/system/mta-sts-daemon.service")
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service(
|
||||||
name="Stop MTA-STS daemon",
|
"mta-sts-daemon.service",
|
||||||
service="mta-sts-daemon.service",
|
|
||||||
daemon_reload=True,
|
|
||||||
running=False,
|
running=False,
|
||||||
enabled=False,
|
enabled=False,
|
||||||
)
|
)
|
||||||
@@ -251,14 +243,7 @@ class WebsiteDeployer(Deployer):
|
|||||||
self.config = config
|
self.config = config
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
files.directory(
|
self.ensure_directory("/var/www")
|
||||||
name="Ensure /var/www exists",
|
|
||||||
path="/var/www",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
present=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
www_path, src_dir, build_dir = get_paths(self.config)
|
www_path, src_dir, build_dir = get_paths(self.config)
|
||||||
@@ -286,17 +271,20 @@ class LegacyRemoveDeployer(Deployer):
|
|||||||
def install(self):
|
def install(self):
|
||||||
apt.packages(name="Remove rspamd", packages="rspamd", present=False)
|
apt.packages(name="Remove rspamd", packages="rspamd", present=False)
|
||||||
|
|
||||||
|
# unbound-anchor was used to download /var/lib/unbound/root.key
|
||||||
|
# It is replaced by dns-root-data which contains /usr/share/dns/root.key.
|
||||||
|
# unbound systemd unit copies /usr/share/dns/root.key
|
||||||
|
# into /var/lib/unbound/root.key automatically on start
|
||||||
|
# as long as /usr/share/dns/root.key is present.
|
||||||
|
apt.packages(name="Remove unbound-anchor", packages="unbound-anchor", present=False)
|
||||||
|
|
||||||
# remove historic expunge script
|
# remove historic expunge script
|
||||||
# which is now implemented through a systemd timer (chatmail-expire)
|
# which is now implemented through a systemd timer (chatmail-expire)
|
||||||
files.file(
|
self.remove_file("/etc/cron.d/expunge")
|
||||||
path="/etc/cron.d/expunge",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove OBS repository key that is no longer used.
|
# Remove OBS repository key that is no longer used.
|
||||||
files.file("/etc/apt/keyrings/obs-home-deltachat.gpg", present=False)
|
self.remove_file("/etc/apt/keyrings/obs-home-deltachat.gpg")
|
||||||
files.line(
|
self.ensure_line(
|
||||||
name="Remove DeltaChat OBS home repository from sources.list",
|
|
||||||
path="/etc/apt/sources.list",
|
path="/etc/apt/sources.list",
|
||||||
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
|
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
|
||||||
escape_regex_characters=True,
|
escape_regex_characters=True,
|
||||||
@@ -304,11 +292,7 @@ class LegacyRemoveDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# prior relay versions used filelogging
|
# prior relay versions used filelogging
|
||||||
files.directory(
|
self.remove_directory("/var/log/journal/")
|
||||||
name="Ensure old logs on disk are deleted",
|
|
||||||
path="/var/log/journal/",
|
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
# remove echobot if it is still running
|
# remove echobot if it is still running
|
||||||
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
|
if has_systemd() and host.get_fact(SystemdEnabled).get("echobot.service"):
|
||||||
systemd.service(
|
systemd.service(
|
||||||
@@ -335,126 +319,70 @@ def check_config(config):
|
|||||||
|
|
||||||
|
|
||||||
class TurnDeployer(Deployer):
|
class TurnDeployer(Deployer):
|
||||||
|
bin_path = "/usr/local/bin/chatmail-turn"
|
||||||
|
|
||||||
def __init__(self, mail_domain):
|
def __init__(self, mail_domain):
|
||||||
self.mail_domain = mail_domain
|
self.mail_domain = mail_domain
|
||||||
self.units = ["turnserver"]
|
self.units = ["turnserver"]
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = TURN_ARTIFACTS[host.get_fact(facts.server.Arch)]
|
||||||
"x86_64": (
|
self.download_executable(url, self.bin_path, sha256sum)
|
||||||
"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.4/chatmail-turn-aarch64-linux",
|
|
||||||
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
|
|
||||||
),
|
|
||||||
}[host.get_fact(facts.server.Arch)]
|
|
||||||
|
|
||||||
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
|
|
||||||
if existing_sha256sum != sha256sum:
|
|
||||||
server.shell(
|
|
||||||
name="Download chatmail-turn",
|
|
||||||
commands=[
|
|
||||||
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
|
|
||||||
"chmod 755 /usr/local/bin/chatmail-turn",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.mail_domain, self.units)
|
configure_remote_units(
|
||||||
|
self, self.mail_domain, self.units, bin_path=self.bin_path
|
||||||
|
)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
activate_remote_units(self.units)
|
activate_remote_units(self, self.units)
|
||||||
|
|
||||||
|
|
||||||
class IrohDeployer(Deployer):
|
class IrohDeployer(Deployer):
|
||||||
|
bin_path = "/usr/local/bin/iroh-relay"
|
||||||
|
config_path = "/etc/iroh-relay.toml"
|
||||||
|
|
||||||
def __init__(self, enable_iroh_relay):
|
def __init__(self, enable_iroh_relay):
|
||||||
self.enable_iroh_relay = enable_iroh_relay
|
self.enable_iroh_relay = enable_iroh_relay
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = IROH_ARTIFACTS[host.get_fact(facts.server.Arch)]
|
||||||
"x86_64": (
|
self.download_executable(
|
||||||
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz",
|
url,
|
||||||
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
|
self.bin_path,
|
||||||
),
|
sha256sum,
|
||||||
"aarch64": (
|
extract="gunzip | tar -xf - ./iroh-relay -O",
|
||||||
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz",
|
)
|
||||||
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
|
|
||||||
),
|
|
||||||
}[host.get_fact(facts.server.Arch)]
|
|
||||||
|
|
||||||
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay")
|
|
||||||
if existing_sha256sum != sha256sum:
|
|
||||||
server.shell(
|
|
||||||
name="Download iroh-relay",
|
|
||||||
commands=[
|
|
||||||
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
|
|
||||||
"chmod 755 /usr/local/bin/iroh-relay",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.need_restart = True
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
systemd_unit = files.put(
|
self.ensure_systemd_unit(
|
||||||
name="Upload iroh-relay systemd unit",
|
"iroh-relay.service.j2",
|
||||||
src=get_resource("iroh-relay.service"),
|
bin_path=self.bin_path,
|
||||||
dest="/etc/systemd/system/iroh-relay.service",
|
config_path=self.config_path,
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
self.need_restart |= systemd_unit.changed
|
self.put_file("iroh-relay.toml", self.config_path)
|
||||||
|
|
||||||
iroh_config = files.put(
|
|
||||||
name="Upload iroh-relay config",
|
|
||||||
src=get_resource("iroh-relay.toml"),
|
|
||||||
dest="/etc/iroh-relay.toml",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
self.need_restart |= iroh_config.changed
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service(
|
||||||
name="Start and enable iroh-relay",
|
"iroh-relay.service",
|
||||||
service="iroh-relay.service",
|
|
||||||
running=True,
|
|
||||||
enabled=self.enable_iroh_relay,
|
enabled=self.enable_iroh_relay,
|
||||||
restarted=self.need_restart,
|
|
||||||
)
|
)
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
|
|
||||||
class JournaldDeployer(Deployer):
|
class JournaldDeployer(Deployer):
|
||||||
def configure(self):
|
def configure(self):
|
||||||
journald_conf = files.put(
|
self.put_file("journald.conf", "/etc/systemd/journald.conf")
|
||||||
name="Configure journald",
|
|
||||||
src=get_resource("journald.conf"),
|
|
||||||
dest="/etc/systemd/journald.conf",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
self.need_restart = journald_conf.changed
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service("systemd-journald.service")
|
||||||
name="Start and enable journald",
|
|
||||||
service="systemd-journald.service",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
restarted=self.need_restart,
|
|
||||||
)
|
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
|
|
||||||
class ChatmailVenvDeployer(Deployer):
|
class ChatmailVenvDeployer(Deployer):
|
||||||
def __init__(self, config):
|
def __init__(self, config):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.units = (
|
self.units = (
|
||||||
|
# doveauth must restart when chatmaild/ini file changes
|
||||||
|
"doveauth",
|
||||||
"chatmail-metadata",
|
"chatmail-metadata",
|
||||||
"lastlogin",
|
"lastlogin",
|
||||||
"chatmail-expire",
|
"chatmail-expire",
|
||||||
@@ -464,14 +392,14 @@ class ChatmailVenvDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
_install_remote_venv_with_chatmaild()
|
_install_remote_venv_with_chatmaild(self)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
_configure_remote_venv_with_chatmaild(self.config)
|
_configure_remote_venv_with_chatmaild(self, self.config)
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self, self.config.mail_domain_bare, self.units)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
activate_remote_units(self.units)
|
activate_remote_units(self, self.units)
|
||||||
|
|
||||||
|
|
||||||
class ChatmailDeployer(Deployer):
|
class ChatmailDeployer(Deployer):
|
||||||
@@ -485,13 +413,15 @@ class ChatmailDeployer(Deployer):
|
|||||||
self.mail_domain = config.mail_domain
|
self.mail_domain = config.mail_domain
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
files.put(
|
self.put_file(
|
||||||
name="Disable installing recommended packages globally",
|
|
||||||
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
src=BytesIO(b'APT::Install-Recommends "false";\n'),
|
||||||
dest="/etc/apt/apt.conf.d/00InstallRecommends",
|
dest="/etc/apt/apt.conf.d/00InstallRecommends",
|
||||||
user="root",
|
)
|
||||||
group="root",
|
# Pin dovecot-* to priority -1 before any apt operation, apt should
|
||||||
mode="644",
|
# never manage dovecot as our version might be lower than the distro's.
|
||||||
|
self.put_file(
|
||||||
|
src=StringIO("Package: dovecot-*\nPin: version *\nPin-Priority: -1\n"),
|
||||||
|
dest="/etc/apt/preferences.d/pin-dovecot",
|
||||||
)
|
)
|
||||||
apt.update(name="apt update", cache_time=24 * 3600)
|
apt.update(name="apt update", cache_time=24 * 3600)
|
||||||
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
apt.upgrade(name="upgrade apt packages", auto_remove=True)
|
||||||
@@ -508,13 +438,10 @@ class ChatmailDeployer(Deployer):
|
|||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
# metadata crashes if the mailboxes dir does not exist
|
# metadata crashes if the mailboxes dir does not exist
|
||||||
files.directory(
|
self.ensure_directory(
|
||||||
name="Ensure vmail mailbox directory exists",
|
str(self.config.mailboxes_dir),
|
||||||
path=str(self.config.mailboxes_dir),
|
owner="vmail",
|
||||||
user="vmail",
|
|
||||||
group="vmail",
|
|
||||||
mode="700",
|
mode="700",
|
||||||
present=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# This file is used by auth proxy.
|
# This file is used by auth proxy.
|
||||||
@@ -535,12 +462,7 @@ class FcgiwrapDeployer(Deployer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service("fcgiwrap.service")
|
||||||
name="Start and enable fcgiwrap",
|
|
||||||
service="fcgiwrap.service",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GithashDeployer(Deployer):
|
class GithashDeployer(Deployer):
|
||||||
@@ -553,12 +475,7 @@ class GithashDeployer(Deployer):
|
|||||||
git_diff = subprocess.check_output(["git", "diff"]).decode()
|
git_diff = subprocess.check_output(["git", "diff"]).decode()
|
||||||
except Exception:
|
except Exception:
|
||||||
git_diff = ""
|
git_diff = ""
|
||||||
files.put(
|
self.put_file(src=StringIO(git_hash + git_diff), dest="/etc/chatmail-version")
|
||||||
name="Upload chatmail relay git commit hash",
|
|
||||||
src=StringIO(git_hash + git_diff),
|
|
||||||
dest="/etc/chatmail-version",
|
|
||||||
mode="700",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_tls_deployer(config, mail_domain):
|
def get_tls_deployer(config, mail_domain):
|
||||||
@@ -584,18 +501,24 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
"""
|
"""
|
||||||
config = read_config(config_path)
|
config = read_config(config_path)
|
||||||
check_config(config)
|
check_config(config)
|
||||||
mail_domain = config.mail_domain
|
bare_host = config.mail_domain_bare
|
||||||
|
|
||||||
if website_only:
|
if website_only:
|
||||||
Deployment().perform_stages([WebsiteDeployer(config)])
|
Deployment().perform_stages([WebsiteDeployer(config)])
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check if mtail_address interface is available (if configured)
|
# Check if mtail_address interface is available (if configured)
|
||||||
if config.mtail_address and config.mtail_address not in ('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)
|
ipv4_addrs = host.get_fact(hardware.Ipv4Addrs)
|
||||||
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
|
all_addresses = [addr for addrs in ipv4_addrs.values() for addr in addrs]
|
||||||
if config.mtail_address not in all_addresses:
|
if config.mtail_address not in all_addresses:
|
||||||
Out().red(f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n")
|
Out().red(
|
||||||
|
f"Deploy failed: mtail_address {config.mtail_address} is not available (VPN up?).\n"
|
||||||
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
if not is_in_container():
|
if not is_in_container():
|
||||||
@@ -614,15 +537,17 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
("nginx", 443),
|
("nginx", 443),
|
||||||
(["master", "smtpd"], 465),
|
(["master", "smtpd"], 465),
|
||||||
(["master", "smtpd"], 587),
|
(["master", "smtpd"], 587),
|
||||||
(["imap-login", "dovecot"], 993),
|
(["dovecot", "imap-login"], 993),
|
||||||
("iroh-relay", 3340),
|
("iroh-relay", 3340),
|
||||||
("mtail", 3903),
|
("mtail", 3903),
|
||||||
("stats", 3904),
|
(["dovecot", "stats"], 3904),
|
||||||
("nginx", 8443),
|
("nginx", 8443),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port),
|
(["master", "smtpd"], config.postfix_reinject_port),
|
||||||
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
(["master", "smtpd"], config.postfix_reinject_port_incoming),
|
||||||
("filtermail", config.filtermail_smtp_port),
|
("filtermail", config.filtermail_smtp_port),
|
||||||
("filtermail", config.filtermail_smtp_port_incoming),
|
("filtermail", config.filtermail_smtp_port_incoming),
|
||||||
|
("filtermail", config.filtermail_http_port_incoming),
|
||||||
|
("filtermail", config.filtermail_lmtp_port_transport),
|
||||||
]
|
]
|
||||||
for service, port in port_services:
|
for service, port in port_services:
|
||||||
print(f"Checking if port {port} is available for {service}...")
|
print(f"Checking if port {port} is available for {service}...")
|
||||||
@@ -635,7 +560,7 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
)
|
)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
tls_deployer = get_tls_deployer(config, mail_domain)
|
tls_deployer = get_tls_deployer(config, bare_host)
|
||||||
|
|
||||||
all_deployers = [
|
all_deployers = [
|
||||||
ChatmailDeployer(config),
|
ChatmailDeployer(config),
|
||||||
@@ -643,13 +568,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool, website_only: bool) -
|
|||||||
FiltermailDeployer(),
|
FiltermailDeployer(),
|
||||||
JournaldDeployer(),
|
JournaldDeployer(),
|
||||||
UnboundDeployer(config),
|
UnboundDeployer(config),
|
||||||
TurnDeployer(mail_domain),
|
TurnDeployer(bare_host),
|
||||||
IrohDeployer(config.enable_iroh_relay),
|
IrohDeployer(config.enable_iroh_relay),
|
||||||
tls_deployer,
|
tls_deployer,
|
||||||
WebsiteDeployer(config),
|
WebsiteDeployer(config),
|
||||||
ChatmailVenvDeployer(config),
|
ChatmailVenvDeployer(config),
|
||||||
MtastsDeployer(),
|
MtastsDeployer(),
|
||||||
OpendkimDeployer(mail_domain),
|
*([] if config.ipv4_relay else [OpendkimDeployer(bare_host)]),
|
||||||
# Dovecot should be started before Postfix
|
# Dovecot should be started before Postfix
|
||||||
# because it creates authentication socket
|
# because it creates authentication socket
|
||||||
# required by Postfix.
|
# required by Postfix.
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
uri = proxy:/run/doveauth/doveauth.socket:auth
|
|
||||||
iterate_disable = no
|
|
||||||
iterate_prefix = userdb/
|
|
||||||
|
|
||||||
default_pass_scheme = plain
|
|
||||||
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
|
|
||||||
# See <https://doc.dovecot.org/2.3/configuration_manual/config_file/config_variables/#modifiers>
|
|
||||||
# for documentation.
|
|
||||||
#
|
|
||||||
# We escape user-provided input and use double quote as a separator.
|
|
||||||
password_key = passdb/%Ew"%Eu
|
|
||||||
user_key = userdb/%Eu
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- Existing addresses are served from the maildir directly.
|
||||||
|
-- Unknown ones are offered to doveauth, which owns the creation policy.
|
||||||
|
local mailboxes_dir = "{{ config.mailboxes_dir }}"
|
||||||
|
local domain_suffix = "@{{ config.mail_domain }}"
|
||||||
|
local create_url = "http://127.0.0.1:{{ config.doveauth_http_port }}/create"
|
||||||
|
|
||||||
|
local http_client
|
||||||
|
|
||||||
|
local function is_ours(user)
|
||||||
|
return user:sub(-#domain_suffix) == domain_suffix
|
||||||
|
and not user:find("/", 1, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function password_hash(user)
|
||||||
|
local fh = io.open(mailboxes_dir .. "/" .. user .. "/password", "r")
|
||||||
|
if not fh then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local hash, rest = fh:read("l", "a")
|
||||||
|
fh:close()
|
||||||
|
if hash == nil or hash == "" or rest ~= "" then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return hash
|
||||||
|
end
|
||||||
|
|
||||||
|
local function userdb_fields(user)
|
||||||
|
return {home = mailboxes_dir .. "/" .. user, uid = "vmail", gid = "vmail"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local function create(user, password)
|
||||||
|
local request = http_client:request({url = create_url, method = "POST"})
|
||||||
|
request:set_payload(user .. "\t" .. password)
|
||||||
|
return request:submit():status()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Entry points called by dovecot
|
||||||
|
|
||||||
|
function script_init()
|
||||||
|
http_client = dovecot.http.client({request_timeout_msecs = 5000, max_attempts = 1})
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function auth_userdb_lookup(req)
|
||||||
|
if not is_ours(req.user) or password_hash(req.user) == nil then
|
||||||
|
return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, {}
|
||||||
|
end
|
||||||
|
return dovecot.auth.USERDB_RESULT_OK, userdb_fields(req.user)
|
||||||
|
end
|
||||||
|
|
||||||
|
function auth_password_verify(req, password)
|
||||||
|
if not is_ours(req.user) then
|
||||||
|
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
|
||||||
|
end
|
||||||
|
local hash = password_hash(req.user)
|
||||||
|
if hash == nil then
|
||||||
|
-- doveauth refuses with 4xx; dovecot reports its own failures as 9000 and up
|
||||||
|
local status = create(req.user, password)
|
||||||
|
if status >= 500 then
|
||||||
|
return dovecot.auth.PASSDB_RESULT_INTERNAL_FAILURE, {}
|
||||||
|
elseif status ~= 200 then
|
||||||
|
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, {}
|
||||||
|
end
|
||||||
|
elseif req:password_verify(hash, password) ~= 1 then
|
||||||
|
return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, {}
|
||||||
|
end
|
||||||
|
return dovecot.auth.PASSDB_RESULT_OK, userdb_fields(req.user)
|
||||||
|
end
|
||||||
@@ -1,32 +1,27 @@
|
|||||||
import io
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from chatmaild.config import Config
|
from chatmaild.config import Config
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
from pyinfra.facts.deb import DebPackages
|
from pyinfra.facts.deb import DebPackages
|
||||||
from pyinfra.facts.server import Arch, Command, Sysctl
|
from pyinfra.facts.server import Arch, Command, Sysctl
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import files, server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import (
|
from cmdeploy.basedeploy import (
|
||||||
Deployer,
|
Deployer,
|
||||||
activate_remote_units,
|
activate_remote_units,
|
||||||
blocked_service_startup,
|
blocked_service_startup,
|
||||||
configure_remote_units,
|
configure_remote_units,
|
||||||
get_resource,
|
|
||||||
is_in_container,
|
is_in_container,
|
||||||
)
|
)
|
||||||
|
from cmdeploy.pins import DOVECOT_SHA256, DOVECOT_VERSION
|
||||||
|
|
||||||
DOVECOT_ARCHIVE_VERSION = "2.3.21+dfsg1-3"
|
VERSION_ID_CMD = "grep '^VERSION_ID=' /etc/os-release"
|
||||||
DOVECOT_PACKAGE_VERSION = f"1:{DOVECOT_ARCHIVE_VERSION}"
|
|
||||||
|
|
||||||
DOVECOT_SHA256 = {
|
|
||||||
("core", "amd64"): "dd060706f52a306fa863d874717210b9fe10536c824afe1790eec247ded5b27d",
|
def _stamped_version(deb_release: int) -> str:
|
||||||
("core", "arm64"): "e7548e8a82929722e973629ecc40fcfa886894cef3db88f23535149e7f730dc9",
|
"""Version as built, including the per-distro suffix stamped by
|
||||||
("imapd", "amd64"): "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86",
|
chatmail/dovecot CI into package version and filename."""
|
||||||
("imapd", "arm64"): "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f",
|
return f"{DOVECOT_VERSION}+deb{deb_release}u1"
|
||||||
("lmtpd", "amd64"): "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab",
|
|
||||||
("lmtpd", "arm64"): "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DovecotDeployer(Deployer):
|
class DovecotDeployer(Deployer):
|
||||||
@@ -35,75 +30,68 @@ class DovecotDeployer(Deployer):
|
|||||||
def __init__(self, config, disable_mail):
|
def __init__(self, config, disable_mail):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.disable_mail = disable_mail
|
self.disable_mail = disable_mail
|
||||||
self.units = ["doveauth"]
|
self.units = []
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
arch = host.get_fact(Arch)
|
arch = host.get_fact(Arch)
|
||||||
|
deb_release = _parse_version_id(host.get_fact(Command, VERSION_ID_CMD))
|
||||||
with blocked_service_startup():
|
with blocked_service_startup():
|
||||||
debs = []
|
debs = []
|
||||||
for pkg in ("core", "imapd", "lmtpd"):
|
for pkg in ("core", "imapd", "lmtpd", "auth-lua"):
|
||||||
deb, changed = _download_dovecot_package(pkg, arch)
|
deb, changed = _download_dovecot_package(pkg, arch, deb_release)
|
||||||
self.need_restart |= changed
|
self.need_restart |= changed
|
||||||
if deb:
|
if deb:
|
||||||
debs.append(deb)
|
debs.append(deb)
|
||||||
if debs:
|
if debs:
|
||||||
deb_list = " ".join(debs)
|
deb_list = " ".join(debs)
|
||||||
# First dpkg may fail on missing dependencies (stderr suppressed);
|
# apt-get install with local .deb paths resolves depends
|
||||||
# apt-get --fix-broken pulls them in, then dpkg retries cleanly.
|
# against the configured repos (e.g. pulls libwrap0),
|
||||||
|
# The pin file written earlier by ChatmailDeployer prevents apt
|
||||||
|
# from installing a 'wrong' version
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Install dovecot packages",
|
name="Install dovecot packages",
|
||||||
commands=[
|
commands=[
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list} 2> /dev/null || true",
|
"DEBIAN_FRONTEND=noninteractive apt-get install -y "
|
||||||
"DEBIAN_FRONTEND=noninteractive apt-get -y --fix-broken install",
|
'-o Dpkg::Options::="--force-confdef" '
|
||||||
f"dpkg --force-confdef --force-confold -i {deb_list}",
|
'-o Dpkg::Options::="--force-confold" '
|
||||||
|
f"--allow-downgrades {deb_list}",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.need_restart = True
|
self.need_restart = True
|
||||||
files.put(
|
|
||||||
name="Pin dovecot packages to block Debian dist-upgrades",
|
|
||||||
src=io.StringIO(
|
|
||||||
"Package: dovecot-*\n"
|
|
||||||
"Pin: version *\n"
|
|
||||||
"Pin-Priority: -1\n"
|
|
||||||
),
|
|
||||||
dest="/etc/apt/preferences.d/pin-dovecot",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
configure_remote_units(self.config.mail_domain, self.units)
|
configure_remote_units(self, self.config.mail_domain_bare, self.units)
|
||||||
config_restart, self.daemon_reload = _configure_dovecot(self.config)
|
_configure_dovecot(self, self.config)
|
||||||
self.need_restart |= config_restart
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
activate_remote_units(self.units)
|
activate_remote_units(self, self.units)
|
||||||
|
|
||||||
# Detect stale binary: package installed but service still runs old (deleted) binary.
|
# Detect stale binary: package installed but service still runs old (deleted) binary.
|
||||||
if not self.disable_mail and not self.need_restart:
|
if not self.disable_mail and not self.need_restart:
|
||||||
stale = host.get_fact(
|
stale = host.get_fact(
|
||||||
Command,
|
Command,
|
||||||
'pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);'
|
"pid=$(systemctl show -p MainPID --value dovecot.service 2>/dev/null);"
|
||||||
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
|
' [ "${pid:-0}" != "0" ] && readlink "/proc/$pid/exe" 2>/dev/null | grep -q "(deleted)"'
|
||||||
" && echo STALE || true",
|
" && echo STALE || true",
|
||||||
)
|
)
|
||||||
if stale == "STALE":
|
if stale == "STALE":
|
||||||
self.need_restart = True
|
self.need_restart = True
|
||||||
|
|
||||||
restart = False if self.disable_mail else self.need_restart
|
active = not self.disable_mail
|
||||||
|
self.ensure_service(
|
||||||
systemd.service(
|
"dovecot.service",
|
||||||
name="Disable dovecot for now"
|
running=active,
|
||||||
if self.disable_mail
|
enabled=active,
|
||||||
else "Start and enable Dovecot",
|
|
||||||
service="dovecot.service",
|
|
||||||
running=False if self.disable_mail else True,
|
|
||||||
enabled=False if self.disable_mail else True,
|
|
||||||
restarted=restart,
|
|
||||||
daemon_reload=self.daemon_reload,
|
|
||||||
)
|
)
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
|
def _parse_version_id(version_line: str) -> int:
|
||||||
|
"""Debian major release from an /etc/os-release VERSION_ID line."""
|
||||||
|
_, _, raw = (version_line or "").strip().partition("=")
|
||||||
|
try:
|
||||||
|
return int(raw.strip('"'))
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"cannot determine Debian release from {version_line!r}")
|
||||||
|
|
||||||
|
|
||||||
def _pick_url(primary, fallback):
|
def _pick_url(primary, fallback):
|
||||||
@@ -115,27 +103,36 @@ def _pick_url(primary, fallback):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
||||||
def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool]:
|
def _download_dovecot_package(package: str, arch: str, deb_release: int) -> tuple[str | None, bool]:
|
||||||
"""Download a dovecot .deb if needed, return (path, changed)."""
|
"""Download a dovecot .deb if needed, return (path, changed)."""
|
||||||
arch = "amd64" if arch == "x86_64" else arch
|
arch = "amd64" if arch == "x86_64" else arch
|
||||||
arch = "arm64" if arch == "aarch64" else arch
|
arch = "arm64" if arch == "aarch64" else arch
|
||||||
|
|
||||||
pkg_name = f"dovecot-{package}"
|
pkg_name = f"dovecot-{package}"
|
||||||
sha256 = DOVECOT_SHA256.get((package, arch))
|
try:
|
||||||
if sha256 is None:
|
# never fall back to the distro package: it is pinned to -1 and would
|
||||||
op = apt.packages(packages=[pkg_name])
|
# in any case be a version we did not build and do not support
|
||||||
return None, bool(getattr(op, "changed", False))
|
sha256 = DOVECOT_SHA256[(arch, deb_release, package)]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"no dovecot build for {pkg_name} on deb{deb_release}/{arch}")
|
||||||
|
|
||||||
|
stamped_version = _stamped_version(deb_release)
|
||||||
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
installed_versions = host.get_fact(DebPackages).get(pkg_name, [])
|
||||||
if DOVECOT_PACKAGE_VERSION in installed_versions:
|
if f"1:{stamped_version}" in installed_versions:
|
||||||
return None, False
|
return None, False
|
||||||
|
|
||||||
url_version = DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
# Primary URL: flat structure with distro suffix in filename
|
||||||
deb_base = f"{pkg_name}_{url_version}_{arch}.deb"
|
primary_deb = f"{pkg_name}_{stamped_version}_{arch}.deb"
|
||||||
primary_url = f"https://download.delta.chat/dovecot/{deb_base}"
|
primary_url = f"https://download.delta.chat/dovecot/{primary_deb}"
|
||||||
fallback_url = f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{url_version}/{deb_base}"
|
# GitHub release files: escaped + in filename; the release tag stays
|
||||||
|
# distro-neutral, both distros ship in one combined release
|
||||||
|
tag_version = DOVECOT_VERSION.replace("+", "%2B")
|
||||||
|
fallback_deb = f"{pkg_name}_{stamped_version.replace('+', '%2B')}_{arch}.deb"
|
||||||
|
fallback_url = (
|
||||||
|
f"https://github.com/chatmail/dovecot/releases/download/upstream%2F{tag_version}/{fallback_deb}"
|
||||||
|
)
|
||||||
url = _pick_url(primary_url, fallback_url)
|
url = _pick_url(primary_url, fallback_url)
|
||||||
deb_filename = f"/root/{deb_base}"
|
deb_filename = f"/root/{primary_deb}"
|
||||||
|
|
||||||
files.download(
|
files.download(
|
||||||
name=f"Download {pkg_name}",
|
name=f"Download {pkg_name}",
|
||||||
@@ -148,38 +145,20 @@ def _download_dovecot_package(package: str, arch: str) -> tuple[str | None, bool
|
|||||||
return deb_filename, True
|
return deb_filename, True
|
||||||
|
|
||||||
|
|
||||||
def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]:
|
def _configure_dovecot(deployer, config: Config, debug: bool = False):
|
||||||
"""Configures Dovecot IMAP server."""
|
"""Configures Dovecot IMAP server."""
|
||||||
need_restart = False
|
deployer.put_template(
|
||||||
daemon_reload = False
|
"dovecot/dovecot.conf.j2",
|
||||||
|
"/etc/dovecot/dovecot.conf",
|
||||||
main_config = files.template(
|
|
||||||
src=get_resource("dovecot/dovecot.conf.j2"),
|
|
||||||
dest="/etc/dovecot/dovecot.conf",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config=config,
|
config=config,
|
||||||
debug=debug,
|
debug=debug,
|
||||||
disable_ipv6=config.disable_ipv6,
|
disable_ipv6=config.disable_ipv6,
|
||||||
)
|
)
|
||||||
need_restart |= main_config.changed
|
deployer.put_template("dovecot/auth.lua.j2", "/etc/dovecot/auth.lua", config=config)
|
||||||
auth_config = files.put(
|
deployer.remove_file("/etc/dovecot/auth.conf")
|
||||||
src=get_resource("dovecot/auth.conf"),
|
deployer.put_file(
|
||||||
dest="/etc/dovecot/auth.conf",
|
"dovecot/push_notification.lua", "/etc/dovecot/push_notification.lua"
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
need_restart |= auth_config.changed
|
|
||||||
lua_push_notification_script = files.put(
|
|
||||||
src=get_resource("dovecot/push_notification.lua"),
|
|
||||||
dest="/etc/dovecot/push_notification.lua",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
need_restart |= lua_push_notification_script.changed
|
|
||||||
|
|
||||||
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
|
||||||
# it is recommended to set the following inotify limits
|
# it is recommended to set the following inotify limits
|
||||||
@@ -203,25 +182,20 @@ def _configure_dovecot(config: Config, debug: bool = False) -> tuple[bool, bool]
|
|||||||
persist=True,
|
persist=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
timezone_env = files.line(
|
deployer.ensure_line(
|
||||||
name="Set TZ environment variable",
|
name="Set TZ environment variable",
|
||||||
path="/etc/environment",
|
path="/etc/environment",
|
||||||
line="TZ=:/etc/localtime",
|
line="TZ=:/etc/localtime",
|
||||||
)
|
)
|
||||||
need_restart |= timezone_env.changed
|
|
||||||
|
|
||||||
restart_conf = files.put(
|
deployer.put_file(
|
||||||
name="dovecot: restart automatically on failure",
|
"service/10_restart_on_failure.conf",
|
||||||
src=get_resource("service/10_restart.conf"),
|
"/etc/systemd/system/dovecot.service.d/10_restart.conf",
|
||||||
dest="/etc/systemd/system/dovecot.service.d/10_restart.conf",
|
|
||||||
)
|
)
|
||||||
daemon_reload |= restart_conf.changed
|
|
||||||
|
|
||||||
# Validate dovecot configuration before restart
|
# Validate dovecot configuration before restart
|
||||||
if need_restart:
|
if deployer.need_restart:
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Validate dovecot configuration",
|
name="Validate dovecot configuration",
|
||||||
commands=["doveconf -n >/dev/null"],
|
commands=["doveconf -n >/dev/null"],
|
||||||
)
|
)
|
||||||
|
|
||||||
return need_restart, daemon_reload
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ listen = 0.0.0.0
|
|||||||
protocols = imap lmtp
|
protocols = imap lmtp
|
||||||
|
|
||||||
auth_mechanisms = plain
|
auth_mechanisms = plain
|
||||||
|
auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@[]
|
||||||
|
|
||||||
{% if debug == true %}
|
{% if debug == true %}
|
||||||
auth_verbose = yes
|
auth_verbose = yes
|
||||||
@@ -36,11 +37,15 @@ default_client_limit = 20000
|
|||||||
# the following warning will be logged:
|
# the following warning will be logged:
|
||||||
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
|
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
|
||||||
service imap {
|
service imap {
|
||||||
process_limit = 50000
|
process_limit = {{ config.max_imap_connections }}
|
||||||
}
|
}
|
||||||
|
|
||||||
mail_server_admin = mailto:root@{{ config.mail_domain }}
|
{% if config.privacy_mail %}
|
||||||
mail_server_comment = Chatmail server
|
# Advertised to clients as IMAP METADATA /shared/admin (RFC 5464).
|
||||||
|
# The privacy_mail contact from chatmail.ini is used because it is
|
||||||
|
# the only address set by an operator.
|
||||||
|
mail_server_admin = mailto:{{ config.privacy_mail }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
# `zlib` enables compressing messages stored in the maildir.
|
# `zlib` enables compressing messages stored in the maildir.
|
||||||
# See
|
# See
|
||||||
@@ -56,12 +61,12 @@ imap_capability = +XDELTAPUSH XCHATMAIL
|
|||||||
|
|
||||||
# Authentication for system users.
|
# Authentication for system users.
|
||||||
passdb {
|
passdb {
|
||||||
driver = dict
|
driver = lua
|
||||||
args = /etc/dovecot/auth.conf
|
args = file=/etc/dovecot/auth.lua blocking=yes
|
||||||
}
|
}
|
||||||
userdb {
|
userdb {
|
||||||
driver = dict
|
driver = lua
|
||||||
args = /etc/dovecot/auth.conf
|
args = file=/etc/dovecot/auth.lua blocking=yes
|
||||||
}
|
}
|
||||||
##
|
##
|
||||||
## Mailbox locations and namespaces
|
## Mailbox locations and namespaces
|
||||||
|
|||||||
+12
-38
@@ -1,10 +1,7 @@
|
|||||||
import io
|
|
||||||
|
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
from pyinfra.facts.files import File
|
from pyinfra.facts.files import File
|
||||||
from pyinfra.operations import files, systemd
|
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer, get_resource
|
from ..basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
class ExternalTlsDeployer(Deployer):
|
class ExternalTlsDeployer(Deployer):
|
||||||
@@ -23,45 +20,22 @@ class ExternalTlsDeployer(Deployer):
|
|||||||
def configure(self):
|
def configure(self):
|
||||||
# Verify cert and key exist on the remote host using pyinfra facts.
|
# Verify cert and key exist on the remote host using pyinfra facts.
|
||||||
for path in (self.cert_path, self.key_path):
|
for path in (self.cert_path, self.key_path):
|
||||||
info = host.get_fact(File, path=path)
|
if host.get_fact(File, path=path) is None:
|
||||||
if info is None:
|
|
||||||
raise Exception(f"External TLS file not found on server: {path}")
|
raise Exception(f"External TLS file not found on server: {path}")
|
||||||
|
|
||||||
# Deploy the .path unit (templated with the cert path).
|
self.ensure_systemd_unit(
|
||||||
# pkg=__package__ is required here because the resource files
|
"external/tls-cert-reload.path.j2",
|
||||||
# live in cmdeploy.external, not the default cmdeploy package.
|
cert_path=self.cert_path,
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
|
self.ensure_systemd_unit(
|
||||||
service_unit = files.put(
|
"external/tls-cert-reload.service",
|
||||||
name="Upload tls-cert-reload.service",
|
|
||||||
src=get_resource("tls-cert-reload.service", pkg=__package__),
|
|
||||||
dest="/etc/systemd/system/tls-cert-reload.service",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if path_unit.changed or service_unit.changed:
|
|
||||||
self.need_restart = True
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
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
|
# No explicit reload needed here: dovecot/nginx read the cert
|
||||||
# on startup, and the .path watcher handles live changes.
|
# on startup, and the .path watcher handles live changes.
|
||||||
|
self.ensure_service(
|
||||||
|
"tls-cert-reload.path",
|
||||||
|
running=True,
|
||||||
|
enabled=True,
|
||||||
|
)
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
Description=Watch TLS certificate for changes
|
Description=Watch TLS certificate for changes
|
||||||
|
|
||||||
[Path]
|
[Path]
|
||||||
PathChanged={cert_path}
|
PathChanged={{ cert_path }}
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
@@ -1,52 +1,37 @@
|
|||||||
from pyinfra import facts, host
|
import os
|
||||||
from pyinfra.operations import files, systemd
|
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer, get_resource
|
from pyinfra import facts, host
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import Deployer
|
||||||
|
from cmdeploy.pins import FILTERMAIL_ARTIFACTS
|
||||||
|
|
||||||
|
|
||||||
class FiltermailDeployer(Deployer):
|
class FiltermailDeployer(Deployer):
|
||||||
services = ["filtermail", "filtermail-incoming"]
|
services = ["filtermail", "filtermail-incoming", "filtermail-transport"]
|
||||||
bin_path = "/usr/local/bin/filtermail"
|
bin_path = "/usr/local/bin/filtermail"
|
||||||
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
|
config_path = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
|
local_bin = os.environ.get("CHATMAIL_FILTERMAIL_BINARY")
|
||||||
|
if local_bin:
|
||||||
|
self.put_executable(
|
||||||
|
src=local_bin,
|
||||||
|
dest=self.bin_path,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
arch = host.get_fact(facts.server.Arch)
|
arch = host.get_fact(facts.server.Arch)
|
||||||
url = f"https://github.com/chatmail/filtermail/releases/download/v0.6.1/filtermail-{arch}"
|
url, sha256sum = FILTERMAIL_ARTIFACTS[arch]
|
||||||
sha256sum = {
|
self.download_executable(url, self.bin_path, sha256sum)
|
||||||
"x86_64": "48b3fb80c092d00b9b0a0ef77a8673496da3b9aed5ec1851e1df936d5589d62f",
|
|
||||||
"aarch64": "c65bd5f45df187d3d65d6965a285583a3be0f44a6916ff12909ff9a8d702c22e",
|
|
||||||
}[arch]
|
|
||||||
self.need_restart |= files.download(
|
|
||||||
name="Download filtermail",
|
|
||||||
src=url,
|
|
||||||
sha256sum=sha256sum,
|
|
||||||
dest=self.bin_path,
|
|
||||||
mode="755",
|
|
||||||
).changed
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
for service in self.services:
|
for service in self.services:
|
||||||
self.need_restart |= files.template(
|
self.ensure_systemd_unit(
|
||||||
src=get_resource(f"filtermail/{service}.service.j2"),
|
f"filtermail/{service}.service.j2",
|
||||||
dest=f"/etc/systemd/system/{service}.service",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
bin_path=self.bin_path,
|
bin_path=self.bin_path,
|
||||||
config_path=self.config_path,
|
config_path=self.config_path,
|
||||||
).changed
|
)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
for service in self.services:
|
for service in self.services:
|
||||||
systemd.service(
|
self.ensure_service(f"{service}.service")
|
||||||
name=f"Start and enable {service}",
|
|
||||||
service=f"{service}.service",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
restarted=self.need_restart,
|
|
||||||
daemon_reload=True,
|
|
||||||
)
|
|
||||||
self.need_restart = False
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Chatmail transport service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart={{ bin_path }} {{ config_path }} transport
|
||||||
|
Restart=always
|
||||||
|
RestartSec=30
|
||||||
|
User=vmail
|
||||||
|
LimitNOFILE=524288
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
Description=Iroh relay
|
Description=Iroh relay
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/iroh-relay --config-path /etc/iroh-relay.toml
|
ExecStart={{ bin_path }} --config-path {{ config_path }}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
User=iroh
|
User=iroh
|
||||||
@@ -28,6 +28,13 @@ counter created_nonci_accounts
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# doveauth refusing new addresses because a chatmail.ini
|
||||||
|
# system resource limit is exceeded.
|
||||||
|
counter rejected_registrations
|
||||||
|
/registration rejected: / {
|
||||||
|
rejected_registrations++
|
||||||
|
}
|
||||||
|
|
||||||
counter postfix_timeouts
|
counter postfix_timeouts
|
||||||
/timeout after DATA/ {
|
/timeout after DATA/ {
|
||||||
postfix_timeouts++
|
postfix_timeouts++
|
||||||
@@ -73,6 +80,11 @@ counter incoming_unencrypted_mail_count
|
|||||||
filtered_incoming_mail_count++
|
filtered_incoming_mail_count++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
counter incoming_mailer_daemon_mail_count
|
||||||
|
/Incoming: Filtering mailer-daemon message from/ {
|
||||||
|
incoming_mailer_daemon_mail_count++
|
||||||
|
filtered_incoming_mail_count++
|
||||||
|
}
|
||||||
|
|
||||||
counter rejected_unencrypted_mail_count
|
counter rejected_unencrypted_mail_count
|
||||||
/Rejected unencrypted mail/ {
|
/Rejected unencrypted mail/ {
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
from pyinfra import facts, host
|
from pyinfra import facts, host
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import (
|
from cmdeploy.basedeploy import Deployer
|
||||||
Deployer,
|
from cmdeploy.pins import FILTERMAIL_ARTIFACTS, MTAIL_ARTIFACTS
|
||||||
get_resource,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MtailDeployer(Deployer):
|
class MtailDeployer(Deployer):
|
||||||
|
bin_path = "/usr/local/bin/mtail"
|
||||||
|
progs_dir = "/etc/mtail"
|
||||||
|
|
||||||
def __init__(self, mtail_address):
|
def __init__(self, mtail_address):
|
||||||
self.mtail_address = mtail_address
|
self.mtail_address = mtail_address
|
||||||
|
|
||||||
@@ -15,54 +16,47 @@ class MtailDeployer(Deployer):
|
|||||||
# Uninstall mtail package to install a static binary.
|
# Uninstall mtail package to install a static binary.
|
||||||
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
|
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False)
|
||||||
|
|
||||||
(url, sha256sum) = {
|
(url, sha256sum) = MTAIL_ARTIFACTS[host.get_fact(facts.server.Arch)]
|
||||||
"x86_64": (
|
self.download_executable(
|
||||||
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
|
url,
|
||||||
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
|
self.bin_path,
|
||||||
),
|
sha256sum,
|
||||||
"aarch64": (
|
extract="gunzip | tar -xf - mtail -O",
|
||||||
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
|
|
||||||
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
|
|
||||||
),
|
|
||||||
}[host.get_fact(facts.server.Arch)]
|
|
||||||
|
|
||||||
server.shell(
|
|
||||||
name="Download mtail",
|
|
||||||
commands=[
|
|
||||||
f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)",
|
|
||||||
"chmod 755 /usr/local/bin/mtail",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
|
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
|
||||||
# This allows to read from journalctl instead of log files.
|
# This allows to read from journalctl instead of log files.
|
||||||
files.template(
|
self.ensure_systemd_unit(
|
||||||
src=get_resource("mtail/mtail.service.j2"),
|
"mtail/mtail.service.j2",
|
||||||
dest="/etc/systemd/system/mtail.service",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
address=self.mtail_address or "127.0.0.1",
|
address=self.mtail_address or "127.0.0.1",
|
||||||
port=3903,
|
port=3903,
|
||||||
|
bin_path=self.bin_path,
|
||||||
|
progs_dir=self.progs_dir,
|
||||||
)
|
)
|
||||||
|
if self.mtail_address:
|
||||||
mtail_conf = files.put(
|
self.put_file(
|
||||||
name="Mtail configuration",
|
"mtail/delivered_mail.mtail", f"{self.progs_dir}/delivered_mail.mtail"
|
||||||
src=get_resource("mtail/delivered_mail.mtail"),
|
)
|
||||||
dest="/etc/mtail/delivered_mail.mtail",
|
url, sha256sum = FILTERMAIL_ARTIFACTS['mtail']
|
||||||
user="root",
|
self.download_executable(
|
||||||
group="root",
|
url,
|
||||||
mode="644",
|
f"{self.progs_dir}/filtermail.mtail",
|
||||||
)
|
sha256sum,
|
||||||
self.need_restart = mtail_conf.changed
|
mode="644",
|
||||||
|
)
|
||||||
|
if self.need_restart:
|
||||||
|
# Check if all installed mtail rules compile or fail early
|
||||||
|
# --one_shot to exit, --port 0 to not clash with running mtail.
|
||||||
|
server.shell(
|
||||||
|
name="Validate mtail programs",
|
||||||
|
commands=[
|
||||||
|
f"timeout 30 {self.bin_path} --compile_only --one_shot"
|
||||||
|
f" --progs {self.progs_dir} --logs /dev/null"
|
||||||
|
" --address 127.0.0.1 --port 0"
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
active = bool(self.mtail_address)
|
||||||
name="Start and enable mtail",
|
self.ensure_service("mtail.service", running=active, enabled=active)
|
||||||
service="mtail.service",
|
|
||||||
running=bool(self.mtail_address),
|
|
||||||
enabled=bool(self.mtail_address),
|
|
||||||
restarted=self.need_restart,
|
|
||||||
)
|
|
||||||
self.need_restart = False
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=mtail
|
Description=mtail
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -"
|
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | {{ bin_path }} --address={{ address }} --port={{ port }} --progs {{ progs_dir }} --logtostderr --logs -"
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
RestartSec=2s
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from chatmaild.config import Config
|
from chatmaild.config import Config
|
||||||
from pyinfra.operations import apt, files, systemd
|
from pyinfra.operations import apt
|
||||||
|
|
||||||
from cmdeploy.basedeploy import (
|
from cmdeploy.basedeploy import (
|
||||||
Deployer,
|
Deployer,
|
||||||
@@ -31,87 +31,50 @@ class NginxDeployer(Deployer):
|
|||||||
# For documentation about policy-rc.d, see:
|
# For documentation about policy-rc.d, see:
|
||||||
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
|
# https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
|
||||||
#
|
#
|
||||||
files.put(
|
self.put_executable(src="policy-rc.d", dest="/usr/sbin/policy-rc.d")
|
||||||
src=get_resource("policy-rc.d"),
|
|
||||||
dest="/usr/sbin/policy-rc.d",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
)
|
|
||||||
|
|
||||||
apt.packages(
|
apt.packages(
|
||||||
name="Install nginx",
|
name="Install nginx",
|
||||||
packages=["nginx", "libnginx-mod-stream"],
|
packages=["nginx", "libnginx-mod-stream"],
|
||||||
)
|
)
|
||||||
|
|
||||||
files.file("/usr/sbin/policy-rc.d", present=False)
|
self.remove_file("/usr/sbin/policy-rc.d")
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
self.need_restart = _configure_nginx(self.config)
|
_configure_nginx(self, self.config)
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service("nginx.service")
|
||||||
name="Start and enable nginx",
|
|
||||||
service="nginx.service",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
restarted=self.need_restart,
|
|
||||||
)
|
|
||||||
self.need_restart = False
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_nginx(config: Config, debug: bool = False) -> bool:
|
def _configure_nginx(deployer, config: Config, debug: bool = False):
|
||||||
"""Configures nginx HTTP server."""
|
"""Configures nginx HTTP server."""
|
||||||
need_restart = False
|
|
||||||
|
|
||||||
main_config = files.template(
|
deployer.put_template(
|
||||||
src=get_resource("nginx/nginx.conf.j2"),
|
"nginx/nginx.conf.j2",
|
||||||
dest="/etc/nginx/nginx.conf",
|
"/etc/nginx/nginx.conf",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config=config,
|
config=config,
|
||||||
disable_ipv6=config.disable_ipv6,
|
disable_ipv6=config.disable_ipv6,
|
||||||
)
|
)
|
||||||
need_restart |= main_config.changed
|
|
||||||
|
|
||||||
autoconfig = files.template(
|
deployer.put_template(
|
||||||
src=get_resource("nginx/autoconfig.xml.j2"),
|
"nginx/autoconfig.xml.j2",
|
||||||
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
|
"/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
need_restart |= autoconfig.changed
|
|
||||||
|
|
||||||
mta_sts_config = files.template(
|
deployer.put_template(
|
||||||
src=get_resource("nginx/mta-sts.txt.j2"),
|
"nginx/mta-sts.txt.j2",
|
||||||
dest="/var/www/html/.well-known/mta-sts.txt",
|
"/var/www/html/.well-known/mta-sts.txt",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
need_restart |= mta_sts_config.changed
|
|
||||||
|
|
||||||
# install CGI newemail script
|
# install CGI newemail script
|
||||||
#
|
#
|
||||||
cgi_dir = "/usr/lib/cgi-bin"
|
cgi_dir = "/usr/lib/cgi-bin"
|
||||||
files.directory(
|
deployer.ensure_directory(cgi_dir)
|
||||||
name=f"Ensure {cgi_dir} exists",
|
|
||||||
path=cgi_dir,
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
)
|
|
||||||
|
|
||||||
files.put(
|
deployer.put_executable(
|
||||||
name="Upload cgi newemail.py script",
|
|
||||||
src=get_resource("newemail.py", pkg="chatmaild").open("rb"),
|
src=get_resource("newemail.py", pkg="chatmaild").open("rb"),
|
||||||
dest=f"{cgi_dir}/newemail.py",
|
dest=f"{cgi_dir}/newemail.py",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="755",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return need_restart
|
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ stream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
# access_log setting is inherited by all server sections
|
||||||
|
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||||
|
|
||||||
{% if config.tls_cert_mode == "self" %}
|
{% if config.tls_cert_mode == "self" %}
|
||||||
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
|
limit_req_zone $binary_remote_addr zone=newaccount:10m rate=2r/s;
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -69,11 +72,9 @@ http {
|
|||||||
|
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|
||||||
server_name {{ config.mail_domain }} www.{{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
|
server_name {{ config.mail_domain }} mta-sts.{{ config.mail_domain }};
|
||||||
|
|
||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
location /mxdeliv {
|
||||||
|
|
||||||
location /mxdeliv/ {
|
|
||||||
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
|
proxy_pass http://127.0.0.1:{{ config.filtermail_http_port_incoming }};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +128,10 @@ http {
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /relay/probe {
|
# Endpoints Iroh uses for net_report probes and that clients
|
||||||
|
# probe to tell whether this relay works. Both paths are served
|
||||||
|
# by iroh-relay 0.35 and by the 1.0 line.
|
||||||
|
location /ping {
|
||||||
proxy_pass http://127.0.0.1:3340;
|
proxy_pass http://127.0.0.1:3340;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,6 @@ http {
|
|||||||
listen 127.0.0.1:8443 ssl;
|
listen 127.0.0.1:8443 ssl;
|
||||||
server_name www.{{ config.mail_domain }};
|
server_name www.{{ config.mail_domain }};
|
||||||
return 301 $scheme://{{ config.mail_domain }}$request_uri;
|
return 301 $scheme://{{ config.mail_domain }}$request_uri;
|
||||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Installs OpenDKIM
|
|||||||
|
|
||||||
from pyinfra import host
|
from pyinfra import host
|
||||||
from pyinfra.facts.files import File
|
from pyinfra.facts.files import File
|
||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, files, server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer, get_resource
|
from cmdeploy.basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
class OpendkimDeployer(Deployer):
|
class OpendkimDeployer(Deployer):
|
||||||
@@ -25,65 +25,39 @@ class OpendkimDeployer(Deployer):
|
|||||||
domain = self.mail_domain
|
domain = self.mail_domain
|
||||||
dkim_selector = "opendkim"
|
dkim_selector = "opendkim"
|
||||||
"""Configures OpenDKIM"""
|
"""Configures OpenDKIM"""
|
||||||
need_restart = False
|
|
||||||
|
|
||||||
main_config = files.template(
|
self.put_template(
|
||||||
src=get_resource("opendkim/opendkim.conf"),
|
"opendkim/opendkim.conf",
|
||||||
dest="/etc/opendkim.conf",
|
"/etc/opendkim.conf",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
||||||
)
|
)
|
||||||
need_restart |= main_config.changed
|
|
||||||
|
|
||||||
screen_script = files.file(
|
self.remove_file("/etc/opendkim/screen.lua")
|
||||||
path="/etc/opendkim/screen.lua",
|
self.remove_file("/etc/opendkim/final.lua")
|
||||||
present=False,
|
|
||||||
)
|
|
||||||
need_restart |= screen_script.changed
|
|
||||||
|
|
||||||
final_script = files.file(
|
self.ensure_directory(
|
||||||
path="/etc/opendkim/final.lua",
|
"/etc/opendkim",
|
||||||
present=False,
|
owner="opendkim",
|
||||||
)
|
|
||||||
need_restart |= final_script.changed
|
|
||||||
|
|
||||||
files.directory(
|
|
||||||
name="Add opendkim directory to /etc",
|
|
||||||
path="/etc/opendkim",
|
|
||||||
user="opendkim",
|
|
||||||
group="opendkim",
|
|
||||||
mode="750",
|
mode="750",
|
||||||
present=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
keytable = files.template(
|
self.put_template(
|
||||||
src=get_resource("opendkim/KeyTable"),
|
"opendkim/KeyTable",
|
||||||
dest="/etc/dkimkeys/KeyTable",
|
"/etc/dkimkeys/KeyTable",
|
||||||
user="opendkim",
|
owner="opendkim",
|
||||||
group="opendkim",
|
|
||||||
mode="644",
|
|
||||||
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
||||||
)
|
)
|
||||||
need_restart |= keytable.changed
|
|
||||||
|
|
||||||
signing_table = files.template(
|
self.put_template(
|
||||||
src=get_resource("opendkim/SigningTable"),
|
"opendkim/SigningTable",
|
||||||
dest="/etc/dkimkeys/SigningTable",
|
"/etc/dkimkeys/SigningTable",
|
||||||
user="opendkim",
|
owner="opendkim",
|
||||||
group="opendkim",
|
|
||||||
mode="644",
|
|
||||||
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
||||||
)
|
)
|
||||||
need_restart |= signing_table.changed
|
self.ensure_directory(
|
||||||
files.directory(
|
"/var/spool/postfix/opendkim",
|
||||||
name="Add opendkim socket directory to /var/spool/postfix",
|
owner="opendkim",
|
||||||
path="/var/spool/postfix/opendkim",
|
|
||||||
user="opendkim",
|
|
||||||
group="opendkim",
|
|
||||||
mode="750",
|
mode="750",
|
||||||
present=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
|
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
|
||||||
@@ -96,12 +70,10 @@ class OpendkimDeployer(Deployer):
|
|||||||
_su_user="opendkim",
|
_su_user="opendkim",
|
||||||
)
|
)
|
||||||
|
|
||||||
service_file = files.put(
|
self.put_file(
|
||||||
name="Configure opendkim to restart once a day",
|
"opendkim/systemd.conf",
|
||||||
src=get_resource("opendkim/systemd.conf"),
|
"/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
|
||||||
dest="/etc/systemd/system/opendkim.service.d/10-prevent-memory-leak.conf",
|
|
||||||
)
|
)
|
||||||
need_restart |= service_file.changed
|
|
||||||
|
|
||||||
files.file(
|
files.file(
|
||||||
name="chown opendkim: /etc/dkimkeys/opendkim.private",
|
name="chown opendkim: /etc/dkimkeys/opendkim.private",
|
||||||
@@ -110,15 +82,5 @@ class OpendkimDeployer(Deployer):
|
|||||||
group="opendkim",
|
group="opendkim",
|
||||||
)
|
)
|
||||||
|
|
||||||
self.need_restart = need_restart
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
systemd.service(
|
self.ensure_service("opendkim.service")
|
||||||
name="Start and enable OpenDKIM",
|
|
||||||
service="opendkim.service",
|
|
||||||
running=True,
|
|
||||||
enabled=True,
|
|
||||||
daemon_reload=self.need_restart,
|
|
||||||
restarted=self.need_restart,
|
|
||||||
)
|
|
||||||
self.need_restart = False
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Versions, hashes, and download URLs for pre-built artifacts fetched during deploy."""
|
||||||
|
|
||||||
|
FILTERMAIL_VERSION = "v0.7.4"
|
||||||
|
FILTERMAIL_ARTIFACTS = {
|
||||||
|
"x86_64": (
|
||||||
|
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-x86_64",
|
||||||
|
"484cb8dff083134aefba9fce4a6b7ef4784a0f0e28e5108ecf8bb9e58a44fd2c",
|
||||||
|
),
|
||||||
|
"aarch64": (
|
||||||
|
f"https://github.com/chatmail/filtermail/releases/download/{FILTERMAIL_VERSION}/filtermail-aarch64",
|
||||||
|
"66aa0ca2ca9add7a12d92883d76f8786384092adfde24a3d3a1d0b1f30d23a9e",
|
||||||
|
),
|
||||||
|
"mtail": (
|
||||||
|
f"https://raw.githubusercontent.com/chatmail/filtermail/{FILTERMAIL_VERSION}/contrib/filtermail.mtail",
|
||||||
|
"948f688bb89ad47e6eb0fc8fa107e201a689f5adc264ff926be487a2a8562b51",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
MTAIL_VERSION = "3.4.9"
|
||||||
|
MTAIL_ARTIFACTS = {
|
||||||
|
"x86_64": (
|
||||||
|
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_amd64.tar.gz",
|
||||||
|
"55f64a87f71955bb871c724b4aadf19fe9d854e6327196919c7fe44943427eab",
|
||||||
|
),
|
||||||
|
"aarch64": (
|
||||||
|
f"https://github.com/jaqx0r/mtail/releases/download/v{MTAIL_VERSION}/mtail_{MTAIL_VERSION}_linux_arm64.tar.gz",
|
||||||
|
"e0a2b66b372ca257d7daeb7ba10f9233a2192a1f9057618fccc6be5c854a2a3c",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# distro-neutral base version, as committed in chatmail/dovecot debian/changelog
|
||||||
|
DOVECOT_VERSION = "2.3.21+dfsg1-3+chatmail2"
|
||||||
|
DOVECOT_SHA256 = {
|
||||||
|
("amd64", 12, "auth-lua"): "ef1b8e1db45147a74b48d63125bd61b2cc2f250e1006656ba1c58b9c12f5cde6",
|
||||||
|
("arm64", 12, "auth-lua"): "c1a06ee9374439893e397ba3b0cacf532a733290c184f4f30ea39df8699be329",
|
||||||
|
("amd64", 13, "auth-lua"): "6c0946d2516efcbcaa09a27df9b8ea701861cce270d71941056b3c69831a5ea2",
|
||||||
|
("arm64", 13, "auth-lua"): "5e6c9cfe47f7f3b8aa0d68a3e607161b6abaee1ad696cb1419e997b3099b2985",
|
||||||
|
("amd64", 12, "core"): "ac3977264d9b9a6fcec53fd3f5cdd2a79ca8aa0324de530c07e535008540826e",
|
||||||
|
("arm64", 12, "core"): "21626c9c9b52cbdcf1a17b5c09e3c4043e69aa371bf83cc2fcb3b7ddaecdc109",
|
||||||
|
("amd64", 13, "core"): "47c242ef23c17e700ac19d52d82c9fdb2ebd757d8beb3a7f6781d2de59f87bd0",
|
||||||
|
("arm64", 13, "core"): "c14c53f112c875f698c4cb6e5870c605cd0a9dd98d35a66e94ceb1827f8020a3",
|
||||||
|
("amd64", 12, "imapd"): "92a7ab5fc7dc32886a0c34404f919f1335d397b48c467e0c1ef77e56978f60ea",
|
||||||
|
("arm64", 12, "imapd"): "9369fd566fec4df109ef23debf34ea0417ae85beb29cbe7de619d4d1f31b120c",
|
||||||
|
("amd64", 13, "imapd"): "e38cc1266455f937ed62f971ea859c47e1a99247841ed0ad946963b524cfdbc5",
|
||||||
|
("arm64", 13, "imapd"): "11d97dabf23171b37f8b1335dfdb81d408f8b95391aea6d4066aecc9fde01dfe",
|
||||||
|
("amd64", 12, "lmtpd"): "dc3de473789969f7dd3504ac8783da5e42a446d2d7a305a4e9d7081a6dfe71ab",
|
||||||
|
("arm64", 12, "lmtpd"): "ae2cbd6c5c43f6d8e2172997b055448f4c79238e2f99cd9ab9200a7d9f548908",
|
||||||
|
("amd64", 13, "lmtpd"): "833b243e28c7baff141ecf37456e310f5d836e7944a3b9f2fe5074adf0d6a418",
|
||||||
|
("arm64", 13, "lmtpd"): "55af47a121ba7e23966b20ddaab2dff7feba4b34677864e045e31a702afa180d",
|
||||||
|
}
|
||||||
|
TURN_VERSION = "v0.4"
|
||||||
|
TURN_ARTIFACTS = {
|
||||||
|
"x86_64": (
|
||||||
|
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-x86_64-linux",
|
||||||
|
"1ec1f5c50122165e858a5a91bcba9037a28aa8cb8b64b8db570aa457c6141a8a",
|
||||||
|
),
|
||||||
|
"aarch64": (
|
||||||
|
f"https://github.com/chatmail/chatmail-turn/releases/download/{TURN_VERSION}/chatmail-turn-aarch64-linux",
|
||||||
|
"0fb3e792419494e21ecad536464929dba706bb2c88884ed8f1788141d26fc756",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
IROH_VERSION = "v0.35.0"
|
||||||
|
IROH_ARTIFACTS = {
|
||||||
|
"x86_64": (
|
||||||
|
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-x86_64-unknown-linux-musl.tar.gz",
|
||||||
|
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1",
|
||||||
|
),
|
||||||
|
"aarch64": (
|
||||||
|
f"https://github.com/n0-computer/iroh/releases/download/{IROH_VERSION}/iroh-relay-{IROH_VERSION}-aarch64-unknown-linux-musl.tar.gz",
|
||||||
|
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1",
|
||||||
|
),
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
from pyinfra.operations import apt, files, server, systemd
|
from pyinfra.operations import apt, server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer, get_resource
|
from cmdeploy.basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
class PostfixDeployer(Deployer):
|
class PostfixDeployer(Deployer):
|
||||||
required_users = [("postfix", None, ["opendkim"])]
|
required_users = [("postfix", None, ["opendkim"])]
|
||||||
daemon_reload = False
|
|
||||||
|
|
||||||
def __init__(self, config, disable_mail):
|
def __init__(self, config, disable_mail):
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -19,81 +18,46 @@ class PostfixDeployer(Deployer):
|
|||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
config = self.config
|
config = self.config
|
||||||
need_restart = False
|
|
||||||
|
|
||||||
main_config = files.template(
|
self.put_template(
|
||||||
src=get_resource("postfix/main.cf.j2"),
|
"postfix/main.cf.j2",
|
||||||
dest="/etc/postfix/main.cf",
|
"/etc/postfix/main.cf",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
config=config,
|
config=config,
|
||||||
disable_ipv6=config.disable_ipv6,
|
disable_ipv6=config.disable_ipv6,
|
||||||
)
|
)
|
||||||
need_restart |= main_config.changed
|
|
||||||
|
|
||||||
master_config = files.template(
|
self.put_template(
|
||||||
src=get_resource("postfix/master.cf.j2"),
|
"postfix/master.cf.j2",
|
||||||
dest="/etc/postfix/master.cf",
|
"/etc/postfix/master.cf",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
debug=False,
|
debug=False,
|
||||||
config=config,
|
config=config,
|
||||||
)
|
)
|
||||||
need_restart |= master_config.changed
|
|
||||||
|
|
||||||
header_cleanup = files.put(
|
self.put_file(
|
||||||
src=get_resource("postfix/submission_header_cleanup"),
|
"postfix/submission_header_cleanup",
|
||||||
dest="/etc/postfix/submission_header_cleanup",
|
"/etc/postfix/submission_header_cleanup",
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
need_restart |= header_cleanup.changed
|
self.put_file("postfix/lmtp_header_cleanup", "/etc/postfix/lmtp_header_cleanup")
|
||||||
|
|
||||||
lmtp_header_cleanup = files.put(
|
res = self.put_file(
|
||||||
src=get_resource("postfix/lmtp_header_cleanup"),
|
"postfix/smtp_tls_policy_map", "/etc/postfix/smtp_tls_policy_map"
|
||||||
dest="/etc/postfix/lmtp_header_cleanup",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
)
|
||||||
need_restart |= lmtp_header_cleanup.changed
|
tls_policy_changed = res.changed
|
||||||
|
if tls_policy_changed:
|
||||||
tls_policy_map = files.put(
|
|
||||||
name="Upload SMTP TLS Policy that accepts self-signed certificates for IP-only hosts",
|
|
||||||
src=get_resource("postfix/smtp_tls_policy_map"),
|
|
||||||
dest="/etc/postfix/smtp_tls_policy_map",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
need_restart |= tls_policy_map.changed
|
|
||||||
if tls_policy_map.changed:
|
|
||||||
server.shell(
|
server.shell(
|
||||||
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
|
commands=["postmap /etc/postfix/smtp_tls_policy_map"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Login map that 1:1 maps email address to login.
|
# Login map that 1:1 maps email address to login.
|
||||||
login_map = files.put(
|
self.put_file("postfix/login_map", "/etc/postfix/login_map")
|
||||||
src=get_resource("postfix/login_map"),
|
|
||||||
dest="/etc/postfix/login_map",
|
|
||||||
user="root",
|
|
||||||
group="root",
|
|
||||||
mode="644",
|
|
||||||
)
|
|
||||||
need_restart |= login_map.changed
|
|
||||||
|
|
||||||
restart_conf = files.put(
|
self.put_file(
|
||||||
name="postfix: restart automatically on failure",
|
"service/10_restart_on_failure.conf",
|
||||||
src=get_resource("service/10_restart.conf"),
|
"/etc/systemd/system/postfix@.service.d/10_restart.conf",
|
||||||
dest="/etc/systemd/system/postfix@.service.d/10_restart.conf",
|
|
||||||
)
|
)
|
||||||
self.daemon_reload = restart_conf.changed
|
|
||||||
|
|
||||||
# Validate postfix configuration before restart
|
# Validate postfix configuration before restart
|
||||||
if need_restart:
|
if self.need_restart:
|
||||||
server.shell(
|
server.shell(
|
||||||
name="Validate postfix configuration",
|
name="Validate postfix configuration",
|
||||||
# Extract stderr and quit with error if non-zero
|
# Extract stderr and quit with error if non-zero
|
||||||
@@ -101,19 +65,11 @@ class PostfixDeployer(Deployer):
|
|||||||
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
|
"""bash -c 'w=$(postconf 2>&1 >/dev/null); [[ -z "$w" ]] || { echo "$w"; false; }'"""
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.need_restart = need_restart
|
|
||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
restart = False if self.disable_mail else self.need_restart
|
active = not self.disable_mail
|
||||||
|
self.ensure_service(
|
||||||
systemd.service(
|
"postfix.service",
|
||||||
name="disable postfix for now"
|
running=active,
|
||||||
if self.disable_mail
|
enabled=active,
|
||||||
else "Start and enable Postfix",
|
|
||||||
service="postfix.service",
|
|
||||||
running=False if self.disable_mail else True,
|
|
||||||
enabled=False if self.disable_mail else True,
|
|
||||||
restarted=restart,
|
|
||||||
daemon_reload=self.daemon_reload,
|
|
||||||
)
|
)
|
||||||
self.need_restart = False
|
|
||||||
|
|||||||
@@ -53,15 +53,19 @@ smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
|
|||||||
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
|
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
|
||||||
tls_preempt_cipherlist = yes
|
tls_preempt_cipherlist = yes
|
||||||
|
|
||||||
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
|
# Reject by default, override per smtpd in master.cf
|
||||||
myhostname = {{ config.mail_domain }}
|
smtpd_relay_restrictions = reject
|
||||||
|
myhostname = {{ config.postfix_myhostname }}
|
||||||
alias_maps = hash:/etc/aliases
|
alias_maps = hash:/etc/aliases
|
||||||
alias_database = hash:/etc/aliases
|
alias_database = hash:/etc/aliases
|
||||||
|
|
||||||
# Postfix does not deliver mail for any domain by itself.
|
# When postfix receives mail for $mydestination,
|
||||||
# Primary domain is listed in `virtual_mailbox_domains` instead
|
# it hands it over to dovecot via $local_transport.
|
||||||
# and handed over to Dovecot.
|
# Note: IP literals must be handled via local delivery / mydestination.
|
||||||
mydestination =
|
mydestination = {{ config.mail_domain }}
|
||||||
|
local_transport = lmtp:unix:private/dovecot-lmtp
|
||||||
|
# postfix doesn't check whether local users exist or not:
|
||||||
|
local_recipient_maps =
|
||||||
|
|
||||||
relayhost =
|
relayhost =
|
||||||
{% if disable_ipv6 %}
|
{% if disable_ipv6 %}
|
||||||
@@ -79,26 +83,16 @@ inet_protocols = ipv4
|
|||||||
inet_protocols = all
|
inet_protocols = all
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# Postfix does not try IPv4 and IPv6 connections
|
|
||||||
# concurrently as of version 3.7.11.
|
|
||||||
#
|
|
||||||
# When relay has both A (IPv4) and AAAA (IPv6) records,
|
|
||||||
# but broken IPv6 connectivity,
|
|
||||||
# every second message is delayed by the connection timeout
|
|
||||||
# <https://www.postfix.org/postconf.5.html#smtp_connect_timeout>
|
|
||||||
# which defaults to 30 seconds. Reducing timeouts is not a solution
|
|
||||||
# as this will result in a failure to connect to slow servers.
|
|
||||||
#
|
|
||||||
# As a workaround we always prefer IPv4 when it is available.
|
|
||||||
#
|
|
||||||
# The setting is documented at
|
|
||||||
# <https://www.postfix.org/postconf.5.html#smtp_address_preference>
|
|
||||||
smtp_address_preference=ipv4
|
|
||||||
|
|
||||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
|
||||||
virtual_mailbox_domains = {{ config.mail_domain }}
|
|
||||||
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
lmtp_header_checks = regexp:/etc/postfix/lmtp_header_cleanup
|
||||||
|
|
||||||
|
# Do not apply header checks to MIME headers
|
||||||
|
# and other headers that are actually part of the message body.
|
||||||
|
# Reference:
|
||||||
|
# <https://www.postfix.org/postconf.5.html#disable_mime_input_processing>
|
||||||
|
# <https://www.postfix.org/header_checks.5.html>
|
||||||
|
# <https://stump.io/blog/2020/11/29/a-little-gotcha-with-postfixs-header_checks/>
|
||||||
|
disable_mime_input_processing = yes
|
||||||
|
|
||||||
mua_client_restrictions = permit_sasl_authenticated, reject
|
mua_client_restrictions = permit_sasl_authenticated, reject
|
||||||
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
|
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
|
||||||
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
|
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
|
||||||
@@ -109,3 +103,35 @@ smtpd_sender_login_maps = regexp:/etc/postfix/login_map
|
|||||||
# Do not lookup SMTP client hostnames to reduce delays
|
# Do not lookup SMTP client hostnames to reduce delays
|
||||||
# and avoid unnecessary DNS requests.
|
# and avoid unnecessary DNS requests.
|
||||||
smtpd_peername_lookup = no
|
smtpd_peername_lookup = no
|
||||||
|
|
||||||
|
# Use filtermail-transport to relay messages.
|
||||||
|
# We can't force postfix to split messages per destination,
|
||||||
|
# when specifying a custom next-hop,
|
||||||
|
# so instead this is handled in filtermail.
|
||||||
|
# We use LMTP instead SMTP so we can communicate per-recipient errors back to postfix.
|
||||||
|
default_transport = lmtp-filtermail:inet:[127.0.0.1]:{{ config.filtermail_lmtp_port_transport }}
|
||||||
|
|
||||||
|
# All deliveries over lmtp-filtermail are treated
|
||||||
|
# as having the same destination [127.0.0.1],
|
||||||
|
# so it is not possible to limit per-destination concurrency here,
|
||||||
|
# it is a job for filtermail-transport.
|
||||||
|
# Total number of parallel deliveries is limited
|
||||||
|
# by "maxproc" column in /etc/postfix/master.cf for lmtp-filtermail.
|
||||||
|
# Settings below are to prevent Postfix queue manager
|
||||||
|
# from limiting the number of LMTP connections to filtermail-transport.
|
||||||
|
# Read <https://www.postfix.org/TUNING_README.html#rope> and
|
||||||
|
# <https://www.postfix.org/SCHEDULER_README.html> for the details
|
||||||
|
# of the Postfix algorithm that we effectively disable here.
|
||||||
|
lmtp-filtermail_initial_destination_concurrency=10000
|
||||||
|
lmtp-filtermail_destination_concurrency_limit=10000
|
||||||
|
|
||||||
|
# Do not try to deliver messages for more than 2 days.
|
||||||
|
maximal_queue_lifetime = 2d
|
||||||
|
|
||||||
|
{% if not config.ipv4_relay %}
|
||||||
|
# DKIM-sign locally generated mail (bounces, DSNs).
|
||||||
|
# These bypass smtpd, so they need explicit milter configuration.
|
||||||
|
non_smtpd_milters = unix:opendkim/opendkim.sock
|
||||||
|
internal_mail_filter_classes = bounce
|
||||||
|
milter_macro_daemon_name = ORIGINATING
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ smtp inet n - y - - smtpd
|
|||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_tls_mandatory_protocols=>=TLSv1.2
|
-o smtpd_tls_mandatory_protocols=>=TLSv1.2
|
||||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
|
||||||
submission inet n - y - 5000 smtpd
|
-o smtpd_relay_restrictions=reject_unauth_destination
|
||||||
|
submission inet n - y - {{ config.max_smtp_connections }} smtpd
|
||||||
-o syslog_name=postfix/submission
|
-o syslog_name=postfix/submission
|
||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
-o smtpd_tls_mandatory_protocols=>=TLSv1.3
|
-o smtpd_tls_mandatory_protocols=>=TLSv1.3
|
||||||
@@ -31,9 +32,9 @@ submission inet n - y - 5000 smtpd
|
|||||||
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
||||||
-o smtpd_recipient_restrictions=
|
-o smtpd_recipient_restrictions=
|
||||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
-o smtpd_client_connection_count_limit=1000
|
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }}
|
||||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||||
smtps inet n - y - 5000 smtpd
|
smtps inet n - y - {{ config.max_smtp_connections }} smtpd
|
||||||
-o syslog_name=postfix/smtps
|
-o syslog_name=postfix/smtps
|
||||||
-o smtpd_tls_wrappermode=yes
|
-o smtpd_tls_wrappermode=yes
|
||||||
-o smtpd_tls_security_level=encrypt
|
-o smtpd_tls_security_level=encrypt
|
||||||
@@ -47,7 +48,7 @@ smtps inet n - y - 5000 smtpd
|
|||||||
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
||||||
-o smtpd_recipient_restrictions=
|
-o smtpd_recipient_restrictions=
|
||||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
-o smtpd_client_connection_count_limit=1000
|
-o smtpd_client_connection_count_limit={{ config.max_smtp_connections // 5 }}
|
||||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||||
#628 inet n - y - - qmqpd
|
#628 inet n - y - - qmqpd
|
||||||
pickup unix n - y 60 1 pickup
|
pickup unix n - y 60 1 pickup
|
||||||
@@ -75,17 +76,19 @@ lmtp unix - - y - - lmtp
|
|||||||
anvil unix - - y - 1 anvil
|
anvil unix - - y - 1 anvil
|
||||||
scache unix - - y - 1 scache
|
scache unix - - y - 1 scache
|
||||||
postlog unix-dgram n - n - 1 postlogd
|
postlog unix-dgram n - n - 1 postlogd
|
||||||
filter unix - n n - - lmtp
|
|
||||||
# Local SMTP server for reinjecting outgoing filtered mail.
|
# Local SMTP server for reinjecting outgoing filtered mail.
|
||||||
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd
|
||||||
-o syslog_name=postfix/reinject
|
-o syslog_name=postfix/reinject
|
||||||
-o milter_macro_daemon_name=ORIGINATING
|
-o milter_macro_daemon_name=ORIGINATING
|
||||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
|
||||||
-o cleanup_service_name=authclean
|
-o cleanup_service_name=authclean
|
||||||
|
-o smtpd_relay_restrictions=permit_mynetworks,reject
|
||||||
|
{% if not config.ipv4_relay %} -o smtpd_milters=unix:opendkim/opendkim.sock
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
# Local SMTP server for reinjecting incoming filtered mail
|
# Local SMTP server for reinjecting incoming filtered mail
|
||||||
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
|
||||||
-o syslog_name=postfix/reinject_incoming
|
-o syslog_name=postfix/reinject_incoming
|
||||||
|
-o smtpd_relay_restrictions=reject_unauth_destination
|
||||||
|
|
||||||
# Cleanup `Received` headers for authenticated mail
|
# Cleanup `Received` headers for authenticated mail
|
||||||
# to avoid leaking client IP.
|
# to avoid leaking client IP.
|
||||||
@@ -100,3 +103,16 @@ filter unix - n n - - lmtp
|
|||||||
# cannot send unprotected Subject.
|
# cannot send unprotected Subject.
|
||||||
authclean unix n - - - 0 cleanup
|
authclean unix n - - - 0 cleanup
|
||||||
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
|
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
|
||||||
|
|
||||||
|
# Reducing `maxproc` here may result in a head of line blocking
|
||||||
|
# when there are many messages sent to unreachable destinations
|
||||||
|
# at the same time.
|
||||||
|
# LMTP clients here talk to filtermail-transport.
|
||||||
|
# LMTP has no pipelining,
|
||||||
|
# so while filtermail-transport tries to deliver the message,
|
||||||
|
# possibly waiting for a long connection timeout
|
||||||
|
# or talking to a slow server, LMTP client cannot be reused.
|
||||||
|
lmtp-filtermail unix - - y - 500 lmtp
|
||||||
|
-o syslog_name=postfix/lmtp-filtermail
|
||||||
|
-o lmtp_header_checks=
|
||||||
|
-o lmtp_tls_security_level=none
|
||||||
|
|||||||
@@ -64,21 +64,25 @@ def get_dkim_entry(mail_domain, pre_command, dkim_selector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def query_dns(typ, domain):
|
def get_authoritative_ns(domain):
|
||||||
# Get autoritative nameserver from the SOA record.
|
ns_replies = [
|
||||||
soa_answers = [
|
|
||||||
x.split()
|
x.split()
|
||||||
for x in shell(
|
for x in shell(
|
||||||
f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress
|
f"dig -r -q {domain} -t NS +noall +authority +answer", print=log_progress
|
||||||
).split("\n")
|
).split("\n")
|
||||||
]
|
]
|
||||||
soa = [a for a in soa_answers if len(a) >= 3 and a[3] == "SOA"]
|
filtered_replies = [a for a in ns_replies if len(a) >= 5 and a[3] == "NS"]
|
||||||
if not soa:
|
if not filtered_replies:
|
||||||
return
|
return
|
||||||
ns = soa[0][4]
|
return filtered_replies[0][4]
|
||||||
|
|
||||||
|
|
||||||
|
def query_dns(typ, domain):
|
||||||
|
ns = get_authoritative_ns(domain)
|
||||||
|
|
||||||
# Query authoritative nameserver directly to bypass DNS cache.
|
# Query authoritative nameserver directly to bypass DNS cache.
|
||||||
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
|
direct_ns = f"@{ns}" if ns else ""
|
||||||
|
res = shell(f"dig {direct_ns} -r -q {domain} -t {typ} +short", print=log_progress)
|
||||||
return next((line for line in res.split("\n") if not line.startswith(";")), "")
|
return next((line for line in res.split("\n") if not line.startswith(";")), "")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
from pyinfra.operations import apt, server
|
from pyinfra.operations import server
|
||||||
|
|
||||||
from cmdeploy.basedeploy import Deployer
|
from ..basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
|
def openssl_selfsigned_args(domain, cert_path, key_path, days=36500):
|
||||||
@@ -34,11 +34,7 @@ class SelfSignedTlsDeployer(Deployer):
|
|||||||
self.cert_path = "/etc/ssl/certs/mailserver.pem"
|
self.cert_path = "/etc/ssl/certs/mailserver.pem"
|
||||||
self.key_path = "/etc/ssl/private/mailserver.key"
|
self.key_path = "/etc/ssl/private/mailserver.key"
|
||||||
|
|
||||||
def install(self):
|
|
||||||
apt.packages(
|
|
||||||
name="Install openssl",
|
|
||||||
packages=["openssl"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def configure(self):
|
def configure(self):
|
||||||
args = openssl_selfsigned_args(
|
args = openssl_selfsigned_args(
|
||||||
@@ -52,3 +48,5 @@ class SelfSignedTlsDeployer(Deployer):
|
|||||||
|
|
||||||
def activate(self):
|
def activate(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=vmail
|
User=vmail
|
||||||
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove
|
ExecStart={execpath} {config_path} -v --remove
|
||||||
|
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
User=vmail
|
User=vmail
|
||||||
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini
|
ExecStart={execpath} {config_path}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Chatmail dict authentication proxy for dovecot
|
Description=Chatmail HTTP authentication service for dovecot
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart={execpath} /run/doveauth/doveauth.socket {config_path}
|
ExecStart={execpath} {config_path}
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=30
|
RestartSec=5
|
||||||
User=vmail
|
User=vmail
|
||||||
RuntimeDirectory=doveauth
|
|
||||||
UMask=0077
|
UMask=0077
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
Restart=always
|
Restart=always
|
||||||
ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
|
ExecStart={bin_path} --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
|
||||||
|
|
||||||
# Create /run/chatmail-turn
|
# Create /run/chatmail-turn
|
||||||
RuntimeDirectory=chatmail-turn
|
RuntimeDirectory=chatmail-turn
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Run the lua scripts we ship under lupa, which bundles Lua 5.4 like dovecot."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from chatmaild.tests.plugin import * # noqa: F403
|
||||||
|
from lupa import lua54
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import get_resource
|
||||||
|
from cmdeploy.tests.plugin import * # noqa: F403
|
||||||
|
|
||||||
|
|
||||||
|
class Lua:
|
||||||
|
"""A Lua runtime to load shipped scripts and mocks into."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.rt = lua54.LuaRuntime(unpack_returned_tuples=True)
|
||||||
|
self.g = self.rt.globals()
|
||||||
|
|
||||||
|
def load(self, path):
|
||||||
|
self.rt.execute(get_resource(path).read_text())
|
||||||
|
|
||||||
|
def table(self, **kwargs):
|
||||||
|
return self.rt.table(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lua():
|
||||||
|
return Lua()
|
||||||
@@ -12,7 +12,7 @@ def test_init(tmp_path, maildomain):
|
|||||||
inipath = tmp_path.joinpath("chatmail.ini")
|
inipath = tmp_path.joinpath("chatmail.ini")
|
||||||
main(["init", "--config", str(inipath), maildomain])
|
main(["init", "--config", str(inipath), maildomain])
|
||||||
config = read_config(inipath)
|
config = read_config(inipath)
|
||||||
assert config.mail_domain == maildomain
|
assert config.mail_domain_bare == maildomain
|
||||||
|
|
||||||
|
|
||||||
def test_capabilities(imap):
|
def test_capabilities(imap):
|
||||||
@@ -89,12 +89,11 @@ def test_concurrent_logins_same_account(
|
|||||||
assert login_results.get()
|
assert login_results.get()
|
||||||
|
|
||||||
|
|
||||||
def test_no_vrfy(cmfactory, chatmail_config):
|
def test_no_vrfy(cmfactory, chatmail_config, maildomain):
|
||||||
ac = cmfactory.get_online_account()
|
ac = cmfactory.get_online_account()
|
||||||
addr = ac.get_config("addr")
|
addr = ac.get_config("addr")
|
||||||
domain = chatmail_config.mail_domain
|
|
||||||
|
|
||||||
s = smtplib.SMTP(domain)
|
s = smtplib.SMTP(maildomain)
|
||||||
s.starttls()
|
s.starttls()
|
||||||
|
|
||||||
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
|
||||||
|
|||||||
@@ -20,17 +20,17 @@ def test_fastcgi_working(maildomain, chatmail_config):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
|
@pytest.mark.filterwarnings("ignore::urllib3.exceptions.InsecureRequestWarning")
|
||||||
def test_newemail_configure(maildomain, rpc, chatmail_config):
|
def test_newemail_configure(maildomain, cmrpc, chatmail_config):
|
||||||
"""Test configuring accounts by scanning a QR code works."""
|
"""Test configuring accounts by scanning a QR code works."""
|
||||||
url = f"DCACCOUNT:https://{maildomain}/new"
|
url = f"DCACCOUNT:https://{maildomain}/new"
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
account_id = rpc.add_account()
|
account_id = cmrpc.add_account()
|
||||||
if chatmail_config.tls_cert_mode == "self":
|
if chatmail_config.tls_cert_mode == "self":
|
||||||
# deltachat core's rustls rejects self-signed HTTPS certs during
|
# deltachat core's rustls rejects self-signed HTTPS certs during
|
||||||
# set_config_from_qr, so fetch credentials via requests instead
|
# set_config_from_qr, so fetch credentials via requests instead
|
||||||
res = requests.post(f"https://{maildomain}/new", verify=False)
|
res = requests.post(f"https://{maildomain}/new", verify=False)
|
||||||
data = res.json()
|
data = res.json()
|
||||||
rpc.add_or_update_transport(account_id, {
|
cmrpc.add_or_update_transport(account_id, {
|
||||||
"addr": data["email"],
|
"addr": data["email"],
|
||||||
"password": data["password"],
|
"password": data["password"],
|
||||||
"imapServer": maildomain,
|
"imapServer": maildomain,
|
||||||
@@ -38,4 +38,4 @@ def test_newemail_configure(maildomain, rpc, chatmail_config):
|
|||||||
"certificateChecks": "acceptInvalidCertificates",
|
"certificateChecks": "acceptInvalidCertificates",
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
rpc.add_transport_from_qr(account_id, url)
|
cmrpc.add_transport_from_qr(account_id, url)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import subprocess
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from chatmaild.config import is_valid_ipv4
|
||||||
|
|
||||||
from cmdeploy import remote
|
from cmdeploy import remote
|
||||||
from cmdeploy.cmdeploy import get_sshexec
|
from cmdeploy.cmdeploy import get_sshexec
|
||||||
@@ -21,6 +22,8 @@ class TestSSHExecutor:
|
|||||||
assert out == out2
|
assert out == out2
|
||||||
|
|
||||||
def test_perform_initial(self, sshexec, maildomain):
|
def test_perform_initial(self, sshexec, maildomain):
|
||||||
|
if is_valid_ipv4(maildomain):
|
||||||
|
pytest.skip(f"{maildomain} is not a domain")
|
||||||
res = sshexec(
|
res = sshexec(
|
||||||
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
remote.rdns.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||||
)
|
)
|
||||||
@@ -61,8 +64,10 @@ class TestSSHExecutor:
|
|||||||
else:
|
else:
|
||||||
pytest.fail("didn't raise exception")
|
pytest.fail("didn't raise exception")
|
||||||
|
|
||||||
def test_opendkim_restarted(self, sshexec):
|
def test_opendkim_restarted(self, sshexec, maildomain):
|
||||||
"""check that opendkim is not running for longer than a day."""
|
"""check that opendkim is not running for longer than a day."""
|
||||||
|
if is_valid_ipv4(maildomain):
|
||||||
|
pytest.skip(f"{maildomain} is an IPv4 relay, opendkim is not installed")
|
||||||
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
|
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp"
|
||||||
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
|
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd))
|
||||||
datestring = out.split("=")[1]
|
datestring = out.split("=")[1]
|
||||||
@@ -189,6 +194,34 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
|||||||
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
|
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounces_are_dkim_signed(cmsetup, cmsetup2, maildata, maildomain):
|
||||||
|
# we send a message to non-existant user and expect a bounce message
|
||||||
|
# which will only get through if the bounce message was DKIM-signed
|
||||||
|
|
||||||
|
if is_valid_ipv4(maildomain):
|
||||||
|
pytest.skip("DKIM is not configured on IPv4-only relays")
|
||||||
|
|
||||||
|
sender = cmsetup2.gen_users(1)[0]
|
||||||
|
nonexistent = f"nosuchuser_test42@{cmsetup.maildomain}"
|
||||||
|
|
||||||
|
msg = maildata(
|
||||||
|
"encrypted.eml",
|
||||||
|
from_addr=sender.addr,
|
||||||
|
to_addr=nonexistent,
|
||||||
|
).as_string()
|
||||||
|
sender.smtp.sendmail(sender.addr, [nonexistent], msg)
|
||||||
|
|
||||||
|
def bounce_in_inbox():
|
||||||
|
messages = sender.imap.fetch_all_messages()
|
||||||
|
for m in messages:
|
||||||
|
if "mail delivery" in m.lower() or "undelivered" in m.lower():
|
||||||
|
return m
|
||||||
|
raise ValueError("bounce not yet in inbox")
|
||||||
|
|
||||||
|
bounce = try_n_times(30, bounce_in_inbox)
|
||||||
|
assert "nosuchuser_test42" in bounce
|
||||||
|
|
||||||
|
|
||||||
def try_n_times(n, f):
|
def try_n_times(n, f):
|
||||||
for _ in range(n - 1):
|
for _ in range(n - 1):
|
||||||
try:
|
try:
|
||||||
@@ -281,3 +314,15 @@ def test_deployed_state(remote):
|
|||||||
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
|
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
|
||||||
for i in range(len(remote_version)):
|
for i in range(len(remote_version)):
|
||||||
assert git_status[i] == remote_version[i], "You have undeployed changes."
|
assert git_status[i] == remote_version[i], "You have undeployed changes."
|
||||||
|
|
||||||
|
|
||||||
|
def test_nginx_access_log_only_defined_once(sshdomain):
|
||||||
|
sshexec = get_sshexec(sshdomain)
|
||||||
|
conf = sshexec(
|
||||||
|
call=remote.rshell.shell,
|
||||||
|
kwargs=dict(command="nginx -T 2>/dev/null"),
|
||||||
|
)
|
||||||
|
access_logs = [l for l in conf.splitlines() if l.strip().startswith("access_log")]
|
||||||
|
assert len(access_logs) == 1, (
|
||||||
|
f"expected 1 access_log, found {len(access_logs)}: {access_logs}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import imap_tools
|
import imap_tools
|
||||||
import pytest
|
import pytest
|
||||||
import requests
|
import requests
|
||||||
|
from chatmaild.tests.test_appversions import check_appversions
|
||||||
|
|
||||||
from cmdeploy.cmdeploy import get_sshexec
|
from cmdeploy.cmdeploy import get_sshexec
|
||||||
from cmdeploy.remote import rshell
|
from cmdeploy.remote import rshell
|
||||||
@@ -15,7 +17,7 @@ def imap_mailbox(cmfactory, ssl_context):
|
|||||||
(ac1,) = cmfactory.get_online_accounts(1)
|
(ac1,) = cmfactory.get_online_accounts(1)
|
||||||
user = ac1.get_config("addr")
|
user = ac1.get_config("addr")
|
||||||
password = ac1.get_config("mail_pw")
|
password = ac1.get_config("mail_pw")
|
||||||
host = user.split("@")[1]
|
host = user.split("@")[1].strip("[").strip("]")
|
||||||
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
||||||
mailbox.login(user, password)
|
mailbox.login(user, password)
|
||||||
mailbox.dc_ac = ac1
|
mailbox.dc_ac = ac1
|
||||||
@@ -51,6 +53,17 @@ class TestMetadataTokens:
|
|||||||
assert res == b"1111 2222"
|
assert res == b"1111 2222"
|
||||||
assert b"Getmetadata completed" in client.readline()
|
assert b"Getmetadata completed" in client.readline()
|
||||||
|
|
||||||
|
def test_get_appversions(self, imap_mailbox):
|
||||||
|
"get app version information shipped with the relay"
|
||||||
|
client = imap_mailbox.client
|
||||||
|
client.send(b'a01 GETMETADATA "" /shared/vendor/deltachat/appversions\n')
|
||||||
|
res = client.readline()
|
||||||
|
assert res[:1] == b"*"
|
||||||
|
res = client.readline().strip().rstrip(b")")
|
||||||
|
# the served value is a single line and passes the shipped file's schema
|
||||||
|
check_appversions(json.loads(res))
|
||||||
|
assert b"Getmetadata completed" in client.readline()
|
||||||
|
|
||||||
|
|
||||||
class TestEndToEndDeltaChat:
|
class TestEndToEndDeltaChat:
|
||||||
"Tests that use Delta Chat accounts on the chat mail instance."
|
"Tests that use Delta Chat accounts on the chat mail instance."
|
||||||
@@ -178,7 +191,7 @@ def test_hide_senders_ip_address(cmfactory, ssl_context):
|
|||||||
chat.send_text("testing submission header cleanup")
|
chat.send_text("testing submission header cleanup")
|
||||||
user2.wait_for_incoming_msg()
|
user2.wait_for_incoming_msg()
|
||||||
addr = user2.get_config("addr")
|
addr = user2.get_config("addr")
|
||||||
host = addr.split("@")[1]
|
host = addr.split("@")[1].strip("[").strip("]")
|
||||||
pw = user2.get_config("mail_pw")
|
pw = user2.get_config("mail_pw")
|
||||||
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
mailbox = imap_tools.MailBox(host, ssl_context=ssl_context)
|
||||||
mailbox.login(addr, pw)
|
mailbox.login(addr, pw)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import imaplib
|
import imaplib
|
||||||
import ipaddress
|
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
@@ -10,24 +9,17 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import is_valid_ipv4, read_config
|
||||||
|
|
||||||
conftestdir = Path(__file__).parent
|
|
||||||
|
|
||||||
|
|
||||||
def _is_ip(domain):
|
def format_mail_domain(raw_domain: str) -> str:
|
||||||
try:
|
if is_valid_ipv4(raw_domain):
|
||||||
ipaddress.ip_address(domain)
|
return f"[{raw_domain}]"
|
||||||
return True
|
return raw_domain
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_configure(config):
|
def pytest_configure(config):
|
||||||
config._benchresults = {}
|
config._benchresults = {}
|
||||||
config.addinivalue_line(
|
|
||||||
"markers", "slow: mark test to require --slow option to run"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_chatmail_config():
|
def _get_chatmail_config():
|
||||||
@@ -58,7 +50,7 @@ def chatmail_config(pytestconfig):
|
|||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def maildomain(chatmail_config):
|
def maildomain(chatmail_config):
|
||||||
return chatmail_config.mail_domain
|
return chatmail_config.mail_domain_bare
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
@@ -213,7 +205,7 @@ class ImapConn:
|
|||||||
status, res = self.conn.select()
|
status, res = self.conn.select()
|
||||||
if int(res[0]) == 0:
|
if int(res[0]) == 0:
|
||||||
raise ValueError("no messages in imap folder")
|
raise ValueError("no messages in imap folder")
|
||||||
status, results = self.conn.fetch("1:*", "(RFC822)")
|
status, results = self.conn.fetch("1:*", "(BODY.PEEK[])")
|
||||||
assert status == "OK"
|
assert status == "OK"
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -278,7 +270,6 @@ def gencreds(chatmail_config):
|
|||||||
|
|
||||||
def gen(domain=None):
|
def gen(domain=None):
|
||||||
domain = domain if domain else chatmail_config.mail_domain
|
domain = domain if domain else chatmail_config.mail_domain
|
||||||
addr_domain = f"[{domain}]" if _is_ip(domain) else domain
|
|
||||||
while 1:
|
while 1:
|
||||||
num = next(count)
|
num = next(count)
|
||||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||||
@@ -292,7 +283,7 @@ def gencreds(chatmail_config):
|
|||||||
password = "".join(
|
password = "".join(
|
||||||
random.choices(alphanumeric, k=chatmail_config.password_min_length)
|
random.choices(alphanumeric, k=chatmail_config.password_min_length)
|
||||||
)
|
)
|
||||||
yield f"{user}@{addr_domain}", f"{password}"
|
yield f"{user}@{domain}", f"{password}"
|
||||||
|
|
||||||
return lambda domain=None: next(gen(domain))
|
return lambda domain=None: next(gen(domain))
|
||||||
|
|
||||||
@@ -317,7 +308,8 @@ class ChatmailACFactory:
|
|||||||
|
|
||||||
def _make_transport(self, domain):
|
def _make_transport(self, domain):
|
||||||
"""Build a transport config dict for the given domain."""
|
"""Build a transport config dict for the given domain."""
|
||||||
addr, password = self.gencreds(domain)
|
domain_deliverable = format_mail_domain(domain)
|
||||||
|
addr, password = self.gencreds(domain_deliverable)
|
||||||
transport = {
|
transport = {
|
||||||
"addr": addr,
|
"addr": addr,
|
||||||
"password": password,
|
"password": password,
|
||||||
@@ -326,7 +318,7 @@ class ChatmailACFactory:
|
|||||||
"imapServer": domain,
|
"imapServer": domain,
|
||||||
"smtpServer": domain,
|
"smtpServer": domain,
|
||||||
}
|
}
|
||||||
if self.chatmail_config.tls_cert_mode == "self":
|
if domain.startswith("_") or is_valid_ipv4(domain):
|
||||||
transport["certificateChecks"] = "acceptInvalidCertificates"
|
transport["certificateChecks"] = "acceptInvalidCertificates"
|
||||||
return transport
|
return transport
|
||||||
|
|
||||||
@@ -341,16 +333,17 @@ class ChatmailACFactory:
|
|||||||
accounts = []
|
accounts = []
|
||||||
for _ in range(num):
|
for _ in range(num):
|
||||||
account = self.dc.add_account()
|
account = self.dc.add_account()
|
||||||
addr, password = self.gencreds(domain)
|
domain_deliverable = format_mail_domain(domain)
|
||||||
if _is_ip(domain):
|
addr, password = self.gencreds(domain_deliverable)
|
||||||
|
if is_valid_ipv4(domain):
|
||||||
# Use DCLOGIN scheme with explicit server hosts,
|
# Use DCLOGIN scheme with explicit server hosts,
|
||||||
# matching how madmail presents its addresses to users.
|
# matching how madmail presents its addresses to users.
|
||||||
qr = (
|
qr = (
|
||||||
f"dclogin:{addr}"
|
f"dclogin:{addr}"
|
||||||
f"?p={password}&v=1"
|
f"?p={password}&v=1"
|
||||||
f"&ih={domain}&ip=993"
|
f"&ih={domain}&ip=993&is=ssl"
|
||||||
f"&sh={domain}&sp=465"
|
f"&sh={domain}&sp=465&ss=ssl"
|
||||||
f"&ic=3&ss=default"
|
f"&ic=3"
|
||||||
)
|
)
|
||||||
future = account.add_transport_from_qr.future(qr)
|
future = account.add_transport_from_qr.future(qr)
|
||||||
else:
|
else:
|
||||||
@@ -361,7 +354,7 @@ class ChatmailACFactory:
|
|||||||
|
|
||||||
# ensure messages stay in INBOX so that they can be
|
# ensure messages stay in INBOX so that they can be
|
||||||
# concurrently fetched via extra IMAP connections during tests
|
# concurrently fetched via extra IMAP connections during tests
|
||||||
account.set_config("delete_server_after", "10")
|
account.set_config("bcc_self", "1")
|
||||||
accounts.append(account)
|
accounts.append(account)
|
||||||
|
|
||||||
for future in futures:
|
for future in futures:
|
||||||
@@ -378,8 +371,12 @@ class ChatmailACFactory:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def rpc(tmp_path_factory):
|
def cmrpc(tmp_path_factory):
|
||||||
"""Start a deltachat-rpc-server process for the test session."""
|
"""Start a deltachat-rpc-server process for the test session.
|
||||||
|
|
||||||
|
Not named "rpc": the deltachat-rpc-client pytest plugin registers a
|
||||||
|
function-scoped fixture under that name and would shadow this one.
|
||||||
|
"""
|
||||||
|
|
||||||
# NB: accounts_dir must NOT already exist as directory --
|
# NB: accounts_dir must NOT already exist as directory --
|
||||||
# core-rust only creates accounts.toml if the dir doesn't exist yet.
|
# core-rust only creates accounts.toml if the dir doesn't exist yet.
|
||||||
@@ -391,10 +388,10 @@ def rpc(tmp_path_factory):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def cmfactory(rpc, gencreds, maildomain, chatmail_config):
|
def cmfactory(cmrpc, gencreds, maildomain, chatmail_config):
|
||||||
"""Return a ChatmailACFactory for creating online Delta Chat accounts."""
|
"""Return a ChatmailACFactory for creating online Delta Chat accounts."""
|
||||||
return ChatmailACFactory(
|
return ChatmailACFactory(
|
||||||
rpc=rpc,
|
rpc=cmrpc,
|
||||||
maildomain=maildomain,
|
maildomain=maildomain,
|
||||||
gencreds=gencreds,
|
gencreds=gencreds,
|
||||||
chatmail_config=chatmail_config,
|
chatmail_config=chatmail_config,
|
||||||
@@ -416,10 +413,10 @@ class Remote:
|
|||||||
def iter_output(self, logcmd="", ready=None):
|
def iter_output(self, logcmd="", ready=None):
|
||||||
getjournal = "journalctl -f" if not logcmd else logcmd
|
getjournal = "journalctl -f" if not logcmd else logcmd
|
||||||
print(self.sshdomain)
|
print(self.sshdomain)
|
||||||
match self.sshdomain:
|
if self.sshdomain in ("@local", "localhost"):
|
||||||
case "@local": command = []
|
command = []
|
||||||
case "localhost": command = []
|
else:
|
||||||
case _: command = ["ssh", f"root@{self.sshdomain}"]
|
command = ["ssh", f"root@{self.sshdomain}"]
|
||||||
[command.append(arg) for arg in getjournal.split()]
|
[command.append(arg) for arg in getjournal.split()]
|
||||||
popen = subprocess.Popen(
|
popen = subprocess.Popen(
|
||||||
command,
|
command,
|
||||||
@@ -466,6 +463,11 @@ def cmsetup(maildomain, gencreds, ssl_context):
|
|||||||
return CMSetup(maildomain, gencreds, ssl_context)
|
return CMSetup(maildomain, gencreds, ssl_context)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cmsetup2(maildomain2, gencreds, ssl_context):
|
||||||
|
return CMSetup(maildomain2, gencreds, ssl_context)
|
||||||
|
|
||||||
|
|
||||||
class CMSetup:
|
class CMSetup:
|
||||||
def __init__(self, maildomain, gencreds, ssl_context):
|
def __init__(self, maildomain, gencreds, ssl_context):
|
||||||
self.maildomain = maildomain
|
self.maildomain = maildomain
|
||||||
@@ -476,7 +478,7 @@ class CMSetup:
|
|||||||
print(f"Creating {num} online users")
|
print(f"Creating {num} online users")
|
||||||
users = []
|
users = []
|
||||||
for i in range(num):
|
for i in range(num):
|
||||||
addr, password = self.gencreds()
|
addr, password = self.gencreds(format_mail_domain(self.maildomain))
|
||||||
user = CMUser(self.maildomain, addr, password, self.ssl_context)
|
user = CMUser(self.maildomain, addr, password, self.ssl_context)
|
||||||
assert user.smtp
|
assert user.smtp
|
||||||
users.append(user)
|
users.append(user)
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
[pytest]
|
|
||||||
addopts = -vrsx --strict-markers
|
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
"""Test auth.lua script against mocked dovecot auth API."""
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
import pytest
|
||||||
|
from chatmaild.doveauth import encrypt_password, verify_password
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import get_resource
|
||||||
|
|
||||||
|
USER1 = "user12345@chat.example.org"
|
||||||
|
USER2 = "newuser12@chat.example.org"
|
||||||
|
OK, UNKNOWN, MISMATCH, INTERNAL = 1, -2, -3, -4
|
||||||
|
|
||||||
|
DOVECOT_MOCKS = """
|
||||||
|
create_status = 200
|
||||||
|
|
||||||
|
dovecot = {
|
||||||
|
auth = {
|
||||||
|
PASSDB_RESULT_OK = OK,
|
||||||
|
PASSDB_RESULT_USER_UNKNOWN = UNKNOWN,
|
||||||
|
PASSDB_RESULT_PASSWORD_MISMATCH = MISMATCH,
|
||||||
|
PASSDB_RESULT_INTERNAL_FAILURE = INTERNAL,
|
||||||
|
USERDB_RESULT_OK = OK,
|
||||||
|
USERDB_RESULT_USER_UNKNOWN = UNKNOWN,
|
||||||
|
},
|
||||||
|
http = {
|
||||||
|
client = function(options)
|
||||||
|
client_options = options
|
||||||
|
return {request = function(_, options)
|
||||||
|
create_request = options
|
||||||
|
return {
|
||||||
|
set_payload = function(_, payload) create_payload = payload end,
|
||||||
|
submit = function()
|
||||||
|
return {status = function() return create_status end}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end}
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def load_authlua(lua, config):
|
||||||
|
lua.g.OK, lua.g.UNKNOWN = OK, UNKNOWN
|
||||||
|
lua.g.MISMATCH, lua.g.INTERNAL = MISMATCH, INTERNAL
|
||||||
|
lua.rt.execute(DOVECOT_MOCKS)
|
||||||
|
template = jinja2.Template(get_resource("dovecot/auth.lua.j2").read_text())
|
||||||
|
lua.rt.execute(template.render(config=config))
|
||||||
|
assert lua.g.script_init() == 0
|
||||||
|
return lua
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def authlua(lua, example_config):
|
||||||
|
return load_authlua(lua, example_config)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def request_for(lua):
|
||||||
|
def request_for(addr):
|
||||||
|
def password_verify(_self, hashed, plain):
|
||||||
|
return 1 if verify_password(hashed, plain) else 0
|
||||||
|
|
||||||
|
return lua.table(user=addr, password_verify=password_verify)
|
||||||
|
|
||||||
|
return request_for
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def create_user(example_config):
|
||||||
|
def create_user(addr, password):
|
||||||
|
example_config.get_user(addr).set_password(encrypt_password(password))
|
||||||
|
|
||||||
|
return create_user
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def write_password_file(example_config):
|
||||||
|
def write_password_file(addr, content):
|
||||||
|
maildir = example_config.mailboxes_dir / addr
|
||||||
|
maildir.mkdir(parents=True, exist_ok=True)
|
||||||
|
maildir.joinpath("password").write_text(content)
|
||||||
|
|
||||||
|
return write_password_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_client_uses_dovecot_setting_names(authlua):
|
||||||
|
"""dovecot's lua http binding silently ignores keys it does not know."""
|
||||||
|
assert dict(authlua.g.client_options) == {
|
||||||
|
"request_timeout_msecs": 5000,
|
||||||
|
"max_attempts": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_address_correct_password(authlua, request_for, create_user):
|
||||||
|
create_user(USER1, "correctgoose")
|
||||||
|
res, fields = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
|
||||||
|
assert res == OK
|
||||||
|
assert fields["uid"] == fields["gid"] == "vmail"
|
||||||
|
assert fields["home"].endswith(USER1)
|
||||||
|
assert authlua.g.create_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_existing_address_wrong_password(authlua, request_for, create_user):
|
||||||
|
create_user(USER1, "correctgoose")
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(USER1), "wronghorse")
|
||||||
|
assert res == MISMATCH
|
||||||
|
|
||||||
|
|
||||||
|
def test_foreign_domain_is_refused_without_calling_out(
|
||||||
|
authlua, request_for, create_user
|
||||||
|
):
|
||||||
|
create_user("user12345@evil.example.org", "correctgoose")
|
||||||
|
request = request_for("user12345@evil.example.org")
|
||||||
|
res, _ = authlua.g.auth_password_verify(request, "correctgoose")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
|
||||||
|
assert authlua.g.create_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_shorter_than_the_domain_is_refused(authlua, request_for):
|
||||||
|
for name in ("x", "", "chat.example.org"):
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(name), "correctgoose")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(name))[0] == UNKNOWN
|
||||||
|
assert authlua.g.create_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_slash_in_username_is_refused(authlua, request_for):
|
||||||
|
request = request_for("../../etc/shadow@chat.example.org")
|
||||||
|
res, _ = authlua.g.auth_password_verify(request, "somepassword")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
assert authlua.g.auth_userdb_lookup(request)[0] == UNKNOWN
|
||||||
|
assert authlua.g.create_payload is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_localpart_policy_is_left_to_doveauth(authlua, request_for):
|
||||||
|
authlua.g.create_status = 403
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for("@chat.example.org"), "somepw")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
assert authlua.g.create_payload == "@chat.example.org\tsomepw"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_address_is_created_via_endpoint(authlua, request_for):
|
||||||
|
res, fields = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
|
||||||
|
assert res == OK
|
||||||
|
assert fields["home"].endswith(USER2)
|
||||||
|
assert authlua.g.create_payload == f"{USER2}\tbrandnewpass"
|
||||||
|
assert authlua.g.create_request["url"] == "http://127.0.0.1:10084/create"
|
||||||
|
|
||||||
|
|
||||||
|
# doveauth refusing is the user's problem, doveauth failing is ours
|
||||||
|
@pytest.mark.parametrize("status", [400, 403, 404])
|
||||||
|
def test_creation_refused_by_doveauth_is_user_unknown(authlua, request_for, status):
|
||||||
|
authlua.g.create_status = status
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
# 9003 is dovecot's own CONNECT_FAILED, what a stopped doveauth actually yields
|
||||||
|
@pytest.mark.parametrize("status", [500, 502, 9003, 9005])
|
||||||
|
def test_creation_that_doveauth_could_not_answer_is_internal_failure(
|
||||||
|
authlua, request_for, status
|
||||||
|
):
|
||||||
|
authlua.g.create_status = status
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(USER2), "brandnewpass")
|
||||||
|
assert res == INTERNAL
|
||||||
|
|
||||||
|
|
||||||
|
def test_userdb_unknown_before_creation_ok_after(authlua, request_for, create_user):
|
||||||
|
request = request_for(USER1)
|
||||||
|
res, _ = authlua.g.auth_userdb_lookup(request)
|
||||||
|
assert res == UNKNOWN
|
||||||
|
# a userdb lookup must never create anything
|
||||||
|
assert authlua.g.create_payload is None
|
||||||
|
|
||||||
|
create_user(USER1, "correctgoose")
|
||||||
|
res, fields = authlua.g.auth_userdb_lookup(request)
|
||||||
|
assert res == OK
|
||||||
|
assert fields["home"].endswith(USER1)
|
||||||
|
assert fields["uid"] == fields["gid"] == "vmail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_password_file_is_unknown(authlua, request_for, write_password_file):
|
||||||
|
write_password_file(USER1, "")
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
|
||||||
|
|
||||||
|
write_password_file(USER1, "\n")
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_file_format_checks(authlua, request_for, write_password_file):
|
||||||
|
write_password_file(USER1, encrypt_password("correctgoose") + "\n")
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
|
||||||
|
assert res == OK
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == OK
|
||||||
|
|
||||||
|
passhash = encrypt_password("correctgoose")
|
||||||
|
write_password_file(USER1, passhash + "\ntrailing junk")
|
||||||
|
authlua.g.create_status = 403
|
||||||
|
res, _ = authlua.g.auth_password_verify(request_for(USER1), "correctgoose")
|
||||||
|
assert res == UNKNOWN
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
def test_ipv4_relay_uses_bracketed_domain(lua, ipv4_config, request_for):
|
||||||
|
# mail_domain is "[1.3.3.7]" here, and is_ours must not read it as a pattern
|
||||||
|
authlua = load_authlua(lua, ipv4_config)
|
||||||
|
addr = f"user12345@{ipv4_config.mail_domain}"
|
||||||
|
ipv4_config.get_user(addr).set_password(encrypt_password("correctgoose"))
|
||||||
|
|
||||||
|
res, fields = authlua.g.auth_password_verify(request_for(addr), "correctgoose")
|
||||||
|
assert res == OK
|
||||||
|
assert fields["home"].endswith(addr)
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(addr))[0] == OK
|
||||||
|
assert authlua.g.auth_userdb_lookup(request_for(USER1))[0] == UNKNOWN
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from cmdeploy.basedeploy import Deployer
|
||||||
|
|
||||||
|
|
||||||
|
def test_put_file_restart_and_reload():
|
||||||
|
deployer = Deployer()
|
||||||
|
mock_res = MagicMock()
|
||||||
|
mock_res.changed = True
|
||||||
|
|
||||||
|
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res):
|
||||||
|
deployer.put_file("foo.conf", "/etc/foo.conf")
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
assert deployer.daemon_reload is False
|
||||||
|
|
||||||
|
deployer = Deployer()
|
||||||
|
|
||||||
|
deployer.put_file("test.service", "/etc/systemd/system/test.service")
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
assert deployer.daemon_reload is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_file():
|
||||||
|
deployer = Deployer()
|
||||||
|
mock_res = MagicMock()
|
||||||
|
mock_res.changed = True
|
||||||
|
|
||||||
|
with patch("cmdeploy.basedeploy.files.file", return_value=mock_res) as mock_file:
|
||||||
|
deployer.remove_file("/etc/foo.conf")
|
||||||
|
mock_file.assert_called_once_with(
|
||||||
|
name="Remove /etc/foo.conf", path="/etc/foo.conf", present=False
|
||||||
|
)
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_systemd_unit():
|
||||||
|
deployer = Deployer()
|
||||||
|
mock_res = MagicMock()
|
||||||
|
mock_res.changed = True
|
||||||
|
|
||||||
|
# Plain service file
|
||||||
|
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
|
||||||
|
deployer.ensure_systemd_unit("iroh-relay.service")
|
||||||
|
assert (
|
||||||
|
mock_put.call_args.kwargs["dest"]
|
||||||
|
== "/etc/systemd/system/iroh-relay.service"
|
||||||
|
)
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
assert deployer.daemon_reload is True
|
||||||
|
|
||||||
|
deployer = Deployer()
|
||||||
|
|
||||||
|
# Template (.j2) dispatches to put_template and strips .j2 suffix
|
||||||
|
with patch("cmdeploy.basedeploy.files.template", return_value=mock_res) as mock_tpl:
|
||||||
|
deployer.ensure_systemd_unit(
|
||||||
|
"filtermail/chatmaild.service.j2",
|
||||||
|
bin_path="/usr/local/bin/filtermail",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
mock_tpl.call_args.kwargs["dest"] == "/etc/systemd/system/chatmaild.service"
|
||||||
|
)
|
||||||
|
|
||||||
|
deployer = Deployer()
|
||||||
|
|
||||||
|
# Explicit dest_name override
|
||||||
|
with patch("cmdeploy.basedeploy.files.put", return_value=mock_res) as mock_put:
|
||||||
|
deployer.ensure_systemd_unit(
|
||||||
|
"acmetool/acmetool-reconcile.timer",
|
||||||
|
dest_name="acmetool-reconcile.timer",
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
mock_put.call_args.kwargs["dest"]
|
||||||
|
== "/etc/systemd/system/acmetool-reconcile.timer"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_service():
|
||||||
|
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
|
||||||
|
deployer = Deployer()
|
||||||
|
deployer.need_restart = True
|
||||||
|
deployer.daemon_reload = True
|
||||||
|
deployer.ensure_service("nginx.service")
|
||||||
|
mock_svc.assert_called_once_with(
|
||||||
|
name="Start and enable nginx.service",
|
||||||
|
service="nginx.service",
|
||||||
|
running=True,
|
||||||
|
enabled=True,
|
||||||
|
restarted=True,
|
||||||
|
daemon_reload=True,
|
||||||
|
)
|
||||||
|
# daemon_reload is cleared to avoid multiple systemctl daemon-reload calls
|
||||||
|
# need_restart is kept to ensure all subsequent services also restart
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
assert deployer.daemon_reload is False
|
||||||
|
|
||||||
|
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
|
||||||
|
# Stopping suppresses restarted even when need_restart is True
|
||||||
|
deployer = Deployer()
|
||||||
|
deployer.need_restart = True
|
||||||
|
deployer.daemon_reload = True
|
||||||
|
deployer.ensure_service(
|
||||||
|
"mta-sts-daemon.service",
|
||||||
|
running=False,
|
||||||
|
enabled=False,
|
||||||
|
)
|
||||||
|
assert mock_svc.call_args.kwargs["restarted"] is False
|
||||||
|
assert deployer.need_restart is True
|
||||||
|
|
||||||
|
with patch("cmdeploy.basedeploy.systemd.service") as mock_svc:
|
||||||
|
# Multiple calls: daemon_reload resets after first, need_restart persists
|
||||||
|
deployer = Deployer()
|
||||||
|
deployer.need_restart = True
|
||||||
|
deployer.daemon_reload = True
|
||||||
|
deployer.ensure_service("chatmaild.service")
|
||||||
|
deployer.ensure_service("chatmaild-metadata.service")
|
||||||
|
second_call = mock_svc.call_args_list[1]
|
||||||
|
assert second_call.kwargs["restarted"] is True
|
||||||
|
assert second_call.kwargs["daemon_reload"] is False
|
||||||
@@ -39,6 +39,14 @@ class TestCmdline:
|
|||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert "deleting config file" in out.lower()
|
assert "deleting config file" in out.lower()
|
||||||
|
|
||||||
|
def test_dns_skip_on_ip(self, capsys, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("CHATMAIL_INI", raising=False)
|
||||||
|
inipath = tmp_path / "chatmail.ini"
|
||||||
|
assert main(["init", "--config", str(inipath), "1.3.3.7"]) == 0
|
||||||
|
assert main(["dns", "--config", str(inipath)]) == 0
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert out == "[WARNING] 1.3.3.7 is not a domain, skipping DNS checks.\n"
|
||||||
|
|
||||||
|
|
||||||
def test_www_folder(example_config, tmp_path):
|
def test_www_folder(example_config, tmp_path):
|
||||||
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
|
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pytest
|
|||||||
|
|
||||||
from cmdeploy import remote
|
from cmdeploy import remote
|
||||||
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
|
from cmdeploy.dns import check_full_zone, check_initial_remote_data, parse_zone_records
|
||||||
|
from cmdeploy.remote.rdns import get_authoritative_ns
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -14,11 +15,15 @@ def mockdns_base(monkeypatch):
|
|||||||
if command.startswith("dig"):
|
if command.startswith("dig"):
|
||||||
if command == "dig":
|
if command == "dig":
|
||||||
return "."
|
return "."
|
||||||
if "SOA" in command:
|
if "with.public.soa" in command and "NS" in command:
|
||||||
|
return "domain.with.public.soa. 2419 IN NS ns1.first-ns.de."
|
||||||
|
if "with.hidden.soa" in command and "NS" in command:
|
||||||
return (
|
return (
|
||||||
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
|
"domain.with.hidden.soa. 2137 IN NS ns1.desec.io.\n"
|
||||||
" 2025102800 14400 1800 604800 3600"
|
"domain.with.hidden.soa. 2137 IN NS ns2.desec.org."
|
||||||
)
|
)
|
||||||
|
if "NS" in command:
|
||||||
|
return "delta.chat. 21600 IN NS ns1.first-ns.de."
|
||||||
command_chunks = command.split()
|
command_chunks = command.split()
|
||||||
domain, typ = command_chunks[4], command_chunks[6]
|
domain, typ = command_chunks[4], command_chunks[6]
|
||||||
try:
|
try:
|
||||||
@@ -125,6 +130,17 @@ class TestPerformInitialChecks:
|
|||||||
assert not l
|
assert not l
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("domain", "ns"),
|
||||||
|
[
|
||||||
|
("domain.with.public.soa", "ns1.first-ns.de."),
|
||||||
|
("domain.with.hidden.soa", "ns1.desec.io."),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_get_authoritative_ns(domain, ns, mockdns):
|
||||||
|
assert get_authoritative_ns(domain) == ns
|
||||||
|
|
||||||
|
|
||||||
def test_parse_zone_records():
|
def test_parse_zone_records():
|
||||||
text = """
|
text = """
|
||||||
; This is a comment
|
; This is a comment
|
||||||
|
|||||||
@@ -3,30 +3,40 @@ from types import SimpleNamespace
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pyinfra.facts.deb import DebPackages
|
from pyinfra.facts.deb import DebPackages
|
||||||
|
from pyinfra.facts.server import Command
|
||||||
|
|
||||||
from cmdeploy.dovecot import deployer as dovecot_deployer
|
from cmdeploy.dovecot import deployer as dovecot_deployer
|
||||||
|
|
||||||
|
|
||||||
|
def _fact_name(key):
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
return f"{key[0].__name__}{key[1:]!r}"
|
||||||
|
return key.__name__
|
||||||
|
|
||||||
|
|
||||||
def make_host(*fact_pairs):
|
def make_host(*fact_pairs):
|
||||||
"""Build a mock host; get_fact(cls) dispatches to the provided facts mapping.
|
"""Build a mock host; get_fact() dispatches to the provided facts mapping.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*fact_pairs: tuples of (fact_class, fact_value) to register
|
*fact_pairs: (fact_class, value) to match any call of that fact, or
|
||||||
|
((fact_class, *args), value) to match one specific call. Needed
|
||||||
|
for Command, which install() and check_restart() invoke with
|
||||||
|
different scripts; a bare Command entry would serve both.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
SimpleNamespace with get_fact that raises a clear error if an
|
SimpleNamespace with get_fact that raises a clear error if an
|
||||||
unexpected fact type is requested.
|
unregistered fact is requested.
|
||||||
"""
|
"""
|
||||||
facts = dict(fact_pairs)
|
facts = dict(fact_pairs)
|
||||||
|
|
||||||
def get_fact(cls):
|
def get_fact(cls, *args):
|
||||||
if cls not in facts:
|
for key in ((cls, *args), cls):
|
||||||
registered = ", ".join(c.__name__ for c in facts)
|
if key in facts:
|
||||||
raise LookupError(
|
return facts[key]
|
||||||
f"unexpected get_fact({cls.__name__}); "
|
registered = ", ".join(_fact_name(k) for k in facts)
|
||||||
f"only registered: {registered}"
|
raise LookupError(
|
||||||
)
|
f"unexpected get_fact({_fact_name((cls, *args))}); only registered: {registered}"
|
||||||
return facts[cls]
|
)
|
||||||
|
|
||||||
return SimpleNamespace(get_fact=get_fact)
|
return SimpleNamespace(get_fact=get_fact)
|
||||||
|
|
||||||
@@ -65,7 +75,9 @@ def track_shell(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
||||||
epoch_version = dovecot_deployer.DOVECOT_PACKAGE_VERSION
|
# what dpkg reports after installing our deb: epoch + the +debNu1 suffix
|
||||||
|
# that chatmail/dovecot CI stamps via dch before building
|
||||||
|
epoch_version = f"1:{dovecot_deployer._stamped_version(12)}"
|
||||||
downloads = []
|
downloads = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dovecot_deployer,
|
dovecot_deployer,
|
||||||
@@ -83,15 +95,17 @@ def test_download_dovecot_package_skips_epoch_matched_install(monkeypatch):
|
|||||||
lambda **kwargs: downloads.append(kwargs),
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64", deb_release=12)
|
||||||
|
|
||||||
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
|
assert deb is None, f"expected no deb path when version matches, got {deb!r}"
|
||||||
assert changed is False, "should not flag changed when version already installed"
|
assert changed is False, "should not flag changed when version already installed"
|
||||||
assert downloads == [], "should not download when version already installed"
|
assert downloads == [], "should not download when version already installed"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("deb_release", [12, 13])
|
||||||
|
@pytest.mark.parametrize("arch", ["amd64", "arm64"])
|
||||||
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
||||||
monkeypatch,
|
monkeypatch, deb_release, arch
|
||||||
):
|
):
|
||||||
downloads = []
|
downloads = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -110,18 +124,26 @@ def test_download_dovecot_package_uses_archive_version_for_url_and_filename(
|
|||||||
lambda **kwargs: downloads.append(kwargs),
|
lambda **kwargs: downloads.append(kwargs),
|
||||||
)
|
)
|
||||||
|
|
||||||
deb, changed = dovecot_deployer._download_dovecot_package("core", "amd64")
|
deb, changed = dovecot_deployer._download_dovecot_package(
|
||||||
|
"core", arch, deb_release=deb_release
|
||||||
|
)
|
||||||
|
|
||||||
archive_version = dovecot_deployer.DOVECOT_ARCHIVE_VERSION.replace("+", "%2B")
|
stamped = dovecot_deployer._stamped_version(deb_release)
|
||||||
expected_deb = f"/root/dovecot-core_{archive_version}_amd64.deb"
|
expected_deb = f"/root/dovecot-core_{stamped}_{arch}.deb"
|
||||||
|
|
||||||
# Verify the returned path uses archive version, not package version (with epoch)
|
# path uses the stamped version, and deb filenames never carry the epoch
|
||||||
assert changed is True, "should flag changed when package not yet installed"
|
assert changed is True, "should flag changed when package not yet installed"
|
||||||
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
|
assert deb == expected_deb, f"deb path mismatch: {deb!r} != {expected_deb!r}"
|
||||||
assert dovecot_deployer.DOVECOT_PACKAGE_VERSION not in deb, (
|
assert "1:" not in deb, f"deb filename must not contain the epoch, got {deb!r}"
|
||||||
f"deb path should use archive version (no epoch), got {deb!r}"
|
|
||||||
)
|
|
||||||
assert len(downloads) == 1, "files.download should be called exactly once"
|
assert len(downloads) == 1, "files.download should be called exactly once"
|
||||||
|
# the checksum is the security boundary: verify the right table row is used
|
||||||
|
assert (
|
||||||
|
downloads[0]["sha256sum"]
|
||||||
|
== dovecot_deployer.DOVECOT_SHA256[(arch, deb_release, "core")]
|
||||||
|
), "must pass the sha256 matching (arch, release, package)"
|
||||||
|
assert f"deb{deb_release}u1" in downloads[0]["src"], (
|
||||||
|
f"download URL should carry the deb{deb_release} suffix, got {downloads[0]['src']!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
||||||
@@ -134,12 +156,14 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
|||||||
(
|
(
|
||||||
dovecot_deployer.DebPackages,
|
dovecot_deployer.DebPackages,
|
||||||
{
|
{
|
||||||
"dovecot-core": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
"dovecot-core": [f"1:{dovecot_deployer._stamped_version(12)}"],
|
||||||
"dovecot-imapd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
"dovecot-imapd": [f"1:{dovecot_deployer._stamped_version(12)}"],
|
||||||
"dovecot-lmtpd": [dovecot_deployer.DOVECOT_PACKAGE_VERSION],
|
"dovecot-lmtpd": [f"1:{dovecot_deployer._stamped_version(12)}"],
|
||||||
|
"dovecot-auth-lua": [f"1:{dovecot_deployer._stamped_version(12)}"],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(dovecot_deployer.Arch, "x86_64"),
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
downloads = []
|
downloads = []
|
||||||
@@ -153,41 +177,26 @@ def test_install_skips_dpkg_path_when_epoch_matched_packages_present(
|
|||||||
|
|
||||||
assert downloads == [], "should not download when all packages epoch-matched"
|
assert downloads == [], "should not download when all packages epoch-matched"
|
||||||
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
|
assert track_shell == [], "should not run dpkg when all packages epoch-matched"
|
||||||
assert deployer.need_restart is False, (
|
assert deployer.need_restart is False, "need_restart should be False when nothing changed"
|
||||||
"need_restart should be False when nothing changed"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_unsupported_arch_falls_back_to_apt(
|
def test_install_unsupported_arch_raises(
|
||||||
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
deployer, patch_blocked, mock_files_put, track_shell, monkeypatch
|
||||||
):
|
):
|
||||||
# For unsupported architectures, all fact lookups return the arch string.
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
dovecot_deployer,
|
dovecot_deployer,
|
||||||
"host",
|
"host",
|
||||||
SimpleNamespace(get_fact=lambda cls: "riscv64"),
|
make_host(
|
||||||
|
(dovecot_deployer.Arch, "riscv64"),
|
||||||
|
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
apt_calls = []
|
|
||||||
|
|
||||||
# Mirrors apt.packages() return value: OperationMeta with .changed property.
|
# we never fall back to the pinned distro package
|
||||||
# Only lmtpd triggers a change to verify |= accumulation of changed flags.
|
with pytest.raises(ValueError, match="no dovecot build for dovecot-core"):
|
||||||
def fake_apt(**kwargs):
|
deployer.install()
|
||||||
apt_calls.append(kwargs)
|
|
||||||
changed = "lmtpd" in kwargs["packages"][0]
|
|
||||||
return SimpleNamespace(changed=changed)
|
|
||||||
|
|
||||||
monkeypatch.setattr(dovecot_deployer.apt, "packages", fake_apt)
|
assert track_shell == [], "should not run apt-get for unsupported arch"
|
||||||
|
|
||||||
deployer.install()
|
|
||||||
|
|
||||||
actual_pkgs = [c["packages"] for c in apt_calls]
|
|
||||||
assert actual_pkgs == [["dovecot-core"], ["dovecot-imapd"], ["dovecot-lmtpd"]], (
|
|
||||||
f"expected apt install of core/imapd/lmtpd, got {actual_pkgs}"
|
|
||||||
)
|
|
||||||
assert track_shell == [], "should not run dpkg for unsupported arch"
|
|
||||||
assert deployer.need_restart is True, (
|
|
||||||
"need_restart should be True when apt installed a package"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_runs_dpkg_when_packages_need_download(
|
def test_install_runs_dpkg_when_packages_need_download(
|
||||||
@@ -199,6 +208,7 @@ def test_install_runs_dpkg_when_packages_need_download(
|
|||||||
make_host(
|
make_host(
|
||||||
(dovecot_deployer.DebPackages, {}),
|
(dovecot_deployer.DebPackages, {}),
|
||||||
(dovecot_deployer.Arch, "x86_64"),
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="12"'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
@@ -214,17 +224,15 @@ def test_install_runs_dpkg_when_packages_need_download(
|
|||||||
|
|
||||||
deployer.install()
|
deployer.install()
|
||||||
|
|
||||||
assert len(track_shell) == 1, (
|
assert len(track_shell) == 1, f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
|
||||||
f"expected one server.shell() call for dpkg install, got {len(track_shell)}"
|
|
||||||
)
|
|
||||||
cmds = track_shell[0]["commands"]
|
cmds = track_shell[0]["commands"]
|
||||||
assert len(cmds) == 3, f"expected 3 dpkg/apt commands, got: {cmds}"
|
assert len(cmds) == 1, f"expected single apt-get install command, got: {cmds}"
|
||||||
assert cmds[0].startswith("dpkg --force-confdef --force-confold -i ")
|
assert "apt-get install -y" in cmds[0]
|
||||||
assert "apt-get -y --fix-broken install" in cmds[1]
|
assert '-o Dpkg::Options::="--force-confdef"' in cmds[0]
|
||||||
assert cmds[2].startswith("dpkg --force-confdef --force-confold -i ")
|
assert '-o Dpkg::Options::="--force-confold"' in cmds[0]
|
||||||
assert deployer.need_restart is True, (
|
assert "--allow-downgrades" in cmds[0]
|
||||||
"need_restart should be True after dpkg install"
|
assert ".deb" in cmds[0]
|
||||||
)
|
assert deployer.need_restart is True, "need_restart should be True after dpkg install"
|
||||||
|
|
||||||
|
|
||||||
def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
||||||
@@ -233,6 +241,45 @@ def test_pick_url_falls_back_on_primary_error(monkeypatch):
|
|||||||
|
|
||||||
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
|
monkeypatch.setattr(dovecot_deployer.urllib.request, "urlopen", raise_error)
|
||||||
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
|
result = dovecot_deployer._pick_url("http://primary", "http://fallback")
|
||||||
assert result == "http://fallback", (
|
assert result == "http://fallback", f"should fall back when primary fails, got {result!r}"
|
||||||
f"should fall back when primary fails, got {result!r}"
|
|
||||||
|
|
||||||
|
def test_install_fails_on_unsupported_debian_version(deployer, patch_blocked, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dovecot_deployer,
|
||||||
|
"host",
|
||||||
|
make_host(
|
||||||
|
(dovecot_deployer.Arch, "x86_64"),
|
||||||
|
((Command, dovecot_deployer.VERSION_ID_CMD), 'VERSION_ID="99"'),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="no dovecot build for dovecot-core on deb99"):
|
||||||
|
deployer.install()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version_line", ["", None, "ID=debian"], ids=["empty", "none", "no-version-id"]
|
||||||
|
)
|
||||||
|
def test_parse_version_id_raises_without_version_id(version_line):
|
||||||
|
with pytest.raises(ValueError, match="cannot determine Debian release"):
|
||||||
|
dovecot_deployer._parse_version_id(version_line)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("deb_release", [12, 13])
|
||||||
|
def test_parse_version_id(deb_release):
|
||||||
|
parsed = dovecot_deployer._parse_version_id(f'VERSION_ID="{deb_release}"\n')
|
||||||
|
assert parsed == deb_release
|
||||||
|
|
||||||
|
|
||||||
|
def test_dovecot_sha256_covers_all_packages_per_release():
|
||||||
|
"""Every release in the table needs all four packages on both arches."""
|
||||||
|
table = dovecot_deployer.DOVECOT_SHA256
|
||||||
|
expected = {
|
||||||
|
(arch, pkg)
|
||||||
|
for arch in ("amd64", "arm64")
|
||||||
|
for pkg in ("core", "imapd", "lmtpd", "auth-lua")
|
||||||
|
}
|
||||||
|
for release in {r for _, r, _ in table}:
|
||||||
|
got = {(arch, pkg) for arch, r, pkg in table if r == release}
|
||||||
|
assert got == expected, f"deb{release} incomplete: {sorted(expected - got)}"
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import importlib.resources
|
from pathlib import Path
|
||||||
|
|
||||||
from cmdeploy.www import build_webpages
|
from cmdeploy.www import build_webpages
|
||||||
|
|
||||||
|
|
||||||
def test_build_webpages(tmp_path, make_config):
|
def test_build_webpages(tmp_path, make_config):
|
||||||
pkgroot = importlib.resources.files("cmdeploy")
|
src_dir = (Path(__file__).resolve() / "../../../../../www/src").resolve()
|
||||||
src_dir = pkgroot.joinpath("../../../www/src").resolve()
|
|
||||||
assert src_dir.exists(), src_dir
|
assert src_dir.exists(), src_dir
|
||||||
config = make_config("chat.example.org")
|
config = make_config("chat.example.org")
|
||||||
build_dir = tmp_path.joinpath("build")
|
build_dir = tmp_path.joinpath("build")
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Test the push_notification.lua we ship, against a mocked dovecot mail API."""
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
USER1 = "user12345@chat.example.org"
|
||||||
|
USER2 = "user67890@chat.example.org"
|
||||||
|
|
||||||
|
DOVECOT_MOCKS = textwrap.dedent("""
|
||||||
|
function make_user(username)
|
||||||
|
local function mailbox(_, name)
|
||||||
|
record("mailbox " .. name)
|
||||||
|
return {
|
||||||
|
sync = function() record("sync") end,
|
||||||
|
metadata_set = function(_, k, v)
|
||||||
|
record("metadata_set " .. k .. "=" .. v)
|
||||||
|
end,
|
||||||
|
free = function() record("free") end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return {username = username, mailbox = mailbox}
|
||||||
|
end
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def script(lua):
|
||||||
|
lua.rt.execute(DOVECOT_MOCKS)
|
||||||
|
lua.load("dovecot/push_notification.lua")
|
||||||
|
return lua
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def deliver(script):
|
||||||
|
def deliver(recipient, sender):
|
||||||
|
calls = []
|
||||||
|
script.g.record = calls.append
|
||||||
|
user = script.g.make_user(recipient)
|
||||||
|
ctx = script.g.dovecot_lua_notify_begin_txn(user)
|
||||||
|
event = script.table(mailbox="INBOX", from_address=sender)
|
||||||
|
script.g.dovecot_lua_notify_event_message_new(ctx, event)
|
||||||
|
script.g.dovecot_lua_notify_end_txn(ctx, True)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
return deliver
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_points_have_the_names_dovecot_calls(script):
|
||||||
|
assert script.g.dovecot_lua_notify_begin_txn is not None
|
||||||
|
assert script.g.dovecot_lua_notify_event_message_new is not None
|
||||||
|
assert script.g.dovecot_lua_notify_end_txn is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_begin_txn_returns_the_user_as_event_context(script):
|
||||||
|
user = script.g.make_user(USER1)
|
||||||
|
ctx = script.g.dovecot_lua_notify_begin_txn(user)
|
||||||
|
ctx.marker = "seen"
|
||||||
|
assert user.marker == "seen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incoming_message_notifies_metadata_server(deliver):
|
||||||
|
assert deliver(USER1, sender=USER2) == [
|
||||||
|
"mailbox INBOX",
|
||||||
|
"sync",
|
||||||
|
"metadata_set /private/messagenew=",
|
||||||
|
"free",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_own_message_does_not_wake_the_sending_device(deliver):
|
||||||
|
assert deliver(USER1, sender=USER1) == [
|
||||||
|
"mailbox INBOX",
|
||||||
|
"sync",
|
||||||
|
"free",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_message_without_from_address_is_notified(deliver):
|
||||||
|
assert deliver(USER1, sender=None) == [
|
||||||
|
"mailbox INBOX",
|
||||||
|
"sync",
|
||||||
|
"metadata_set /private/messagenew=",
|
||||||
|
"free",
|
||||||
|
]
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
# Managed by cmdeploy: disable IPv6 in unbound.
|
# Managed by cmdeploy
|
||||||
server:
|
server:
|
||||||
|
{% if disable_ipv6 %}
|
||||||
interface: 127.0.0.1
|
interface: 127.0.0.1
|
||||||
do-ip6: no
|
do-ip6: no
|
||||||
|
{% endif %}
|
||||||
|
cache-max-negative-ttl: 0
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
import importlib.resources
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -37,7 +36,7 @@ def prepare_template(source):
|
|||||||
|
|
||||||
|
|
||||||
def get_paths(config) -> (Path, Path, Path):
|
def get_paths(config) -> (Path, Path, Path):
|
||||||
reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
|
reporoot = (Path(__file__).resolve() / "../../../../").resolve()
|
||||||
www_path = Path(config.www_folder)
|
www_path = Path(config.www_folder)
|
||||||
# if www_folder was not set, use default directory
|
# if www_folder was not set, use default directory
|
||||||
if config.www_folder == "":
|
if config.www_folder == "":
|
||||||
@@ -133,8 +132,7 @@ def find_merge_conflict(src_dir) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
path = importlib.resources.files(__package__)
|
reporoot = (Path(__file__).resolve() / "../../../../").resolve()
|
||||||
reporoot = path.joinpath("../../../").resolve()
|
|
||||||
inipath = reporoot.joinpath("chatmail.ini")
|
inipath = reporoot.joinpath("chatmail.ini")
|
||||||
config = read_config(inipath)
|
config = read_config(inipath)
|
||||||
config.webdev = True
|
config.webdev = True
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
# For the full list of built-in configuration values, see the documentation:
|
# For the full list of built-in configuration values, see the documentation:
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||||
|
|
||||||
@@ -16,12 +18,24 @@ author = 'chatmail collective'
|
|||||||
extensions = [
|
extensions = [
|
||||||
#'sphinx.ext.autodoc',
|
#'sphinx.ext.autodoc',
|
||||||
#'sphinx.ext.viewdoc',
|
#'sphinx.ext.viewdoc',
|
||||||
|
'sphinx.ext.extlinks',
|
||||||
'sphinxcontrib.mermaid',
|
'sphinxcontrib.mermaid',
|
||||||
]
|
]
|
||||||
|
|
||||||
templates_path = ['_templates']
|
templates_path = ['_templates']
|
||||||
exclude_patterns = []
|
exclude_patterns = []
|
||||||
|
|
||||||
|
# Repository links go through the roles below.
|
||||||
|
# CI sets DOC_GITHUB_REF to the head commit of a pull request,
|
||||||
|
gh_ref = os.environ.get("DOC_GITHUB_REF", "main")
|
||||||
|
|
||||||
|
extlinks = {
|
||||||
|
"repofile": (f"https://github.com/chatmail/relay/blob/{gh_ref}/%s", "%s"),
|
||||||
|
"repodir": (f"https://github.com/chatmail/relay/tree/{gh_ref}/%s", "%s"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Warn about repository links spelled out in full instead of using the roles.
|
||||||
|
extlinks_detect_hardcoded_links = True
|
||||||
|
|
||||||
|
|
||||||
# -- Options for HTML output -------------------------------------------------
|
# -- Options for HTML output -------------------------------------------------
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ goes beyond what classic email servers offer:
|
|||||||
streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_;
|
streaming, privacy-preserving Push Notifications for Apple, Google, and `Ubuntu Touch <https://docs.ubports.com/en/latest/appdev/guides/pushnotifications.html>`_;
|
||||||
|
|
||||||
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
|
- **Security Enforcement**: only strict TLS, DKIM and OpenPGP with minimized metadata accepted
|
||||||
|
(DKIM is not enforced on :ref:`IP-only relays <iponly>`)
|
||||||
|
|
||||||
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
|
- **Reliable Federation and Decentralization:** No spam or IP reputation checks, federating
|
||||||
depends on established IETF standards and protocols.
|
depends on established IETF standards and protocols.
|
||||||
@@ -47,6 +48,29 @@ Dovecot, and are configured to run unattended without much maintenance
|
|||||||
effort. Chatmail relays happily run on low-end hardware like a Raspberry
|
effort. Chatmail relays happily run on low-end hardware like a Raspberry
|
||||||
Pi.
|
Pi.
|
||||||
|
|
||||||
|
.. _upgrade:
|
||||||
|
|
||||||
|
How can I upgrade my chatmail relay?
|
||||||
|
------------------------------------
|
||||||
|
|
||||||
|
To upgrade to the latest ``main`` branch,
|
||||||
|
``cd`` into your local checkout of https://github.com/chatmail/relay/
|
||||||
|
and run the following commands:
|
||||||
|
|
||||||
|
::
|
||||||
|
|
||||||
|
git pull origin main --rebase --autostash
|
||||||
|
scripts/initenv.sh
|
||||||
|
scripts/cmdeploy run
|
||||||
|
|
||||||
|
If you don't want the latest development version,
|
||||||
|
but a specific tagged release like `1.10.0 <https://github.com/chatmail/relay/releases/tag/1.10.0>`_,
|
||||||
|
run ``git pull origin 1.10.0`` instead.
|
||||||
|
|
||||||
|
If you made local changes for your setup,
|
||||||
|
they will be reapplied as long as they don't conflict with the upgrade.
|
||||||
|
If a conflict arises, ``git status`` will tell you how to resolve it.
|
||||||
|
|
||||||
|
|
||||||
How trustable are chatmail relays?
|
How trustable are chatmail relays?
|
||||||
----------------------------------
|
----------------------------------
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ Minimal requirements and prerequisites
|
|||||||
|
|
||||||
You will need the following:
|
You will need the following:
|
||||||
|
|
||||||
- Control over a domain through a DNS provider of your choice.
|
|
||||||
|
|
||||||
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
|
- A Debian 12 **deployment server** with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
|
||||||
IPv6 is encouraged if available. Chatmail relay servers only require
|
IPv6 is encouraged if available. Chatmail relay servers only require
|
||||||
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
|
1GB RAM, one CPU, and perhaps 10GB storage for a few thousand active
|
||||||
@@ -28,6 +26,11 @@ You will need the following:
|
|||||||
(An ed25519 private key is required due to an `upstream bug in
|
(An ed25519 private key is required due to an `upstream bug in
|
||||||
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
|
paramiko <https://github.com/paramiko/paramiko/issues/2191>`_)
|
||||||
|
|
||||||
|
- Control over a domain through a DNS provider of your choice
|
||||||
|
(there is experimental support for :ref:`IP-only relays <iponly>`).
|
||||||
|
|
||||||
|
|
||||||
|
.. _setup:
|
||||||
|
|
||||||
Setup with ``scripts/cmdeploy``
|
Setup with ``scripts/cmdeploy``
|
||||||
-------------------------------------
|
-------------------------------------
|
||||||
@@ -98,6 +101,15 @@ steps. Please substitute it with your own domain.
|
|||||||
configure at your DNS provider (it can take some time until they are
|
configure at your DNS provider (it can take some time until they are
|
||||||
public).
|
public).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Docker installation
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
There is experimental support for running chatmail via Docker.
|
||||||
|
A monolithic image based on the above cmdeploy method is available `through a separate repository <https://github.com/chatmail/docker/pkgs/container/docker>`_.
|
||||||
|
See the `chatmail/docker README <https://github.com/chatmail/docker>`_ for full setup instructions.
|
||||||
|
|
||||||
Other helpful commands
|
Other helpful commands
|
||||||
----------------------
|
----------------------
|
||||||
|
|
||||||
@@ -187,6 +199,93 @@ creating addresses, login with ssh to the deployment machine and run:
|
|||||||
Chatmail address creation will be denied while this file is present.
|
Chatmail address creation will be denied while this file is present.
|
||||||
|
|
||||||
|
|
||||||
|
.. _system-limits:
|
||||||
|
|
||||||
|
Configurable System Limits
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
Limits for auto-rejecting address creation
|
||||||
|
..........................................
|
||||||
|
|
||||||
|
A relay refuses creation of new addresses
|
||||||
|
when the machine runs low on resources,
|
||||||
|
but existing addresses keep working.
|
||||||
|
|
||||||
|
Three ``chatmail.ini`` settings control this,
|
||||||
|
shown here with their defaults::
|
||||||
|
|
||||||
|
max_load_1m = 5
|
||||||
|
min_available_memory = 200M
|
||||||
|
min_free_disk_space = 1G
|
||||||
|
|
||||||
|
- ``max_load_1m`` is the maximum 1-minute load average,
|
||||||
|
as reported by ``uptime``;
|
||||||
|
it counts processes waiting for disk I/O as well as for CPU.
|
||||||
|
It is deliberately not scaled by the number of CPUs
|
||||||
|
because I/O rather than CPU is what typically limits a relay.
|
||||||
|
|
||||||
|
- ``min_available_memory`` is the minimum memory available without swapping.
|
||||||
|
|
||||||
|
- ``min_free_disk_space`` is the minimum free disk space
|
||||||
|
on the file system holding the mailboxes.
|
||||||
|
|
||||||
|
The defaults suit the small machine described in
|
||||||
|
`Minimal requirements and prerequisites`_.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If you run a bigger machine,
|
||||||
|
raise ``max_load_1m`` after watching ``uptime`` under typical load.
|
||||||
|
|
||||||
|
Rejections are logged by the ``doveauth`` service,
|
||||||
|
so you can check whether a limit is set too tightly::
|
||||||
|
|
||||||
|
journalctl -u doveauth --grep 'registration rejected'
|
||||||
|
|
||||||
|
If ``mtail_address`` is set, rejections are also counted
|
||||||
|
in the ``rejected_registrations`` metric.
|
||||||
|
|
||||||
|
|
||||||
|
Overall IMAP and SMTP connection limits
|
||||||
|
.......................................
|
||||||
|
|
||||||
|
Two further settings bound how many connections
|
||||||
|
the relay accepts at all, again shown with their defaults::
|
||||||
|
|
||||||
|
max_imap_connections = 10000
|
||||||
|
max_smtp_connections = 1000
|
||||||
|
|
||||||
|
``max_imap_connections`` becomes the Dovecot imap process limit,
|
||||||
|
and ``max_smtp_connections`` the Postfix process limit
|
||||||
|
on each of the submission and smtps ports.
|
||||||
|
A single client IP may use up to a fifth of ``max_smtp_connections``.
|
||||||
|
Each connection costs memory,
|
||||||
|
so these limits defend the relay against running out of RAM.
|
||||||
|
|
||||||
|
Unless ``imap_compress`` is enabled,
|
||||||
|
an IMAP connection that is idle for ``imap_hibernate_timeout``
|
||||||
|
is handed over to the ``imap-hibernate`` process
|
||||||
|
and does not count towards ``max_imap_connections``,
|
||||||
|
which is why a relay can serve far more IMAP clients
|
||||||
|
than this setting suggests.
|
||||||
|
|
||||||
|
If you run a large relay with 10k or 100k's of addresses,
|
||||||
|
check current connection counts before upgrading
|
||||||
|
and set the limits accordingly.
|
||||||
|
|
||||||
|
To see how close a running relay is to these two limits,
|
||||||
|
copy ``scripts/check-connections.sh`` from the relay repository
|
||||||
|
onto the relay and run it there::
|
||||||
|
|
||||||
|
imap 5 ports 143,993 (max_imap_connections)
|
||||||
|
5 dovecot sessions, 0 of them in an active imap process
|
||||||
|
submission 0 ports 465,587 (max_smtp_connections per port)
|
||||||
|
incoming 0 port 25 (from other relays, no chatmail.ini limit)
|
||||||
|
|
||||||
|
It counts established sockets with ``ss``
|
||||||
|
and cross-checks the IMAP number against ``doveadm who``.
|
||||||
|
|
||||||
|
|
||||||
Running a relay with self-signed certificates
|
Running a relay with self-signed certificates
|
||||||
----------------------------------------------
|
----------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ Contributions and feedback welcome through the https://github.com/chatmail/relay
|
|||||||
reverse_dns
|
reverse_dns
|
||||||
related
|
related
|
||||||
faq
|
faq
|
||||||
|
iponly
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.. _iponly:
|
||||||
|
|
||||||
|
Hosting without DNS records
|
||||||
|
===========================
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
This option is experimental and might change without notice.
|
||||||
|
|
||||||
|
In case you don't have a domain,
|
||||||
|
for example in a local network,
|
||||||
|
you can run a chatmail relay with only an IPv4 address as well.
|
||||||
|
|
||||||
|
To deploy a relay without a domain,
|
||||||
|
run ``cmdeploy init`` with only the IPv4 address
|
||||||
|
during the :ref:`installation steps <setup>`,
|
||||||
|
for example ``cmdeploy init 13.12.23.42``.
|
||||||
|
|
||||||
|
Drawbacks
|
||||||
|
---------
|
||||||
|
|
||||||
|
- your transport encryption will only use self-signed TLS certificates,
|
||||||
|
which are vulnerable against MITM attacks.
|
||||||
|
the chatmail core's end-to-end encryption should suffice in most scenarios though.
|
||||||
|
|
||||||
|
- your messages will not be DKIM-signed;
|
||||||
|
experimentally, most chatmail relays accept non-DKIM-signed messages from IP-only relays,
|
||||||
|
but some relays might not accept messages from yours.
|
||||||
|
|
||||||
|
|
||||||
|
Email addresses
|
||||||
|
---------------
|
||||||
|
|
||||||
|
When running without a domain,
|
||||||
|
your chatmail addresses will use the IPv4 address
|
||||||
|
in brackets as the domain part,
|
||||||
|
for example ``user@[13.12.23.42]``.
|
||||||
|
This is a valid email address format
|
||||||
|
according to :rfc:`5321`.
|
||||||
|
|
||||||
+91
-39
@@ -6,13 +6,13 @@ Technical overview
|
|||||||
Directories of the relay repository
|
Directories of the relay repository
|
||||||
-----------------------------------
|
-----------------------------------
|
||||||
|
|
||||||
The `chatmail relay repository <https://github.com/chatmail/relay/tree/main/>`_
|
The `chatmail relay repository <https://github.com/chatmail/relay>`_
|
||||||
has four main directories.
|
has four main directories.
|
||||||
|
|
||||||
``scripts/``
|
``scripts/``
|
||||||
~~~~~~~~~~~~~
|
~~~~~~~~~~~~~
|
||||||
|
|
||||||
`scripts <https://github.com/chatmail/relay/tree/main/scripts>`_
|
:repodir:`scripts`
|
||||||
offers two convenience tools for beginners:
|
offers two convenience tools for beginners:
|
||||||
|
|
||||||
- ``initenv.sh`` installs a local virtualenv Python environment and
|
- ``initenv.sh`` installs a local virtualenv Python environment and
|
||||||
@@ -71,7 +71,7 @@ The deployed system components of a chatmail relay are:
|
|||||||
``chatmaild/``
|
``chatmaild/``
|
||||||
~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~
|
||||||
|
|
||||||
`chatmaild <https://github.com/chatmail/relay/tree/main/chatmaild>`_
|
:repodir:`chatmaild`
|
||||||
is a Python package containing several small services which handle
|
is a Python package containing several small services which handle
|
||||||
authentication, trigger push notifications on new messages, ensure
|
authentication, trigger push notifications on new messages, ensure
|
||||||
that outbound mails are encrypted, delete inactive users, and some
|
that outbound mails are encrypted, delete inactive users, and some
|
||||||
@@ -83,25 +83,25 @@ that integrate with Dovecot and Postfix to achieve instant-onboarding
|
|||||||
and only relaying OpenPGP end-to-end messages encrypted messages. A
|
and only relaying OpenPGP end-to-end messages encrypted messages. A
|
||||||
short overview of ``chatmaild`` services:
|
short overview of ``chatmaild`` services:
|
||||||
|
|
||||||
- `doveauth <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/doveauth.py>`_
|
- :repofile:`doveauth <chatmaild/src/chatmaild/doveauth.py>`
|
||||||
implements create-on-login address semantics and is used by Dovecot
|
implements create-on-login address semantics.
|
||||||
during IMAP login and by Postfix during SMTP/SUBMISSION login which
|
Dovecot authenticates IMAP logins, and Postfix SMTP/SUBMISSION logins through `Dovecot SASL
|
||||||
in turn uses `Dovecot SASL
|
<https://doc.dovecot.org/2.3/configuration_manual/authentication/authentication_mechanisms/>`_,
|
||||||
<https://doc.dovecot.org/2.3/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket>`_
|
from an :repofile:`auth.lua <cmdeploy/src/cmdeploy/dovecot/auth.lua.j2>` script
|
||||||
to authenticate logins.
|
that reads the maildir directly. Only addresses which do not exist yet
|
||||||
|
are passed on to doveauth, which owns the creation policy.
|
||||||
|
|
||||||
- `chatmail-metadata <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py>`_
|
- :repofile:`chatmail-metadata <chatmaild/src/chatmaild/metadata.py>`
|
||||||
is contacted by a `Dovecot lua
|
is contacted by a
|
||||||
script <https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`_
|
:repofile:`Dovecot lua script <cmdeploy/src/cmdeploy/dovecot/push_notification.lua>`
|
||||||
to store user-specific relay-side config. On new messages, it `passes
|
to store user-specific relay-side config. On new messages, it
|
||||||
the user’s push notification
|
:repofile:`passes the user’s push notification token <chatmaild/src/chatmaild/notifier.py>`
|
||||||
token <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/notifier.py>`_
|
|
||||||
to
|
to
|
||||||
`notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_
|
`notifications.delta.chat <https://delta.chat/en/help#instant-delivery>`_
|
||||||
so the push notifications on the user’s phone can be triggered by
|
so the push notifications on the user’s phone can be triggered by
|
||||||
Apple/Google/Huawei.
|
Apple/Google/Huawei.
|
||||||
|
|
||||||
- `chatmail-expire <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/expire.py>`_
|
- :repofile:`chatmail-expire <chatmaild/src/chatmaild/expire.py>`
|
||||||
deletes old messages, large messages, and entire mailboxes
|
deletes old messages, large messages, and entire mailboxes
|
||||||
of users who have not logged in for longer than
|
of users who have not logged in for longer than
|
||||||
``delete_inactive_users_after`` days.
|
``delete_inactive_users_after`` days.
|
||||||
@@ -109,15 +109,14 @@ short overview of ``chatmaild`` services:
|
|||||||
- ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism
|
- ``chatmail-quota-expire`` is called by Dovecot's ``quota_warning`` mechanism
|
||||||
and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``.
|
and will automatically remove oldest messages to keep mailboxes well under ``max_mailbox_size``.
|
||||||
|
|
||||||
- `lastlogin <https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py>`_
|
- :repofile:`lastlogin <chatmaild/src/chatmaild/lastlogin.py>`
|
||||||
is contacted by Dovecot when a user logs in and stores the date of
|
is contacted by Dovecot when a user logs in and stores the date of
|
||||||
the login.
|
the login.
|
||||||
|
|
||||||
``www/``
|
``www/``
|
||||||
~~~~~~~~~
|
~~~~~~~~~
|
||||||
|
|
||||||
`www <https://github.com/chatmail/relay/tree/main/www>`_ contains
|
:repodir:`www` contains the html, css, and markdown files which make up a chatmail relay’s
|
||||||
the html, css, and markdown files which make up a chatmail relay’s
|
|
||||||
web page. Edit them before deploying to make your chatmail relay
|
web page. Edit them before deploying to make your chatmail relay
|
||||||
stand out.
|
stand out.
|
||||||
|
|
||||||
@@ -153,9 +152,11 @@ Chatmail relay dependency diagram
|
|||||||
autoconfig.xml --- dovecot;
|
autoconfig.xml --- dovecot;
|
||||||
postfix --- |10080|filtermail-outgoing;
|
postfix --- |10080|filtermail-outgoing;
|
||||||
postfix --- |10081|filtermail-incoming;
|
postfix --- |10081|filtermail-incoming;
|
||||||
|
postfix --- |10083|filtermail-transport;
|
||||||
filtermail-outgoing --- |10025 reinject|postfix;
|
filtermail-outgoing --- |10025 reinject|postfix;
|
||||||
filtermail-incoming --- |10026 reinject|postfix;
|
filtermail-incoming --- |10026 reinject|postfix;
|
||||||
dovecot --- |doveauth.socket|doveauth;
|
postfix --- |milter opendkim.sock|OpenDKIM
|
||||||
|
dovecot --- |10084 create|doveauth;
|
||||||
dovecot --- |message delivery|maildir["maildir
|
dovecot --- |message delivery|maildir["maildir
|
||||||
/home/vmail/.../user"];
|
/home/vmail/.../user"];
|
||||||
dovecot --- |lastlogin.socket|lastlogin;
|
dovecot --- |lastlogin.socket|lastlogin;
|
||||||
@@ -178,26 +179,51 @@ Chatmail relay dependency diagram
|
|||||||
style nginx-right fill:#f66;
|
style nginx-right fill:#f66;
|
||||||
style postfix fill:#f66;
|
style postfix fill:#f66;
|
||||||
style dovecot fill:#f66;
|
style dovecot fill:#f66;
|
||||||
|
style OpenDKIM fill:#f66;
|
||||||
style notification-proxy fill:#f66;
|
style notification-proxy fill:#f66;
|
||||||
|
|
||||||
Message between users on the same relay
|
Accepting and delivering mail
|
||||||
---------------------------------------
|
-----------------------------
|
||||||
|
|
||||||
.. mermaid::
|
.. mermaid::
|
||||||
:caption: This diagram shows the path a non-federated message takes.
|
:caption: This diagram shows all the paths a message can take.
|
||||||
|
|
||||||
graph LR;
|
flowchart LR
|
||||||
sender --> |465|smtps/smtpd;
|
subgraph chatmail relay
|
||||||
sender --> |587|submission/smtpd;
|
subgraph postfix
|
||||||
smtps/smtpd --> |10080|filtermail;
|
qmgr .-> lmtp-filtermail["lmtp/lmtp-filtermail (default_transport)"]
|
||||||
submission/smtpd --> |10080|filtermail;
|
qmgr .-> lmtp["lmtp (local_transport)"]
|
||||||
filtermail --> |10025|smtpd_reinject;
|
lmtp --> cleanup["cleanup (lmtp_header_cleanup)"]
|
||||||
smtpd_reinject --> cleanup;
|
bounce
|
||||||
cleanup --> qmgr;
|
smtpd-submission["smtpd/submission"]
|
||||||
qmgr --> smtpd_accepts_message;
|
smtpd-smtps["smtpd/smtps"]
|
||||||
qmgr --> |lmtp|dovecot;
|
smtpd-reinject-outgoing["smtpd/reinject-outgoing"] --> authclean["cleanup/authclean (submission_header_cleanup)"]
|
||||||
dovecot --> recipient;
|
authclean --> qmgr
|
||||||
dovecot --> sender's_other_devices;
|
smtpd-smtp["smtpd/smtp"]
|
||||||
|
smtpd-reinject-incoming["smtpd/reinject-incoming"] --> qmgr
|
||||||
|
end
|
||||||
|
lmtp-filtermail --LMTP inet:10083--> filtermail-transport
|
||||||
|
cleanup --LMTP unix:private/dovecot-lmtp --> dovecot
|
||||||
|
dovecot --> maildir
|
||||||
|
smtpd-submission --SMTP inet:10080--> filtermail-outgoing
|
||||||
|
smtpd-smtps --SMTP inet:10080--> filtermail-outgoing
|
||||||
|
filtermail-outgoing --SMTP inet:10025--> smtpd-reinject-outgoing
|
||||||
|
open-dkim["OpenDKIM (signing only)"] <--milter unix:opendkim/opendkim.sock--> smtpd-reinject-outgoing
|
||||||
|
bounce <--milter unix:opendkim/opendkim.sock--> open-dkim
|
||||||
|
bounce --> qmgr
|
||||||
|
nginx
|
||||||
|
smtpd-smtp -.SMTP inet:10081.-> filtermail-incoming
|
||||||
|
nginx -.HTTP inet:10082.-> filtermail-incoming
|
||||||
|
filtermail-incoming --SMTP inet:10026--> smtpd-reinject-incoming
|
||||||
|
end
|
||||||
|
filtermail-transport -.SMTP inet:25.-> mta1[Remote relay]
|
||||||
|
filtermail-transport -.HTTPS /mxdeliv.-> mta1
|
||||||
|
client[Client] -.SMTP inet:587.-> smtpd-submission
|
||||||
|
client -.SMTP inet:465.-> smtpd-smtps
|
||||||
|
client -.SMTP inet:443.-> nginx
|
||||||
|
nginx -.SMTP inet:465.-> smtpd-smtps
|
||||||
|
mta2[Remote relay] -.SMTP inet:25.-> smtpd-smtp
|
||||||
|
mta2 -.HTTPS /mxdeliv.-> nginx
|
||||||
|
|
||||||
Operational details of a chatmail relay
|
Operational details of a chatmail relay
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
@@ -222,6 +248,33 @@ Fresh chatmail addresses have a mailbox directory that contains:
|
|||||||
directories will typically be empty unless the user of that address
|
directories will typically be empty unless the user of that address
|
||||||
hasn’t been online for a while.
|
hasn’t been online for a while.
|
||||||
|
|
||||||
|
App version information (experimental)
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A chatmail relay ships the
|
||||||
|
:repofile:`appversions.json <chatmaild/src/chatmaild/defaults/appversions.json>`
|
||||||
|
file of the ``chatmaild`` package
|
||||||
|
and serves its content under the IMAP METADATA key
|
||||||
|
``/shared/vendor/deltachat/appversions``.
|
||||||
|
Chat apps installed outside of app stores read this key
|
||||||
|
to learn about updates and where to download them.
|
||||||
|
The mechanism is experimental and may change.
|
||||||
|
|
||||||
|
The file travels with the normal deploy:
|
||||||
|
update the repository checkout and run ``cmdeploy run``.
|
||||||
|
Local modifications of ``appversions.json`` are deployed as-is,
|
||||||
|
so you can serve your own app version information,
|
||||||
|
including links to app downloads.
|
||||||
|
There is no automatic refresh:
|
||||||
|
version information changes only when you deploy again.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Note that as of August 2026, only Delta Chat Android Google Play version
|
||||||
|
is beginning to support discovering app versions from relays.
|
||||||
|
Generally, consumers of relay-provided app version information
|
||||||
|
need to verify themselves that downloaded app files are valid.
|
||||||
|
|
||||||
Active ports
|
Active ports
|
||||||
~~~~~~~~~~~~
|
~~~~~~~~~~~~
|
||||||
|
|
||||||
@@ -264,7 +317,8 @@ from the chatmail relay server.
|
|||||||
Email domain authentication (DKIM)
|
Email domain authentication (DKIM)
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails.
|
Chatmail relays enforce :rfc:`DKIM <6376>` to authenticate incoming emails
|
||||||
|
(except for :ref:`IP-only relays <iponly>`).
|
||||||
Incoming emails must have a valid DKIM signature with
|
Incoming emails must have a valid DKIM signature with
|
||||||
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
|
Signing Domain Identifier (SDID, ``d=`` parameter in the DKIM-Signature
|
||||||
header) equal to the ``From:`` header domain. This property is checked
|
header) equal to the ``From:`` header domain. This property is checked
|
||||||
@@ -295,9 +349,7 @@ ensured by ``filtermail`` proxy.
|
|||||||
TLS requirements
|
TLS requirements
|
||||||
~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
Postfix is configured to require valid TLS by setting
|
Filtermail (used for delivery) requires a valid TLS.
|
||||||
`smtp_tls_security_level <https://www.postfix.org/postconf.5.html#smtp_tls_security_level>`_
|
|
||||||
to ``verify``.
|
|
||||||
|
|
||||||
You can test it by resolving ``MX`` records of your relay domain and
|
You can test it by resolving ``MX`` records of your relay domain and
|
||||||
then connecting to MX relays (e.g ``mx.example.org``) with
|
then connecting to MX relays (e.g ``mx.example.org``) with
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ We know of three work-in-progress alternative implementation efforts:
|
|||||||
it to support all of the features and configuration settings required
|
it to support all of the features and configuration settings required
|
||||||
to operate as a chatmail relay.
|
to operate as a chatmail relay.
|
||||||
|
|
||||||
- `Madmail <https://github.com/themadorg/madmail>`_: an
|
- `Madmail <https://github.com/themadorg/madmail>`_: a Rust-based
|
||||||
experimental fork of `Maddy Mail Server <https://maddy.email/>`_, modified
|
single-binary chatmail relay. Madmail v2 is a rewrite of an earlier
|
||||||
for chatmail deployments. It provides a single binary solution
|
experimental fork of `Maddy Mail Server <https://maddy.email/>`_.
|
||||||
for running a chatmail relay.
|
It includes SMTP, IMAP, encryption enforcement, and real-time
|
||||||
|
services (TURN/Iroh), and runs on Linux and Windows.
|
||||||
|
|
||||||
- `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_:
|
- `Chatmail Cookbook <https://github.com/feld/chatmail-cookbook>`_:
|
||||||
A Chef Cookbook implementing a relay server. The project follows the
|
A Chef Cookbook implementing a relay server. The project follows the
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Show current IMAP and SMTP connections on a chatmail relay,
|
||||||
|
# to compare against the max_imap_connections and max_smtp_connections
|
||||||
|
# settings in chatmail.ini. Run this on the relay itself.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Count established TCP connections whose local port is one of the arguments.
|
||||||
|
established() {
|
||||||
|
filter=$(printf 'sport = :%s or ' "$@")
|
||||||
|
ss -Htn state established "( ${filter% or } )" | wc -l
|
||||||
|
}
|
||||||
|
|
||||||
|
# doveadm prints a header line and then one line per logged-in user,
|
||||||
|
# with that user's number of connections in the second column.
|
||||||
|
sessions=$(doveadm who | awk 'NR > 1 { n += $2 } END { print n + 0 }')
|
||||||
|
|
||||||
|
# Unless imap_compress is enabled, connections idle for
|
||||||
|
# imap_hibernate_timeout are handed over to the imap-hibernate
|
||||||
|
# process, so they cost no imap process while idle.
|
||||||
|
active=$(pgrep -x imap | wc -l)
|
||||||
|
|
||||||
|
printf 'imap %6d ports 143,993 (max_imap_connections)\n' "$(established 143 993)"
|
||||||
|
printf ' %6d dovecot sessions, %d of them in an active imap process\n' "$sessions" "$active"
|
||||||
|
printf 'submission %6d ports 465,587 (max_smtp_connections per port)\n' "$(established 465 587)"
|
||||||
|
printf 'incoming %6d port 25 (from other relays, no chatmail.ini limit)\n' "$(established 25)"
|
||||||
@@ -20,6 +20,10 @@ fi
|
|||||||
|
|
||||||
python3 -m venv --upgrade-deps venv
|
python3 -m venv --upgrade-deps venv
|
||||||
|
|
||||||
|
# an editable install puts src/ on sys.path, so a leftover egg-info there is
|
||||||
|
# found as a second distribution and keeps removed entry points registered
|
||||||
|
rm -rf chatmaild/src/*.egg-info cmdeploy/src/*.egg-info
|
||||||
|
|
||||||
venv/bin/pip install -e chatmaild
|
venv/bin/pip install -e chatmaild
|
||||||
venv/bin/pip install -e cmdeploy
|
venv/bin/pip install -e cmdeploy
|
||||||
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs
|
venv/bin/pip install sphinx sphinxcontrib-mermaid sphinx-autobuild furo # for building the docs
|
||||||
|
|||||||
Reference in New Issue
Block a user