{ config, pkgs, lib, ... }: let # Public IP of the home network where the k8s cluster ingress lives. # Keep in sync with dns/domains/rubenhensen.nl.yaml. homeIP = "62.41.86.27"; # Hosts tunneled to the home k8s cluster. Each entry gets: # * an SNI map entry for TCP passthrough on :443 # * an HTTP vhost on :80 that reverse-proxies to the cluster # The cluster's nginx-ingress terminates TLS with cert-manager. tunneledHosts = [ "rss.rubenhensen.nl" "authentik.rubenhensen.nl" "vault.rubenhensen.nl" "ynab.rubenhensen.nl" "argocd.rubenhensen.nl" "ha.rubenhensen.nl" "longhorn.rubenhensen.nl" "lingo.rubenhensen.nl" "blog.rubenhensen.nl" "serpbear.rubenhensen.nl" "git.rubenhensen.nl" ]; sniMapEntries = lib.concatMapStringsSep "\n" (h: " ${h} ${homeIP}:443;") tunneledHosts; tunneledVhosts = lib.listToAttrs (map (h: { name = h; value = { listen = [ { addr = "0.0.0.0"; port = 80; } { addr = "[::]"; port = 80; } ]; locations."/".proxyPass = "http://${homeIP}"; }; }) tunneledHosts); in { # ────────────────────────────────────────────── # Firewall # ────────────────────────────────────────────── networking.firewall.allowedTCPPorts = [ 25 # SMTP 465 # SMTP submissions (implicit TLS) 587 # SMTP submission (STARTTLS) 993 # IMAP (implicit TLS) 4190 # ManageSieve 443 # HTTPS (webadmin) 80 # HTTP (ACME) ]; # ────────────────────────────────────────────── # ACME / Let's Encrypt # ────────────────────────────────────────────── security.acme = { acceptTerms = true; defaults.email = "admin@rubenhensen.nl"; certs."mail.rubenhensen.nl" = { group = "stalwart-mail"; reloadServices = [ "stalwart-mail" ]; webroot = "/var/lib/acme/acme-challenge"; extraDomainNames = [ "autoconfig.rubenhensen.nl" "autodiscover.rubenhensen.nl" "rubenhensen.nl" ]; }; }; systemd.tmpfiles.rules = [ "d /var/lib/acme/acme-challenge 0755 acme acme -" ]; # Serve ACME challenges via nginx on port 80. # Also reverse-proxy tunneled hosts to the home k8s cluster, and do # SNI-based TCP passthrough on 443 so the cluster's cert-manager keeps # owning the TLS certificate for those hosts. services.nginx = { enable = true; recommendedProxySettings = true; # SNI passthrough on 443: # - mail.rubenhensen.nl (and anything else) → local stalwart on 8443 # - tunneled hosts → home cluster ingress on 443 streamConfig = '' map $ssl_preread_server_name $tunnel_upstream { ${sniMapEntries} default 127.0.0.1:8443; } server { listen 443; listen [::]:443; proxy_pass $tunnel_upstream; ssl_preread on; } ''; # Port 80: tunneled hosts reverse-proxy to the home cluster so its # nginx-ingress handles HTTP→HTTPS redirects and cert-manager # HTTP-01 ACME challenges. Mail-related hosts serve ACME challenges # locally for stalwart's cert and redirect everything else to HTTPS. virtualHosts = tunneledVhosts // { "mail.rubenhensen.nl" = { listen = [ { addr = "0.0.0.0"; port = 80; } { addr = "[::]"; port = 80; } ]; locations."/.well-known/acme-challenge/".root = "/var/lib/acme/acme-challenge"; locations."/".return = "301 https://$host$request_uri"; }; "autoconfig.rubenhensen.nl" = { listen = [ { addr = "0.0.0.0"; port = 80; } { addr = "[::]"; port = 80; } ]; locations."/.well-known/acme-challenge/".root = "/var/lib/acme/acme-challenge"; locations."/".return = "301 https://$host$request_uri"; }; "autodiscover.rubenhensen.nl" = { listen = [ { addr = "0.0.0.0"; port = 80; } { addr = "[::]"; port = 80; } ]; locations."/.well-known/acme-challenge/".root = "/var/lib/acme/acme-challenge"; locations."/".return = "301 https://$host$request_uri"; }; "rubenhensen.nl" = { listen = [ { addr = "0.0.0.0"; port = 80; } { addr = "[::]"; port = 80; } ]; locations."/.well-known/acme-challenge/".root = "/var/lib/acme/acme-challenge"; locations."/".return = "301 https://$host$request_uri"; }; }; }; # ────────────────────────────────────────────── # Stalwart mail server # ────────────────────────────────────────────── services.stalwart-mail = { enable = true; settings = { server = { hostname = "mail.rubenhensen.nl"; listener = { smtp = { bind = "[::]:25"; protocol = "smtp"; }; submission = { bind = "[::]:587"; protocol = "smtp"; }; submissions = { bind = "[::]:465"; protocol = "smtp"; tls.implicit = true; }; imaptls = { bind = "[::]:993"; protocol = "imap"; tls.implicit = true; }; managesieve = { bind = "[::]:4190"; protocol = "managesieve"; }; https = { # nginx owns the public :443 and does SNI passthrough to here # for the mail.rubenhensen.nl SNI. Stalwart still terminates TLS. bind = "127.0.0.1:8443"; protocol = "http"; tls.implicit = true; }; }; }; certificate.default = { cert = "%{file:/var/lib/acme/mail.rubenhensen.nl/fullchain.pem}%"; private-key = "%{file:/var/lib/acme/mail.rubenhensen.nl/key.pem}%"; }; storage = { data = "rocksdb"; fts = "rocksdb"; blob = "rocksdb"; lookup = "rocksdb"; directory = "internal"; }; store.rocksdb = { type = "rocksdb"; path = "/var/lib/stalwart-mail/data"; compression = "lz4"; }; directory.internal = { type = "internal"; store = "rocksdb"; }; tracer.stdout = { type = "stdout"; level = "info"; ansi = false; enable = true; }; oauth.oidc = { issuer-url = "https://authentik.rubenhensen.nl/application/o/stalwart/"; client-id = "%{file:/run/credentials/stalwart-mail.service/oidc-client-id}%"; client-secret = "%{file:/run/credentials/stalwart-mail.service/oidc-client-secret}%"; }; signature."rsa" = { private-key = "%{file:/run/credentials/stalwart-mail.service/dkim-rsa.key}%"; domain = "rubenhensen.nl"; selector = "202603r2"; headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id"]; algorithm = "rsa-sha-256"; canonicalization = "relaxed/relaxed"; expire = "10d"; set-body-length = false; report = true; }; signature."ed25519" = { private-key = "%{file:/run/credentials/stalwart-mail.service/dkim-ed25519.key}%"; domain = "rubenhensen.nl"; selector = "202603e2"; headers = ["From" "To" "Cc" "Date" "Subject" "Message-ID" "Organization" "MIME-Version" "Content-Type" "In-Reply-To" "References" "List-Id"]; algorithm = "ed25519-sha256"; canonicalization = "relaxed/relaxed"; set-body-length = false; report = false; }; auth.dkim.sign = [ { "if" = "listener != 'smtp'"; "then" = "['rsa', 'ed25519']"; } { "else" = false; } ]; authentication.fallback-admin = { user = "admin"; secret = "%{file:/run/credentials/stalwart-mail.service/stalwart-admin-password}%"; }; }; }; systemd.services.stalwart-mail.serviceConfig.LoadCredentialEncrypted = [ "stalwart-admin-password:/root/secrets/[%%secrets/stalwart-admin-password%%]" "oidc-client-id:/root/secrets/[%%secrets/oidc-client-id%%]" "oidc-client-secret:/root/secrets/[%%secrets/oidc-client-secret%%]" "dkim-rsa.key:/root/secrets/[%%secrets/dkim-rsa.key%%]" "dkim-ed25519.key:/root/secrets/[%%secrets/dkim-ed25519.key%%]" ]; users.users.stalwart-mail.extraGroups = [ "acme" ]; }