From c8055467d6008cc8fded83fcbb315d69706ee6c8 Mon Sep 17 00:00:00 2001 From: Ruben Hensen Date: Sun, 15 Mar 2026 10:58:50 +0100 Subject: [PATCH] mailserver: add broken sogo --- ansible/mailserver/README.md | 4 +- .../mailserver/host_vars/mailserver/vars.yml | 2 + .../mailserver/host_vars/mailserver/vault.yml | 26 ++-- ansible/mailserver/playbook.yml | 13 +- .../roles/certbot/defaults/main.yml | 6 + .../mailserver/roles/certbot/tasks/main.yml | 87 ++++++++++++++ .../mailserver/roles/nginx/defaults/main.yml | 7 ++ .../mailserver/roles/nginx/handlers/main.yml | 5 + ansible/mailserver/roles/nginx/tasks/main.yml | 31 +++++ .../roles/nginx/templates/mail.conf.j2 | 113 ++++++++++++++++++ .../mailserver/roles/sogo/defaults/main.yml | 11 ++ .../mailserver/roles/sogo/handlers/main.yml | 5 + ansible/mailserver/roles/sogo/tasks/main.yml | 88 ++++++++++++++ .../roles/sogo/templates/sogo.conf.j2 | 45 +++++++ .../roles/stalwart/defaults/main.yml | 30 +++-- .../roles/stalwart/files/schema.sql | 33 +++++ .../roles/stalwart/tasks/configure.yml | 53 +++++++- .../roles/stalwart/tasks/prepare.yml | 31 +++++ .../roles/stalwart/templates/config.toml.j2 | 31 +++-- .../mailserver/roles/ufw/defaults/main.yml | 2 +- apps/templates/authentik-helm.yaml | 22 ++++ 21 files changed, 601 insertions(+), 44 deletions(-) create mode 100644 ansible/mailserver/roles/certbot/defaults/main.yml create mode 100644 ansible/mailserver/roles/certbot/tasks/main.yml create mode 100644 ansible/mailserver/roles/nginx/defaults/main.yml create mode 100644 ansible/mailserver/roles/nginx/handlers/main.yml create mode 100644 ansible/mailserver/roles/nginx/tasks/main.yml create mode 100644 ansible/mailserver/roles/nginx/templates/mail.conf.j2 create mode 100644 ansible/mailserver/roles/sogo/defaults/main.yml create mode 100644 ansible/mailserver/roles/sogo/handlers/main.yml create mode 100644 ansible/mailserver/roles/sogo/tasks/main.yml create mode 100644 ansible/mailserver/roles/sogo/templates/sogo.conf.j2 create mode 100644 ansible/mailserver/roles/stalwart/files/schema.sql diff --git a/ansible/mailserver/README.md b/ansible/mailserver/README.md index d9a5713..cca2289 100644 --- a/ansible/mailserver/README.md +++ b/ansible/mailserver/README.md @@ -89,7 +89,7 @@ From the web UI you can manage domains, accounts, DKIM keys, and other mail sett ## 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. +Managed by certbot (Let's Encrypt). On first run, certbot uses standalone mode (temporarily stops Stalwart to bind port 80/443). Renewals happen automatically via `certbot.timer` and a deploy hook restarts Stalwart. Certs are at `/etc/letsencrypt/live/mail.rubenhensen.nl/`. ## Ports @@ -101,7 +101,7 @@ Handled automatically by Stalwart's built-in ACME support (Let's Encrypt, `tls-a | 587 | SMTP submission (STARTTLS) | | 993 | IMAP (implicit TLS) | | 443 | HTTPS (web admin + JMAP + ACME) | -| 8080 | HTTP | +| 80 | HTTP (ACME + redirect) | ## If something breaks diff --git a/ansible/mailserver/host_vars/mailserver/vars.yml b/ansible/mailserver/host_vars/mailserver/vars.yml index f98df3c..0eea35c 100644 --- a/ansible/mailserver/host_vars/mailserver/vars.yml +++ b/ansible/mailserver/host_vars/mailserver/vars.yml @@ -1,2 +1,4 @@ --- stalwart_fallback_admin_password: "{{ vault_stalwart_fallback_admin_password }}" +stalwart_db_password: "{{ vault_stalwart_db_password }}" +sogo_db_password: "{{ vault_sogo_db_password }}" diff --git a/ansible/mailserver/host_vars/mailserver/vault.yml b/ansible/mailserver/host_vars/mailserver/vault.yml index 92394d9..d6203a5 100644 --- a/ansible/mailserver/host_vars/mailserver/vault.yml +++ b/ansible/mailserver/host_vars/mailserver/vault.yml @@ -1,10 +1,18 @@ $ANSIBLE_VAULT;1.1;AES256 -31346664666161363261666662653164623562376161313865323065636664643331303234633263 -6164323366303938346231636134323762323634343731340a313966306130333832363764323633 -61326261613839303765303239653439343563333031643030363764653735663337356631613331 -3162643836623463370a323834393931373434373636366266376639366561336333653165343166 -66623162343032663034613663373133663563343463613935306366363461616636646630663961 -37376132666531323230383530633430353762346237343035393065656230306430376334333937 -64383632366333316337323036303532343838343035653631396165313939303465653730303865 -35393935636361633135343438666263633165386435656361613136313039303235303134333031 -6433 +37373639633565366339643336366137666465303365363532393364626333306232636661373331 +6239343934326134386663396366333232613337323266320a303836623065633534653034666661 +33643731356161656563313330626166613562356434313961313539643636373665373635613635 +3631636639393563330a666530353733623166313063373439306234613938353366303331336234 +37616266363434336639373535303038666231656461373061376666636464393233323963366234 +65383062666162343561353961373230323763323432646364663437313361633331313433613465 +62663034623330333936623035353235393461333363303435613364643838373164313431636562 +30323736326632663032616136643430306330366333353565646432343466386564303432616339 +32303231363835666537386136376532346632343961396334663130376366373566313965383163 +34396566363830666437303266623437383064396462643132336332643239326364313632386164 +38363561643038363065313630636437366636623134666564613937613633643835656236623331 +32393366643433613230646361316539633236353539316162336237373737316136373466663863 +36646162323266336436343631383837363338336234653063626565653931633862616662666265 +34396430313132333536363736333434666634333432336639633963393665616163653237343235 +36626138356230313963383033643432313839333232386265633330666664666262343433396362 +66646666356563303136636337323333306466373036313333643135643436393165656539396139 +3861 diff --git a/ansible/mailserver/playbook.yml b/ansible/mailserver/playbook.yml index 4e98376..4d8acb3 100644 --- a/ansible/mailserver/playbook.yml +++ b/ansible/mailserver/playbook.yml @@ -4,8 +4,11 @@ become: true roles: - - base - - ssh - - ufw - - fail2ban - - stalwart + - { role: base, tags: [base] } + - { role: ssh, tags: [ssh] } + - { role: ufw, tags: [ufw] } + - { role: fail2ban, tags: [fail2ban] } + - { role: certbot, tags: [certbot] } + - { role: stalwart, tags: [stalwart] } + - { role: sogo, tags: [sogo] } + - { role: nginx, tags: [nginx] } diff --git a/ansible/mailserver/roles/certbot/defaults/main.yml b/ansible/mailserver/roles/certbot/defaults/main.yml new file mode 100644 index 0000000..19c84d8 --- /dev/null +++ b/ansible/mailserver/roles/certbot/defaults/main.yml @@ -0,0 +1,6 @@ +--- +certbot_email: "admin@rubenhensen.nl" +certbot_domains: + - "mail.rubenhensen.nl" + - "stalwart.rubenhensen.nl" +certbot_webroot: "/var/www/acme" diff --git a/ansible/mailserver/roles/certbot/tasks/main.yml b/ansible/mailserver/roles/certbot/tasks/main.yml new file mode 100644 index 0000000..caef20d --- /dev/null +++ b/ansible/mailserver/roles/certbot/tasks/main.yml @@ -0,0 +1,87 @@ +--- +- name: Install certbot + ansible.builtin.apt: + name: certbot + state: present + +- name: Check if certificate already exists + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ certbot_domains[0] }}/fullchain.pem" + register: certbot_cert + +- name: Stop Stalwart for initial cert issuance + ansible.builtin.service: + name: stalwart + state: stopped + when: not certbot_cert.stat.exists + +- name: Obtain certificate (standalone) + ansible.builtin.command: + cmd: > + certbot certonly --standalone + --non-interactive + --agree-tos + --email {{ certbot_email }} + {% for domain in certbot_domains %}-d {{ domain }} {% endfor %} + when: not certbot_cert.stat.exists + changed_when: true + +- name: Start Stalwart after cert issuance + ansible.builtin.service: + name: stalwart + state: started + when: not certbot_cert.stat.exists + +- name: Grant stalwart read access to certs + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - /etc/letsencrypt/live + - /etc/letsencrypt/archive + +- name: Deploy Stalwart admin password for renewal hook + ansible.builtin.copy: + dest: /etc/letsencrypt/.stalwart-admin-pass + content: "{{ stalwart_fallback_admin_password }}" + owner: root + group: root + mode: "0600" + +- name: Deploy certbot renewal hook + ansible.builtin.copy: + dest: /etc/letsencrypt/renewal-hooks/deploy/stalwart.sh + content: | + #!/bin/bash + DOMAIN="{{ certbot_domains[0] }}" + PASS=$(cat /etc/letsencrypt/.stalwart-admin-pass) + + python3 -c " + import json, urllib.request + + cert = open('/etc/letsencrypt/live/$DOMAIN/fullchain.pem').read() + key = open('/etc/letsencrypt/live/$DOMAIN/privkey.pem').read() + body = json.dumps([{'type':'insert','assert_empty':False,'values':[ + ['certificate.default.cert', cert], + ['certificate.default.private-key', key], + ]}]).encode() + + import base64 + auth = base64.b64encode(('admin:' + open('/etc/letsencrypt/.stalwart-admin-pass').read().strip()).encode()).decode() + + req = urllib.request.Request('http://127.0.0.1:8080/api/settings', data=body, + headers={'Content-Type':'application/json','Authorization':'Basic ' + auth}, method='POST') + urllib.request.urlopen(req) + + req = urllib.request.Request('http://127.0.0.1:8080/api/reload', + headers={'Authorization':'Basic ' + auth}) + urllib.request.urlopen(req) + " + mode: "0755" + +- name: Ensure certbot renewal timer is enabled + ansible.builtin.systemd: + name: certbot.timer + enabled: true + state: started diff --git a/ansible/mailserver/roles/nginx/defaults/main.yml b/ansible/mailserver/roles/nginx/defaults/main.yml new file mode 100644 index 0000000..a413154 --- /dev/null +++ b/ansible/mailserver/roles/nginx/defaults/main.yml @@ -0,0 +1,7 @@ +--- +nginx_server_name: "mail.rubenhensen.nl" +nginx_stalwart_server_name: "stalwart.rubenhensen.nl" +nginx_tls_cert: "/etc/letsencrypt/live/mail.rubenhensen.nl/fullchain.pem" +nginx_tls_key: "/etc/letsencrypt/live/mail.rubenhensen.nl/privkey.pem" +nginx_stalwart_backend: "127.0.0.1:8443" +nginx_sogo_backend: "127.0.0.1:20000" diff --git a/ansible/mailserver/roles/nginx/handlers/main.yml b/ansible/mailserver/roles/nginx/handlers/main.yml new file mode 100644 index 0000000..e96eb8e --- /dev/null +++ b/ansible/mailserver/roles/nginx/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: reload nginx + ansible.builtin.systemd: + name: nginx + state: reloaded diff --git a/ansible/mailserver/roles/nginx/tasks/main.yml b/ansible/mailserver/roles/nginx/tasks/main.yml new file mode 100644 index 0000000..e6515eb --- /dev/null +++ b/ansible/mailserver/roles/nginx/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Install Nginx + ansible.builtin.apt: + name: nginx + state: present + +- name: Remove default site + ansible.builtin.file: + path: /etc/nginx/sites-enabled/default + state: absent + notify: reload nginx + +- name: Deploy mail site config + ansible.builtin.template: + src: mail.conf.j2 + dest: /etc/nginx/sites-available/mail.conf + mode: "0644" + notify: reload nginx + +- name: Enable mail site + ansible.builtin.file: + src: /etc/nginx/sites-available/mail.conf + dest: /etc/nginx/sites-enabled/mail.conf + state: link + notify: reload nginx + +- name: Enable and start Nginx + ansible.builtin.systemd: + name: nginx + enabled: true + state: started diff --git a/ansible/mailserver/roles/nginx/templates/mail.conf.j2 b/ansible/mailserver/roles/nginx/templates/mail.conf.j2 new file mode 100644 index 0000000..8d76318 --- /dev/null +++ b/ansible/mailserver/roles/nginx/templates/mail.conf.j2 @@ -0,0 +1,113 @@ +server { + listen 80; + listen [::]:80; + server_name {{ nginx_server_name }}; + + # ACME challenge + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + + # Redirect everything else to HTTPS + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ nginx_server_name }}; + + ssl_certificate {{ nginx_tls_cert }}; + ssl_certificate_key {{ nginx_tls_key }}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # SOGo + location ^~ /SOGo { + proxy_pass http://{{ nginx_sogo_backend }}; + proxy_redirect http://{{ nginx_sogo_backend }} /; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header x-webobjects-server-protocol HTTP/1.0; + proxy_set_header x-webobjects-remote-host $remote_addr; + proxy_set_header x-webobjects-server-name $server_name; + proxy_set_header x-webobjects-server-url $scheme://$host; + proxy_set_header x-webobjects-server-port $server_port; + proxy_buffer_size 128k; + proxy_buffers 64 512k; + proxy_busy_buffers_size 512k; + client_max_body_size 100m; + } + + # SOGo static resources + location ^~ /SOGo.woa/WebServerResources/ { + alias /usr/lib/GNUstep/SOGo/WebServerResources/; + } + + location /SOGo/WebServerResources/ { + alias /usr/lib/GNUstep/SOGo/WebServerResources/; + } + + # Microsoft AutoDiscover / ActiveSync + location ^~ /Microsoft-Server-ActiveSync { + proxy_pass http://{{ nginx_sogo_backend }}/SOGo/Microsoft-Server-ActiveSync; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 360; + proxy_send_timeout 360; + proxy_read_timeout 360; + } + + # CalDAV/CardDAV well-known + location /.well-known/caldav { + return 301 https://$host/SOGo/dav; + } + location /.well-known/carddav { + return 301 https://$host/SOGo/dav; + } + + # Default: SOGo + location / { + return 301 https://$host/SOGo; + } +} + +# Stalwart web admin +server { + listen 80; + listen [::]:80; + server_name {{ nginx_stalwart_server_name }}; + + location /.well-known/acme-challenge/ { + root /var/www/acme; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ nginx_stalwart_server_name }}; + + ssl_certificate {{ nginx_tls_cert }}; + ssl_certificate_key {{ nginx_tls_key }}; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass https://{{ nginx_stalwart_backend }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + } +} diff --git a/ansible/mailserver/roles/sogo/defaults/main.yml b/ansible/mailserver/roles/sogo/defaults/main.yml new file mode 100644 index 0000000..8f3f7cd --- /dev/null +++ b/ansible/mailserver/roles/sogo/defaults/main.yml @@ -0,0 +1,11 @@ +--- +sogo_db_name: "sogo" +sogo_db_user: "sogo" +sogo_db_password: "{{ vault_sogo_db_password }}" + +sogo_imap_server: "127.0.0.1" +sogo_smtp_server: "127.0.0.1" +sogo_mail_domain: "rubenhensen.nl" + +sogo_time_zone: "Europe/Amsterdam" +sogo_page_title: "Mail - rubenhensen.nl" diff --git a/ansible/mailserver/roles/sogo/handlers/main.yml b/ansible/mailserver/roles/sogo/handlers/main.yml new file mode 100644 index 0000000..d9aee25 --- /dev/null +++ b/ansible/mailserver/roles/sogo/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart sogo + ansible.builtin.systemd: + name: sogo + state: restarted diff --git a/ansible/mailserver/roles/sogo/tasks/main.yml b/ansible/mailserver/roles/sogo/tasks/main.yml new file mode 100644 index 0000000..ecab2d1 --- /dev/null +++ b/ansible/mailserver/roles/sogo/tasks/main.yml @@ -0,0 +1,88 @@ +--- +- name: Install PostgreSQL + ansible.builtin.apt: + name: + - postgresql + - postgresql-client + - python3-psycopg2 + state: present + +- name: Ensure PostgreSQL is running + ansible.builtin.systemd: + name: postgresql + enabled: true + state: started + +- name: Create SOGo database user + become: true + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ sogo_db_user }}" + password: "{{ sogo_db_password }}" + +- name: Create SOGo database + become: true + become_user: postgres + community.postgresql.postgresql_db: + name: "{{ sogo_db_name }}" + owner: "{{ sogo_db_user }}" + +- name: Grant SOGo user read access to Stalwart accounts + become: true + become_user: postgres + community.postgresql.postgresql_privs: + database: "stalwart" + roles: "{{ sogo_db_user }}" + type: table + objs: accounts,emails + privs: SELECT + +- name: Download SOGo repository key + ansible.builtin.shell: + cmd: > + wget -qO- "https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9" + | gpg --dearmor > /etc/apt/trusted.gpg.d/sogo.gpg + creates: /etc/apt/trusted.gpg.d/sogo.gpg + +- name: Add SOGo nightly repository + ansible.builtin.apt_repository: + repo: "deb https://packages.sogo.nu/nightly/5/ubuntu/ {{ ansible_facts['distribution_release'] }} {{ ansible_facts['distribution_release'] }}" + filename: sogo + state: present + +- name: Install SOGo and dependencies + ansible.builtin.apt: + name: + - sogo + - sogo-activesync + - sope4.9-gdl1-postgresql + state: present + update_cache: true + +- name: Disable Apache (installed as SOGo dependency, Nginx is used instead) + ansible.builtin.systemd: + name: apache2 + enabled: false + state: stopped + failed_when: false + +- name: Deploy SOGo configuration + ansible.builtin.template: + src: sogo.conf.j2 + dest: /etc/sogo/sogo.conf + owner: sogo + group: sogo + mode: "0640" + notify: restart sogo + +- name: Enable and start SOGo + ansible.builtin.systemd: + name: sogo + enabled: true + state: started + +- name: Enable and start memcached + ansible.builtin.systemd: + name: memcached + enabled: true + state: started diff --git a/ansible/mailserver/roles/sogo/templates/sogo.conf.j2 b/ansible/mailserver/roles/sogo/templates/sogo.conf.j2 new file mode 100644 index 0000000..340e857 --- /dev/null +++ b/ansible/mailserver/roles/sogo/templates/sogo.conf.j2 @@ -0,0 +1,45 @@ +{ + SOGoTimeZone = "{{ sogo_time_zone }}"; + SOGoPageTitle = "{{ sogo_page_title }}"; + SOGoLanguage = "English"; + + SOGoMailDomain = "{{ sogo_mail_domain }}"; + SOGoIMAPServer = "imaps://{{ sogo_imap_server }}:993/?tls=YES&tlsVerifyMode=none"; + SOGoSMTPServer = "smtp://{{ sogo_smtp_server }}:587/?tls=YES&tlsVerifyMode=none"; + SOGoSMTPAuthenticationType = "PLAIN"; + SOGoIMAPAclConformsToIMAPExt = YES; + SOGoForceExternalLoginWithEmail = YES; + + SOGoUserSources = ( + { + type = sql; + id = directory; + viewURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/stalwart/sogo_users"; + canAuthenticate = YES; + isAddressBook = NO; + LoginFieldNames = ("mail"); + userPasswordAlgorithm = "sha512-crypt"; + } + ); + + SOGoProfileURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_user_profile"; + OCSFolderInfoURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_folder_info"; + OCSSessionsFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_sessions_folder"; + OCSEMailAlarmsFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_alarms_folder"; + OCSStoreURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_store"; + OCSAclURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_acl"; + OCSCacheFolderURL = "postgresql://{{ sogo_db_user }}:{{ sogo_db_password }}@127.0.0.1:5432/{{ sogo_db_name }}/sogo_cache_folder"; + + SOGoAppointmentSendEMailNotifications = YES; + SOGoEnableEMailAlarms = YES; + + SOGoMemcachedHost = "127.0.0.1"; + + SOGoCalendarDefaultRoles = ( + PublicViewer, + ConfidentialDAccessor + ); + + SOGoSuperUsernames = ("admin"); + SOGoFirstDayOfWeek = 1; +} diff --git a/ansible/mailserver/roles/stalwart/defaults/main.yml b/ansible/mailserver/roles/stalwart/defaults/main.yml index 55e7b5c..6355999 100644 --- a/ansible/mailserver/roles/stalwart/defaults/main.yml +++ b/ansible/mailserver/roles/stalwart/defaults/main.yml @@ -39,12 +39,12 @@ stalwart_server_listeners: options: tls.implicit: true - name: "https" - bind: "[::]:443" + bind: "127.0.0.1:8443" protocol: "http" options: tls.implicit: true - name: "http" - bind: "[::]:8080" + bind: "127.0.0.1:8080" protocol: "http" # Stores @@ -54,11 +54,24 @@ stalwart_stores: options: path: "{{ stalwart_data_path }}" compression: "lz4" + - name: "postgresql" + type: "postgresql" + options: + host: "127.0.0.1" + port: 5432 + database: "{{ stalwart_db_name }}" + user: "{{ stalwart_db_user }}" + password: "{{ stalwart_db_password }}" # Directory -stalwart_directory_type: "internal" +stalwart_directory_type: "sql" stalwart_directory_options: - store: "rocksdb" + store: "postgresql" + +# Database +stalwart_db_name: "stalwart" +stalwart_db_user: "stalwart" +stalwart_db_password: "{{ vault_stalwart_db_password }}" # Tracers stalwart_tracers: @@ -76,12 +89,9 @@ stalwart_tracers: 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" +# TLS certificate (managed by certbot) +stalwart_tls_cert: "/etc/letsencrypt/live/{{ stalwart_server_hostname }}/fullchain.pem" +stalwart_tls_key: "/etc/letsencrypt/live/{{ stalwart_server_hostname }}/privkey.pem" # Extra config sections (list of {name, options} dicts) stalwart_additional_configs: [] diff --git a/ansible/mailserver/roles/stalwart/files/schema.sql b/ansible/mailserver/roles/stalwart/files/schema.sql new file mode 100644 index 0000000..04468e0 --- /dev/null +++ b/ansible/mailserver/roles/stalwart/files/schema.sql @@ -0,0 +1,33 @@ +CREATE TABLE IF NOT EXISTS accounts ( + name VARCHAR(255) PRIMARY KEY, + password VARCHAR(255) NOT NULL, + description VARCHAR(255) DEFAULT '', + type VARCHAR(20) NOT NULL DEFAULT 'individual', + email VARCHAR(255) NOT NULL, + quota INTEGER DEFAULT 0, + active BOOLEAN DEFAULT true +); + +CREATE TABLE IF NOT EXISTS group_members ( + name VARCHAR(255) NOT NULL, + member_of VARCHAR(255) NOT NULL, + PRIMARY KEY (name, member_of) +); + +CREATE TABLE IF NOT EXISTS emails ( + name VARCHAR(255) NOT NULL, + address VARCHAR(255) NOT NULL, + type VARCHAR(20) NOT NULL DEFAULT 'primary', + PRIMARY KEY (name, address) +); + +-- View for SOGo (expects c_ prefixed columns) +CREATE OR REPLACE VIEW sogo_users AS +SELECT + name AS c_uid, + name AS c_name, + password AS c_password, + description AS c_cn, + email AS mail +FROM accounts +WHERE active = true; diff --git a/ansible/mailserver/roles/stalwart/tasks/configure.yml b/ansible/mailserver/roles/stalwart/tasks/configure.yml index a3ec56c..a607de0 100644 --- a/ansible/mailserver/roles/stalwart/tasks/configure.yml +++ b/ansible/mailserver/roles/stalwart/tasks/configure.yml @@ -19,4 +19,55 @@ ansible.builtin.service: name: "{{ stalwart_service_name }}" state: "{{ stalwart_service_state }}" - enabled: "{{ stalwart_service_enabled }}" \ No newline at end of file + enabled: "{{ stalwart_service_enabled }}" + +- name: Flush handlers to ensure Stalwart is running + ansible.builtin.meta: flush_handlers + +- name: Wait for Stalwart API + ansible.builtin.wait_for: + port: 8080 + host: 127.0.0.1 + delay: 2 + timeout: 30 + +- name: Read TLS certificate + ansible.builtin.slurp: + src: "{{ stalwart_tls_cert }}" + register: tls_cert_content + +- name: Read TLS private key + ansible.builtin.slurp: + src: "{{ stalwart_tls_key }}" + register: tls_key_content + +- name: Push TLS certificate to Stalwart + ansible.builtin.uri: + url: "http://127.0.0.1:8080/api/settings" + method: POST + user: "{{ stalwart_fallback_admin_login }}" + password: "{{ stalwart_fallback_admin_password }}" + force_basic_auth: true + body_format: json + body: + - type: "insert" + assert_empty: false + values: + - - "certificate.default.cert" + - "{{ tls_cert_content.content | b64decode }}" + - - "certificate.default.private-key" + - "{{ tls_key_content.content | b64decode }}" + - - "certificate.default.default" + - "true" + - - "server.tls.certificate" + - "default" + status_code: [200, 204] + +- name: Reload Stalwart to apply certificate + ansible.builtin.uri: + url: "http://127.0.0.1:8080/api/reload" + method: GET + user: "{{ stalwart_fallback_admin_login }}" + password: "{{ stalwart_fallback_admin_password }}" + force_basic_auth: true + status_code: [200, 204] diff --git a/ansible/mailserver/roles/stalwart/tasks/prepare.yml b/ansible/mailserver/roles/stalwart/tasks/prepare.yml index 5cfe350..fe51dc9 100644 --- a/ansible/mailserver/roles/stalwart/tasks/prepare.yml +++ b/ansible/mailserver/roles/stalwart/tasks/prepare.yml @@ -21,6 +21,37 @@ group: "{{ stalwart_system_group }}" mode: "0755" +- name: Create Stalwart database user + become: true + become_user: postgres + community.postgresql.postgresql_user: + name: "{{ stalwart_db_user }}" + password: "{{ stalwart_db_password }}" + when: stalwart_directory_type == "sql" + +- name: Create Stalwart database + become: true + become_user: postgres + community.postgresql.postgresql_db: + name: "{{ stalwart_db_name }}" + owner: "{{ stalwart_db_user }}" + when: stalwart_directory_type == "sql" + +- name: Deploy database schema + ansible.builtin.copy: + src: schema.sql + dest: /tmp/stalwart-schema.sql + mode: "0644" + when: stalwart_directory_type == "sql" + +- name: Apply database schema + become: true + become_user: postgres + ansible.builtin.command: + cmd: psql -d {{ stalwart_db_name }} -f /tmp/stalwart-schema.sql + changed_when: false + when: stalwart_directory_type == "sql" + - name: Check if already installed ansible.builtin.stat: path: "{{ stalwart_executable_path }}" diff --git a/ansible/mailserver/roles/stalwart/templates/config.toml.j2 b/ansible/mailserver/roles/stalwart/templates/config.toml.j2 index ed4e3ed..67af81a 100644 --- a/ansible/mailserver/roles/stalwart/templates/config.toml.j2 +++ b/ansible/mailserver/roles/stalwart/templates/config.toml.j2 @@ -1,9 +1,6 @@ [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 }}] @@ -19,19 +16,6 @@ protocol = "{{ listener.protocol }}" {% 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 }}" @@ -62,6 +46,21 @@ type = "{{ stalwart_directory_type }}" {%- endif %} {% endfor %} +{% if stalwart_directory_type == "sql" %} +columns.secret = "password" +columns.description = "description" +columns.email = "email" +columns.quota = "quota" +columns.class = "type" + +[store.postgresql.query] +name = "SELECT name, type, password, description, email, quota FROM accounts WHERE name = $1 AND active = true" +members = "SELECT member_of FROM group_members WHERE name = $1" +recipients = "SELECT name FROM emails WHERE address = $1" +emails = "SELECT address FROM emails WHERE name = $1 ORDER BY type DESC, address ASC" +secrets = "SELECT password FROM accounts WHERE name = $1 AND active = true" +{% endif %} + {% for tracer in stalwart_tracers %} [tracer.{{ tracer.type }}] diff --git a/ansible/mailserver/roles/ufw/defaults/main.yml b/ansible/mailserver/roles/ufw/defaults/main.yml index 71d4259..a9f4331 100644 --- a/ansible/mailserver/roles/ufw/defaults/main.yml +++ b/ansible/mailserver/roles/ufw/defaults/main.yml @@ -8,4 +8,4 @@ ufw_allowed_ports: - { 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)" } + - { port: "80", proto: "tcp", comment: "HTTP (ACME + redirect)" } diff --git a/apps/templates/authentik-helm.yaml b/apps/templates/authentik-helm.yaml index cd5f418..11424a8 100644 --- a/apps/templates/authentik-helm.yaml +++ b/apps/templates/authentik-helm.yaml @@ -61,7 +61,20 @@ spec: secretKeyRef: name: authentik-postgres-credentials key: password + - name: VAULT_OIDC_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: vault-oidc-client-secret + key: client_secret server: + volumes: + - name: custom-blueprints + configMap: + name: authentik-blueprint-vault + volumeMounts: + - name: custom-blueprints + mountPath: /blueprints/custom/vault-oidc.yaml + subPath: vault-oidc.yaml ingress: enabled: true ingressClassName: nginx @@ -73,6 +86,15 @@ spec: - secretName: authentik-tls hosts: - authentik.rubenhensen.nl + worker: + volumes: + - name: custom-blueprints + configMap: + name: authentik-blueprint-vault + volumeMounts: + - name: custom-blueprints + mountPath: /blueprints/custom/vault-oidc.yaml + subPath: vault-oidc.yaml postgresql: enabled: false redis: