initial commit, mostly copied from another repo

This commit is contained in:
holger krekel
2023-10-12 15:06:48 +02:00
parent 54e7128abc
commit 8f70c4049a
17 changed files with 693 additions and 0 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
__pycache__/
*.py[cod]
*$py.class
*.swp
# C extensions
*.so

14
README.md Normal file
View File

@@ -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 <https://www.postfix.org/SASL_README.html#server_dovecot>
## 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).

42
deploy.py Normal file
View File

@@ -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()

7
pyproject.toml Normal file
View File

@@ -0,0 +1,7 @@
[build-system]
requires = ["setuptools>=45"]
build-backend = "setuptools.build_meta"
[project]
name = "chatmail"
version = "0.1"

215
src/chatmail/__init__.py Normal file
View File

@@ -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)

View File

@@ -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}"],
)

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
#!/bin/sh
set -e
EVENT_NAME="$1"
[ "$EVENT_NAME" = "live-updated" ] || exit 42
systemctl restart nginx.service

View File

@@ -0,0 +1,2 @@
"acme-enter-email": "{{ email }}"
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf": true

View File

@@ -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)

View File

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

View File

@@ -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).
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
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 = </var/lib/acme/live/{{ config.hostname }}/fullchain
ssl_key = </var/lib/acme/live/{{ config.hostname }}/privkey
ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes

View File

@@ -0,0 +1,51 @@
# This is a basic configuration for signing and verifying. It can easily be
# adapted to suit a basic installation. See opendkim.conf(5) and
# /usr/share/doc/opendkim/examples/opendkim.conf.sample for complete
# documentation of available configuration parameters.
Syslog yes
SyslogSuccess yes
#LogWhy no
# Common signing and verification parameters. In Debian, the "From" header is
# oversigned, because it is often the identity key used by reputation systems
# and thus somewhat security sensitive.
Canonicalization relaxed/simple
#Mode sv
#SubDomains no
OversignHeaders From
# Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com),
# using the private key stored in /etc/dkimkeys/example.private. More granular
# setup options can be found in /usr/share/doc/opendkim/README.opendkim.
Domain {{ config.domain_name }}
Selector {{ config.opendkim_selector }}
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged
# user (for example, Postfix). You may need to add user "postfix" to group
# "opendkim" in that case.
UserID opendkim
UMask 007
# Socket for the MTA connection (required). If the MTA is inside a chroot jail,
# it must be ensured that the socket is accessible. In Debian, Postfix runs in
# a chroot in /var/spool/postfix, therefore a Unix socket would have to be
# configured as shown on the last line below.
#Socket local:/run/opendkim/opendkim.sock
#Socket inet:8891@localhost
#Socket inet:8891
Socket local:/var/spool/postfix/opendkim/opendkim.sock
PidFile /run/opendkim/opendkim.pid
# Hosts for which to sign rather than verify, default is 127.0.0.1. See the
# OPERATION section of opendkim(8) for more information.
#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
# by the package dns-root-data.
TrustAnchorFile /usr/share/dns/root.key
#Nameservers 127.0.0.1

View File

@@ -0,0 +1,48 @@
myorigin = {{ config.domain_name }}
smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
biff = no
# appending .domain is the MUA's job.
append_dot_mydomain = no
# Uncomment the next line to generate "delayed mail" warnings
#delay_warning_time = 4h
readme_directory = no
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on
# fresh installs.
compatibility_level = 2
# TLS parameters
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.domain_name }}/fullchain
smtpd_tls_key_file=/var/lib/acme/live/{{ config.domain_name }}/privkey
smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.domain_name }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
# Postfix does not deliver mail for any domain by itself.
# Primary domain is listed in `virtual_mailbox_domains` instead
# and handed over to Dovecot.
mydestination =
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
virtual_transport = lmtp:unix:private/dovecot-lmtp
virtual_mailbox_domains = {{ config.domain_name }}
smtpd_milters = unix:opendkim/opendkim.sock
non_smtpd_milters = $smtpd_milters

View File

@@ -0,0 +1,72 @@
#
# Postfix master process configuration file. For details on the format
# of the file, see the master(5) manual page (command: "man 5 master" or
# on-line: http://www.postfix.org/master.5.html).
#
# Do not forget to execute "postfix reload" after editing this file.
#
# ==========================================================================
# service type private unpriv chroot wakeup maxproc command + args
# (yes) (yes) (no) (never) (100)
# ==========================================================================
smtp inet n - y - - smtpd
#smtp inet n - y - 1 postscreen
#smtpd pass - - y - - smtpd
#dnsblog unix - - y - 0 dnsblog
#tlsproxy unix - - y - 0 tlsproxy
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_tls_auth_only=yes
-o smtpd_reject_unlisted_recipient=no
-o smtpd_client_restrictions=$mua_client_restrictions
-o smtpd_helo_restrictions=$mua_helo_restrictions
-o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_sasl_type=dovecot
-o smtpd_sasl_path=private/auth
-o smtpd_reject_unlisted_recipient=no
-o smtpd_client_restrictions=$mua_client_restrictions
-o smtpd_helo_restrictions=$mua_helo_restrictions
-o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
#628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup
qmgr unix n - n 300 1 qmgr
#qmgr unix n - n 300 1 oqmgr
tlsmgr unix - - y 1000? 1 tlsmgr
rewrite unix - - y - - trivial-rewrite
bounce unix - - y - 0 bounce
defer unix - - y - 0 bounce
trace unix - - y - 0 bounce
verify unix - - y - 1 verify
flush unix n - y 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - y - - smtp
relay unix - - y - - smtp
-o syslog_name=postfix/$service_name
# -o smtp_helo_timeout=5 -o smtp_connect_timeout=5
showq unix n - y - - showq
error unix - - y - - error
retry unix - - y - - error
discard unix - - y - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - y - - lmtp
anvil unix - - y - 1 anvil
scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd

13
tox.ini Normal file
View File

@@ -0,0 +1,13 @@
[tox]
isolated_build = true
envlist = lint
[testenv:lint]
skipdist = True
skip_install = True
deps =
ruff
black
commands =
black --quiet --check --diff src/
ruff src/