From fe289e0600699af0547016334cf251cc0ab9828c Mon Sep 17 00:00:00 2001 From: Christine Elisabeth Koppel Date: Sun, 15 Feb 2026 15:47:54 +0100 Subject: [PATCH] Add mail server configuration with Stalwart Mail, secrets management, and Traefik integration --- .sops.yaml | 6 +- dry_run.zsh | 1 + flake.nix | 5 + .../modules/secrets-config/sops-mail.nix | 58 ++++++++ .../system/blank_system_USE_THIS_AS_COPY.nix | 1 + .../modules/system/mail-server.nix | 135 ++++++++++++++++++ nix-system-configs/modules/system/traefik.nix | 64 +++++++++ nix-system-configs/secrets/mail/secrets.yaml | 19 +++ 8 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 nix-system-configs/modules/secrets-config/sops-mail.nix create mode 100644 nix-system-configs/modules/system/mail-server.nix create mode 100644 nix-system-configs/secrets/mail/secrets.yaml diff --git a/.sops.yaml b/.sops.yaml index 4fdecd4..fc66233 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -34,4 +34,8 @@ creation_rules: key_groups: - age: - *admin_christine - - *server_build_machine \ No newline at end of file + - *server_build_machine + - path_regex: nix-system-configs/secrets/mail/[^/]+\.(yaml|json|env|ini)$ + key_groups: + - age: + - *admin_christine \ No newline at end of file diff --git a/dry_run.zsh b/dry_run.zsh index 9920b80..f350dab 100755 --- a/dry_run.zsh +++ b/dry_run.zsh @@ -16,6 +16,7 @@ configs=( "nixos-traefik" "nixos-build-machine" "nixos-logs" + "nixos-mail" ) if [[ -n "$1" ]]; then diff --git a/flake.nix b/flake.nix index c43d220..be591ff 100644 --- a/flake.nix +++ b/flake.nix @@ -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]; + }; }; }; } diff --git a/nix-system-configs/modules/secrets-config/sops-mail.nix b/nix-system-configs/modules/secrets-config/sops-mail.nix new file mode 100644 index 0000000..499d620 --- /dev/null +++ b/nix-system-configs/modules/secrets-config/sops-mail.nix @@ -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"; + }; +} diff --git a/nix-system-configs/modules/system/blank_system_USE_THIS_AS_COPY.nix b/nix-system-configs/modules/system/blank_system_USE_THIS_AS_COPY.nix index dc2d50c..6311c3d 100644 --- a/nix-system-configs/modules/system/blank_system_USE_THIS_AS_COPY.nix +++ b/nix-system-configs/modules/system/blank_system_USE_THIS_AS_COPY.nix @@ -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 = { diff --git a/nix-system-configs/modules/system/mail-server.nix b/nix-system-configs/modules/system/mail-server.nix new file mode 100644 index 0000000..257f6a7 --- /dev/null +++ b/nix-system-configs/modules/system/mail-server.nix @@ -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; + }; + }; + }; + }; +} diff --git a/nix-system-configs/modules/system/traefik.nix b/nix-system-configs/modules/system/traefik.nix index b0017a4..7275a45 100644 --- a/nix-system-configs/modules/system/traefik.nix +++ b/nix-system-configs/modules/system/traefik.nix @@ -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 diff --git a/nix-system-configs/secrets/mail/secrets.yaml b/nix-system-configs/secrets/mail/secrets.yaml new file mode 100644 index 0000000..a085774 --- /dev/null +++ b/nix-system-configs/secrets/mail/secrets.yaml @@ -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