Compare commits

..

2 Commits

Author SHA1 Message Date
holger krekel
b441891d1f add postfix instrumented debugging 2023-10-16 22:38:36 +02:00
holger krekel
1c37d1f59b add global debug flag and instrument dovecot with it 2023-10-16 22:29:40 +02:00
27 changed files with 278 additions and 614 deletions

View File

@@ -1,40 +1,61 @@
# Chat Mail server configuration # Chat Mail server configuration
This repository setups a ready-to-go chatmail instance This package deploys Postfix and Dovecot servers, including OpenDKIM for DKIM signing.
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 uses Dovecot for authentication as described in <https://www.postfix.org/SASL_README.html#server_dovecot>
## Getting started ## Getting started
1. prepare your local system: prepare:
scripts/init.sh pip install -e chatmail-infra
2. set environment variable to the chatmail domain you want to setup:
export CHATMAIL_DOMAIN=c1.testrun.org # replace with your host
3. run the deploy of the chat mail instance:
scripts/deploy.sh
## Running tests and benchmarks (offline and online) then run with pyinfra command line tool:
1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows CHATMAIL_DOMAIN=c1.testrun.org pyinfra --ssh-user root c1.testrun.org deploy.py
to login to the chatmail instance server.
2. To run local and online tests:
scripts/test.sh ## Structure (wip)
```
3. To run benchmarks against your chatmail instance: # package doveauth tool and deploy chatmail server to a envvar-specified ssh-reachable host
deploy.py
scripts/bench.sh # chatmail pyinfra deploy package
chatmail-pyinfra
pyproject.toml
chatmail/__init__ ...
## Running tests (offline and online) # doveauth tool used by dovecot's auth mechanism on the host system
doveauth
README.md
pyproject.toml
doveauth.py
test_doveauth.py
# lmtp server to block (outgoing) unencrypted messages
filtermail
README.md
pyproject.toml
....
# online tests (after deploy)
online-tests # runnable via pytest
# scripts for setup/development/deployment
scripts/
init.sh # create venv/other perequires
deploy.sh # run pyinfra based deploy of everything
test.sh # run all local and online tests
bench.sh # run performance benchmark tests
``` ```
## Dovecot/Postfix configuration ## Dovecot/Postfix configuration
### Ports ### Ports
@@ -44,16 +65,4 @@ Dovecot listens on ports 143(imap) and 993 (imaps).
## DNS ## DNS
For DKIM you must add a DNS entry as found in /etc/opendkim/selector.txt on your chatmail instance. For DKIM you must add a DNS entry as in /etc/opendkim/selector.txt (where selector is the opendkim_selector configured in the chatmail inventory).
The above `scripts/deploy.sh` prints out the DKIM selector and DNS entry you
need to setup with your DNS provider.
## 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

@@ -10,6 +10,7 @@ dependencies = [
] ]
[project.scripts] [project.scripts]
doveauth = "chatmaild.doveauth:main"
doveauth-dictproxy = "chatmaild.dictproxy:main" doveauth-dictproxy = "chatmaild.dictproxy:main"
filtermail = "chatmaild.filtermail:main" filtermail = "chatmaild.filtermail:main"

View File

@@ -1,4 +1,3 @@
import logging
import os import os
import sys import sys
import json import json
@@ -12,8 +11,6 @@ import subprocess
from .database import Database from .database import Database
NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str): def encrypt_password(password: str):
password = password.encode("ascii") password = password.encode("ascii")
@@ -30,11 +27,6 @@ def encrypt_password(password: str):
def create_user(db, user, password): def create_user(db, user, password):
if os.path.exists(NOCREATE_FILE):
logging.warning(
f"Didn't create account: {NOCREATE_FILE} exists. Delete the file to enable account creation."
)
return
with db.write_transaction() as conn: with db.write_transaction() as conn:
conn.create_user(user, password) conn.create_user(user, password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password) return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
@@ -61,7 +53,7 @@ def lookup_passdb(db, user, password):
return userdata return userdata
def handle_dovecot_request(msg, db, mail_domain): def handle_dovecot_request(msg, db):
print(f"received msg: {msg!r}", file=sys.stderr) 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
@@ -72,15 +64,13 @@ def handle_dovecot_request(msg, db, mail_domain):
res = "" res = ""
if namespace == "shared": if namespace == "shared":
if type == "userdb": if type == "userdb":
if user.endswith(f"@{mail_domain}"): res = lookup_userdb(db, user)
res = lookup_userdb(db, user)
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
reply_command = "N" reply_command = "N"
elif type == "passdb": elif type == "passdb":
if user.endswith(f"@{mail_domain}"): res = lookup_passdb(db, user, password=args[0])
res = lookup_passdb(db, user, password=args[0])
if res: if res:
reply_command = "O" reply_command = "O"
else: else:
@@ -99,8 +89,6 @@ 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]) db = Database(sys.argv[3])
with open("/etc/mailname", "r") as fp:
mail_domain = fp.read().strip()
class Handler(StreamRequestHandler): class Handler(StreamRequestHandler):
def handle(self): def handle(self):
@@ -108,7 +96,7 @@ def main():
msg = self.rfile.readline().strip().decode() msg = self.rfile.readline().strip().decode()
if not msg: if not msg:
break break
res = handle_dovecot_request(msg, db, mail_domain) res = handle_dovecot_request(msg, db)
if res: if res:
print(f"sending result: {res!r}", file=sys.stderr) print(f"sending result: {res!r}", file=sys.stderr)
self.wfile.write(res.encode("ascii")) self.wfile.write(res.encode("ascii"))

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
import base64
import sys
from .database import Database
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 create_user(db, user, password):
with db.write_transaction() as conn:
conn.create_user(user, password)
return dict(home=f"/home/vmail/{user}", uid="vmail", gid="vmail", password=password)
def verify_user(db, user, password):
userdata = get_user_data(db, user)
if userdata:
if userdata.get("password") == password:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
else:
userdata = create_user(db, user, password)
userdata["status"] = "ok"
return userdata
def lookup_user(db, user):
userdata = get_user_data(db, user)
if userdata:
userdata["status"] = "ok"
else:
userdata["status"] = "fail"
return userdata
def dump_result(res):
for key, value in res.items():
print(f"{key}={value}")
def main():
db = Database("/home/vmail/passdb.sqlite")
if sys.argv[1] == "hexauth":
login = base64.b16decode(sys.argv[2]).decode()
password = base64.b16decode(sys.argv[3]).decode()
res = verify_user(db, login, password)
dump_result(res)
elif sys.argv[1] == "hexlookup":
login = base64.b16decode(sys.argv[2]).decode()
res = lookup_user(db, login)
dump_result(res)
if __name__ == "__main__":
main()

View File

@@ -1,19 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import logging import logging
import time
import sys
from email.parser import BytesParser from email.parser import BytesParser
from email import policy from email import policy
from email.utils import parseaddr
from aiosmtpd.smtp import SMTP from aiosmtpd.lmtp import LMTP
from aiosmtpd.controller import Controller from aiosmtpd.controller import UnixSocketController
from smtplib import SMTP as SMTPClient from smtplib import SMTP as SMTPClient
def check_encrypted(message): def check_encrypted(content):
"""Check that the message is an OpenPGP-encrypted message.""" """Check that the message is an OpenPGP-encrypted message."""
message = BytesParser(policy=policy.default).parsebytes(content)
if not message.is_multipart(): if not message.is_multipart():
return False return False
if message.get("subject") != "...": if message.get("subject") != "...":
@@ -34,93 +32,72 @@ def check_encrypted(message):
return True return True
class SMTPController(Controller): class ExampleController(UnixSocketController):
def factory(self): def factory(self):
return SMTP(self.handler, **self.SMTP_kwargs) return LMTP(self.handler, **self.SMTP_kwargs)
class BeforeQueueHandler: class ExampleHandler:
def __init__(self): async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
self.send_rate_limiter = SendRateLimiter() envelope.rcpt_tos.append(address)
async def handle_MAIL(self, server, session, envelope, address, mail_options):
logging.info(f"handle_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" return "250 OK"
async def handle_DATA(self, server, session, envelope): async def handle_DATA(self, server, session, envelope):
logging.info("handle_DATA before-queue") logging.info("Processing DATA message from %s", envelope.mail_from)
error = check_DATA(envelope)
if error: valid_recipients = []
return error
logging.info("re-injecting the mail that passed checks") mail_encrypted = check_encrypted(envelope.content)
client = SMTPClient("localhost", "10025")
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content) res = []
return "250 OK" 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
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:
res += ["500 Outgoing mail must be encrypted"]
continue
valid_recipients += [recipient]
res += ["250 OK"]
# Reinject the mail back into Postfix.
if valid_recipients:
logging.info("Reinjecting the mail")
client = SMTPClient("localhost", "10026")
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
return "\r\n".join(res)
async def asyncmain_beforequeue(port): async def asyncmain(loop):
Controller(BeforeQueueHandler(), hostname="127.0.0.1", port=port).start() controller = ExampleController(
ExampleHandler(), unix_socket="/var/spool/postfix/private/filtermail"
)
def check_DATA(envelope): controller.start()
"""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
def __init__(self):
self.addr2timestamps = {}
def is_sending_allowed(self, mail_from):
last = self.addr2timestamps.setdefault(mail_from, [])
now = time.time()
last[:] = [ts for ts in last if ts >= (now - 60)]
if len(last) <= self.MAX_USER_SEND_PER_MINUTE:
last.append(now)
return True
return False
def main(): def main():
args = sys.argv[1:]
assert len(args) == 1
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
task = asyncmain_beforequeue(port=int(args[0])) loop.create_task(asyncmain(loop=loop))
loop.create_task(task)
loop.run_forever() loop.run_forever()
if __name__ == "__main__":
main()

View File

@@ -1,8 +1,8 @@
[Unit] [Unit]
Description=Chatmail Postfix BeforeQeue filter Description=Email filter for chatmail servers
[Service] [Service]
ExecStart=/usr/local/bin/filtermail 10080 ExecStart=/usr/local/bin/filtermail
Restart=always Restart=always
RestartSec=30 RestartSec=30

View File

@@ -1,9 +1,7 @@
import os
import pytest import pytest
import chatmaild.dictproxy from .dictproxy import get_user_data
from .dictproxy import get_user_data, lookup_passdb from .doveauth import verify_user
from .database import Database, DBError from .database import Database, DBError
@@ -15,31 +13,16 @@ def db(tmpdir):
def test_basic(db): def test_basic(db):
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate" verify_user(db, "link2xt@c1.testrun.org", "asdf")
if os.path.exists(chatmaild.dictproxy.NOCREATE_FILE):
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
lookup_passdb(db, "link2xt@c1.testrun.org", "asdf")
data = get_user_data(db, "link2xt@c1.testrun.org") data = get_user_data(db, "link2xt@c1.testrun.org")
assert data assert data
def test_dont_overwrite_password_on_wrong_login(db): def test_verify_or_create(db):
"""Test that logging in with a different password doesn't create a new user""" res = verify_user(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983")
res = lookup_passdb(db, "newuser1@something.org", "kajdlkajsldk12l3kj1983") assert res["status"] == "ok"
assert res["password"] res = verify_user(db, "newuser1@something.org", "kajdlqweqwe")
res2 = lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe") assert res["status"] == "fail"
# this function always returns a password hash, which is actually compared by dovecot.
assert res["password"] == res2["password"]
def test_nocreate_file(db):
chatmaild.dictproxy.NOCREATE_FILE = "/tmp/nocreate"
with open(chatmaild.dictproxy.NOCREATE_FILE, "w+") as f:
f.write("")
assert os.path.exists(chatmaild.dictproxy.NOCREATE_FILE)
lookup_passdb(db, "newuser1@something.org", "kajdlqweqwe")
assert not get_user_data(db, "newuser1@something.org")
os.remove(chatmaild.dictproxy.NOCREATE_FILE)
def test_db_version(db): def test_db_version(db):

View File

@@ -1,52 +1,12 @@
from .filtermail import check_encrypted, check_DATA, SendRateLimiter
from email.parser import BytesParser
from email import policy
import pytest import pytest
from .filtermail import check_encrypted
def test_reject_forged_from():
def makemail(from_addr):
return BytesParser(policy=policy.default).parsebytes(
"\r\n".join(
[
f"From: <{from_addr}",
"To: <barbaz@c3.testrun.org>",
"Date: Sun, 15 Oct 2023 16:41:44 +0000",
"Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>",
"Chat-Version: 1.0",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no",
"",
"Hi!",
"",
"",
]
).encode()
)
class envelope:
mail_from = "bob@c3.testrun.org"
rcpt_tos = ["somebody@c3.testrun.org"]
# test that the filter lets good mail through
envelope.content = makemail(envelope.mail_from).as_bytes()
assert not check_DATA(envelope=envelope)
# test that the filter rejects forged mail
envelope.content = makemail("forged@c3.testrun.org").as_bytes()
error = check_DATA(envelope=envelope)
assert "500" in error
def test_filtermail(): def test_filtermail():
def check_encrypted_bstr(content): assert not check_encrypted(b"foo")
message = BytesParser(policy=policy.default).parsebytes(content)
return check_encrypted(message)
assert not check_encrypted_bstr(b"foo") assert not check_encrypted(
assert not check_encrypted_bstr(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -76,7 +36,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted_bstr( assert not check_encrypted(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -107,7 +67,7 @@ def test_filtermail():
) )
# https://xkcd.com/1181/ # https://xkcd.com/1181/
assert not check_encrypted_bstr( assert not check_encrypted(
"\r\n".join( "\r\n".join(
[ [
"Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=", "Subject: =?utf-8?q?Message_from_foobar=40c2=2Etestrun=2Eorg?=",
@@ -139,7 +99,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert check_encrypted_bstr( assert check_encrypted(
"\r\n".join( "\r\n".join(
[ [
"Subject: ...", "Subject: ...",
@@ -212,7 +172,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted_bstr( assert not check_encrypted(
"\r\n".join( "\r\n".join(
[ [
"Subject: Buy Penis Enlargement at www.malicious-domain.com", "Subject: Buy Penis Enlargement at www.malicious-domain.com",
@@ -285,7 +245,7 @@ def test_filtermail():
).encode() ).encode()
) )
assert not check_encrypted_bstr( assert not check_encrypted(
"\r\n".join( "\r\n".join(
[ [
"Subject: Message opened", "Subject: Message opened",
@@ -324,15 +284,3 @@ def test_filtermail():
] ]
).encode() ).encode()
) )
def test_send_rate_limiter():
limiter = SendRateLimiter()
for i in range(100):
if limiter.is_sending_allowed("some@example.org"):
if i <= SendRateLimiter.MAX_USER_SEND_PER_MINUTE:
continue
pytest.fail("limiter didn't work")
else:
assert i == SendRateLimiter.MAX_USER_SEND_PER_MINUTE + 1
break

View File

@@ -34,28 +34,43 @@ def _install_chatmaild() -> None:
commands=[f"pip install --break-system-packages {remote_path}"], commands=[f"pip install --break-system-packages {remote_path}"],
) )
for fn in ( files.put(
"doveauth-dictproxy", name="upload doveauth-dictproxy.service",
"filtermail", src=importlib.resources.files("chatmaild")
): .joinpath("doveauth-dictproxy.service")
files.put( .open("rb"),
name=f"Upload {fn}.service", dest="/etc/systemd/system/doveauth-dictproxy.service",
src=importlib.resources.files("chatmaild") user="root",
.joinpath(f"{fn}.service") group="root",
.open("rb"), mode="644",
dest=f"/etc/systemd/system/{fn}.service", )
user="root", systemd.service(
group="root", name="Setup doveauth-dictproxy service",
mode="644", service="doveauth-dictproxy.service",
) running=True,
systemd.service( enabled=True,
name=f"Setup {fn} service", restarted=True,
service=f"{fn}.service", daemon_reload=True,
running=True, )
enabled=True,
restarted=True, files.put(
daemon_reload=True, name="upload filtermail.service",
) src=importlib.resources.files("chatmaild")
.joinpath("filtermail.service")
.open("rb"),
dest="/etc/systemd/system/filtermail.service",
user="root",
group="root",
mode="644",
)
systemd.service(
name="Setup filtermail service",
service="filtermail.service",
running=True,
enabled=True,
restarted=True,
daemon_reload=True,
)
def _configure_opendkim(domain: str, dkim_selector: str) -> bool: def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
@@ -158,33 +173,6 @@ def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
return need_restart return need_restart
def _configure_nginx(domain: str, debug: bool = False) -> bool:
"""Configures nginx HTTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
autoconfig = files.template(
src=importlib.resources.files(__package__).joinpath("autoconfig.xml.j2"),
dest="/var/www/html/.well-known/autoconfig/mail/config-v1.1.xml",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= autoconfig.changed
return need_restart
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None: def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
"""Deploy a chat-mail instance. """Deploy a chat-mail instance.
@@ -206,7 +194,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
) )
# Deploy acmetool to have TLS certificates. # Deploy acmetool to have TLS certificates.
deploy_acmetool(nginx_hook=True, domains=[mail_server]) deploy_acmetool(domains=[mail_server])
apt.packages( apt.packages(
name="Install Postfix", name="Install Postfix",
@@ -226,17 +214,11 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
], ],
) )
apt.packages(
name="Install nginx",
packages=["nginx"],
)
_install_chatmaild() _install_chatmaild()
debug = False debug = False
dovecot_need_restart = _configure_dovecot(mail_server, debug=debug) dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
postfix_need_restart = _configure_postfix(mail_domain, debug=debug) postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
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)
systemd.service( systemd.service(
name="Start and enable OpenDKIM", name="Start and enable OpenDKIM",
@@ -262,21 +244,6 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
restarted=dovecot_need_restart, restarted=dovecot_need_restart,
) )
systemd.service(
name="Start and enable nginx",
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
server.shell(
name="Setup /etc/mailname",
commands=[f"echo {mail_domain} >/etc/mailname; chmod 644 /etc/mailname"],
)
def callback(): def callback():
result = server.shell( result = server.shell(
commands=[ commands=[

View File

@@ -38,13 +38,22 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
email=email, email=email,
) )
files.template( service_file = files.put(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"), src=importlib.resources.files(__package__)
dest="/var/lib/acme/conf/target", .joinpath("acmetool-redirector.service")
.open("rb"),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root", user="root",
group="root", group="root",
mode="644", mode="644",
) )
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
for domain in domains: for domain in domains:
server.shell( server.shell(

View File

@@ -0,0 +1,11 @@
[Unit]
Description=acmetool HTTP redirector
[Service]
Type=notify
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -1,7 +0,0 @@
request:
provider: https://acme-v02.api.letsencrypt.org/directory
key:
type: rsa
challenge:
webroot-paths:
- /var/www/html/.well-known/acme-challenge

View File

@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="{{ config.domain_name }}">
<domain>{{ config.domain_name }}</domain>
<displayName>{{ config.domain_name }} chatmail</displayName>
<displayShortName>{{ config.domain_name }}</displayShortName>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<incomingServer type="imap">
<hostname>{{ config.domain_name }}</hostname>
<port>143</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>465</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
<outgoingServer type="smtp">
<hostname>{{ config.domain_name }}</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig>

View File

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

View File

@@ -1,47 +0,0 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /var/log/nginx/error.log;
events {
worker_connections 768;
# multi_accept on;
}
http {
sendfile on;
tcp_nopush on;
# Do not emit nginx version on error pages.
server_tokens off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_certificate /var/lib/acme/live/{{ config.domain_name }}/fullchain;
ssl_certificate_key /var/lib/acme/live/{{ config.domain_name }}/privkey;
gzip on;
server {
listen 80 default_server;
listen [::]:80 default_server;
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
root /var/www/html;
index index.html index.htm;
server_name _;
location / {
# First attempt to serve request as file, then
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
}
}

View File

@@ -32,7 +32,7 @@ submission inet n - y - - smtpd
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080 -o content_filter=filter:unix:private/filtermail
smtps inet n - y - - smtpd smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
@@ -47,7 +47,7 @@ smtps inet n - y - - smtpd
-o smtpd_recipient_restrictions= -o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080 -o content_filter=filter:unix:private/filtermail
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup cleanup unix n - y - 0 cleanup
@@ -70,11 +70,13 @@ showq unix n - y - - showq
error unix - - y - - error error unix - - y - - error
retry unix - - y - - error retry unix - - y - - error
discard unix - - y - - discard discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp filter unix - n n - - lmtp
# Local SMTP server for reinjecting filered mail. # Local SMTP server for reinjecting filered mail.
localhost:10025 inet n - n - 10 smtpd localhost:10026 inet n - n - 10 smtpd
-o content_filter= -o content_filter=

View File

@@ -75,24 +75,6 @@ class ImapConn:
print(f"imap-login {user!r} {password!r}") print(f"imap-login {user!r} {password!r}")
self.conn.login(user, password) self.conn.login(user, password)
def fetch_all(self):
print("imap-fetch all")
status, res = self.conn.select()
if int(res[0]) == 0:
raise ValueError("no messages in imap folder")
status, results = self.conn.fetch("1:*", "(RFC822)")
assert status == "OK"
return results
def fetch_all_messages(self):
print("imap-fetch all messages")
results = self.fetch_all()
messages = []
for item in results:
if len(item) == 2:
messages.append(item[1].decode())
return messages
@pytest.fixture @pytest.fixture
def smtp(maildomain): def smtp(maildomain):
@@ -115,11 +97,6 @@ class SmtpConn:
print(f"smtp-login {user!r} {password!r}") print(f"smtp-login {user!r} {password!r}")
self.conn.login(user, password) self.conn.login(user, password)
def sendmail(self, from_addr, to_addrs, msg):
print(f"smtp-sendmail from={from_addr!r} to_addrs={to_addrs!r}")
print(f"smtp-sendmail message size: {len(msg)}")
return self.conn.sendmail(from_addr=from_addr, to_addrs=to_addrs, msg=msg)
@pytest.fixture(params=["imap", "smtp"]) @pytest.fixture(params=["imap", "smtp"])
def imap_or_smtp(request): def imap_or_smtp(request):
@@ -221,66 +198,4 @@ class Remote:
) )
while 1: while 1:
line = self.popen.stdout.readline() line = self.popen.stdout.readline()
res = line.decode().strip().lower() yield line.decode().strip().lower()
if res:
yield res
else:
break
@pytest.fixture
def mailgen(request):
class Mailgen:
def get_encrypted(self, from_addr, to_addr):
data = request.fspath.dirpath("mailgen/encrypted.eml").read()
return data.format(from_addr=from_addr, to_addr=to_addr)
return Mailgen()
@pytest.fixture
def cmsetup(maildomain, gencreds):
return CMSetup(maildomain, gencreds)
class CMSetup:
def __init__(self, maildomain, gencreds):
self.maildomain = maildomain
self.gencreds = gencreds
def gen_users(self, num):
print(f"Creating {num} online users")
users = []
for i in range(num):
addr, password = self.gencreds()
user = CMUser(self.maildomain, addr, password)
assert user.smtp
users.append(user)
return users
class CMUser:
def __init__(self, maildomain, addr, password):
self.maildomain = maildomain
self.addr = addr
self.password = password
self._smtp = None
self._imap = None
@property
def smtp(self):
if not self._smtp:
handle = SmtpConn(self.maildomain)
handle.connect()
handle.login(self.addr, self.password)
self._smtp = handle
return self._smtp
@property
def imap(self):
if not self._imap:
imap = ImapConn(self.maildomain)
imap.connect()
imap.login(self.addr, self.password)
self._imap = imap
return self._imap

View File

@@ -1,66 +0,0 @@
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"
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: PGP/MIME version identification
Content-Type: application/pgp-encrypted
Version: 1
--YFrteb74qSXmggbOxZL9dRnhymywAi
Content-Description: OpenPGP encrypted message
Content-Disposition: inline; filename="encrypted.asc";
Content-Type: application/octet-stream; name="encrypted.asc"
-----BEGIN PGP MESSAGE-----
wU4DhW3gBZ/VvCYSAQdA8bMs2spwbKdGjVsL1ByPkNrqD7frpB73maeL6I6SzDYg
O5G53tv339RdKq3WRcCtEEvxjHlUx2XNwXzC04BpmfvBTgNfPUyLDzjXnxIBB0Ae
8ymwGvXMCCimHXN0Dg8Ui62KOi03h0UgheoHWovJSCDF4CKre/xtFr3nL7lq/PKI
JsjVNz7/RK9FSXF6WwfONtLCyQGEuVAsB/KXfCBEyfKhaMwGHvhujRidGW5uV1no
lMGl3ODmo29Lgeu2uSE7EpJRZoe6hU6ddmBkqxax61ZtkaFlGFFpdo2K8balNNdz
ZsJ/9mmI9x3oOJ4/l1nhQbUO9ADbs7gJhFdV5Qkp30b5fCI7bU+aoe1ccBbLe/WM
YUty1PqcuQT7XjA+XmYuL261tvW8pBetT+i33/E2d8PzzYt2IuK9qeevyS+yxdwA
kfwejFWzzsUlJaDxs1x4XOxkMgSj+jo+g12dFOb7fyClsAnq23iDb8AuaT/BScAI
+lO+gher69+6LmM7VGHLG5k762J1jTaQCaKt1s8TAWV99Eo4491vL6fyvk3l/Cfg
RXSwiWFgj19Pn0Rq7CD9v22UE2vdUMBTcV4aw79mClk1YQ23jbF0y5DCjPdJ62Zo
tskBgFt3NoWV80jZ76zIBLrrjLwCCll8JjJtFwSkt2GX5RFBsVa4A8IDht9RtEk7
rrHgbSZQfkauEi/mH3/6CDZoLqSHudUZ7d4MaJwun1TkFYGe2ORwGJd4OBj3oGJp
H8YBwCpk///L/fKjX0Gg3M8nrpM4wrRFhPKidAgO/kcm25X4+ZHlVkWBTCt5RWKI
fHh6oLDZCqCfcgMkE1KKmwfIHaUkhq5BPRigwy6i5dh1DM4+1UCLh3dxzVbqE9b9
61NB19nXdRtDA2sOUnj9ve6m/wEPyCb6/zBQZqvCBYb1/AjdXpUrFT+DbpfyxaXN
XfhDVb5mNqNM/IVj0V5fvTc6vOfYbzQtPm10H+FdWWfb+rJRfyC3MA2w2IqstFe3
w3bu2iE6CQvSqRvge+ZqLKt/NqYwOURiUmpuklbl3kPJ97+mfKWoiqk8Iz1VY+bb
NMUC7aoGv+jcoj+WS6PYO8N6BeRVUUB3ZJSf8nzjgxm1/BcM+UD3BPrlhT11ODRs
baifGbprMWwt3dhb8cQgRT8GPdpO1OsDkzL6iikMjLHWWiA99GV6ruiHsIPw6boW
A6/uSOskbDHOROotKmddGTBd0iiHXAoQsJFt1ZjUkt6EHrgWs+GAvrvKpXs1mrz8
uj3GwEFrHS+Xuf2UDgpszYT3hI2cL/kUtGakVR7m7vVMZqXBUbZdGAEb1PZNPwsI
E4aMK02+EVB+tSN4Fzj99N2YD0inVYt+oPjr2tHhUS6aSGBNS/48Ki47DOg4Sxkn
lkOWnEbCD+XTnbDd
=agR5
-----END PGP MESSAGE-----
--YFrteb74qSXmggbOxZL9dRnhymywAi--

View File

@@ -1,7 +1,3 @@
import smtplib
import pytest
def test_remote(remote, imap_or_smtp): def test_remote(remote, imap_or_smtp):
lineproducer = remote.iter_output(imap_or_smtp.logcmd) lineproducer = remote.iter_output(imap_or_smtp.logcmd)
imap_or_smtp.connect() imap_or_smtp.connect()
@@ -17,44 +13,3 @@ def test_use_two_chatmailservers(cmfactory, maildomain2):
domain1 = ac1.get_config("addr").split("@")[1] domain1 = ac1.get_config("addr").split("@")[1]
domain2 = ac2.get_config("addr").split("@")[1] domain2 = ac2.get_config("addr").split("@")[1]
assert domain1 != domain2 assert domain1 != domain2
@pytest.mark.parametrize("forgeaddr", ["internal", "someone@example.org"])
def test_reject_forged_from(cmsetup, mailgen, lp, forgeaddr):
user1, user3 = cmsetup.gen_users(2)
lp.sec("send encrypted message with forged from")
print("envelope_from", user1.addr)
if forgeaddr == "internal":
addr_to_forge = cmsetup.gen_users(1)[0].addr
else:
addr_to_forge = "someone@example.org"
print("message to inject:")
msg = mailgen.get_encrypted(from_addr=addr_to_forge, to_addr=user3.addr)
for line in msg.split("\n")[:4]:
print(f" {line}")
lp.sec("Send forged mail and check remote postfix lmtp processing result")
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)
@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

@@ -1,5 +1,4 @@
import pytest import pytest
import smtplib
def test_login_basic_functioning(imap_or_smtp, gencreds, lp): def test_login_basic_functioning(imap_or_smtp, gencreds, lp):

View File

@@ -67,17 +67,3 @@ class TestEndToEndDeltaChat:
break break
pytest.fail("sending succeeded although messages should exceed quota") pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(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("ac1: create QR code and let ac2 scan it, starting the securejoin")
qr = ac1.get_setup_contact_qr()
lp.sec("ac2: start QR-code based setup contact protocol")
ch = ac2.qr_setup_contact(qr)
assert ch.id >= 10
ac1._evtracker.wait_securejoin_inviter_progress(1000)

View File

@@ -8,6 +8,12 @@
- limit: configure max-connections per account - limit: configure max-connections per account
## Filtermail
- (alex, Only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format.
TODO: mime-parse mails and check/add tests
## nami: send out rate limit / rspamd ## nami: send out rate limit / rspamd
- basic outgoing send rate/limits (depending on "account-rating") - basic outgoing send rate/limits (depending on "account-rating")

View File

@@ -2,9 +2,8 @@
: ${CHATMAIL_DOMAIN:=c1.testrun.org} : ${CHATMAIL_DOMAIN:=c1.testrun.org}
export CHATMAIL_DOMAIN export CHATMAIL_DOMAIN
chatmaild/venv/bin/python3 -m build -n --sdist chatmaild --outdir dist venv/bin/python3 -m build -n --sdist chatmaild --outdir dist
deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" \ deploy-chatmail/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" deploy.py
deploy-chatmail/src/deploy_chatmail/deploy.py
rm -r dist/ rm -r dist/

View File

@@ -6,9 +6,12 @@ deploy-chatmail/venv/bin/pip install -e deploy-chatmail
deploy-chatmail/venv/bin/pip install -e chatmaild deploy-chatmail/venv/bin/pip install -e chatmaild
python3 -m venv chatmaild/venv python3 -m venv chatmaild/venv
sudo apt install -y dovecot-core && sudo systemctl disable --now dovecot chatmaild/venv/bin/pip install pytest
chatmaild/venv/bin/pip install --upgrade pytest build 'setuptools>=68'
chatmaild/venv/bin/pip install -e chatmaild chatmaild/venv/bin/pip install -e chatmaild
python3 -m venv online-tests/venv 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 pytest-benchmark
python3 -m venv venv
venv/bin/pip install build
venv/bin/pip install 'setuptools>=68'

View File

@@ -2,7 +2,7 @@
set -e set -e
: ${CHATMAIL_DOMAIN:=c1.testrun.org} : ${CHATMAIL_DOMAIN:=c1.testrun.org}
: ${CHATMAIL_SSH:=$CHATMAIL_DOMAIN} : ${CHATMAIL_SSH_HOST:=$CHATMAIL_DOMAIN}
rsync -avz . "root@$CHATMAIL_SSH:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore" rsync -avz . "root@$CHATMAIL_SSH_HOST:/root/chatmail" --exclude='/.git' --filter="dir-merge,- .gitignore"
ssh "root@$CHATMAIL_SSH" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py" ssh "root@$CHATMAIL_SSH_HOST" "cd /root/chatmail; apt install -y python3-venv; python3 -m venv venv; venv/bin/pip install pyinfra build; venv/bin/python3 -m build -n --sdist chatmaild --outdir dist; venv/bin/pip install -e ./deploy-chatmail -e ./chatmaild; export CHATMAIL_DOMAIN=$CHATMAIL_DOMAIN; venv/bin/pyinfra @local deploy.py"

View File

@@ -1,3 +1,7 @@
#!/bin/bash #!/bin/bash
chatmaild/venv/bin/pytest chatmaild/ $@ set -e
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 $@ pushd chatmaild/src/chatmaild
../../venv/bin/pytest
popd
online-tests/venv/bin/pytest online-tests/ -vrx --durations=5 --slow