diff --git a/mail_aliases/defaults/main.yml b/mail_aliases/defaults/main.yml deleted file mode 100644 index 3a1b002..0000000 --- a/mail_aliases/defaults/main.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -# Entries of /etc/aliases managed here: name -> recipient. -# Root mail goes to the admin mailbox (doc/host-mail-relay.md in the kubernetes repo). -mail_aliases: - root: admin@ruekov.eu diff --git a/mail_aliases/handlers/main.yml b/mail_aliases/handlers/main.yml deleted file mode 100644 index a7289b0..0000000 --- a/mail_aliases/handlers/main.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -- name: Rebuild alias database - ansible.builtin.command: - cmd: newaliases - changed_when: true diff --git a/mail_aliases/tasks/main.yml b/mail_aliases/tasks/main.yml deleted file mode 100644 index 1e2921a..0000000 --- a/mail_aliases/tasks/main.yml +++ /dev/null @@ -1,19 +0,0 @@ ---- -# Postfix reads /etc/aliases through the compiled /etc/aliases.db. Without the db, local delivery -# defers every mail with "alias database unavailable". -- name: Alias entries - ansible.builtin.lineinfile: - path: /etc/aliases - regexp: "^{{ item.key }}:" - line: "{{ item.key }}: {{ item.value }}" - create: true - owner: root - group: root - mode: "0644" - loop: "{{ mail_aliases | dict2items }}" - notify: Rebuild alias database - -- name: Alias database exists - ansible.builtin.command: - cmd: newaliases - creates: /etc/aliases.db diff --git a/mail_relay/defaults/main.yml b/mail_relay/defaults/main.yml new file mode 100644 index 0000000..18ecb61 --- /dev/null +++ b/mail_relay/defaults/main.yml @@ -0,0 +1,18 @@ +--- +# Every host sends its mail through the mailcow submission port with its own mailbox +# (doc/host-mail-relay.md in the kubernetes repo). The password is set in host_vars. +mail_relay: + host: tranio.ruekov.eu + port: 587 + # SASL login of the mailcow mailbox; the password is an SMTP-only app password of it. + username: "{{ inventory_hostname_short }}.system@mail.ruekov.eu" + # Sender address every local address is rewritten to; mailcow accepts only addresses of the login. + sender: "{{ mail_relay.username }}" + password: "" + # Root mail goes to this address: /etc/aliases and, on Proxmox hosts, the email of root@pam. + root_mail: admin@ruekov.eu + myhostname: "{{ inventory_hostname }}" + # The local postfix only takes mail from the host itself. + inet_interfaces: loopback-only + # The smtp inet service of master.cf; off where another daemon holds port 25 (tranio: mailcow). + smtpd_listener: true diff --git a/mail_relay/handlers/main.yml b/mail_relay/handlers/main.yml new file mode 100644 index 0000000..0b0f8e9 --- /dev/null +++ b/mail_relay/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart postfix + ansible.builtin.service: + name: postfix + state: restarted diff --git a/mail_relay/tasks/main.yml b/mail_relay/tasks/main.yml new file mode 100644 index 0000000..7438ec8 --- /dev/null +++ b/mail_relay/tasks/main.yml @@ -0,0 +1,208 @@ +--- +- name: Mailbox password is set + ansible.builtin.assert: + that: mail_relay.password | length > 0 + fail_msg: "mail_relay.password of {{ inventory_hostname }} is empty (host_vars)" + quiet: true + +- name: Postfix state + ansible.builtin.stat: + path: /usr/sbin/postfix + register: mail_relay_postfix + +- name: Exim state + ansible.builtin.stat: + path: /usr/sbin/exim4 + register: mail_relay_exim + +# Postfix replaces exim4 as the mail-transport-agent; the package scripts do not stop a running exim4 +# while policy-rc.d denies service actions, and its listener would block postfix on port 25. +- name: Stop exim4 + ansible.builtin.systemd_service: + name: exim4 + state: stopped + enabled: false + when: mail_relay_exim.stat.exists and not mail_relay_postfix.stat.exists + +- name: Preseed postfix + ansible.builtin.debconf: + name: postfix + question: "{{ item.question }}" + vtype: "{{ item.vtype }}" + value: "{{ item.value }}" + loop: + - { question: postfix/main_mailer_type, vtype: select, value: Internet Site } + - { question: postfix/mailname, vtype: string, value: "{{ mail_relay.myhostname }}" } + loop_control: + label: "{{ item.question }}" + when: not mail_relay_postfix.stat.exists + +# A third-party repository with a bad signature makes the refresh fail on some hosts; the install +# below still fails if the index lacks the packages. +- name: Refresh apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + failed_when: false + +# The package starts postfix with the Debian defaults, which listen on all interfaces; on a host whose +# port 25 is taken (tranio: mailcow) that start fails. policy-rc.d keeps the start until the +# configuration is in place. +- name: Install postfix + ansible.builtin.apt: + name: + - postfix + - libsasl2-modules + - ca-certificates + policy_rc_d: "{{ omit if mail_relay_postfix.stat.exists else 101 }}" + +- name: Postfix configuration + when: not ansible_check_mode or mail_relay_postfix.stat.exists + block: + - name: Local names of the host + ansible.builtin.set_fact: + mail_relay_local_names: >- + {{ [mail_relay.myhostname, inventory_hostname, inventory_hostname_short, ansible_facts['hostname'], + 'localhost', 'localhost.localdomain'] | unique }} + + - name: Sender rewrite map + ansible.builtin.template: + src: generic.regexp.j2 + dest: /etc/postfix/generic + owner: root + group: root + mode: "0644" + notify: Restart postfix + + - name: Relay settings in main.cf + ansible.builtin.lineinfile: + path: /etc/postfix/main.cf + regexp: "^{{ item.key }}\\s*=" + line: "{{ item.key }} = {{ item.value }}" + loop: "{{ mail_relay_settings | dict2items }}" + vars: + mail_relay_settings: + myhostname: "{{ mail_relay.myhostname }}" + myorigin: $myhostname + inet_interfaces: "{{ mail_relay.inet_interfaces }}" + relayhost: "[{{ mail_relay.host }}]:{{ mail_relay.port }}" + smtp_sasl_auth_enable: "yes" + smtp_sasl_password_maps: hash:/etc/postfix/sasl_passwd + smtp_sasl_security_options: noanonymous + smtp_tls_security_level: verify + smtp_tls_CAfile: /etc/ssl/certs/ca-certificates.crt + smtp_generic_maps: regexp:/etc/postfix/generic + loop_control: + label: "{{ item.key }}" + notify: Restart postfix + + - name: SASL credentials + ansible.builtin.copy: + dest: /etc/postfix/sasl_passwd + content: "[{{ mail_relay.host }}]:{{ mail_relay.port }} {{ mail_relay.username }}:{{ mail_relay.password }}\n" + owner: root + group: root + mode: "0600" + no_log: true + register: mail_relay_sasl + + - name: SASL database state + ansible.builtin.stat: + path: /etc/postfix/sasl_passwd.db + register: mail_relay_sasl_db + + # A task instead of a handler: a failed run would drop the notification and leave the database missing. + - name: Rebuild sasl_passwd database + ansible.builtin.command: + cmd: postmap /etc/postfix/sasl_passwd + when: mail_relay_sasl.changed or not mail_relay_sasl_db.stat.exists + changed_when: true + notify: Restart postfix + + # inet_interfaces cannot switch the listener off ("none" is resolved as a host name): the service + # is commented out in master.cf instead. + - name: SMTP listener in master.cf + ansible.builtin.lineinfile: + path: /etc/postfix/master.cf + regexp: "^#?smtp\\s+inet\\s+n\\s+-\\s+y\\s+-\\s+-\\s+smtpd\\s*$" + line: "{{ '' if mail_relay.smtpd_listener else '#' }}smtp inet n - y - - smtpd" + notify: Restart postfix + + - name: Root alias + ansible.builtin.lineinfile: + path: /etc/aliases + regexp: "^root:" + line: "root: {{ mail_relay.root_mail }}" + create: true + owner: root + group: root + mode: "0644" + + - name: Alias file state + ansible.builtin.stat: + path: /etc/aliases + register: mail_relay_aliases + + - name: Alias database state + ansible.builtin.stat: + path: /etc/aliases.db + register: mail_relay_aliases_db + + # Postfix reads /etc/aliases through the compiled /etc/aliases.db; without a current db local + # delivery defers or misroutes. A task instead of a handler, so a failed run cannot lose the rebuild. + - name: Rebuild alias database + ansible.builtin.command: + cmd: newaliases + when: >- + not mail_relay_aliases_db.stat.exists + or mail_relay_aliases_db.stat.mtime < mail_relay_aliases.stat.mtime + changed_when: true + + - name: Apply pending changes + ansible.builtin.meta: flush_handlers + + - name: SASL database permissions + ansible.builtin.file: + path: /etc/postfix/sasl_passwd.db + owner: root + group: root + mode: "0600" + + - name: Postfix running + ansible.builtin.service: + name: postfix + state: started + enabled: true + +- name: Proxmox state + ansible.builtin.stat: + path: /usr/sbin/pveum + register: mail_relay_pve + +# The built-in mail-to-root notification target mails the email of root@pam. +- name: Email of root@pam + when: mail_relay_pve.stat.exists + block: + - name: Read root@pam + ansible.builtin.command: + cmd: pvesh get /access/users/root@pam --output-format json + register: mail_relay_pve_root + changed_when: false + check_mode: false + + - name: Set the email of root@pam + ansible.builtin.command: + cmd: "pveum user modify root@pam --email {{ mail_relay.root_mail }}" + when: (mail_relay_pve_root.stdout | from_json).email | default('') != mail_relay.root_mail + +# Run with --tags mail_relay_test: the mail takes the root alias through the relay. +- name: Test mail to root + ansible.builtin.command: + cmd: sendmail -t + stdin: | + To: root + Subject: mail_relay test from {{ inventory_hostname }} + + Test mail of the mail_relay role. + changed_when: true + tags: [never, mail_relay_test] diff --git a/mail_relay/templates/generic.regexp.j2 b/mail_relay/templates/generic.regexp.j2 new file mode 100644 index 0000000..809d2b8 --- /dev/null +++ b/mail_relay/templates/generic.regexp.j2 @@ -0,0 +1,4 @@ +# mailcow rejects a sender that does not belong to the login of the submission, so every address of +# this host is rewritten to the mailbox. The map also applies to recipient addresses: only the names +# of this host match, never a recipient such as admin@ruekov.eu. +/^[^@]+@({{ mail_relay_local_names | map('regex_escape') | join('|') }})$/ {{ mail_relay.sender }}