mirror of
https://github.com/chatmail/relay.git
synced 2026-05-11 00:14:36 +00:00
Compare commits
6 Commits
withlmtp
...
forged_fro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801b035f18 | ||
|
|
6f8823cbeb | ||
|
|
c0673cc43f | ||
|
|
6dcd686701 | ||
|
|
087ae78edc | ||
|
|
7e93299bce |
@@ -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
|
||||
@@ -1,10 +0,0 @@
|
||||
[Unit]
|
||||
Description=Chatmail Postfix BeforeQeue filter
|
||||
|
||||
[Service]
|
||||
ExecStart=/usr/local/bin/filtermail beforequeue 10080
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,15 +1,12 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import sys
|
||||
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 UnixSocketController
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
|
||||
@@ -35,41 +32,12 @@ def check_encrypted(message):
|
||||
return True
|
||||
|
||||
|
||||
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}"
|
||||
|
||||
async def handle_DATA(self, server, session, envelope):
|
||||
logging.info("handle_DATA before-queue: re-injecting the mail")
|
||||
client = SMTPClient("localhost", "10026")
|
||||
client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
|
||||
return "250 OK"
|
||||
class ExampleController(UnixSocketController):
|
||||
def factory(self):
|
||||
return LMTP(self.handler, **self.SMTP_kwargs)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class AfterQueueHandler:
|
||||
class ExampleHandler:
|
||||
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||
envelope.rcpt_tos.append(address)
|
||||
return "250 OK"
|
||||
@@ -78,8 +46,8 @@ class AfterQueueHandler:
|
||||
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")
|
||||
logging.info("Reinjecting the mail")
|
||||
client = SMTPClient("localhost", "10026")
|
||||
client.sendmail(envelope.mail_from, valid_recipients, envelope.content)
|
||||
else:
|
||||
logging.info("no valid recipients, ignoring mail")
|
||||
@@ -87,6 +55,13 @@ class AfterQueueHandler:
|
||||
return "\r\n".join(res)
|
||||
|
||||
|
||||
async def asyncmain(loop):
|
||||
controller = ExampleController(
|
||||
ExampleHandler(), unix_socket="/var/spool/postfix/private/filtermail"
|
||||
)
|
||||
controller.start()
|
||||
|
||||
|
||||
def lmtp_handle_DATA(envelope):
|
||||
"""the central filtering function for e-mails."""
|
||||
logging.info(f"Processing DATA message from {envelope.mail_from}")
|
||||
@@ -138,35 +113,13 @@ def lmtp_handle_DATA(envelope):
|
||||
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
|
||||
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)
|
||||
loop.create_task(task)
|
||||
loop.create_task(asyncmain(loop=loop))
|
||||
loop.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
10
chatmaild/src/chatmaild/filtermail.service
Normal file
10
chatmaild/src/chatmaild/filtermail.service
Normal file
@@ -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
|
||||
@@ -1,7 +1,6 @@
|
||||
from .filtermail import check_encrypted, lmtp_handle_DATA, SendRateLimiter
|
||||
from .filtermail import check_encrypted, lmtp_handle_DATA
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
import pytest
|
||||
|
||||
|
||||
def test_reject_forged_from():
|
||||
@@ -327,15 +326,3 @@ def test_filtermail():
|
||||
]
|
||||
).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
|
||||
|
||||
@@ -53,25 +53,24 @@ def _install_chatmaild() -> None:
|
||||
daemon_reload=True,
|
||||
)
|
||||
|
||||
for fn in ("filtermail-after", "filtermail-before"):
|
||||
files.put(
|
||||
name=f"upload {fn}.service",
|
||||
src=importlib.resources.files("chatmaild")
|
||||
.joinpath(f"{fn}.service")
|
||||
.open("rb"),
|
||||
dest=f"/etc/systemd/system/{fn}.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
systemd.service(
|
||||
name=f"Setup {fn} service",
|
||||
service=f"{fn}.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=True,
|
||||
daemon_reload=True,
|
||||
)
|
||||
files.put(
|
||||
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:
|
||||
@@ -174,33 +173,6 @@ def _configure_dovecot(mail_server: str, debug: bool = False) -> bool:
|
||||
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:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
@@ -222,7 +194,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
)
|
||||
|
||||
# Deploy acmetool to have TLS certificates.
|
||||
deploy_acmetool(nginx_hook=True, domains=[mail_server])
|
||||
deploy_acmetool(domains=[mail_server])
|
||||
|
||||
apt.packages(
|
||||
name="Install Postfix",
|
||||
@@ -242,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()
|
||||
debug = False
|
||||
dovecot_need_restart = _configure_dovecot(mail_server, debug=debug)
|
||||
postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
|
||||
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
|
||||
nginx_need_restart = _configure_nginx(mail_domain)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable OpenDKIM",
|
||||
@@ -278,14 +244,6 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
|
||||
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(
|
||||
|
||||
@@ -38,13 +38,22 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
email=email,
|
||||
)
|
||||
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
|
||||
dest="/var/lib/acme/conf/target",
|
||||
service_file = files.put(
|
||||
src=importlib.resources.files(__package__)
|
||||
.joinpath("acmetool-redirector.service")
|
||||
.open("rb"),
|
||||
dest="/etc/systemd/system/acmetool-redirector.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
systemd.service(
|
||||
name="Setup acmetool-redirector service",
|
||||
service="acmetool-redirector.service",
|
||||
running=False,
|
||||
enabled=False,
|
||||
restarted=service_file.changed,
|
||||
)
|
||||
|
||||
for domain in domains:
|
||||
server.shell(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,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 smtpd_proxy_filter=127.0.0.1:10080
|
||||
-o content_filter=filter:unix:private/filtermail-afterqueue
|
||||
-o content_filter=filter:unix:private/filtermail
|
||||
smtps inet n - y - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
@@ -48,7 +47,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 smtpd_proxy_filter=127.0.0.1:10080
|
||||
-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
|
||||
@@ -78,6 +77,4 @@ 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=
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import pytest
|
||||
import smtplib
|
||||
|
||||
|
||||
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
|
||||
@@ -35,22 +34,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")
|
||||
|
||||
Reference in New Issue
Block a user