infra/roles/tools/tasks/main.yml
jack e09e2fe04a
All checks were successful
CI/CD / syntax-check (push) Successful in 1m32s
CI/CD / deploy (push) Successful in 15m57s
fix(snappymail): set admin password via Python+PHP directly in application.ini
djmaze/snappymail does not reliably apply SNAPPYMAIL_ADMIN_PASSWORD.
Instead: read current hash from application.ini, verify it against vault
password using password_verify() in container PHP, update only if wrong.
Idempotent — no restart if password is already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 21:51:58 +07:00

261 lines
8.7 KiB
YAML

---
- name: Create tools root directory
ansible.builtin.file:
path: "{{ tools_root }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_group }}"
mode: "0750"
- name: Create mailserver directories
ansible.builtin.file:
path: "{{ tools_root }}/mailserver/{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- mail-data
- mail-state
- mail-logs
- config
- name: Create snappymail data directory
ansible.builtin.file:
path: "{{ tools_root }}/snappymail/data"
state: directory
owner: "82" # www-data uid in Alpine (SnappyMail container user)
group: "82"
mode: "0755"
# ── TLS certificate for mail.csrx.ru (via certbot + Cloudflare DNS-01) ───────
- name: Install certbot and Cloudflare DNS plugin
ansible.builtin.apt:
name:
- certbot
- python3-certbot-dns-cloudflare
state: present
update_cache: false
- name: Deploy Cloudflare credentials for certbot
ansible.builtin.copy:
content: |
dns_cloudflare_api_token = {{ cloudflare_dns_api_token }}
dest: /etc/letsencrypt/cloudflare.ini
mode: "0600"
owner: root
group: root
- name: Obtain TLS certificate for mx.{{ domain_base }}
ansible.builtin.command: >
certbot certonly
--dns-cloudflare
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini
--email {{ acme_email }}
--agree-tos --no-eff-email
-d mx.{{ domain_base }}
--non-interactive
register: certbot_result
changed_when: "'Certificate not yet due for renewal' not in certbot_result.stdout"
failed_when:
- certbot_result.rc != 0
- "'Certificate not yet due for renewal' not in certbot_result.stdout"
- "'Certificate not yet due for renewal' not in certbot_result.stderr"
- name: Deploy certbot renewal deploy-hook (reload mailserver after cert renewal)
ansible.builtin.copy:
dest: /etc/letsencrypt/renewal-hooks/deploy/reload-mailserver.sh
mode: "0750"
owner: root
group: root
content: |
#!/usr/bin/env bash
# Triggered by certbot after successful cert renewal
# Reloads Postfix + Dovecot TLS without full restart
set -euo pipefail
if docker ps --format '{{ '{{' }}.Names{{ '}}' }}' | grep -q '^mailserver$'; then
docker exec mailserver supervisorctl restart postfix dovecot
echo "[$(date)] mailserver TLS reloaded after cert renewal"
fi
- name: Schedule certbot auto-renewal (twice daily, certbot renews only when needed)
ansible.builtin.cron:
name: "certbot renew"
minute: "15"
hour: "3,15"
job: "certbot renew --quiet --deploy-hooks 2>&1 | logger -t certbot"
user: root
state: present
# ── Open mail ports in UFW ────────────────────────────────────────────────────
- name: Allow SMTP inbound (port 25)
community.general.ufw:
rule: allow
port: "25"
proto: tcp
- name: Allow SMTP submission (port 587)
community.general.ufw:
rule: allow
port: "587"
proto: tcp
- name: Allow IMAPS (port 993)
community.general.ufw:
rule: allow
port: "993"
proto: tcp
- name: Allow SMTPS (port 465)
community.general.ufw:
rule: allow
port: "465"
proto: tcp
# ── Deploy configs and start stack ────────────────────────────────────────────
- name: Deploy docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ tools_root }}/docker-compose.yml"
owner: "{{ deploy_user }}"
group: "{{ deploy_group }}"
mode: "0640"
- name: Deploy .env
ansible.builtin.template:
src: env.j2
dest: "{{ tools_root }}/.env"
owner: "{{ deploy_user }}"
group: "{{ deploy_group }}"
mode: "0600"
- name: Pull images
community.docker.docker_image:
name: "{{ item }}"
source: pull
loop:
- "{{ outline_image }}"
- "{{ outline_db_image }}"
- "{{ outline_redis_image }}"
- "{{ n8n_image }}"
- "{{ mailserver_image }}"
- "{{ snappymail_image }}"
- name: Start tools stack
community.docker.docker_compose_v2:
project_src: "{{ tools_root }}"
state: present
pull: missing
# ── SnappyMail admin password — write bcrypt hash directly to application.ini ──
# djmaze/snappymail does not reliably apply SNAPPYMAIL_ADMIN_PASSWORD env var;
# instead we verify and update the hash in the config file on every deploy.
- name: Ensure SnappyMail admin password is set correctly
ansible.builtin.shell: |
python3 << 'PYEOF'
import subprocess, re, sys
config_path = "{{ tools_root }}/snappymail/data/_data_/_default_/configs/application.ini"
password = "{{ snappymail_admin_password }}"
try:
with open(config_path) as f:
content = f.read()
except FileNotFoundError:
print("CONFIG_NOT_FOUND")
sys.exit(1)
# Extract current hash (bcrypt hashes start with $2y$)
m = re.search(r'^admin_password\s*=\s*"?(\$2y\$[^"\n]+)', content, re.M)
current_hash = m.group(1).strip() if m else ""
if current_hash:
r = subprocess.run(
["docker", "exec", "snappymail", "php", "-r",
f"echo password_verify('{password}', '{current_hash}') ? 'yes' : 'no';"],
capture_output=True, text=True
)
if r.stdout.strip() == "yes":
print("ALREADY_SET")
sys.exit(0)
# Generate a new bcrypt hash using PHP inside the container
r = subprocess.run(
["docker", "exec", "snappymail", "php", "-r",
f"echo password_hash('{password}', PASSWORD_BCRYPT);"],
capture_output=True, text=True
)
new_hash = r.stdout.strip()
if not new_hash.startswith("$2y$"):
print(f"HASH_ERROR: {r.stderr}")
sys.exit(1)
new_content = re.sub(
r'^admin_password\s*=.*$',
f'admin_password = "{new_hash}"',
content, flags=re.M
)
with open(config_path, "w") as f:
f.write(new_content)
print("UPDATED")
PYEOF
register: snappymail_pw_result
changed_when: "'UPDATED' in snappymail_pw_result.stdout"
failed_when: >
snappymail_pw_result.rc != 0 or
'CONFIG_NOT_FOUND' in snappymail_pw_result.stdout or
'HASH_ERROR' in snappymail_pw_result.stdout
- name: Restart SnappyMail after password update
ansible.builtin.command: docker restart snappymail
when: snappymail_pw_result.changed
changed_when: true
# ── Mail accounts (idempotent: check host-side config file) ──────────────────
- name: Wait for mailserver to be ready
ansible.builtin.command: docker exec mailserver postfix status
register: postfix_status
changed_when: false
retries: 18
delay: 10
until: postfix_status.rc == 0
- name: Create mail accounts
ansible.builtin.command: >
docker exec mailserver setup email add {{ item.address }} {{ item.password }}
loop:
- { address: "noreply@{{ domain_base }}", password: "{{ mailserver_noreply_password }}" }
- { address: "admin@{{ domain_base }}", password: "{{ mailserver_admin_password }}" }
- { address: "jack@{{ domain_base }}", password: "{{ mailserver_jack_password }}" }
register: mail_account_result
changed_when: mail_account_result.rc == 0
failed_when: >
mail_account_result.rc != 0 and
'already exists' not in mail_account_result.stderr
# ── DKIM ─────────────────────────────────────────────────────────────────────
- name: Check if DKIM key exists
ansible.builtin.stat:
path: "{{ tools_root }}/mailserver/config/opendkim/keys/{{ domain_base }}/mail.private"
register: dkim_key
- name: Generate DKIM key
ansible.builtin.command: >
docker exec mailserver setup config dkim domain {{ domain_base }}
when: not dkim_key.stat.exists
register: dkim_generated
- name: Read DKIM DNS record
ansible.builtin.command: >
cat {{ tools_root }}/mailserver/config/opendkim/keys/{{ domain_base }}/mail.txt
when: dkim_generated is changed
register: dkim_record
- name: Print DKIM DNS instructions
ansible.builtin.debug:
msg: |
══════════════════════════════════════════════════════════════════
DKIM key generated! Add this TXT record to Cloudflare DNS:
{{ dkim_record.stdout }}
══════════════════════════════════════════════════════════════════
when: dkim_generated is changed