From a66d7d6e8da5c54fe3340531211d45a3a3aa29ed Mon Sep 17 00:00:00 2001 From: decentral1se Date: Mon, 30 May 2022 09:31:11 +0200 Subject: [PATCH] init --- LICENSE | 15 + README.md | 1 + defaults/main.yml | 3 + files/discourse-smtp-fast-rejection | 118 ++++++ files/receive-mail | 75 ++++ handlers/main.yml | 1 + meta/main.yml | 12 + molecule/default/Dockerfile.j2 | 2 + molecule/default/create.yml | 41 ++ molecule/default/destroy.yml | 16 + molecule/default/molecule.yml | 45 +++ molecule/default/playbook.yml | 11 + molecule/default/prepare.yml | 30 ++ molecule/default/requirements.yml | 2 + molecule/default/tests/test_default.yml | 9 + requirements.txt | 3 + tasks/dkim_domain.yml | 98 +++++ tasks/main.yml | 397 ++++++++++++++++++++ templates/KeyTable.j2 | 1 + templates/SigningTable.j2 | 1 + templates/TrustedHosts.j2 | 4 + templates/dkim.hosts.j2 | 4 + templates/forward.j2 | 1 + templates/mail-receiver-environment.json.j2 | 5 + templates/opendkim.conf.j2 | 90 +++++ templates/opendkim.j2 | 23 ++ templates/transport.j2 | 1 + vars/main.yml | 2 + 28 files changed, 1011 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 defaults/main.yml create mode 100644 files/discourse-smtp-fast-rejection create mode 100644 files/receive-mail create mode 100644 handlers/main.yml create mode 100644 meta/main.yml create mode 100644 molecule/default/Dockerfile.j2 create mode 100644 molecule/default/create.yml create mode 100644 molecule/default/destroy.yml create mode 100644 molecule/default/molecule.yml create mode 100644 molecule/default/playbook.yml create mode 100644 molecule/default/prepare.yml create mode 100644 molecule/default/requirements.yml create mode 100644 molecule/default/tests/test_default.yml create mode 100644 requirements.txt create mode 100644 tasks/dkim_domain.yml create mode 100644 tasks/main.yml create mode 100644 templates/KeyTable.j2 create mode 100644 templates/SigningTable.j2 create mode 100644 templates/TrustedHosts.j2 create mode 100644 templates/dkim.hosts.j2 create mode 100644 templates/forward.j2 create mode 100644 templates/mail-receiver-environment.json.j2 create mode 100644 templates/opendkim.conf.j2 create mode 100644 templates/opendkim.j2 create mode 100644 templates/transport.j2 create mode 100644 vars/main.yml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8b3526c --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +autonomic.discourse-email: Simple e-mail stack for Discourse +Copyright (C) 2022 Autonomic Co-operative + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d7e579 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# discourse.email diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..2289184 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,3 @@ +--- +DISCOURSE_API_USER: system +postfix_opendkim_port: 8892 diff --git a/files/discourse-smtp-fast-rejection b/files/discourse-smtp-fast-rejection new file mode 100644 index 0000000..b077841 --- /dev/null +++ b/files/discourse-smtp-fast-rejection @@ -0,0 +1,118 @@ +#!/usr/bin/env ruby + +require 'syslog' +require 'json' +require 'uri' +require 'cgi' +require 'net/http' + +ENV_FILE = "/etc/postfix/mail-receiver-environment.json" + +def logger + @logger ||= Syslog.open("smtp-reject", Syslog::LOG_PID, Syslog::LOG_MAIL) +end + +def fatal(*args) + logger.crit *args + exit 1 +end + +def main + unless File.exists?(ENV_FILE) + fatal "Config file %s does not exist. Aborting.", ENV_FILE + end + + real_env = JSON.parse(File.read(ENV_FILE)) + + %w{DISCOURSE_BASE_URL DISCOURSE_API_KEY DISCOURSE_API_USERNAME}.each do |kw| + fatal "env var %s is required", kw unless real_env[kw] + end + + process_requests(real_env) +end + +def process_requests(env) + $stdout.sync = true # unbuffered output + + args = {} + while line = gets + # Fill up args with the request details. + # logger.err "KDDEBUG line %s", line + line = line.chomp + if line.empty? + process_single_request(args, env) + args = {} # reset for next request. + else + k,v = line.chomp.split('=', 2) + args[k] = v + end + end +end + +def process_single_request(args, env) + # logger.err "KDDEBUG args %s", args + action = 'dunno' + if args['request'] != 'smtpd_access_policy' + action = 'defer_if_permit Internal error, Request type invalid' + elsif args['protocol_state'] != 'RCPT' + action = 'dunno' + elsif args['sender'].nil? + action = 'defer_if_permit No sender specified' + elsif args['recipient'].nil? + action = 'defer_if_permit No recipient specified' + else + action = maybe_reject_email(args['sender'], args['recipient'], env) + end + + puts "action=#{action}" + puts '' +end + +def maybe_reject_email(from, to, env) + endpoint = "#{env['DISCOURSE_BASE_URL']}/admin/email/smtp_should_reject.json" + key = env["DISCOURSE_API_KEY"] + username = env["DISCOURSE_API_USERNAME"] + # just maker sure we have something in the from field + # so we can test for addresses remotely + if from == '' + from = 'test@example.org' + end + uri = URI.parse(endpoint) + fromarg = CGI::escape(from) + toarg = CGI::escape(to) + + api_qs = "from=#{fromarg}&to=#{toarg}" + if uri.query and !uri.query.empty? + uri.query += "&#{api_qs}" + else + uri.query = api_qs + end + + begin + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + # logger.err "KDDEBUG request_uri %s", uri.request_uri + get = Net::HTTP::Get.new(uri.request_uri, "api-key" => "#{key}", "api-username" => "#{username}") + response = http.request(get) + rescue StandardError => ex + logger.err "Failed to GET smtp_should_reject answer from %s: %s (%s)", endpoint, ex.message, ex.class + logger.err ex.backtrace.map { |l| " #{l}" }.join("\n") + return "defer_if_permit Internal error, API request preparation failed" + ensure + http.finish if http && http.started? + end + + if Net::HTTPSuccess === response + reply = JSON.parse(response.body) + if reply['reject'] + return "reject #{reply['reason']}" + end + else + logger.err "Failed to GET smtp_should_reject answer from %s: %s", endpoint, response.code + return "defer_if_permit Internal error, API request failed" + end + + return "dunno" # let future tests also be allowed to reject this one. +end + +main if __FILE__ == $0 diff --git a/files/receive-mail b/files/receive-mail new file mode 100644 index 0000000..9a08288 --- /dev/null +++ b/files/receive-mail @@ -0,0 +1,75 @@ +#!/usr/bin/env ruby + +ENV_FILE = "/etc/postfix/mail-receiver-environment.json" +EX_TEMPFAIL = 75 +EX_SUCCESS = 0 + +require 'syslog' +require 'json' +require "uri" +require "net/http" + +def logger + @logger ||= Syslog.open("receive-mail", Syslog::LOG_PID, Syslog::LOG_MAIL) +end + +def fatal(*args) + logger.crit *args + exit EX_TEMPFAIL +end + +def main + unless File.exists?(ENV_FILE) + fatal "Config file %s does not exist. Aborting.", ENV_FILE + end + + real_env = JSON.parse(File.read(ENV_FILE)) + + %w{DISCOURSE_BASE_URL DISCOURSE_API_KEY DISCOURSE_API_USERNAME}.each do |kw| + fatal "env var %s is required", kw unless real_env[kw] + end + + recipient = ARGV.first + mail = $stdin.read + + logger.debug "Recipient: #{recipient}" + fatal "No recipient passed on command line." unless recipient + fatal "No message passed on stdin." if mail.nil? || mail.empty? + + post_email(recipient, mail, real_env) +rescue StandardError => ex + logger.err "Unexpected error while invoking mail processor: %s (%s)", ex.message, ex.class + logger.err ex.backtrace.map { |l| " #{l}" }.join("\n") + + exit EX_TEMPFAIL +end + +def post_email(_recipient, mail, env) + endpoint = "#{env['DISCOURSE_BASE_URL']}/admin/email/handle_mail" + key = env["DISCOURSE_API_KEY"] + username = env["DISCOURSE_API_USERNAME"] + + uri = URI.parse(endpoint) + + begin + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + post = Net::HTTP::Post.new(uri.request_uri,"api-key" => "#{key}", "api-username" => "#{username}") + post.set_form_data(email: mail) + + response = http.request(post) + rescue StandardError => ex + logger.err "Failed to POST the e-mail to %s: %s (%s)", endpoint, ex.message, ex.class + logger.err ex.backtrace.map { |l| " #{l}" }.join("\n") + exit EX_TEMPFAIL + ensure + http.finish if http && http.started? + end + + exit EX_SUCCESS if Net::HTTPSuccess === response + + logger.err "Failed to POST the e-mail to %s: %s", endpoint, response.code + exit EX_TEMPFAIL +end + +main if __FILE__ == $0 diff --git a/handlers/main.yml b/handlers/main.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/handlers/main.yml @@ -0,0 +1 @@ +--- diff --git a/meta/main.yml b/meta/main.yml new file mode 100644 index 0000000..015086d --- /dev/null +++ b/meta/main.yml @@ -0,0 +1,12 @@ +--- +galaxy_info: + author: "autonomic-roles" + company: "Autonomic Cooperative" + license: "GPLv3" + description: "Simple e-mail stack for Discourse" + dependencies: [] + min_ansible_version: 2.4.3 + platforms: + - name: Debian + versions: + - jessie diff --git a/molecule/default/Dockerfile.j2 b/molecule/default/Dockerfile.j2 new file mode 100644 index 0000000..903b63a --- /dev/null +++ b/molecule/default/Dockerfile.j2 @@ -0,0 +1,2 @@ +FROM {{ item.image }} +RUN /bin/sh -c 'apt-get update && apt-get upgrade -y && apt-get install -y python sudo bash' diff --git a/molecule/default/create.yml b/molecule/default/create.yml new file mode 100644 index 0000000..d850cdc --- /dev/null +++ b/molecule/default/create.yml @@ -0,0 +1,41 @@ +--- +- name: Setup + hosts: localhost + connection: local + gather_facts: False + no_log: "{{ not lookup('env', 'MOLECULE_DEBUG') | bool }}" + vars: + molecule_file: "{{ lookup('env', 'MOLECULE_FILE') }}" + molecule_ephemeral_directory: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}" + molecule_scenario_directory: "{{ lookup('env', 'MOLECULE_SCENARIO_DIRECTORY') }}" + molecule_yml: "{{ lookup('file', molecule_file) | from_yaml }}" + tasks: + - name: Create Dockerfiles from image names + template: + src: "{{ molecule_scenario_directory }}/Dockerfile.j2" + dest: "{{ molecule_ephemeral_directory }}/Dockerfile_{{ item.image | regex_replace('[^a-zA-Z0-9_]', '_') }}" + with_items: "{{ molecule_yml.platforms }}" + register: platforms + + - name: Build an Ansible compatible image + docker_image: + path: "{{ molecule_ephemeral_directory }}" + name: "molecule_local/{{ item.item.image }}" + dockerfile: "{{ item.item.dockerfile | default(item.invocation.module_args.dest) }}" + force: "{{ item.item.force | default(True) }}" + with_items: "{{ platforms.results }}" + when: platforms.changed + + - name: Create molecule instance(s) + docker_container: + name: "{{ item.name }}" + hostname: "{{ item.name }}" + image: "molecule_local/{{ item.image }}" + state: started + recreate: False + log_driver: json-file + command: "{{ item.command | default('sleep infinity') }}" + privileged: "{{ item.privileged | default(omit) }}" + volumes: "{{ item.volumes | default(omit) }}" + capabilities: "{{ item.capabilities | default(omit) }}" + with_items: "{{ molecule_yml.platforms }}" diff --git a/molecule/default/destroy.yml b/molecule/default/destroy.yml new file mode 100644 index 0000000..7e8f9c3 --- /dev/null +++ b/molecule/default/destroy.yml @@ -0,0 +1,16 @@ +--- +- name: Teardown + hosts: localhost + connection: local + gather_facts: False + no_log: "{{ not lookup('env', 'MOLECULE_DEBUG') | bool }}" + vars: + molecule_file: "{{ lookup('env','MOLECULE_FILE') }}" + molecule_yml: "{{ lookup('file', molecule_file) | from_yaml }}" + tasks: + - name: Destroy molecule instance(s) + docker_container: + name: "{{ item.name }}" + state: absent + force_kill: "{{ item.force_kill | default(True) }}" + with_items: "{{ molecule_yml.platforms }}" diff --git a/molecule/default/molecule.yml b/molecule/default/molecule.yml new file mode 100644 index 0000000..02598e9 --- /dev/null +++ b/molecule/default/molecule.yml @@ -0,0 +1,45 @@ +--- +dependency: + name: gilt + +driver: + name: docker + +lint: + name: yamllint + +platforms: + - name: container + image: debian:stretch + privileged: true + +provisioner: + name: ansible + lint: + name: ansible-lint + connection_options: + ansible_ssh_user: 'root' + ansible_ssh_common_args: -o ServerAliveInterval=30 -o ControlMaster=auto -o ControlPersist=60s + +scenario: + name: default + test_sequence: + - dependency + - lint + - cleanup + - destroy + - syntax + - create + - prepare + - converge + # no idempotence for the moment + # - idempotence + - side_effect + - verify + - cleanup + - destroy + +verifier: + name: testinfra + lint: + name: flake8 diff --git a/molecule/default/playbook.yml b/molecule/default/playbook.yml new file mode 100644 index 0000000..c30d72f --- /dev/null +++ b/molecule/default/playbook.yml @@ -0,0 +1,11 @@ +--- +- name: Converge + hosts: all + gather_facts: True + become: true + roles: + - role: autonomic.discourse.email + vars: + hostname: forum.example.com + root_email_forward: nobody@example.com + DISCOURSE_API_KEY: foobar diff --git a/molecule/default/prepare.yml b/molecule/default/prepare.yml new file mode 100644 index 0000000..94d5ce6 --- /dev/null +++ b/molecule/default/prepare.yml @@ -0,0 +1,30 @@ +--- +- name: Prepare + hosts: all + gather_facts: True + become: True + roles: + - role: geerlingguy.docker + tasks: + - name: Install python for Ansible + raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal python-zipstream) + become: true + changed_when: false + + - name: Install Ansible Python packages + apt: + package: + - python-setuptools + - python-pip + become: true + + - name: Install python-docker + pip: + name: docker + + - name: Create dummy Discourse container + docker_container: + name: app + image: alpine + state: started + privileged: true diff --git a/molecule/default/requirements.yml b/molecule/default/requirements.yml new file mode 100644 index 0000000..3f938f6 --- /dev/null +++ b/molecule/default/requirements.yml @@ -0,0 +1,2 @@ +--- +- geerlingguy.docker diff --git a/molecule/default/tests/test_default.yml b/molecule/default/tests/test_default.yml new file mode 100644 index 0000000..147c21b --- /dev/null +++ b/molecule/default/tests/test_default.yml @@ -0,0 +1,9 @@ +--- + +file: + /etc/mail-receiver-environment.json: + exists: true + +package: + postfix: + installed: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a1eca4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +ansible==2.4.3.0 +docker<3.0 # https://github.com/ansible/ansible/issues/35612 +molecule==2.22 diff --git a/tasks/dkim_domain.yml b/tasks/dkim_domain.yml new file mode 100644 index 0000000..e583c62 --- /dev/null +++ b/tasks/dkim_domain.yml @@ -0,0 +1,98 @@ +--- +- name: "Directory for opendkim keys for {{ domain }} present" + file: + path: "/etc/opendkim/keys/{{ domain }}" + state: directory + owner: opendkim + group: opendkim + mode: 0700 + tags: + - email + +- name: "OpenDKIM selector present for {{ domain }}" + shell: "date +%Y%m%d > /etc/opendkim/{{ domain }}_selector.txt" + args: + executable: /bin/bash + creates: "/etc/opendkim/{{ domain }}_selector.txt" + tags: + - email + +- name: "OpenDKIM selector selector read for {{ domain }}" + slurp: + src: "/etc/opendkim/{{ domain }}_selector.txt" + register: "selector_b64encoded" + tags: + - email + +- name: "Set a fact for the selector for {{ domain }}" + set_fact: + selector: "{{ selector_b64encoded['content'] | b64decode | trim }}" + tags: + - email + +- name: "Keys for {{ domain }} present" + command: "opendkim-genkey -b 2048 -h sha256 -s {{ selector }} -d {{ domain }} -D /etc/opendkim/keys/{{ domain }}" + args: + creates: "/etc/opendkim/keys/{{ domain }}/{{ selector }}.private" + tags: + - email + +- name: "SPF record added to /etc/opendkim/keys/{{ domain }}/{{ selector }}.txt" + lineinfile: + path: "/etc/opendkim/keys/{{ domain }}/{{ selector }}.txt" + line: '{{ domain }}. IN TXT "v=spf1 a mx include:{{ domain }} ~all"' + state: present + tags: + - email + +- name: "OpenDKIM private key for {{ domain }} owned and only readable by opendkim user" + file: + path: "/etc/opendkim/keys/{{ domain }}/{{ selector }}.private" + owner: opendkim + group: opendkim + mode: 0600 + tags: + - email + +- name: "OpenDKIM key check for {{ domain }}" + shell: "opendkim-testkey -d {{ domain }} -s {{ selector }} -k {{ selector }}.private -vvv || echo 'key FAIL'" + args: + chdir: "/etc/opendkim/keys/{{ domain }}" + check_mode: false + register: opendkim_check + changed_when: false + tags: + - email + +- name: "DNS configuration needed for {{ domain }}" + debug: + msg: "Please add the DNS record from /etc/opendkim/keys/{{ domain }}/{{ selector }}.txt" + when: '"key OK" not in opendkim_check.stdout' + tags: + - email + +- name: "OpenDKIM key check passed so {{ domain }} added to new KeyTable and SigningTable files" + block: + + - name: "KeyTable for {{ domain }} {{ opendkim_check.stdout }}" + lineinfile: + path: /etc/opendkim/KeyTable.new + line: "{{ selector }}._domainkey.{{ domain }} {{ domain }}:{{ selector }}:/etc/opendkim/keys/{{ domain }}/{{ selector }}.private" + regexp: "\\._domainkey\\.{{ domain }} {{ domain }}:{{ selector }}:" + state: present + create: true + tags: + - email + + - name: "SigningTable for {{ domain }} {{ opendkim_check.stdout }}" + lineinfile: + path: /etc/opendkim/SigningTable.new + line: "*@{{ domain }} {{ selector }}._domainkey.{{ domain }}" + regexp: "^\\*@{{ domain }} " + state: present + create: true + tags: + - email + + when: '"key OK" in opendkim_check.stdout' +... diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..687f4b5 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,397 @@ +--- +- name: Ruby packages installed + apt: + pkg: + - ruby2.3 + - ruby-addressable + - ruby-json + - ruby-net-http-persistent + - ruby-syslog-logger + state: present + update_cache: yes + tags: + - email + +- name: Ruby script receive-mail in place + copy: + src: files/receive-mail + dest: /usr/local/bin/receive-mail + mode: 0755 + tags: + - email + +- name: Ruby script discourse-smtp-fast-rejection in place + copy: + src: files/discourse-smtp-fast-rejection + dest: /usr/local/bin/discourse-smtp-fast-rejection + mode: 0755 + tags: + - email + +- name: Old, unneeded files removed + file: + path: /usr/local/bin/discourse-smtp-rcpt-acl + state: absent + tags: + - email + +- name: debconf-utils installed for Ansible + apt: + name: debconf-utils + state: present + tags: + - email + +- name: Debconf Postfix hostname set + debconf: + name: postfix + question: "postfix/mailname" + value: "{{ hostname }}" + vtype: string + tags: + - email + +- name: Debconf Postfix set to be a internet server + debconf: + name: postfix + question: "postfix/main_mailer_type" + value: "Internet Site" + vtype: string + tags: + - email + +- name: Postfix and related email packages installed + apt: + pkg: + - ca-certificates + - curl + - debian-archive-keyring + - dnsutils + - mailutils + - mutt + - opendkim + - opendkim-tools + - postfix + - pwgen + - whois + state: present + tags: + - email + +- name: Postfix smtpd_relay_restrictions set + command: postconf -e "smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated reject_unauth_destination" + changed_when: false + tags: + - email + +- name: Postfix set not to use /etc/aliases + command: postconf -e "alias_maps = " + changed_when: false + tags: + - email + +- name: Postfix mydestination set to localhost + command: postconf -e "mydestination = localhost" + changed_when: false + tags: + - email + +- name: python-docker installed + apt: + pkg: + - python3-docker + state: present + tags: + - email + +- name: Fetch app container information + docker_container_info: + name: app + register: containerinfo + tags: + - email + +- name: Get the app container IP address + set_fact: + app_ip_address: '{{ containerinfo.container.NetworkSettings.IPAddress }}' + tags: + - email + +- name: Postfix my networks set to include {{ app_ip_address }} + command: postconf -e "mynetworks = 127.0.0.0/8,{{ app_ip_address }}" + changed_when: false + tags: + - email + +- name: Postfix relay domains set to {{ hostname }} + command: postconf -e "relay_domains = {{ hostname }}" + changed_when: false + tags: + - email + +- name: Postfix smtpd_recipient_restrictions set + command: postconf -e "smtpd_recipient_restrictions = permit_mynetworks, check_policy_service unix:private/policy" + changed_when: false + tags: + - email + +- name: Postfix opportunistic TLS enabled + command: postconf -e "smtp_tls_security_level = may" + changed_when: false + tags: + - email + +- name: Postfix set to use sub-addresing + command: postconf -e "recipient_delimiter = +" + changed_when: false + tags: + - email + +- name: Postfix disable UTF-8 SMTP input + command: postconf -e "smtputf8_enable=no" + changed_when: false + tags: + - email + +- name: Postfix Time Zone and Lang set + command: postconf -e "export_environment='TZ LANG'" + changed_when: false + tags: + - email + +- name: Postfix set for ipv4 only + command: postconf -e "inet_protocols = ipv4" + changed_when: false + tags: + - email + +- name: Postfix set to use /usr/local/bin/receive-mail + command: postconf -M -e "discourse/unix=discourse unix - n n - - pipe user=nobody:nogroup argv=/usr/local/bin/receive-mail ${recipient}" + changed_when: false + tags: + - email + +- name: Postfix transport in place + template: + src: templates/transport.j2 + dest: /etc/postfix/transport + mode: 0644 + tags: + - email + +- name: Postfix Transport Maps file set + command: postconf -e "transport_maps=hash:/etc/postfix/transport" + changed_when: false + tags: + - email + +- name: Postmap run with Transport Maps file + command: postmap /etc/postfix/transport + changed_when: false + tags: + - email + +- name: Postfix set to reject incorrect email addresses + command: postconf -M -e "policy/unix=policy unix - n n - - spawn user=nobody argv=/usr/local/bin/discourse-smtp-fast-rejection" + changed_when: false + tags: + - email + +- name: Stat "/var/discourse/shared/standalone/letsencrypt/{{ hostname }}/{{ hostname }}.cer" + stat: + path: "/var/discourse/shared/standalone/letsencrypt/{{ hostname }}/{{ hostname }}.cer" + check_mode: false + register: le_cert + tags: + - email + +- block: + + - name: Postfix configured to use Let's Encrypt RSA cert for incoming email + command: postconf -e "smtpd_tls_cert_file = /var/discourse/shared/standalone/letsencrypt/{{ hostname }}/{{ hostname }}.cer" + tags: + - email + + - name: Postfix configured to use Let's Encrypt RSA key for incoming email + command: postconf -e "smtpd_tls_key_file = /var/discourse/shared/standalone/letsencrypt/{{ hostname }}/{{ hostname }}.key" + tags: + - email + + when: le_cert.stat.exists + +- name: Directories for opendkim keys and configuration present + file: + path: "{{ dir.name }}" + state: directory + owner: "{{ dir.owner }}" + group: "{{ dir.group }}" + mode: "{{ dir.mode }}" + loop: + - name: /etc/opendkim + mode: "0750" + owner: root + group: opendkim + - name: /etc/opendkim/keys + mode: "0750" + owner: root + group: opendkim + loop_control: + loop_var: dir + tags: + - email + +- name: Set a fact for the postfix_dkim_domains array if it it not defined + set_fact: + dkim_domains: + - "{{ hostname | default(inventory_hostname) }}" + when: ( dkim_domains is not defined ) or ( dkim_domains == [] ) + tags: + - email + +- name: Generate new KeyTable and SigningTable files + template: + src: "{{ template }}.j2" + dest: "/etc/opendkim/{{ template }}.new" + loop: + - KeyTable + - SigningTable + loop_control: + loop_var: template + tags: + - email + +- name: Loop through the postfix_dkim_domains array including DKIM tasks + include_tasks: dkim_domain.yml + loop: "{{ dkim_domains }}" + loop_control: + loop_var: domain + tags: + - email + +- name: Copy the new KeyTable and SigningTable files into place if changed + copy: + src: "{{ file }}.new" + dest: "{{ file }}" + remote_src: true + loop: + - /etc/opendkim/KeyTable + - /etc/opendkim/SigningTable + loop_control: + loop_var: file + tags: + - email + +- name: Check if the KeyTable has more than one line + command: wc -l /etc/opendkim/KeyTable + check_mode: false + changed_when: false + register: opendkim_keytable_check + tags: + - email + +- name: Check if the SigningTable has more than one line + command: wc -l /etc/opendkim/SigningTable + check_mode: false + changed_when: false + register: opendkim_signingtable_check + tags: + - email + +- name: Set fact for KeyTable and SigningTable file lengths + set_fact: + opendkim_keytable_length: "{{ opendkim_keytable_check.stdout | replace('/etc/opendkim/KeyTable', '') | trim | int }}" + opendkim_signingtable_length: "{{ opendkim_signingtable_check.stdout | replace('/etc/opendkim/SigningTable', '') | trim | int }}" + tags: + - email + +- name: Enable OpenDKIM + block: + + - name: Configure TrustedHosts + template: + src: templates/TrustedHosts.j2 + dest: /etc/opendkim/TrustedHosts + owner: root + group: root + mode: 0644 + tags: + - email + + - name: OpenDKIM configuration in place + template: + src: templates/opendkim.conf.j2 + dest: /etc/opendkim.conf + tags: + - email + + - name: Run postconf to add DKIM configuration to main.cf + command: postconf -e "{{ edit }}" + loop: + - "milter_default_action = accept" + - "milter_protocol = 6" + - "smtpd_milters = inet:localhost:{{ postfix_opendkim_port }}" + - "non_smtpd_milters = inet:localhost:{{ postfix_opendkim_port }}" + loop_control: + loop_var: edit + tags: + - email + + - name: OpenDKIM enabled and restarted + service: + name: opendkim + enabled: true + state: restarted + tags: + - email + + when: ( opendkim_keytable_length | int > 1 ) and ( opendkim_signingtable_length | int > 1 ) + +- name: Disable OpenDKIM + block: + + - name: Run postconf to remove DKIM configuration from main.cf + command: postconf -X "{{ remove }}" + loop: + - "milter_default_action" + - "milter_protocol" + - "smtpd_milters" + - "non_smtpd_milters" + loop_control: + loop_var: remove + changed_when: false + tags: + - email + + - name: OpenDKIM disabled and stopped + service: + name: opendkim + enabled: false + state: stopped + when: ( postfix_dkim_dns_configured is not defined ) or ( not postfix_dkim_dns_configured ) + tags: + - email + + when: ( opendkim_keytable_length | int == 1 ) or ( opendkim_signingtable_length | int == 1 ) + +- name: mail-receiver-environment in place + template: + src: templates/mail-receiver-environment.json.j2 + dest: /etc/postfix/mail-receiver-environment.json + owner: root + group: root + mode: 0644 + +- name: Postfix restarted + service: + name: postfix + state: restarted + tags: + - email + +- name: Root .forward in place + template: + src: templates/forward.j2 + dest: /root/.forward + tags: + - email +... diff --git a/templates/KeyTable.j2 b/templates/KeyTable.j2 new file mode 100644 index 0000000..e2bb153 --- /dev/null +++ b/templates/KeyTable.j2 @@ -0,0 +1 @@ +# {{ ansible_managed }} diff --git a/templates/SigningTable.j2 b/templates/SigningTable.j2 new file mode 100644 index 0000000..e2bb153 --- /dev/null +++ b/templates/SigningTable.j2 @@ -0,0 +1 @@ +# {{ ansible_managed }} diff --git a/templates/TrustedHosts.j2 b/templates/TrustedHosts.j2 new file mode 100644 index 0000000..8674dc0 --- /dev/null +++ b/templates/TrustedHosts.j2 @@ -0,0 +1,4 @@ +# {{ ansible_managed }} +127.0.0.1 +localhost +{{ app_ip_address }} diff --git a/templates/dkim.hosts.j2 b/templates/dkim.hosts.j2 new file mode 100644 index 0000000..270c45d --- /dev/null +++ b/templates/dkim.hosts.j2 @@ -0,0 +1,4 @@ +# {{ ansible_managed }} +# Add these entries to the DNS: +{{ hostname | default(inventory_hostname) }}. IN TXT "v=spf1 a mx include:{{ hostname | default(inventory_hostname) }} ~all" +{{ postfix_dkim_selector_hostname | default(inventory_hostname) }}._domainkey.{{ hostname | default(inventory_hostname) }}. IN TXT "v=DKIM1;k=rsa;t=s;s=email;p={{ postfix_dkim_pub_key_stripped.stdout }}" diff --git a/templates/forward.j2 b/templates/forward.j2 new file mode 100644 index 0000000..cba0f38 --- /dev/null +++ b/templates/forward.j2 @@ -0,0 +1 @@ +{{ root_email_forward }} diff --git a/templates/mail-receiver-environment.json.j2 b/templates/mail-receiver-environment.json.j2 new file mode 100644 index 0000000..3efaf06 --- /dev/null +++ b/templates/mail-receiver-environment.json.j2 @@ -0,0 +1,5 @@ +{ + "DISCOURSE_BASE_URL": "https://{{ hostname }}", + "DISCOURSE_API_KEY": "{{ DISCOURSE_API_KEY }}", + "DISCOURSE_API_USERNAME": "{{ DISCOURSE_API_USER }}" +} diff --git a/templates/opendkim.conf.j2 b/templates/opendkim.conf.j2 new file mode 100644 index 0000000..4385e63 --- /dev/null +++ b/templates/opendkim.conf.j2 @@ -0,0 +1,90 @@ +# {{ ansible_managed }} +# +# This is a basic configuration that can easily be adapted to suit a standard +# installation. For more advanced options, see opendkim.conf(5) and/or +# /usr/share/doc/opendkim/examples/opendkim.conf.sample. + +# Log to syslog +Syslog yes +SyslogSuccess yes +LogWhy yes +# Required to use local socket with MTAs that access the socket as a non- +# privileged user (e.g. Postfix) +UMask 002 + +# Sign for example.com with key in /etc/dkimkeys/dkim.key using +# selector '2007' (e.g. 2007._domainkey.example.com) +# Domain example.com +# KeyFile /etc/dkimkeys/dkim.key +# Selector 2007 + +# Commonly-used options; the commented-out versions show the defaults. +#Canonicalization simple +#Mode sv +#SubDomains no + +# Socket smtp://localhost +# +# ## Socket socketspec +# ## +# ## Names the socket where this filter should listen for milter connections +# ## from the MTA. Required. Should be in one of these forms: +# ## +# ## inet:port@address to listen on a specific interface +# ## inet:port to listen on all interfaces +# ## local:/path/to/socket to listen on a UNIX domain socket +# +Socket inet:{{ postfix_opendkim_port }}@localhost +#Socket local:/var/run/opendkim/opendkim.sock + +## PidFile filename +### default (none) +### +### Name of the file where the filter should write its pid before beginning +### normal operations. +# +PidFile /var/run/opendkim/opendkim.pid + + +# Always oversign From (sign using actual From and a null From to prevent +# malicious signatures header fields (From and/or others) between the signer +# and the verifier. From is oversigned by default in the Debian pacakge +# because it is often the identity key used by reputation systems and thus +# somewhat security sensitive. +OversignHeaders From + +## ResolverConfiguration filename +## default (none) +## +## Specifies a configuration file to be passed to the Unbound library that +## performs DNS queries applying the DNSSEC protocol. See the Unbound +## documentation at http://unbound.net for the expected content of this file. +## The results of using this and the TrustAnchorFile setting at the same +## time are undefined. +## In Debian, /etc/unbound/unbound.conf is shipped as part of the Suggested +## unbound package + +# ResolverConfiguration /etc/unbound/unbound.conf + +## TrustAnchorFile filename +## default (none) +## +## Specifies a file from which trust anchor data should be read when doing +## DNS queries and applying the DNSSEC protocol. See the Unbound documentation +## at http://unbound.net for the expected format of this file. + +TrustAnchorFile /usr/share/dns/root.key + +## Userid userid +### default (none) +### +### Change to user "userid" before starting normal operation? May include +### a group ID as well, separated from the userid by a colon. +# +UserID opendkim:opendkim + +## Signing options +ExternalIgnoreList refile:/etc/opendkim/TrustedHosts +InternalHosts refile:/etc/opendkim/TrustedHosts +KeyTable refile:/etc/opendkim/KeyTable +SigningTable refile:/etc/opendkim/SigningTable diff --git a/templates/opendkim.j2 b/templates/opendkim.j2 new file mode 100644 index 0000000..db9c60f --- /dev/null +++ b/templates/opendkim.j2 @@ -0,0 +1,23 @@ +# {{ ansible_managed }} +# Command-line options specified here will override the contents of +# /etc/opendkim.conf. See opendkim(8) for a complete list of options. +#DAEMON_OPTS="" +# Change to /var/spool/postfix/var/run/opendkim to use a Unix socket with +# postfix in a chroot: +#RUNDIR=/var/spool/postfix/var/run/opendkim +RUNDIR=/var/run/opendkim +# +# Uncomment to specify an alternate socket +# Note that setting this will override any Socket value in opendkim.conf +# default: +# SOCKET=local:$RUNDIR/opendkim.sock +# listen on all interfaces on port 54321: +#SOCKET=inet:54321 +# listen on loopback on port 12345: +SOCKET=inet:{{ postfix_opendkim_port }}@localhost +# listen on 192.0.2.1 on port 12345: +#SOCKET=inet:12345@192.0.2.1 +USER=opendkim +GROUP=opendkim +PIDFILE=$RUNDIR/$NAME.pid +EXTRAAFTER= diff --git a/templates/transport.j2 b/templates/transport.j2 new file mode 100644 index 0000000..e4f9e67 --- /dev/null +++ b/templates/transport.j2 @@ -0,0 +1 @@ +{{ hostname }} discourse: diff --git a/vars/main.yml b/vars/main.yml new file mode 100644 index 0000000..6332e53 --- /dev/null +++ b/vars/main.yml @@ -0,0 +1,2 @@ +--- +ansible_user: "{{ lookup('env', 'ANSIBLE_USER') }}"