Compare commits

..

10 Commits

Author SHA1 Message Date
link2xt
501351cfe5 Prepare for pyinfra 3 2024-02-13 21:20:25 +00:00
link2xt
c8d9f20a48 fix: avoid "Argument list too long" in expunge.cron
Make `find` look for accounts.
2024-02-13 07:37:23 +00:00
missytake
6a30db7ce0 tests: test that echobot replies to msg. closes #199 2024-01-31 16:45:26 +01:00
link2xt
9e9ab80422 Do not subscribe to TLS reports 2024-01-31 14:35:54 +01:00
link2xt
5b9debfbdf Test dict protocol handler as a separate function 2024-01-30 23:49:17 +00:00
link2xt
788309b85a Merge Postfix TLS hardening
https://github.com/deltachat/chatmail/pull/97
2024-01-30 18:45:34 +00:00
link2xt
5bbb3d9b21 Rewrite and document smtpd_tls_exclude_ciphers 2024-01-27 02:10:02 +00:00
link2xt
6bc2186912 postfix: set tls_preempt_cipherlist 2024-01-26 19:45:53 +00:00
link2xt
8d5f91bf98 postfix: use new syntax for TLS version 2024-01-26 19:42:18 +00:00
missytake
9ddf60d0fc postfix: enforce TLS 1.2, disallow some insecure TLS ciphers 2024-01-26 19:41:48 +00:00
10 changed files with 129 additions and 66 deletions

View File

@@ -160,6 +160,19 @@ def handle_dovecot_request(msg, db, config: Config):
return None
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
while True:
msg = rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
wfile.write(res.encode("ascii"))
wfile.flush()
else:
logging.warning("request had no answer: %r", msg)
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
request_queue_size = 100
@@ -173,16 +186,7 @@ def main():
class Handler(StreamRequestHandler):
def handle(self):
try:
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, config)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
handle_dovecot_protocol(self.rfile, self.wfile, db, config)
except Exception:
logging.exception("Exception in the handler")
raise

View File

@@ -1,11 +1,17 @@
import io
import json
import pytest
import threading
import queue
import threading
import traceback
import chatmaild.doveauth
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.doveauth import (
get_user_data,
lookup_passdb,
handle_dovecot_request,
handle_dovecot_protocol,
)
from chatmaild.database import DBError
@@ -69,6 +75,15 @@ def test_handle_dovecot_request(db, example_config):
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_handle_dovecot_protocol(db, example_config):
rfile = io.BytesIO(
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
)
wfile = io.BytesIO()
handle_dovecot_protocol(rfile, wfile, db, example_config)
assert wfile.getvalue() == b"N\n"
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
num_threads = 50
req_per_thread = 5

View File

@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "cmdeploy"
version = "0.2"
dependencies = [
"pyinfra",
"pyinfra==3.0b0",
"pillow",
"qrcode",
"markdown",

View File

@@ -1,6 +1,7 @@
"""
Chat Mail pyinfra deploy.
"""
import sys
import importlib.resources
import subprocess
@@ -9,13 +10,15 @@ import io
from pathlib import Path
from pyinfra import host
from pyinfra.operations import apt, files, server, systemd, pip
from pyinfra.operations import apt, files, server, systemd, pip, util
from pyinfra.facts.files import File
from pyinfra.facts.systemd import SystemdEnabled
from .acmetool import deploy_acmetool
from chatmaild.config import read_config, Config
from typing import Callable
def _build_chatmaild(dist_dir) -> None:
dist_dir = Path(dist_dir).resolve()
@@ -126,10 +129,8 @@ def _install_remote_venv_with_chatmaild(config) -> None:
)
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[], bool]:
"""Configures OpenDKIM"""
need_restart = False
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
@@ -152,7 +153,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= main_config.changed
screen_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/screen.lua"),
@@ -161,7 +161,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"),
@@ -170,7 +169,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
group="root",
mode="644",
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
@@ -189,7 +187,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= keytable.changed
signing_table = files.template(
src=importlib.resources.files(__package__).joinpath("opendkim/SigningTable"),
@@ -199,7 +196,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
mode="644",
config={"domain_name": domain, "opendkim_selector": dkim_selector},
)
need_restart |= signing_table.changed
files.directory(
name="Add opendkim socket directory to /var/spool/postfix",
path="/var/spool/postfix/opendkim",
@@ -224,10 +221,12 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
_sudo_user="opendkim",
)
return need_restart
return util.any_changed(
main_config, screen_script, final_script, keytable, signing_table
)
def _install_mta_sts_daemon() -> bool:
def _install_mta_sts_daemon() -> Callable[[], bool]:
need_restart = False
config = files.put(
@@ -240,7 +239,6 @@ def _install_mta_sts_daemon() -> bool:
group="root",
mode="644",
)
need_restart |= config.changed
server.shell(
name="install postfix-mta-sts-resolver with pip",
@@ -260,15 +258,12 @@ def _install_mta_sts_daemon() -> bool:
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
return need_restart
return util.any_changed(config, systemd_unit)
def _configure_postfix(config: Config, debug: bool = False) -> bool:
def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool]:
"""Configures Postfix SMTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/main.cf.j2"),
dest="/etc/postfix/main.cf",
@@ -277,7 +272,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
mode="644",
config=config,
)
need_restart |= main_config.changed
master_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
@@ -288,7 +282,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
debug=debug,
config=config,
)
need_restart |= master_config.changed
header_cleanup = files.put(
src=importlib.resources.files(__package__).joinpath(
@@ -299,27 +292,21 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
group="root",
mode="644",
)
need_restart |= header_cleanup.changed
# Login map that 1:1 maps email address to login.
login_map = files.put(
src=importlib.resources.files(__package__).joinpath(
"postfix/login_map"
),
src=importlib.resources.files(__package__).joinpath("postfix/login_map"),
dest="/etc/postfix/login_map",
user="root",
group="root",
mode="644",
)
need_restart |= login_map.changed
return need_restart
return util.any_changed(main_config, master_config, header_cleanup, login_map)
def _configure_dovecot(config: Config, debug: bool = False) -> bool:
def _configure_dovecot(config: Config, debug: bool = False) -> Callable[[], bool]:
"""Configures Dovecot IMAP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/dovecot.conf.j2"),
dest="/etc/dovecot/dovecot.conf",
@@ -329,7 +316,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
config=config,
debug=debug,
)
need_restart |= main_config.changed
auth_config = files.put(
src=importlib.resources.files(__package__).joinpath("dovecot/auth.conf"),
dest="/etc/dovecot/auth.conf",
@@ -337,7 +323,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
group="root",
mode="644",
)
need_restart |= auth_config.changed
files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
@@ -359,13 +344,11 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
persist=True,
)
return need_restart
return util.any_changed(main_config, auth_config)
def _configure_nginx(domain: str, debug: bool = False) -> bool:
def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], bool]:
"""Configures nginx HTTP server."""
need_restart = False
main_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
@@ -374,7 +357,6 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
mode="644",
config={"domain_name": domain},
)
need_restart |= main_config.changed
autoconfig = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/autoconfig.xml.j2"),
@@ -384,7 +366,6 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
mode="644",
config={"domain_name": domain},
)
need_restart |= autoconfig.changed
mta_sts_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/mta-sts.txt.j2"),
@@ -394,7 +375,6 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
mode="644",
config={"domain_name": domain},
)
need_restart |= mta_sts_config.changed
# install CGI newemail script
#
@@ -415,7 +395,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
mode="755",
)
return need_restart
return util.any_changed(main_config, autoconfig, mta_sts_config)
def _remove_rspamd() -> None:
@@ -519,7 +499,12 @@ def deploy_chatmail(config_path: Path) -> None:
service="opendkim.service",
running=True,
enabled=True,
restarted=opendkim_need_restart,
)
systemd.service(
name="Restart OpenDKIM",
service="opendkim.service",
restarted=True,
_if=opendkim_need_restart,
)
systemd.service(
@@ -528,7 +513,12 @@ def deploy_chatmail(config_path: Path) -> None:
daemon_reload=True,
running=True,
enabled=True,
restarted=mta_sts_need_restart,
)
systemd.service(
name="Restart MTA-STS daemon",
service="mta-sts-daemon.service",
restarted=True,
_if=mta_sts_need_restart,
)
systemd.service(
@@ -536,7 +526,12 @@ def deploy_chatmail(config_path: Path) -> None:
service="postfix.service",
running=True,
enabled=True,
restarted=postfix_need_restart,
)
systemd.service(
name="Restart Postfix",
service="postfix.service",
restarted=True,
_if=postfix_need_restart,
)
systemd.service(
@@ -544,7 +539,12 @@ def deploy_chatmail(config_path: Path) -> None:
service="dovecot.service",
running=True,
enabled=True,
restarted=dovecot_need_restart,
)
systemd.service(
name="Restart Dovecot",
service="dovecot.service",
restarted=True,
_if=dovecot_need_restart,
)
systemd.service(
@@ -552,7 +552,12 @@ def deploy_chatmail(config_path: Path) -> None:
service="nginx.service",
running=True,
enabled=True,
restarted=nginx_need_restart,
)
systemd.service(
name="Restart nginx",
service="nginx.service",
restarted=True,
_if=nginx_need_restart,
)
# This file is used by auth proxy.

View File

@@ -69,7 +69,12 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
systemd.service(
name="Restart acmetool-redirector service",
service="acmetool-redirector.service",
restarted=True,
_if=service_file.did_change,
)
server.shell(

View File

@@ -11,6 +11,5 @@ _dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
_mta-sts.{chatmail_domain}. TXT "v=STSv1; id={sts_id}"
mta-sts.{chatmail_domain}. CNAME {chatmail_domain}.
www.{chatmail_domain}. CNAME {chatmail_domain}.
_smtp._tls.{chatmail_domain}. TXT "v=TLSRPTv1;rua=mailto:{email}"
{dkim_entry}
_adsp._domainkey.{chatmail_domain}. TXT "dkim=discardable"

View File

@@ -82,7 +82,6 @@ def show_dns(args, out) -> int:
f.read()
.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,
@@ -102,7 +101,6 @@ def show_dns(args, out) -> int:
for line in zonefile.splitlines():
line = line.format(
acme_account_url=acme_account_url,
email=f"root@{args.config.mail_domain}",
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
chatmail_domain=args.config.mail_domain,
dkim_entry=dkim_entry,

View File

@@ -1,10 +1,10 @@
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/cur -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# even if they are unseen
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/new -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/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 * * * dovecot 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).
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/tmp -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }}/*/.*/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 * * * dovecot find /home/vmail/mail/{{ config.mail_domain }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete

View File

@@ -23,6 +23,31 @@ smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
smtpd_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites
# and known insecure algorithms.
#
# Disabling anonymous ciphers
# does not generally improve security
# because clients that want to verify certificate
# will not select them anyway,
# but makes cipher suite list shorter and security scanners happy.
# See <https://www.postfix.org/TLS_README.html> for discussion.
#
# Only ancient insecure ciphers should be disabled here
# as MTA clients that do not support more secure cipher
# likely do not support MTA-STS either and will
# otherwise fall back to using plaintext connection.
smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
# Override client's preference order.
# <https://www.postfix.org/postconf.5.html#tls_preempt_cipherlist>
#
# This is mostly to ensure cipher suites with forward secrecy
# are preferred over non cipher suites without forward secrecy.
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
tls_preempt_cipherlist = yes
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.mail_domain }}

View File

@@ -136,3 +136,15 @@ def test_hide_senders_ip_address(cmfactory):
user2.direct_imap.select_folder("Inbox")
msg = user2.direct_imap.get_all_messages()[0]
assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp):
ac = cmfactory.get_online_accounts(1)[0]
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
text = "hi, I hope you text me back"
chat.send_text(text)
lp.sec("Wait for reply from echobot")
reply = ac.wait_next_incoming_message()
assert reply.text == text