Eigener Mailserver mit Stalwart

Artikel Bild
Der Artikel zeigt das Aufsetzen von Stalwart mittels (rootless) Podman Quadlets.

Einen Mailserver mit allem Drum und Dran aufzusetzen ist wahrlich nichts, was man eben mal so nebenher macht. Man merkt, dass die in die Jahre gekommenen Mailprotokolle durch zusätzliche Mechanismen nachträglich abgesichert wurden. Das schlägt sich in zusätzlichen Konfigurationsschritten nieder. Ich betreibe – nur für die Familie – seit vielen Jahren einen Postfix/Dovecot Mailserver und habe schon Stunde um Stunde mit Konfigurationsanpassungen in etwa drölfzig Einstellungsdateien verbracht.

Kürzlich bin ich jedoch auf Stalwart aufmerksam geworden. Das ist ein recht neuer Mailserver, der noch dazu relativ einfach aufzusetzen sein soll. Da er in Rust implementiert ist, darf man auch auf gute Performance und wenige (Speichermanagement-)Bugs hoffen. Spamfilter, fail2ban, Sieve-Support etc. hat Stalwart bereits integriert.

Also wollte ich es einfach mal ausprobieren und Stalwart aufsetzen. Leider ist die geplante Kurzanleitung nun doch etwas aus dem Ruder gelaufen, weil ich das Ganze aus Interesse als Podman Quadlets aufsetzen wollte.

Ein Hinweis vorab

Durch den Artikel möchte ich nicht (!) dazu ermuntern, dass nun alle flux einen eigenen Mailserver aufsetzen. Manche Mailserver (Telekom) sind sehr "picky", was die Akzeptanz von Mails anderer Server anbelangt. Man sollte sich jedenfalls gut überlegen, ob man sich das Administrieren eines eigenen Mailservers ans Bein binden möchte.

Ungeachtet dessen hoffe ich dennoch, dass der Text einigermaßen interessant ist und den Einsatz von Quadlets hinreichend nachvollziehbar illustriert.

Überblick

Die Idee ist, auf dem Host einen Nginx Caddy Server als Reverse Proxy für die Container zu betreiben. Caddy beschafft sich die SSL-Zertifikate bei Let's Encrypt ganz automatisch, sodass man sich damit beinahe gar nicht beschäftigen muss. Beinahe deshalb, weil wir nachher Stalwart eben doch das entsprechende Zertifikat unterschieben müssen. Da Stalwart als rootless Container hinter dem Proxy läuft, kann er das nicht selbst machen, braucht das Zertifikat aber trotzdem, weil ansonsten u. a. STARTSSL nicht funktionieren würde.

Alles außer dem Proxy soll also containerisiert laufen. Ein PostgreSQL-Container dient Stalwart (und später ggf. weiteren Containeranwendungen) als Datenhalde.

Ganz grob soll es also wie folgt ausschauen. Die Nummern geben die Ports an, Caddy lauscht also etwa "außen" an Port 25 und leitet an Port 10025 weiter, was wiederum bei Stalwarts "internem" Port 25 ankommt:

Host Podman-Container -------------- ---------------------------- | Caddy | | | |--------------| | ------------ | | | | | PostgreSQL | | | | | ------------ | | | | | 5432 | | | | | ------------ | | | | | | | | ------------ | | | | | Stalwart | | | | | ------------ | | 25 -> 10025 | -> | | 10025:25 | | | 443 -> 10443 | -> | | 10443:443 | | | 465 -> 10465 | -> | | 10465:465 | | | 993 -> 10993 | -> | | 10993:993 | | | ... | | | ... | | | | | ------------ | | | | | -------------- ------»netint.network«------

Übrigens habe ich für meine Tests Debian Testing (Trixie) verwendet, weil ich ein einigermaßen aktuelles Podman-Paket benötigte. Zum Ausprobieren ist das ok, kann aber so seine Probleme mit sich bringen, wie ich selbst feststellen musste (siehe Abschnitt Stolpersteine).

Caddy als Reverse Proxy

Weil rootless Container (standardmäßig) keine Ports unterhalb von 1024 verwenden dürfen, brauchen wir einen Reverse Proxy, der die Anfragen auf den üblichen Ports entgegennimmt und an die Ports der Container weiterreicht.

Zumindest bei den SMTP/SMTPS- und IMAPS-Ports hat man mit Caddy jedoch zunächst ein Problem, weil dieser standardmäßig keine Weiterleitung auf TCP-Ebene (Layer 4) unterstützt. Nun könnte man hergehen und diese Aufgabe einfach über Nginx abhandeln, der dies beherrscht. Für Caddy gibt es jedoch eine ganze Reihe von Modulen die zunächst mit einem Standard-Caddy nicht mitgeliefert werden, darunter auch für Layer 4. Wenn man also Caddy mit den entsprechenden Layer4-Modulen baut, kann man bei einer reinen Caddy-Lösung ohne Nginx bleiben. Zum Glück ist das sehr einfach. Man braucht bloß Go installiert haben und kann sich dann mit dem Tool caddyx ein benutzerdefiniertes Caddy bauen:

xcaddy build \ --with github.com/mholt/caddy-l4/modules/l4proxy \ --with github.com/mholt/caddy-l4/modules/l4tls \ --with github.com/mholt/caddy-l4/modules/l4proxyprotocol

Danach bringt man Debian mittels update-alternatives dazu, das Custom Build als Standard zu verwenden. Das Vorgehen ist hier sehr gut beschrieben. Überhaupt kann man auf der Caddyseite nachlesen, wie Caddy für die eigene Linux-Distribution installiert werden kann.

So gerüstet können wir schon mal das /etc/caddy/Caddyfile anlegen. Dazu musste ich ziemlich herumexperimentieren, da ich kaum hilfreiche Beispiele finden konnte. So hat sich mir nicht recht erschlossen, warum die Layer 4-Einstellungen in den globalen Bereich ({...}) des Caddyfiles müssen. Diese Konfiguration hat mir jedenfalls funktioniert:

{ layer4 { 0.0.0.0:25 { route { proxy { proxy_protocol v2 upstream localhost:10025 } } } 0.0.0.0:993 { route { proxy { proxy_protocol v2 upstream localhost:10993 } } } 0.0.0.0:465 { route { proxy { proxy_protocol v2 upstream localhost:10465 } } } 0.0.0.0:4190 { route { proxy { proxy_protocol v2 upstream localhost:14190 } } } } } mail.example.com { reverse_proxy https://127.0.0.1:10443 { transport http { proxy_protocol v2 tls_server_name mail.example.com } } }

(Selbstverständlich müssen entsprechende DNS-Einträge für mail.example.com gemacht worden sein.)

Was sind Quadlets?

Mit Podman Quadlets kann man Container sehr einfach mittels Systemd erstellen und verwalten. Dabei erstellt man .container-Dateien, die mit dem Befehl systemctl --user daemon-reload verarbeitet und dann über die üblichen Systemd-Mechanismen gesteuert werden können. Auch werden die Dienste dann (wenn man möchte) automatisch beim Systemstart mit gestartet, was ansonsten bei reinen Podman Containern so eine Sache ist, weil es keinen überwachenden Podman-Prozess gibt, wie es bei Docker der Fall ist. Ich bin in der Thematik aber ehrlich gesagt nicht so tief drin. Für mich sind Quadlets jedenfalls eine elegante Art, um zu einfach steuerbaren (Systemd-)Containerdiensten zu kommen.

Vorab

Eigentlich dürfen User-Systemd-Dienste nicht einfach weiterlaufen, wenn der Benutzer ausgeloggt ist. Da wir die Quadlets jedoch – was durchaus empfohlen wird – rootless aufsetzen möchten, muss genau das erlaubt werden. Dazu muss man als root lediglich das Kommando loginctl enable-linger ausführen.

PostgreSQL

Stalwart unterstützt diverse Storage-Backends. Da ich für andere Dienste eh PostgreSQL einsetzen möchte, habe ich mich auch beim Mail-Server dafür entschieden. Das ist also der erste Container, den wir für unser Vorhaben einrichten. Da die Datenbank nur für lokale Dienste verfügbar sein soll, richtigen wir zunächst ein internes Netzwerk dafür ein. Wenn nicht anders angegeben, sind wir nun als "normaler" User eingeloggt, dem wir im vorigen Abschnitt erlaubt haben, langlaufende Dienste auszuführen.

.config/containers/systemd/netint.network:

[Unit] Description=Internal Podman Network [Network] NetworkName=netint DisableDNS=false Internal=true

Jetzt fehlt noch der Container für die Datenbankanwendung. Ich habe mich für Postgres 17, basierend auf Alpine Linux, entschieden. .config/containers/systemd/dbpg.container:

[Unit] Description=Internal PostgreSQL database [Container] Image=docker.io/library/postgres:17-alpine ContainerName=dbpg AutoUpdate=local Volume=/srv/podvols/postgresql:/var/lib/postgresql/data Environment=POSTGRES_PASSWORD=MeinSicheresPasswort Environment=LANG=de_DE.utf8 Environment=POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE" Environment=PGDATA=/var/lib/postgresql/data/pgdata Timezone=Europe/Berlin ShmSize=128m Network=netint.network [Service] TimeoutStartSec=600 Restart=always [Install] WantedBy=multi-user.target default.target

Die Zeilen dürften überwiegend selbsterklärend sein. Das Volume mit den Daten habe ich auf /srv gelegt, was bei mir ein eigenes "logical Volume" (LVM) ist, von dem recht einfach Snapshots für Backupzwecke gemacht werden können.

Nach einem systemctl --user daemon-reload startet systemctl --user start dbpg den Container. Das interne Netzwerk wird automatisch erzeugt und der Container bekommt darin eine IP-Adresse. Wird der Server neu gestartet, startet auch die PostgreSQL-Datenbank wieder automatisch.

Stalwart

Es fehlt natürlich noch der eigentliche Mailserver. Dieser muss sowohl in das interne Netzwerk, da ja auf die Datenbank zugegriffen werden muss, als auch in das externe Netzwerk, da der Mailserver u. a. aus dem Internet die neuesten Spaminformationen herunterladen können soll.

.config/containers/systemd/netext.network:

[Unit] Description=External Podman Network [Network] NetworkName=netext DisableDNS=false Internal=false

Schlussendlich erstellen wir auch noch den Stalwart Container .config/containers/systemd/stalwart.container:

[Unit] Description=Stalwart Mail-Server Requires=dbpg.service After=dbpg.service [Container] Image=docker.io/stalwartlabs/mail-server:latest ContainerName=stalwart AutoUpdate=local PublishPort=10443:443 PublishPort=10025:25 PublishPort=10465:465 PublishPort=10587:587 PublishPort=10993:993 PublishPort=14190:4190 Network=netext.network Network=netint.network Timezone=Europe/Berlin Volume=/srv/podvols/stalwart-mail:/opt/stalwart-mail [Service] TimeoutStartSec=600 Restart=always [Install] WantedBy=multi-user.target default.target

Der Container soll erst nach der Datenbank gestartet werden (After=dbpg.service) und legt seine Dateien ebenfalls in einem Volume unter /srv ab. Nach dem ersten Start des Containers erfährt man mittels podman container logs stalwart das admin-Passwort und es kann mit der eigentlichen Einrichtung von Stalwart begonnen werden. Das meiste kann über die Webseite https://mail.example.com gemacht werden. Dazu verweise ich auf die sehr gute Stalwart-Dokumentation . Schön ist, dass einem Stalwart beim Hinzufügen einer Domain genau sagt, was man für DNS-Einträge zu machen hat, damit Dinge wie DMARC, DKIM, SPF und Co. funktionieren.

Ein Hinweis: Damit die Weitergabe der eigentlichen IP-Adresse gelingt, muss unbedingt unter Network SettingsProxy networks die Podman Netzwerkmaske angegeben werden, also etwa: 10.0.0.0/8.

Und unter Settings → Stores kann der Zugriff auf PostgreSQL eingerichtet werden. Einen entsprechenden Datenbankuser samt Datenbank sollte man vorher natürlich einrichten. Wie gesagt, an dieser Stelle empfiehlt es sich, die die Stalwart-Doku zu schauen.

TLS Zertifikat

Damit Stalwart u.a. STARTTLS anbieten kann, benötigt er ein TLS Zertifikat. Wie erwähnt kann Stalwart aus dem Container heraus ein Zertifikat leider nicht selbst abrufen (über DNS-01 sollte das möglich sein, was hier aber nicht behandelt wird). Zum Glück hat das aber Caddy schon erledigt und man kann sich die beiden Zertifikatsdateien (Endungen .crt und .key) von /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com nach z. B. /srv/podvols/stalwart-mail/cert/ kopieren (bitte auf die Berechtigungen achten, sodass Stalwart das Zertifikat auch nutzen kann).

Stalwart-seitig tragen wir noch in die config.toml (bei mir unter /srv/podvols/stalwart-mail/etc/) ein, wo das Zertifikat zu finden ist:

server.tls.certificate = "default" certificate.default.cert = "%{file:/opt/stalwart-mail/cert/mail.example.com.crt}%" certificate.default.default = true certificate.default.private-key = "%{file:/opt/stalwart-mail/cert/mail.example.com.key}%"

Nach einem Neustart von Stalwart ist das Zertifikat eingerichtet. Aber was passiert, wenn das Zertifikat von Caddy erneuert wird? Auch hier kommt uns Systemd zu Hilfe und wir richten einfach einen Dienst ein, der

  1. das Zertifikat bei einer Aktualisierung (Datei wird modifiziert) in das Stalwart-Verzeichnis kopiert
  2. über eine API Stalwart das Zertifikat neu einlesen lässt
  3. (optional noch eine Ntfy-Nachricht verschickt, damit man informiert ist)

Das Ganze richten wir als root ein und es bedarf zweier Dateien:

/etc/systemd/system/stalwart.path:

[Unit] Description=import certs from caddy to stalwart [Path] PathModified=/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/mail.example.com.crt [Install] WantedBy=multi-user.target

/etc/systemd/system/stalwart.service:

[Unit] Description=imports certs from caddy to stalwart [Service] Type=oneshot ExecStart=/usr/bin/cp -f /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/mail.example.com.crt /srv/podvols/stalwart-mail/cert/ ExecStart=/usr/bin/cp -f /var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.example.com/mail.example.com.key /srv/podvols/stalwart-mail/cert/ ExecStart=/usr/bin/curl -X GET -H "Accept: aplication/json" -H "Authorization: Bearer MEIN_STALWART_APIKEY" https://mail.example.com/api/reload/certificate ExecStart=/usr/bin/curl -H "Authorization: Bearer MEIN_NTFY_APIKEY" -H "Title: Stalwart" -d "SSL-Zertifikat aktualisiert" ntfy.example.com/aalsfdkjssd [Install] WantedBy=multi-user.target

Die Sache mit der Ntfy-Push-Mitteilung ist wie gesagt optional. Über die Weboberfläche von Stalwart muss man sich aber jedenfalls ein Zugriffstoken für die API holen. Als root setzt man Systemd über den neuen Dienst mit systemctl daemon-reload in Kenntnis und dann aktiviert man den Dienst noch mit systemctl enable --now stalwart.path.

Stolpersteine

Abgesehen davon, dass man bei so einem Projekt schnell vom Hundertsten (Custom-Caddy bauen) ins Tausendste (wie kommt Stalwart ans Zertifikat?) kommt, hat mich ein Problem beinahe verrückt gemacht: Nach einem Neustart des vServers hat plötzlich das Routing für die Container nicht mehr funktioniert, so dass Stalwart keine Spamdaten herunterladen und auch keine Mails verschicken konnte. Das Problem tritt wohl nur in manchen Fällen (und natürlich bei mir!) auf und ich möchte hier kurz meinen Workaround schildern.

Der Kern des Problems ist, dass nicht lange genug gewartet wird, bis der Server eine IPv4-Adresse (über DHCP) bekommt. Genaueres lässt sich im Issue 25656 nachlesen.

Mein Workaround besteht darin, dass ein Skript auf eine vorhandene IPv4-Adresse prüft. /usr/local/bin/checkipv4.sh:

#!/bin/env bash /usr/sbin/ip a show ens3 | /usr/bin/grep "inet " | /usr/bin/awk '{ print $2 }' | /usr/bin/grep -q -E "^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)/[0-9][0-9]$"

Dieses Skript wird nun in podman-user-wait-network-online.service unseres Podman-Users eingebunden. Dazu ruft man am besten systemctl --user edit podman-user-wait-network-online.service auf und editiert dann die Datei entsprechend. Letztendlich sollte das dann so ausschauen:

[…] [Unit] Description=Checks additionally for IPv4 address [Service] Type=oneshot TimeoutStartSec=90s ExecStart=/bin/sh -c 'until systemctl is-active network-online.target && /usr/local/bin/checkipv4.sh; do sleep 0.5; done' RemainAfterExit=yes […]

Fazit

Ich bitte um Nachsicht, dass der Artikel nun doch etwas umfangreicher geworden ist und womöglich trotzdem viele Fragen nicht beantwortet. So einiges wurde gar nicht angesprochen, etwa die Firewall, das Einspielen von Updates, oder das Erstellen von Backups. Auch Sicherheitsaspekte wie SELinux und Co. habe ich geflissentlich übergangen.

Vieles kann man sicherlich besser/anders machen, aber für mich ist es ein schönes Setup, das den Umzug auf einen anderen Server dank der Containerisierung ziemlich einfach macht. Das eigentliche Mailsetup lässt sich wie gesagt über die Stalwart-Doku nachlesen.

Gerne könnt ihr in die Kommentare schreiben, was ihr anders machen würdet oder wo ihr Verbesserungspotential seht.

Quellen:
Bild: Pixabay


GNU/Linux.ch ist ein Community-Projekt. Bei uns kannst du nicht nur mitlesen, sondern auch selbst aktiv werden. Wir freuen uns, wenn du mit uns über die Artikel in unseren Chat-Gruppen oder im Fediverse diskutierst. Auch du selbst kannst Autor werden. Reiche uns deinen Artikelvorschlag über das Formular auf unserer Webseite ein.