diff --git a/.gitignore b/.gitignore index 68bc17f9..594ea4b0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ __pycache__/ *.py[cod] *$py.class +*.swp # C extensions *.so diff --git a/README.md b/README.md new file mode 100644 index 00000000..4c39c07a --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Chat Mail server configuration + +This package deploys Postfix and Dovecot servers, including OpenDKIM for DKIM signing. + +Postfix uses Dovecot for authentication as described in + +## Ports + +Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions). +Dovecot listens on ports 143(imap) and 993 (imaps). + +## DNS + +For DKIM you must add a DNS entry as in /etc/opendkim/selector.txt (where selector is the opendkim_selector configured in the chatmail inventory). diff --git a/deploy.py b/deploy.py new file mode 100644 index 00000000..763f790e --- /dev/null +++ b/deploy.py @@ -0,0 +1,42 @@ +import os +from pyinfra import host, facts +from chatmail import deploy_chatmail + + +# the following is to prevent rate-limits with querying letsencrypt +# servers during deploys. It probably makes more sense to check +# in acmetool if a cert exists and skip recreating it because +# the acmetool pyinfra will renew certs via its cronjob, anyway. + +def unpack_acme_state(): + from pyinfra.operations import files, server + from io import BytesIO + + local_acme_filename = "acme_state.tar.gz" + + if os.path.exists(local_acme_filename): + with open(local_acme_filename, "rb") as f: + acme_state = f.read() + files.put( + name="Upload acme state tar", + src=BytesIO(acme_state), + dest="/root/acme_state.tar.gz", + mode="600", + ) + server.shell( + name="Unpack acme state directory", + commands=[ + "mkdir -p /var/lib/acme && tar -C /var/lib/acme -x -z < /root/acme_state.tar.gz" + ], + ) + else: + print("no cached acme state found, deploy will recreate letsencrypt certs") + print("use this command to create a cache file:") + ssh_host = f"{host.data.ssh_user}@{host.data.host.name}" + cmd = f"'tar -C /var/lib/acme -c . -z' > {local_acme_filename}" + print(f"ssh {ssh_host} {cmd}") + + +unpack_acme_state() + +deploy_chatmail() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..05512356 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,7 @@ +[build-system] +requires = ["setuptools>=45"] +build-backend = "setuptools.build_meta" + +[project] +name = "chatmail" +version = "0.1" diff --git a/src/chatmail/__init__.py b/src/chatmail/__init__.py new file mode 100644 index 00000000..6a8cdae1 --- /dev/null +++ b/src/chatmail/__init__.py @@ -0,0 +1,215 @@ +""" +Chat Mail pyinfra deploy. +""" +import importlib.resources +from io import StringIO + +from pyinfra import host, logger +from pyinfra.operations import apt, files, server, systemd, python +from .acmetool import deploy_acmetool + + +def _install_chatctl() -> None: + """Setup chatctl.""" + files.put( + src=importlib.resources.files(__package__) + .joinpath("chatctl/chatctl.py") + .open("rb"), + dest="/home/vmail/chatctl", + user="vmail", + group="vmail", + mode="755", + ) + + +def _configure_opendkim( + domain: str, dkim_selector: str, dkim_key: str, dkim_txt: str +) -> bool: + """Configures OpenDKIM""" + need_restart = False + + main_config = files.template( + src=importlib.resources.files(__package__).joinpath("opendkim/opendkim.conf"), + dest="/etc/opendkim.conf", + user="root", + group="root", + mode="644", + config={"domain_name": domain, "opendkim_selector": dkim_selector}, + ) + + files.directory( + name="Add opendkim socket directory to /var/spool/postfix", + path="/var/spool/postfix/opendkim", + user="opendkim", + group="opendkim", + mode="750", + present=True, + ) + + if dkim_key: + files.put( + name="Put the DKIM key", + src=StringIO(dkim_key), + dest=f"/etc/dkimkeys/{dkim_selector}.private", + mode="600", + ) + files.put( + name="Put the DKIM DNS textfile", + src=StringIO(dkim_txt), + dest=f"/etc/dkimkeys/{dkim_selector}.txt", + mode="600", + ) + else: + server.shell( + name="Generate OpenDKIM domain keys", + commands=[ + f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}" + ], + _sudo=True, + _sudo_user="opendkim", + ) + + need_restart |= main_config.changed + + return need_restart + + +def _configure_postfix(domain: str) -> 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", + user="root", + group="root", + mode="644", + config={"domain_name": domain}, + ) + need_restart |= main_config.changed + + master_config = files.put( + src=importlib.resources.files(__package__) + .joinpath("postfix/master.cf") + .open("rb"), + dest="/etc/postfix/master.cf", + user="root", + group="root", + mode="644", + ) + need_restart |= master_config.changed + + return need_restart + + +def _configure_dovecot(domain: str) -> 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", + user="root", + group="root", + mode="644", + config={"hostname": domain}, + ) + need_restart |= main_config.changed + + # luarocks install http lpeg_patterns fifo + auth_script = files.put( + src=importlib.resources.files(__package__).joinpath("dovecot/auth.lua"), + dest="/etc/dovecot/auth.lua", + user="root", + group="root", + mode="644", + ) + need_restart |= auth_script.changed + + return need_restart + + +def deploy_chatmail() -> None: + domain = host.data.domain + dkim_selector = host.data.dkim_selector + dkim_key = host.data.dkim_key + dkim_txt = host.data.dkim_txt + + apt.update(name="apt update") + server.group(name="Create vmail group", group="vmail", system=True) + server.user(name="Create vmail user", user="vmail", group="vmail", system=True) + + server.group(name="Create opendkim group", group="opendkim", system=True) + server.user( + name="Add postfix user to opendkim group for socket access", + user="postfix", + groups=["opendkim"], + system=True, + ) + + # Deploy acmetool to have TLS certificates. + deploy_acmetool(domains=[domain]) + + apt.packages( + name="Install Postfix", + packages="postfix", + ) + + apt.packages( + name="Install Dovecot", + packages=[ + "dovecot-imapd", + "dovecot-lmtpd", + "dovecot-auth-lua", + ], + ) + + apt.packages( + name="Install OpenDKIM", + packages=[ + "opendkim", + "opendkim-tools", + ], + ) + + _install_chatctl() + dovecot_need_restart = _configure_dovecot(domain) + postfix_need_restart = _configure_postfix(domain) + opendkim_need_restart = _configure_opendkim( + domain, dkim_selector, dkim_key, dkim_txt + ) + + systemd.service( + name="Start and enable OpenDKIM", + service="opendkim.service", + running=True, + enabled=True, + restarted=opendkim_need_restart, + ) + + systemd.service( + name="Start and enable Postfix", + service="postfix.service", + running=True, + enabled=True, + restarted=postfix_need_restart, + ) + + systemd.service( + name="Start and enable Dovecot", + service="dovecot.service", + running=True, + enabled=True, + restarted=dovecot_need_restart, + ) + + def callback(): + result = server.shell( + commands=[ + f"""sed 's/\tIN/ 600 IN/;s/\t(//;s/\"$//;s/^\t \"//g; s/ ).*//' """ + f"""/etc/dkimkeys/{dkim_selector}.txt | tr --delete '\n'""" + ] + ) + logger.info(f"Add this TXT entry into DNS zone: {result.stdout}") + + python.call(name="Print TXT entry for DKIM", function=callback) diff --git a/src/chatmail/acmetool/__init__.py b/src/chatmail/acmetool/__init__.py new file mode 100644 index 00000000..fb96238f --- /dev/null +++ b/src/chatmail/acmetool/__init__.py @@ -0,0 +1,62 @@ +import importlib.resources + +from pyinfra.operations import apt, files, systemd, server + + +def deploy_acmetool(nginx_hook=False, email="", domains=[]): + """Deploy acmetool.""" + apt.packages( + name="Install acmetool", + packages=["acmetool"], + ) + + files.put( + src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"), + dest="/etc/cron.d/acmetool", + user="root", + group="root", + mode="644", + ) + + if nginx_hook: + files.put( + src=importlib.resources.files(__package__) + .joinpath("acmetool.hook") + .open("rb"), + dest="/usr/lib/acme/hooks/nginx", + user="root", + group="root", + mode="744", + ) + + files.template( + src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"), + dest="/var/lib/acme/conf/responses", + user="root", + group="root", + mode="644", + email=email, + ) + + 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=True, + enabled=True, + restarted=service_file.changed, + ) + + for domain in domains: + server.shell( + name=f"Request certificate for {domain}", + commands=[f"acmetool want {domain}"], + ) diff --git a/src/chatmail/acmetool/acmetool-redirector.service b/src/chatmail/acmetool/acmetool-redirector.service new file mode 100644 index 00000000..2e434b9b --- /dev/null +++ b/src/chatmail/acmetool/acmetool-redirector.service @@ -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 diff --git a/src/chatmail/acmetool/acmetool.cron b/src/chatmail/acmetool/acmetool.cron new file mode 100644 index 00000000..1d709a16 --- /dev/null +++ b/src/chatmail/acmetool/acmetool.cron @@ -0,0 +1,4 @@ +SHELL=/bin/sh +PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin +MAILTO=root +20 16 * * * root /usr/bin/acmetool --batch reconcile diff --git a/src/chatmail/acmetool/acmetool.hook b/src/chatmail/acmetool/acmetool.hook new file mode 100644 index 00000000..9ee11f45 --- /dev/null +++ b/src/chatmail/acmetool/acmetool.hook @@ -0,0 +1,5 @@ +#!/bin/sh +set -e +EVENT_NAME="$1" +[ "$EVENT_NAME" = "live-updated" ] || exit 42 +systemctl restart nginx.service diff --git a/src/chatmail/acmetool/response-file.yaml.j2 b/src/chatmail/acmetool/response-file.yaml.j2 new file mode 100644 index 00000000..2e5ff23b --- /dev/null +++ b/src/chatmail/acmetool/response-file.yaml.j2 @@ -0,0 +1,2 @@ +"acme-enter-email": "{{ email }}" +"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf": true diff --git a/src/chatmail/chatctl/chatctl.py b/src/chatmail/chatctl/chatctl.py new file mode 100644 index 00000000..1078d3d8 --- /dev/null +++ b/src/chatmail/chatctl/chatctl.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import base64 +import sys + +if sys.argv[1] == "hexauth": + login = base64.b16decode(sys.argv[2]) + password = base64.b16decode(sys.argv[3]) + if login == b"link2xt@instant2.testrun.org" and password == b"Ahyei6ie": + sys.exit(0) + else: + sys.exit(1) +elif sys.argv[1] == "hexlookup": + login = base64.b16decode(sys.argv[2]) + if login == b"link2xt@instant2.testrun.org": + sys.exit(0) + else: + sys.exit(1) diff --git a/src/chatmail/dovecot/auth.lua b/src/chatmail/dovecot/auth.lua new file mode 100644 index 00000000..eb4b73b3 --- /dev/null +++ b/src/chatmail/dovecot/auth.lua @@ -0,0 +1,35 @@ +-- Lua based authentication script for Dovecot. +-- +-- It calls external chatctl command to answer requests. + +-- Hexadecimal aka base16 encoding. +function hex(data) + return (data:gsub(".", function(char) return string.format("%2X", char:byte()) end)) +end + +-- Escape shell argument by hex encoding it and wrapping in quotes. +function escape(data) + return ("'"..hex(data).."'") +end + +function auth_password_verify(request, password) + if os.execute("/home/vmail/chatctl hexauth "..escape(request.user).." "..escape(password)) then + return dovecot.auth.PASSDB_RESULT_OK, {} + end + return dovecot.auth.PASSDB_RESULT_PASSWORD_MISMATCH, "" +end + +function auth_passdb_lookup(request) + if os.execute("/home/vmail/chatctl hexlookup "..escape(request.user)) then + return dovecot.auth.PASSDB_RESULT_OK, {} + end + return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "no such user" +end + +function auth_userdb_lookup(request) + if os.execute("/home/vmail/chatctl hexlookup "..escape(request.user)) then + return dovecot.auth.USERDB_RESULT_OK, "uid=vmail gid=vmail" + end + + return dovecot.auth.USERDB_RESULT_USER_UNKNOWN, "no such user" +end diff --git a/src/chatmail/dovecot/dovecot.conf.j2 b/src/chatmail/dovecot/dovecot.conf.j2 new file mode 100644 index 00000000..c4ccaaef --- /dev/null +++ b/src/chatmail/dovecot/dovecot.conf.j2 @@ -0,0 +1,94 @@ +## Dovecot configuration file + +protocols = imap lmtp + +auth_mechanisms = plain + +# Authentication for system users. +passdb { + driver = lua + args = file=/etc/dovecot/auth.lua +} +userdb { + driver = lua + args = file=/etc/dovecot/auth.lua +} + +## +## Mailbox locations and namespaces +## + +# Mailboxes are stored in the "mail" directory of the vmail user home. +mail_location = maildir:/home/vmail/mail/%d/%u + +namespace inbox { + inbox = yes + + mailbox Drafts { + special_use = \Drafts + } + mailbox Junk { + special_use = \Junk + } + mailbox Trash { + special_use = \Trash + } + + # For \Sent mailboxes there are two widely used names. We'll mark both of + # them as \Sent. User typically deletes one of them if duplicates are created. + mailbox Sent { + special_use = \Sent + } + mailbox "Sent Messages" { + special_use = \Sent + } +} + +mail_uid = vmail +mail_gid = vmail +mail_privileged_group = vmail + +## +## Mail processes +## + +# Enable IMAP COMPRESS (RFC 4978). +# +protocol imap { + mail_plugins = $mail_plugins imap_zlib +} + +plugin { + imap_compress_deflate_level = 6 +} + +service lmtp { + user=vmail + + unix_listener /var/spool/postfix/private/dovecot-lmtp { + group = postfix + mode = 0600 + user = postfix + } +} + +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0660 + user = postfix + group = postfix + } +} + +service auth-worker { + # Default is root. + # Drop privileges we don't need. + user = $default_internal_user +} + +ssl = required +ssl_cert =