From 2a3bfb5d1aaf99e0e7b8ecce5c5d89a411dec352 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Sat, 14 Mar 2026 22:24:53 +0100 Subject: [PATCH] Ansible config for hetzner mailserver --- ansible/mailserver/.gitignore | 1 + ansible/mailserver/README.md | 114 ++++++++++++++++++ ansible/mailserver/ansible.cfg | 6 + .../mailserver/host_vars/mailserver/vars.yml | 2 + .../mailserver/host_vars/mailserver/vault.yml | 10 ++ ansible/mailserver/inventory.yml | 7 ++ ansible/mailserver/playbook.yml | 11 ++ .../mailserver/roles/base/defaults/main.yml | 4 + .../mailserver/roles/base/handlers/main.yml | 5 + ansible/mailserver/roles/base/tasks/main.yml | 88 ++++++++++++++ .../roles/fail2ban/defaults/main.yml | 18 +++ .../roles/fail2ban/handlers/main.yml | 5 + .../mailserver/roles/fail2ban/tasks/main.yml | 25 ++++ .../roles/fail2ban/templates/jail.local.j2 | 20 +++ .../fail2ban/templates/stalwart-auth.conf.j2 | 4 + .../mailserver/roles/ssh/defaults/main.yml | 6 + .../mailserver/roles/ssh/handlers/main.yml | 5 + ansible/mailserver/roles/ssh/tasks/main.yml | 10 ++ .../roles/ssh/templates/sshd_config.j2 | 38 ++++++ .../roles/stalwart/defaults/main.yml | 87 +++++++++++++ .../roles/stalwart/handlers/main.yml | 9 ++ .../roles/stalwart/tasks/configure.yml | 22 ++++ .../roles/stalwart/tasks/install.yml | 36 ++++++ .../mailserver/roles/stalwart/tasks/main.yml | 9 ++ .../roles/stalwart/tasks/prepare.yml | 67 ++++++++++ .../roles/stalwart/templates/config.toml.j2 | 94 +++++++++++++++ .../stalwart/templates/stalwart.service.j2 | 21 ++++ .../mailserver/roles/stalwart/vars/main.yml | 13 ++ .../mailserver/roles/ufw/defaults/main.yml | 11 ++ ansible/mailserver/roles/ufw/tasks/main.yml | 27 +++++ 30 files changed, 775 insertions(+) create mode 100644 ansible/mailserver/.gitignore create mode 100644 ansible/mailserver/README.md create mode 100644 ansible/mailserver/ansible.cfg create mode 100644 ansible/mailserver/host_vars/mailserver/vars.yml create mode 100644 ansible/mailserver/host_vars/mailserver/vault.yml create mode 100644 ansible/mailserver/inventory.yml create mode 100644 ansible/mailserver/playbook.yml create mode 100644 ansible/mailserver/roles/base/defaults/main.yml create mode 100644 ansible/mailserver/roles/base/handlers/main.yml create mode 100644 ansible/mailserver/roles/base/tasks/main.yml create mode 100644 ansible/mailserver/roles/fail2ban/defaults/main.yml create mode 100644 ansible/mailserver/roles/fail2ban/handlers/main.yml create mode 100644 ansible/mailserver/roles/fail2ban/tasks/main.yml create mode 100644 ansible/mailserver/roles/fail2ban/templates/jail.local.j2 create mode 100644 ansible/mailserver/roles/fail2ban/templates/stalwart-auth.conf.j2 create mode 100644 ansible/mailserver/roles/ssh/defaults/main.yml create mode 100644 ansible/mailserver/roles/ssh/handlers/main.yml create mode 100644 ansible/mailserver/roles/ssh/tasks/main.yml create mode 100644 ansible/mailserver/roles/ssh/templates/sshd_config.j2 create mode 100644 ansible/mailserver/roles/stalwart/defaults/main.yml create mode 100644 ansible/mailserver/roles/stalwart/handlers/main.yml create mode 100644 ansible/mailserver/roles/stalwart/tasks/configure.yml create mode 100644 ansible/mailserver/roles/stalwart/tasks/install.yml create mode 100644 ansible/mailserver/roles/stalwart/tasks/main.yml create mode 100644 ansible/mailserver/roles/stalwart/tasks/prepare.yml create mode 100644 ansible/mailserver/roles/stalwart/templates/config.toml.j2 create mode 100644 ansible/mailserver/roles/stalwart/templates/stalwart.service.j2 create mode 100644 ansible/mailserver/roles/stalwart/vars/main.yml create mode 100644 ansible/mailserver/roles/ufw/defaults/main.yml create mode 100644 ansible/mailserver/roles/ufw/tasks/main.yml diff --git a/ansible/mailserver/.gitignore b/ansible/mailserver/.gitignore new file mode 100644 index 0000000..b36779c --- /dev/null +++ b/ansible/mailserver/.gitignore @@ -0,0 +1 @@ +.vault_pass diff --git a/ansible/mailserver/README.md b/ansible/mailserver/README.md new file mode 100644 index 0000000..d9a5713 --- /dev/null +++ b/ansible/mailserver/README.md @@ -0,0 +1,114 @@ +# Mailserver Ansible + +Ansible playbook that deploys and hardens a [Stalwart](https://stalw.art) all-in-one mail server on Ubuntu. + +## What it does + +| Role | Purpose | +|------|---------| +| `base` | apt upgrades, unattended-upgrades, timezone, swap, sysctl hardening | +| `ssh` | Key-only root login, disable password auth, rate limiting | +| `ufw` | Firewall — deny all incoming except SSH, SMTP, IMAP, HTTPS | +| `fail2ban` | Brute force protection for SSH + Stalwart auth | +| `stalwart` | Stalwart mail server with built-in ACME (Let's Encrypt) | + +## Prerequisites + +- Ubuntu server with root SSH key access +- DNS: `mail.rubenhensen.nl` → server IP (A record) +- DNS: MX record for your domain pointing to `mail.rubenhensen.nl` +- Ansible installed locally (`brew install ansible`) + +## Setup + +```bash +cd ~/Repos/k8scd/ansible/mailserver + +# 1. Create vault password file (gitignored) +echo 'your-vault-password' > .vault_pass +chmod 600 .vault_pass + +# 2. Create encrypted secrets +ansible-vault create host_vars/mailserver/vault.yml +# Add: +# --- +# vault_stalwart_fallback_admin_password: "your-admin-password" + +# 3. Edit inventory if server IP changed +# inventory.yml → ansible_host + +# 4. Run +ansible-playbook playbook.yml +``` + +## Day-to-day operations + +**Re-run after config changes:** +```bash +ansible-playbook playbook.yml +``` + +**Edit encrypted secrets:** +```bash +ansible-vault edit host_vars/mailserver/vault.yml +``` + +**Run only a specific role:** +```bash +ansible-playbook playbook.yml --tags stalwart +``` +(Note: tags aren't configured yet — use `--start-at-task "task name"` or add tags if needed) + +**Upgrade Stalwart:** +Bump `stalwart_version` in `roles/stalwart/defaults/main.yml` and re-run. It only re-downloads when the version changes. + +## File structure + +``` +├── ansible.cfg # Ansible settings + vault password file path +├── inventory.yml # Server IP, SSH user, python interpreter +├── .vault_pass # Vault password (gitignored) +├── .gitignore +├── host_vars/mailserver/ +│ ├── vars.yml # Maps variables to vault references +│ └── vault.yml # Encrypted secrets (committed as ciphertext) +└── roles/ + ├── base/ # OS hardening + swap + ├── ssh/ # sshd_config template + ├── ufw/ # Firewall rules + ├── fail2ban/ # Jails for SSH + Stalwart + └── stalwart/ # Mail server install + config.toml template +``` + +## Stalwart admin + +Web admin: `https://mail.rubenhensen.nl` +Login: `admin` / (password from vault) + +From the web UI you can manage domains, accounts, DKIM keys, and other mail settings. + +## TLS certificates + +Handled automatically by Stalwart's built-in ACME support (Let's Encrypt, `tls-alpn-01` challenge on port 443). No certbot needed. Certificates auto-renew. + +## Ports + +| Port | Service | +|------|---------| +| 22 | SSH | +| 25 | SMTP | +| 465 | SMTP submission (implicit TLS) | +| 587 | SMTP submission (STARTTLS) | +| 993 | IMAP (implicit TLS) | +| 443 | HTTPS (web admin + JMAP + ACME) | +| 8080 | HTTP | + +## If something breaks + +- Stalwart logs: `/opt/stalwart/logs/` +- Stalwart config: `/opt/stalwart/etc/config.toml` +- Service status: `systemctl status stalwart` +- fail2ban status: `fail2ban-client status` / `fail2ban-client status sshd` +- Firewall: `ufw status` +- Check banned IPs: `fail2ban-client status stalwart-auth` +- Unban an IP: `fail2ban-client set unbanip ` diff --git a/ansible/mailserver/ansible.cfg b/ansible/mailserver/ansible.cfg new file mode 100644 index 0000000..71b854a --- /dev/null +++ b/ansible/mailserver/ansible.cfg @@ -0,0 +1,6 @@ +[defaults] +inventory = inventory.yml +roles_path = roles +host_key_checking = False +retry_files_enabled = False +vault_password_file = .vault_pass diff --git a/ansible/mailserver/host_vars/mailserver/vars.yml b/ansible/mailserver/host_vars/mailserver/vars.yml new file mode 100644 index 0000000..f98df3c --- /dev/null +++ b/ansible/mailserver/host_vars/mailserver/vars.yml @@ -0,0 +1,2 @@ +--- +stalwart_fallback_admin_password: "{{ vault_stalwart_fallback_admin_password }}" diff --git a/ansible/mailserver/host_vars/mailserver/vault.yml b/ansible/mailserver/host_vars/mailserver/vault.yml new file mode 100644 index 0000000..92394d9 --- /dev/null +++ b/ansible/mailserver/host_vars/mailserver/vault.yml @@ -0,0 +1,10 @@ +$ANSIBLE_VAULT;1.1;AES256 +31346664666161363261666662653164623562376161313865323065636664643331303234633263 +6164323366303938346231636134323762323634343731340a313966306130333832363764323633 +61326261613839303765303239653439343563333031643030363764653735663337356631613331 +3162643836623463370a323834393931373434373636366266376639366561336333653165343166 +66623162343032663034613663373133663563343463613935306366363461616636646630663961 +37376132666531323230383530633430353762346237343035393065656230306430376334333937 +64383632366333316337323036303532343838343035653631396165313939303465653730303865 +35393935636361633135343438666263633165386435656361613136313039303235303134333031 +6433 diff --git a/ansible/mailserver/inventory.yml b/ansible/mailserver/inventory.yml new file mode 100644 index 0000000..97a260c --- /dev/null +++ b/ansible/mailserver/inventory.yml @@ -0,0 +1,7 @@ +all: + hosts: + mailserver: + ansible_host: 46.224.26.65 + ansible_user: root + ansible_ssh_private_key_file: ~/.ssh/id_ed25519 + ansible_python_interpreter: /usr/bin/python3.12 diff --git a/ansible/mailserver/playbook.yml b/ansible/mailserver/playbook.yml new file mode 100644 index 0000000..4e98376 --- /dev/null +++ b/ansible/mailserver/playbook.yml @@ -0,0 +1,11 @@ +--- +- name: Configure and harden mail server + hosts: mailserver + become: true + + roles: + - base + - ssh + - ufw + - fail2ban + - stalwart diff --git a/ansible/mailserver/roles/base/defaults/main.yml b/ansible/mailserver/roles/base/defaults/main.yml new file mode 100644 index 0000000..66b7129 --- /dev/null +++ b/ansible/mailserver/roles/base/defaults/main.yml @@ -0,0 +1,4 @@ +--- +base_timezone: "Europe/Amsterdam" +base_swap_size: "2G" +base_swap_enabled: true diff --git a/ansible/mailserver/roles/base/handlers/main.yml b/ansible/mailserver/roles/base/handlers/main.yml new file mode 100644 index 0000000..0a81482 --- /dev/null +++ b/ansible/mailserver/roles/base/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: reload sysctl + ansible.builtin.command: + cmd: sysctl --system + changed_when: true diff --git a/ansible/mailserver/roles/base/tasks/main.yml b/ansible/mailserver/roles/base/tasks/main.yml new file mode 100644 index 0000000..3269159 --- /dev/null +++ b/ansible/mailserver/roles/base/tasks/main.yml @@ -0,0 +1,88 @@ +--- +- name: Update apt cache and upgrade packages + ansible.builtin.apt: + update_cache: true + upgrade: safe + cache_valid_time: 3600 + +- name: Install essential packages + ansible.builtin.apt: + name: + - unattended-upgrades + - apt-listchanges + - logrotate + - curl + - tar + state: present + +- name: Enable unattended upgrades + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + mode: "0644" + +- name: Set timezone + community.general.timezone: + name: "{{ base_timezone }}" + +- name: Deploy sysctl hardening config + ansible.builtin.copy: + dest: /etc/sysctl.d/99-hardening.conf + content: | + # Prevent IP spoofing + net.ipv4.conf.all.rp_filter = 1 + net.ipv4.conf.default.rp_filter = 1 + # Ignore ICMP redirects + net.ipv4.conf.all.accept_redirects = 0 + net.ipv6.conf.all.accept_redirects = 0 + net.ipv4.conf.all.send_redirects = 0 + # Ignore source-routed packets + net.ipv4.conf.all.accept_source_route = 0 + net.ipv6.conf.all.accept_source_route = 0 + # SYN flood protection + net.ipv4.tcp_syncookies = 1 + # Log martians + net.ipv4.conf.all.log_martians = 1 + mode: "0644" + notify: reload sysctl + +- name: Create swap file + when: base_swap_enabled + block: + - name: Check if swap file exists + ansible.builtin.stat: + path: /swapfile + register: swap_file + + - name: Create swap file + ansible.builtin.command: + cmd: "fallocate -l {{ base_swap_size }} /swapfile" + when: not swap_file.stat.exists + changed_when: true + + - name: Set swap file permissions + ansible.builtin.file: + path: /swapfile + mode: "0600" + when: not swap_file.stat.exists + + - name: Format swap file + ansible.builtin.command: + cmd: mkswap /swapfile + when: not swap_file.stat.exists + changed_when: true + + - name: Enable swap file + ansible.builtin.command: + cmd: swapon /swapfile + when: not swap_file.stat.exists + changed_when: true + + - name: Add swap to fstab + ansible.builtin.lineinfile: + path: /etc/fstab + line: "/swapfile none swap sw 0 0" + state: present diff --git a/ansible/mailserver/roles/fail2ban/defaults/main.yml b/ansible/mailserver/roles/fail2ban/defaults/main.yml new file mode 100644 index 0000000..150e3bd --- /dev/null +++ b/ansible/mailserver/roles/fail2ban/defaults/main.yml @@ -0,0 +1,18 @@ +--- +fail2ban_bantime: "1h" +fail2ban_findtime: "10m" +fail2ban_maxretry: 5 + +fail2ban_jails: + - name: sshd + enabled: true + port: "{{ ssh_port | default(22) }}" + maxretry: 3 + bantime: "1h" + - name: stalwart-auth + enabled: true + port: "25,465,587,993,443" + maxretry: 5 + bantime: "1h" + logpath: "/opt/stalwart/logs/stalwart.log*" + filter: stalwart-auth diff --git a/ansible/mailserver/roles/fail2ban/handlers/main.yml b/ansible/mailserver/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..6f6780a --- /dev/null +++ b/ansible/mailserver/roles/fail2ban/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart fail2ban + ansible.builtin.systemd: + name: fail2ban + state: restarted diff --git a/ansible/mailserver/roles/fail2ban/tasks/main.yml b/ansible/mailserver/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..4d499ca --- /dev/null +++ b/ansible/mailserver/roles/fail2ban/tasks/main.yml @@ -0,0 +1,25 @@ +--- +- name: Install fail2ban + ansible.builtin.apt: + name: fail2ban + state: present + +- name: Deploy Stalwart auth filter + ansible.builtin.template: + src: stalwart-auth.conf.j2 + dest: /etc/fail2ban/filter.d/stalwart-auth.conf + mode: "0644" + notify: restart fail2ban + +- name: Deploy jail.local + ansible.builtin.template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.local + mode: "0644" + notify: restart fail2ban + +- name: Enable and start fail2ban + ansible.builtin.systemd: + name: fail2ban + enabled: true + state: started diff --git a/ansible/mailserver/roles/fail2ban/templates/jail.local.j2 b/ansible/mailserver/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..5906f7f --- /dev/null +++ b/ansible/mailserver/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,20 @@ +[DEFAULT] +bantime = {{ fail2ban_bantime }} +findtime = {{ fail2ban_findtime }} +maxretry = {{ fail2ban_maxretry }} +banaction = ufw + +{% for jail in fail2ban_jails %} +[{{ jail.name }}] +enabled = {{ jail.enabled | lower }} +port = {{ jail.port }} +maxretry = {{ jail.maxretry | default(fail2ban_maxretry) }} +bantime = {{ jail.bantime | default(fail2ban_bantime) }} +{% if jail.logpath is defined %} +logpath = {{ jail.logpath }} +{% endif %} +{% if jail.filter is defined %} +filter = {{ jail.filter }} +{% endif %} + +{% endfor %} diff --git a/ansible/mailserver/roles/fail2ban/templates/stalwart-auth.conf.j2 b/ansible/mailserver/roles/fail2ban/templates/stalwart-auth.conf.j2 new file mode 100644 index 0000000..1d821f3 --- /dev/null +++ b/ansible/mailserver/roles/fail2ban/templates/stalwart-auth.conf.j2 @@ -0,0 +1,4 @@ +[Definition] +failregex = ^\s*\S+ authentication\s+error\s+.*?remote\.ip= + ^\s*.*?Authentication failed.*?ip= +ignoreregex = diff --git a/ansible/mailserver/roles/ssh/defaults/main.yml b/ansible/mailserver/roles/ssh/defaults/main.yml new file mode 100644 index 0000000..efea399 --- /dev/null +++ b/ansible/mailserver/roles/ssh/defaults/main.yml @@ -0,0 +1,6 @@ +--- +ssh_port: 22 +ssh_permit_root_login: "prohibit-password" # allows key-based root login +ssh_password_authentication: "no" +ssh_max_auth_tries: 3 +ssh_allowed_users: "root" # space-separated list diff --git a/ansible/mailserver/roles/ssh/handlers/main.yml b/ansible/mailserver/roles/ssh/handlers/main.yml new file mode 100644 index 0000000..2000127 --- /dev/null +++ b/ansible/mailserver/roles/ssh/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sshd + ansible.builtin.systemd: + name: sshd + state: restarted diff --git a/ansible/mailserver/roles/ssh/tasks/main.yml b/ansible/mailserver/roles/ssh/tasks/main.yml new file mode 100644 index 0000000..59b40be --- /dev/null +++ b/ansible/mailserver/roles/ssh/tasks/main.yml @@ -0,0 +1,10 @@ +--- +- name: Deploy hardened sshd config + ansible.builtin.template: + src: sshd_config.j2 + dest: /etc/ssh/sshd_config + owner: root + group: root + mode: "0600" + validate: "sshd -t -f %s" + notify: restart sshd diff --git a/ansible/mailserver/roles/ssh/templates/sshd_config.j2 b/ansible/mailserver/roles/ssh/templates/sshd_config.j2 new file mode 100644 index 0000000..064d9ef --- /dev/null +++ b/ansible/mailserver/roles/ssh/templates/sshd_config.j2 @@ -0,0 +1,38 @@ +Port {{ ssh_port }} +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +# Authentication +PermitRootLogin {{ ssh_permit_root_login }} +PasswordAuthentication {{ ssh_password_authentication }} +ChallengeResponseAuthentication no +KbdInteractiveAuthentication no +UsePAM yes +MaxAuthTries {{ ssh_max_auth_tries }} +PubkeyAuthentication yes +AuthorizedKeysFile .ssh/authorized_keys + +# Disable unused auth methods +GSSAPIAuthentication no +KerberosAuthentication no +HostbasedAuthentication no +PermitEmptyPasswords no + +# Session +X11Forwarding no +PrintMotd no +TCPKeepAlive yes +ClientAliveInterval 300 +ClientAliveCountMax 2 +LoginGraceTime 30 + +# Restrict users +AllowUsers {{ ssh_allowed_users }} + +# Logging +LogLevel VERBOSE +SyslogFacility AUTH + +# SFTP +Subsystem sftp /usr/lib/openssh/sftp-server diff --git a/ansible/mailserver/roles/stalwart/defaults/main.yml b/ansible/mailserver/roles/stalwart/defaults/main.yml new file mode 100644 index 0000000..55e7b5c --- /dev/null +++ b/ansible/mailserver/roles/stalwart/defaults/main.yml @@ -0,0 +1,87 @@ +--- +stalwart_version: "0.15.5" + +stalwart_system_user: "stalwart" +stalwart_system_group: "stalwart" + +stalwart_service_state: "started" +stalwart_service_enabled: true + +stalwart_server_hostname: "mail.rubenhensen.nl" +stalwart_server_max_connections: 8192 + +# Storage backend +stalwart_storage_data: "rocksdb" +stalwart_storage_fts: "rocksdb" +stalwart_storage_blob: "rocksdb" +stalwart_storage_lookup: "rocksdb" + +# Admin credentials +stalwart_fallback_admin_login: "admin" +stalwart_fallback_admin_password: "changeme!" # override via vault in host_vars + +# Listeners +stalwart_server_listeners: + - name: "smtp" + bind: "[::]:25" + protocol: "smtp" + - name: "submission" + bind: "[::]:587" + protocol: "smtp" + - name: "submissions" + bind: "[::]:465" + protocol: "smtp" + options: + tls.implicit: true + - name: "imaptls" + bind: "[::]:993" + protocol: "imap" + options: + tls.implicit: true + - name: "https" + bind: "[::]:443" + protocol: "http" + options: + tls.implicit: true + - name: "http" + bind: "[::]:8080" + protocol: "http" + +# Stores +stalwart_stores: + - name: "rocksdb" + type: "rocksdb" + options: + path: "{{ stalwart_data_path }}" + compression: "lz4" + +# Directory +stalwart_directory_type: "internal" +stalwart_directory_options: + store: "rocksdb" + +# Tracers +stalwart_tracers: + - type: "stdout" + options: + level: "info" + ansi: false + enable: true + - type: "log" + options: + level: "info" + path: "{{ stalwart_logs_path }}" + prefix: "stalwart.log" + rotate: "daily" + ansi: false + enable: true + +# ACME / Let's Encrypt +stalwart_acme_enabled: true +stalwart_acme_directory: "https://acme-v02.api.letsencrypt.org/directory" +stalwart_acme_contact: "mailto:admin@rubenhensen.nl" +stalwart_acme_domains: + - "mail.rubenhensen.nl" + +# Extra config sections (list of {name, options} dicts) +stalwart_additional_configs: [] diff --git a/ansible/mailserver/roles/stalwart/handlers/main.yml b/ansible/mailserver/roles/stalwart/handlers/main.yml new file mode 100644 index 0000000..28adccc --- /dev/null +++ b/ansible/mailserver/roles/stalwart/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: restart stalwart + ansible.builtin.systemd: + name: "{{ stalwart_service_name }}" + state: restarted diff --git a/ansible/mailserver/roles/stalwart/tasks/configure.yml b/ansible/mailserver/roles/stalwart/tasks/configure.yml new file mode 100644 index 0000000..a3ec56c --- /dev/null +++ b/ansible/mailserver/roles/stalwart/tasks/configure.yml @@ -0,0 +1,22 @@ +--- +- name: Initialize Stalwart + ansible.builtin.command: + cmd: "{{ stalwart_executable_path }} --init {{ stalwart_install_path }}" + creates: "{{ stalwart_config_file_path }}" + become: true + become_user: "{{ stalwart_system_user }}" + +- name: Deploy configuration + ansible.builtin.template: + src: "config.toml.j2" + dest: "{{ stalwart_config_file_path }}" + owner: "{{ stalwart_system_user }}" + group: "{{ stalwart_system_group }}" + mode: "0640" + notify: restart stalwart + +- name: Ensure service is in correct state + ansible.builtin.service: + name: "{{ stalwart_service_name }}" + state: "{{ stalwart_service_state }}" + enabled: "{{ stalwart_service_enabled }}" \ No newline at end of file diff --git a/ansible/mailserver/roles/stalwart/tasks/install.yml b/ansible/mailserver/roles/stalwart/tasks/install.yml new file mode 100644 index 0000000..3a7599b --- /dev/null +++ b/ansible/mailserver/roles/stalwart/tasks/install.yml @@ -0,0 +1,36 @@ +--- +- name: Ensure directories exist + ansible.builtin.file: + path: "{{ directory_to_create }}" + state: "directory" + owner: "{{ stalwart_system_user }}" + group: "{{ stalwart_system_group }}" + mode: "0750" + loop: + - "{{ stalwart_install_path }}" + - "{{ stalwart_bin_path }}" + - "{{ stalwart_config_path }}" + - "{{ stalwart_logs_path }}" + - "{{ stalwart_data_path }}" + loop_control: + loop_var: "directory_to_create" + +- name: Download and extract Stalwart + ansible.builtin.unarchive: + src: "{{ stalwart_download_url }}" + dest: "{{ stalwart_bin_path }}" + owner: "{{ stalwart_system_user }}" + group: "{{ stalwart_system_group }}" + mode: "0750" + remote_src: true + notify: restart stalwart + when: (not stalwart_exec.stat.exists) or (stalwart_current_version != stalwart_version) + +- name: Install systemd service file + ansible.builtin.template: + src: "stalwart.service.j2" + dest: "{{ stalwart_service_file_path }}" + mode: "0644" + notify: + - reload systemd + - restart stalwart diff --git a/ansible/mailserver/roles/stalwart/tasks/main.yml b/ansible/mailserver/roles/stalwart/tasks/main.yml new file mode 100644 index 0000000..3b91e70 --- /dev/null +++ b/ansible/mailserver/roles/stalwart/tasks/main.yml @@ -0,0 +1,9 @@ +--- +- name: Import prepare tasks + ansible.builtin.import_tasks: "prepare.yml" + +- name: Import install tasks + ansible.builtin.import_tasks: "install.yml" + +- name: Import configure tasks + ansible.builtin.import_tasks: "configure.yml" diff --git a/ansible/mailserver/roles/stalwart/tasks/prepare.yml b/ansible/mailserver/roles/stalwart/tasks/prepare.yml new file mode 100644 index 0000000..5cfe350 --- /dev/null +++ b/ansible/mailserver/roles/stalwart/tasks/prepare.yml @@ -0,0 +1,67 @@ +--- +- name: Create Stalwart group + ansible.builtin.group: + name: "{{ stalwart_system_group }}" + system: true + +- name: Create Stalwart user + ansible.builtin.user: + name: "{{ stalwart_system_user }}" + group: "{{ stalwart_system_group }}" + create_home: false + home: "{{ stalwart_install_path }}" + shell: "/usr/sbin/nologin" + system: true + +- name: Create Ansible remote_tmp for stalwart user + ansible.builtin.file: + path: "{{ stalwart_install_path }}/.ansible/tmp" + state: directory + owner: "{{ stalwart_system_user }}" + group: "{{ stalwart_system_group }}" + mode: "0755" + +- name: Check if already installed + ansible.builtin.stat: + path: "{{ stalwart_executable_path }}" + register: stalwart_exec + +- name: Get version if stalwart is installed + when: stalwart_exec.stat.exists + block: + - name: Get version + ansible.builtin.command: "{{ stalwart_executable_path }} -V" + failed_when: false + changed_when: false + check_mode: false + register: stalwart_output + + - name: Set current installed version + ansible.builtin.set_fact: + stalwart_current_version: "{{ stalwart_output.stdout_lines[0] }}" + +- name: Check if config exists + ansible.builtin.stat: + path: "{{ stalwart_config_file_path }}" + register: stalwart_config_file_st + +- name: Preserve admin password salt from existing config + when: stalwart_config_file_st.stat.exists + block: + - name: Get config file content + ansible.builtin.slurp: + src: "{{ stalwart_config_file_path }}" + register: config_file_content + + - name: Get existing password hash + ansible.builtin.set_fact: + hash_stored: "{{ config_file_content['content'] | b64decode | regex_search('(\\n|^)(secret|authentication\\.fallback-admin\\.secret) = \"(.*)\"\\n', '\\3') }}" + + - name: Get existing salt + ansible.builtin.set_fact: + stalwart_fallback_admin_password_salt: "{{ (hash_stored[0] | split('$'))[2] }}" + +- name: Create salt if it does not exist + ansible.builtin.set_fact: + stalwart_fallback_admin_password_salt: "{{ lookup('password', '/dev/null length=16 chars=ascii_letters,digits') }}" + when: stalwart_fallback_admin_password_salt is undefined diff --git a/ansible/mailserver/roles/stalwart/templates/config.toml.j2 b/ansible/mailserver/roles/stalwart/templates/config.toml.j2 new file mode 100644 index 0000000..ed4e3ed --- /dev/null +++ b/ansible/mailserver/roles/stalwart/templates/config.toml.j2 @@ -0,0 +1,94 @@ +[server] +hostname = "{{ stalwart_server_hostname }}" +max-connections = {{ stalwart_server_max_connections }} +{% if stalwart_acme_enabled %} +tls.certificate = "acme" +{% endif %} + +{% for listener in stalwart_server_listeners %} +[server.listener.{{ listener.name }}] +bind = "{{ listener.bind }}" +protocol = "{{ listener.protocol }}" +{% for option_name, option_value in (listener.options | default({})).items() %} +{{ option_name }} = +{%- if option_value is string %} "{{ option_value }}" +{%- elif option_value is boolean %} {{ option_value | lower }} +{%- else %} {{ option_value }} +{%- endif %} + +{% endfor %} + +{% endfor %} +{% if stalwart_acme_enabled %} +[certificate.acme] +type = "acme" +provider = "letsencrypt" +domains = [{% for d in stalwart_acme_domains %}"{{ d }}"{% if not loop.last %}, {% endif %}{% endfor %}] + +[acme.letsencrypt] +directory = "{{ stalwart_acme_directory }}" +contact = ["{{ stalwart_acme_contact }}"] +challenge = "tls-alpn-01" +default = true +{% endif %} + +[storage] +data = "{{ stalwart_storage_data }}" +fts = "{{ stalwart_storage_fts }}" +blob = "{{ stalwart_storage_blob }}" +lookup = "{{ stalwart_storage_lookup }}" +directory = "{{ stalwart_directory_type }}" + +{% for store in stalwart_stores %} +[store.{{ store.name }}] +type = "{{ store.type }}" +{% for option_name, option_value in (store.options | default({})).items() %} +{{ option_name }} = +{%- if option_value is string %} "{{ option_value }}" +{%- elif option_value is boolean %} {{ option_value | lower }} +{%- else %} {{ option_value }} +{%- endif %} + +{% endfor %} + +{% endfor %} +[directory.{{ stalwart_directory_type }}] +type = "{{ stalwart_directory_type }}" +{% for option_name, option_value in stalwart_directory_options.items() %} +{{ option_name }} = +{%- if option_value is string %} "{{ option_value }}" +{%- elif option_value is boolean %} {{ option_value | lower }} +{%- else %} {{ option_value }} +{%- endif %} + +{% endfor %} + +{% for tracer in stalwart_tracers %} +[tracer.{{ tracer.type }}] +type = "{{ tracer.type }}" +{% for option_name, option_value in (tracer.options | default({})).items() %} +{{ option_name }} = +{%- if option_value is string %} "{{ option_value }}" +{%- elif option_value is boolean %} {{ option_value | lower }} +{%- else %} {{ option_value }} +{%- endif %} + +{% endfor %} + +{% endfor %} +[authentication.fallback-admin] +user = "{{ stalwart_fallback_admin_login }}" +secret = "{{ stalwart_fallback_admin_password | ansible.builtin.password_hash(salt=stalwart_fallback_admin_password_salt, rounds=5000) }}" + +{% for config in stalwart_additional_configs | default([]) %} +[{{ config.name }}] +{% for option_name, option_value in (config.options | default({})).items() %} +{{ option_name }} = +{%- if option_value is string %} "{{ option_value }}" +{%- elif option_value is boolean %} {{ option_value | lower }} +{%- else %} {{ option_value }} +{%- endif %} + +{% endfor %} + +{% endfor %} diff --git a/ansible/mailserver/roles/stalwart/templates/stalwart.service.j2 b/ansible/mailserver/roles/stalwart/templates/stalwart.service.j2 new file mode 100644 index 0000000..ca52bb2 --- /dev/null +++ b/ansible/mailserver/roles/stalwart/templates/stalwart.service.j2 @@ -0,0 +1,21 @@ +[Unit] +Description=Stalwart Mail Server +Conflicts=postfix.service sendmail.service exim4.service +ConditionPathExists={{ stalwart_config_file_path }} +After=network-online.target + +[Service] +Type=simple +LimitNOFILE=65536 +KillMode=process +KillSignal=SIGINT +Restart=on-failure +RestartSec=5 +ExecStart={{ stalwart_executable_path }} --config={{ stalwart_config_file_path }} +SyslogIdentifier=stalwart +User={{ stalwart_system_user }} +Group={{ stalwart_system_group }} +AmbientCapabilities=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/ansible/mailserver/roles/stalwart/vars/main.yml b/ansible/mailserver/roles/stalwart/vars/main.yml new file mode 100644 index 0000000..31c9a55 --- /dev/null +++ b/ansible/mailserver/roles/stalwart/vars/main.yml @@ -0,0 +1,13 @@ +--- +stalwart_download_url: "https://github.com/stalwartlabs/stalwart/releases/download/v{{ stalwart_version }}/stalwart-{{ ansible_facts['architecture'] }}-unknown-linux-gnu.tar.gz" + +stalwart_install_path: "/opt/stalwart" +stalwart_bin_path: "{{ stalwart_install_path }}/bin" +stalwart_config_path: "{{ stalwart_install_path }}/etc" +stalwart_data_path: "{{ stalwart_install_path }}/data" +stalwart_logs_path: "{{ stalwart_install_path }}/logs" +stalwart_executable_path: "{{ stalwart_bin_path }}/stalwart" +stalwart_config_file_path: "{{ stalwart_config_path }}/config.toml" + +stalwart_service_name: "stalwart" +stalwart_service_file_path: "/etc/systemd/system/{{ stalwart_service_name }}.service" diff --git a/ansible/mailserver/roles/ufw/defaults/main.yml b/ansible/mailserver/roles/ufw/defaults/main.yml new file mode 100644 index 0000000..71d4259 --- /dev/null +++ b/ansible/mailserver/roles/ufw/defaults/main.yml @@ -0,0 +1,11 @@ +--- +ufw_default_incoming: "deny" +ufw_default_outgoing: "allow" +ufw_allowed_ports: + - { port: "{{ ssh_port | default(22) }}", proto: "tcp", comment: "SSH" } + - { port: "25", proto: "tcp", comment: "SMTP" } + - { port: "465", proto: "tcp", comment: "SMTP submissions (implicit TLS)" } + - { port: "587", proto: "tcp", comment: "SMTP submission (STARTTLS)" } + - { port: "993", proto: "tcp", comment: "IMAP (implicit TLS)" } + - { port: "443", proto: "tcp", comment: "HTTPS (web admin + JMAP)" } + - { port: "8080", proto: "tcp", comment: "HTTP (certbot + redirect)" } diff --git a/ansible/mailserver/roles/ufw/tasks/main.yml b/ansible/mailserver/roles/ufw/tasks/main.yml new file mode 100644 index 0000000..33f52bd --- /dev/null +++ b/ansible/mailserver/roles/ufw/tasks/main.yml @@ -0,0 +1,27 @@ +--- +- name: Install ufw + ansible.builtin.apt: + name: ufw + state: present + +- name: Set default incoming policy + community.general.ufw: + direction: incoming + default: "{{ ufw_default_incoming }}" + +- name: Set default outgoing policy + community.general.ufw: + direction: outgoing + default: "{{ ufw_default_outgoing }}" + +- name: Allow configured ports + community.general.ufw: + rule: allow + port: "{{ item.port }}" + proto: "{{ item.proto }}" + comment: "{{ item.comment | default(omit) }}" + loop: "{{ ufw_allowed_ports }}" + +- name: Enable ufw + community.general.ufw: + state: enabled