Stalwart: Difference between revisions

Eliasp (talk | contribs)
Onny (talk | contribs)
rdns setup
(5 intermediate revisions by the same user not shown)
Line 5: Line 5:
   |color=var(--border-color-warning)
   |color=var(--border-color-warning)
   |background=var(--background-color-warning-subtle)
   |background=var(--background-color-warning-subtle)
   |The content of this page are in reference to versions of Stalwart prior to v0.16. Later versions of Stalwart have adopted a new configuration format clearly described (and with examples for NixOS) in the upstream documentation: [https://stalw.art/docs/management/cli/apply/#nixos Stalwart Declarative bulk operations].
   |The content of this page are in reference to versions of Stalwart starting from v0.16. For legacy setup and migration instructions please refer follwing [[Stalwart/Legacy_setup]] page.}}
}}
== Setup ==
{{Warning|1=The Stalwart module for version the version >= 0.16 is not yet upstream and will be available in the upcoming NixOS 26.11 release.}}The following example enables the Stalwart mail server for the domain ''example.org'', listening on mail delivery SMTP/Submission (<code>25, 465</code>), IMAPS (<code>993</code>) and JMAP ports (8080/443) for mail clients to connect to. Mailboxes for the accounts <code>postmaster@example.org</code> and <code>info@example.org</code> get created if they don't exist yet.


Most of the DNS entries are managed by Stalwart including TLS key generation. In this example we configure INWX as domain provider. For supported protocols and hosts see [https://github.com/stalwartlabs/dns-update upstream documentation].


== Setup ==
{{file|||3=# FIXME: Workaround that inwx dns driver sends MX/CNAME/NS record
The following example enables the Stalwart mail server for the domain ''example.org'', listening on mail delivery SMTP/Submission (<code>25, 465</code>), IMAPS (<code>993</code>) and JMAP ports (8080/443) for mail clients to connect to. Mailboxes for the accounts <code>postmaster@example.org</code> and <code>user1@example.org</code> get created if they don't exist yet.
# content as FQDN with trailing dot, which INWX api rejects.
# Still broken upstream as of dns-update 0.5.8.
nixpkgs.overlays = [
  (final: prev: {
    stalwart_0_16 = prev.stalwart_0_16.overrideAttrs (oldAttrs: {
      cargoDeps = oldAttrs.cargoDeps.overrideAttrs (oldDeps: {
        buildCommand = (oldDeps.buildCommand or "") + ''
          substituteInPlace "$out/source-registry-0/dns-update-0.5.5/src/providers/inwx.rs" \
            --replace-fail 'DnsRecord::CNAME(name) => ("CNAME", name.as_str().into_fqdn().into_owned(), None),' \
                            'DnsRecord::CNAME(name) => ("CNAME", name.as_str().into_name().into_owned(), None),' \
            --replace-fail 'DnsRecord::NS(name) => ("NS", name.as_str().into_fqdn().into_owned(), None),' \
                            'DnsRecord::NS(name) => ("NS", name.as_str().into_name().into_owned(), None),' \
            --replace-fail 'mx.exchange.as_str().into_fqdn().into_owned(),' \
                            'mx.exchange.as_str().into_name().into_owned(),'
        '';
        outputHash = "sha256-9AI+YSY85FgZhIxuhmZCUizGEhwYmC+O3iGwu3mv2Nk=";
        outputHashAlgo = "sha256";
        outputHashMode = "recursive";
      });
    });
  })
];


{{file|||3=environment.etc = {
environment.etc = {
   "stalwart/mail-pw1".text = "foobar";
   "stalwart-admin-password".text = "mypassword";
  "stalwart/mail-pw2".text = "foobar";
   "stalwart-inwx-password".text = "myinwxpass";
   "stalwart/admin-pw".text = "foobar";
  "stalwart/acme-secret".text = "secret123";
};
};


services.stalwart = {
services.stalwart = {
   enable = true;
   enable = true;
   openFirewall = true;
   package = pkgs.stalwart_0_16;
   credentials = {
   admin = {
     mail-pw1 = "/etc/stalwart/mail-pw1";
     enable = true;
     mail-pw2 = "/etc/stalwart/mail-pw2";
     username = "admin";
     acme-secret = "/etc/stalwart/acme-secret";
     passwordFile = "/etc/stalwart-admin-password";
    admin-pw = "/etc/stalwart/admin-pw";
   };
   };
   settings = {
   url = "https://mail.example.org";
     server = {
  stateVersion = "26.11";
      hostname = "mx1.example.org";
  recovery.port = 8081;
      tls = {
  provision =
         enable = true;
    let
         implicit = true;
      variant = type: value: { "@type" = type; } // value;
    in
     {
      enable = true;
      url = "http://127.0.0.1:8080";
      singletons = {
        SystemSettings = {
          defaultHostname = "mail.example.org";
          defaultDomainId = "#main-domain";
        };
         Http.useXForwarded = true;
         MtaSts.mode = "enforce";
       };
       };
       listener = {
       objects = {
        smtp = {
        NetworkListener = {
          protocol = "smtp";
          reconcile = true;
          bind = "[::]:25";
          match = [ "name" ];
          objects = {
            listener-mgmt = {
              name = "management";
              protocol = "http";
              bind = [ "[::]:8080" ];
              tlsImplicit = false;
            };
            listener-smtp-relay = {
              name = "relay";
              protocol = "smtp";
              bind = [ "[::]:25" ];
              tlsImplicit = false;
            };
            listener-smtp-submissions = {
              name = "submissions";
              protocol = "smtp";
              bind = [ "[::]:465" ];
              tlsImplicit = true;
            };
            listener-imap = {
              name = "imap";
              protocol = "imap";
              bind = [ "[::]:993" ];
              tlsImplicit = true;
            };
          };
         };
         };
         submissions = {
         DnsServer = {
           bind = "[::]:465";
           reconcile = false;
          protocol = "smtp";
          match = null;
           tls.implicit = true;
          objects = {
            dns-inwx = variant "Inwx" {
              description = "INWX";
              username = "myuser";
              password = variant "File" {
                filePath = "/etc/stalwart-inwx-password";
              };
              sandbox = false;
            };
           };
         };
         };
         imaps = {
         AcmeProvider = {
           bind = "[::]:993";
           reconcile = false;
          protocol = "imap";
          match = null;
           tls.implicit = true;
          objects = {
            acme-letsencrypt = {
              directory = "https://acme-v02.api.letsencrypt.org/directory";
              challengeType = "Dns01";
              contact = [ "postmaster@example.org" ];
            };
           };
         };
         };
         jmap = {
         Domain = {
           bind = "[::]:8080";
           reconcile = true;
          url = "https://mail.example.org";
          match = [ "name" ];
          protocol = "http";
          objects = {
            main-domain = {
              isEnabled = true;
              name = "example.org";
              catchAllAddress = "all@example.org";
              reportAddressUri = "mailto:postmaster@example.org";
              subAddressing = variant "Enabled" { };
              dnsManagement = variant "Automatic" {
                dnsServerId = "#dns-inwx";
                publishRecords = [
                  "mx"
                  "spf"
                  "dkim"
                  "dmarc"
                  "tlsa"
                  "srv"
                  "mtaSts"
                  "tlsRpt"
                  "autoConfig"
                  "autoDiscover"
                  # FIXME: Currently disabled since the CAA config
                  # is very strict and would conflict with Caddy ACME
                  # "caa"
                ];
              };
              certificateManagement = variant "Automatic" {
                acmeProviderId = "#acme-letsencrypt";
              };
              dkimManagement = variant "Automatic" (
                let
                  days2millis = days: days * 24 * 60 * 60 * 1000;
                in
                {
                  selectorTemplate = "v{version}-{algorithm}-{date-%Y%m%d}";
                  rotateAfter = days2millis 90;
                  retireAfter = days2millis 7;
                  deleteAfter = days2millis 30;
                }
              );
            };
          };
         };
         };
         management = {
         Account = {
           bind = [ "127.0.0.1:8080" ];
           reconcile = false;
           protocol = "http";
          match = [
            "name"
            "domainId"
          ];
           objects = {
            user-info = variant "User" {
              name = "info";
              domainId = "#main-domain";
              memberGroupIds = [ "#group-admin" ];
              roles = variant "User" { };
              credentials = [ (variant "Password" { secret = "mypassword"; }) ];
            };
            group-admin = variant "Group" {
              name = "admin";
              domainId = "#main-domain";
              description = "Administrators";
              roles = variant "Default" { };
              aliases = [
                {
                  enabled = true;
                  name = "postmaster";
                  domainId = "#main-domain";
                }
              ];
            };
          };
         };
         };
       };
       };
     };
     };
    lookup.default = {
      hostname = "mx1.example.org";
      domain = "example.org";
    };
    acme."letsencrypt" = {
      directory = "https://acme-v02.api.letsencrypt.org/directory";
      challenge = "dns-01";
      contact = "user1@example.org";
      domains = [ "example.org" "mx1.example.org" ];
      provider = "cloudflare";
      secret = "%{file:/run/credentials/stalwart.service/acme-secret}%";
    };
    session.auth = {
      mechanisms = "[plain]";
      directory = "'in-memory'";
    };
    storage.directory = "in-memory";
    session.rcpt.directory = "'in-memory'";
    directory."imap".lookup.domains = [ "example.org" ];
    directory."in-memory" = {
      type = "memory";
      principals = [
        {
          class = "individual";
          name = "User 1";
          secret = "%{file:/run/credentials/stalwart.service/mail-pw1}%";
          email = [ "user1@example.org" ];
        }
        {
          class = "individual";
          name = "postmaster";
          secret = "%{file:/run/credentials/stalwart.service/mail-pw2}%";
          email = [ "postmaster@example.org" ];
        }
      ];
    };
    authentication.fallback-admin = {
      user = "admin";
      secret = "%{file:/run/credentials/stalwart.service/admin-pw}%";
    };
  };
};
};


services.caddy = {
services.caddy = {
   enable = true;
   enable = true;
   virtualHosts = {
   openFirewall = true;
    "webadmin.example.org" = {
  # Let's Encrypt account contact
      extraConfig = ''
  email = "postmaster@example.org";
        reverse_proxy http://127.0.0.1:8080
  virtualHosts."mail.example.org" = {
      '';
    serverAliases = [
      serverAliases = [
      "mta-sts.example.org"
        "mta-sts.example.org"
      "autoconfig.example.org"
        "autoconfig.example.org"
      "autodiscover.example.org"
        "autodiscover.example.org"
      "ua-auto-config.example.org"
        "mail.example.org"
    ];
      ];
     extraConfig = ''
     };
      reverse_proxy http://127.0.0.1:8080
    '';
   };
   };
};|name=/etc/nixos/configuration.nix|lang=nix}}
};
 
# FIXME will get implemented as stalwart.openFirewall
networking.firewall.allowedTCPPorts = [
  25
  465
  587
  993
];|name=/etc/nixos/configuration.nix|lang=nix}}


TLS key generation is done using DNS-01 challenge through Cloudflare domain provider, see dns-update library for [https://github.com/stalwartlabs/dns-update further providers] or configure [https://stalw.art/docs/server/tls/certificates manual certificates].
Change the user and password of your DNS provider. The password for the <code>info@</code> mailbox and admin user is stored in plain-text here for demonstration purpose, please consider using a secret-management tool [[Comparison of secret managing schemes|such as agenix or sops-nix]].


=== DNS records ===
=== DNS records ===
Before adding required records to the example domain <code>example.org</code>, we need to register the domain on the Stalwart server.<syntaxhighlight lang="shell">
Following DNS records need to be configured manually since they are not managed by Stalwart.
stalwart-cli --url https://webadmin.example.org domain create example.org
</syntaxhighlight>Authenticate using the fallback-admin password.
 
Review the list of which DNS records are required including their values for the mail server to work at https://webadmin.example.org/manage/directory/domains/tuxtux.com.co/view. Especially following records are essential:
 
{| class="wikitable"
{| class="wikitable"
! Record Type
! Record Type
Line 145: Line 245:
| ''IPv6 address of the mail server''
| ''IPv6 address of the mail server''
| Required
| Required
|-
| CNAME
| autoconfig
| example.org
| Mail client autoconfiguration
|-
| CNAME
| autodiscover
| example.org
| Outlook / Exchange compatibility
|-
|-
| CNAME
| CNAME
Line 160: Line 250:
| example.org
| example.org
| Mail host
| Mail host
|-
| CNAME
| mta-sts
| example.org
| MTA-STS
|-
| CNAME
| webadmin
| example.org
| Stalwart web administration interface
|-
| MX
| example.org
| mx1.example.org
| Mail delivery
|-
| SRV
| _imaps._tcp
| ''See Web Admin for exact values''
| IMAPS service
|-
| SRV
| _submissions._tcp
| ''See Web Admin for exact values''
| SMTP Submission service
|-
| TLSA
| _25._tcp.example.org.
| 3 1 1 …
| Only the record starting with <code>3 1 1</code> is required
|-
| TLSA
| _25._tcp.mx1.example.org.
| 3 1 1 …
| Only the record starting with <code>3 1 1</code> is required
|-
| TXT
| 202409e._domainkey
| ''DKIM public key''
| DKIM
|-
| TXT
| 202409r._domainkey
| ''DKIM public key''
| DKIM
|-
| TXT
| _dmarc
| ''DMARC policy''
| DMARC
|-
| TXT
| mx1
| ''SPF or server information''
| Depends on configuration
|-
| TXT
| _smtp._tls
| ''MTA-STS policy''
| SMTP TLS reporting
|-
| TXT
| example.org
| ''SPF record''
| SPF
|}
|}
=== rDNS setup ===
Configure rDNS in your VPS provider configuration dashbord to the IPv4 and IPv6 addresses, used in the DNS records above.


=== DNSSEC ===
=== DNSSEC ===
Line 231: Line 259:


For example, check if DNSSEC is working correctly for your new TLSA record
For example, check if DNSSEC is working correctly for your new TLSA record
  # nix shell nixpkgs#dnsutils --command delv _25._tcp.mx1.example.org TLSA @1.1.1.1
  # nix shell nixpkgs#dnsutils --command delv _25._tcp.mail.example.org TLSA @1.1.1.1
  ; fully validated
  ; fully validated
  _25._tcp.mx1.example.org. 10800 IN TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96 e3498238
  _25._tcp.mail.example.org. 10800 IN TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96 e3498238
  _25._tcp.mx1.example.org. 10800 IN RRSIG TLSA 13 5 10800 20230601000000 20230511000000 39688 example.org. He9VYZ35xTC3fNo8GJa6swPrZodSnjjIWPG6Th2YbsOEKTV1E8eGtJ2A +eyBd9jgG+B3cA/jw8EJHmpvy/buCw==
  _25._tcp.mail.example.org. 10800 IN RRSIG TLSA 13 5 10800 20230601000000 20230511000000 39688 example.org. He9VYZ35xTC3fNo8GJa6swPrZodSnjjIWPG6Th2YbsOEKTV1E8eGtJ2A +eyBd9jgG+B3cA/jw8EJHmpvy/buCw==


=== Running behind reverse proxy ===
=== Running behind reverse proxy ===
Line 289: Line 317:


== Tips and tricks ==
== Tips and tricks ==
=== Auto update TLSA records ===
Stalwart [https://github.com/stalwartlabs/stalwart/issues/1664 does not yet] automatically update the TLSA record if your ACME certificate changes.
Following script is a possible workaround. It extracts the ACME cert every five minute, calculates the TLSA hash and compares it with the upstream record. If it doesn't match, it uses [https://github.com/Stenstromen/gotlsaflare gotlsaflare] to update the TLSA record on Cloudflare.
<syntaxhighlight lang="nixos">systemd.services.tlsa-cloudflare-update = {
  description = "Check and update TLSA/DANE record for mx1 from Stalwart ACME Cert";
 
  after = [
    "network-online.target"
    "stalwart.service"
  ];
  wants = [
    "network-online.target"
    "stalwart.service"
  ];
 
  serviceConfig = {
    Type = "oneshot";
    User = "stalwart";
    Group = "stalwart";
    EnvironmentFile = config.age.secrets.gotlsaflare-cloudflare-token.path;
    RuntimeDirectory = "stalwart-tlsa";
  };
  environment = {
    DOMAIN = "example.org";
    SUBDOMAIN = "mail";
    PORT = "25";
    ACME_PROVIDER_ID = "cloudflare";
  };
  path = with pkgs; [
    bash
    coreutils
    openssl
    dnsutils
    gotlsaflare
    rocksdb.tools
    gawk
  ];
  script = ''
    set -eu
    TLSA_RECORD="_$PORT._tcp.$SUBDOMAIN.$DOMAIN"
    DB_PATH="/var/lib/stalwart/db"
    TEMP_RAW="/run/stalwart-tlsa/cert.bundle"
    TEMP_CRT="/run/stalwart-tlsa/cert.crt"
    echo "Starting TLSA update process for $DOMAIN"
    ldb --db="$DB_PATH" --column_family=s get "acme.$ACME_PROVIDER_ID.cert" | base64 -d > "$TEMP_RAW"
    if [ ! -s "$TEMP_RAW" ]; then
      echo "ERROR: ACME certificate extraction failed"
      exit 1
    fi
    openssl x509 -in "$TEMP_RAW" -out "$TEMP_CRT"
    LOCAL_HASH=$(openssl x509 -in "$TEMP_CRT" -pubkey -noout | openssl pkey -pubin -outform DER | openssl sha256 | awk '{print tolower($2)}')
    echo "Local hash: $LOCAL_HASH"
    UPSTREAM_HASH=$(dig +nosplit +short TLSA "$TLSA_RECORD" | awk '{print tolower($4)}' | head -n1)
    echo "Upstream hash: $UPSTREAM_HASH"
    if [ "$LOCAL_HASH" = "$UPSTREAM_HASH" ]; then
      echo "Hashes match. DNS is up to date."
      exit 0
    fi
    echo "Hashes differ! Updating Cloudflare..."
    gotlsaflare update \
      --url "$DOMAIN" \
      --subdomain "$SUBDOMAIN" \
      --tcp"$PORT" \
      --cert "$TEMP_CRT"
    echo "TLSA update completed successfully."
  '';
};
systemd.timers.tlsa-cloudflare-update = {
  description = "Run TLSA check and update every 5 minutes";
  wantedBy = [ "timers.target" ];
  timerConfig = {
    OnBootSec = "2m";
    OnUnitActiveSec = "5m";
    Unit = "tlsa-cloudflare-update.service";
  };
};</syntaxhighlight>
Adapt the variables <code>DOMAIN</code>, <code>SUBDOMAIN</code>, and <code>PORT</code> according to your needs. The variable <code>ACME_PROVIDER_ID</code> corresponds to the ACME profile name you've setup in the Stalwart webadmin interface. <code>EnvironmentFile</code> points to a file containing the secret Cloudflare api token in the format: TOKEN=12345678[...].
==== deSEC.io ====
In case you want to update your TLSA records at deSEC you can use [https://codeberg.org/Cameo007/dyndns-tlsa-desec dyndns-tlsa-desec] ('''install via flake''') which checks your existing records and updates them if necessary. The certificate and key are taken from the specified directory (like your [[ACME]] directory)
It defaults to <code>3 1 1</code> but you can choose other values as described [[wikipedia:DNS-based_Authentication_of_Named_Entities#RR_data_fields|here]].<syntaxhighlight lang="nixos">
services.dyndns-tlsa-desec = {
  enable = true;
  api_token_file = config.age.secrets.dyndns-tlsa-desec-api-key.path;
  tlsa_zones."example.com" = {
    cert_path = "/var/lib/acme/example.com/";
    records."_25._tcp.mail" = { };
  };
};
</syntaxhighlight>The program is executed hourly per default but you can set the <code>interval</code> option to any [https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html#Calendar%20Events systemd calendar event].<syntaxhighlight lang="nixos">
services.dyndns-tlsa-desec.interval = "5m"; # Every 5 minutes
</syntaxhighlight>


=== Sending from subaddresses ===
=== Sending from subaddresses ===