Compare commits

..

68 Commits

Author SHA1 Message Date
holger krekel
4f175eec94 add DNS tests, make remote ssh-exec errors show locally, cleanup ssh-bootstrap 2024-07-13 20:00:28 +02:00
holger krekel
1cb64b4777 restructure DNS checks 2024-07-13 19:20:30 +02:00
holger krekel
f88bc86c54 simplify remote zone-file checking and insist for "dns" subcommand that all records are present 2024-07-13 19:20:30 +02:00
holger krekel
db1054f4bd - better debugging for DNS queries
- don't try to guess IP addresses but insist on A and AAAA records
- try to allow ipv4 or ipv6 only zones
- move chatmail.zone generation to jinja so we can have conditionals
2024-07-13 19:20:30 +02:00
holger krekel
134f498778 report back on ip determination -- deal with failure to obtain ip address 2024-07-13 19:20:30 +02:00
holger krekel
c4f46dc499 fix maildata handling after prematurely merging #369 2024-07-13 19:20:06 +02:00
Daniel Kahn Gillmor
c1fd573de2 Add tests for alternate mail subjects 2024-07-13 18:33:42 +02:00
Daniel Kahn Gillmor
c6b083472f Accept encrypted messages that use hcp_minimal
in draft-ietf-lamps-header-protection-22, hcp_minimal recommends
"[...]" as the obscured Subject header.  In the pending draft
-23 (hopefully released this week, going into a working group last
call), the same HCP will be renamed to hcp_baseline, but it still
recommends the use of "[...]" for the obscured Subject header.
2024-07-13 18:33:42 +02:00
holger krekel
254fe95394 postfix was hitting the "100 clients" smtp-submission connected limit (DC apps) and switched to stress mode which brings more randomness/relay to smtp-connections. We now allow 5K because it should be fine for the machine. 2024-07-13 17:19:15 +02:00
holger krekel
ac61ac082e Revert "postfix: fix timeout to 300s on submission ports"
This reverts commit 39584c7b7d.
2024-07-13 16:13:54 +02:00
link2xt
02df395dab filtermail: do not inject addresses into fromat string 2024-07-13 11:46:49 +02:00
link2xt
39584c7b7d postfix: fix timeout to 300s on submission ports
Otherwise smtpd reduces it to 10s on "overload".
2024-07-13 11:46:20 +02:00
link2xt
4ebc4f3069 postfix: do not lookup client hostnames 2024-07-13 11:45:54 +02:00
missytake
1eca8aa143 CI: don't let commits in other PRs interrupt CI runs (#361) 2024-07-12 12:05:21 +02:00
missytake
9c09d50e8f acmetool: reload nginx after requesting new cert 2024-07-12 11:07:35 +02:00
link2xt
d73e896e66 Add changelog entry for HTTPS/IMAP/SMTP multiplexing 2024-07-11 10:31:45 +00:00
link2xt
283045dc4a Multiplex HTTPS, IMAP and SMTP on port 443
Services are distinguished based on ALPN.
For example,
    openssl s_client -connect example.org:443 -alpn smtp
gives SMTP connection and
    openssl s_client -connect example.org:443 -alpn imap
gives IMAP connection.
2024-07-11 10:30:46 +00:00
holger krekel
180cfb3951 get rid of xfailing test 2024-07-11 12:08:33 +02:00
holger krekel
610637da80 don't report on xfail, it's useless 2024-07-11 02:16:08 +02:00
holger krekel
73e6f5e6da apply last review suggestions 2024-07-10 19:20:51 +02:00
holger krekel
b7e6926880 changing newline-naming as suggested 2024-07-10 19:20:51 +02:00
holger krekel
a7ef6ee35b don't use kwargs for overrides parameter 2024-07-10 19:20:51 +02:00
holger krekel
920e062293 let config.get_user_maildir return a Path 2024-07-10 19:20:51 +02:00
holger krekel
794a0608a1 Path-ify config.mailboxes_dir 2024-07-10 19:20:51 +02:00
holger krekel
fc09653de3 remove all occurences of hardcoded /home/vmail for database and mailbox dirs 2024-07-10 19:20:51 +02:00
holger krekel
c8661fd135 introduce "mailboxes_dir" config ini option to avoid hardcoding /home/vmail/mail/....
in source code and to improve testability.
2024-07-10 19:20:51 +02:00
holger krekel
4b0600a453 be a bit more lenient on keeping old users 2024-07-10 00:02:34 +02:00
holger krekel
f1c10cac2b chunked deletion 2024-07-10 00:02:34 +02:00
holger krekel
af83ca0235 ensuring int-ness of last_login 2024-07-09 19:12:55 +02:00
holger krekel
8f6870ebb7 fix and streamline deletion test 2024-07-09 19:12:55 +02:00
holger krekel
0e8bdbd3e3 streamline address deletion test 2024-07-09 19:12:55 +02:00
holger krekel
0d593c22d1 apply code review and also catch "." as username 2024-07-09 19:12:55 +02:00
holger krekel
a1f0a3e23b Apply suggestions from code review
Co-authored-by: link2xt <link2xt@testrun.org>
2024-07-09 19:12:55 +02:00
holger krekel
9b15d8de24 more precise test, streamline wording (accounts -> address) 2024-07-09 19:12:55 +02:00
holger krekel
aaa51cf234 add changelog PR link 2024-07-09 19:12:55 +02:00
holger krekel
66c7115cfc run removal of inactive users daily 2024-07-09 19:12:55 +02:00
holger krekel
823386d824 delete inactive users works 2024-07-09 19:12:55 +02:00
holger krekel
433cb71211 basic remove-users functionality and tests 2024-07-09 19:12:55 +02:00
link2xt
62c60d3070 doveauth: log when a new account is created 2024-07-09 00:24:06 +02:00
holger krekel
698d328620 don't do PTR reverse checking 2024-07-08 21:48:27 +02:00
link2xt
4292355310 Add nonci_accounts metric
Calculating this with PromQL is not easy
due to interpolation.

Also add HELP and TYPE metadata for each metric.
2024-07-08 18:33:18 +00:00
holger krekel
85bb301255 feat: faster and simpler DNS checks, better ip-address determination (#346)
* drastically reduce round-trips for dns checks, and do it during 'run' and 'dns' sub commands 
* provide progress-dots for dns checks and "--verbose" for seeing what is executed remotely 
* introduce ssh-mediated remote python function execution mechanism
2024-07-08 20:10:52 +02:00
link2xt
0d61c13c58 DKIM-sign Content-Type and oversign all signed headers
Oversigning (including header name in DKIM-Signature
more times that it appears in the mail) prevents
adding more headers with the same name
without invalidating DKIM signature.

We don't want middleboxes to insert a second From header,
adding Cc field to mails that don't have one etc.
2024-07-08 14:27:11 +00:00
holger krekel
15f79e0826 remove fix-file-owner which takes forever on servers with many mail directories
(it's unclear why this is still needed and should be fixed differently in any case)
2024-07-06 10:31:41 +02:00
holger krekel
3d96f0fdfa Support iterating over all users with doveadm commands (#344) 2024-07-06 01:19:57 +00:00
link2xt
733b9604ba dovecot: enable gzip compression on disk 2024-07-05 20:13:03 +00:00
link2xt
969fdd7995 Remove sieve to enable hardlink deduplication in LMTP
LMTP does not deduplicate messages
if sieve plugin is used.

We don't check for Auto-Submitted header anymore
as iOS application has a notification service
and should not display "You have a new message".
2024-07-05 19:22:26 +00:00
link2xt
b1d11d7747 Revert 57c29c14a4
Apparently this causes outlook.com messages to be rejected
even though they don't use `l=` tag.
2024-07-03 20:36:31 +00:00
link2xt
e948bdaea8 filtermail: do not allow ASCII armor without actual payload
Last line is removed as "optional checksum",
so it can contain anything.
Make sure that there is at least some actual payload
besides this line.
2024-07-03 19:36:07 +00:00
link2xt
17389b8667 Increase number of logged in IMAP sessions to 50000 2024-07-01 17:20:23 +00:00
link2xt
635b5de304 Replace bash with /bin/sh 2024-07-01 11:47:38 +02:00
holger krekel
67be981176 make a more complete test 2024-06-27 15:36:39 +02:00
missytake
0b8402c187 doveauth: ensure username length 2024-06-27 15:36:39 +02:00
missytake
7c98c1f8c9 test: ensure minimum username length 2024-06-27 15:36:39 +02:00
B. Petersen
0483603d4a fix headline ordering numbers, typo
before, the order was 2 - 3.1 - 3.2 - 3
i think, the gist was to have subheadlines under "2.";
this is fixed by this PR.

moreover, the PR contains a small typo fix.
2024-06-24 14:26:55 +02:00
missytake
6b59b8be44 CI: accept ns.testrun.org host key 2024-06-19 14:34:17 +02:00
missytake
07ffc003e4 CI: fix check whether acme certs exist 2024-06-18 14:49:37 +02:00
missytake
4cb62df33f CI: change to staging2.testrun.org 2024-06-18 14:49:37 +02:00
missytake
ef58f011fb CI: disable CAA record for now 2024-06-18 14:49:37 +02:00
Christian Hagenest
f7ef236ac8 Revert "CI: disable requesting new certs for staging.testrun.org"
This reverts commit 127d9d6460.
2024-06-18 14:49:37 +02:00
Christian Hagenest
dbe906a331 bump actions/checkout to v4 in test-and-deploy.yml 2024-06-18 14:49:37 +02:00
Christian Hagenest
3899f41c61 switch to checkout@v4 #301 2024-06-18 14:49:37 +02:00
link2xt
57c29c14a4 Reject DKIM signatures that do not cover the whole message body 2024-06-18 02:48:54 +00:00
link2xt
2b5d903cc5 Allow SKESK packets in encrypted mails
They are not used by Delta Chat now,
but this will allow to start using them in the future.
2024-06-13 19:48:59 +02:00
link2xt
c8d270a853 Check that OpenPGP has only PKESK and SEIPD packets (#323) 2024-06-12 17:21:37 +00:00
link2xt
72f4e9edbf filtermail: remove support for unencrypted MDNs
Delta Chat does not send them since 1.43.
1.44 has been released for a while already
and 1.46 is in the process of being released.
2024-06-11 16:18:39 +00:00
link2xt
1ce0a2b0ba Improve filtermail checks for encrypted messages
Ensure that first part only contains "Version: 1"
and second part only contains base64 payload
enclosed in "-----BEGIN PGP MESSAGE-----"
and "-----END PGP MESSAGE-----".
2024-06-11 16:18:39 +00:00
Christian Hagenest
044ebfb9a2 delete buggy dovecot submodule for dovebuild 2024-06-11 16:51:29 +02:00
53 changed files with 1170 additions and 483 deletions

View File

@@ -9,7 +9,7 @@ jobs:
name: isolated chatmaild tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: run chatmaild tests
working-directory: chatmaild
@@ -19,7 +19,7 @@ jobs:
name: deploy-chatmail tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: initenv
run: scripts/initenv.sh

View File

@@ -1,6 +1,6 @@
;; Zone file for staging.testrun.org
;; Zone file for staging2.testrun.org
$ORIGIN staging.testrun.org.
$ORIGIN staging2.testrun.org.
$TTL 300
@ IN SOA ns.testrun.org. root.nine.testrun.org (
@@ -15,6 +15,7 @@ $TTL 300
@ IN NS ns.testrun.org.
;; DNS records.
@ IN A 37.27.37.98
mta-sts.staging.testrun.org. CNAME staging.testrun.org.
www.staging.testrun.org. CNAME staging.testrun.org.
@ IN A 37.27.24.139
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
www.staging2.testrun.org. CNAME staging2.testrun.org.

View File

@@ -1,4 +1,4 @@
name: deploy on staging.testrun.org, and run tests
name: deploy on staging2.testrun.org, and run tests
on:
push:
@@ -13,28 +13,35 @@ on:
jobs:
deploy:
name: deploy on staging.testrun.org, and run tests
name: deploy on staging2.testrun.org, and run tests
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: staging-deploy
cancel-in-progress: true
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
steps:
- uses: actions/checkout@v3
- uses: jsok/serialize-workflow-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v4
- name: prepare SSH
run: |
mkdir ~/.ssh
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan staging.testrun.org > ~/.ssh/known_hosts
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
# save previous acme & dkim state
rsync -avz root@staging.testrun.org:/var/lib/acme . || true
rsync -avz root@staging.testrun.org:/etc/dkimkeys . || true
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
if [ -z "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
# make sure CAA record isn't set
ssh -o StrictHostKeyChecking=accept-new root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: rebuild staging.testrun.org to have a clean VPS
- name: rebuild staging2.testrun.org to have a clean VPS
run: |
curl -X POST \
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
@@ -49,17 +56,17 @@ jobs:
- name: upload TLS cert after rebuilding
run: |
echo " --- wait until staging.testrun.org VPS is rebuilt --- "
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
rm ~/.ssh/known_hosts
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org id -u
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
# download acme & dkim state from ns.testrun.org
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
# restore acme & dkim state to staging.testrun.org
rsync -avz acme-restore/acme/ root@staging.testrun.org:/var/lib/acme || true
rsync -avz dkimkeys-restore/dkimkeys/ root@staging.testrun.org:/etc/dkimkeys || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown root:root -R /var/lib/acme || true
# restore acme & dkim state to staging2.testrun.org
rsync -avz acme-restore/acme/ root@staging2.testrun.org:/var/lib/acme || true
rsync -avz dkimkeys-restore/dkimkeys/ root@staging2.testrun.org:/etc/dkimkeys || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks
run: cmdeploy fmt -v
@@ -67,23 +74,23 @@ jobs:
- name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy
- run: cmdeploy init staging.testrun.org
- run: cmdeploy init staging2.testrun.org
- run: cmdeploy run
- run: cmdeploy run --verbose
- name: set DNS entries
run: |
ssh -o StrictHostKeyChecking=accept-new -v root@staging.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
cmdeploy dns --zonefile staging-generated.zone --verbose
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
cat .github/workflows/staging.testrun.org-default.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging.testrun.org /etc/nsd/staging.testrun.org.zone
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
ssh root@ns.testrun.org systemctl reload nsd
- name: cmdeploy test
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
- name: cmdeploy dns (try 3 times)
run: cmdeploy dns || cmdeploy dns || cmdeploy dns
run: cmdeploy dns -v || cmdeploy dns -v || cmdeploy dns -v

View File

@@ -2,6 +2,73 @@
## untagged
- BREAKING: new required chatmail.ini values:
mailboxes_dir = /home/vmail/mail/{mail_domain}
passdb = /home/vmail/passdb.sqlite
reducing hardcoding these two paths all over the files, also improving testability.
([#351](https://github.com/deltachat/chatmail/pull/351))
- BREAKING: new required chatmail.ini value 'delete_inactive_users_after = 100'
which removes users from database and mails after 100 days without any login.
([#350](https://github.com/deltachat/chatmail/pull/350))
- reload nginx in the acmetool cronjob
([#360](https://github.com/deltachat/chatmail/pull/360))
- remove checking of reverse-DNS PTR records. Chatmail-servers don't
depend on it and even in the wider e-mail system it's not common anymore.
If it's an issue, a chatmail operator can still care to properly set reverse DNS.
([#348](https://github.com/deltachat/chatmail/pull/348))
- Make DNS-checking faster and more interactive, run it fully during "cmdeploy run",
also introducing a generic mechanism for rapid remote ssh-based python function execution.
([#346](https://github.com/deltachat/chatmail/pull/346))
- Don't fix file owner ship of /home/vmail
([#345](https://github.com/deltachat/chatmail/pull/345))
- Support iterating over all users with doveadm commands
([#344](https://github.com/deltachat/chatmail/pull/344))
- Test and fix for attempts to create inadmissible accounts
([#333](https://github.com/deltachat/chatmail/pull/321))
- check that OpenPGP has only PKESK, SKESK and SEIPD packets
([#323](https://github.com/deltachat/chatmail/pull/323),
[#324](https://github.com/deltachat/chatmail/pull/324))
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
([#320](https://github.com/deltachat/chatmail/pull/320))
- replace `bash` with `/bin/sh`
([#334](https://github.com/deltachat/chatmail/pull/334))
- Increase number of logged in IMAP sessions to 50000
([#335](https://github.com/deltachat/chatmail/pull/335))
- filtermail: do not allow ASCII armor without actual payload
([#325](https://github.com/deltachat/chatmail/pull/325))
- Remove sieve to enable hardlink deduplication in LMTP
([#343](https://github.com/deltachat/chatmail/pull/343))
- dovecot: enable gzip compression on disk
([#341](https://github.com/deltachat/chatmail/pull/341))
- DKIM-sign Content-Type and oversign all signed headers
([#296](https://github.com/deltachat/chatmail/pull/296))
- Add nonci_accounts metric
([#347](https://github.com/deltachat/chatmail/pull/347))
- doveauth: log when a new account is created
([#349](https://github.com/deltachat/chatmail/pull/349))
- Multiplex HTTPS, IMAP and SMTP on port 443
([#357](https://github.com/deltachat/chatmail/pull/357))
## 1.3.0 - 2024-06-06
- don't check necessary DNS records on cmdeploy init anymore

View File

@@ -155,7 +155,8 @@ While this file is present, account creation will be blocked.
[Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
[Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
[nginx](https://www.nginx.com/) listens on port 443 (https).
[nginx](https://www.nginx.com/) listens on port 8443 (https-alt) and 443 (https).
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
Delta Chat apps will, however, discover all ports and configurations

View File

@@ -26,6 +26,7 @@ chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main"
[project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin"

View File

@@ -1,9 +1,13 @@
from pathlib import Path
import iniconfig
def read_config(inipath):
assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath)
return Config(inipath, params=cfg.sections["params"])
params = cfg.sections["params"]
return Config(inipath, params=params)
class Config:
@@ -13,11 +17,14 @@ class Config:
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
self.max_mailbox_size = params["max_mailbox_size"]
self.delete_mails_after = params["delete_mails_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.mailboxes_dir = Path(params["mailboxes_dir"].strip())
self.passdb_path = Path(params["passdb_path"].strip())
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.iroh_relay = params.get("iroh_relay")
@@ -29,14 +36,36 @@ class Config:
def _getbytefile(self):
return open(self._inipath, "rb")
def get_user_maildir(self, addr):
if addr and addr != "." and "/" not in addr:
res = self.mailboxes_dir.joinpath(addr).resolve()
if res.is_relative_to(self.mailboxes_dir):
return res
raise ValueError(f"invalid address {addr!r}")
def write_initial_config(inipath, mail_domain):
def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides."""
from importlib.resources import files
inidir = files(__package__).joinpath("ini")
content = (
inidir.joinpath("chatmail.ini.f").read_text().format(mail_domain=mail_domain)
)
source_inipath = inidir.joinpath("chatmail.ini.f")
content = source_inipath.read_text().format(mail_domain=mail_domain)
# apply config overrides
new_lines = []
for line in content.split("\n"):
new_line = line.strip()
if new_line and new_line[0] not in "#[":
name, value = map(str.strip, new_line.split("=", maxsplit=1))
value = overrides.get(name, value)
new_line = f"{name} = {value}"
new_lines.append(new_line)
content = "\n".join(new_lines)
# apply testrun privacy overrides
if mail_domain.endswith(".testrun.org"):
override_inipath = inidir.joinpath("override-testrun.ini")
privacy = iniconfig.IniConfig(override_inipath)["privacy"]

View File

@@ -0,0 +1,33 @@
"""
Remove inactive users
"""
import shutil
import sys
import time
from .config import read_config
from .database import Database
from .doveauth import iter_userdb_lastlogin_before
def delete_inactive_users(db, config, CHUNK=100):
cutoff_date = time.time() - config.delete_inactive_users_after * 86400
old_users = iter_userdb_lastlogin_before(db, cutoff_date)
chunks = (old_users[i : i + CHUNK] for i in range(0, len(old_users), CHUNK))
for sublist in chunks:
for user in sublist:
user_mail_dir = config.get_user_maildir(user)
shutil.rmtree(user_mail_dir, ignore_errors=True)
with db.write_transaction() as conn:
for user in sublist:
conn.execute("DELETE FROM users WHERE addr = ?", (user,))
def main():
(cfgpath,) = sys.argv[1:]
config = read_config(cfgpath)
db = Database(config.passdb_path)
delete_inactive_users(db, config)

View File

@@ -60,6 +60,7 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
config.username_min_length,
config.username_max_length,
)
return False
return True
@@ -67,7 +68,7 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
def get_user_data(db, config: Config, user):
if user == f"echo@{config.mail_domain}":
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
)
@@ -75,7 +76,7 @@ def get_user_data(db, config: Config, user):
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
result["home"] = str(config.get_user_maildir(user))
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
@@ -85,7 +86,7 @@ def lookup_userdb(db, config: Config, user):
return get_user_data(db, config, user)
def lookup_passdb(db, config: Config, user, cleartext_password):
def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None):
if user == f"echo@{config.mail_domain}":
# Echobot writes password it wants to log in with into /run/echobot/password
try:
@@ -95,21 +96,25 @@ def lookup_passdb(db, config: Config, user, cleartext_password):
return None
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/echo@{config.mail_domain}",
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
password=encrypt_password(password),
)
if last_login is None:
last_login = time.time()
last_login = int(last_login)
with db.write_transaction() as conn:
userdata = conn.get_user(user)
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
"UPDATE users SET last_login=? WHERE addr=?", (last_login, user)
)
userdata["home"] = f"/home/vmail/mail/{config.mail_domain}/{user}"
userdata["home"] = str(config.get_user_maildir(user))
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
@@ -119,15 +124,34 @@ def lookup_passdb(db, config: Config, user, cleartext_password):
encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
conn.execute(q, (user, encrypted_password, int(time.time())))
conn.execute(q, (user, encrypted_password, last_login))
print(f"Created address: {user}", file=sys.stderr)
return dict(
home=f"/home/vmail/mail/{config.mail_domain}/{user}",
home=str(config.get_user_maildir(user)),
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def iter_userdb(db) -> list:
"""Get a list of all user addresses."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr from users",
).fetchall()
return [x[0] for x in rows]
def iter_userdb_lastlogin_before(db, cutoff_date):
"""Get a list of users where last login was before cutoff_date."""
with db.read_connection() as conn:
rows = conn.execute(
"SELECT addr FROM users WHERE last_login < ?", (cutoff_date,)
).fetchall()
return [x[0] for x in rows]
def split_and_unescape(s):
"""Split strings using double quote as a separator and backslash as escape character
into parts."""
@@ -191,6 +215,13 @@ def handle_dovecot_request(msg, db, config: Config):
reply_command = "N"
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
elif short_command == "I": # ITERATE
# example: I0\t0\tshared/userdb/
parts = msg[1:].split("\t")
if parts[2] == "shared/userdb/":
result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db))
return f"{result}\n"
raise UnknownCommand(msg)
@@ -214,9 +245,9 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main():
socket = sys.argv[1]
db = Database(sys.argv[2])
config = read_config(sys.argv[3])
socket, cfgpath = sys.argv[1:]
config = read_config(cfgpath)
db = Database(config.passdb_path)
class Handler(StreamRequestHandler):
def handle(self):

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env python3
import asyncio
import base64
import binascii
import logging
import sys
import time
@@ -13,56 +15,132 @@ from aiosmtpd.controller import Controller
from .config import read_config
def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload.
OpenPGP payload must consist only of PKESK and SKESK packets
terminated by a single SEIPD packet.
Returns True if OpenPGP payload is correct,
False otherwise.
May raise IndexError while trying to read OpenPGP packet header
if it is truncated.
"""
i = 0
while i < len(payload):
# Only OpenPGP format is allowed.
if payload[i] & 0xC0 != 0xC0:
return False
packet_type_id = payload[i] & 0x3F
i += 1
if payload[i] < 192:
# One-octet length.
body_len = payload[i]
i += 1
elif payload[i] < 224:
# Two-octet length.
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
i += 2
elif payload[i] == 255:
# Five-octet length.
body_len = (
(payload[i + 1] << 24)
| (payload[i + 2] << 16)
| (payload[i + 3] << 8)
| payload[i + 4]
)
i += 5
else:
# Partial body length is not allowed.
return False
i += body_len
if i == len(payload):
if packet_type_id == 18:
# Last packet should be
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
return True
elif packet_type_id not in [1, 3]:
# All packets except the last one must be either
# Public-Key Encrypted Session Key Packet (PKESK)
# or
# Symmetric-Key Encrypted Session Key Packet (SKESK)
return False
if i == 0:
return False
if i > len(payload):
# Payload is truncated.
return False
return True
def check_armored_payload(payload: str):
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
if not payload.startswith(prefix):
return False
payload = payload.removeprefix(prefix)
suffix = "-----END PGP MESSAGE-----\r\n\r\n"
if not payload.endswith(suffix):
return False
payload = payload.removesuffix(suffix)
# Remove CRC24.
payload = payload.rpartition("=")[0]
try:
payload = base64.b64decode(payload)
except binascii.Error:
return False
try:
return check_openpgp_payload(payload)
except IndexError:
return False
def check_encrypted(message):
"""Check that the message is an OpenPGP-encrypted message."""
"""Check that the message is an OpenPGP-encrypted message.
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
"""
if not message.is_multipart():
return False
if message.get("subject") != "...":
if message.get("subject") not in {"...", "[...]"}:
return False
if message.get_content_type() != "multipart/encrypted":
return False
parts_count = 0
for part in message.iter_parts():
# We explicitly check Content-Type of each part later,
# but this is to be absolutely sure `get_payload()` returns string and not list.
if part.is_multipart():
return False
if parts_count == 0:
if part.get_content_type() != "application/pgp-encrypted":
return False
payload = part.get_payload()
if payload.strip() != "Version: 1":
return False
elif parts_count == 1:
if part.get_content_type() != "application/octet-stream":
return False
if not check_armored_payload(part.get_payload()):
return False
else:
return False
parts_count += 1
return True
def check_mdn(message, envelope):
if len(envelope.rcpt_tos) != 1:
return False
for name in ["auto-submitted", "chat-version"]:
if not message.get(name):
return False
if message.get_content_type() != "multipart/report":
return False
body = message.get_body()
if body.get_content_type() != "text/plain":
return False
if list(body.iter_attachments()) or list(body.iter_parts()):
return False
# even with all mime-structural checks an attacker
# could try to abuse the subject or body to contain links or other
# annoyance -- we skip on checking subject/body for now as Delta Chat
# should evolve to create E2E-encrypted read receipts anyway.
# and then MDNs are just encrypted mail and can pass the border
# to other instances.
return True
async def asyncmain_beforequeue(config):
port = config.filtermail_smtp_port
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
@@ -74,7 +152,7 @@ class BeforeQueueHandler:
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
logging.info(f"handle_MAIL from {address}")
logging.info("handle_MAIL from %s", address)
envelope.mail_from = address
max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
@@ -98,19 +176,16 @@ class BeforeQueueHandler:
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
logging.info("Processing DATA message from %s", envelope.mail_from)
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
_, from_addr = parseaddr(message.get("from").strip())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
logging.info("mime-from: %s envelope-from: %r", from_addr, envelope.mail_from)
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if not mail_encrypted and check_mdn(message, envelope):
return
if envelope.mail_from in self.config.passthrough_senders:
return

View File

@@ -8,18 +8,21 @@ mail_domain = {mail_domain}
#
#
# Account Restrictions
# Restrictions on user addresses
#
# how many mails a user can send out per minute
max_user_send_per_minute = 60
# maximum mailbox size of a chatmail account
# maximum mailbox size of a chatmail address
max_mailbox_size = 100M
# days after which mails are unconditionally deleted
delete_mails_after = 20
# days after which users without a login are deleted (database and mails)
delete_inactive_users_after = 100
# minimum length a username must have
username_min_length = 9
@@ -29,7 +32,7 @@ username_max_length = 9
# minimum length a password must have
password_min_length = 9
# list of chatmail accounts which can send outbound un-encrypted mail
# list of chatmail addresses which can send outbound un-encrypted mail
passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails
@@ -39,6 +42,12 @@ passthrough_recipients = xstore@testrun.org groupsbot@hispanilandia.net
# Deployment Details
#
# Directory where user mailboxes are stored
mailboxes_dir = /home/vmail/mail/{mail_domain}
# user address sqlite database path
passdb_path = /home/vmail/passdb.sqlite
# where the filtermail SMTP service listens
filtermail_smtp_port = 10080
@@ -60,4 +69,3 @@ privacy_pdo =
# postal address of the privacy supervisor
privacy_supervisor =

View File

@@ -1,7 +1,6 @@
import logging
import os
import sys
from pathlib import Path
from socketserver import (
StreamRequestHandler,
ThreadingMixIn,
@@ -128,12 +127,12 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main():
socket, vmail_dir, config_path = sys.argv[1:]
socket, config_path = sys.argv[1:]
config = read_config(config_path)
iroh_relay = config.iroh_relay
vmail_dir = Path(vmail_dir)
vmail_dir = config.mailboxes_dir
if not vmail_dir.exists():
logging.error("vmail dir does not exist: %r", vmail_dir)
return 1

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import sys
import time
from pathlib import Path
@@ -16,9 +15,15 @@ def main(vmail_dir=None):
if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1
timestamp = int(time.time() * 1000)
print(f"accounts {accounts} {timestamp}")
print(f"ci_accounts {ci_accounts} {timestamp}")
print("# HELP total number of accounts")
print("# TYPE accounts gauge")
print(f"accounts {accounts}")
print("# HELP number of CI accounts")
print("# TYPE ci_accounts gauge")
print(f"ci_accounts {ci_accounts}")
print("# HELP number of non-CI accounts")
print("# TYPE nonci_accounts gauge")
print(f"nonci_accounts {accounts - ci_accounts}")
if __name__ == "__main__":

View File

@@ -1,6 +1,6 @@
From: {from_addr}
To: {to_addr}
Subject: ...
Subject: {subject}
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>

View File

@@ -0,0 +1,44 @@
From: {from_addr}
To: {to_addr}
Subject: ...
Date: Sun, 15 Oct 2023 16:43:21 +0000
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
Chat-Version: 1.0
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
MIME-Version: 1.0
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"

View File

@@ -16,7 +16,11 @@ def make_config(tmp_path):
inipath = tmp_path.joinpath("chatmail.ini")
def make_conf(mail_domain):
write_initial_config(inipath, mail_domain=mail_domain)
basedir = tmp_path.joinpath(f"vmail/{mail_domain}")
basedir.mkdir(parents=True, exist_ok=True)
passdb = tmp_path.joinpath("vmail/passdb.sqlite")
overrides = dict(mailboxes_dir=str(basedir), passdb_path=str(passdb))
write_initial_config(inipath, mail_domain, overrides=overrides)
return read_config(inipath)
return make_conf
@@ -67,9 +71,11 @@ def maildata(request):
assert datadir.exists(), datadir
def maildata(name, from_addr, to_addr):
data = datadir.joinpath(name).read_text()
text = data.format(from_addr=from_addr, to_addr=to_addr)
def maildata(name, from_addr, to_addr, subject="..."):
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode()
text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject)
return BytesParser(policy=policy.default).parsebytes(text.encode())
return maildata

View File

@@ -1,3 +1,4 @@
import pytest
from chatmaild.config import read_config
@@ -30,3 +31,31 @@ def test_read_config_testrun(make_config):
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):
config = make_config("something.testrun.org")
mailboxes_dir = config.mailboxes_dir
passdb_path = config.passdb_path
assert mailboxes_dir.name == "something.testrun.org"
assert passdb_path.name == "passdb.sqlite"
assert passdb_path.is_relative_to(tmp_path)
assert config.mail_domain == "something.testrun.org"
path = config.get_user_maildir("user1@something.testrun.org")
assert not path.exists()
assert path == mailboxes_dir.joinpath("user1@something.testrun.org")
with pytest.raises(ValueError):
config.get_user_maildir("")
with pytest.raises(ValueError):
config.get_user_maildir(None)
with pytest.raises(ValueError):
config.get_user_maildir("../some@something.testrun.org")
with pytest.raises(ValueError):
config.get_user_maildir("..")
with pytest.raises(ValueError):
config.get_user_maildir(".")

View File

@@ -0,0 +1,51 @@
import time
from chatmaild.delete_inactive_users import delete_inactive_users
from chatmaild.doveauth import lookup_passdb
def test_remove_stale_users(db, example_config):
new = time.time()
old = new - (example_config.delete_inactive_users_after * 86400) - 1
def create_user(addr, last_login):
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=last_login)
md = example_config.get_user_maildir(addr)
md.mkdir(parents=True)
md.joinpath("cur").mkdir()
md.joinpath("cur", "something").mkdir()
# create some stale and some new accounts
to_remove = []
for i in range(150):
addr = f"oldold{i:03}@chat.example.org"
create_user(addr, last_login=old)
with db.read_connection() as conn:
assert conn.get_user(addr)
to_remove.append(addr)
remain = []
for i in range(5):
addr = f"newnew{i:03}@chat.example.org"
create_user(addr, last_login=new)
remain.append(addr)
# check pre and post-conditions for delete_inactive_users()
for addr in to_remove:
assert example_config.get_user_maildir(addr).exists()
delete_inactive_users(db, example_config)
for p in example_config.mailboxes_dir.iterdir():
assert not p.name.startswith("old")
for addr in to_remove:
assert not example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert not conn.get_user(addr)
for addr in remain:
assert example_config.get_user_maildir(addr).exists()
with db.read_connection() as conn:
assert conn.get_user(addr)

View File

@@ -11,8 +11,12 @@ from chatmaild.doveauth import (
get_user_data,
handle_dovecot_protocol,
handle_dovecot_request,
is_allowed_to_create,
iter_userdb,
iter_userdb_lastlogin_before,
lookup_passdb,
)
from chatmaild.newemail import create_newemail_dict
def test_basic(db, example_config):
@@ -25,6 +29,49 @@ def test_basic(db, example_config):
assert data == data2
def test_iterate_addresses(db, example_config):
addresses = []
for i in range(10):
addresses.append(f"asdf1234{i}@chat.example.org")
lookup_passdb(db, example_config, addresses[-1], "q9mr3faue")
res = iter_userdb(db)
assert res == addresses
def test_iterate_addresses_lastlogin_before(db, example_config):
addresses = []
cutoff_date = 1000
for i in range(10):
addr = f"oldold{i:03}@chat.example.org"
lookup_passdb(
db, example_config, addr, "q9mr3faue", last_login=cutoff_date - 10
)
addresses.append(addr)
for i in range(5):
addr = f"newnew{i:03}@chat.example.org"
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=cutoff_date + i)
res = iter_userdb_lastlogin_before(db, cutoff_date)
assert sorted(res) == sorted(addresses)
def test_invalid_username_length(example_config):
config = example_config
config.username_min_length = 6
config.username_max_length = 10
password = create_newemail_dict(config)["password"]
assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password)
assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password)
assert not is_allowed_to_create(
config, f"0123456789x@{config.mail_domain}", password
)
def test_dont_overwrite_password_on_wrong_login(db, example_config):
"""Test that logging in with a different password doesn't create a new user"""
res = lookup_passdb(
@@ -67,10 +114,7 @@ def test_handle_dovecot_request(db, example_config):
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert (
userdata["home"]
== "/home/vmail/mail/chat.example.org/some42123@chat.example.org"
)
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")
@@ -92,6 +136,18 @@ def test_handle_dovecot_protocol(db, example_config):
assert wfile.getvalue() == b"N\n"
def test_handle_dovecot_protocol_iterate(db, gencreds, example_config):
lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue")
lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue")
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
lines = wfile.getvalue().decode("ascii").split("\n")
assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t"
assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t"
assert not lines[2]
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50
req_per_thread = 5

View File

@@ -2,8 +2,8 @@ import pytest
from chatmaild.filtermail import (
BeforeQueueHandler,
SendRateLimiter,
check_armored_payload,
check_encrypted,
check_mdn,
)
@@ -54,42 +54,33 @@ def test_filtermail_no_encryption_detection(maildata):
def test_filtermail_encryption_detection(maildata):
msg = maildata("encrypted.eml", from_addr="1@example.org", to_addr="2@example.org")
assert check_encrypted(msg)
for subject in ("...", "[...]"):
msg = maildata(
"encrypted.eml",
from_addr="1@example.org",
to_addr="2@example.org",
subject=subject,
)
assert check_encrypted(msg)
# if the subject is not "..." it is not considered ac-encrypted
# if the subject is not a known encrypted subject value, it is not considered ac-encrypted
msg.replace_header("Subject", "Click this link")
assert not check_encrypted(msg)
def test_filtermail_is_mdn(maildata, gencreds, handler):
def test_filtermail_no_literal_packets(maildata):
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
assert not check_encrypted(msg)
def test_filtermail_unencrypted_mdn(maildata, gencreds):
"""Unencrypted MDNs should not pass."""
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_addr, to_addr)
msg = maildata("mdn.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert check_mdn(msg, env)
print(msg.as_string())
assert not handler.check_DATA(env)
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
thirdaddr = gencreds()[0]
msg = maildata("mdn.eml", from_addr, to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr, thirdaddr]
content = msg.as_bytes()
assert not check_mdn(msg, env)
assert not check_encrypted(msg)
def test_send_rate_limiter():
@@ -110,7 +101,7 @@ def test_excempt_privacy(maildata, gencreds, handler):
handler.config.passthrough_recipients = [to_addr]
false_to = "privacy@something.org"
msg = maildata("plain.eml", from_addr, to_addr)
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
@@ -133,7 +124,7 @@ def test_passthrough_senders(gencreds, handler, maildata):
to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1]
msg = maildata("plain.eml", acc1, to_addr)
msg = maildata("plain.eml", from_addr=acc1, to_addr=to_addr)
class env:
mail_from = acc1
@@ -142,3 +133,59 @@ def test_passthrough_senders(gencreds, handler, maildata):
# assert that None/no error is returned
assert not handler.check_DATA(envelope=env)
def test_check_armored_payload():
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
=b5Kp\r
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == True
payload = """-----BEGIN PGP MESSAGE-----\r
\r
HELLOWORLD
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False
payload = """-----BEGIN PGP MESSAGE-----\r
\r
=njUN
-----END PGP MESSAGE-----\r
\r
"""
assert check_armored_payload(payload) == False

View File

@@ -8,9 +8,10 @@ def test_main(tmp_path, capsys):
out, _ = capsys.readouterr()
d = {}
for line in out.split("\n"):
if line.strip():
name, num, _ = line.split()
if line.strip() and not line.startswith("#"):
name, num = line.split()
d[name] = int(num)
assert d["accounts"] == 4
assert d["ci_accounts"] == 3
assert d["nonci_accounts"] == 1

View File

@@ -18,6 +18,7 @@ dependencies = [
"ruff",
"pytest",
"pytest-xdist",
"execnet",
"imap_tools",
]

View File

@@ -92,7 +92,7 @@ def _install_remote_venv_with_chatmaild(config) -> None:
group="root",
mode="644",
config={
"mail_domain": config.mail_domain,
"mailboxes_dir": config.mailboxes_dir,
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
},
)
@@ -338,20 +338,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
)
need_restart |= lua_push_notification_script.changed
sieve_script = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/default.sieve"),
dest="/etc/dovecot/default.sieve",
user="root",
group="root",
mode="644",
)
need_restart |= sieve_script.changed
if sieve_script.changed:
server.shell(
name="compile sieve script",
commands=["/usr/bin/sievec /etc/dovecot/default.sieve"],
)
files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
dest="/etc/cron.d/expunge",
@@ -479,11 +465,6 @@ def deploy_chatmail(config_path: Path) -> None:
)
server.user(name="Create echobot user", user="echobot", system=True)
server.shell(
name="Fix file owner in /home/vmail",
commands=["test -d /home/vmail && chown -R vmail:vmail /home/vmail"],
)
# Add our OBS repository for dovecot_no_delay
files.put(
name="Add Deltachat OBS GPG key to apt keyring",
@@ -548,12 +529,12 @@ def deploy_chatmail(config_path: Path) -> None:
apt.packages(
name="Install Dovecot",
packages=["dovecot-imapd", "dovecot-lmtpd", "dovecot-sieve"],
packages=["dovecot-imapd", "dovecot-lmtpd"],
)
apt.packages(
name="Install nginx",
packages=["nginx"],
packages=["nginx", "libnginx-mod-stream"],
)
apt.packages(
@@ -649,5 +630,3 @@ def deploy_chatmail(config_path: Path) -> None:
name="Ensure cron is installed",
packages=["cron"],
)

View File

@@ -69,8 +69,7 @@ def deploy_acmetool(email="", domains=[]):
restarted=service_file.changed,
)
if str(host) != "staging.testrun.org":
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
)
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
)

View File

@@ -1,4 +1,4 @@
SHELL=/bin/sh
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
MAILTO=root
20 16 * * * root /usr/bin/acmetool --batch reconcile && systemctl reload dovecot && systemctl reload postfix
20 16 * * * root /usr/bin/acmetool --batch reconcile && systemctl reload dovecot && systemctl reload postfix && systemctl reload nginx

View File

@@ -1,15 +0,0 @@
{chatmail_domain}. A {ipv4}
{chatmail_domain}. AAAA {ipv6}
{chatmail_domain}. MX 10 {chatmail_domain}.
_submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}.
_submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}.
_imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
{chatmail_domain}. CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} ~all"
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}.
{dkim_entry}
_adsp._domainkey.{chatmail_domain}. TXT "dkim=discardable"

View File

@@ -0,0 +1,21 @@
{% if ipv4 %}
{{ chatmail_domain }}. A {{ ipv4 }}
{% endif %}
{% if ipv6 %}
{{ chatmail_domain }}. AAAA {{ ipv6 }}
{% endif %}
{{ chatmail_domain }}. MX 10 {{ chatmail_domain }}.
_submission._tcp.{{ chatmail_domain }}. SRV 0 1 587 {{ chatmail_domain }}.
_submissions._tcp.{{ chatmail_domain }}. SRV 0 1 465 {{ chatmail_domain }}.
_imap._tcp.{{ chatmail_domain }}. SRV 0 1 143 {{ chatmail_domain }}.
_imaps._tcp.{{ chatmail_domain }}. SRV 0 1 993 {{ chatmail_domain }}.
{% if acme_account_url %}
{{ chatmail_domain }}. CAA 128 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
{% endif %}
{{ chatmail_domain }}. TXT "v=spf1 a:{{ chatmail_domain }} ~all"
_dmarc.{{ chatmail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_mta-sts.{{ chatmail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
mta-sts.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
www.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
{{ dkim_entry }}
_adsp._domainkey.{{ chatmail_domain }}. TXT "dkim=discardable"

View File

@@ -15,7 +15,8 @@ from pathlib import Path
from chatmaild.config import read_config, write_initial_config
from termcolor import colored
from cmdeploy.dns import check_necessary_dns, show_dns
from . import dns, remote_funcs
from .sshexec import SSHExec
#
# cmdeploy sub commands and options
@@ -35,8 +36,9 @@ def init_cmd(args, out):
mail_domain = args.chatmail_domain
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
return 1
else:
write_initial_config(args.inipath, mail_domain)
write_initial_config(args.inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {args.inipath}")
@@ -51,12 +53,10 @@ def run_cmd_options(parser):
def run_cmd(args, out):
"""Deploy chatmail services on the remote server."""
mail_domain = args.config.mail_domain
if not check_necessary_dns(
out,
mail_domain,
):
sys.exit(1)
remote_data = dns.get_initial_remote_data(args, out)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
@@ -64,8 +64,16 @@ def run_cmd(args, out):
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
out.check_call(cmd, env=env)
print("Deploy completed, call `cmdeploy dns` next.")
retcode = out.check_call(cmd, env=env)
if retcode == 0:
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
out.red("Run 'cmdeploy run' again")
retcode = 0
else:
out.red("Deploy failed")
return retcode
def dns_cmd_options(parser):
@@ -77,15 +85,18 @@ def dns_cmd_options(parser):
def dns_cmd(args, out):
"""Generate dns zone file."""
exit_code = show_dns(args, out)
exit(exit_code)
"""Check DNS entries and optionally generate dns zone file."""
remote_data = dns.get_initial_remote_data(args, out)
if not remote_data:
return 1
retcode = dns.show_dns(args, out, remote_data)
return retcode
def status_cmd(args, out):
"""Display status for online chatmail instance."""
ssh = f"ssh root@{args.config.mail_domain}"
sshexec = args.get_sshexec()
out.green(f"chatmail domain: {args.config.mail_domain}")
if args.config.privacy_mail:
@@ -93,10 +104,8 @@ def status_cmd(args, out):
else:
out.red("no privacy settings")
s1 = "systemctl --type=service --state=running"
for line in out.shell_output(f"{ssh} -- {s1}").split("\n"):
if line.startswith(" "):
print(line)
for line in sshexec(remote_funcs.get_systemd_running):
print(line)
def test_cmd_options(parser):
@@ -125,7 +134,7 @@ def test_cmd(args, out):
"-n4",
"-rs",
"-x",
"-vrx",
"-v",
"--durations=5",
]
if args.slow:
@@ -135,14 +144,6 @@ def test_cmd(args, out):
def fmt_cmd_options(parser):
parser.add_argument(
"--verbose",
"-v",
dest="verbose",
action="store_true",
help="provide information on invocations",
)
parser.add_argument(
"--check",
"-c",
@@ -172,7 +173,6 @@ def fmt_cmd(args, out):
out.check_call(" ".join(format_args), quiet=not args.verbose)
out.check_call(" ".join(check_args), quiet=not args.verbose)
return 0
def bench_cmd(args, out):
@@ -208,16 +208,6 @@ class Out:
color = "red" if red else ("green" if green else None)
print(colored(msg, color), file=file)
def shell_output(self, arg, no_print=False, timeout=10):
if not no_print:
self(f"[$ {arg}]", file=sys.stderr)
output = subprocess.STDOUT
else:
output = subprocess.DEVNULL
return subprocess.check_output(
arg, shell=True, timeout=timeout, stderr=output
).decode()
def check_call(self, arg, env=None, quiet=False):
if not quiet:
self(f"[$ {arg}]", file=sys.stderr)
@@ -240,6 +230,14 @@ def add_config_option(parser):
type=Path,
help="path to the chatmail.ini file",
)
parser.add_argument(
"--verbose",
"-v",
dest="verbose",
action="store_true",
default=False,
help="provide verbose logging",
)
def add_subcommand(subparsers, func):
@@ -279,11 +277,23 @@ def get_parser():
def main(args=None):
"""Provide main entry point for 'xdcget' CLI invocation."""
"""Provide main entry point for 'cmdeploy' CLI invocation."""
parser = get_parser()
args = parser.parse_args(args=args)
if not hasattr(args, "func"):
return parser.parse_args(["-h"])
ssh_cache = []
def get_sshexec():
if not ssh_cache:
print(f"[ssh] login to {args.config.mail_domain}")
ssh = SSHExec(args.config.mail_domain, remote_funcs, verbose=args.verbose)
ssh_cache.append(ssh)
return ssh_cache[0]
args.get_sshexec = get_sshexec
out = Out()
kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
@@ -301,7 +311,6 @@ def main(args=None):
if res is None:
res = 0
return res
except KeyboardInterrupt:
out.red("KeyboardInterrupt")
sys.exit(130)

View File

@@ -1,209 +1,77 @@
import datetime
import importlib
import subprocess
import sys
import requests
from jinja2 import Template
from . import remote_funcs
class DNS:
def __init__(self, out, mail_domain):
self.session = requests.Session()
self.out = out
self.ssh = f"ssh root@{mail_domain} -- "
self.out.shell_output(
f"{ self.ssh }'apt-get update && apt-get install -y dnsutils'",
timeout=60,
no_print=True,
)
try:
self.shell(f"unbound-control flush_zone {mail_domain}")
except subprocess.CalledProcessError:
pass
def shell(self, cmd):
try:
return self.out.shell_output(f"{self.ssh}{cmd}", no_print=True)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
if "exit status 255" in str(e) or "timed out" in str(e):
self.out.red(f"Error: can't reach the server with: {self.ssh[:-4]}")
sys.exit(1)
else:
raise
def get_ipv4(self):
cmd = "ip a | grep 'inet ' | grep 'scope global' | grep -oE '[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}' | head -1"
return self.shell(cmd).strip()
def get_ipv6(self):
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str:
"""Get a DNS entry or empty string if there is none."""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
return line
def check_ptr_record(self, ip: str, mail_domain) -> bool:
"""Check the PTR record for an IPv4 or IPv6 address."""
result = self.shell(f"dig -r -x {ip} +short").rstrip()
return result == f"{mail_domain}."
def show_dns(args, out) -> int:
"""Check existing DNS records, optionally write them to zone file, return exit code 0 or 1."""
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
def get_initial_remote_data(args, out):
sshexec = args.get_sshexec()
mail_domain = args.config.mail_domain
ssh = f"ssh root@{mail_domain}"
dns = DNS(out, mail_domain)
return sshexec.logged(
call=remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
)
print("Checking your DKIM keys and DNS entries...")
try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
def check_initial_remote_data(remote_data, print=print):
mail_domain = remote_data["mail_domain"]
if not remote_data["A"] and not remote_data["AAAA"]:
print("Missing A and/or AAAA DNS records for {mail_domain}!")
elif not remote_data["MTA_STS"]:
print("Missing MTA-STS CNAME record:")
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}")
else:
return remote_data
def show_dns(args, out, remote_data) -> int:
"""Check existing DNS records, optionally write them to zone file
and return (exitcode, remote_data) tuple."""
sshexec = args.get_sshexec()
if not remote_data["acme_account_url"]:
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
return 1
dkim_selector = "opendkim"
dkim_pubkey = out.shell_output(
ssh + f" -- openssl rsa -in /etc/dkimkeys/{dkim_selector}.private"
" -pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
if not remote_data["dkim_entry"]:
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
return 1
sts_id = remote_data.get("sts_id")
if not sts_id:
sts_id = datetime.datetime.now().strftime("%Y%m%d%H%M")
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
content = template.read_text()
zonefile = Template(content).render(
acme_account_url=remote_data.get("acme_account_url"),
dkim_entry=remote_data["dkim_entry"],
ipv4=remote_data["A"],
ipv6=remote_data["AAAA"],
sts_id=sts_id,
chatmail_domain=args.config.mail_domain,
)
dkim_entry_value = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_entry_str = ""
while len(dkim_entry_value) >= 255:
dkim_entry_str += '"' + dkim_entry_value[:255] + '" '
dkim_entry_value = dkim_entry_value[255:]
dkim_entry_str += '"' + dkim_entry_value + '"'
dkim_entry = f"{dkim_selector}._domainkey.{mail_domain}. TXT {dkim_entry_str}"
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
lines.append("")
zonefile = "\n".join(lines)
ipv6 = dns.get_ipv6()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
ipv4 = dns.get_ipv4()
reverse_ipv4 = dns.check_ptr_record(ipv4, mail_domain)
to_print = []
diff_records = sshexec.logged(
remote_funcs.check_zonefile, kwargs=dict(zonefile=zonefile)
)
with open(template, "r") as f:
zonefile = (
f.read()
.format(
acme_account_url=acme_account_url,
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
ipv4=ipv4,
)
.strip()
)
try:
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
print(f"DNS records successfully written to: {args.zonefile}")
return 0
except TypeError:
pass
for raw_line in zonefile.splitlines():
line = raw_line.format(
acme_account_url=acme_account_url,
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
ipv6=ipv6,
).strip()
for typ in ["A", "AAAA", "CNAME", "CAA"]:
if f" {typ} " in line:
domain, value = line.split(f" {typ} ")
current = dns.get(typ, domain.strip()[:-1])
if current != value.strip():
to_print.append(line)
if " MX " in line:
domain, typ, prio, value = line.split()
current = dns.get(typ, domain[:-1])
if not current:
to_print.append(line)
elif current.split()[1] != value:
print(line.replace(prio, str(int(current[0]) + 1)))
if " SRV " in line:
domain, typ, prio, weight, port, value = line.split()
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
continue
if getattr(args, "zonefile", None):
with open(args.zonefile, "w+") as zf:
zf.write(zonefile)
out.green(f"DNS records successfully written to: {args.zonefile}")
return 0
# TXT records longer than 255 bytes
# are split into multiple <character-string>s.
# This typically happens with DKIM record
# which contains long RSA key.
#
# Removing `" "` before comparison
# to get back a single string.
if current.replace('" "', "") != value.replace('" "', ""):
to_print.append(line)
exit_code = 0
if to_print:
to_print.insert(
0, "You should configure the following DNS entries at your provider:\n"
)
to_print.append(
"\nIf you already configured the DNS entries, wait a bit until the DNS entries propagate to the Internet."
)
print("\n".join(to_print))
exit_code = 1
if diff_records:
out.red("Please set the following DNS entries at your DNS provider:\n")
for line in diff_records:
out(line)
return 1
else:
out.green("Great! All your DNS entries are correct.")
to_print = []
if not reverse_ipv4:
to_print.append(f"\tIPv4:\t{ipv4}\t{args.config.mail_domain}")
if not reverse_ipv6:
to_print.append(f"\tIPv6:\t{ipv6}\t{args.config.mail_domain}")
if len(to_print) > 0:
if len(to_print) == 1:
warning = "You should add the following PTR/reverse DNS entry:"
else:
warning = "You should add the following PTR/reverse DNS entries:"
out.red(warning)
for entry in to_print:
print(entry)
print(
"You can do so at your hosting provider (maybe this isn't your DNS provider)."
)
exit_code = 1
return exit_code
def check_necessary_dns(out, mail_domain):
"""Check whether $mail_domain and mta-sts.$mail_domain resolve."""
print("Checking necessary DNS records... ")
dns = DNS(out, mail_domain)
ipv4 = dns.get("A", mail_domain)
ipv6 = dns.get("AAAA", mail_domain)
mta_entry = dns.get("CNAME", "mta-sts." + mail_domain)
www_entry = dns.get("CNAME", "www." + mail_domain)
to_print = []
if not (ipv4 or ipv6):
to_print.append(f"\t{mail_domain}.\t\t\tA<your server's IPv4 address>")
if mta_entry != mail_domain + ".":
to_print.append(f"\tmta-sts.{mail_domain}.\tCNAME\t{mail_domain}.")
if www_entry != mail_domain + ".":
to_print.append(f"\twww.{mail_domain}.\tCNAME\t{mail_domain}.")
if to_print:
to_print.insert(
0,
"\nFor chatmail to work, you need to configure this at your DNS provider:\n",
)
for line in to_print:
print(line)
print()
else:
dns.out.green("All necessary DNS records seem to be set.")
return True
out.green("Great! All your DNS entries are verified and correct.")
return 0

View File

@@ -1,5 +1,7 @@
uri = proxy:/run/doveauth/doveauth.socket:auth
iterate_disable = yes
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/configuration_manual/config_file/config_variables/#modifiers>

View File

@@ -1,7 +0,0 @@
require ["imap4flags"];
# flag the message so it doesn't cause a push notification
if header :is ["Auto-Submitted"] ["auto-replied", "auto-generated"] {
addflag "$Auto";
}

View File

@@ -19,10 +19,33 @@ mail_debug = yes
# master: Warning: service(stats): client_limit (1000) reached, client connections are being dropped
default_client_limit = 20000
# Increase number of logged in IMAP connections.
# Each connection is handled by a separate `imap` process.
# `imap` process should have `client_limit=1` as described in
# <https://doc.dovecot.org/configuration_manual/service_configuration/#service-limits>
# so each logged in IMAP session will need its own `imap` process.
#
# If this limit is reached,
# users will fail to LOGIN as `imap-login` process
# will accept them logging in but fail to transfer logged in
# connection to `imap` process until someone logs out and
# the following warning will be logged:
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
service imap {
process_limit = 50000
}
mail_server_admin = mailto:root@{{ config.mail_domain }}
mail_server_comment = Chatmail server
mail_plugins = quota
# `zlib` enables compressing messages stored in the maildir.
# See
# <https://doc.dovecot.org/configuration_manual/zlib_plugin/>
# for documentation.
#
# quota plugin documentation:
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
mail_plugins = zlib quota
# these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small
@@ -44,7 +67,7 @@ userdb {
##
# Mailboxes are stored in the "mail" directory of the vmail user home.
mail_location = maildir:/home/vmail/mail/%d/%u
mail_location = maildir:{{ config.mailboxes_dir }}/%u
namespace inbox {
inbox = yes
@@ -80,7 +103,7 @@ mail_privileged_group = vmail
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
# Enable IMAP COMPRESS (RFC 4978).
# `imap_zlib` enables IMAP COMPRESS (RFC 4978).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
protocol imap {
mail_plugins = $mail_plugins imap_zlib imap_quota
@@ -88,9 +111,6 @@ protocol imap {
}
protocol lmtp {
# quota plugin documentation:
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
#
# notify plugin is a dependency of push_notification plugin:
# <https://doc.dovecot.org/settings/plugin/notify-plugin/>
#
@@ -99,10 +119,11 @@ protocol lmtp {
#
# mail_lua and push_notification_lua are needed for Lua push notification handler.
# <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
#
# Sieve to mark messages that should not be notified as \Seen
# <https://doc.dovecot.org/configuration_manual/sieve/configuration/>
mail_plugins = $mail_plugins quota mail_lua notify push_notification push_notification_lua sieve
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
}
plugin {
zlib_save = gz
}
plugin {
@@ -124,10 +145,6 @@ plugin {
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
}
plugin {
sieve_default = file:/etc/dovecot/default.sieve
}
service lmtp {
user=vmail

View File

@@ -1,11 +1,12 @@
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# even if they are unseen
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
3 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -name 'maildirsize' -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
3 0 * * * vmail find {{ config.mailboxes_dir }} -name 'maildirsize' -type f -delete
4 0 * * * vmail /usr/local/lib/chatmaild/venv/bin/delete_inactive_users /usr/local/lib/chatmaild/chatmail.ini

View File

@@ -17,12 +17,8 @@ function dovecot_lua_notify_event_message_new(user, event)
if user.username ~= event.from_address then
-- Incoming message
if not contains(event.keywords, "$Auto") then
-- Not an Auto-Submitted message, notifying.
-- Notify METADATA server about new message.
mbox:metadata_set("/private/messagenew", "")
end
-- Notify METADATA server about new message.
mbox:metadata_set("/private/messagenew", "")
end
mbox:free()

View File

@@ -1 +1 @@
*/5 * * * * root {{ config.execpath }} /home/vmail/mail/{{ config.mail_domain }} >/var/www/html/metrics
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics

View File

@@ -19,6 +19,13 @@
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>465</port>
@@ -33,5 +40,12 @@
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>443</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View File

@@ -1,3 +1,5 @@
load_module modules/ngx_stream_module.so;
user www-data;
worker_processes auto;
pid /run/nginx.pid;
@@ -8,6 +10,21 @@ events {
# multi_accept on;
}
stream {
map $ssl_preread_alpn_protocols $proxy {
default 127.0.0.1:8443;
~\bsmtp\b 127.0.0.1:submissions;
~\bimap\b 127.0.0.1:imaps;
}
server {
listen 443;
listen [::]:443;
proxy_pass $proxy;
ssl_preread on;
}
}
http {
sendfile on;
tcp_nopush on;
@@ -26,8 +43,8 @@ http {
gzip on;
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
listen 8443 ssl default_server;
listen [::]:8443 ssl default_server;
root /var/www/html;
@@ -78,8 +95,8 @@ http {
# Redirect www. to non-www
server {
listen 443 ssl;
listen [::]:443 ssl;
listen 8443 ssl;
listen [::]:8443 ssl;
server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7;

View File

@@ -25,7 +25,24 @@ KeyTable /etc/dkimkeys/KeyTable
SigningTable refile:/etc/dkimkeys/SigningTable
# Sign Autocrypt header in addition to the default specified in RFC 6376.
SignHeaders *,+autocrypt
#
# Default list is here:
# <https://github.com/trusteddomainproject/OpenDKIM/blob/5c539587561785a66c1f67f720f2fb741f320785/libopendkim/dkim.c#L221-L245>
SignHeaders *,+autocrypt,+content-type
# Prevent addition of second Content-Type header
# and other important headers that should not be added
# after signing the message.
# See
# <https://www.zone.eu/blog/2024/05/17/bimi-and-dmarc-cant-save-you/>
# and RFC 6376 (page 41) for reference.
#
# We don't use "l=" body length so the problem described in RFC 6376
# is not applicable, but adding e.g. a second "From" header
# or second "Autocrypt" header is better prevented in any case.
#
# Default is empty.
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
# Script to ignore signatures that do not correspond to the From: domain.
ScreenPolicyScript /etc/opendkim/screen.lua

View File

@@ -77,3 +77,7 @@ mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_
# 1:1 map MAIL FROM to SASL login name.
smtpd_sender_login_maps = regexp:/etc/postfix/login_map
# Do not lookup SMTP client hostnames to reduce delays
# and avoid unnecessary DNS requests.
smtpd_peername_lookup = no

View File

@@ -15,7 +15,7 @@ smtp inet n - y - - smtpd -v
smtp inet n - y - - smtpd
{%- endif %}
-o smtpd_milters=unix:opendkim/opendkim.sock
submission inet n - y - - smtpd
submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
@@ -32,7 +32,7 @@ submission inet n - y - - smtpd
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
smtps inet n - y - - smtpd
smtps inet n - y - 5000 smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt

View File

@@ -0,0 +1,125 @@
"""
Pure python functions which execute remotely in a system Python interpreter.
All functions of this module
- need to get and and return Python builtin data types only,
- can only use standard library dependencies,
- can freely call each other.
"""
import re
import traceback
from subprocess import CalledProcessError, check_output
def shell(command, fail_ok=False):
print(f"$ {command}")
try:
return check_output(command, shell=True).decode().rstrip()
except CalledProcessError:
if not fail_ok:
raise
return ""
def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")]
def perform_initial_checks(mail_domain):
"""Collecting initial DNS zone content."""
assert mail_domain
A = query_dns("A", mail_domain)
AAAA = query_dns("AAAA", mail_domain)
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS)
if not MTA_STS or (not A and not AAAA):
return res
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
if not shell("dig", fail_ok=True):
shell("apt-get install -y dnsutils")
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim")
# parse out sts-id if exists, example: "v=STSv1; id=2090123"
parts = query_dns("TXT", f"_mta-sts.{mail_domain}").split("id=")
res["sts_id"] = parts[1].rstrip('"') if len(parts) == 2 else ""
return res
def get_dkim_entry(mail_domain, dkim_selector):
try:
dkim_pubkey = shell(
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
)
except CalledProcessError:
return
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
return f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"'
def query_dns(typ, domain):
res = shell(f"dig -r -q {domain} -t {typ} +short")
print(res)
if res:
return res.split("\n")[0]
return ""
def check_zonefile(zonefile):
"""Check expected zone file entries."""
diff = []
for zf_line in zonefile.splitlines():
print(f"dns-checking {zf_line!r}")
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip()
query_value = query_dns(zf_typ, zf_domain)
if zf_value != query_value:
assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line
diff.append(zf_line)
return diff
## Function Execution server
def _run_loop(cmd_channel):
while 1:
cmd = cmd_channel.receive()
if cmd is None:
break
cmd_channel.send(_handle_one_request(cmd))
def _handle_one_request(cmd):
func_name, kwargs = cmd
try:
res = globals()[func_name](**kwargs)
return ("finish", res)
except:
data = traceback.format_exc()
return ("error", data)
# check if this module is executed remotely
# and setup a simple serialized function-execution loop
if __name__ == "__channelexec__":
channel = channel # noqa (channel object gets injected)
# enable simple "print" debugging for anyone changing this module
globals()["print"] = lambda x="": channel.send(("log", x))
_run_loop(channel)

View File

@@ -2,7 +2,7 @@
Description=Chatmail dict proxy for IMAP METADATA
[Service]
ExecStart={execpath} /run/chatmail-metadata/metadata.socket /home/vmail/mail/{mail_domain} {config_path}
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
Restart=always
RestartSec=30
User=vmail

View File

@@ -2,7 +2,7 @@
Description=Chatmail dict authentication proxy for dovecot
[Service]
ExecStart={execpath} /run/doveauth/doveauth.socket /home/vmail/passdb.sqlite {config_path}
ExecStart={execpath} /run/doveauth/doveauth.socket {config_path}
Restart=always
RestartSec=30
User=vmail

View File

@@ -0,0 +1,48 @@
import sys
import execnet
class FuncError(Exception):
pass
class SSHExec:
RemoteError = execnet.RemoteError
FuncError = FuncError
def __init__(self, host, remote_funcs, verbose=False, python="python3", timeout=60):
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
self._remote_cmdloop_channel = self.gateway.remote_exec(remote_funcs)
self.timeout = timeout
self.verbose = verbose
def __call__(self, call, kwargs=None, log_callback=None):
if kwargs is None:
kwargs = {}
self._remote_cmdloop_channel.send((call.__name__, kwargs))
while 1:
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
if log_callback is not None and code == "log":
log_callback(data)
elif code == "finish":
return data
elif code == "error":
raise self.FuncError(data)
def logged(self, call, kwargs):
def log_progress(data):
sys.stdout.write(".")
sys.stdout.flush()
title = call.__doc__
if not title:
title = call.__name__
if self.verbose:
print("[ssh] " + title)
return self(call, kwargs, log_callback=print)
else:
print(title, end="")
res = self(call, kwargs, log_callback=log_progress)
print()
return res

View File

@@ -2,6 +2,56 @@ import smtplib
import pytest
from cmdeploy import remote_funcs
from cmdeploy.sshexec import SSHExec
class TestSSHExecutor:
@pytest.fixture(scope="class")
def sshexec(self, sshdomain):
return SSHExec(sshdomain, remote_funcs)
def test_ls(self, sshexec):
out = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
out2 = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
assert out == out2
def test_perform_initial(self, sshexec, maildomain):
res = sshexec(
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
assert res["A"] or res["AAAA"]
def test_logged(self, sshexec, maildomain, capsys):
sshexec.logged(
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
out, err = capsys.readouterr()
assert out.startswith("Collecting")
assert out.endswith("....\n")
assert out.count("\n") == 1
sshexec.verbose = True
sshexec.logged(
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
)
out, err = capsys.readouterr()
lines = out.split("\n")
assert len(lines) > 4
assert remote_funcs.perform_initial_checks.__doc__ in lines[0]
def test_exception(self, sshexec, capsys):
try:
sshexec.logged(
remote_funcs.perform_initial_checks,
kwargs=dict(mail_domain=None),
)
except sshexec.FuncError as e:
assert "remote_funcs.py" in str(e)
assert "AssertionError" in str(e)
else:
pytest.fail("didn't raise exception")
def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
@@ -90,12 +140,12 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
def test_expunged(remote, chatmail_config):
outdated_days = int(chatmail_config.delete_mails_after) + 1
find_cmds = [
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/new/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f",
f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
]
for cmd in find_cmds:
for line in remote.iter_output(cmd):

View File

@@ -35,7 +35,7 @@ def pytest_runtest_setup(item):
pytest.skip("skipping slow test, use --slow to run")
@pytest.fixture
@pytest.fixture(scope="session")
def chatmail_config(pytestconfig):
current = basedir = Path().resolve()
while 1:
@@ -49,12 +49,12 @@ def chatmail_config(pytestconfig):
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
@pytest.fixture
@pytest.fixture(scope="session")
def maildomain(chatmail_config):
return chatmail_config.mail_domain
@pytest.fixture
@pytest.fixture(scope="session")
def sshdomain(maildomain):
return os.environ.get("CHATMAIL_SSH", maildomain)

View File

@@ -21,8 +21,9 @@ class TestCmdline:
run = parser.parse_args(["run"])
assert init and run
@pytest.mark.xfail(reason="init doesn't exit anymore, check for CLI output instead")
def test_init_not_overwrite(self):
main(["init", "chat.example.org"])
with pytest.raises(SystemExit):
main(["init", "chat.example.org"])
def test_init_not_overwrite(self, capsys):
assert main(["init", "chat.example.org"]) == 0
capsys.readouterr()
assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr()
assert "path exists" in out.lower()

View File

@@ -0,0 +1,50 @@
import pytest
from cmdeploy import remote_funcs
from cmdeploy.dns import check_initial_remote_data
class TestPerformInitialChecks:
@pytest.fixture
def mockdns(self, monkeypatch):
qdict = {
"A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": {"mta-sts.some.domain": "some.domain"},
}.copy()
def query_dns(typ, domain):
try:
return qdict[typ][domain]
except KeyError:
return ""
monkeypatch.setattr(remote_funcs, query_dns.__name__, query_dns)
return qdict
def test_perform_initial_checks_ok1(self, mockdns):
remote_data = remote_funcs.perform_initial_checks("some.domain")
assert len(remote_data) == 7
@pytest.mark.parametrize("drop", ["A", "AAAA"])
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
del mockdns[drop]
remote_data = remote_funcs.perform_initial_checks("some.domain")
assert len(remote_data) == 7
assert not remote_data[drop]
l = []
res = check_initial_remote_data(remote_data, print=l.append)
assert res
assert not l
def test_perform_initial_checks_no_mta_sts(self, mockdns):
del mockdns["CNAME"]
remote_data = remote_funcs.perform_initial_checks("some.domain")
assert len(remote_data) == 4
assert not remote_data["MTA_STS"]
l = []
res = check_initial_remote_data(remote_data, print=l.append)
assert not res
assert len(l) == 2

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/sh
#
# Wrapper for cmdelpoy to run it in activated virtualenv.
set -e

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Install dependencies
echo "Installing dependencies for this script:"

Submodule scripts/dovecot/dovecot-build/dovecot deleted from 4b7f802ca1

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
python3 -m venv --upgrade-deps venv

View File

@@ -77,7 +77,7 @@ we process the following data and details:
- Users can retrieve or delete all stored messages
without intervention from the operators using standard IMAP client tools.
### 3.1 Account setup
### 2.1 Account setup
Creating an account happens in one of two ways on our mail servers:
@@ -98,7 +98,7 @@ Art. 6 (1) lit. b GDPR,
as you have a usage contract with us
by using our services.
## 3.2 Processing of E-Mail-Messages
### 2.2 Processing of E-Mail-Messages
In addition,
we will process data
@@ -124,7 +124,7 @@ Therefore, limits are enforced:
- message size limits
- any other limit neccessary for the whole server to function in a healthy way
- any other limit necessary for the whole server to function in a healthy way
and to prevent abuse.
The processing and use of the above permissions