feat(hetzner): full migration of docker-mailserver to stalwart
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
# Root-only runtime file holding the fallback-admin password hash (0600 root).
|
||||
adminHashFile = "/var/lib/stalwart/.admin-hash";
|
||||
|
||||
# Materialize the fallback-admin password hash from sops into a root-only file.
|
||||
# Materialize the fallback-admin password hash from sops into a root-only file.
|
||||
writeAdminHash = pkgs.writeShellScript "stalwart-write-admin-hash" ''
|
||||
set -euo pipefail
|
||||
install -d -o root -g root -m 0755 "$(dirname ${adminHashFile})"
|
||||
@@ -22,6 +22,18 @@
|
||||
| tr -d '\n' > "${adminHashFile}"
|
||||
chmod 0600 "${adminHashFile}"
|
||||
'';
|
||||
|
||||
# The outbound DKIM key is the existing opendkim key from docker-mailserver
|
||||
# (selector "mail", domain severijnse.eu, PKCS#8 RSA 2048). Install it into the
|
||||
# stalwart-owned data dir so the server can read it and it is covered by the
|
||||
# /var/lib/stalwart backup.
|
||||
dkimKeyDir = "/var/lib/stalwart/dkim/severijnse.eu";
|
||||
dkimKeySrc = "/home/admin/dms/config/opendkim/keys/severijnse.eu/mail.private";
|
||||
writeDkimKey = pkgs.writeShellScript "stalwart-write-dkim-key" ''
|
||||
set -euo pipefail
|
||||
install -d -o stalwart -g stalwart -m 0750 ${dkimKeyDir}
|
||||
install -o stalwart -g stalwart -m 0640 ${dkimKeySrc} ${dkimKeyDir}/mail.private
|
||||
'';
|
||||
in {
|
||||
# The hetzner host is built with nixos-24.05, which ships its own
|
||||
# `services.stalwart-mail` module (for the old 0.8.x package). We want the
|
||||
@@ -41,16 +53,27 @@ in {
|
||||
stateVersion = "26.05";
|
||||
package = unstablePkgs.stalwart;
|
||||
|
||||
# Temporary internal listeners while docker-mailserver still owns 25/143/465/587/993.
|
||||
# Public listeners. The host firewall (networking.nix) already allows
|
||||
# 25/143/465/587/993, so openFirewall stays off: the module would also open
|
||||
# every other listener port incl. the 8080 webadmin.
|
||||
openFirewall = false;
|
||||
|
||||
settings = {
|
||||
# EHLO / hostname for the server (docs server.hostname).
|
||||
server.hostname = "mail.severijnse.eu";
|
||||
|
||||
# Public origin the JMAP/webadmin API is served from, advertised in the
|
||||
# JMAP session (docs server/core/network.md "http.url"). Value must be a
|
||||
# JScript expression, hence the single-quoted string literal. Without this
|
||||
# Stalwart advertises http://mail.severijnse.eu:8080, which the browser
|
||||
# refuses to fetch and breaks Bulwark webmail.
|
||||
http.url = "'https://mail.severijnse.eu'";
|
||||
|
||||
certificate."mail-severijnse-eu" = {
|
||||
cert = "%{file:${certDir}/mail.severijnse.eu.crt}%";
|
||||
private-key = "%{file:${certDir}/mail.severijnse.eu.key}%";
|
||||
# Docs server/tls/certificates.md: used when the client sends no SNI.
|
||||
default = true;
|
||||
};
|
||||
|
||||
server.tls = {
|
||||
@@ -59,18 +82,32 @@ in {
|
||||
implicit = false;
|
||||
};
|
||||
|
||||
# Temporary internal listeners (docs server/listener.md + protocol, tls.implicit override).
|
||||
# Public listeners (docs server/listener.md + protocol, tls.implicit override).
|
||||
# Bind "[::]:port" for dual-stack IPv4+IPv6 (docs: "to bind a listener to
|
||||
# all interfaces"); listing both 0.0.0.0 and [::] makes the [::] bind fail
|
||||
# with EADDRINUSE on kernels with net.ipv6.bindv6only=0.
|
||||
# 143/587 use STARTTLS (server.tls.implicit=false default), 993/465 the
|
||||
# implicit-TLS variants, 25 the plain (STARTTLS) MX port.
|
||||
server.listener = {
|
||||
"imap" = {
|
||||
bind = ["127.0.0.1:1143"];
|
||||
bind = ["[::]:143"];
|
||||
protocol = "imap";
|
||||
};
|
||||
"imaps" = {
|
||||
bind = ["[::]:993"];
|
||||
protocol = "imap";
|
||||
tls.implicit = true;
|
||||
};
|
||||
"smtp" = {
|
||||
bind = ["[::]:25"];
|
||||
protocol = "smtp";
|
||||
};
|
||||
"smtp-submission" = {
|
||||
bind = ["127.0.0.1:1587"];
|
||||
bind = ["[::]:587"];
|
||||
protocol = "smtp";
|
||||
};
|
||||
"smtp-submissions" = {
|
||||
bind = ["127.0.0.1:1465"];
|
||||
bind = ["[::]:465"];
|
||||
protocol = "smtp";
|
||||
tls.implicit = true;
|
||||
};
|
||||
@@ -80,10 +117,29 @@ in {
|
||||
};
|
||||
};
|
||||
|
||||
# Auth per inbound/auth.md: not required on the plain SMTP listener (port 25),
|
||||
# required everywhere else (IMAP + submission). Directory is the module default "internal".
|
||||
session.auth.mechanisms = "[plain]";
|
||||
session.auth.directory = "'internal'";
|
||||
# Auth per docs mta/inbound/auth.md (AUTH stage): authentication is
|
||||
# disabled on the plain SMTP listener (port 25) and required everywhere
|
||||
# else (IMAP + submission). Only offer PLAIN/LOGIN over TLS, so clear-text
|
||||
# listeners (143/587 pre-STARTTLS) advertise no SASL mechanisms. This
|
||||
# mirrors the code defaults (crates/common/src/config/smtp/session.rs).
|
||||
# The JMAP/webadmin "http-management" listener (bind 127.0.0.1:8080) is
|
||||
# plain HTTP and reachable only from localhost, where bulwark connects;
|
||||
# its basic-auth uses the "plain" mechanism, so it must be exempted from
|
||||
# the is_tls gate or webmail logins fail with "Authentication not allowed".
|
||||
session.auth.mechanisms = [
|
||||
{
|
||||
"if" = "local_port != 25 && (is_tls || listener == 'http-management')";
|
||||
"then" = "[plain, login]";
|
||||
}
|
||||
{"else" = false;}
|
||||
];
|
||||
session.auth.directory = [
|
||||
{
|
||||
"if" = "listener != 'smtp'";
|
||||
"then" = "'internal'";
|
||||
}
|
||||
{"else" = false;}
|
||||
];
|
||||
session.auth.require = [
|
||||
{
|
||||
"if" = "listener != 'smtp'";
|
||||
@@ -92,6 +148,43 @@ in {
|
||||
{"else" = false;}
|
||||
];
|
||||
|
||||
# Outbound DKIM signing (docs mta/authentication/dkim/sign): sign with the
|
||||
# "mail" signature on everything submitted via non-25 listeners; do not sign
|
||||
# inbound mail received on the plain "smtp" listener.
|
||||
auth.dkim.sign = [
|
||||
{
|
||||
"if" = "listener != 'smtp'";
|
||||
"then" = "['mail']";
|
||||
}
|
||||
{"else" = false;}
|
||||
];
|
||||
|
||||
# ARC sealing uses the same "mail" signature (docs mta/authentication/arc).
|
||||
# The code default ('rsa-' + report.domain) would reference a signature
|
||||
# name that does not exist and log "ARC sealer not found".
|
||||
auth.arc.seal = "'mail'";
|
||||
|
||||
# Every *downstream* sign rule defaults to signing with
|
||||
# ['rsa-<report.domain>', 'ed25519-<report.domain>'] (queue.rs/report.rs),
|
||||
# names that do not exist here and log "DKIM signer not found" on DSNs and
|
||||
# reports. Point them all at the real "mail" signature.
|
||||
report.dsn.sign = "['mail']";
|
||||
report.spf.sign = "['mail']";
|
||||
report.dmarc.sign = "['mail']";
|
||||
report.dmarc.aggregate.sign = "['mail']";
|
||||
report.tls.aggregate.sign = "['mail']";
|
||||
|
||||
# Reuse the existing opendkim key (selector mail) so no DNS change is needed.
|
||||
signature.mail = {
|
||||
private-key = "%{file:/var/lib/stalwart/dkim/severijnse.eu/mail.private}%";
|
||||
domain = "severijnse.eu";
|
||||
selector = "mail";
|
||||
headers = ["From" "To" "Date" "Subject" "Message-Id"];
|
||||
algorithm = "rsa-sha256";
|
||||
canonicalization = "relaxed/relaxed";
|
||||
set-body-length = false;
|
||||
};
|
||||
|
||||
# Fallback admin (auth/authorization/administrator.md): bootstrap admin with
|
||||
# every permission, used to create the internal-directory accounts via the
|
||||
# management REST API / CLI. Secret is a SHA-512-crypt hash, injected via
|
||||
@@ -102,8 +195,10 @@ in {
|
||||
secret = "%{file:/run/credentials/stalwart.service/stalwart-admin}%";
|
||||
};
|
||||
|
||||
# Route docs routing: /strategy.md + /routing.md:
|
||||
# local domains → local store, everything else → MX. local/mx are built-in.
|
||||
# Route docs mta/outbound/routing.md + strategy.md: local domains → local
|
||||
# store, everything else → MX. The strategy names are defined explicitly
|
||||
# (docs define queue.route.mx/local; the built-in fallback in
|
||||
# core.rs:get_route_or_default only kicks in for undeclared names).
|
||||
queue.strategy.route = [
|
||||
{
|
||||
"if" = "is_local_domain('', rcpt_domain)";
|
||||
@@ -111,6 +206,13 @@ in {
|
||||
}
|
||||
{"else" = "'mx'";}
|
||||
];
|
||||
queue.route."mx" = {
|
||||
type = "mx";
|
||||
ip-lookup = "ipv4_then_ipv6";
|
||||
};
|
||||
queue.route."local" = {
|
||||
type = "local";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -118,8 +220,8 @@ in {
|
||||
# ProtectSystem=strict). The TLS key tlsa-updater installs is 0640 root:root;
|
||||
# regrant it to the stalwart group after every cert sync so stalwart can serve TLS.
|
||||
systemd.services.stalwart = {
|
||||
after = ["tlsa-update.service" "stalwart-admin-secret.service"];
|
||||
requires = ["tlsa-update.service" "stalwart-admin-secret.service"];
|
||||
after = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"];
|
||||
requires = ["tlsa-update.service" "stalwart-admin-secret.service" "stalwart-dkim.service"];
|
||||
};
|
||||
|
||||
# Make the management CLI available for account creation and maildir import
|
||||
@@ -142,6 +244,17 @@ in {
|
||||
};
|
||||
};
|
||||
|
||||
# Install the outbound DKIM key into the stalwart data dir before the service starts.
|
||||
systemd.services.stalwart-dkim = {
|
||||
description = "Install Stalwart outbound DKIM key";
|
||||
wantedBy = ["multi-user.target"];
|
||||
before = ["stalwart.service"];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${writeDkimKey}";
|
||||
};
|
||||
};
|
||||
|
||||
# LoadCredential: expose the materialized hash to stalwart only at
|
||||
# /run/credentials/stalwart.service/stalwart-admin (see `credentials` option
|
||||
# in the upstream module; the value is the source path on disk).
|
||||
@@ -172,7 +285,11 @@ in {
|
||||
"/var/lib/bulwark:/app/data:Z"
|
||||
];
|
||||
environment = {
|
||||
JMAP_SERVER_URL = "http://127.0.0.1:8080";
|
||||
# JMAP_SERVER_URL is the public origin (the browser uses it verbatim for
|
||||
# /.well-known/jmap + the session apiUrl). HOSTNAME stays 127.0.0.1 so
|
||||
# Next.js binds to loopback (caddy reverse_proxy's 127.0.0.1:3002); binding
|
||||
# to the public hostname made the container unreachable for caddy (502).
|
||||
JMAP_SERVER_URL = "https://mail.severijnse.eu";
|
||||
HOSTNAME = "127.0.0.1";
|
||||
PORT = "3002";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user