#!/usr/bin/env python
#
# Copyright (c) 2013-2014 Marin Atanasov Nikolov <dnaeon@gmail.com>
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer
#    in this position and unchanged.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR(S) ``AS IS'' AND ANY EXPRESS OR
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL THE AUTHOR(S) BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
# THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""
The zabbix-ldap-sync script is used for syncing LDAP users with Zabbix.

"""

import random
import string
import ConfigParser
import sys
import ldap
import ldap.filter
from pyzabbix import ZabbixAPI
from docopt import docopt

class LDAPConn(object):
    """
    LDAP connector class

    Defines methods for retrieving users and groups from LDAP server.

    """
    def __init__(self, uri, base, user, passwd):
        self.uri   = uri
        self.base  = base
        self.ldap_user  = user
        self.ldap_pass  = passwd

    def connect(self):
        """
        Establish a connection to the LDAP server.

        Raises:
            SystemExit

        """
        self.conn  = ldap.initialize(self.uri)
	self.conn.set_option(ldap.OPT_REFERRALS,0)

        try:
            self.conn.simple_bind(self.ldap_user, self.ldap_pass)
        except ldap.SERVER_DOWN as e:
            raise SystemExit, 'Cannot connect to LDAP server: %s' % e

    def disconnect(self):
        """
        Disconnect from the LDAP server.

        """
        self.conn.unbind()

    def get_group_members(self, group):
        """
        Retrieves the members of an LDAP group

        Args:
            group (str): The LDAP group name

        Returns:
            A list of all users in the LDAP group

        """
        attrlist = [group_member_attribute]
        filter   = group_filter % group

        result = self.conn.search_s(base=self.base,
                                    scope=ldap.SCOPE_SUBTREE,
                                    filterstr=filter,
                                    attrlist=attrlist)

        if not result:
            print '>>> Unable to find group %s, skipping group' % group
            return None

        # if this is activedirectory, we have to replace each CN with the actual username
        if active_directory:

            final_listing = []

            for members in result:
                result_dn = members[0]
                result_attrs = members[1]

            if "member" in result_attrs:
                for member in result_attrs["member"]:

                    attrlist = ['sAMAccountName']
                    filter   = '(&(objectClass=person)(distinguishedName=%s))' % ldap.filter.escape_filter_chars(member)

                    # get the actual LDAP object for each group member
                    uid = self.conn.search_s(base=self.base,
                                    scope=ldap.SCOPE_SUBTREE,
                                    filterstr=filter,
                                    attrlist=attrlist)

                    dn, users = uid.pop()
                    username = users.get('sAMAccountName')
                    if lowercase:
                        final_listing.append(str(username)[2 : -2].lower())
                    else:
                        final_listing.append(str(username)[2 : -2])

            return final_listing

        else:

            dn, users = result.pop()

            return users[group_member_attribute]

    def get_user_email(self, username, ldap_mail):
        """
        Retrieves the 'mail' attribute of an LDAP user

        Args:
            username (str): The LDAP username to lookup
            ldap_mail (str): The name of the field containing the mail address

        Returns:
            The user's mail attribute

        """
        attrlist = [ldap_mail]
        filter   = mail_filter % username

        result = self.conn.search_s(base=self.base,
                                    scope=ldap.SCOPE_SUBTREE,
                                    filterstr=filter,
                                    attrlist=attrlist)

        if not result:
            return None

        dn, data = result.pop()

        mail = data.get(ldap_mail)

        if not mail:
            return None

        return mail.pop()

    def get_user_sn(self, username):
        """
        Retrieves the 'sn' attribute of an LDAP user

        Args:
            username (str): The LDAP username to lookup

        Returns:
            The user's surname attribute

        """
        attrlist = ['sn']
        filter   = sn_filter % username

        result = self.conn.search_s(base=self.base,
                                    scope=ldap.SCOPE_SUBTREE,
                                    filterstr=filter,
                                    attrlist=attrlist)

        if not result:
            return None

        dn, data = result.pop()

        sn = data.get('sn')

        if not sn:
            return None

        return sn.pop()

    def get_user_givenName(self, username):
        """
        Retrieves the 'givenName' attribute of an LDAP user

        Args:
            username (str): The LDAP username to lookup

        Returns:
            The user's given name attribute

        """
        attrlist = ['givenName']
        filter   = givenName_filter % username

        result = self.conn.search_s(base=self.base,
                                    scope=ldap.SCOPE_SUBTREE,
                                    filterstr=filter,
                                    attrlist=attrlist)

        if not result:
            return None

        dn, data = result.pop()

        name = data.get('givenName')

        if not name:
            return None

        return name.pop()

class ZabbixConn(object):
    """
    Zabbix connector class

    Defines methods for managing Zabbix users and groups

    """
    def __init__(self, server, username, password):
        self.server   = server
        self.username = username
        self.password = password

    def connect(self):
        """
        Establishes a connection to the Zabbix server

        Raises:
            SystemExit

        """
        self.conn = ZabbixAPI(self.server)

        try:
            self.conn.login(self.username, self.password)
        except ZabbixAPI.ZabbixAPIException as e:
            raise SystemExit, 'Cannot login to Zabbix server: %s' % e

    def get_users(self):
        """
        Retrieves the existing Zabbix users

        Returns:
            A list of the existing Zabbix users

        """
        result = self.conn.user.get(output='extend')

        users = [user['alias'] for user in result]

        return users

    def get_user_id(self, user):
        """
        Retrieves the userid of a specified user

        Args:
            user (str): The Zabbix username to lookup

        Returns:
            The userid of the specified user

        """
        result = self.conn.user.get(output='extend')

        userid = [u['userid'] for u in result if u['alias'] == user].pop()

        return userid

    def get_groups(self):
        """
        Retrieves the existing Zabbix groups

        Returns:
            A dict of the existing Zabbix groups and their group ids

        """
        result = self.conn.usergroup.get(status=0,output='extend')

        groups = [{'name': group['name'], 'usrgrpid': group['usrgrpid']} for group in result]

        return groups

    def get_group_members(self, groupid):
        """
        Retrieves group members for a Zabbix group

        Args:
            groupid (int): The group id

        Returns:
            A list of the Zabbix users for the specified group id

        """
        result = self.conn.user.get(output='extend',usrgrpids=groupid)

        users = [user['alias'] for user in result]

        return users


    def create_group(self, group):
        """
        Creates a new Zabbix group

        Args:
            group (str): The Zabbix group name to create

        Returns:
            The groupid of the newly created group

        """
        result = self.conn.usergroup.create(name=group,permission=3)

        groupid = result['usrgrpids'].pop()

        return groupid

    def create_user(self, user, groupid):
        """
        Creates a new Zabbix user

        Args:
            user    (dict): A dict containing the user details
            groupid  (int): The groupid for the new user

        """
        random_passwd = ''.join(random.sample(string.ascii_letters + string.digits, 32))

        user_defaults = { 'autologin': 0, 'type': 1, 'usrgrps': [ { 'usrgrpid': str(groupid) } ], 'passwd': random_passwd}
        user.update(user_defaults)

        result = self.conn.user.create(user)

        return result

    def update_user(self, user, groupid):
        """
        Adds an existing Zabbix user to a group

        Args:
            user    (dict): A dict containing the user details
            groupid  (int): The groupid to add the user to

        """
        userid = self.get_user_id(user)

        result = self.conn.usergroup.massadd(usrgrpids=[str(groupid)], userids=[str(userid)])

        return result

    def update_media(self, user, mail):
        """
        Adds media (email) to an existing Zabbix user

        Args:
            user    (dict): A dict containing the user details

        """

        userid = self.get_user_id(user)

        result = self.conn.user.addmedia(users=[ { "userid": str(userid) } ], medias={ 'mediatypeid': '1', 'sendto': mail, 'active': '0', 'severity': '63', 'period': '1-7,00:00-24:00' })

        return result

    def create_missing_groups(self, ldap_groups):
        """
        Creates any missing LDAP groups in Zabbix

        Args:
            ldap_groups (list): A list of LDAP groups to create

        """
        missing_groups = set(ldap_groups) - set([g['name'] for g in self.get_groups()])

        for eachGroup in missing_groups:
            print '>>> Creating Zabbix group %s' % eachGroup
            grpid = self.create_group(eachGroup)
            print '>>> Group %s created with groupid %s' % (eachGroup, grpid)

    def sync_users(self, ldap_uri, ldap_base, ldap_groups, ldap_user, ldap_pass, ldap_mail):
        """
        Syncs Zabbix with LDAP users

        """
        ldap_conn = LDAPConn(ldap_uri, ldap_base, ldap_user, ldap_pass)
        ldap_conn.connect()

        zabbix_all_users = self.get_users()

        for eachGroup in ldap_groups:
            ldap_users = ldap_conn.get_group_members(eachGroup)

            if not ldap_users:
                continue

            zabbix_grpid = [g['usrgrpid'] for g in self.get_groups() if g['name'] == eachGroup].pop()

            zabbix_group_users = self.get_group_members(zabbix_grpid)

            missing_users = set(ldap_users) - set(zabbix_group_users)

            # Add missing users
            for eachUser in missing_users:

                # Create new user if it does not exists already
                if eachUser not in zabbix_all_users:
                    print '>>> Creating user %s, member of Zabbix group %s' % (eachUser, eachGroup)
                    user = { 'alias': eachUser }
                    user['name'] = ldap_conn.get_user_givenName(eachUser)
                    user['surname'] = ldap_conn.get_user_sn(eachUser)
                    self.create_user(user, zabbix_grpid)
                    zabbix_all_users.append(eachUser)
                    print '>>> Updating user media for %s, adding email' % eachUser
                    mailaddress = ldap_conn.get_user_email(eachUser, ldap_mail)
                    if mailaddress:
                        self.update_media(eachUser, mailaddress)
                else:
                    # Update existing user to be member of the group
                    print '>>> Updating user %s, adding to group %s' % (eachUser, eachGroup)
                    self.update_user(eachUser, zabbix_grpid)

            # Just mention any extra users in the groups, do not remove them
            extra_users = set(zabbix_group_users) - set(ldap_users)
            if extra_users:
                print '>>> Users in group %s which are not found in LDAP group:' % eachGroup
                for eachUser in extra_users:
                    print ' * %s' % eachUser

        ldap_conn.disconnect()

class ZabbixLDAPConf(object):
    """
    Zabbix-LDAP configuration class

    Provides methods for parsing and retrieving config entries

    """
    def __init__(self, config):
        self.config = config

    def load_config(self):
        """
        Loads the configuration file

        Raises:
            ConfigParser.NoOptionError

        """
        parser = ConfigParser.ConfigParser({"mail": "mail"})
        parser.read(self.config)

        try:
            self.ldap_uri     = parser.get('ldap', 'uri')
            self.ldap_base    = parser.get('ldap', 'base')
            self.ldap_groups  = [i.strip() for i in parser.get('ldap', 'groups').split(',')]
            self.ldap_type    = parser.get('ldap', 'type')
            self.ldap_user    = parser.get('ldap', 'binduser')
            self.ldap_pass    = parser.get('ldap', 'bindpass')
            self.ldap_mail    = parser.get('ldap', 'mail')
            self.zbx_server   = parser.get('zabbix', 'server')
            self.zbx_username = parser.get('zabbix', 'username')
            self.zbx_password = parser.get('zabbix', 'password')
        except ConfigParser.NoOptionError as e:
            raise SystemExit, 'Configuration issues detected in %s' % self.config

def main():
    usage="""
Usage: zabbix-ldap-sync [-l] -f <config>
       zabbix-ldap-sync -v
       zabbix-ldap-sync -h

Options:
  -h, --help                    Display this usage info
  -v, --version                 Display version and exit
  -l, --lowercase               Create AD user names as lowercase
  -f <config>, --file <config>  Configuration file to use

"""
    args = docopt(usage, version="0.1.1")

    config = ZabbixLDAPConf(args['--file'])
    config.load_config()

    # set up AD differences, if necessary
    global active_directory
    global group_filter
    global group_member_attribute
    global uid_attribute
    global sn_filter
    global givenName_filter
    global mail_filter
    global lowercase
    lowercase = args['--lowercase']
    if config.ldap_type == 'activedirectory':
        active_directory = "true"
        group_filter = "(&(objectClass=group)(name=%s))"
        group_member_attribute = "member"
        uid_attribute = "sAMAccountName"
        sn_filter = "(&(objectClass=person)(sAMAccountName=%s))"
        givenName_filter = "(&(objectClass=person)(sAMAccountName=%s))"
        mail_filter = "(&(objectClass=person)(sAMAccountName=%s))"
    else:
        active_directory = None
        group_filter = "(&(objectClass=posixGroup)(cn=%s))"
        group_member_attribute = "memberUid"
        sn_filter = "(&(objectClass=posixAccount)(uid=%s))"
        givenName_filter = "(&(objectClass=posixAccount)(uid=%s))"
        mail_filter = "(&(objectClass=posixAccount)(uid=%s))"
    zabbix_conn = ZabbixConn(config.zbx_server, config.zbx_username, config.zbx_password)
    zabbix_conn.connect()

    zabbix_conn.create_missing_groups(config.ldap_groups)
    zabbix_conn.sync_users(config.ldap_uri, config.ldap_base, config.ldap_groups, config.ldap_user, config.ldap_pass, config.ldap_mail)

if __name__ == '__main__':
    main()
