Compare commits

..

1 Commits

Author SHA1 Message Date
holger krekel
ca763960e5 streamline account creation and add tests
also incorporates nine.testrun.org user policies
2023-11-01 20:30:12 +01:00
17 changed files with 76 additions and 262 deletions

View File

@@ -1,61 +1,26 @@
# Chatmail instances optimized for Delta Chat apps # Chat Mail server configuration
This repository helps to setup a ready-to-use chatmail instance This repository setups a ready-to-go chatmail instance
comprised of a minimal setup of the battle-tested comprised of a minimal setup of the battle-tested
[postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services. [postfix smtp server](https://www.postfix.org) and [dovecot imap server](https://www.dovecot.org).
The setup is designed and optimized for providing chatmail accounts ## Getting started
for use by [Delta Chat apps](https://delta.chat).
Chatmail accounts are automatically created by a first login, 1. prepare your local system:
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. Setup a domain with `A` and `AAAA` records for your chatmail server. 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: 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
4. Deploy the chat mail instance to your chatmail server: 4. run the deploy of the chat mail instance:
scripts/deploy.sh scripts/deploy.sh
This script uses `pyinfra` and `ssh` to setup packages and configure 5. run `scripts/generate-dns-zone.sh` and create the generated DNS records at your DNS provider
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)
@@ -70,26 +35,28 @@ While this file is present, account creation will be blocked.
scripts/bench.sh scripts/bench.sh
## Running tests (offline and online)
## Development Background for chatmail instances ```
## Dovecot/Postfix configuration
This repository drives the development of "chatmail instances", ### Ports
comprised of minimal setups of
- [postfix smtp server](https://www.postfix.org) Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
- [dovecot imap server](https://www.dovecot.org) Dovecot listens on ports 143(imap) and 993 (imaps).
as well as two custom services that are integrated with these two: ## DNS
- `chatmaild/src/chatmaild/dictproxy.py` implements For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance.
create-on-login account creation semantics and is used The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you
by Dovecot during login authentication and by Postfix need to setup with your DNS provider.
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

@@ -33,6 +33,13 @@ class Connection:
def cursor(self): def cursor(self):
return self._sqlconn.cursor() return self._sqlconn.cursor()
def create_user(self, addr: str, password: str):
"""Create a row in the users table."""
self.execute("PRAGMA foreign_keys=on")
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
self.execute(q, (addr, password, int(time.time())))
def get_user(self, addr: str) -> {}: def get_user(self, addr: str) -> {}:
"""Get a row from the users table.""" """Get a row from the users table."""
q = "SELECT addr, password, last_login from users WHERE addr = ?" q = "SELECT addr, password, last_login from users WHERE addr = ?"

View File

@@ -1,6 +1,5 @@
import logging import logging
import os import os
import time
import sys import sys
import json import json
import crypt import crypt
@@ -47,6 +46,17 @@ def is_allowed_to_create(user, cleartext_password) -> bool:
return True 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): def get_user_data(db, user):
with db.read_connection() as conn: with db.read_connection() as conn:
result = conn.get_user(user) result = conn.get_user(user)
@@ -61,33 +71,18 @@ def lookup_userdb(db, user):
def lookup_passdb(db, user, cleartext_password): def lookup_passdb(db, user, cleartext_password):
with db.write_transaction() as conn: userdata = get_user_data(db, user)
userdata = conn.get_user(user) if not userdata:
if userdata:
# Update last login time.
conn.execute(
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
)
userdata["uid"] = "vmail"
userdata["gid"] = "vmail"
return userdata
if not is_allowed_to_create(user, cleartext_password): if not is_allowed_to_create(user, cleartext_password):
return return
encrypted_password = encrypt_password(cleartext_password) encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login) userdata = create_user(db=db, user=user, encrypted_password=encrypted_password)
VALUES (?, ?, ?)""" userdata["password"] = userdata["password"].strip()
conn.execute(q, (user, encrypted_password, int(time.time()))) return userdata
return dict(
home=f"/home/vmail/{user}",
uid="vmail",
gid="vmail",
password=encrypted_password,
)
def handle_dovecot_request(msg, db, mail_domain): def handle_dovecot_request(msg, db, mail_domain):
print(f"received msg: {msg!r}", file=sys.stderr)
short_command = msg[0] short_command = msg[0]
if short_command == "L": # LOOKUP if short_command == "L": # LOOKUP
parts = msg[1:].split("\t") parts = msg[1:].split("\t")
@@ -110,6 +105,7 @@ def handle_dovecot_request(msg, db, mail_domain):
reply_command = "O" reply_command = "O"
else: else:
reply_command = "N" reply_command = "N"
print(f"res: {res!r}", file=sys.stderr)
json_res = json.dumps(res) if res else "" json_res = json.dumps(res) if res else ""
return f"{reply_command}{json_res}\n" return f"{reply_command}{json_res}\n"
return None return None
@@ -134,6 +130,7 @@ def main():
break break
res = handle_dovecot_request(msg, db, mail_domain) 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

@@ -202,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/nginx.conf.j2"), src=importlib.resources.files(__package__).joinpath("nginx.conf.j2"),
dest="/etc/nginx/nginx.conf", dest="/etc/nginx/nginx.conf",
user="root", user="root",
group="root", group="root",
@@ -212,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("nginx/autoconfig.xml.j2"), src=importlib.resources.files(__package__).joinpath("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",
@@ -277,12 +277,6 @@ 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",

View File

@@ -19,7 +19,7 @@ mail_plugins = quota
# these are the capabilities Delta Chat cares about actually # these are the capabilities Delta Chat cares about actually
# so let's keep the network overhead per login small # so let's keep the network overhead per login small
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs # https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE
# Authentication for system users. # Authentication for system users.

View File

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

View File

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

View File

@@ -1,15 +1,21 @@
import os
import json import json
import pytest import pytest
import threading
import queue
import traceback
import chatmaild.dictproxy import chatmaild.dictproxy
from chatmaild.dictproxy import get_user_data, lookup_passdb, handle_dovecot_request from chatmaild.dictproxy import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.database import Database, DBError from chatmaild.database import Database, DBError
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
def test_basic(db): def test_basic(db):
lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u") lookup_passdb(db, "link2xt@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
data = get_user_data(db, "link2xt@c1.testrun.org") data = get_user_data(db, "link2xt@c1.testrun.org")
@@ -47,10 +53,8 @@ def test_too_high_db_version(db):
def test_handle_dovecot_request(db): def test_handle_dovecot_request(db):
msg = ( msg = ('Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/'
"Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/" 'some42@c3.testrun.org\tsome42@c3.testrun.org')
"some42@c3.testrun.org\tsome42@c3.testrun.org"
)
res = handle_dovecot_request(msg, db, "c3.testrun.org") res = handle_dovecot_request(msg, db, "c3.testrun.org")
assert res assert res
assert res[0] == "O" and res.endswith("\n") assert res[0] == "O" and res.endswith("\n")
@@ -58,29 +62,3 @@ def test_handle_dovecot_request(db):
assert userdata["home"] == "/home/vmail/some42@c3.testrun.org" assert userdata["home"] == "/home/vmail/some42@c3.testrun.org"
assert userdata["uid"] == userdata["gid"] == "vmail" assert userdata["uid"] == userdata["gid"] == "vmail"
assert userdata["password"].startswith("{SHA512-CRYPT}") assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_100_concurrent_lookups(db):
num = 100
dbs = [Database(db.path) for i in range(num)]
print(f"created {num} databases")
results = queue.Queue()
def lookup(db):
try:
lookup_passdb(db, "something@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
except Exception:
results.put(traceback.format_exc())
else:
results.put(None)
threads = [threading.Thread(target=lookup, args=(db,), daemon=True) for db in dbs]
print(f"created {num} threads, starting them and waiting for results")
for thread in threads:
thread.start()
for _ in dbs:
res = results.get()
if res is not None:
pytest.fail(f"concurrent lookup failed\n{res}")

View File

@@ -9,10 +9,9 @@ import itertools
from email.parser import BytesParser from email.parser import BytesParser
from email import policy from email import policy
from pathlib import Path from pathlib import Path
from math import ceil
import pytest import pytest
from chatmaild.database import Database
conftestdir = Path(__file__).parent conftestdir = Path(__file__).parent
@@ -72,7 +71,7 @@ def pytest_report_header():
@pytest.fixture @pytest.fixture
def benchmark(request): def benchmark(request):
def bench(func, num, name=None, reportfunc=None): def bench(func, num, name=None):
if name is None: if name is None:
name = func.__name__ name = func.__name__
durations = [] durations = []
@@ -81,7 +80,7 @@ def benchmark(request):
func() func()
durations.append(time.time() - now) durations.append(time.time() - now)
durations.sort() durations.sort()
request.config._benchresults[name] = (reportfunc, durations) request.config._benchresults[name] = durations
return bench return bench
@@ -102,9 +101,7 @@ def pytest_terminal_summary(terminalreporter):
headers = f"{'benchmark name': <30} " + fcol(float_names) headers = f"{'benchmark name': <30} " + fcol(float_names)
tr.write_line(headers) tr.write_line(headers)
tr.write_line("-" * len(headers)) tr.write_line("-" * len(headers))
summary_lines = [] for name, durations in results.items():
for name, (reportfunc, durations) in results.items():
measures = [ measures = [
sorted(durations)[len(durations) // 2], sorted(durations)[len(durations) // 2],
min(durations), min(durations),
@@ -113,16 +110,6 @@ def pytest_terminal_summary(terminalreporter):
line = f"{name: <30} " line = f"{name: <30} "
line += fcol(f"{float: 2.2f}" for float in measures) line += fcol(f"{float: 2.2f}" for float in measures)
tr.write_line(line) tr.write_line(line)
vmedian, vmin, vmax = measures
if reportfunc:
for line in reportfunc(vmin=vmin, vmedian=vmedian, vmax=vmax):
summary_lines.append(line)
if summary_lines:
tr.write_line("")
tr.section("benchmark summary measures")
for line in summary_lines:
tr.write_line(line)
@pytest.fixture @pytest.fixture
@@ -130,16 +117,6 @@ def imap(maildomain):
return ImapConn(maildomain) return ImapConn(maildomain)
@pytest.fixture
def make_imap_connection(maildomain):
def make_imap_connection():
conn = ImapConn(maildomain)
conn.connect()
return conn
return make_imap_connection
class ImapConn: class ImapConn:
AuthError = imaplib.IMAP4.error AuthError = imaplib.IMAP4.error
logcmd = "journalctl -f -u dovecot" logcmd = "journalctl -f -u dovecot"
@@ -180,16 +157,6 @@ def smtp(maildomain):
return SmtpConn(maildomain) return SmtpConn(maildomain)
@pytest.fixture
def make_smtp_connection(maildomain):
def make_smtp_connection():
conn = SmtpConn(maildomain)
conn.connect()
return conn
return make_smtp_connection
class SmtpConn: class SmtpConn:
AuthError = smtplib.SMTPAuthenticationError AuthError = smtplib.SMTPAuthenticationError
logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp" logcmd = "journalctl -f -t postfix/smtpd -t postfix/smtp -t postfix/lmtp"
@@ -235,13 +202,6 @@ def gencreds(maildomain):
return lambda domain=None: next(gen(domain)) return lambda domain=None: next(gen(domain))
@pytest.fixture()
def db(tmpdir):
db_path = tmpdir / "passdb.sqlite"
print("database path:", db_path)
return Database(db_path)
# #
# Delta Chat testplugin re-use # Delta Chat testplugin re-use
# use the cmfactory fixture to get chatmail instance accounts # use the cmfactory fixture to get chatmail instance accounts
@@ -312,7 +272,7 @@ class Remote:
self.sshdomain = sshdomain self.sshdomain = sshdomain
def iter_output(self, logcmd=""): def iter_output(self, logcmd=""):
getjournal = "journalctl -f" if not logcmd else logcmd getjournal = f"journalctl -f" if not logcmd else logcmd
self.popen = subprocess.Popen( self.popen = subprocess.Popen(
["ssh", f"root@{self.sshdomain}", getjournal], ["ssh", f"root@{self.sshdomain}", getjournal],
stdout=subprocess.PIPE, stdout=subprocess.PIPE,

View File

@@ -1,6 +1,5 @@
import pytest import pytest
import threading import smtplib
import queue
def test_login_basic_functioning(imap_or_smtp, gencreds, lp): def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
@@ -24,7 +23,7 @@ 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("creating users with a short password is not allowed") lp.sec(f"creating users with a short password is not allowed")
user, _password = gencreds() user, _password = gencreds()
with pytest.raises(imap_or_smtp.AuthError): with pytest.raises(imap_or_smtp.AuthError):
imap_or_smtp.login(user, "admin") imap_or_smtp.login(user, "admin")
@@ -41,30 +40,3 @@ def test_login_same_password(imap_or_smtp, gencreds):
imap_or_smtp.login(user1, password1) imap_or_smtp.login(user1, password1)
imap_or_smtp.connect() imap_or_smtp.connect()
imap_or_smtp.login(user2, password1) imap_or_smtp.login(user2, password1)
def test_concurrent_logins_same_account(
make_imap_connection, make_smtp_connection, gencreds
):
"""Test concurrent smtp and imap logins
and check remote server succeeds on each connection.
"""
user1, password1 = gencreds()
login_results = queue.Queue()
def login_smtp_imap(smtp, imap):
try:
imap.login(user1, password1)
except Exception:
login_results.put(False)
else:
login_results.put(True)
conns = [(make_smtp_connection(), make_imap_connection()) for i in range(10)]
for args in conns:
thread = threading.Thread(target=login_smtp_imap, args=args, daemon=True)
thread.start()
for _ in conns:
assert login_results.get()

View File

@@ -91,7 +91,7 @@ class TestEndToEndDeltaChat:
lp.sec("setup encrypted comms between ac1 and ac2 on different instances") lp.sec("setup encrypted comms between ac1 and ac2 on different instances")
qr = ac1.get_setup_contact_qr() qr = ac1.get_setup_contact_qr()
ac2.qr_setup_contact(qr) ch = ac2.qr_setup_contact(qr)
msg = ac2.wait_next_incoming_message() msg = ac2.wait_next_incoming_message()
assert "verified" in msg.text assert "verified" in msg.text

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

View File

@@ -1,61 +0,0 @@
<!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>