From c9dc32bd1053dce5f453e50b2e022a34952aa6fa Mon Sep 17 00:00:00 2001 From: link2xt Date: Fri, 13 Oct 2023 21:50:14 +0000 Subject: [PATCH] Add filtermail --- chatmail-pyinfra/src/chatmail/__init__.py | 51 ++++++++++- .../src/chatmail/postfix/master.cf | 6 ++ filtermail/filtermail.service | 10 +++ filtermail/pyproject.toml | 33 +++++++ filtermail/src/filtermail/__init__.py | 0 filtermail/src/filtermail/filtermail.py | 85 +++++++++++++++++++ plan.txt | 10 +-- scripts/deploy.sh | 15 +++- scripts/init.sh | 4 + 9 files changed, 201 insertions(+), 13 deletions(-) create mode 100644 filtermail/filtermail.service create mode 100644 filtermail/pyproject.toml create mode 100644 filtermail/src/filtermail/__init__.py create mode 100644 filtermail/src/filtermail/filtermail.py diff --git a/chatmail-pyinfra/src/chatmail/__init__.py b/chatmail-pyinfra/src/chatmail/__init__.py index a19c0653..9156d476 100644 --- a/chatmail-pyinfra/src/chatmail/__init__.py +++ b/chatmail-pyinfra/src/chatmail/__init__.py @@ -23,10 +23,6 @@ def _install_doveauth() -> None: src=doveauth_path.open("rb"), dest=remote_path, ) - apt.packages( - name="apt install python3-pip", - packages="python3-pip", - ) # Maybe if we introduce dependencies to the doveauth package at some point, we should not install doveauth # system-wide anymore. For now it's fine though. server.shell( @@ -35,6 +31,48 @@ def _install_doveauth() -> None: ) +def _install_filtermail() -> None: + """Setup filtermail.""" + filtermail_filename = "filtermail-0.1.tar.gz" + filtermail_path = importlib.resources.files(__package__).joinpath( + f"../../../filtermail/dist/{filtermail_filename}" + ) + remote_path = f"/tmp/{filtermail_filename}" + if Path(str(filtermail_path)).exists(): + files.put( + name="upload local filtermail build", + src=filtermail_path.open("rb"), + dest=remote_path, + ) + apt.packages( + name="apt install python3-aiosmtpd", + packages="python3-aiosmtpd", + ) + + # --no-deps because aiosmtplib is installed with `apt`. + server.shell( + name="install local doveauth build with pip", + commands=[f"pip install --break-system-packages --no-deps {remote_path}"], + ) + + files.put( + src=importlib.resources.files(__package__) + .joinpath("../../../filtermail/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, + ) + + def _configure_opendkim(domain: str, dkim_selector: str) -> bool: """Configures OpenDKIM""" need_restart = False @@ -172,7 +210,12 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N ], ) + apt.packages( + name="apt install python3-pip", + packages="python3-pip", + ) _install_doveauth() + _install_filtermail() dovecot_need_restart = _configure_dovecot(mail_server) postfix_need_restart = _configure_postfix(mail_domain) opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector) diff --git a/chatmail-pyinfra/src/chatmail/postfix/master.cf b/chatmail-pyinfra/src/chatmail/postfix/master.cf index 3136a58a..c8363198 100644 --- a/chatmail-pyinfra/src/chatmail/postfix/master.cf +++ b/chatmail-pyinfra/src/chatmail/postfix/master.cf @@ -28,6 +28,7 @@ submission inet n - y - - smtpd -o smtpd_recipient_restrictions= -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING + -o content_filter=filter:unix:private/filtemail smtps inet n - y - - smtpd -o syslog_name=postfix/smtps -o smtpd_tls_wrappermode=yes @@ -42,6 +43,7 @@ smtps inet n - y - - smtpd -o smtpd_recipient_restrictions= -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING + -o content_filter=filter:unix:private/filtermail #628 inet n - y - - qmqpd pickup unix n - y 60 1 pickup cleanup unix n - y - 0 cleanup @@ -70,3 +72,7 @@ lmtp unix - - y - - lmtp anvil unix - - y - 1 anvil 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= diff --git a/filtermail/filtermail.service b/filtermail/filtermail.service new file mode 100644 index 00000000..b5953854 --- /dev/null +++ b/filtermail/filtermail.service @@ -0,0 +1,10 @@ +[Unit] +Description=Email filter for chatmail servers + +[Service] +ExecStart=/usr/local/bin/filtermail +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/filtermail/pyproject.toml b/filtermail/pyproject.toml new file mode 100644 index 00000000..2d3fb4e9 --- /dev/null +++ b/filtermail/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["setuptools>=45"] +build-backend = "setuptools.build_meta" + +[project] +name = "filtermail" +version = "0.1" +dependencies = [ + "aiosmtpd" +] + +[project.scripts] +filtermail = "filtermail.filtermail:main" + +[tool.pytest.ini_options] +addopts = "-v -ra --strict-markers" + +[tool.tox] +legacy_tox_ini = """ +[tox] +isolated_build = true +envlist = lint + +[testenv:lint] +skipdist = True +skip_install = True +deps = + ruff + black +commands = + black --quiet --check --diff src/ + ruff src/ +""" diff --git a/filtermail/src/filtermail/__init__.py b/filtermail/src/filtermail/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/filtermail/src/filtermail/filtermail.py b/filtermail/src/filtermail/filtermail.py new file mode 100644 index 00000000..e2058cab --- /dev/null +++ b/filtermail/src/filtermail/filtermail.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +import asyncio +import logging + +from aiosmtpd.lmtp import LMTP +from aiosmtpd.controller import UnixSocketController +from smtplib import SMTP as SMTPClient + + +def check_encrypted(envelope): + """https://xkcd.com/1181/""" + return "-----BEGIN PGP MESSAGE-----" in envelope.content.decode( + "utf8", errors="replace" + ) + + +class ExampleController(UnixSocketController): + def factory(self): + return LMTP(self.handler, **self.SMTP_kwargs) + + +class ExampleHandler: + 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): + logging.info("Processing DATA message from %s", envelope.mail_from) + + valid_recipients = [] + + mail_encrypted = check_encrypted(envelope) + + 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 + + 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(loop): + controller = ExampleController( + ExampleHandler(), unix_socket="/var/spool/postfix/private/filtermail" + ) + controller.start() + + +def main(): + logging.basicConfig(level=logging.INFO) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.create_task(asyncmain(loop=loop)) + loop.run_forever() + + +if __name__ == "__main__": + main() diff --git a/plan.txt b/plan.txt index 888423f2..ea2f8bf2 100644 --- a/plan.txt +++ b/plan.txt @@ -11,14 +11,12 @@ 4. automatic expiry of users that haven't logged in for N days -## Postfix goals/steps +## Filtermail -1. block all outgoing mails with our own LMTP program +1. Only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format. + Currently only checks for "-----BEGIN PGP MESSAGE-----". -2. only allow (outgoing) mails if secure-join or autocrypt-pgp-encrypted format - (probably via an lmtp service) - -3. basic outgoing send rate/limits (depending on "account-rating") +2. basic outgoing send rate/limits (depending on "account-rating") ## online tests (first with plain python/pytest) diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 1f93876e..0571e001 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,7 +1,16 @@ #!/usr/bin/env bash : ${CHATMAIL_DOMAIN:=c1.testrun.org} export CHATMAIL_DOMAIN -cd doveauth + +pushd doveauth venv/bin/python3 -m build -../chatmail-pyinfra/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" ../deploy.py -rm -r dist/ +popd + +pushd filtermail +venv/bin/python3 -m build +popd + +chatmail-pyinfra/venv/bin/pyinfra --ssh-user root "$CHATMAIL_DOMAIN" deploy.py + +#rm -r doveauth/dist/ +#rm -r filtermail/dist/ diff --git a/scripts/init.sh b/scripts/init.sh index cd05253c..b31a2c90 100755 --- a/scripts/init.sh +++ b/scripts/init.sh @@ -10,3 +10,7 @@ doveauth/venv/bin/pip install -e doveauth python3 -m venv online-tests/venv online-tests/venv/bin/pip install pytest pytest-timeout pdbpp deltachat + +python3 -m venv filtermail/venv +filtermail/venv/bin/pip install build +filtermail/venv/bin/pip install -e filtermail