Compare commits

..

27 Commits

Author SHA1 Message Date
link2xt
4c9e8d40b4 Remove authentication logs from dictproxy
They log the passwords and make it difficult to spot actual exceptions.
2023-11-07 19:15:59 +00:00
missytake
40a88c7fc6 nginx: move config to own directory 2023-11-05 01:32:21 +01:00
holger krekel
8791e7735d simplify history of nine branch 2023-11-01 23:15:25 +01:00
holger krekel
248f67dcf6 fix nocreate location 2023-11-01 22:42:38 +01:00
holger krekel
a24df735d4 streamline README, port some changes/additions from nine-branch 2023-11-01 22:42:38 +01:00
holger krekel
7d0797c510 streamline account creation and add tests
also incorporates nine.testrun.org user policies
2023-11-01 21:57:43 +01:00
holger krekel
3a9db729f8 simplify sysctl call 2023-10-31 22:03:03 +01:00
holger krekel
7eb86cba34 increase inotify limits for dovecot 2023-10-31 22:03:03 +01:00
link2xt
5633c0612e dovecot: increase number of simultaneous connections handled by imap-login
Otherwise deltachat core CI running fails with "Connection queue full"
error on IMAP connections.
2023-10-29 19:09:33 +00:00
holger krekel
d5912b909c fix benchmark script 2023-10-28 16:50:24 +02:00
link2xt
f75eb0658c Require that passwords are at least 10 characters long 2023-10-28 13:38:15 +00:00
link2xt
7c5ec1e0df Add scripts/generate-dns-zone.sh 2023-10-24 21:23:20 +00:00
holger krekel
11ebc4623c somehow this deploy.sh adpatation was missing from main, not sure why 2023-10-24 23:19:40 +02:00
missytake
cf29053389 added full path to tox 2023-10-24 23:19:27 +02:00
holger krekel
1e7d0d10f5 follow link2xt advise and don't check subject/body at all -- turns out there were no tests anyway. 2023-10-22 14:59:37 +02:00
holger krekel
3dd94cbe69 passes the test 2023-10-22 14:59:37 +02:00
holger krekel
ed1b2f9da1 add a failing test for read receipts between two instances 2023-10-22 14:59:37 +02:00
link2xt
7ee84b44df Use tox -c option 2023-10-22 14:48:30 +02:00
link2xt
02205246dd Setup deltachat dependency in init.sh 2023-10-22 14:48:30 +02:00
holger krekel
fcd3194eb1 run tests via scripts 2023-10-22 14:48:30 +02:00
holger krekel
bdef189ce1 try to run all offline tests in CI 2023-10-22 14:48:30 +02:00
holger krekel
3058ddc542 fix init.sh and test.sh
use tox for chatmaild non-online tests
2023-10-22 14:48:30 +02:00
holger krekel
bada933fef add missing file 2023-10-22 14:48:30 +02:00
holger krekel
1d74b94162 rename fixture to maildata and rename doveauth 2023-10-22 14:48:30 +02:00
holger krekel
eee6d0c871 more maildata shifting 2023-10-22 14:48:30 +02:00
holger krekel
ed5e37f1fa move all inlined mails to a data directory 2023-10-22 14:48:30 +02:00
holger krekel
364300274e move all tests into a root "tests" folder so they can share setup and config 2023-10-22 14:48:30 +02:00
32 changed files with 486 additions and 170 deletions

View File

@@ -5,14 +5,27 @@ on:
push: push:
jobs: jobs:
lint: tox:
name: Lint name: chatmail tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Lint chatmaild - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild
run: pipx run tox run: pipx run tox
- name: Lint deploy-chatmail - name: run deploy-chatmail offline tests
working-directory: deploy-chatmail working-directory: deploy-chatmail
run: pipx run tox run: pipx run tox
- name: run deploy-chatmail offline tests
working-directory: deploy-chatmail
run: pipx run tox
scripts:
name: chatmail script invocations
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: run init.sh
run: ./scripts/init.sh
- name: run test.sh
run: ./scripts/test.sh

2
.gitignore vendored
View File

@@ -159,3 +159,5 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
chatmail.zone

View File

@@ -1,23 +1,61 @@
# Chat Mail server configuration # Chatmail instances optimized for Delta Chat apps
This repository setups a ready-to-go chatmail instance This repository helps to setup a ready-to-use chatmail instance
comprised of a minimal setup of the battle-tested comprised of a minimal setup of the battle-tested
[postfix smtp server](https://www.postfix.org) and [dovecot imap server](https://www.dovecot.org). [postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
## Getting started The setup is designed and optimized for providing chatmail accounts
for use by [Delta Chat apps](https://delta.chat).
1. prepare your local system: Chatmail accounts are automatically created by a first login,
after which the initially specified password is required for using them.
## Getting Started deploying your own chatmail instance
1. Prepare your local (presumably Linux) system:
scripts/init.sh scripts/init.sh
2. set environment variable to the chatmail domain you want to setup: 2. Setup a domain with `A` and `AAAA` records for your chatmail server.
3. Set environment variable to the chatmail domain you want to setup:
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
3. run the deploy of the chat mail instance: 4. Deploy the chat mail instance to your chatmail server:
scripts/deploy.sh scripts/deploy.sh
This script uses `pyinfra` and `ssh` to setup packages and configure
the chatmail instance on your remote server.
5. Run `scripts/generate-dns-zone.sh` and
transfer the generated DNS records at your DNS provider
6. Start a Delta Chat app and create a new account
by typing an e-mail address with an arbitrary username
and `@<your-chatmail-domain>` appended.
Use an at least 10-character random password.
### Ports
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
Dovecot listens on ports 143(imap) and 993 (imaps).
Delta Chat will, however, discover all ports and configurations
automatically by reading the `autoconfig.xml` file from the chatmail instance.
## Emergency Commands to disable automatic account creation
If you need to stop account creation,
e.g. because some script is wildly creating accounts, run:
touch /etc/chatmail-nocreate
While this file is present, account creation will be blocked.
## Running tests and benchmarks (offline and online) ## Running tests and benchmarks (offline and online)
@@ -32,28 +70,26 @@ comprised of a minimal setup of the battle-tested
scripts/bench.sh scripts/bench.sh
## Running tests (offline and online)
``` ## Development Background for chatmail instances
## Dovecot/Postfix configuration
### Ports This repository drives the development of "chatmail instances",
comprised of minimal setups of
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions). - [postfix smtp server](https://www.postfix.org)
Dovecot listens on ports 143(imap) and 993 (imaps). - [dovecot imap server](https://www.dovecot.org)
## DNS as well as two custom services that are integrated with these two:
For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance. - `chatmaild/src/chatmaild/dictproxy.py` implements
The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you create-on-login account creation semantics and is used
need to setup with your DNS provider. by Dovecot during login authentication and by Postfix
which in turn uses Dovecot SASL to authenticate users
to send mails for them.
- `chatmaild/src/chatmaild/filtermail.py` prevents
unencrypted e-mail from leaving the chatmail instance
and is integrated into postfix's outbound mail pipelines.
## Emergency Commands
If you need to stop account creation,
e.g. because some script is wildly creating accounts,
just run `touch /tmp/nocreate`.
You can remove the file
as soon as the attacker was banned
by different means.

View File

@@ -20,7 +20,7 @@ addopts = "-v -ra --strict-markers"
legacy_tox_ini = """ legacy_tox_ini = """
[tox] [tox]
isolated_build = true isolated_build = true
envlist = lint envlist = lint,py
[testenv:lint] [testenv:lint]
skipdist = True skipdist = True
@@ -31,4 +31,10 @@ deps =
commands = commands =
black --quiet --check --diff src/ black --quiet --check --diff src/
ruff src/ ruff src/
[testenv]
passenv = CHATMAIL_DOMAIN
deps = pytest
pdbpp
commands = pytest -v -rsXx {posargs: ../tests/chatmaild}
""" """

View File

@@ -21,68 +21,92 @@ def encrypt_password(password: str):
return "{SHA512-CRYPT}" + passhash return "{SHA512-CRYPT}" + passhash
class DictProxy: def is_allowed_to_create(user, cleartext_password) -> bool:
def __init__(self, db, mail_domain): """Return True if user and password are admissable."""
self.db = db if os.path.exists(NOCREATE_FILE):
self.mail_domain = mail_domain logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
return False
def create_user(self, user, password): if len(cleartext_password) < 10:
if os.path.exists(NOCREATE_FILE): logging.warning("Password needs to be at least 10 characters long")
logging.warning(f"Didn't create account: {NOCREATE_FILE} exists.") return False
parts = user.split("@")
if len(parts) != 2:
logging.warning(f"user {user!r} is not a proper e-mail address")
return False
localpart, domain = parts
if domain == "nine.testrun.org":
# nine.testrun.org policy, username has to be exactly nine chars
if len(localpart) != 9:
logging.warning(f"localpart {localpart!r} has not exactly nine chars")
return False
return True
def create_user(db, user, encrypted_password):
with db.write_transaction() as conn:
conn.create_user(user, encrypted_password)
return dict(
home=f"/home/vmail/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def get_user_data(db, user):
with db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(db, user):
return get_user_data(db, user)
def lookup_passdb(db, user, cleartext_password):
userdata = get_user_data(db, user)
if not userdata:
if not is_allowed_to_create(user, cleartext_password):
return return
with self.db.write_transaction() as conn: encrypted_password = encrypt_password(cleartext_password)
conn.create_user(user, password) userdata = create_user(db=db, user=user, encrypted_password=encrypted_password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password) userdata["password"] = userdata["password"].strip()
return userdata
def get_user_data(self, user):
with self.db.read_connection() as conn:
result = conn.get_user(user)
if result:
result["uid"] = "vmail"
result["gid"] = "vmail"
return result
def lookup_userdb(self, user): def handle_dovecot_request(msg, db, mail_domain):
return self.get_user_data(user) short_command = msg[0]
if short_command == "L": # LOOKUP
parts = msg[1:].split("\t")
def lookup_passdb(self, user, password): keyname, user = parts[:2]
userdata = self.get_user_data(user) namespace, type, *args = keyname.split("/")
if not userdata: reply_command = "F"
return self.create_user(user, encrypt_password(password)) res = ""
userdata["password"] = userdata["password"].strip() if namespace == "shared":
return userdata if type == "userdb":
if user.endswith(f"@{mail_domain}"):
res = lookup_userdb(db, user)
def handle_dovecot_request(self, msg): if res:
print(f"received msg: {msg!r}", file=sys.stderr) reply_command = "O"
short_command = msg[0] else:
if short_command == "L": # LOOKUP reply_command = "N"
parts = msg[1:].split("\t") elif type == "passdb":
keyname, user = parts[:2] if user.endswith(f"@{mail_domain}"):
namespace, type, *args = keyname.split("/") res = lookup_passdb(db, user, cleartext_password=args[0])
reply_command = "F" if res:
res = "" reply_command = "O"
if namespace == "shared": else:
if type == "userdb": reply_command = "N"
if user.endswith(f"@{self.mail_domain}"): json_res = json.dumps(res) if res else ""
res = lookup_userdb(db, user) return f"{reply_command}{json_res}\n"
if res: return None
reply_command = "O"
else:
reply_command = "N"
elif type == "passdb":
if user.endswith(f"@{self.mail_domain}"):
res = lookup_passdb(db, user, password=args[0])
if res:
reply_command = "O"
else:
reply_command = "N"
print(f"res: {res!r}", file=sys.stderr)
json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n"
return None
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer): class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
@@ -92,20 +116,18 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
def main(): def main():
socket = sys.argv[1] socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2]) passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
with open("/etc/mailname", "r") as fp: with open("/etc/mailname", "r") as fp:
mail_domain = fp.read().strip() mail_domain = fp.read().strip()
db = Database(sys.argv[3])
dictproxy = DictProxy(db, mail_domain)
class Handler(StreamRequestHandler): class Handler(StreamRequestHandler):
def handle(self): def handle(self):
while True: while True:
msg = self.rfile.readline().strip().decode() msg = self.rfile.readline().strip().decode()
if not msg: if not msg:
break break
res = dictproxy.handle_dovecot_request(msg) res = handle_dovecot_request(msg, db, mail_domain)
if res: if res:
print(f"sending result: {res!r}", file=sys.stderr)
self.wfile.write(res.encode("ascii")) self.wfile.write(res.encode("ascii"))
self.wfile.flush() self.wfile.flush()

View File

@@ -34,6 +34,34 @@ def check_encrypted(message):
return True 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
class SMTPController(Controller): class SMTPController(Controller):
def factory(self): def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs) return SMTP(self.handler, **self.SMTP_kwargs)
@@ -82,6 +110,9 @@ def check_DATA(envelope):
if envelope.mail_from.lower() != from_addr.lower(): if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>" return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if not mail_encrypted and check_mdn(message, envelope):
return
envelope_from_domain = from_addr.split("@").pop() envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient: if envelope.mail_from == recipient:

View File

@@ -4,8 +4,8 @@ Chat Mail pyinfra deploy.
import importlib.resources import importlib.resources
from pathlib import Path from pathlib import Path
from pyinfra import host, logger from pyinfra import host
from pyinfra.operations import apt, files, server, systemd, python from pyinfra.operations import apt, files, server, systemd
from pyinfra.facts.files import File from pyinfra.facts.files import File
from .acmetool import deploy_acmetool from .acmetool import deploy_acmetool
@@ -70,6 +70,36 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
mode="644", mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector}, config={"domain_name": domain, "opendkim_selector": dkim_selector},
) )
need_restart |= main_config.changed
files.directory(
name="Add opendkim directory to /etc",
path="/etc/opendkim",
user="opendkim",
group="opendkim",
mode="750",
present=True,
)
keytable = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/KeyTable"),
dest="/etc/dkimkeys/KeyTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"),
dest="/etc/dkimkeys/SigningTable",
user="opendkim",
group="opendkim",
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory( files.directory(
name="Add opendkim socket directory to /var/spool/postfix", name="Add opendkim socket directory to /var/spool/postfix",
@@ -90,8 +120,6 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
_sudo_user="opendkim", _sudo_user="opendkim",
) )
need_restart |= main_config.changed
return need_restart return need_restart
@@ -155,6 +183,17 @@ def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
mode="644", mode="644",
) )
# as per https://doc.dovecot.org/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
server.sysctl(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
return need_restart return need_restart
@@ -163,7 +202,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
need_restart = False need_restart = False
main_config = files.template( main_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx.conf.j2"), src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
dest="/etc/nginx/nginx.conf", dest="/etc/nginx/nginx.conf",
user="root", user="root",
group="root", group="root",
@@ -173,7 +212,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
need_restart |= main_config.changed need_restart |= main_config.changed
autoconfig = files.template( autoconfig = files.template(
src=importlib.resources.files(__package__).joinpath("autoconfig.xml.j2"), src=importlib.resources.files(__package__).joinpath("nginx/autoconfig.xml.j2"),
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml", dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root", user="root",
group="root", group="root",
@@ -238,6 +277,12 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector) opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
nginx_need_restart = _configure_nginx(mail_domain) nginx_need_restart = _configure_nginx(mail_domain)
# deploy web pages and info if we have them
pkg_root = importlib.resources.files(__package__)
www_path = pkg_root.joinpath(f"../../../www/{mail_domain}").resolve()
if www_path.is_dir():
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz"])
systemd.service( systemd.service(
name="Start and enable OpenDKIM", name="Start and enable OpenDKIM",
service="opendkim.service", service="opendkim.service",
@@ -292,14 +337,3 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
enabled=True, enabled=True,
restarted=journald_conf, restarted=journald_conf,
) )
def callback():
result = server.shell(
commands=[
f"""sed 's/\tIN/ 600 IN/;s/\t(//;s/\"$//;s/^\t \"//g; s/ ).*//' """
f"""/etc/dkimkeys/{dkim_selector}.txt | tr --delete '\n'"""
]
)
logger.info(f"Add this TXT entry into DNS zone: {result.stdout}")
python.call(name="Print TXT entry for DKIM", function=callback)

View File

@@ -6,7 +6,7 @@ from deploy_chatmail import deploy_chatmail
def main(): def main():
mail_domain = os.getenv("CHATMAIL_DOMAIN") mail_domain = os.getenv("CHATMAIL_DOMAIN")
mail_server = os.getenv("CHATMAIL_SERVER", mail_domain) mail_server = os.getenv("CHATMAIL_SERVER", mail_domain)
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "2023") dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "dkim")
assert mail_domain assert mail_domain
assert mail_server assert mail_server

View File

@@ -118,6 +118,24 @@ service auth-worker {
user = vmail user = vmail
} }
service imap-login {
# High-security mode.
# Each process serves a single connection and exits afterwards.
# This is the default, but we set it explicitly to be sure.
# See <https://doc.dovecot.org/admin_manual/login_processes/#high-security-mode> for details.
service_count = 1
# Inrease the number of simultaneous connections.
#
# As of Dovecot 2.3.19.1 the default is 100 processes.
# Combined with `service_count = 1` it means only 100 connections
# can be handled simultaneously.
process_limit = 10000
# Avoid startup latency for new connections.
process_min_avail = 10
}
ssl = required ssl = required
ssl_cert = </var/lib/acme/live/{{ config.hostname }}/fullchain ssl_cert = </var/lib/acme/live/{{ config.hostname }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.hostname }}/privkey ssl_key = </var/lib/acme/live/{{ config.hostname }}/privkey

View File

@@ -0,0 +1 @@
dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private

View File

@@ -0,0 +1 @@
*@{{ config.domain_name }} {{ config.opendkim_selector }}._domainkey.{{ config.domain_name }}

View File

@@ -1,7 +1,4 @@
# This is a basic configuration for signing and verifying. It can easily be # OpenDKIM configuration.
# adapted to suit a basic installation. See opendkim.conf(5) and
# /usr/share/doc/opendkim/examples/opendkim.conf.sample for complete
# documentation of available configuration parameters.
Syslog yes Syslog yes
SyslogSuccess yes SyslogSuccess yes
@@ -21,7 +18,9 @@ OversignHeaders From
# setup options can be found in /usr/share/doc/opendkim/README.opendkim. # setup options can be found in /usr/share/doc/opendkim/README.opendkim.
Domain {{ config.domain_name }} Domain {{ config.domain_name }}
Selector {{ config.opendkim_selector }} Selector {{ config.opendkim_selector }}
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable /etc/dkimkeys/SigningTable
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when # In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged # using a local socket with MTAs that access the socket as a non-privileged

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
set -e set -e
online-tests/venv/bin/pytest online-tests/benchmark.py -vrx venv/bin/pytest online-tests/benchmark.py -vrx

View File

@@ -1,10 +1,15 @@
#!/usr/bin/env bash #!/usr/bin/env bash
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
export CHATMAIL_DOMAIN
chatmaild/venv/bin/python3 -m build -n --sdist chatmaild --outdir dist echo -----------------------------------------
echo deploying to $CHATMAIL_DOMAIN
echo -----------------------------------------
deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \ echo WARNING: in five seconds deploy to $CHATMAIL_DOMAIN starts
sleep 5
venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \
deploy-chatmail/src/deploy_chatmail/deploy.py deploy-chatmail/src/deploy_chatmail/deploy.py
rm -r dist/ rm -r dist/

20
scripts/generate-dns-zone.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/sh
: ${CHATMAIL_DOMAIN:=c1.testrun.org}
: ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN}
set -e
SSH="ssh root@$CHATMAIL_SSH"
EMAIL="root@$CHATMAIL_DOMAIN"
ACME_ACCOUNT_URL="$($SSH -- acmetool account-url)"
cat <<EOF
$CHATMAIL_DOMAIN. MX 10 $CHATMAIL_DOMAIN.
$CHATMAIL_DOMAIN. TXT "v=spf1 a:$CHATMAIL_DOMAIN -all"
_dmarc.$CHATMAIL_DOMAIN. TXT "v=DMARC1;p=reject;rua=mailto:$EMAIL;ruf=mailto:$EMAIL;fo=1;adkim=r;aspf=r"
_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. IN CAA 0 issue "letsencrypt.org; accounturi=$ACME_ACCOUNT_URL"
EOF
$SSH opendkim-genzone -F | sed 's/^;.*$//;/^$/d'

View File

@@ -11,3 +11,4 @@ conn.login(f"imapcapa", "pass")
status, res = conn.capability() status, res = conn.capability()
for capa in sorted(res[0].decode().split()): for capa in sorted(res[0].decode().split()):
print(capa) print(capa)

View File

@@ -1,13 +1,8 @@
#!/bin/sh #!/bin/sh
set -e set -e
python3 -m venv deploy-chatmail/venv python3 -m venv venv
deploy-chatmail/venv/bin/pip install pyinfra pytest pip=venv/bin/pip
deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv $pip install pyinfra pytest build 'setuptools>=68' tox deltachat
chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68' $pip install -e deploy-chatmail
chatmaild/venv/bin/pip install -e chatmaild $pip install -e chatmaild
python3 -m venv online-tests/venv
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat

View File

@@ -5,7 +5,7 @@ import imaplib
domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org") domain = os.environ.get("CHATMAIL_DOMAIN", "c3.testrun.org")
NUM_CONNECTIONS = 10 NUM_CONNECTIONS=10
conns = [] conns = []
@@ -16,7 +16,7 @@ for i in range(NUM_CONNECTIONS):
conns.append(conn) conns.append(conn)
tlsdone = time.time() tlsdone = time.time()
duration = tlsdone - start duration = tlsdone-start
print(f"{duration}: TLS connections opening TLS connections") print(f"{duration}: TLS connections opening TLS connections")
for i, conn in enumerate(conns): for i, conn in enumerate(conns):

View File

@@ -1,3 +1,4 @@
#!/bin/bash #!/bin/bash
chatmaild/venv/bin/pytest chatmaild/ $@ venv/bin/tox -c chatmaild
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 $@ venv/bin/tox -c deploy-chatmail
venv/bin/pytest tests/online -vrx --durations=5 $@

View File

@@ -1,9 +0,0 @@
import pytest
from chatmaild.database import Database
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)

View File

@@ -1,40 +1,44 @@
import os import os
import json
import pytest import pytest
import chatmaild.dictproxy import chatmaild.dictproxy
from chatmaild.dictproxy import DictProxy from chatmaild.dictproxy import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.database import DBError from chatmaild.database import Database, DBError
@pytest.fixture @pytest.fixture()
def dictproxy(db, maildomain): def db(tmpdir):
return DictProxy(db, maildomain) db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
def test_basic(dictproxy, tmpdir, monkeypatch):
monkeypatch.setattr( def test_basic(db):
chatmaild.dictproxy, "NOCREATE_FILE", tmpdir.join("nocreate").strpath lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
) data = get_user_data(db, "link2xt@c1.testrun.org")
dictproxy.lookup_passdb("link2xt@c1.testrun.org", "asdf") assert data
assert dictproxy.get_user_data("link2xt@c1.testrun.org") data2 = lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
assert data == data2
def test_dont_overwrite_password_on_wrong_login(dictproxy): def test_dont_overwrite_password_on_wrong_login(db):
"""Test that logging in with a different password doesn't create a new user""" """Test that logging in with a different password doesn't create a new user"""
res = dictproxy.lookup_passdb("newuser1@something.org", "kajdlkajsldk12l3kj1983") res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
assert res["password"] assert res["password"]
res2 = dictproxy.lookup_passdb("newuser1@something.org", "kajdlqweqwe") res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
# this function always returns a password hash, which is actually compared by dovecot. # this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"] assert res["password"] == res2["password"]
def test_nocreate_file(dictproxy, tmpdir, monkeypatch): def test_nocreate_file(db, monkeypatch, tmpdir):
nocreate = tmpdir.join("nocreate") p = tmpdir.join("nocreate")
monkeypatch.setattr(chatmaild.dictproxy, "NOCREATE_FILE", str(nocreate)) p.write("")
nocreate.write("") monkeypatch.setattr(chatmaild.dictproxy, "NOCREATE_FILE", str(p))
dictproxy.lookup_passdb("newuser1@something.org", "kajdlqweqwe") lookup_passdb(db, "newuser1@something.org", "zequ0Aimuchoodaechik")
assert not dictproxy.get_user_data("newuser1@something.org") assert not get_user_data(db, "newuser1@something.org")
def test_db_version(db): def test_db_version(db):
@@ -46,3 +50,15 @@ def test_too_high_db_version(db):
conn.execute("PRAGMA user_version=%s;" % (999,)) conn.execute("PRAGMA user_version=%s;" % (999,))
with pytest.raises(DBError): with pytest.raises(DBError):
db.ensure_tables() db.ensure_tables()
def test_handle_dovecot_request(db):
msg = ('Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/'
'some42@c3.testrun.org\tsome42@c3.testrun.org')
res = handle_dovecot_request(msg, db, "c3.testrun.org")
assert res
assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip())
assert userdata["home"] == "/home/vmail/some42@c3.testrun.org"
assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}")

View File

@@ -1,7 +1,13 @@
from chatmaild.filtermail import check_encrypted, check_DATA, SendRateLimiter from chatmaild.filtermail import check_encrypted, check_DATA, SendRateLimiter, check_mdn
import pytest import pytest
@pytest.fixture
def maildomain():
# let's not depend on a real chatmail instance for the offline tests below
return "chatmail.example.org"
def test_reject_forged_from(maildata, gencreds): def test_reject_forged_from(maildata, gencreds):
class env: class env:
mail_from = gencreds()[0] mail_from = gencreds()[0]
@@ -35,8 +41,33 @@ def test_filtermail_encryption_detection(maildata):
assert not check_encrypted(msg) assert not check_encrypted(msg)
def test_filtermail_mdn_is_not_encrypted(maildata): def test_filtermail_is_mdn(maildata, gencreds):
assert not check_encrypted(maildata("mdn.eml")) from_addr = gencreds()[0]
to_addr = gencreds()[0] + ".other"
msg = maildata("mdn.eml", from_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 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)
def test_send_rate_limiter(): def test_send_rate_limiter():

View File

@@ -195,8 +195,8 @@ def gencreds(maildomain):
num = next(count) num = next(count)
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890" alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
user = "".join(random.choices(alphanumeric, k=10)) user = "".join(random.choices(alphanumeric, k=10))
user = f"ac{num}_{user}" user = f"ac{num}_{user}"[:9]
password = "".join(random.choices(alphanumeric, k=10)) password = "".join(random.choices(alphanumeric, k=12))
yield f"{user}@{domain}", f"{password}" yield f"{user}@{domain}", f"{password}"
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@@ -290,7 +290,7 @@ class Remote:
def maildata(request, gencreds): def maildata(request, gencreds):
datadir = conftestdir.joinpath("mail-data") datadir = conftestdir.joinpath("mail-data")
def maildata(name, parsed=True, from_addr=None, to_addr=None): def maildata(name, from_addr=None, to_addr=None):
if from_addr is None: if from_addr is None:
from_addr = gencreds()[0] from_addr = gencreds()[0]
if to_addr is None: if to_addr is None:

View File

@@ -1,6 +1,6 @@
Subject: Message opened Subject: Message opened
From: <barbaz@c2.testrun.org> From: <{from_addr}>
To: <foobar@c2.testrun.org> To: <{to_addr}>
Date: Sun, 15 Oct 2023 16:43:25 +0000 Date: Sun, 15 Oct 2023 16:43:25 +0000
Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org> Message-ID: <Mr.78MWtlV7RAi.goCFzBhCYfy@c2.testrun.org>
Auto-Submitted: auto-replied Auto-Submitted: auto-replied

View File

@@ -23,6 +23,11 @@ def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
with pytest.raises(imap_or_smtp.AuthError): with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, password + "wrong") imap_or_smtp.login(user, password + "wrong")
lp.sec(f"creating users with a short password is not allowed")
user, _password = gencreds()
with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, "admin")
def test_login_same_password(imap_or_smtp, gencreds): def test_login_same_password(imap_or_smtp, gencreds):
"""Test two different users logging in with the same password """Test two different users logging in with the same password

View File

@@ -1,3 +1,4 @@
import time
import random import random
import pytest import pytest
@@ -81,3 +82,29 @@ class TestEndToEndDeltaChat:
ch = ac2.qr_setup_contact(qr) ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10 assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000) ac1._evtracker.wait_securejoin_inviter_progress(1000)
def test_read_receipts_between_instances(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.switch_maildomain(maildomain2)
ac2 = cmfactory.new_online_configuring_account(cache=False)
cmfactory.bring_accounts_online()
lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr()
ch = ac2.qr_setup_contact(qr)
msg = ac2.wait_next_incoming_message()
assert "verified" in msg.text
lp.sec("ac1 sends a message and ac2 marks it as seen")
chat = ac1.create_chat(ac2)
msg = chat.send_text("hi")
m = ac2.wait_next_incoming_message()
m.mark_seen()
# we can only indirectly wait for mark-seen to cause an smtp-error
lp.sec("try to wait for markseen to complete and check error states")
deadline = time.time() + 3.1
while time.time() < deadline:
msgs = m.chat.get_messages()
for msg in msgs:
assert "error" not in m.get_message_info()
time.sleep(1)

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>nine.testrun.org - Experimenting with the Future of Email</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
.wrapper {
width: 100%;
max-width: 596px;
margin: 0 auto;
}
.section {
width: 100%;
max-width: 596px;
}
.text {
box-sizing: border-box;
padding: 9px;
font-size: 18px;
font-family: "Courier New", monospace;
color: white;
background-position: left top;
background-image: url(collage-bg.png);
background-repeat: no-repeat;
background-size: 100% 100%;
}
h1, h2, h3 {
font-size: 16px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="wrapper">
<img class="section" src="collage-top.png" />
<div class="section text">
<h1>welcome to nine.testrun.org</h1>
<p>
to get an account,
invent a word with <i>exactly</i> nine characters
and append @nine.testrun.org to it.
eg. <b>hellofits@nine.testrun.org</b>
</p>
<p>
if the email address is not yet taken, you'll get that account.
the first login sets your password.
that's it.
</p>
</div>
<img class="section" src="collage-down.png" />
<div class="section text">
<h1>faq</h1>
<p><i>why are other email providers 1000 times more complicated?</i></p>
<p>because they want to for $reasons</p>
</div>
</div>
</body>
</html>