Ansible config for hetzner mailserver

This commit is contained in:
Ruben Hensen
2026-03-14 22:24:53 +01:00
parent bc2f75294d
commit 2a3bfb5d1a
30 changed files with 775 additions and 0 deletions
@@ -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"