Add mail server configuration with Stalwart Mail, secrets management, and Traefik integration
All checks were successful
Build Nix modules (dry-run) / build-modules (push) Successful in 4m0s

This commit is contained in:
Root User 2026-02-15 15:47:54 +01:00
parent a91e60eb70
commit fe289e0600
Signed by: root
GPG key ID: 087F0A95E5766D72
8 changed files with 288 additions and 1 deletions

View file

@ -35,3 +35,7 @@ creation_rules:
- age:
- *admin_christine
- *server_build_machine
- path_regex: nix-system-configs/secrets/mail/[^/]+\.(yaml|json|env|ini)$
key_groups:
- age:
- *admin_christine

View file

@ -16,6 +16,7 @@ configs=(
"nixos-traefik"
"nixos-build-machine"
"nixos-logs"
"nixos-mail"
)
if [[ -n "$1" ]]; then

View file

@ -57,6 +57,11 @@
inherit system;
modules = [./nix-system-configs/modules/system/gramethus.nix];
};
"nixos-mail" = nixpkgs.lib.nixosSystem {
inherit system;
modules = [./nix-system-configs/modules/system/mail-server.nix];
};
};
};
}

View file

@ -0,0 +1,58 @@
{
config,
pkgs,
lib,
...
}: {
imports = let
# replace this with an actual commit id or tag
commit = "17eea6f3816ba6568b8c81db8a4e6ca438b30b7c";
in [
"${builtins.fetchTarball {
url = "https://github.com/Mic92/sops-nix/archive/${commit}.tar.gz";
# replace this with an actual hash
sha256 = "ktjWTq+D5MTXQcL9N6cDZXUf9kX8JBLLBLT0ZyOTSYY=";
}}/modules/sops"
];
# This will add secrets.yml to the nix store
# You can avoid this by adding a string to the full path instead, i.e.
# sops.defaultSopsFile = "/root/.sops/secrets/example.yaml";
sops.defaultSopsFile = ../../secrets/mail/secrets.yaml;
# This will automatically import SSH keys as age keys
#sops.age.sshKeyPaths = ["/home/nixosdd/.ssh/id_ed25519"];
# This is using an age key that is expected to already be in the filesystem
sops.age.keyFile = "/var/lib/sops-nix/key.txt";
# This will generate a new key if the key specified above does not exist
sops.age.generateKey = true;
# Export individual WireGuard keys from the SOPS YAML as text secrets so they
# are available both as strings and as files (.path)
sops.secrets."admin-password" = {
format = "yaml";
sopsFile = ../../secrets/mail/secrets.yaml;
owner = "root";
mode = "0400";
};
sops.secrets."board-member-password" = {
format = "yaml";
sopsFile = ../../secrets/mail/secrets.yaml;
owner = "root";
mode = "0400";
};
sops.secrets."cloudflare-dns-token" = {
format = "yaml";
sopsFile = ../../secrets/mail/secrets.yaml;
owner = "root";
mode = "0444";
};
sops.secrets."cloudflare-username" = {
format = "yaml";
sopsFile = ../../secrets/mail/secrets.yaml;
owner = "root";
mode = "0444";
};
}

View file

@ -35,6 +35,7 @@ in {
(choose [./modules/bootloader/seabios-assigned-iso-at-birth.nix ../bootloader/seabios-assigned-iso-at-birth.nix])
(choose [./modules/lix-default.nix ../lix-default.nix])
(choose [./modules/secrets-config/sops-the-blank-system.nix ../secrets-config/sops-the-blank-system.nix])
(choose [./modules/toolsets/grafana_metric.nix ../toolsets/grafana_metric.nix])
];
config = {

View file

@ -0,0 +1,135 @@
{
config,
pkgs,
lib,
...
}: let
choose = paths: lib.findFirst builtins.pathExists null paths;
in {
options.local = {
hostname = lib.mkOption {
type = lib.types.str;
default = "nixos-default";
description = "System hostname";
};
username = lib.mkOption {
type = lib.types.str;
default = "user";
description = "Primary user username";
};
userDescription = lib.mkOption {
type = lib.types.str;
default = "NixOS User";
description = "Primary user description";
};
address = lib.mkOption {
type = lib.types.str;
default = "10.1.1.100";
description = "Static IP address";
};
};
imports = lib.filter (x: x != null) [
(choose [./modules/desktop-manager/sway_greetd_homemanager.nix ../desktop-manager/sway_greetd_homemanager.nix])
(choose [./modules/local/hostname_username.nix ../local/hostname_username.nix])
(choose [./modules/local/networking_local.nix ../local/networking_local.nix])
(choose [./modules/bootloader/seabios-assigned-proxmox-at-birth.nix ../bootloader/seabios-assigned-proxmox-at-birth.nix])
(choose [./modules/lix-default.nix ../lix-default.nix])
(choose [./modules/secrets-config/sops-the-blank-system.nix ../secrets-config/sops-the-blank-system.nix])
(choose [./modules/toolsets/grafana_metric.nix ../toolsets/grafana_metric.nix])
(choose [./modules/secrets-config/sops-mail.nix ../secrets-config/sops-mail.nix])
];
config = {
local.hostname = "nixos-mailserver";
local.username = "mailprg";
local.userDescription = "NixOS PRG Mailing Service";
local.address = "10.1.1.15";
system.stateVersion = "25.11";
services.stalwart-mail = {
enable = true;
openFirewall = true;
settings = {
server = {
hostname = "mail.prg-radio.org";
tls = {
enable = true;
implicit = true;
};
listener = {
smtp = {
protocol = "smtp";
bind = "[::]:25";
proxy.trusted-networks = [
"10.1.1.250/32"
];
};
submissions = {
bind = "[::]:465";
protocol = "smtp";
tls.implicit = true;
# Also trust proxy for SMTPS
proxy.trusted-networks = ["10.1.1.250/32"];
};
imaps = {
bind = "[::]:993";
protocol = "imap";
tls.implicit = true;
proxy.trusted-networks = ["10.1.1.250/32"];
};
jmap = {
bind = "[::]:8080";
url = "https://mail.prg-radio.org";
protocol = "http";
};
management = {
bind = ["127.0.0.1:8080"];
protocol = "http";
};
};
};
lookup.default = {
hostname = "mail.prg-radio.org";
domain = "prg-radio.org";
};
acme."letsencrypt" = {
directory = "https://acme-v02.api.letsencrypt.org/directory";
challenge = "dns-01";
contact = config.sops.secrets."cloudflare-username".path;
domains = ["prg-radio.org" "mail.prg-radio.org"];
provider = "cloudflare";
secret = config.sops.secrets."cloudflare-dns-token".path;
};
session.auth = {
mechanisms = "[plain]";
directory = "'in-memory'";
};
storage.directory = "in-memory";
session.rcpt.directory = "'in-memory'";
directory."imap".lookup.domains = ["prg-radio.org"];
directory."in-memory" = {
type = "memory";
principals = [
{
class = "individual";
name = "Polyteknisk Radiogruppe Board Member";
secret = config.sops.secrets."board-member-password".path;
email = ["board@prg-radio.org"];
}
{
class = "individual";
name = "postmaster";
secret = config.sops.secrets."board-member-password".path;
email = ["postmaster@prg-radio.org"];
}
];
};
authentication.fallback-admin = {
user = "admin";
secret = config.sops.secrets."admin-password".path;
};
};
};
};
}

View file

@ -265,6 +265,20 @@ in {
teamspeak-data = {
address = "[::]:30033/tcp";
};
# Mail entrypoints: plain SMTP, implicit SMTPS (465) and IMAPS (993)
#smtp = {
# # plain SMTP (port 25) - TCP passthrough to backend
# address = "[::]:25";
# };
smtps = {
# implicit TLS SMTP (port 465) - passthrough to backend
address = "[::]:465";
};
imaps = {
# implicit TLS IMAP (port 993) - passthrough to backend
address = "[::]:993";
};
};
log = {
level = "INFO";
@ -350,6 +364,14 @@ in {
tls = {};
middlewares = ["anubisForwardAuth"];
};
# Mail HTTP (JMAP / web) - terminate TLS at Traefik and forward to Stalwart JMAP HTTP listener
mail = {
rule = "Host(`mail.prg-radio.org`)";
service = "mail-jmap";
entryPoints = ["websecure"];
tls = {certresolver = "acme";};
};
};
http.services = {
@ -387,6 +409,11 @@ in {
{url = "http://10.1.1.10:3000";}
];
};
# Mail JMAP HTTP backend
mail-jmap.loadBalancer = {
servers = [{url = "http://10.1.1.15:8080";}];
};
};
# TCP routing for TeamSpeak
@ -424,6 +451,30 @@ in {
passthrough = true;
};
};
# Mail TCP routers: SMTP (25), SMTPS (465 implicit TLS), IMAPS (993 implicit TLS)
#mail-smtp = {
# # catch-all TCP router for port 25
# rule = "HostSNI(`*`)";
# service = "mail-smtp";
# entryPoints = ["smtp"];
# };
mail-smtps = {
# SMTPS implicit TLS - passthrough to backend
rule = "HostSNI(`*`)";
service = "mail-smtps";
entryPoints = ["smtps"];
tls = {passthrough = true;};
};
mail-imaps = {
# IMAPS implicit TLS - passthrough to backend
rule = "HostSNI(`*`)";
service = "mail-imaps";
entryPoints = ["imaps"];
tls = {passthrough = true;};
};
};
tcp.services = {
@ -442,6 +493,19 @@ in {
{address = "10.1.1.248:12244";}
];
};
# Mail TCP services
# mail-smtp.loadBalancer = {
# servers = [ { address = "10.1.1.15:25"; } ];
# };
mail-smtps.loadBalancer = {
servers = [{address = "10.1.1.15:465";}];
};
mail-imaps.loadBalancer = {
servers = [{address = "10.1.1.15:993";}];
};
};
# UDP routing for TeamSpeak voice

View file

@ -0,0 +1,19 @@
admin-password: ENC[AES256_GCM,data:o8nD9CDNzufxVjfH7TqUY4QO6Xkz1xbpFu74jMVRR57NDKpKsg3ijRmBvvq1/QuJ2TM=,iv:xVGjzrRFpCyjriW6yB/UjuGiNhK4oeBiDA+Pk/BTOWc=,tag:H/ZuqffTDGsGTWTTKplvvA==,type:str]
board-member-password: ENC[AES256_GCM,data:8KLyqyGFi/ehvbNU6HsWilMO42UnXYL6puc/OOPgMDiU2fuDM2o7TibUCn1BXi9UyiE=,iv:UyLOFSARK/5p/zin7vqQic9C5qlryd/O/mjcw3eZ3ao=,tag:N2RJC6nZC+V3XoAw/eWYfA==,type:str]
cloudflare-dns-token: ENC[AES256_GCM,data:W0S9WWmhQMwjKbKI60vBjYlSb0chZUYcVs3A4DsVRfJwErcIqDWoJg==,iv:nRH8hLvCavydVC16M93N+r/t9WwNY4339CE4zGSINsk=,tag:yIUKs/XB3arhApGeik6p1A==,type:str]
cloudflare-username: ENC[AES256_GCM,data:5QCWOZcv6RKlk1/5LWjjCW0m,iv:9VzhRDlLq36pMfv0tu7POxheLFKdXP8tLLiKlTOr/30=,tag:MiijON1+mKJj6VMdnaJTkA==,type:str]
sops:
age:
- recipient: age1746rvsvsc3snxfl7cndm222wd5kck4aqj3x7nednlegq0gdjhfcqx0qv7m
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBseTM3bTJtamFBVzRkOVpp
bEZNQlJoNUVjTFd0NGl3VktmUUdHWi9wMTM0CnE3bTh5QmM1KzVqTnI3RHdmbXhN
a0NWRkthbG9OZ3pZMEZkU3hjWUNyd1UKLS0tIE5KdXgzQmRRWTVBbDFTSEkzQW9o
ZWE0ci9wSmhpWmc1OHZwb09aTjk1TUUKitmILkhief6sapPh3gZAEDsaHqcv3se0
+6w+hs05ChkXHQ+JlXOTznd5ZNS4hwOAk5KOconNbauBaKWDplnHhA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-02-15T14:13:19Z"
mac: ENC[AES256_GCM,data:7hFjuvictSbcXLqXwG0VgWErKJpFsy1PfDyepQQXpszpMT4Z/BwvXlk4ppKo8C0PaCLv2qi86yBmFm/O6xUBhsMEFWYHQ+mJpYtqLX0GDvj1cn4LwDEnRa+2SiHkkZeHSwrtOHCBw8vE2R2sXBaNMkUoSXkcQ4lPS6YjulpO1vw=,iv:5aZxEnPymcvNpsUyGvvRI3o7hnfExSFWlBrzoIhQkFQ=,tag:6JJGvBL9XeiPw+TdN2qEgA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.11.0