feat(hetzner): add Stalwart, restic backups, and virtualcam
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
{
|
||||
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}"
|
||||
'';
|
||||
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;
|
||||
|
||||
# Temporary internal listeners while docker-mailserver still owns 25/143/465/587/993.
|
||||
openFirewall = false;
|
||||
|
||||
settings = {
|
||||
# EHLO / hostname for the server (docs server.hostname).
|
||||
server.hostname = "mail.severijnse.eu";
|
||||
|
||||
certificate."mail-severijnse-eu" = {
|
||||
cert = "%{file:${certDir}/mail.severijnse.eu.crt}%";
|
||||
private-key = "%{file:${certDir}/mail.severijnse.eu.key}%";
|
||||
};
|
||||
|
||||
server.tls = {
|
||||
certificate = "mail-severijnse-eu";
|
||||
enable = true;
|
||||
implicit = false;
|
||||
};
|
||||
|
||||
# Temporary internal listeners (docs server/listener.md + protocol, tls.implicit override).
|
||||
server.listener = {
|
||||
"imap" = {
|
||||
bind = ["127.0.0.1:1143"];
|
||||
protocol = "imap";
|
||||
};
|
||||
"smtp-submission" = {
|
||||
bind = ["127.0.0.1:1587"];
|
||||
protocol = "smtp";
|
||||
};
|
||||
"smtp-submissions" = {
|
||||
bind = ["127.0.0.1:1465"];
|
||||
protocol = "smtp";
|
||||
tls.implicit = true;
|
||||
};
|
||||
"http-management" = {
|
||||
bind = ["127.0.0.1:8080"];
|
||||
protocol = "http";
|
||||
};
|
||||
};
|
||||
|
||||
# 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'";
|
||||
session.auth.require = [
|
||||
{"if" = "listener != 'smtp'"; "then" = true;}
|
||||
{"else" = 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 routing: /strategy.md + /routing.md:
|
||||
# local domains → local store, everything else → MX. local/mx are built-in.
|
||||
queue.strategy.route = [
|
||||
{
|
||||
"if" = "is_local_domain('', rcpt_domain)";
|
||||
"then" = "'local'";
|
||||
}
|
||||
{"else" = "'mx'";}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# 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"];
|
||||
requires = ["tlsa-update.service" "stalwart-admin-secret.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}";
|
||||
};
|
||||
};
|
||||
|
||||
# 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 = "http://127.0.0.1:8080";
|
||||
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 - -"
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user