mirror of
https://github.com/rubenhensen/k8scd.git
synced 2026-09-17 02:12:55 +02:00
Ansible config for hetzner mailserver
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.vault_pass
|
||||
@@ -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 <jail> unbanip <ip>`
|
||||
@@ -0,0 +1,6 @@
|
||||
[defaults]
|
||||
inventory = inventory.yml
|
||||
roles_path = roles
|
||||
host_key_checking = False
|
||||
retry_files_enabled = False
|
||||
vault_password_file = .vault_pass
|
||||
@@ -0,0 +1,2 @@
|
||||
---
|
||||
stalwart_fallback_admin_password: "{{ vault_stalwart_fallback_admin_password }}"
|
||||
@@ -0,0 +1,10 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
31346664666161363261666662653164623562376161313865323065636664643331303234633263
|
||||
6164323366303938346231636134323762323634343731340a313966306130333832363764323633
|
||||
61326261613839303765303239653439343563333031643030363764653735663337356631613331
|
||||
3162643836623463370a323834393931373434373636366266376639366561336333653165343166
|
||||
66623162343032663034613663373133663563343463613935306366363461616636646630663961
|
||||
37376132666531323230383530633430353762346237343035393065656230306430376334333937
|
||||
64383632366333316337323036303532343838343035653631396165313939303465653730303865
|
||||
35393935636361633135343438666263633165386435656361613136313039303235303134333031
|
||||
6433
|
||||
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
- name: Configure and harden mail server
|
||||
hosts: mailserver
|
||||
become: true
|
||||
|
||||
roles:
|
||||
- base
|
||||
- ssh
|
||||
- ufw
|
||||
- fail2ban
|
||||
- stalwart
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
base_timezone: "Europe/Amsterdam"
|
||||
base_swap_size: "2G"
|
||||
base_swap_enabled: true
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: reload sysctl
|
||||
ansible.builtin.command:
|
||||
cmd: sysctl --system
|
||||
changed_when: true
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: restart fail2ban
|
||||
ansible.builtin.systemd:
|
||||
name: fail2ban
|
||||
state: restarted
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,4 @@
|
||||
[Definition]
|
||||
failregex = ^\s*\S+ authentication\s+error\s+.*?remote\.ip=<HOST>
|
||||
^\s*.*?Authentication failed.*?ip=<HOST>
|
||||
ignoreregex =
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
- name: restart sshd
|
||||
ansible.builtin.systemd:
|
||||
name: sshd
|
||||
state: restarted
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)" }
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user