mirror of
https://github.com/chatmail/relay.git
synced 2026-05-11 16:34:39 +00:00
Compare commits
8 Commits
socks-setu
...
fix221
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e313bc3707 | ||
|
|
21778fa4f3 | ||
|
|
14342383cf | ||
|
|
926de76010 | ||
|
|
ee25d35db1 | ||
|
|
ee2115584b | ||
|
|
1c9c088657 | ||
|
|
b5afac2f1a |
@@ -1,4 +1,6 @@
|
|||||||
import random
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
import importlib.resources
|
import importlib.resources
|
||||||
import itertools
|
import itertools
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
@@ -57,7 +59,12 @@ def db(tmpdir):
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def maildata(request):
|
def maildata(request):
|
||||||
datadir = importlib.resources.files(__package__).joinpath("mail-data")
|
try:
|
||||||
|
datadir = importlib.resources.files(__package__).joinpath("mail-data")
|
||||||
|
except TypeError:
|
||||||
|
# in python3.9 or lower, the above doesn't work, so we get datadir this way:
|
||||||
|
datadir = Path(os.getcwd()).joinpath("chatmaild/src/chatmaild/tests/mail-data")
|
||||||
|
|
||||||
assert datadir.exists(), datadir
|
assert datadir.exists(), datadir
|
||||||
|
|
||||||
def maildata(name, from_addr, to_addr):
|
def maildata(name, from_addr, to_addr):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Chat Mail pyinfra deploy.
|
Chat Mail pyinfra deploy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import importlib.resources
|
import importlib.resources
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -303,9 +304,7 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
|
|||||||
|
|
||||||
# Login map that 1:1 maps email address to login.
|
# Login map that 1:1 maps email address to login.
|
||||||
login_map = files.put(
|
login_map = files.put(
|
||||||
src=importlib.resources.files(__package__).joinpath(
|
src=importlib.resources.files(__package__).joinpath("postfix/login_map"),
|
||||||
"postfix/login_map"
|
|
||||||
),
|
|
||||||
dest="/etc/postfix/login_map",
|
dest="/etc/postfix/login_map",
|
||||||
user="root",
|
user="root",
|
||||||
group="root",
|
group="root",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Provides the `cmdeploy` entry point function,
|
Provides the `cmdeploy` entry point function,
|
||||||
along with command line option and subcommand parsing.
|
along with command line option and subcommand parsing.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import importlib
|
|||||||
import subprocess
|
import subprocess
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
class DNS:
|
class DNS:
|
||||||
def __init__(self, out, mail_domain):
|
def __init__(self, out, mail_domain):
|
||||||
@@ -34,12 +36,11 @@ class DNS:
|
|||||||
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
|
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
|
||||||
return self.shell(cmd).strip()
|
return self.shell(cmd).strip()
|
||||||
|
|
||||||
def get(self, typ: str, domain: str) -> str | None:
|
def get(self, typ: str, domain: str) -> str:
|
||||||
"""Get a DNS entry"""
|
"""Get a DNS entry or empty string if there is none."""
|
||||||
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
|
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
|
||||||
line = dig_result.partition("\n")[0]
|
line = dig_result.partition("\n")[0]
|
||||||
if line:
|
return line
|
||||||
return line
|
|
||||||
|
|
||||||
def check_ptr_record(self, ip: str, mail_domain) -> bool:
|
def check_ptr_record(self, ip: str, mail_domain) -> bool:
|
||||||
"""Check the PTR record for an IPv4 or IPv6 address."""
|
"""Check the PTR record for an IPv4 or IPv6 address."""
|
||||||
@@ -54,22 +55,25 @@ def show_dns(args, out) -> int:
|
|||||||
ssh = f"ssh root@{mail_domain}"
|
ssh = f"ssh root@{mail_domain}"
|
||||||
dns = DNS(out, mail_domain)
|
dns = DNS(out, mail_domain)
|
||||||
|
|
||||||
def read_dkim_entries(entry):
|
|
||||||
lines = []
|
|
||||||
for line in entry.split("\n"):
|
|
||||||
if line.startswith(";") or not line.strip():
|
|
||||||
continue
|
|
||||||
line = line.replace("\t", " ")
|
|
||||||
lines.append(line)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
print("Checking your DKIM keys and DNS entries...")
|
print("Checking your DKIM keys and DNS entries...")
|
||||||
try:
|
try:
|
||||||
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
|
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
|
||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
print("Please run `cmdeploy run` first.")
|
print("Please run `cmdeploy run` first.")
|
||||||
return 1
|
return 1
|
||||||
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
|
|
||||||
|
dkim_selector = "opendkim"
|
||||||
|
dkim_pubkey = out.shell_output(
|
||||||
|
ssh + f" -- openssl rsa -in /etc/dkimkeys/{dkim_selector}.private"
|
||||||
|
" -pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
|
||||||
|
)
|
||||||
|
dkim_entry_value = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
||||||
|
dkim_entry_str = ""
|
||||||
|
while len(dkim_entry_value) >= 255:
|
||||||
|
dkim_entry_str += '"' + dkim_entry_value[:255] + '" '
|
||||||
|
dkim_entry_value = dkim_entry_value[255:]
|
||||||
|
dkim_entry_str += '"' + dkim_entry_value + '"'
|
||||||
|
dkim_entry = f"{dkim_selector}._domainkey.{mail_domain}. TXT {dkim_entry_str}"
|
||||||
|
|
||||||
ipv6 = dns.get_ipv6()
|
ipv6 = dns.get_ipv6()
|
||||||
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
|
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
|
||||||
@@ -97,7 +101,6 @@ def show_dns(args, out) -> int:
|
|||||||
return 0
|
return 0
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
started_dkim_parsing = False
|
|
||||||
for line in zonefile.splitlines():
|
for line in zonefile.splitlines():
|
||||||
line = line.format(
|
line = line.format(
|
||||||
acme_account_url=acme_account_url,
|
acme_account_url=acme_account_url,
|
||||||
@@ -124,28 +127,23 @@ def show_dns(args, out) -> int:
|
|||||||
current = dns.get("SRV", domain[:-1])
|
current = dns.get("SRV", domain[:-1])
|
||||||
if current != f"{prio} {weight} {port} {value}":
|
if current != f"{prio} {weight} {port} {value}":
|
||||||
to_print.append(line)
|
to_print.append(line)
|
||||||
if " TXT " in line:
|
if " TXT " in line:
|
||||||
domain, value = line.split(" TXT ")
|
domain, value = line.split(" TXT ")
|
||||||
current = dns.get("TXT", domain.strip()[:-1])
|
current = dns.get("TXT", domain.strip()[:-1])
|
||||||
if domain.startswith("_mta-sts."):
|
if domain.startswith("_mta-sts."):
|
||||||
if current:
|
if current:
|
||||||
if current.split("id=")[0] == value.split("id=")[0]:
|
if current.split("id=")[0] == value.split("id=")[0]:
|
||||||
continue
|
continue
|
||||||
if current != value:
|
|
||||||
|
# TXT records longer than 255 bytes
|
||||||
|
# are split into multiple <character-string>s.
|
||||||
|
# This typically happens with DKIM record
|
||||||
|
# which contains long RSA key.
|
||||||
|
#
|
||||||
|
# Removing `" "` before comparison
|
||||||
|
# to get back a single string.
|
||||||
|
if current.replace('" "', "") != value.replace('" "', ""):
|
||||||
to_print.append(line)
|
to_print.append(line)
|
||||||
if "IN TXT ( " in line:
|
|
||||||
started_dkim_parsing = True
|
|
||||||
dkim_lines = [line]
|
|
||||||
if started_dkim_parsing and line.startswith('"'):
|
|
||||||
dkim_lines.append(" " + line)
|
|
||||||
domain, data = "\n".join(dkim_lines).split(" IN TXT ")
|
|
||||||
current = dns.get("TXT", domain.strip()[:-1])
|
|
||||||
if current:
|
|
||||||
current = "( %s )" % (current.replace('" "', '"\n "'))
|
|
||||||
if current.replace(";", "\\;") != data:
|
|
||||||
to_print.append(dkim_entry)
|
|
||||||
else:
|
|
||||||
to_print.append(dkim_entry)
|
|
||||||
|
|
||||||
exit_code = 0
|
exit_code = 0
|
||||||
if to_print:
|
if to_print:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
|
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
# or in any IMAP subfolder
|
# or in any IMAP subfolder
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
# even if they are unseen
|
# even if they are unseen
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
|
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
2 0 * * * vmail find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
|
import socket
|
||||||
|
|
||||||
from chatmaild.config import read_config
|
from chatmaild.config import read_config
|
||||||
from cmdeploy.cmdeploy import main
|
from cmdeploy.cmdeploy import main
|
||||||
@@ -78,3 +79,24 @@ def test_concurrent_logins_same_account(
|
|||||||
|
|
||||||
for _ in conns:
|
for _ in conns:
|
||||||
assert login_results.get()
|
assert login_results.get()
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_vrfy(chatmail_config):
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((chatmail_config.mail_domain, 25))
|
||||||
|
banner = sock.recv(1024)
|
||||||
|
print(banner)
|
||||||
|
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||||
|
result = sock.recv(1024)
|
||||||
|
print(result)
|
||||||
|
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||||
|
result2 = sock.recv(1024)
|
||||||
|
print(result2)
|
||||||
|
assert result[0:10] == result2[0:10]
|
||||||
|
sock.send(b"VRFY wrongaddress\r\n")
|
||||||
|
result = sock.recv(1024)
|
||||||
|
print(result)
|
||||||
|
sock.send(b"VRFY echo\r\n")
|
||||||
|
result2 = sock.recv(1024)
|
||||||
|
print(result2)
|
||||||
|
assert result[0:10] == result2[0:10] == b"252 2.0.0 "
|
||||||
|
|||||||
@@ -83,3 +83,18 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
|||||||
assert b"4.7.1: Too much mail from" in outcome[1]
|
assert b"4.7.1: Too much mail from" in outcome[1]
|
||||||
return
|
return
|
||||||
pytest.fail("Rate limit was not exceeded")
|
pytest.fail("Rate limit was not exceeded")
|
||||||
|
|
||||||
|
|
||||||
|
def test_expunged(remote, chatmail_config):
|
||||||
|
outdated_days = int(chatmail_config.delete_mails_after) + 1
|
||||||
|
find_cmds = [
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/cur/*' -mtime +{outdated_days} -type f",
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/new/*' -mtime +{outdated_days} -type f",
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/tmp/*' -mtime +{outdated_days} -type f",
|
||||||
|
f"find /home/vmail/mail/{chatmail_config.mail_domain} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
|
||||||
|
]
|
||||||
|
for cmd in find_cmds:
|
||||||
|
for line in remote.iter_output(cmd):
|
||||||
|
assert not line
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ def test_echobot(cmfactory, chatmail_config, lp):
|
|||||||
ac = cmfactory.get_online_accounts(1)[0]
|
ac = cmfactory.get_online_accounts(1)[0]
|
||||||
|
|
||||||
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
|
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
|
||||||
chat = ac.create_chat(f'echo@{chatmail_config.mail_domain}')
|
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
|
||||||
text = "hi, I hope you text me back"
|
text = "hi, I hope you text me back"
|
||||||
chat.send_text(text)
|
chat.send_text(text)
|
||||||
lp.sec("Wait for reply from echobot")
|
lp.sec("Wait for reply from echobot")
|
||||||
|
|||||||
Reference in New Issue
Block a user