306 lines
12 KiB
Nix
306 lines
12 KiB
Nix
{
|
|
lib,
|
|
pkgs,
|
|
unstablePkgs,
|
|
...
|
|
}: let
|
|
# Caddy's dist dir (see tlsa-updater.nix): cert 0644, key 0640 root:root.
|
|
# The Stalwart service runs as "stalwart"; grant it read access to the key.
|
|
certDir = "/var/lib/caddy/certificates/acme-v02.api.letsencrypt.org-directory/mail.severijnse.eu";
|
|
# sops-encrypted secrets (single file holds all service secrets), same as backup.nix.
|
|
secretsFile = ../../secrets/secrets.yaml;
|
|
# 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.
|
|
writeAdminHash = pkgs.writeShellScript "stalwart-write-admin-hash" ''
|
|
set -euo pipefail
|
|
install -d -o root -g root -m 0755 "$(dirname ${adminHashFile})"
|
|
${pkgs.sops}/bin/sops \
|
|
--decrypt --extract '["stalwart_admin_hash"]' \
|
|
--input-type yaml --output-type yaml ${secretsFile} \
|
|
| 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
|
|
# 0.15.5 module from the locked nixpkgs-unstable instead, so we must exclude
|
|
# the 24.05 default module (which defines the same option namespace) to avoid
|
|
# the rename-based infinite recursion, and import the unstable one in its place.
|
|
disabledModules = [
|
|
"services/mail/stalwart-mail.nix"
|
|
];
|
|
|
|
imports = [
|
|
"${unstablePkgs.path}/nixos/modules/services/mail/stalwart.nix"
|
|
];
|
|
|
|
services.stalwart = {
|
|
enable = true;
|
|
stateVersion = "26.05";
|
|
package = unstablePkgs.stalwart;
|
|
|
|
# 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 = {
|
|
certificate = "mail-severijnse-eu";
|
|
enable = true;
|
|
implicit = false;
|
|
};
|
|
|
|
# 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 = ["[::]:143"];
|
|
protocol = "imap";
|
|
};
|
|
"imaps" = {
|
|
bind = ["[::]:993"];
|
|
protocol = "imap";
|
|
tls.implicit = true;
|
|
};
|
|
"smtp" = {
|
|
bind = ["[::]:25"];
|
|
protocol = "smtp";
|
|
};
|
|
"smtp-submission" = {
|
|
bind = ["[::]:587"];
|
|
protocol = "smtp";
|
|
};
|
|
"smtp-submissions" = {
|
|
bind = ["[::]:465"];
|
|
protocol = "smtp";
|
|
tls.implicit = true;
|
|
};
|
|
"http-management" = {
|
|
bind = ["127.0.0.1:8080"];
|
|
protocol = "http";
|
|
};
|
|
};
|
|
|
|
# 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'";
|
|
"then" = true;
|
|
}
|
|
{"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
|
|
# LoadCredential (services.stalwart.credentials) so no secret lands in the
|
|
# Nix store.
|
|
authentication."fallback-admin" = {
|
|
user = "admin";
|
|
secret = "%{file:/run/credentials/stalwart.service/stalwart-admin}%";
|
|
};
|
|
|
|
# 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)";
|
|
"then" = "'local'";
|
|
}
|
|
{"else" = "'mx'";}
|
|
];
|
|
queue.route."mx" = {
|
|
type = "mx";
|
|
ip-lookup = "ipv4_then_ipv6";
|
|
};
|
|
queue.route."local" = {
|
|
type = "local";
|
|
};
|
|
};
|
|
};
|
|
|
|
# The module's service runs as user/group "stalwart" (ProtectHome=true,
|
|
# 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" "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
|
|
# (docs management/cli/). Version-pinned to the locked unstable nixpkgs.
|
|
environment.systemPackages = [unstablePkgs.stalwart-cli];
|
|
|
|
# Materialize the fallback-admin password hash from sops before stalwart starts.
|
|
# The admin hash is a SHA-512-crypt value, safe to pass through a root-only file.
|
|
systemd.services.stalwart-admin-secret = {
|
|
description = "Materialize Stalwart fallback-admin password hash from sops";
|
|
wantedBy = ["multi-user.target"];
|
|
before = ["stalwart.service"];
|
|
# The age key lives in /etc/age/keys.txt; the service must know where it is
|
|
# and needs a HOME for age to report its user config directory.
|
|
environment.SOPS_AGE_KEY_FILE = "/etc/age/keys.txt";
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
Environment = ["HOME=/root"];
|
|
ExecStart = "${writeAdminHash}";
|
|
};
|
|
};
|
|
|
|
# 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).
|
|
services.stalwart.credentials.stalwart-admin = adminHashFile;
|
|
|
|
systemd.services.stalwart-cert-perm = {
|
|
description = "Grant stalwart read access to its TLS private key";
|
|
after = ["tlsa-update.service" "stalwart.service"];
|
|
partOf = ["tlsa-update.service"];
|
|
wantedBy = ["multi-user.target"];
|
|
path = [pkgs.coreutils];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = "${pkgs.coreutils}/bin/chgrp stalwart ${certDir}/mail.severijnse.eu.key";
|
|
ExecStartPost = "${pkgs.coreutils}/bin/chmod 0640 ${certDir}/mail.severijnse.eu.key";
|
|
};
|
|
};
|
|
|
|
# Bulwark webmail (self-hosted JMAP webmail for Stalwart). Serving on
|
|
# mail.severijnse.eu behind Caddy (see caddy.nix). It connects to Stalwart's
|
|
# JMAP endpoint at 127.0.0.1:8080, so uses host networking. Next.js defaults
|
|
# to POST_SIZE/etc via env; JMAP_SERVER_URL points at the Stalwart http
|
|
# listener which serves JMAP at /jmap.
|
|
virtualisation.oci-containers.containers.bulwark = {
|
|
image = "ghcr.io/bulwarkmail/webmail:latest";
|
|
autoStart = true;
|
|
volumes = [
|
|
"/var/lib/bulwark:/app/data:Z"
|
|
];
|
|
environment = {
|
|
# 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";
|
|
};
|
|
extraOptions = [
|
|
"--network=host"
|
|
"--label=com.centurylinklabs.watchtower.enable=true"
|
|
];
|
|
};
|
|
|
|
systemd.tmpfiles.rules = [
|
|
"d /var/lib/bulwark 0755 1001 1001 - -"
|
|
];
|
|
}
|