Compare commits

...

19 Commits

Author SHA1 Message Date
holger krekel
cbbcf3cbca add marker dynamically to allow "pytest" to execute nicely at repo root without warnings 2023-10-20 22:45:11 +02:00
holger krekel
d2af9df8f9 rename test files to be unambigously numbered 2023-10-20 22:39:23 +02:00
holger krekel
83e6a42252 slight refinement for benchmark formatting, not worth a PR 2023-10-20 18:43:06 +02:00
link2xt
eb69dd58f7 Setup CI 2023-10-20 15:18:17 +02:00
link2xt
31c45f951d dictproxy: use crypt instead of doveadm pw 2023-10-20 14:05:25 +02:00
holger krekel
3012bfb79d some reformatting and striking overall 2023-10-20 11:05:58 +02:00
holger krekel
03442bc115 some improvements, adding a bnech 2023-10-20 11:05:58 +02:00
holger krekel
1ae6291d06 add ping-pong bench and formatting 2023-10-20 11:05:58 +02:00
holger krekel
1b347f97a0 better benchmarking and reporting 2023-10-20 11:05:58 +02:00
link2xt
902f98c9ba Set syslog name for reinject proxy 2023-10-19 03:22:27 +00:00
link2xt
89311063f8 Turn filtermail into a beforequeue handler and implement rate limit 2023-10-19 03:04:00 +00:00
link2xt
1cdc5d1351 Revert "open a persistent client between the BeforeQueueHandler and postfix smtpd without content filter"
This reverts commit fb2ea27477.
2023-10-19 02:22:38 +00:00
link2xt
30680cb170 filtermail: port is args[0], not args[1] 2023-10-19 02:22:30 +00:00
link2xt
c514fb00a3 Import SMTP from aiosmtpd.lmtp, not aiosmtpd.smtp 2023-10-19 02:22:15 +00:00
holger krekel
c7995356b9 shift for simpler diff 2023-10-19 01:16:19 +02:00
holger krekel
fb2ea27477 open a persistent client between the BeforeQueueHandler and postfix smtpd without content filter 2023-10-19 01:10:06 +02:00
holger krekel
7cf6cc2c91 remove filtermail split and LMTP backend 2023-10-19 01:05:49 +02:00
holger krekel
4358d5fe61 only do a smtp beforequeue-handler, also simplifies the send-rate-limiting test and improves DC behaviour 2023-10-19 00:54:45 +02:00
link2xt
329b845c79 Configure journald to retain logs for 3 days 2023-10-18 22:54:50 +02:00
17 changed files with 234 additions and 219 deletions

18
.github/workflows/ci.yaml vendored Normal file
View File

@@ -0,0 +1,18 @@
name: CI
on:
pull_request:
push:
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Lint chatmaild
working-directory: chatmaild
run: pipx run tox
- name: Lint deploy-chatmail
working-directory: deploy-chatmail
run: pipx run tox

View File

@@ -2,13 +2,13 @@ import logging
import os
import sys
import json
import crypt
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
ThreadingMixIn,
)
import pwd
import subprocess
from .database import Database
@@ -16,17 +16,9 @@ NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str):
password = password.encode("ascii")
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
process = subprocess.Popen(
["doveadm", "pw", "-s", "SHA512-CRYPT"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
)
stdout_data, _stderr_data = process.communicate(
input=password + b"\n" + password + b"\n"
)
return stdout_data.decode("ascii").strip()
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash
def create_user(db, user, password):

View File

@@ -1,10 +0,0 @@
[Unit]
Description=Chatmail Postfix AfterQueue filter
[Service]
ExecStart=/usr/local/bin/filtermail afterqueue /var/spool/postfix/private/filtermail-afterqueue
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -7,9 +7,8 @@ from email.parser import BytesParser
from email import policy
from email.utils import parseaddr
from aiosmtpd.lmtp import LMTP
from aiosmtpd.smtp import SMTP
from aiosmtpd.controller import UnixSocketController, Controller
from aiosmtpd.controller import Controller
from smtplib import SMTP as SMTPClient
@@ -35,24 +34,71 @@ def check_encrypted(message):
return True
class SMTPController(Controller):
def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs)
class BeforeQueueHandler:
def __init__(self):
self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options):
logging.info(f"handle_MAIL from {address}")
if self.send_rate_limiter.is_sending_allowed(address):
envelope.mail_from = address
return "250 OK"
return f"450 4.7.1: Too much mail from {address}"
envelope.mail_from = address
if not self.send_rate_limiter.is_sending_allowed(address):
return f"450 4.7.1: Too much mail from {address}"
parts = envelope.mail_from.split("@")
if len(parts) != 2:
return f"500 Invalid from address <{envelope.mail_from!r}>"
return "250 OK"
async def handle_DATA(self, server, session, envelope):
logging.info("handle_DATA before-queue: re-injecting the mail")
client = SMTPClient("localhost", "10026")
logging.info("handle_DATA before-queue")
error = check_DATA(envelope)
if error:
return error
logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", "10025")
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
return "250 OK"
async def asyncmain_beforequeue(port):
Controller(BeforeQueueHandler(), hostname="127.0.0.1", port=port).start()
def check_DATA(envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {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}")
if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
envelope_from_domain = from_addr.split("@").pop()
for recipient in envelope.rcpt_tos:
if envelope.mail_from == recipient:
# Always allow sending emails to self.
continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
is_outgoing = recipient_domain != envelope_from_domain
if is_outgoing and not mail_encrypted:
is_securejoin = message.get("secure-join") in ["vc-request", "vg-request"]
if not is_securejoin:
return f"500 Invalid unencrypted mail to <{recipient}>"
class SendRateLimiter:
MAX_USER_SEND_PER_MINUTE = 80
@@ -69,104 +115,12 @@ class SendRateLimiter:
return False
class AfterQueueHandler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
envelope.rcpt_tos.append(address)
return "250 OK"
async def handle_DATA(self, server, session, envelope):
valid_recipients, res = lmtp_handle_DATA(envelope)
# Reinject the mail back into Postfix.
if valid_recipients:
logging.info("afterqueue: re-injecting the mail")
client = SMTPClient("localhost", "10027")
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
else:
logging.info("no valid recipients, ignoring mail")
return "\r\n".join(res)
def lmtp_handle_DATA(envelope):
"""the central filtering function for e-mails."""
logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message)
valid_recipients = []
res = []
for recipient in envelope.rcpt_tos:
my_local_domain = envelope.mail_from.split("@")
if len(my_local_domain) != 2:
res += [f"500 Invalid from address <{envelope.mail_from}>"]
continue
_, from_addr = parseaddr(message.get("from").strip())
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from}")
if envelope.mail_from.lower() != from_addr.lower():
res += [f"500 Invalid FROM <{from_addr}> for <{envelope.mail_from}>"]
continue
if envelope.mail_from == recipient:
# Always allow sending emails to self.
valid_recipients += [recipient]
res += ["250 OK"]
continue
recipient_local_domain = recipient.split("@")
if len(recipient_local_domain) != 2:
res += [f"500 Invalid address <{recipient}>"]
continue
is_outgoing = recipient_local_domain[1] != my_local_domain[1]
if (
is_outgoing
and not mail_encrypted
and message.get("secure-join") != "vc-request"
and message.get("secure-join") != "vg-request"
):
res += ["500 Outgoing mail must be encrypted"]
continue
valid_recipients += [recipient]
res += ["250 OK"]
assert len(envelope.rcpt_tos) == len(res)
assert len(valid_recipients) <= len(res)
return valid_recipients, res
class UnixController(UnixSocketController):
def factory(self):
return LMTP(self.handler, **self.SMTP_kwargs)
class SMTPController(Controller):
def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs)
async def asyncmain_afterqueue(loop, unix_socket_fn):
UnixController(AfterQueueHandler(), unix_socket=unix_socket_fn).start()
async def asyncmain_beforequeue(loop, port):
Controller(BeforeQueueHandler(), hostname="127.0.0.1", port=port).start()
def main():
args = sys.argv[1:]
assert len(args) == 2
assert len(args) == 1
logging.basicConfig(level=logging.INFO)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
if args[0] == "afterqueue":
task = asyncmain_afterqueue(loop, args[1])
elif args[0] == "beforequeue":
task = asyncmain_beforequeue(loop, port=int(args[1]))
else:
raise SystemExit(1)
task = asyncmain_beforequeue(port=int(args[0]))
loop.create_task(task)
loop.run_forever()

View File

@@ -2,7 +2,7 @@
Description=Chatmail Postfix BeforeQeue filter
[Service]
ExecStart=/usr/local/bin/filtermail beforequeue 10080
ExecStart=/usr/local/bin/filtermail 10080
Restart=always
RestartSec=30

View File

@@ -1,4 +1,4 @@
from .filtermail import check_encrypted, lmtp_handle_DATA, SendRateLimiter
from .filtermail import check_encrypted, check_DATA, SendRateLimiter
from email.parser import BytesParser
from email import policy
import pytest
@@ -31,15 +31,12 @@ def test_reject_forged_from():
# test that the filter lets good mail through
envelope.content = makemail(envelope.mail_from).as_bytes()
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
assert valid_recipients == envelope.rcpt_tos
assert len(res) == 1 and "250" in res[0]
assert not check_DATA(envelope=envelope)
# test that the filter rejects forged mail
envelope.content = makemail("forged@c3.testrun.org").as_bytes()
valid_recipients, res = lmtp_handle_DATA(envelope=envelope)
assert not valid_recipients
assert len(res) == 1 and "500" in res[0]
error = check_DATA(envelope=envelope)
assert "500" in error
def test_filtermail():

View File

@@ -34,28 +34,12 @@ def _install_chatmaild() -> None:
commands=[f"pip install --break-system-packages {remote_path}"],
)
files.put(
name="upload doveauth-dictproxy.service",
src=importlib.resources.files("chatmaild")
.joinpath("doveauth-dictproxy.service")
.open("rb"),
dest="/etc/systemd/system/doveauth-dictproxy.service",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Setup doveauth-dictproxy service",
service="doveauth-dictproxy.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
for fn in ("filtermail-after", "filtermail-before"):
for fn in (
"doveauth-dictproxy",
"filtermail",
):
files.put(
name=f"upload {fn}.service",
name=f"Upload {fn}.service",
src=importlib.resources.files("chatmaild")
.joinpath(f"{fn}.service")
.open("rb"),
@@ -293,6 +277,22 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
journald_conf = files.put(
name="Configure journald",
src=importlib.resources.files(__package__).joinpath("journald.conf"),
dest="/etc/systemd/journald.conf",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Start and enable journald",
service="systemd-journald.service",
running=True,
enabled=True,
restarted=journald_conf,
)
def callback():
result = server.shell(
commands=[

View File

@@ -1,6 +1,6 @@
import importlib.resources
from pyinfra.operations import apt, files, systemd, server
from pyinfra.operations import apt, files, server
def deploy_acmetool(nginx_hook=False, email="", domains=[]):

View File

@@ -0,0 +1,2 @@
[Journal]
MaxRetentionSec=3d

View File

@@ -33,7 +33,6 @@ submission inet n - y - - smtpd
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080
-o content_filter=filter:unix:private/filtermail-afterqueue
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
@@ -77,7 +76,5 @@ scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail.
localhost:10026 inet n - n - 10 smtpd
-o content_filter=filter:unix:private/filtermail-afterqueue
localhost:10027 inet n - n - 10 smtpd
-o content_filter=
localhost:10025 inet n - n - 10 smtpd
-o syslog_name=postfix/reinject

View File

@@ -1,34 +1,60 @@
def test_tls_serialized_connect(benchmark, imap_or_smtp):
def connect():
imap_or_smtp.connect()
def test_tls_imap(benchmark, imap):
def imap_connect():
imap.connect()
benchmark(connect)
benchmark(imap_connect, 10)
def test_login(benchmark, imap_or_smtp, gencreds):
cls = imap_or_smtp.__class__
conns = []
for i in range(20):
conn = cls(imap_or_smtp.host)
conn.connect()
conns.append(conn)
def test_login_imap(benchmark, imap, gencreds):
def imap_connect_and_login():
imap.connect()
imap.login(*gencreds())
def login():
conn = conns.pop()
conn.login(*gencreds())
benchmark(login)
benchmark(imap_connect_and_login, 10)
def test_send_and_receive_10(benchmark, cmfactory, lp):
"""send many messages between two accounts"""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def test_tls_smtp(benchmark, smtp):
def smtp_connect():
smtp.connect()
def send_10_receive_all():
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2.wait_next_incoming_message()
benchmark(smtp_connect, 10)
benchmark(send_10_receive_all)
def test_login_smtp(benchmark, smtp, gencreds):
def smtp_connect_and_login():
smtp.connect()
smtp.login(*gencreds())
benchmark(smtp_connect_and_login, 10)
class TestDC:
def test_autoconfigure(self, benchmark, cmfactory):
def autoconfig_and_idle_ready():
cmfactory.get_online_accounts(1)
benchmark(autoconfig_and_idle_ready, 5)
def test_ping_pong(self, benchmark, cmfactory):
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def ping_pong():
chat.send_text("ping")
msg = ac2.wait_next_incoming_message()
msg.chat.send_text("pong")
ac1.wait_next_incoming_message()
benchmark(ping_pong, 5)
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
def send_10_receive_10():
for i in range(10):
chat.send_text(f"hello {i}")
for i in range(10):
ac2.wait_next_incoming_message()
benchmark(send_10_receive_10, 5)

View File

@@ -1,10 +1,12 @@
import os
import io
import time
import random
import subprocess
import imaplib
import smtplib
import itertools
from math import ceil
import pytest
@@ -14,6 +16,13 @@ def pytest_addoption(parser):
)
def pytest_configure(config):
config._benchresults = {}
config.addinivalue_line(
"markers", "slow: mark test to require --slow option to run"
)
def pytest_runtest_setup(item):
markers = list(item.iter_markers(name="slow"))
if markers:
@@ -54,6 +63,49 @@ def pytest_report_header():
return ["-" * len(text), text, "-" * len(text)]
@pytest.fixture
def benchmark(request):
def bench(func, num, name=None):
if name is None:
name = func.__name__
durations = []
for i in range(num):
now = time.time()
func()
durations.append(time.time() - now)
durations.sort()
request.config._benchresults[name] = durations
return bench
def pytest_terminal_summary(terminalreporter):
tr = terminalreporter
results = tr.config._benchresults
if not results:
return
tr.section("benchmark results")
float_names = 'median min max'.split()
width = max(map(len, float_names))
def fcol(parts):
return " ".join(part.rjust(width) for part in parts)
headers = f"{'benchmark name': <30} " + fcol(float_names)
tr.write_line(headers)
tr.write_line("-" * len(headers))
for name, durations in results.items():
measures = [
sorted(durations)[len(durations) // 2],
min(durations),
max(durations),
]
line = f"{name: <30} "
line += fcol(f"{float: 2.2f}" for float in measures)
tr.write_line(line)
@pytest.fixture
def imap(maildomain):
return ImapConn(maildomain)

View File

@@ -1,3 +1,2 @@
[pytest]
addopts = -vrsx --strict-markers
markers = slow: mark test as slow (requires --slow option to run)

View File

@@ -35,22 +35,3 @@ def test_login_same_password(imap_or_smtp, gencreds):
imap_or_smtp.login(user1, password1)
imap_or_smtp.connect()
imap_or_smtp.login(user2, password1)
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, mailgen):
"""Test that the per-account send-mail limit is exceeded."""
user1, user2 = cmsetup.gen_users(2)
mail = mailgen.get_encrypted(user1.addr, user2.addr)
for i in range(100):
print("Sending mail", str(i))
try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e:
if i < 80:
pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr]
assert outcome[0] == 450
assert b'4.7.1: Too much mail from' in outcome[1]
return
pytest.fail("Rate limit was not exceeded")

View File

@@ -20,7 +20,7 @@ def test_use_two_chatmailservers(cmfactory, maildomain2):
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
def test_reject_forged_from(cmsetup, mailgen, lp, remote, forgeaddr):
def test_reject_forged_from(cmsetup, mailgen, lp, forgeaddr):
user1, user3 = cmsetup.gen_users(2)
lp.sec("send encrypted message with forged from")
@@ -36,17 +36,25 @@ def test_reject_forged_from(cmsetup, mailgen, lp, remote, forgeaddr):
print(f" {line}")
lp.sec("Send forged mail and check remote postfix lmtp processing result")
remote_log = remote.iter_output("journalctl -t postfix/lmtp")
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user3.addr], msg=msg)
for line in remote_log:
# print(line)
if "500 invalid from" in line and user3.addr in line:
break
else:
pytest.fail("remote postfix/filtermail failed to reject message")
with pytest.raises(smtplib.SMTPException) as e:
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user3.addr], msg=msg)
assert "500" in str(e.value)
# check that the logged in user (who sent the forged msg) got a non-delivery notice
for message in user1.imap.fetch_all_messages():
if "Invalid FROM" in message and addr_to_forge in message:
@pytest.mark.slow
def test_exceed_rate_limit(cmsetup, gencreds, mailgen):
"""Test that the per-account send-mail limit is exceeded."""
user1, user2 = cmsetup.gen_users(2)
mail = mailgen.get_encrypted(user1.addr, user2.addr)
for i in range(100):
print("Sending mail", str(i))
try:
user1.smtp.sendmail(user1.addr, [user2.addr], mail)
except smtplib.SMTPException as e:
if i < 80:
pytest.fail(f"rate limit was exceeded too early with msg {i}")
outcome = e.recipients[user2.addr]
assert outcome[0] == 450
assert b'4.7.1: Too much mail from' in outcome[1]
return
pytest.fail(f"forged From={addr_to_forge} did not cause non-delivery notice")
pytest.fail("Rate limit was not exceeded")

View File

@@ -6,9 +6,8 @@ deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv
sudo apt install -y dovecot-core && sudo systemctl disable --now dovecot
chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68'
chatmaild/venv/bin/pip install -e chatmaild
python3 -m venv online-tests/venv
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat pytest-benchmark
online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat