Compare commits

..

7 Commits

Author SHA1 Message Date
Christian Hagenest
b19d571f22 Change Shebang in initenv to be same as cmdeploy 2024-03-05 14:02:32 +01:00
link2xt
14342383cf Generate our own single-line DKIM entry 2024-02-17 09:34:25 +00:00
missytake
926de76010 tests: make maildata work with python3.9 2024-02-17 09:27:02 +00:00
link2xt
ee25d35db1 Fix Python 3.9 support
I installed pyenv and then installed Python 3.9:
$ pyenv install 3.9
$ eval "$(pyenv init -)"
$ pyenv shell 3.9

In a clean repository I ran
$ scripts/cmdeploy init
$ scripts/cmdeploy run
$ scripts/cmdeploy dns
$ scripts/cmdeploy fmt

With the changes made all these commands work.

scripts/cmdeploy test fails some tests
using maildata fixture at
  importlib.resources.files(__package__).joinpath("mail-data")
line but this is not critical.
2024-02-17 09:27:02 +00:00
link2xt
ee2115584b Run scripts/cmdeploy fmt 2024-02-15 14:07:10 +00:00
missytake
1c9c088657 tests: add test that currently no outdated mails are stored on the server 2024-02-14 12:19:12 +01:00
missytake
b5afac2f1a expunge: run cronjob with vmail instead of dovecot. fix #210 2024-02-14 12:19:12 +01:00
9 changed files with 99 additions and 88 deletions

View File

@@ -1,4 +1,6 @@
import random
from pathlib import Path
import os
import importlib.resources
import itertools
from email.parser import BytesParser
@@ -57,7 +59,12 @@ def db(tmpdir):
@pytest.fixture
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
def maildata(name, from_addr, to_addr):

View File

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

View File

@@ -10,15 +10,13 @@ import io
from pathlib import Path
from pyinfra import host
from pyinfra.operations import apt, files, server, systemd, pip, util
from pyinfra.operations import apt, files, server, systemd, pip
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()
@@ -129,8 +127,10 @@ def _install_remote_venv_with_chatmaild(config) -> None:
)
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[], bool]:
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
"""Configures OpenDKIM"""
need_restart = False
server.group(name="Create opendkim group", group="opendkim", system=True)
server.user(
name="Create opendkim user",
@@ -153,6 +153,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
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,6 +162,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
group="root",
mode="644",
)
need_restart |= screen_script.changed
final_script = files.put(
src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"),
@@ -169,6 +171,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
group="root",
mode="644",
)
need_restart |= final_script.changed
files.directory(
name="Add opendkim directory to /etc",
@@ -187,6 +190,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
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"),
@@ -196,7 +200,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
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",
@@ -221,12 +225,10 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> Callable[[]
_sudo_user="opendkim",
)
return util.any_changed(
main_config, screen_script, final_script, keytable, signing_table
)
return need_restart
def _install_mta_sts_daemon() -> Callable[[], bool]:
def _install_mta_sts_daemon() -> bool:
need_restart = False
config = files.put(
@@ -239,6 +241,7 @@ def _install_mta_sts_daemon() -> Callable[[], bool]:
group="root",
mode="644",
)
need_restart |= config.changed
server.shell(
name="install postfix-mta-sts-resolver with pip",
@@ -258,12 +261,15 @@ def _install_mta_sts_daemon() -> Callable[[], bool]:
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
return util.any_changed(config, systemd_unit)
return need_restart
def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool]:
def _configure_postfix(config: Config, debug: bool = False) -> 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",
@@ -272,6 +278,7 @@ def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool
mode="644",
config=config,
)
need_restart |= main_config.changed
master_config = files.template(
src=importlib.resources.files(__package__).joinpath("postfix/master.cf.j2"),
@@ -282,6 +289,7 @@ def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool
debug=debug,
config=config,
)
need_restart |= master_config.changed
header_cleanup = files.put(
src=importlib.resources.files(__package__).joinpath(
@@ -292,6 +300,7 @@ def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool
group="root",
mode="644",
)
need_restart |= header_cleanup.changed
# Login map that 1:1 maps email address to login.
login_map = files.put(
@@ -301,12 +310,15 @@ def _configure_postfix(config: Config, debug: bool = False) -> Callable[[], bool
group="root",
mode="644",
)
need_restart |= login_map.changed
return util.any_changed(main_config, master_config, header_cleanup, login_map)
return need_restart
def _configure_dovecot(config: Config, debug: bool = False) -> Callable[[], bool]:
def _configure_dovecot(config: Config, debug: bool = False) -> 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",
@@ -316,6 +328,7 @@ def _configure_dovecot(config: Config, debug: bool = False) -> Callable[[], 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",
@@ -323,6 +336,7 @@ def _configure_dovecot(config: Config, debug: bool = False) -> Callable[[], bool
group="root",
mode="644",
)
need_restart |= auth_config.changed
files.template(
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
@@ -344,11 +358,13 @@ def _configure_dovecot(config: Config, debug: bool = False) -> Callable[[], bool
persist=True,
)
return util.any_changed(main_config, auth_config)
return need_restart
def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], bool]:
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/nginx.conf.j2"),
dest="/etc/nginx/nginx.conf",
@@ -357,6 +373,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], 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"),
@@ -366,6 +383,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], 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"),
@@ -375,6 +393,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], bool]:
mode="644",
config={"domain_name": domain},
)
need_restart |= mta_sts_config.changed
# install CGI newemail script
#
@@ -395,7 +414,7 @@ def _configure_nginx(domain: str, debug: bool = False) -> Callable[[], bool]:
mode="755",
)
return util.any_changed(main_config, autoconfig, mta_sts_config)
return need_restart
def _remove_rspamd() -> None:
@@ -499,12 +518,7 @@ def deploy_chatmail(config_path: Path) -> None:
service="opendkim.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart OpenDKIM",
service="opendkim.service",
restarted=True,
_if=opendkim_need_restart,
restarted=opendkim_need_restart,
)
systemd.service(
@@ -513,12 +527,7 @@ def deploy_chatmail(config_path: Path) -> None:
daemon_reload=True,
running=True,
enabled=True,
)
systemd.service(
name="Restart MTA-STS daemon",
service="mta-sts-daemon.service",
restarted=True,
_if=mta_sts_need_restart,
restarted=mta_sts_need_restart,
)
systemd.service(
@@ -526,12 +535,7 @@ def deploy_chatmail(config_path: Path) -> None:
service="postfix.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart Postfix",
service="postfix.service",
restarted=True,
_if=postfix_need_restart,
restarted=postfix_need_restart,
)
systemd.service(
@@ -539,12 +543,7 @@ def deploy_chatmail(config_path: Path) -> None:
service="dovecot.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart Dovecot",
service="dovecot.service",
restarted=True,
_if=dovecot_need_restart,
restarted=dovecot_need_restart,
)
systemd.service(
@@ -552,12 +551,7 @@ def deploy_chatmail(config_path: Path) -> None:
service="nginx.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart nginx",
service="nginx.service",
restarted=True,
_if=nginx_need_restart,
restarted=nginx_need_restart,
)
# This file is used by auth proxy.

View File

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

View File

@@ -2,6 +2,7 @@
Provides the `cmdeploy` entry point function,
along with command line option and subcommand parsing.
"""
import argparse
import shutil
import subprocess

View File

@@ -5,6 +5,8 @@ import importlib
import subprocess
import datetime
from typing import Optional
class DNS:
def __init__(self, out, mail_domain):
@@ -34,7 +36,7 @@ class DNS:
cmd = "ip a | grep inet6 | grep 'scope global' | sed -e 's#/64 scope global##' | sed -e 's#inet6##'"
return self.shell(cmd).strip()
def get(self, typ: str, domain: str) -> str | None:
def get(self, typ: str, domain: str) -> Optional[str]:
"""Get a DNS entry"""
dig_result = self.shell(f"dig -r -q {domain} -t {typ} +short")
line = dig_result.partition("\n")[0]
@@ -54,22 +56,25 @@ def show_dns(args, out) -> int:
ssh = f"ssh root@{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...")
try:
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
except subprocess.CalledProcessError:
print("Please run `cmdeploy run` first.")
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()
reverse_ipv6 = dns.check_ptr_record(ipv6, mail_domain)
@@ -97,7 +102,6 @@ def show_dns(args, out) -> int:
return 0
except TypeError:
pass
started_dkim_parsing = False
for line in zonefile.splitlines():
line = line.format(
acme_account_url=acme_account_url,
@@ -124,28 +128,23 @@ def show_dns(args, out) -> int:
current = dns.get("SRV", domain[:-1])
if current != f"{prio} {weight} {port} {value}":
to_print.append(line)
if " TXT " in line:
if " TXT " in line:
domain, value = line.split(" TXT ")
current = dns.get("TXT", domain.strip()[:-1])
if domain.startswith("_mta-sts."):
if current:
if current.split("id=")[0] == value.split("id=")[0]:
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)
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
if to_print:

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 }} -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
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
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
2 0 * * * vmail 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).
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
2 0 * * * vmail 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

View File

@@ -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]
return
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

View File

@@ -1,4 +1,4 @@
#!/bin/bash
#!/usr/bin/env bash
set -e
python3 -m venv --upgrade-deps venv