Commits

Zhang Huangbin  committed 792b663

* Ability to bypass or block centain domains or users in OpenLDAP backend.
WARNING: This feature requires at least iRedMail-0.6.0.

  • Participants
  • Parent commits 94ebfa6

Comments (0)

Files changed (7)

-iRedAPD-1.2.4-RC1:
+iRedAPD-1.3.0:
+    * Ability to bypass or block centain domains or users in OpenLDAP backend.
+      WARNING: This feature requires at least iRedMail-0.6.0.
     * Ability to handle policy 'subdomain'. Bypass if sender is under same
       domain or sub domains.
     * Ability to handle policy 'membersAndAllowedOnly'.

File etc/iredapd-rr.ini.sample

+[general]
+# Listen address and port.
+listen_addr     = 127.0.0.1
+listen_port     = 7778
+
+# Background/daemon mode: yes, no.
+# Detach iredapd from terminal. Enable when you're happy
+# that things are working as expected.
+run_as_daemon   = yes
+
+# Path to pid file.
+pid_file        = /var/run/iredapd-rr.pid
+
+# Log type: file.
+# Note:
+#   - Currently, only 'file' type is supported.
+#   - If log_type is 'file', log_file is required.
+#   - If log_type is 'mysql', sql related info are required.
+log_type        = file
+log_file        = /var/log/iredapd-rr.log
+
+# Log level: info, warning, error, debug.
+# 'info' is recommended for product use. sample log entry:
+#       INFO user@domain.ltd -> list@domain.ltd, DUNNO
+log_level       = info
+
+# Backend: ldap, mysql.
+backend = ldap
+
+[ldap]
+# For ldap backend only.
+# LDAP server setting.
+# Uri must starts with ldap:// or ldaps:// (TLS/SSL).
+#
+# Tip: You can get binddn, bindpw from /etc/postfix/ldap_*.cf.
+#
+uri         = ldap://127.0.0.1:389
+binddn      = cn=vmail,dc=iredmail,dc=org
+bindpw      = mRAEWpGRtlCs1O0QuWpXoaJ36EjRql
+basedn      = o=domains,dc=iredmail,dc=org
+
+# Enabled plugins.
+#   - Plugin name is file name which placed under 'src/plugins/' directory.
+#   - Plugin names MUST be seperated by comma.
+plugins = ldap_recipient_restrictions

File rc_scripts/iredapd-rr

+#!/usr/bin/env bash
+
+# Author: Zhang Huangbin (michaelbibby@gmail.com)
+
+### BEGIN RHEL/CentOS INIT INFO
+# chkconfig: - 27 73
+# description: iredapd is a mail list access policy daemon.
+# processname: iredapd
+### END RHEL/CentOS INIT INFO
+
+### BEGIN Debian/Ubuntu INIT INFO
+# Provides:          iredapd 
+# Required-Start:    $network $syslog
+# Required-Stop:     $network $syslog
+# Default-Start:     2 3 4 5
+# Default-Stop:      0 1 6
+# Short-Description: Control iredapd daemon.
+# Description: Control iredapd daemon.
+### BEGIN Debian/Ubuntu INIT INFO
+
+PROG='iredapd'
+BINPATH='/opt/iredapd-rr/src/iredapd-rr.py'
+CONFIG='/opt/iredapd-rr/etc/iredapd-rr.ini'
+PIDFILE='/var/run/iredapd-rr.pid'
+
+check_status() {
+    # Usage: check_status pid_number
+    PID="${1}"
+    l=$(ps -p ${PID} | wc -l | awk '{print $1}')
+    if [ X"$l" == X"2" ]; then
+        echo "running"
+    else
+        echo "stopped"
+    fi
+}
+
+start() {
+    if [ -f ${PIDFILE} ]; then
+        PID="$(cat ${PIDFILE})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "${PROG} is already running."
+        else
+            rm -f ${PIDFILE} >/dev/null 2>&1
+            echo "Starting ${PROG} ..."
+            python ${BINPATH} ${CONFIG}
+        fi
+    else
+        echo "Starting ${PROG} ..."
+        python ${BINPATH} ${CONFIG}
+    fi
+    unset s
+}
+
+stop() {
+    if [ -f ${PIDFILE} ]; then
+        PID="$(cat ${PIDFILE})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "Stopping ${PROG} ..."
+            kill -9 ${PID}
+            if [ X"$?" == X"0" ]; then
+                rm -f ${PIDFILE} >/dev/null 2>&1
+            else
+                echo -e "\t\t[ FAILED ]"
+            fi
+        else
+            echo "${PROG} is already stopped."
+            rm -f ${PIDFILE} >/dev/null 2>&1
+        fi
+    else
+        echo "${PROG} is already stopped."
+    fi
+    unset s
+}
+
+status() {
+    if [ -f ${PIDFILE} ]; then
+        PID="$(cat ${PIDFILE})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "${PROG} is running."
+        else
+            echo "${PROG} is stopped."
+        fi
+    else
+        echo "${PROG} is stopped."
+    fi
+    unset s
+}
+
+case "$1" in
+    start) start ;;
+    stop) stop ;;
+    status) status ;;
+    restart) stop && start ;;
+    *)
+        echo $"Usage: $0 {start|stop|restart|status}"
+        RETVAL=1
+        ;;
+esac

File rc_scripts/iredapd-rr.freebsd

+#!/usr/local/bin/bash
+
+# Author: Zhang Huangbin (michaelbibby@gmail.com)
+
+# PROVIDE: iredapd
+# REQUIRE: DAEMON
+# KEYWORD: shutdown
+
+. /etc/rc.subr
+name='iredapd'
+rcvar=`set_rcvar`
+
+BINPATH='/opt/iredapd-rr/src/iredapd-rr.py'
+CONFIG='/opt/iredapd-rr/etc/iredapd-rr.ini'
+pidfile='/var/run/iredapd-rr.pid'
+
+check_status() {
+    # Usage: check_status pid_number
+    PID="${1}"
+    l=$(ps -p ${PID} | wc -l | awk '{print $1}')
+    if [ X"$l" == X"2" ]; then
+        echo "running"
+    else
+        echo "stopped"
+    fi
+}
+
+start() {
+    if [ -f ${pidfile} ]; then
+        PID="$(cat ${pidfile})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "${name} is already running."
+        else
+            rm -f ${pidfile} >/dev/null 2>&1
+            echo "Starting ${name} ..."
+            /usr/local/bin/python ${BINPATH} ${CONFIG}
+        fi
+    else
+        echo "Starting ${name} ..."
+        /usr/local/bin/python ${BINPATH} ${CONFIG}
+    fi
+    unset s
+}
+
+stop() {
+    if [ -f ${pidfile} ]; then
+        PID="$(cat ${pidfile})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "Stopping ${name} ..."
+            kill -9 ${PID}
+            if [ X"$?" == X"0" ]; then
+                rm -f ${pidfile} >/dev/null 2>&1
+            else
+                echo -e "\t\t[ FAILED ]"
+            fi
+        else
+            echo "${name} is already stopped."
+            rm -f ${pidfile} >/dev/null 2>&1
+        fi
+    else
+        echo "${name} is already stopped."
+    fi
+    unset s
+}
+
+status() {
+    if [ -f ${pidfile} ]; then
+        PID="$(cat ${pidfile})"
+        s="$(check_status ${PID})"
+
+        if [ X"$s" == X"running" ]; then
+            echo "${name} is running."
+        else
+            echo "${name} is stopped."
+        fi
+    else
+        echo "${name} is stopped."
+    fi
+    unset s
+}
+
+empty(){
+case "$1" in
+    start) start ;;
+    stop) stop ;;
+    status) status ;;
+    restart) stop && start ;;
+    *)
+        echo $"Usage: $0 {start|stop|restart|status}"
+        RETVAL=1
+        ;;
+esac
+}
+
+start_cmd="start"
+stop_cmd="stop"
+status_cmd="status"
+restart_cmd="stop && start"
+
+command="start"
+load_rc_config ${name}
+run_rc_command "$1"

File src/iredapd-rr.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+# Author: Zhang Huangbin <michaelbibby (at) gmail.com>
+
+import os
+import os.path
+import sys
+import ConfigParser
+import socket
+import asyncore
+import asynchat
+import logging
+import daemon
+
+__version__ = "1.3"
+
+sys.path.append(os.path.abspath(os.path.dirname(__file__)) + '/plugins-rr')
+
+ACTION_ACCEPT = 'DUNNO'
+ACTION_DEFER = 'DEFER_IF_PERMIT Service temporarily unavailable'
+ACTION_REJECT = 'REJECT Not Authorized'
+ACTION_DEFAULT = 'DUNNO'
+
+# Get config file.
+if len(sys.argv) != 2:
+    sys.exit('Usage: %s /path/to/iredapd.ini')
+else:
+    config_file = sys.argv[1]
+
+    # Check file exists.
+    if not os.path.exists(config_file):
+        sys.exit('File not exist: %s.' % config_file)
+
+# Read configurations.
+cfg = ConfigParser.SafeConfigParser()
+cfg.read(config_file)
+
+
+class apdChannel(asynchat.async_chat):
+    def __init__(self, conn, remoteaddr):
+        asynchat.async_chat.__init__(self, conn)
+        self.buffer = []
+        self.map = {}
+        self.set_terminator('\n')
+        logging.debug("Connect from " + remoteaddr[0])
+
+    def push(self, msg):
+        asynchat.async_chat.push(self, msg + '\n')
+
+    def collect_incoming_data(self, data):
+        self.buffer.append(data)
+
+    def found_terminator(self):
+        if len(self.buffer) is not 0:
+            line = self.buffer.pop()
+            logging.debug("smtp session: " + line)
+            if line.find('=') != -1:
+                key = line.split('=')[0]
+                value = line.split('=')[1]
+                self.map[key] = value
+        elif len(self.map) != 0:
+            try:
+                if cfg.get('general', 'backend', 'ldap') == 'ldap':
+                    modeler = LDAPModeler()
+                else:
+                    modeler = MySQLModeler()
+
+                result = modeler.handle_data(self.map)
+                logging.debug("result replying: %s." % str(result))
+                if result != None:
+                    action = result
+                else:
+                    action = ACTION_ACCEPT
+            except Exception, e:
+                action = ACTION_DEFAULT
+                logging.debug('Error: %s. Use default action instead: %s' %
+                        (str(e), str(action)))
+
+            logging.info('%s -> %s, %s' %
+                    (self.map['sender'], self.map['recipient'], action))
+            self.push('action=' + action)
+            self.push('')
+            asynchat.async_chat.handle_close(self)
+            logging.debug("Connection closed")
+        else:
+            action = ACTION_DEFER
+            logging.debug("replying: " + action)
+            self.push(action)
+            self.push('')
+            asynchat.async_chat.handle_close(self)
+            logging.debug("Connection closed")
+
+
+class apdSocket(asyncore.dispatcher):
+    def __init__(self, localaddr):
+        asyncore.dispatcher.__init__(self)
+        self.create_socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.set_reuse_addr()
+        self.bind(localaddr)
+        self.listen(5)
+        ip, port = localaddr
+        logging.info("Starting iredapd (v%s, pid: %d), listening on %s:%s." %
+                (__version__, os.getpid(), ip, str(port)))
+
+    def handle_accept(self):
+        conn, remoteaddr = self.accept()
+        channel = apdChannel(conn, remoteaddr)
+
+
+class MySQLModeler:
+    def __init__(self):
+        import web
+
+        # Turn off debug mode.
+        web.config.debug = False
+
+        self.db = web.database(
+            dbn='mysql',
+            host=cfg.get('mysql', 'server', 'localhost'),
+            db=cfg.get('mysql', 'db', 'vmail'),
+            user=cfg.get('mysql', 'user', 'vmail'),
+            pw=cfg.get('mysql', 'password'),
+        )
+
+    def handle_data(self, map):
+        if 'sender' in map.keys() and 'recipient' in map.keys():
+            # Get plugin module name and convert plugin list to python list type.
+            self.plugins = cfg.get('mysql', 'plugins', '')
+            self.plugins = [v.strip() for v in self.plugins.split(',')]
+
+            # Get sender, recipient.
+            # Sender/recipient are used almost in all plugins, so store them
+            # a dict and pass to plugins.
+            senderReceiver = {
+                'sender': map['sender'],
+                'recipient': map['recipient'],
+                'sender_domain': map['sender'].split('@')[-1],
+                'recipient_domain': map['recipient'].split('@')[-1],
+            }
+
+            if len(self.plugins) > 0:
+                #
+                # Import plugin modules.
+                #
+                self.modules = []
+
+                # Load plugin module.
+                for plugin in self.plugins:
+                    try:
+                        self.modules.append(__import__(plugin))
+                    except Exception, e:
+                        logging.debug('Error while importing plugin module (%s): %s' % (plugin, str(e)))
+
+                #
+                # Apply plugins.
+                #
+                self.action = ''
+                for module in self.modules:
+                    try:
+                        logging.debug('Apply plugin (%s).' % (module.__name__, ))
+                        pluginAction = module.restriction(
+                            dbConn=self.db,
+                            senderReceiver=senderReceiver,
+                            smtpSessionData=map,
+                        )
+
+                        logging.debug('Response from plugin (%s): %s' % (module.__name__, pluginAction))
+                        if not pluginAction.startswith('DUNNO'):
+                            logging.info('Response from plugin (%s): %s' % (module.__name__, pluginAction))
+                            return pluginAction
+                    except Exception, e:
+                        logging.debug('Error while apply plugin (%s): %s' % (module, str(e)))
+
+            else:
+                # No plugins available.
+                return 'DUNNO'
+        else:
+            return ACTION_DEFER
+
+
+
+class LDAPModeler:
+    def __init__(self):
+        import ldap
+
+        self.ldap = ldap
+
+        # Read LDAP server settings.
+        self.uri = cfg.get('ldap', 'uri', 'ldap://127.0.0.1:389')
+        self.binddn = cfg.get('ldap', 'binddn')
+        self.bindpw = cfg.get('ldap', 'bindpw')
+        self.baseDN = cfg.get('ldap', 'basedn')
+
+        # Initialize ldap connection.
+        try:
+            self.conn = self.ldap.initialize(self.uri)
+            logging.debug('LDAP connection initialied success.')
+        except Exception, e:
+            logging.error('LDAP initialized failed: %s.' % str(e))
+            sys.exit()
+
+        # Bind to ldap server.
+        if self.binddn != '' and self.bindpw != '':
+            try:
+                self.conn.bind_s(self.binddn, self.bindpw)
+                logging.debug('LDAP bind success.')
+            except self.ldap.INVALID_CREDENTIALS:
+                logging.error('LDAP bind failed: incorrect bind dn or password.')
+                sys.exit()
+            except Exception, e:
+                logging.error('LDAP bind failed: %s.' % str(e))
+                sys.exit()
+
+    def __get_sender_dn_ldif(self, sender):
+        logging.debug('__get_sender_dn_ldif (sender): %s' % sender)
+
+        if len(sender) < 6 or sender is None:
+            return (None, None)
+
+        try:
+            logging.debug('__get_sender_dn_ldif: Quering LDAP')
+            result = self.conn.search_s(
+                    self.baseDN,
+                    self.ldap.SCOPE_SUBTREE,
+                    '(&(|(mail=%s)(shadowAddress=%s))(|(objectClass=mailUser)(objectClass=mailList)(objectClass=mailAlias)))' % (sender, sender),
+                    )
+            logging.debug('__get_sender_dn_ldif (result): %s' % str(result))
+            if len(result) == 0:
+                return (None, None)
+            else:
+                return (result[0][0], result[0][1])
+        except Exception, e:
+            logging.debug('!!! ERROR !!! __get_sender_dn_ldif (result): %s' % str(e))
+            return (None, None)
+
+    def handle_data(self, map):
+        if 'sender' in map.keys() and 'recipient' in map.keys():
+            # Get plugin module name and convert plugin list to python list type.
+            self.plugins = cfg.get('ldap', 'plugins', '')
+            self.plugins = [v.strip() for v in self.plugins.split(',')]
+
+            if len(self.plugins) > 0:
+
+                # Get account dn and LDIF data.
+                senderDN, senderLdif = self.__get_sender_dn_ldif(map['sasl_username'])
+
+                # Return if recipient account doesn't exist.
+                if senderDN is None or senderLdif is None:
+                    logging.debug('Sender DN or LDIF is none.')
+                    return ACTION_DEFAULT
+
+                #
+                # Import plugin modules.
+                #
+                self.modules = []
+
+                # Load plugin module.
+                for plugin in self.plugins:
+                    try:
+                        self.modules.append(__import__(plugin))
+                    except Exception, e:
+                        logging.debug('Error while importing plugin module (%s): %s' % (plugin, str(e)))
+
+                #
+                # Apply plugins.
+                #
+                self.action = ''
+                for module in self.modules:
+                    try:
+                        logging.debug('Apply plugin (%s).' % (module.__name__, ))
+                        pluginAction = module.restriction(
+                            ldapConn=self.conn,
+                            ldapBaseDn=self.baseDN,
+                            ldapSenderDn=senderDN,
+                            ldapSenderLdif=senderLdif,
+                            smtpSessionData=map,
+                        )
+
+                        logging.debug('Response from plugin (%s): %s' % (module.__name__, pluginAction))
+                        if not pluginAction.startswith('DUNNO'):
+                            logging.info('Response from plugin (%s): %s' % (module.__name__, pluginAction))
+                            return pluginAction
+                    except Exception, e:
+                        logging.debug('Error while apply plugin (%s): %s' % (module, str(e)))
+
+            else:
+                # No plugins available.
+                return 'DUNNO'
+        else:
+            return ACTION_DEFER
+
+
+def main():
+    # Chroot in current directory.
+    try:
+        os.chdir(os.path.abspath(os.path.dirname(__file__)))
+    except:
+        pass
+
+    # Get listen address/port.
+    listen_addr = cfg.get('general', 'listen_addr', '127.0.0.1')
+    listen_port = int(cfg.get('general', 'listen_port', '7777'))
+
+    run_as_daemon = cfg.get('general', 'run_as_daemon', 'yes')
+
+    # Get log level.
+    log_level = getattr(logging, cfg.get('general', 'log_level', 'info').upper())
+
+    # Initialize file based logger.
+    if cfg.get('general', 'log_type', 'file') == 'file':
+        if run_as_daemon == 'yes':
+            logging.basicConfig(
+                    level=log_level,
+                    format='%(asctime)s %(levelname)s %(message)s',
+                    datefmt='%Y-%m-%d %H:%M:%S',
+                    filename=cfg.get('general', 'log_file', '/var/log/iredapd.log'),
+                    )
+        else:
+            logging.basicConfig(
+                    level=log_level,
+                    format='%(asctime)s %(levelname)s %(message)s',
+                    datefmt='%Y-%m-%d %H:%M:%S',
+                    )
+
+    # Initialize policy daemon.
+    socketDaemon = apdSocket((listen_addr, listen_port))
+
+    # Run this program as daemon.
+    if run_as_daemon == 'yes':
+        daemon.daemonize()
+
+    try:
+        # Write pid number into pid file.
+        f = open(cfg.get('general', 'pid_file', '/var/run/iredapd.pid'), 'w')
+        f.write(str(os.getpid()))
+        f.close()
+
+        # Starting loop.
+        asyncore.loop()
+    except KeyboardInterrupt:
+        pass
+
+if __name__ == '__main__':
+    main()

File src/plugins-rr/__init__.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+# Author: Zhang Huangbin <michaelbibby (at) gmail.com>
+
+
+

File src/plugins-rr/ldap_recipient_restrictions.py

+#!/usr/bin/env python
+# encoding: utf-8
+
+# Author:   Zhang Huangbin <zhb (at) iredmail.org>
+# Date:     2010-04-20
+# Purpose:  Per-user whitelist/blacklist for sender restrictions.
+#           Bypass all whitelisted recipients, reject all blacklisted recipients.
+
+# ------------- Addition configure required ------------
+# * In postfix main.cf:
+#
+#   smtpd_sender_restrictions =
+#           check_policy_service inet:127.0.0.1:7778,
+#           [YOUR OTHER RESTRICTIONS HERE]
+#
+#   Here, ip address '127.0.0.1' and port number '7778' are set in iRedAPD-RR
+#   config file: iredapd-rr.ini.
+# ------------------------------------------------------
+
+# Value of mailWhitelistRecipient and mailBlacklistRecipient:
+#   - Single address:   user@domain.ltd
+#   - Whole domain:     @domain.ltd
+#   - Whole Domain and its sub-domains: @.domain.ltd
+#   - All sender:       @.
+
+# Debug.
+#import logging
+#logging.basicConfig(level=logging.DEBUG)
+
+def restriction(smtpSessionData, ldapSenderLdif, **kargs):
+    # Get sender address.
+    sender = smtpSessionData.get('sender').lower()
+    splited_sender_domain = str(sender.split('@')[-1]).split('.')
+
+    # Get correct domain name and sub-domain name.
+    # Sample recipient domain: sub2.sub1.com.cn
+    #   -> sub2.sub1.com.cn
+    #   -> .sub2.sub1.com.cn
+    #   -> .sub1.com.cn
+    #   -> .com.cn
+    #   -> .cn
+    list_senders = [sender, '@'+sender.split('@')[-1],]
+    for counter in range(len(splited_sender_domain)):
+        # Append domain and sub-domain.
+        list_senders += ['@.' + '.'.join(splited_sender_domain)]
+        splited_sender_domain.pop(0)
+
+    #logging.debug(str(list_senders))
+
+    # Get value of amavisBlacklistedSender.
+    blRecipients = [v.lower()
+            for v in ldapSenderLdif.get('mailBlacklistRecipient', [])
+            ]
+    wlRecipients = [v.lower()
+            for v in ldapSenderLdif.get('mailWhitelistRecipient', [])
+            ]
+
+    # Bypass whitelisted senders if has intersection set.
+    if len(set(list_senders) & set(wlRecipients)) > 0:
+        return 'DUNNO'
+
+    # Reject blacklisted senders if has intersection set.
+    if len(set(list_senders) & set(blRecipients)) > 0:
+        return 'REJECT Not authorized'
+
+    # If not matched bl/wl list:
+    return 'DUNNO'