commit 16c2f29f065139ee360e717e79eb2831c3bb72a7 Author: Mauro Torrez Date: Sun Jun 2 18:35:36 2019 -0300 commit inicial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5fbf38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,209 @@ +# ---> Emacs +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Org-mode +.org-id-locations +*_archive + +# flymake-mode +*_flymake.* + +# eshell files +/eshell/history +/eshell/lastdir + +# elpa packages +/elpa/ + +# reftex files +*.rel + +# AUCTeX auto folder +/auto/ + +# cask packages +.cask/ +dist/ + +# Flycheck +flycheck_*.el + +# server auth directory +/server/ + +# projectiles files +.projectile + +# directory configuration +.dir-locals.el + +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ---> Ansible +*.retry + +# ---> Linux +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# ---> macOS +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b556f4d --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# Postfix Ansible role + +Rol de Ansible para configurar el servidor SMTP Postfix. diff --git a/action_plugins/postconf.py b/action_plugins/postconf.py new file mode 100755 index 0000000..357e100 --- /dev/null +++ b/action_plugins/postconf.py @@ -0,0 +1,369 @@ +# -*- coding: utf-8 -*- +# +# (c) 2018, Mauro Torrez +# +# 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 . + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re +from ansible.plugins.action import ActionBase +from ansible.utils.vars import merge_hash +from ansible.module_utils._text import to_text + +class PfWorkflowError(Exception): + pass + +class ActionModule(ActionBase): + + def getarg(self, key, default=True): + if isinstance(default, basestring): + if key == 'parameter': + return self._task.args.get(key, default) + return '{}'.format(self._task.args.get(key, default)) + if default: + if key == 'state': + return '{}'.format(self._task.args.get(key, 'present')) + if key == 'parameter': + return self._task.args.get(key, '') + if key in ('service', 'command', 'value', 'type'): + return '{}'.format(self._task.args.get(key, '')) + if key in ('private', 'unprivileged', 'chroot', 'wakeup', 'process_limit'): + return '{}'.format(self._task.args.get(key, '')) + # no default value for key: return as-is + return self._task.args.get(key) + + + def runcmd(self, reg_name, cmd, param=None): + try: + if param: + self.reg[param][reg_name] = self._execute_module( + module_name='command', + module_args=dict(_raw_params=cmd) + ) + return + self.reg[reg_name] = self._execute_module( + module_name='command', + module_args=dict(_raw_params=cmd) + ) + except Exception as e: + raise PfWorkflowError( + "{}: {}; failed command line: {}".format( + type(e).__name__, + to_text(e), + cmd + ) + ) + + + def validate_arguments(self): + '''assert arguments are valid''' + + # validate service name + self.state = self.getarg('state') + if self.state not in ('present', 'absent', 'prepend', 'append'): + raise PfWorkflowError( + 'Invalid state requested: use "present", "absent", "prepend" or "append"' + ) + + # validate service name + self.service = self.getarg('service') + if re.search('[^a-zA-Z0-9_]',self.service): + raise PfWorkflowError( + 'Invalid character found in service identifier' + ) + + # validate service type text + self.stype = self.getarg('type') + if re.search('[^a-zA-Z0-9_]',self.stype): + raise PfWorkflowError( + 'Invalid character found in service type identifier' + ) + + # validate flags + for p in ('private', 'unprivileged', 'chroot'): + if re.search('[^yn-]',self.getarg(p)): + raise PfWorkflowError( + 'Invalid value for boolean flag {}'.format(p) + ) + + # validate flags + if re.search('[^0-9?-]',self.getarg('wakeup')): + raise PfWorkflowError( + 'Invalid value for wakeup flag: {}'.format(self.getarg('wakeup')) + ) + + # validate flags + if re.search('[^0-9-]',self.getarg('process_limit')): + raise PfWorkflowError( + 'Invalid value for process_limit flag: {}'.format(self.getarg('process_limit')) + ) + + + def set_service(self): + ''' + Set service entry in master.cf + ''' + if not self.service: + self.reg['service_skipped'] = True + return + + state = self.getarg('state') + + # guess existing entry ------------------------------------------------ + + # get current service entry (if exists) + cmdline = 'postconf -M {}'.format(self.service) + if self.stype: + # append service type to the command line + cmdline = '{}/{}'.format(cmdline,self.stype) + + self.runcmd('cmd_initial_check',cmdline) + + # check service is defined and unambiguous by counting stdout lines + if len(self.reg['cmd_initial_check']['stdout_lines']) > 1: + raise PfWorkflowError( + 'Ambiguous service identifier, please provide service type' + ) + + elif len(self.reg['cmd_initial_check']['stdout_lines']) == 1: + if not self.stype: + self.stype = self.reg['cmd_initial_check']['stdout'].split()[1] + + # fill service fields as given by postconf + cmdline = 'postconf -Fh \ + {0}/{1}/private {0}/{1}/unprivileged {0}/{1}/chroot \ + {0}/{1}/wakeup {0}/{1}/process_limit {0}/{1}/command'.format( + self.service, self.stype) + self.runcmd('cmd_fields_check',cmdline) + + private = self.getarg( + 'private', + self.reg['cmd_fields_check']['stdout_lines'][0] + ) + unprivileged = self.getarg( + 'unprivileged', + self.reg['cmd_fields_check']['stdout_lines'][1] + ) + chroot = self.getarg( + 'chroot', + self.reg['cmd_fields_check']['stdout_lines'][2] + ) + wakeup = self.getarg( + 'wakeup', + self.reg['cmd_fields_check']['stdout_lines'][3] + ) + process_limit = self.getarg( + 'process_limit', + self.reg['cmd_fields_check']['stdout_lines'][4] + ) + + # change the command field when set explicitly + command = self.reg['cmd_fields_check']['stdout_lines'][5] + if self.getarg('command') and command.split()[0] != self.getarg('command'): + command = self.getarg('command') + + else: + # no entry for service, create it if state != absent + if state != 'absent': + private = self.getarg('private','-') + unprivileged = self.getarg('unprivileged','-') + chroot = self.getarg('chroot','-') + wakeup = self.getarg('wakeup','-') + process_limit = self.getarg('process_limit','-') + command = self.getarg('command') + + cmdline = 'postconf -M {0}/{1}="{0} {1} {2} {3} {4} {5} {6} {7}"'.format( + self.service, + self.stype, + private, + unprivileged, + chroot, + wakeup, + process_limit, + command + ) + self.runcmd('cmd_add_service_entry',cmdline) + + + # state = absent ------------------------------------------------------ + if state == 'absent': + if self.getarg('parameter'): + # state=absent with parameter does not remove service entry + pass + else: + # remove whole service definition + cmdline = 'postconf -M# {}/{}'.format(service,stype) + self.runcmd('cmd_remove_service',cmdline) + + # non-absent states: set all fields ----------------------------------- + else: + cmdline = 'postconf -F \ + {0}/{1}/private={2} {0}/{1}/unprivileged={3} {0}/{1}/chroot={4} \ + {0}/{1}/wakeup={5} {0}/{1}/process_limit={6} {0}/{1}/command={7}'.format( + self.service, self.stype, private, unprivileged, chroot, wakeup, process_limit, + '"' + command.replace(r'\\',r'\\\\').replace(r'"',r'\"') + '"' + ) + self.runcmd('cmd_fields_set',cmdline) + + + # verify changes + cmdline = 'postconf -M {}/{}'.format(self.service,self.stype) + self.runcmd('cmd_final_check',cmdline) + + self.reg['service_changed'] = True + if self.reg['cmd_initial_check']['stdout'] == self.reg['cmd_final_check']['stdout']: + self.reg['service_changed'] = False + + + def set_single_parameter(self, parameter='', value='', state=''): + ''' + Set a single parameter value in main.cf (service=None) or master.cf + ''' + + # value should be string + if not isinstance(value, basestring): + try: + value = ', '.join(value) + except TypeError: + value = str(value) + + # default command to execute for setting the parameter + postconf_cmd = 'postconf' + + # validate parameter identifier + if not parameter or re.search('[^a-zA-Z0-9_-]', parameter): + raise PfWorkflowError( + 'Invalid parameter identifier: «{}»'.format(parameter) + ) + + regkey = "param_{}".format(parameter) + self.reg[regkey] = {} + + if self.service: + # change command to execute for setting the parameter + postconf_cmd = 'postconf -P' + # change parameter identifier to include service/type + parameter = '/'.join([self.service,self.stype,parameter]) + + # + # general logic for setting parameter + # + + # 1: get current value + cmdline = "{} -h {}".format(postconf_cmd, parameter) + self.runcmd('cmd_initial_check',cmdline,regkey) + current_value = self.reg[regkey]['cmd_initial_check']['stdout'] + + # 2: act according to desired state + if state == 'absent': + cmdline = "{} -X {}".format(postconf_cmd, parameter) + self.runcmd('cmd_remove',cmdline,regkey) + + elif state in ('append', 'prepend'): + # we assume the current value to be a comma-separated list, + # and check if desired value is contained in current value + + if value not in current_value: + + # only modify parameter if desired value not contained in current + if state == 'prepend': + new_value = ', '.join([value,current_value]) + else: + new_value = ', '.join([current_value,value]) + + # escape value according to shlex posix mode + escaped_value= '"' + new_value.replace( + r'\\',r'\\\\').replace(r'"',r'\"')+'"' + cmdline = "{} -e {}={}".format(postconf_cmd, parameter, escaped_value) + self.runcmd('cmd_modify',cmdline,regkey) + + elif state == 'present': + if value.strip() != current_value.strip(): + # set value exactly as required + # escape value according to shlex posix mode + escaped_value= '"' + value.replace( + r'\\',r'\\\\').replace(r'"',r'\"')+'"' + cmdline = "{} -e {}={}".format(postconf_cmd, parameter, escaped_value) + self.runcmd('cmd_modify_exact',cmdline,regkey) + + else: + # unsupported state + raise PfWorkflowError( + "Unsupported value in «state»" + ) + + # finally check how the value has changed + cmdline = "{} -h {}".format(postconf_cmd, parameter) + self.runcmd('cmd_final_check',cmdline,regkey) + + changed = self.reg.get('parameter_changed', False) + final_value = self.reg[regkey]['cmd_final_check']['stdout'] + if final_value != current_value: + changed = True + + self.reg['parameter_changed'] = changed + + + def run(self, tmp=None, task_vars=None): + + result = super(ActionModule, self).run(tmp, task_vars) + if result.get('skipped'): + return result + + try: + self.validate_arguments() + except PfWorkflowError as e: + return { + 'failed': True, + 'msg': e.message + } + + self.reg = {} + + # process service + if self.getarg('service',False): + self.set_service() + + parameter = self.getarg('parameter') + state = self.getarg('state') + if parameter: + # build (parameter, value) pairs for iteration + if isinstance(parameter, basestring): + pv = [ (parameter, self.getarg('value')) ] + elif isinstance(parameter, dict): + # if dict ignore new_value + pv = parameter.items() + else: + # assume list --> only supported for 'absent' + pv = zip(parameter, parameter) + + for p in pv: + # loop over parameters and exit if error + self.set_single_parameter(p[0], p[1], state=state) + else: + self.reg['parameter_skipped'] = True + + if all((self.reg.get('service_skipped',False), + self.reg.get('parameter_skipped',False))): + return { + 'failed': True, + 'msg': 'FATAL: no action requested!' + } + + result['changed'] = any((self.reg.get('service_changed'), + self.reg.get('parameter_changed'))) + result['reg'] = self.reg + return result diff --git a/defaults/main.yml b/defaults/main.yml new file mode 100644 index 0000000..5d554d5 --- /dev/null +++ b/defaults/main.yml @@ -0,0 +1,271 @@ +--- + +# Postfix config directory (changing this is probably a bad idea) +postfix_config_dir: "/etc/postfix" + +# Where to save access lists +postfix_rules_dir: "{{ postfix_config_dir }}/rules" + +# accepted email domains +postfix_mail_domains: "{{ mail_domains | default(['example.com']) }}" + +# postfix server domain: used for identification of the server +postfix_server_domain: "{{ postfix_mail_domains | first }}" + +# postfix server identification +postfix_server_name: "mail.{{ postfix_server_domain }}" + +# domains considered as "local" unix domains (local server users) +# ansible_fqdn, localhost.localdomain, and localhost are always added regardless of this value +postfix_unix_domains: [] + +# networks considered "local" +# 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 are always added regardless of this value +postfix_local_networks: [] + +# domain-specific configuration +postfix_domain_config: {} + +# LDAP ------------------------------------------------------------------------ + +# Default LDAP connection parameters +postfix_ldap_server: "localhost" +postfix_ldap_port: 389 +postfix_ldap_version: 3 +postfix_ldap_scope: "sub" +postfix_ldap_bind: no +postfix_ldap_bind_dn: '' +postfix_ldap_bind_pw: '' +postfix_ldap_start_tls: no +postfix_ldap_tls_ca_cert_dir: '' +postfix_ldap_tls_ca_cert_file: '' +postfix_ldap_use_group_alias: yes + +# SQLITE ---------------------------------------------------------------------- + +postfix_sqlite_user_query: "SELECT name FROM users WHERE name = '%u'" +postfix_sqlite_alias_query: "SELECT dest FROM aliases WHERE alias = '%s'" + +# ----------------------------------------------------------------------------- + +# enable submission service? +postfix_submission_enable: yes + +# TLS ------------------------------------------------------------------------- + +# TLS certificate/private key to use +postfix_tls_certificate: "{{ tls_certificate | default('') }}" +postfix_tls_private_key: "{{ tls_certificate_key | default('') }}" + +# level of encryption to use for sending mail to the Internet +# only change this if you know what the implications are, see +# http://www.postfix.org/postconf.5.html#smtp_tls_security_level +# possible values: none|may|encrypt|dane|dane-only|fingerprint|verify|secure +postfix_outgoing_tls_security: 'may' + +# level of encryption required for receiving mail from the Internet +# only change this if you know what the implications are, see +# http://www.postfix.org/postconf.5.html#smtpd_tls_security_level +postfix_incoming_tls_security: 'may' + +# wether to allow insecure (plaintext) login from clients +postfix_allow_insecure_auth: no + +# enable/disable tls session cache +postfix_tls_session_cache: yes + +# SASL ------------------------------------------------------------------------ + +# wether to allow SASL authentication on smtpd (MX port 25) +postfix_enable_smtpd_auth: no + +# creo que esto no sirve para nada +# smtpd_sasl_local_domain: $myhostname + +# valor por defecto ya es noanonymous +# smtpd_sasl_security_options: noanonymous + +# ----------------------------------------------------------------------------- + + +# MESSAGE SIZE LIMIT ---------------------------------------------------------- + +# This value should be fine for most people. Note that increasing this limit +# does not guarantee delivery of very big emails: remote server size +# restrictions still apply. +postfix_message_size_limit: 31457280 + +# HELO required --------------------------------------------------------------- +postfix_helo_required: yes + +# biff enabled +postfix_biff: no + +# postfix_bounce_queue_lifetime: 2d +# postfix_maximal_queue_lifetime: 2d +# postfix_compatibility_level = 2 DEFAULT 0 + +# ----------------------------------------------------------------------------- + +# RESTRICTIONS AND ACCESS LISTS ----------------------------------------------- + +# Host-based access list: PCRE table, for wildcard support +# every item should contain a 'regex' field and a corresponding 'action' field +postfix_client_access_list: [] + # - regex: '172\.16\.192\.0\/24' + # action: 'REJECT Please use alternative server' + # - regex: 'rude\.client\.com' + # action: 'REJECT Get out!' + +# Restricciones aplicadas a los clientes SMTP +postfix_client_restrictions: + - check_client_access pcre:{{ postfix_rules_dir }}/client_access_list + - permit_sasl_authenticated + - permit_mynetworks + - reject_unknown_client_hostname + - reject_unauth_pipelining + - permit + +# Restricciones aplicadas en SMTP DATA +postfix_data_restrictions: + - reject_unauth_pipelining + - permit + +# HELO access list: reject/accept clients by their HELO hostname +# Hash table, list of items with "host" and corresponding "action" fields +postfix_helo_access_list: [] + # - host: "{{ansible_ip}}" + # action: "REJECT You can't be me. Get out!" + # - host: "{{ansible_fqdn}}" + # action: "REJECT You can't be me. Get out!" + +# Requerir HELO/EHLO y aplicarle restricciones +postfix_helo_restrictions: + - check_helo_access hash:{{ postfix_rules_dir }}/helo_access_list + - permit_mynetworks + - reject_non_fqdn_helo_hostname + - reject_invalid_helo_hostname + - warn_if_reject + - permit + +# Restricciones de control de relay aplicadas en RCPT TO, antes de smtpd_recipient_restrictions +postfix_relay_restrictions: + - permit_mynetworks + - permit_sasl_authenticated + - reject_unauth_destination + - permit + +# Recipient access list: reject/accept mail by RCPT TO recipients +postfix_recipient_access_list: [] + # - rcpt: "emailvalidation.helpdesk01@gmail.com" + # action: "REJECT Forbidden recipient. Get out!" + +# Restricciones al destinatario especificado en RCPT TO +postfix_recipient_restrictions: + - check_recipient_access hash:{{ postfix_rules_dir }}/recipient_access_list + - permit_mynetworks + - permit_sasl_authenticated + - reject_unknown_recipient_domain + - reject_non_fqdn_recipient + - reject_unauth_destination + - permit + +# Recipient access list: reject/accept mail by MAIL FROM sender +postfix_sender_access_list: [] + # - sender: "@addr.com" + # action: "REJECT We're fed up with your spam. Get out!" + +# Restricciones aplicadas al remitente especificado en MAIL FROM +postfix_sender_restrictions: + - check_sender_access hash:{{ postfix_rules_dir }}/sender_access_list + - permit_sasl_authenticated + - permit_mynetworks + - reject_unknown_sender_domain + - reject_non_fqdn_sender + - permit + +# configuracion postscreen ---------------------------------------------------- +# ver http://www.postfix.org/POSTSCREEN_README.html +# y tambien http://www.postfix.org/postscreen.8.html + +# habilitar postscreen? +postfix_postscreen_enable: yes + +# lista blanca/negra de IPs (solo se permiten valores ip, ip/netmask) +postfix_postscreen_access_list: [] + # action = (permit|dunno|reject). Ejemplos: + # - address: "127.0.0.0/8" + # action: dunno + # - address: "2001:db8::/32" + # action: reject + +# lista de sitios y ponderacion a usar como criterio dnsbl +# cada item puede ser un string o un dict con atributos: +# .item, .score (opcional, default=1) y .mask (opcional, para +# ocultar la lista a clientes remotos) +postfix_postscreen_dnsbl_sites: + - site: zen.spamhaus.org + score: 3 + - site: b.barracudacentral.org + score: 2 + - site: bl.spameatingmonkey.net + score: 2 + mask: spameatingmonkey.com + - site: bl.spamcop.net + - dnsbl.sorbs.net + - site: psbl.surriel.com + - bl.mailspike.net + - site: swl.spamhaus.org + score: -4 # whitelist + # example: + # - site: mypassword.bl.service.com + # score: 3 + # mask: service.com + +# acción a efectuar cuando el cliente está en la lista negra (access_list) +postfix_postscreen_blacklist_action: drop + +# habilitar tests bare_newline? (no recomendado) +postfix_postscreen_bare_newline_enable: no + +# acción a efectuar cuando el cliente no cumple el test bare_newline +postfix_postscreen_bare_newline_action: ignore + +# acción a efectuar cuando el cliente está en una lista dnsbl (enforce|ignore|drop) +postfix_postscreen_dnsbl_action: enforce + +# mapeo que determina cuáles dnsbls informar al cliente como razón del rechazo a la conexion +postfix_postscreen_dnsbl_reply_map: "pcre:$config_directory/reglas/postscreen_dnsbl_reply_map.pcre" + +# umbral a superar para considerar al host remoto como spammer +postfix_postscreen_dnsbl_threshold: 3 + +# cuando esta bajo de este puntaje, no se hacen mas tests y se pasa a una whitelist +postfix_postscreen_dnsbl_whitelist_threshold: -1 + +# accion a efectuar cuando el cliente habla antes de su turno (enforce|ignore|drop) +postfix_postscreen_greet_action: enforce + +# tiempo a esperar para detectar un cliente malo +postfix_postscreen_greet_wait: "${stress?{2}:{6}}s" + +# habilitar deteccion de comandos no-smtp? (no recomendado) +postfix_postscreen_non_smtp_command_enable: no + +# accion a efectuar cuando no se cumple el test non_smtp_command +postfix_postscreen_non_smtp_command_action: drop + +# habilitar tests pipelining? (no recomendado) +postfix_postscreen_pipelining_enable: no + +# accion a efectuar cuando no se cumple pipelining (enforce|ignore|drop) +postfix_postscreen_pipelining_action: enforce + +# ----------------------------------------------------------------------------- + +# Hosts considerados como test, obligados a usar otro servidor +# El valor debe ser una regexp válida +# correo_hosts_test: [] + +# redes internas obligadas a utilizar servicio submission +# correo_forzar_submission: [] diff --git a/filter_plugins/belist.py b/filter_plugins/belist.py new file mode 100644 index 0000000..5c016f4 --- /dev/null +++ b/filter_plugins/belist.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from collections import Sequence +def assert_list(arg): + ''' Convert string argument to list-of-strings ''' + if isinstance(arg, basestring): + return [ arg ] + if not isinstance(arg, Sequence): + return [ arg ] + return arg + +class FilterModule(object): + def filters(self): + return { + 'belist': assert_list + } diff --git a/filter_plugins/binary.py b/filter_plugins/binary.py new file mode 100644 index 0000000..09990ac --- /dev/null +++ b/filter_plugins/binary.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from collections import Sequence +def binary(arg,yes_value='yes',no_value='no'): + ''' Convert boolean argument to 'yes' or 'no' string value ''' + if isinstance(arg, bool): + if arg: + return yes_value + return no_value + return arg + +class FilterModule(object): + def filters(self): + return { + 'binary': binary + } diff --git a/handlers/main.yml b/handlers/main.yml new file mode 100644 index 0000000..aad3fe2 --- /dev/null +++ b/handlers/main.yml @@ -0,0 +1,32 @@ +--- +- name: restart postfix + service: name=postfix state=restarted + +- name: reload postfix + service: name=postfix state=restarted + +- name: newaliases + command: newaliases + +- name: postmap hash aliases + command: "postmap hash:{{ dc[item]['alias_lookup']['file'] }}" + when: + - "dc[item]['alias_lookup']['provider'] == 'file'" + with_items: "{{ postfix_mail_domains|belist }}" + +- name: postmap hash users + command: "postmap hash:{{ dc[item]['user_lookup']['file'] }}" + when: + - "dc[item]['user_lookup']['provider'] == 'file'" + with_items: "{{ postfix_mail_domains|belist }}" + +- name: postmap no reply aliases + command: "postmap hash:{{ dc[item]['noreply_file'] }}" + with_items: "{{ postfix_mail_domains|belist }}" + +- name: postmap access lists + command: postmap {{item}} + with_items: + - "{{ postfix_rules_dir }}/helo_access_list" + - "{{ postfix_rules_dir }}/recipient_access_list" + - "{{ postfix_rules_dir }}/sender_access_list" diff --git a/tasks/main.yml b/tasks/main.yml new file mode 100644 index 0000000..c875ad7 --- /dev/null +++ b/tasks/main.yml @@ -0,0 +1,255 @@ +--- +- name: "Load default config for domains" + set_fact: + dc: "{{ dc|default({})|combine( { item: { + 'user_lookup': { + 'provider': 'file', + 'file': vmail_home +'/'+item+'_users', + 'domain': item, + 'server_host': postfix_ldap_server, + 'server_port': postfix_ldap_port, + 'version': postfix_ldap_version, + 'scope': postfix_ldap_scope, + 'bind': postfix_ldap_bind, + 'bind_dn': postfix_ldap_bind_dn, + 'bind_pw': postfix_ldap_bind_pw, + 'start_tls': postfix_ldap_start_tls, + 'tls_ca_cert_file': postfix_ldap_tls_ca_cert_file, + 'tls_ca_cert_dir': postfix_ldap_tls_ca_cert_dir, + 'search_base': + 'ou=People,'+item.split('.')|map('regex_replace','^','dc=')|join(','), + 'query_filter': '(&(objectClass=inetOrgPerson)(uid=%u))', + 'result_attribute': 'uid', + 'result_format': vmail_home+'/mail/'+item+'/%s/', + 'dbpath': vmail_home+'/'+item+'_users.sqlite', + 'query': postfix_sqlite_user_query + }, + 'users': [], + 'alias_lookup': { + 'provider': 'file', + 'file': vmail_home +'/'+item+'_aliases', + 'domain': item, + 'server_host': postfix_ldap_server, + 'server_port': postfix_ldap_port, + 'version': postfix_ldap_version, + 'scope': postfix_ldap_scope, + 'bind': postfix_ldap_bind, + 'bind_dn': postfix_ldap_bind_dn, + 'bind_pw': postfix_ldap_bind_pw, + 'start_tls': postfix_ldap_start_tls, + 'tls_ca_cert_file': postfix_ldap_tls_ca_cert_file, + 'tls_ca_cert_dir': postfix_ldap_tls_ca_cert_dir, + 'search_base': + 'ou=Alias,'+item.split('.')|map('regex_replace','^','dc=')|join(','), + 'query_filter': '(&(objectClass=nisMailAlias)(cn=%u))', + 'result_attribute': 'rfc822MailMember', + 'result_format': '%s', + 'dbpath': vmail_home+'/'+item+'_aliases.sqlite', + 'query': postfix_sqlite_alias_query + }, + 'aliases': [], + 'use_group_as_alias': postfix_ldap_use_group_alias, + 'group_lookup': { + 'provider': 'ldap', + 'domain': item, + 'server_host': postfix_ldap_server, + 'server_port': postfix_ldap_port, + 'version': postfix_ldap_version, + 'scope': postfix_ldap_scope, + 'bind': postfix_ldap_bind, + 'bind_dn': postfix_ldap_bind_dn, + 'bind_pw': postfix_ldap_bind_pw, + 'start_tls': postfix_ldap_start_tls, + 'tls_ca_cert_file': postfix_ldap_tls_ca_cert_file, + 'tls_ca_cert_dir': postfix_ldap_tls_ca_cert_dir, + 'search_base': + 'ou=Group,'+item.split('.')|map('regex_replace','^','dc=')|join(','), + 'query_filter': '(&(objectClass=posixGroup)(cn=%u))', + 'result_attribute': 'memberUid', + 'result_format': '%s@{{d}}', + }, + 'noreply_aliases': [ 'noreply' ], + 'noreply_file': vmail_home +'/'+item+'_noreply', + } }, recursive=True) }}" + with_items: "{{ postfix_mail_domains|belist }}" + +- name: "Override config for domains" + set_fact: + dc: '{{ dc | combine(postfix_domain_config, recursive=True) }}' + +- apt: name=postfix update_cache=yes +- apt: name=postfix-pcre + notify: restart postfix + +- apt: name=postfix-ldap + when: + # see http://jmespath.org/ + - '"ldap" in dc|json_query("*.[ alias_lookup, user_lookup ][].provider")' + notify: restart postfix +- apt: name=postfix-sqlite + when: + # see http://jmespath.org/ + - '"sqlite" in dc|json_query("*.[ alias_lookup, user_lookup ][].provider")' + notify: restart postfix + +- name: "Template Dovecot delivery/auth service config for Postfix" + template: + src: 11-postfix.conf.j2 + dest: /etc/dovecot/conf.d/11-postfix.conf + notify: restart dovecot + +- name: "Configure lookup tables" + include_tasks: lookup_tables.yml + with_items: "{{postfix_mail_domains|belist}}" + loop_control: + loop_var: "domain" + +- name: "Configure no-reply local mail alias" + blockinfile: + block: | + _dev_null: /dev/null + marker: "# {mark} ANSIBLE-MANAGED ALIASES" + path: "/etc/aliases" + notify: newaliases + +- name: "Create rules directory for access lists" + file: + name: "{{ postfix_rules_dir }}" + state: directory + +- name: "Template client access list" + blockinfile: + path: "{{ postfix_rules_dir }}/client_access_list" + create: yes + block: | + # Edit host variable `postfix_client_access_list` to change these values + {% for entry in postfix_client_access_list -%} + {{ entry.regex }} {{ entry.action }} + {% endfor %} + +- name: "Template helo access list" + blockinfile: + path: "{{ postfix_rules_dir }}/helo_access_list" + create: yes + block: | + # Edit host variable `postfix_helo_access_list` to change these values + {% for entry in postfix_helo_access_list -%} + {{ entry.host }} {{ entry.action }} + {% endfor %} + notify: postmap access lists + +- name: "Template recipient access list" + blockinfile: + path: "{{ postfix_rules_dir }}/recipient_access_list" + create: yes + block: | + # Edit host variable `postfix_recipient_access_list` to change these values + {% for entry in postfix_recipient_access_list -%} + {{ entry.rcpt }} {{ entry.action }} + {% endfor %} + notify: postmap access lists + +- name: "Template sender access list" + blockinfile: + path: "{{ postfix_rules_dir }}/sender_access_list" + create: yes + block: | + # Edit host variable `postfix_sender_access_list` to change these values + {% for entry in postfix_sender_access_list -%} + {{ entry.sender }} {{ entry.action }} + {% endfor %} + notify: postmap access lists + +- name: "Set main.cf parameters" + postconf: + parameter: + mydestination: >- + {{ postfix_unix_domains | belist | + union( [ ansible_fqdn, 'localhost.localdomain', 'localhost'] ) | + difference( postfix_mail_domains|belist ) }} + myhostname: "{{ postfix_server_name }}" + mydomain: "{{ postfix_server_domain }}" + mynetworks: >- + {{ ['127.0.0.0/8', '[::ffff:127.0.0.0]/104', '[::1]/128'] | + union( postfix_local_networks ) }} + virtual_alias_maps: >- + {% for d in postfix_mail_domains|belist %} + {% set p = dc[d]['alias_lookup']['provider'] %} + {% if p == "ldap" %} + ldap:{{ postfix_config_dir }}/{{d}}_ldap_alias.cf + {% if dc[d]['use_group_as_alias'] %}, + ldap:{{ postfix_config_dir }}/{{d}}_ldap_group.cf + {% endif %} + {% elif p == "sqlite" %} + sqlite:{{ postfix_config_dir }}/{{d}}_sqlite_alias.cf + {% elif p == "file" %} + hash:{{ vmail_home }}/{{d}}_aliases + {% endif %}{{ '' if loop.last else ',' }}{% endfor %}, + hash:{{ postfix_config_dir }}/noreply_aliases + virtual_mailbox_maps: >- + {% for d in postfix_mail_domains|belist %} + {% set p = dc[d]['user_lookup']['provider'] %} + {% if p == "ldap" %} + ldap:{{ postfix_config_dir }}/{{d}}_ldap_user.cf + {% elif p == "sqlite" %} + sqlite:{{ postfix_config_dir }}/{{d}}_sqlite_user.cf + {% elif p == "file" %} + hash:{{ vmail_home }}/{{d}}_users + {% endif %}{{ '' if loop.last else ',' }}{% endfor %}, + virtual_transport: "lmtp:unix:private/dovecot-lmtp" + virtual_mailbox_domains: "{{ postfix_mail_domains }}" + smtpd_sasl_path: private/auth + smtpd_sasl_type: dovecot + smtpd_sasl_auth_enable: "{{ 'yes' if postfix_enable_smtpd_auth else 'no' }}" + smtpd_tls_cert_file: "{{ postfix_tls_certificate }}" + smtpd_tls_key_file: "{{ postfix_tls_private_key }}" + smtp_tls_security_level: "{{postfix_incoming_tls_security}}" + smtpd_tls_security_level: "{{postfix_outgoing_tls_security}}" + smtpd_tls_auth_only: "{{ 'yes' if postfix_allow_insecure_auth else 'no'}}" + smtpd_tls_session_cache_database: "{{ 'btree:${data_directory}/smtpd_scache' if postfix_tls_session_cache else '' }}" + smtpd_client_restrictions: "{{ postfix_client_restrictions }}" + smtpd_data_restrictions: "{{ postfix_data_restrictions }}" + smtpd_helo_restrictions: "{{ postfix_helo_restrictions }}" + smtpd_relay_restrictions: "{{ postfix_relay_restrictions }}" + smtpd_recipient_restrictions: "{{ postfix_recipient_restrictions }}" + message_size_limit: "{{ postfix_message_size_limit }}" + smtpd_helo_required: "{{ 'yes' if postfix_helo_required else 'no' }}" + biff: "{{ 'yes' if postfix_biff else 'no' }}" + notify: reload postfix + +- name: "Enable submission service" + postconf: + service: submission + type: inet + private: 'n' + command: smtpd + parameter: + milter_macro_daemon_name: ORIGINATING + smtpd_client_restrictions: + - permit_sasl_authenticated + - reject + smtpd_sasl_auth_enable: 'yes' + smtpd_tls_security_level: encrypt + syslog_name: postfix/submission + notify: reload postfix + when: "postfix_submission_enable == True" + +- name: "Disable submission service" + postconf: + service: submission + type: inet + state: absent + notify: reload postfix + when: "postfix_submission_enable == False" + +- name: "Enable postscreen" + include_tasks: postscreen.yml + when: "postfix_postscreen_enable == True" + +- name: "Disable postscreen" + include_tasks: postscreen_disable.yml + when: "postfix_postscreen_enable == False" + +# TODO: mensajes + +# TODO: milter_header_checks