diff --git a/hieradata/common.yaml b/hieradata/common.yaml index 7ca2200..bda632d 100644 --- a/hieradata/common.yaml +++ b/hieradata/common.yaml @@ -167,6 +167,10 @@ lookup_options: postfix::virtuals: merge: strategy: deep + stalwart::postgresql_password: + convert_to: Sensitive + stalwart::s3_secret_key: + convert_to: Sensitive facts_path: '/opt/puppetlabs/facter/facts.d' diff --git a/hieradata/roles/infra/mail/backend.eyaml b/hieradata/roles/infra/mail/backend.eyaml new file mode 100644 index 0000000..2cd3885 --- /dev/null +++ b/hieradata/roles/infra/mail/backend.eyaml @@ -0,0 +1,4 @@ +--- +profiles::sql::postgresdb::dbpass: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAEZkKX2ThGom2PffofEuRBHbyiq68PCsq0+19eSa02fpVPKgZ/5BEjzBhwvrt0BWZWsjYGhccFQ69DR+lTuqS50GcRSAiNQ2LDX2a3J1pu39oIKsNVmcJTza0f5T0VeI3A7sZkn7jL+NVz5ANp8V0EMfAjaduGQ7Jac+8dBsvTrLbJ+1AZVrjaKPxOI1+5tpE7qx35mM0oDVy0NwmlaVf8vbK6jyzyUJRs4Sb+mpioPi5sDxHgClzsQnu93HqqAIqR5UzsUv7MDMljOGYUF5ITyPU836I1LEZ9UfiVO7AikQ3A31LaSUWvwsxRHKxiQXJ7v6W/O+Nt3jdIR0eoqC5xTBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBC11+SUDLmz6bBrGyfYT9DPgDB3UhuhJ3kUruMTdRCW0Y6hoSBBQYCO+ZRFJToGTkz/BcxVw2Xtwjc7UmKmLodsDAo=] +stalwart::s3_access_key: ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAkMQhrgL/+XvSk2VTXruccabDTc6xA8RddymI7lwSfIs8rZ0houCeHqjBpWC7VMuZwger1R7CChlx0VoD657HNJNaDvXas/6gFLRIZgyl/EI5xcJ9V9247d3IPOd2yRVlniVQswBzlJOQa8/wJdjLOD1hxw56eEnqTfqmz3YUweEWBNtb0rNuti3eYTXA0MRVEAi7/PoSyAiDIMRMLFCZx5vo12GHNk5Ei6NioUbgzvazcVnvwPwGg9l5eKMqmGGGEobIcXSquV6aLqX5lbXM21hz0SSLLDOG8QRLbMR2yGX0w9lR1rU+5uNKiRYlOwoySQRx2Z0JX2G37OBa0srr2zBMBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBAfExMqDF1zLjgY6YvwRrd+gCCV8+cT80RyNiWpLocDUjaQ66HK0K/gDpLiF3UZtoVpGw==] +stalwart::s3_secret_key: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEApO6B2vhdbciclFgKHK0hl7Vj3ssA60JTW71G1cdBMgp0vuvzl3jCTFsTRTfm0Xf+mdhfaDI0f2WmFs+e+0i8Aqmum1eTOda0TUwkUbE2d0TBlt+mAjdyDsiqBlwbU5sP+DLvweyM26w46u0SKqOufnIs0ZcPD5Hrv2Zut6OIPsMa6d3GTYHeOAIkC1T06wbpaYr31Z32NRhaHRgex4uduOUtZ7V2RJIX+HzTvCi4iFYNZvXI8sj4CG8SBCFq7SBYhegFnYh/L8RRwhz6qkCLJbmqqASZkYoPBFNqBFKyyTMsFprPPWM/nEGlkUOdGt6OgjsjsFs3hmGl69s5Nq2ixjBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBClR0dhOig5w37ZDWdW16k/gDDJsGAJLQpXi3DqXNBegFC4T6A1Mn0fvKatZo2ET0xlkfaKJas484pMdOs0gQil9As=] diff --git a/hieradata/roles/infra/mail/backend.yaml b/hieradata/roles/infra/mail/backend.yaml new file mode 100644 index 0000000..73501b3 --- /dev/null +++ b/hieradata/roles/infra/mail/backend.yaml @@ -0,0 +1,39 @@ +--- +hiera_include: + - stalwart + - profiles::sql::postgresdb + +# additional altnames +profiles::pki::vault::alt_names: + - mail.main.unkin.net + - imap.main.unkin.net + +# manage a pgsql database + user +profiles::sql::postgresdb::cluster_name: "patroni-shared-%{facts.environment}" +profiles::sql::postgresdb::dbname: stalwart +profiles::sql::postgresdb::dbuser: stalwart + + +# Cluster role for node discovery +stalwart::cluster_role: "%{facts.enc_role}" + +# PostgreSQL connection +stalwart::postgresql_host: "master.%{hiera('profiles::sql::postgresdb::cluster_name')}.service.%{facts.country}-%{facts.region}.consul" +stalwart::postgresql_database: "%{hiera('profiles::sql::postgresdb::dbname')}" +stalwart::postgresql_user: "%{hiera('profiles::sql::postgresdb::dbuser')}" +stalwart::postgresql_password: "%{hiera('profiles::sql::postgresdb::dbpass')}" + +# S3/Ceph-RGW connection +stalwart::s3_endpoint: 'https://radosgw.service.consul' +stalwart::s3_bucket: 'stalwart-maildata' +stalwart::s3_region: "%{facts.region}" + +# Domains and relay +stalwart::domains: + - 'mail.unkin.net' +stalwart::postfix_relay_host: 'out-mta.main.unkin.net' +stalwart::manage_dns_records: true # DNS records point to individual servers + +## With load balancer: +#stalwart::manage_dns_records: true +#stalwart::loadbalancer_host: 'mail-lb.example.com' diff --git a/modules/stalwart/README.md b/modules/stalwart/README.md new file mode 100644 index 0000000..49960d1 --- /dev/null +++ b/modules/stalwart/README.md @@ -0,0 +1,213 @@ +# Stalwart Mail Server Module + +This Puppet module manages Stalwart Mail Server, a modern, secure, and scalable mail server implementation that supports IMAP, JMAP, WebDAV, and SMTP protocols. + +## Overview + +The `stalwart` module provides a comprehensive solution for deploying Stalwart Mail Server in a clustered environment with: + +- **PostgreSQL backend** for data, full-text search, and in-memory storage +- **S3/Ceph-RGW backend** for blob storage (emails, attachments, sieve scripts) +- **Automatic cluster discovery** using `query_nodes()` +- **DNS autodiscovery records** for email client configuration +- **TLS certificate management** integration +- **Postfix relay integration** for SMTP routing + +## Features + +- ✅ **Multi-node clustering** with peer-to-peer coordination +- ✅ **PostgreSQL authentication** with SQL directory backend +- ✅ **S3 blob storage** with compression support +- ✅ **IMAP/IMAPS protocols** for email access +- ✅ **HTTP/HTTPS protocols** for JMAP, WebDAV, and autodiscovery +- ✅ **SMTP relay** for postfix integration +- ✅ **DNS autodiscovery** record management +- ✅ **Automatic role distribution** across cluster nodes +- ✅ **TLS security** with Vault PKI integration + +## Requirements + +- **Puppet 6+** with `query_nodes()` function support +- **Stalwart RPM package** (creates user, directories, systemd service) +- **PostgreSQL cluster** for data storage +- **S3-compatible storage** (Ceph-RGW, MinIO, AWS S3) +- **DNS management** via `profiles::dns::record` +- **PKI management** via `profiles::pki::vault::alt_names` + +## Usage + +### Recommended Usage with Role + +The recommended way to use this module is via the `roles::infra::mail::backend` role with hieradata configuration: + +```puppet +include roles::infra::mail::backend +``` + +Configure all parameters in `hieradata/roles/infra/mail/backend.yaml` - see `examples/role-hieradata.yaml` for a complete example. + +### Direct Class Usage + +```puppet +class { 'stalwart': + node_id => 1, + cluster_role => 'mail-backend', + postgresql_host => 'pgsql.example.com', + postgresql_database => 'stalwart', + postgresql_user => 'stalwart', + postgresql_password => Sensitive('secretpassword'), + s3_endpoint => 'https://ceph-rgw.example.com', + s3_bucket => 'stalwart-blobs', + s3_access_key => 'accesskey', + s3_secret_key => Sensitive('secretkey'), + domains => ['example.com'], + postfix_relay_host => 'postfix.example.com', +} +``` + +## Hieradata Configuration + +See `examples/role-hieradata.yaml` for a complete example of role-based hieradata configuration. + +### Required Parameters + +```yaml +# Cluster role for node discovery +stalwart::cluster_role: 'mail-backend' + +# Optional: Unique node identifier (auto-calculated if not specified) +# stalwart::node_id: 1 + +# PostgreSQL connection +stalwart::postgresql_host: 'pgsql.example.com' +stalwart::postgresql_database: 'stalwart' +stalwart::postgresql_user: 'stalwart' +stalwart::postgresql_password: > + ENC[PKCS7,encrypted_password...] + +# S3/Ceph-RGW connection +stalwart::s3_endpoint: 'https://ceph-rgw.example.com' +stalwart::s3_bucket: 'stalwart-blobs' +stalwart::s3_access_key: 'access_key' +stalwart::s3_secret_key: > + ENC[PKCS7,encrypted_secret...] + +# Domains and relay +stalwart::domains: + - 'example.com' +stalwart::postfix_relay_host: 'postfix.example.com' +``` + +## Architecture + +### Cluster Setup + +The module automatically discovers cluster members using `query_nodes()` based on: +- `enc_role` matching `cluster_role` parameter +- `country` fact matching the node's country fact +- `region` fact matching the node's region fact + +**Node ID Assignment:** +- Node IDs are **automatically extracted** from the last 4 digits of the hostname +- Example: `ausyd1nxvm1234` → node ID `1234` +- Manual override available via `stalwart::node_id` parameter if needed +- Hostname must end with 4 digits for automatic extraction to work +- Ensures unique IDs when following consistent hostname patterns + +### Storage Layout + +- **Data Store**: PostgreSQL (metadata, folders, settings) +- **Full-Text Search**: PostgreSQL (search indexes) +- **In-Memory Store**: PostgreSQL (caching, sessions) +- **Blob Store**: S3/Ceph-RGW (emails, attachments, files) + +### Directory Structure (Created by RPM) + +- **Config**: `/opt/stalwart/etc/config.toml` +- **Data**: `/var/lib/stalwart/` (queue, reports) +- **Logs**: `/var/log/stalwart/stalwart.log` +- **Binary**: `/opt/stalwart/bin/stalwart` +- **User**: `stalwart:stalwart` (system user) + +### Network Ports + +- **143**: IMAP (STARTTLS) +- **993**: IMAPS (implicit TLS) +- **443**: HTTPS (JMAP, WebDAV, autodiscovery) +- **2525**: SMTP relay (postfix communication) +- **11200**: Cluster coordination (peer-to-peer) +- **9090**: Prometheus metrics + +### DNS Records + +When `manage_dns_records: true`, the module creates: +- `autoconfig.domain.com` → server FQDN (Thunderbird) +- `autodiscover.domain.com` → server FQDN (Outlook) +- `_imap._tcp.domain.com` SRV record +- `_imaps._tcp.domain.com` SRV record +- `_caldav._tcp.domain.com` SRV record +- `_carddav._tcp.domain.com` SRV record + +## PostgreSQL Schema + +The module expects these tables in the PostgreSQL database: + +```sql +CREATE TABLE accounts ( + name TEXT PRIMARY KEY, + secret TEXT, + description TEXT, + type TEXT NOT NULL, + quota INTEGER DEFAULT 0, + active BOOLEAN DEFAULT true +); + +CREATE TABLE group_members ( + name TEXT NOT NULL, + member_of TEXT NOT NULL, + PRIMARY KEY (name, member_of) +); + +CREATE TABLE emails ( + name TEXT NOT NULL, + address TEXT NOT NULL, + type TEXT, + PRIMARY KEY (name, address) +); +``` + +## Security + +- **TLS required** for all connections +- **PostgreSQL SSL** enabled by default +- **S3 HTTPS** endpoints required +- **Password hashing** supported (SHA512, BCRYPT, etc.) +- **Certificate management** via Vault PKI + +## Monitoring + +- **Prometheus metrics** on port 9090 +- **Log files** in `/var/log/stalwart/` +- **Queue monitoring** in `/var/lib/stalwart/queue/` +- **Service status** via systemd (`stalwart.service`) + +## Troubleshooting + +### Cluster Formation Issues +- Verify `query_nodes()` returns expected nodes +- Check `country` and `region` facts are consistent +- Ensure `cluster_role` matches across all nodes + +### Storage Connection Issues +- Test PostgreSQL connectivity and credentials +- Verify S3 endpoint accessibility and credentials +- Check network connectivity between nodes + +### TLS Certificate Issues +- Ensure PKI alt_names include all required domains +- Verify certificate paths exist and are readable +- Check certificate expiration dates + +## License + +This module is part of the internal infrastructure management system. \ No newline at end of file diff --git a/modules/stalwart/examples/hieradata.yaml b/modules/stalwart/examples/hieradata.yaml new file mode 100644 index 0000000..e8eff84 --- /dev/null +++ b/modules/stalwart/examples/hieradata.yaml @@ -0,0 +1,57 @@ +# Example hieradata for profiles::mail::stalwart +# This shows the required and optional parameters for Stalwart configuration + +# Required: Unique node ID for each server in the cluster (1, 2, 3, etc.) +profiles::mail::stalwart::node_id: 1 + +# Required: Cluster role name for query_nodes() discovery +profiles::mail::stalwart::cluster_role: 'mail-backend' + +# Required: PostgreSQL connection settings +profiles::mail::stalwart::postgresql_host: 'pgsql.example.com' +profiles::mail::stalwart::postgresql_port: 5432 +profiles::mail::stalwart::postgresql_database: 'stalwart' +profiles::mail::stalwart::postgresql_user: 'stalwart' +profiles::mail::stalwart::postgresql_password: > + ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAxample...] +profiles::mail::stalwart::postgresql_ssl: true + +# Required: S3/Ceph-RGW connection settings +profiles::mail::stalwart::s3_endpoint: 'https://ceph-rgw.example.com' +profiles::mail::stalwart::s3_bucket: 'stalwart-blobs' +profiles::mail::stalwart::s3_region: 'default' +profiles::mail::stalwart::s3_access_key: 'stalwart_access_key' +profiles::mail::stalwart::s3_secret_key: > + ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAxample...] +profiles::mail::stalwart::s3_key_prefix: 'stalwart/' + +# Required: Domains this mail backend serves +profiles::mail::stalwart::domains: + - 'example.com' + - 'mail.example.com' + +# Required: Postfix relay host for SMTP delivery +profiles::mail::stalwart::postfix_relay_host: 'postfix.example.com' + +# Optional: Protocol configuration (defaults shown) +profiles::mail::stalwart::enable_imap: true +profiles::mail::stalwart::enable_imap_tls: true +profiles::mail::stalwart::enable_http: true +profiles::mail::stalwart::enable_smtp_relay: true + +# Optional: Management settings +profiles::mail::stalwart::manage_dns_records: true +profiles::mail::stalwart::log_level: 'info' + +# Optional: TLS certificate paths (defaults shown) +profiles::mail::stalwart::tls_cert: '/etc/pki/tls/vault/certificate.crt' +profiles::mail::stalwart::tls_key: '/etc/pki/tls/vault/private.key' + +# Example PKI alt_names configuration for TLS certificates +# This should include all domains and hostnames that need certificates +profiles::pki::vault::alt_names: + mail-backend: + - 'imap.example.com' + - 'mail.example.com' + - 'autoconfig.example.com' + - 'autodiscover.example.com' diff --git a/modules/stalwart/examples/role-hieradata.yaml b/modules/stalwart/examples/role-hieradata.yaml new file mode 100644 index 0000000..5679bdb --- /dev/null +++ b/modules/stalwart/examples/role-hieradata.yaml @@ -0,0 +1,58 @@ +# Example hieradata for roles::infra::mail::backend +# Place this in: hieradata/roles/infra/mail/backend.yaml + +# Stalwart module configuration - all parameters passed directly to the module +# stalwart::node_id: 1234 # Optional - automatically extracted from last 4 digits of hostname +stalwart::cluster_role: 'mail-backend' + +# PostgreSQL connection settings +stalwart::postgresql_host: 'pgsql.example.com' +stalwart::postgresql_port: 5432 +stalwart::postgresql_database: 'stalwart' +stalwart::postgresql_user: 'stalwart' +stalwart::postgresql_password: > + ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAxample...] +stalwart::postgresql_ssl: true + +# S3/Ceph-RGW connection settings +stalwart::s3_endpoint: 'https://ceph-rgw.example.com' +stalwart::s3_bucket: 'stalwart-blobs' +stalwart::s3_region: 'default' +stalwart::s3_access_key: 'stalwart_access_key' +stalwart::s3_secret_key: > + ENC[PKCS7,MIIBiQYJKoZIhvcNAQcDoIIBejCCAXYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAxample...] +stalwart::s3_key_prefix: 'stalwart/' + +# Domains this mail backend serves +stalwart::domains: + - 'example.com' + - 'mail.example.com' + +# Postfix relay host for SMTP delivery +stalwart::postfix_relay_host: 'postfix.example.com' + +# Optional protocol configuration (defaults shown) +stalwart::enable_imap: true +stalwart::enable_imap_tls: true +stalwart::enable_http: true +stalwart::enable_smtp_relay: true + +# Optional management settings +stalwart::manage_dns_records: true +stalwart::log_level: 'info' + +# Optional TLS certificate paths (defaults work with profiles::pki::vault) +# stalwart::tls_cert: '/etc/pki/tls/vault/certificate.crt' +# stalwart::tls_key: '/etc/pki/tls/vault/private.key' + +# Optional path overrides (RPM package sets up these defaults) +# stalwart::config_dir: '/opt/stalwart/etc' +# stalwart::data_dir: '/var/lib/stalwart' + +# PKI alt_names configuration for TLS certificates +# This should include all domains and hostnames that need certificates +profiles::pki::vault::alt_names: + - 'imap.example.com' + - 'mail.example.com' + - 'autoconfig.example.com' + - 'autodiscover.example.com' diff --git a/modules/stalwart/manifests/config.pp b/modules/stalwart/manifests/config.pp new file mode 100644 index 0000000..66b185e --- /dev/null +++ b/modules/stalwart/manifests/config.pp @@ -0,0 +1,32 @@ +# @summary Manages Stalwart Mail Server configuration +# +# @api private +class stalwart::config { + assert_private() + + # Main configuration file + file { "${stalwart::config_dir}/config.toml": + ensure => file, + owner => 'stalwart', + group => 'stalwart', + mode => '0640', + content => template('stalwart/config.toml.erb'), + notify => Service['stalwart'], + } + + # Create directories for storage + file { "${stalwart::data_dir}/queue": + ensure => directory, + owner => 'stalwart', + group => 'stalwart', + mode => '0750', + } + + file { "${stalwart::data_dir}/reports": + ensure => directory, + owner => 'stalwart', + group => 'stalwart', + mode => '0750', + } + +} \ No newline at end of file diff --git a/modules/stalwart/manifests/dns.pp b/modules/stalwart/manifests/dns.pp new file mode 100644 index 0000000..fb30150 --- /dev/null +++ b/modules/stalwart/manifests/dns.pp @@ -0,0 +1,67 @@ +# @summary Manages DNS autodiscovery records for Stalwart +# +# @param target_host +# FQDN to point DNS records to (defaults to current server) +# +# @api private +class stalwart::dns ( + Stdlib::Fqdn $target_host = $facts['networking']['fqdn'], +) { + assert_private() + + # Create autodiscovery DNS records for each domain + $stalwart::domains.each |$domain| { + + # Autoconfig record for Thunderbird/Mozilla clients + profiles::dns::record { "autoconfig_${domain}": + record => "autoconfig.${domain}", + type => 'CNAME', + value => "${target_host}.", + zone => $domain, + order => 100, + } + + # Autodiscover record for Outlook/Microsoft clients + profiles::dns::record { "autodiscover_${domain}": + record => "autodiscover.${domain}", + type => 'CNAME', + value => "${target_host}.", + zone => $domain, + order => 101, + } + + # IMAP SRV records + profiles::dns::record { "imap_srv_${domain}": + record => "_imap._tcp.${domain}", + type => 'SRV', + value => "10 1 143 ${target_host}.", + zone => $domain, + order => 102, + } + + profiles::dns::record { "imaps_srv_${domain}": + record => "_imaps._tcp.${domain}", + type => 'SRV', + value => "10 1 993 ${target_host}.", + zone => $domain, + order => 103, + } + + # CalDAV and CardDAV SRV records + profiles::dns::record { "caldav_srv_${domain}": + record => "_caldav._tcp.${domain}", + type => 'SRV', + value => "10 1 443 ${target_host}.", + zone => $domain, + order => 104, + } + + profiles::dns::record { "carddav_srv_${domain}": + record => "_carddav._tcp.${domain}", + type => 'SRV', + value => "10 1 443 ${target_host}.", + zone => $domain, + order => 105, + } + } +} diff --git a/modules/stalwart/manifests/init.pp b/modules/stalwart/manifests/init.pp new file mode 100644 index 0000000..508d0b7 --- /dev/null +++ b/modules/stalwart/manifests/init.pp @@ -0,0 +1,219 @@ +# @summary Main class for managing Stalwart Mail Server +# +# This class provides a comprehensive setup of Stalwart Mail Server with +# clustering, authentication, storage, and protocol support. +# +# @example Basic Stalwart setup +# class { 'stalwart': +# node_id => 1, +# postgresql_host => 'pgsql.example.com', +# postgresql_database => 'stalwart', +# postgresql_user => 'stalwart', +# postgresql_password => Sensitive('secretpassword'), +# s3_endpoint => 'https://ceph-rgw.example.com', +# s3_bucket => 'stalwart-blobs', +# s3_access_key => 'accesskey', +# s3_secret_key => Sensitive('secretkey'), +# domains => ['example.com'], +# postfix_relay_host => 'postfix.example.com', +# } +# +# @param node_id +# Unique identifier for this node in the cluster (1-N). If not specified, +# automatically calculated based on sorted position in cluster member list. +# +# @param cluster_role +# Role name for cluster member discovery via query_nodes() +# +# +# @param postgresql_host +# PostgreSQL server hostname/IP +# +# @param postgresql_port +# PostgreSQL server port +# +# @param postgresql_database +# PostgreSQL database name +# +# @param postgresql_user +# PostgreSQL username +# +# @param postgresql_password +# PostgreSQL password (Sensitive) +# +# @param postgresql_ssl +# Enable SSL/TLS for PostgreSQL connections +# +# @param s3_endpoint +# S3/Ceph-RGW endpoint URL +# +# @param s3_bucket +# S3 bucket name for blob storage +# +# @param s3_region +# S3 region +# +# @param s3_access_key +# S3 access key +# +# @param s3_secret_key +# S3 secret key (Sensitive) +# +# @param s3_key_prefix +# S3 key prefix for stalwart objects +# +# @param domains +# Array of domains this server handles +# +# @param postfix_relay_host +# Postfix relay host for SMTP delivery +# +# @param bind_address +# IP address to bind services to +# +# @param advertise_address +# IP address to advertise to cluster members +# +# @param enable_imap +# Enable IMAP protocol listener +# +# @param enable_imap_tls +# Enable IMAP over TLS listener +# +# @param enable_http +# Enable HTTP listener for JMAP/WebDAV/Autodiscovery +# +# @param enable_smtp_relay +# Enable SMTP for postfix relay communication +# +# @param package_ensure +# Package version to install +# +# @param config_dir +# Stalwart configuration directory +# +# @param data_dir +# Stalwart data directory +# +# @param log_level +# Logging verbosity level +# +# @param manage_firewall +# Whether to manage firewall rules +# +# @param tls_cert +# Path to TLS certificate file +# +# @param tls_key +# Path to TLS private key file +# +# @param manage_dns_records +# Whether to create DNS autodiscovery records +# +class stalwart ( + String $cluster_role, + Stdlib::Host $postgresql_host, + String $postgresql_database, + String $postgresql_user, + Sensitive[String] $postgresql_password, + Stdlib::HTTPUrl $s3_endpoint, + String $s3_bucket, + String $s3_access_key, + Sensitive[String] $s3_secret_key, + Array[Stdlib::Fqdn] $domains, + Stdlib::Host $postfix_relay_host, + Optional[Integer] $node_id = undef, + Stdlib::Port $postgresql_port = 5432, + Boolean $postgresql_ssl = true, + String $s3_region = 'us-east-1', + String $s3_key_prefix = 'stalwart/', + Stdlib::IP::Address $bind_address = $facts['networking']['ip'], + Stdlib::IP::Address $advertise_address = $facts['networking']['ip'], + Boolean $enable_imap = true, + Boolean $enable_imap_tls = true, + Boolean $enable_http = true, + Boolean $enable_smtp_relay = true, + String $package_ensure = 'present', + Stdlib::Absolutepath $config_dir = '/opt/stalwart/etc', + Stdlib::Absolutepath $data_dir = '/var/lib/stalwart', + Enum['error','warn','info','debug','trace'] $log_level = 'info', + Boolean $manage_firewall = false, + Stdlib::Absolutepath $tls_cert = '/etc/pki/tls/vault/certificate.crt', + Stdlib::Absolutepath $tls_key = '/etc/pki/tls/vault/private.key', + Boolean $manage_dns_records = true, + Optional[Stdlib::Fqdn] $loadbalancer_host = undef, +) { + + # Query cluster members for validation + $cluster_query = "enc_role='${cluster_role}' and country='${facts['country']}' and region='${facts['region']}'" + $cluster_members = query_nodes($cluster_query, 'networking.fqdn') + $sorted_cluster_members = sort($cluster_members) + + # Calculate node_id from last 4 digits of hostname if not provided + $my_fqdn = $facts['networking']['fqdn'] + $hostname = $facts['networking']['hostname'] + + # Extract last 4 digits from hostname (e.g., ausyd1nxvm1234 -> 1234) + if $hostname =~ /^.*(\d{4})$/ { + $hostname_digits = $1 + $calculated_node_id = Integer($hostname_digits) + } else { + fail("Unable to extract 4-digit node ID from hostname '${hostname}'. Hostname must end with 4 digits or specify node_id manually.") + } + + # Use provided node_id or calculated one + $effective_node_id = $node_id ? { + undef => $calculated_node_id, + default => $node_id, + } + + # Validate parameters + if $effective_node_id < 1 { + fail('node_id must be a positive integer') + } + + if empty($domains) { + fail('At least one domain must be specified') + } + + if !($my_fqdn in $sorted_cluster_members) { + fail("This node (${my_fqdn}) is not found in cluster members for role '${cluster_role}' in ${facts['country']}-${facts['region']}") + } + + # Create base directories (user/group/base dirs created by package) + file { [$config_dir, $data_dir]: + ensure => directory, + owner => 'stalwart', + group => 'stalwart', + mode => '0750', + } + + # Include sub-classes in dependency order + include stalwart::install + include stalwart::config + include stalwart::service + + # Handle DNS records if requested + if $manage_dns_records { + if $loadbalancer_host { + # Only first node in cluster creates DNS records pointing to load balancer + if $my_fqdn == $sorted_cluster_members[0] { + class { 'stalwart::dns': + target_host => $loadbalancer_host, + } + } + } else { + # Current behavior: each server creates its own DNS records + include stalwart::dns + } + } + + # Class ordering + Class['stalwart::install'] + -> Class['stalwart::config'] + -> Class['stalwart::service'] + + if $manage_dns_records { + Class['stalwart::service'] -> Class['stalwart::dns'] + } +} \ No newline at end of file diff --git a/modules/stalwart/manifests/install.pp b/modules/stalwart/manifests/install.pp new file mode 100644 index 0000000..7650f77 --- /dev/null +++ b/modules/stalwart/manifests/install.pp @@ -0,0 +1,11 @@ +# @summary Manages Stalwart Mail Server package installation +# +# @api private +class stalwart::install { + assert_private() + + # Install stalwart package (user/group created by package preinstall script) + package { 'stalwart': + ensure => $stalwart::package_ensure, + } +} \ No newline at end of file diff --git a/modules/stalwart/manifests/service.pp b/modules/stalwart/manifests/service.pp new file mode 100644 index 0000000..82ff537 --- /dev/null +++ b/modules/stalwart/manifests/service.pp @@ -0,0 +1,26 @@ +# @summary Manages Stalwart Mail Server service +# +# @api private +class stalwart::service { + assert_private() + + # Service is installed by the RPM package + service { 'stalwart': + ensure => running, + enable => true, + subscribe => [ + File[$stalwart::tls_cert], + File[$stalwart::tls_key], + ], + } + + # Add capability to bind to privileged ports (143, 443, 993) + systemd::manage_dropin { 'bind-capabilities.conf': + ensure => present, + unit => 'stalwart.service', + service_entry => { + 'AmbientCapabilities' => 'CAP_NET_BIND_SERVICE', + }, + notify => Service['stalwart'], + } +} \ No newline at end of file diff --git a/modules/stalwart/templates/config.toml.erb b/modules/stalwart/templates/config.toml.erb new file mode 100644 index 0000000..39d3ffe --- /dev/null +++ b/modules/stalwart/templates/config.toml.erb @@ -0,0 +1,257 @@ +# Stalwart Mail Server Configuration +# Generated by Puppet - DO NOT EDIT MANUALLY + +[server] +hostname = "<%= @facts['networking']['fqdn'] %>" +greeting = "Stalwart ESMTP" + +[server.listener."smtp-relay"] +bind = ["<%= @bind_address %>:25"] +protocol = "smtp" +greeting = "Stalwart SMTP Relay" + +<% if @enable_imap -%> +[server.listener."imap"] +bind = ["<%= @bind_address %>:143"] +protocol = "imap" +<% end -%> + +<% if @enable_imap_tls -%> +[server.listener."imaps"] +bind = ["<%= @bind_address %>:993"] +protocol = "imap" +tls.implicit = true +<% end -%> + +<% if @enable_http -%> +[server.listener."https"] +bind = ["<%= @bind_address %>:443"] +protocol = "http" +tls.implicit = true +<% end -%> + +[server.tls] +enable = true +implicit = false +certificate = "default" + +# Cluster Configuration +<% other_cluster_members = @sorted_cluster_members.reject { |member| member == @facts['networking']['fqdn'] } -%> +<% cluster_size = @sorted_cluster_members.length -%> +[cluster] +node-id = <%= @effective_node_id %> + +<% if cluster_size > 1 -%> +# Peer-to-peer coordination +[cluster.coordinator] +type = "peer-to-peer" +addr = "<%= @bind_address %>:11200" +advertise-addr = "<%= @advertise_address %>:11200" + +<% other_cluster_members.each do |node| -%> +[[cluster.coordinator.peers]] +addr = "<%= node %>:11200" +<% end -%> + +# Cluster roles for 3-node setup +[cluster.roles.purge] +stores = ["1", "2", "3"] +accounts = ["1", "2"] + +[cluster.roles.acme] +renew = ["1"] + +[cluster.roles.metrics] +calculate = ["1", "2"] +push = ["1"] + +[cluster.roles.push-notifications] +push-notifications = ["1", "3"] + +[cluster.roles.fts-indexing] +fts-indexing = ["2", "3"] + +[cluster.roles.bayes-training] +bayes-training = ["1"] + +[cluster.roles.imip-processing] +imip-processing = ["2"] + +[cluster.roles.calendar-alerts] +calendar-alerts = ["3"] +<% end -%> + +# Storage Configuration + +# PostgreSQL store for data, FTS, and in-memory +[store."postgresql"] +type = "postgresql" +host = "<%= @postgresql_host %>" +port = <%= @postgresql_port %> +database = "<%= @postgresql_database %>" +user = "<%= @postgresql_user %>" +password = "<%= @postgresql_password %>" +timeout = "15s" + +[store."postgresql".tls] +enable = <%= @postgresql_ssl %> +allow-invalid-certs = false + +[store."postgresql".pool] +max-connections = 10 + +[store."postgresql".purge] +frequency = "0 3 *" + +# PostgreSQL directory queries for authentication +[store."postgresql".query] +name = "SELECT name, type, secret, description, quota FROM accounts WHERE name = $1 AND active = true" +members = "SELECT member_of FROM group_members WHERE name = $1" +recipients = "SELECT name FROM emails WHERE address = $1 ORDER BY name ASC" +emails = "SELECT address FROM emails WHERE name = $1 ORDER BY type DESC, address ASC" + +# S3/Ceph-RGW store for blobs +[store."s3"] +type = "s3" +bucket = "<%= @s3_bucket %>" +region = "<%= @s3_region %>" +access-key = "<%= @s3_access_key %>" +secret-key = "<%= @s3_secret_key %>" +endpoint = "<%= @s3_endpoint %>" +timeout = "30s" +key-prefix = "<%= @s3_key_prefix %>" +compression = "lz4" + +[store."s3".purge] +frequency = "30 5 *" + +# Storage assignment +[storage] +data = "postgresql" +fts = "postgresql" +blob = "s3" +lookup = "postgresql" +in-memory = "postgresql" + +# Directory configuration +[directory."postgresql"] +type = "sql" +store = "postgresql" + +[directory."postgresql".columns] +name = "name" +description = "description" +secret = "secret" +email = "address" +quota = "quota" +class = "type" + +# Authentication configuration +[authentication] +fallback-admin = ["admin@<%= @domains.first %>"] + +[authentication.directory] +directories = ["postgresql"] + +# Authorization configuration +[authorization] +directory = "postgresql" + +# JMAP configuration +[jmap] +directory = "postgresql" + +[jmap.protocol] +request-max-size = 10485760 +get.max-objects = 500 +query.max-results = 5000 +changes.max-results = 5000 +upload.max-size = 50000000 +upload.ttl = "1h" + +# IMAP configuration +[imap] +directory = "postgresql" + +[imap.protocol] +max-requests = 64 + +# SMTP configuration for postfix relay +[session.rcpt] +relay = true + +[session.data] +pipe.command = "sendmail" +pipe.arguments = ["-i", "-f", "{sender}", "{recipient}"] + +# Outbound SMTP configuration +[queue] +path = "<%= @data_dir %>/queue" + +[queue.schedule] +retry = ["2s", "5s", "1m", "5m", "15m", "30m", "1h", "2h"] +notify = ["1d", "3d"] +expire = "5d" + +[session.extensions] +future-release = "7d" + +# Relay configuration for postfix +[remote."postfix"] +address = "<%= @postfix_relay_host %>" +port = 25 +protocol = "smtp" + +[session.mail] +rewrite = [ + { if = "!is_local_domain(rcpt_domain)", then = "set('remote', 'postfix')" } +] + +# HTTP configuration +[server.http] +use-x-forwarded = false +permissive-cors = false + +# Disable spam filtering (handled by postfix) +[session.ehlo] +reject-non-fqdn = false + +[session.mail] +rewrite = [] + +[session.rcpt] +directory = "postgresql" +relay = true +max-recipients = 25 + +[session.data] +max-messages = 10 +max-message-size = 52428800 + +# TLS configuration +[certificate."default"] +cert = "%{file:<%= @tls_cert %>}%" +private-key = "%{file:<%= @tls_key %>}%" + +# Logging configuration +[tracer] +type = "log" +level = "<%= @log_level %>" +ansi = false +multiline = true + +[tracer.file] +path = "/var/log/stalwart/stalwart.log" +rotate = "daily" +keep = 30 + +# Report storage +[report] +path = "<%= @data_dir %>/reports" +hash = "sha256" +encrypt = false + +# Metrics configuration +[metrics] +prometheus.enable = true +prometheus.port = 9090 diff --git a/site/roles/manifests/infra/mail/backend.pp b/site/roles/manifests/infra/mail/backend.pp index c13b141..a7c64a1 100644 --- a/site/roles/manifests/infra/mail/backend.pp +++ b/site/roles/manifests/infra/mail/backend.pp @@ -1,9 +1,14 @@ -# a role to deploy a imap/pop3 backend for mail services +# roles::infra::mail::backend +# +# Configures Stalwart IMAP backend servers in a clustered configuration +# with PostgreSQL for data/fts/memory storage and S3/Ceph-RGW for blob storage. +# Integrates with postfix hosts for SMTP relay functionality. +# class roles::infra::mail::backend { if $facts['firstrun'] { include profiles::defaults include profiles::firstrun::init - }else{ + } else { include profiles::defaults include profiles::base }