diff --git a/hieradata/common.yaml b/hieradata/common.yaml index 7ca2200..5dfadad 100644 --- a/hieradata/common.yaml +++ b/hieradata/common.yaml @@ -167,6 +167,12 @@ lookup_options: postfix::virtuals: merge: strategy: deep + stalwart::postgresql_password: + convert_to: Sensitive + stalwart::s3_secret_key: + convert_to: Sensitive + stalwart::fallback_admin_password: + 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..162eedc --- /dev/null +++ b/hieradata/roles/infra/mail/backend.eyaml @@ -0,0 +1,5 @@ +--- +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=] +stalwart::fallback_admin_password: ENC[PKCS7,MIIBmQYJKoZIhvcNAQcDoIIBijCCAYYCAQAxggEhMIIBHQIBADAFMAACAQEwDQYJKoZIhvcNAQEBBQAEggEAMp9wmIhRwj5kxfUcvc+/q/oUs/vBhSqP19ZfErM4vLDK20VOBTnPhSP2lfVh9pqO0c2hpWFeuqBWMynghO+HUBJfAn29Vrc8a9iSBxQ3XuF/uiRq1inOKCQpdsU18TyCrYV9AJFNf9U20JuUoav79m7EKLHS07PHAZ0osqIYy93eXdCFhwXAGHijp4wMMQz/5z1F1mZoSrc1cXe3y8iBeAvvjnRfpw14gOKZBjmEGUbo7AIyc3wax5hbOQYf/v+Hd90JarvAufxGytg9WKO20cChWYbmYDnIkytVt3vHdHf4RT8M635l6qwLr/70O1MdE7bkrVRKP8M3KLyH072pJTBcBgkqhkiG9w0BBwEwHQYJYIZIAWUDBAEqBBDSJwptBDvPd0WpxiIovZsjgDBBwesNW+UNo4b0idhyqsyWL2rtO7wLStWHgUIvRFJACCrTKKqlu7sta6mhu/ZsnF0=] 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..020b1e4 --- /dev/null +++ b/modules/stalwart/README.md @@ -0,0 +1,230 @@ +# 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 + +### Fallback Administrator + +Stalwart includes a fallback administrator account for initial setup and emergency access: + +- **Default username**: `admin` (configurable via `stalwart::fallback_admin_user`) +- **Default password**: `admin` (configurable via `stalwart::fallback_admin_password`) +- **Purpose**: Initial server configuration and emergency access when directory services are unavailable +- **Security**: Password is automatically hashed using SHA-512 crypt format + +**Important**: Change the default password in production by setting different hieradata values: + +```yaml +stalwart::fallback_admin_password: "your-secure-password" +``` + +The fallback admin should only be used for initial setup and emergencies. Create regular admin accounts in PostgreSQL for day-to-day management. + +## 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..2b43ea5 --- /dev/null +++ b/modules/stalwart/manifests/config.pp @@ -0,0 +1,81 @@ +# @summary Manages Stalwart Mail Server configuration +# +# @api private +class stalwart::config { + assert_private() + + # Create base directories (package creates user/group and base dirs) + file { [$stalwart::config_dir, $stalwart::data_dir, $stalwart::webadmin_unpack_path]: + ensure => directory, + owner => 'stalwart', + group => 'stalwart', + mode => '0750', + } + + # Ensure log directory exists + file { '/var/log/stalwart': + ensure => directory, + owner => 'stalwart', + group => 'stalwart', + mode => '0755', + } + + # Main configuration file + file { "${stalwart::config_dir}/config.toml": + ensure => file, + owner => 'stalwart', + group => 'stalwart', + mode => '0640', + content => epp('stalwart/config.toml.epp', { + 'cluster_size' => $stalwart::cluster_size, + 'other_cluster_members' => $stalwart::other_cluster_members, + 'effective_node_id' => $stalwart::effective_node_id, + 'bind_address' => $stalwart::bind_address, + 'advertise_address' => $stalwart::advertise_address, + 'postgresql_host' => $stalwart::postgresql_host, + 'postgresql_port' => $stalwart::postgresql_port, + 'postgresql_database' => $stalwart::postgresql_database, + 'postgresql_user' => $stalwart::postgresql_user, + 'postgresql_password' => $stalwart::postgresql_password.unwrap, + 'postgresql_ssl' => $stalwart::postgresql_ssl, + 's3_endpoint' => $stalwart::s3_endpoint, + 's3_bucket' => $stalwart::s3_bucket, + 's3_region' => $stalwart::s3_region, + 's3_access_key' => $stalwart::s3_access_key, + 's3_secret_key' => $stalwart::s3_secret_key.unwrap, + 's3_key_prefix' => $stalwart::s3_key_prefix, + 'domains' => $stalwart::domains, + 'postfix_relay_host' => $stalwart::postfix_relay_host, + 'enable_imap' => $stalwart::enable_imap, + 'enable_imap_tls' => $stalwart::enable_imap_tls, + 'enable_http' => $stalwart::enable_http, + 'data_dir' => $stalwart::data_dir, + 'tls_cert' => $stalwart::tls_cert, + 'tls_key' => $stalwart::tls_key, + 'log_level' => $stalwart::log_level, + 'fallback_admin_user' => $stalwart::fallback_admin_user, + 'fallback_admin_password' => $stalwart::fallback_admin_password, + 'webadmin_unpack_path' => $stalwart::webadmin_unpack_path, + 'webadmin_resource_url' => $stalwart::webadmin_resource_url, + 'webadmin_auto_update' => $stalwart::webadmin_auto_update, + 'node_facts' => $facts, + }), + 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', + } + +} 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..27b2671 --- /dev/null +++ b/modules/stalwart/manifests/init.pp @@ -0,0 +1,225 @@ +# @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, + String $fallback_admin_user = 'admin', + Sensitive[String] $fallback_admin_password = Sensitive('admin'), + Stdlib::Absolutepath $webadmin_unpack_path = "${data_dir}/webadmin", + Stdlib::HTTPUrl $webadmin_resource_url = 'https://github.com/stalwartlabs/webadmin/releases/latest/download/webadmin.zip', + Boolean $webadmin_auto_update = true, +) { + + # Calculate node_id from last 4 digits of hostname if not provided + $my_fqdn = $facts['networking']['fqdn'] + $hostname = $facts['networking']['hostname'] + + # Query cluster members for validation + $cluster_query = "enc_role='${cluster_role}' and country='${facts['country']}' and region='${facts['region']}'" + $cluster_members_raw = query_nodes($cluster_query, 'networking.fqdn') + $cluster_members = $cluster_members_raw ? { + undef => [], + default => $cluster_members_raw, + } + $sorted_cluster_members = sort($cluster_members) + + # Calculate cluster information for templates + $other_cluster_members = $sorted_cluster_members.filter |$member| { $member != $my_fqdn } + $cluster_size = length($sorted_cluster_members) + + # 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']}") + } + + + # 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'] + } +} 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.epp b/modules/stalwart/templates/config.toml.epp new file mode 100644 index 0000000..83c2c14 --- /dev/null +++ b/modules/stalwart/templates/config.toml.epp @@ -0,0 +1,255 @@ +# Stalwart Mail Server Configuration +# Generated by Puppet - DO NOT EDIT MANUALLY + +[server] +hostname = "<%= $node_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" +<% } -%> + +<% if $enable_imap_tls { -%> +[server.listener."imaps"] +bind = ["<%= $bind_address %>:993"] +protocol = "imap" +tls.implicit = true +<% } -%> + +<% if $enable_http { -%> +[server.listener."https"] +bind = ["<%= $bind_address %>:443"] +protocol = "http" +tls.implicit = true +<% } -%> + +[server.tls] +enable = true +implicit = false +certificate = "default" + +[webadmin] +path = "<%= $webadmin_unpack_path %>" +auto-update = <%= $webadmin_auto_update %> +resource = "<%= $webadmin_resource_url %>" + +# Cluster Configuration +[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 |$node| { -%> +[[cluster.coordinator.peers]] +addr = "<%= $node %>:11200" +<% } -%> + +# 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"] +<% } -%> + +# 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 *" + +# 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" +directory = "internal" +in-memory = "postgresql" + +# Directory configuration +[directory.internal] +type = "internal" +store = "postgresql" + +# Authentication configuration +[authentication.fallback-admin] +user = "<%= $fallback_admin_user %>" +secret = "<%= pw_hash($fallback_admin_password.unwrap, 'SHA-512', 'stalwart') %>" + +[authentication] +[authentication.directory] +directories = ["internal"] + +# Authorization configuration +[authorization] +directory = "internal" + +# JMAP configuration +[jmap] +directory = "internal" + +[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 = "internal" + +[imap.protocol] +max-requests = 64 + +# SMTP configuration for postfix relay +[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" + +# HTTP configuration +[server.http] +use-x-forwarded = false +permissive-cors = false + +# Disable spam filtering (handled by postfix) +[session.ehlo] +reject-non-fqdn = false + +[session.rcpt] +type = "internal" +store = "postgresql" +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 + +# Queue routing configuration +[queue.strategy] +route = [ { if = "is_local_domain('', rcpt_domain)", then = "'local'" }, + { else = "'relay'" } ] + +[queue.route."local"] +type = "local" + +[queue.route."relay"] +type = "relay" +address = "<%= $postfix_relay_host %>" +port = 25 +protocol = "smtp" + +[queue.route."relay".tls] +implicit = false +allow-invalid-certs = false 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 }