#!/usr/bin/env python3
#
# File: ldap-repl-check
# Description: check the state of replication
# Author: Bill MacAllister <bill@ca-zephyr.org>
# Copyright: Dropbox, Inc. 2018
# Copyright: 2023 CZ Software

import argparse
import ldap
import logging
import re
import subprocess
import sys
import syslog
import time

##############################################################################
# Utility Routines
##############################################################################


# ------------------------------------------------------------------------
# Usage display

def display_usage(name=None):
    """ Display help message """
    return '''
Synopsis:
   ldap-repl-check [<hostname>] [<hostname>]
'''


# ------------------------------------------------------------------------
# Display the POD from the end of this file

def display_pod():
    cmd = '/usr/bin/pod2text ' + sys.argv[0]
    cmd_bytes = subprocess.check_output(cmd,
                                        stderr=subprocess.STDOUT,
                                        shell=True)
    cmd_text = cmd_bytes.decode('utf-8')
    print(cmd_text)


# ------------------------------------------------------------------------
# Turn LDAP zulu time into a number of seconds

def csn_seconds(contextCSN):
    z = re.sub('[.].*', '', contextCSN)
    t_obj = time.strptime(z, '%Y%m%d%H%M%S')
    t_secs = int(time.mktime(t_obj))
    return t_secs


# ------------------------------------------------------------------------
# Display the POD from the end of this file

def ldapserver_connect(a_hostname, a_sasl=False):

    # Bind to the directory
    try:
        ldap_conn = ldap.initialize("ldap://" + a_hostname)
        if a_sasl:
            ol_auth = ldap.sasl.gssapi("")
            ldap_conn.sasl_interactive_bind_s("", ol_auth)
    except ldap.LDAPError as e:
        print('UNKNOWN: problem connecting to ({}) - {}'.format(a_hostname, e))
        sys.exit(3)
    return ldap_conn


##############################################################################
# Primary class
##############################################################################

class ldap_repl_check:

    # -----------------------------------------------------------
    # Initialization

    def __init__(self):

        self.ldap_base = 'dc=ca-zephyr,dc=org'

        # Open syslog and use it for processing messages
        syslog.openlog(logoption=syslog.LOG_PID,
                       facility=syslog.LOG_LOCAL3)

        return

    # -----------------------------------------------------------
    # Utility routine to write INFO syslog messages

    def info(self, txt):
        syslog.syslog(txt)
        return

    # -----------------------------------------------------------
    # Find master

    def find_master(self, hostname):

        ldap_conn = ldapserver_connect(hostname, True)

        # Set up the ldap search
        ldap_base = 'cn=config'
        ldap_filter = 'olcSyncrepl=*'
        ldap_scope = ldap.SCOPE_SUBTREE
        ldap_attrs = ['olcSyncrepl']
        try:
            result_id = ldap_conn.search(ldap_base,
                                         ldap_scope,
                                         ldap_filter,
                                         ldap_attrs)
        except ldap.LDAPError as e:
            print('ERROR: {} while finding master directory server'.format(e))
            sys.exit(3)

        logging.debug('base = {}'.format(ldap_base))
        logging.debug('scope = {}'.format(ldap_scope))
        logging.debug('filter = {}'.format(ldap_filter))

        # Read the principals for this group from the directory
        sync_repl = None
        while 1:
            try:
                ldap_type, ldap_entry = ldap_conn.result(result_id, 0)
            except ldap.LDAPError as e:
                print('UNKNOWN: {}'.format(e))
                sys.exit(3)
            if ldap_entry == []:
                break
            if ldap_type == ldap.RES_SEARCH_ENTRY:
                sync_repl_b = ldap_entry[0][1]['olcSyncrepl'][0]
                sync_repl = sync_repl_b.decode()
        ldap_master = None
        if sync_repl is not None:
            logging.debug('olcSyncrepl {}'.format(sync_repl))
            m = re.search(r'provider=ldap://(\S+):\d+', sync_repl)
            if m:
                ldap_master = m.group(1)
            else:
                print('UNKNOWN: LDAP master not found for {}'.format(hostname))
                sys.exit(3)
        return ldap_master

    # -----------------------------------------------------------
    # Read the Directory and write files

    def read_csn(self, hostname):

        ldap_conn = ldapserver_connect(hostname)

        # Set up the ldap search
        ldap_base = self.ldap_base
        ldap_filter = 'objectclass=*'
        ldap_scope = ldap.SCOPE_BASE
        ldap_attrs = ['contextCSN']
        try:
            result_id = ldap_conn.search(ldap_base,
                                         ldap_scope,
                                         ldap_filter,
                                         ldap_attrs)
        except ldap.SERVER_DOWN:
            print('CRITICAL: problem contacting server {}'.format(hostname))
            sys.exit(2)
        except ldap.LDAPError as e:
            print('UNKNOWN: problem connecting to{} = {}'.format(hostname, e))
            sys.exit(3)

        logging.debug('base = {}'.format(ldap_base))
        logging.debug('scope = {}'.format(ldap_scope))
        logging.debug('filter = {}'.format(ldap_filter))

        # Read the principals for this group from the directory
        context_csn_dict = {}
        while 1:
            try:
                ldap_type, ldap_entry = ldap_conn.result(result_id, 0)
            except ldap.LDAPError as e:
                print('UNKNOWN: problem reading from {} - {}'.format(hostname,
                                                                     e))
                sys.exit(3)
            if ldap_entry == []:
                break
            if ldap_type == ldap.RES_SEARCH_ENTRY:
                for context_csn_b in ldap_entry[0][1]['contextCSN']:
                    context_csn = context_csn_b.decode()
                    bits = context_csn.split('#')
                    logging.debug('rawCSN: {}'.format(context_csn))
                    context_csn_dict[bits[2]] = bits[0]

        ldap_conn.unbind()
        return context_csn_dict

    # -----------------------------------------------------------
    # Read the Directory and write files

    def check_replication(self, slave, master, crit, warn):
        slave_csn = self.read_csn(slave)
        master_csn = self.read_csn(master)
        logging.debug('{} {}'.format(slave_csn, slave))
        logging.debug('{} {}'.format(master_csn, master))
        if len(slave_csn) != len(master_csn):
            msg = 'CRITICAL: contextCSN count mismatch '
            msg += 'master={} '.format(len(master_csn.keys))
            msg += 'slave={}'.format(len(slave_csn.keys))
            print(msg)
            sys.exit(2)
        msg = ''
        level = 0
        for i in master_csn:
            slave_sec = csn_seconds(slave_csn[i])
            master_sec = csn_seconds(master_csn[i])
            seconds_behind = master_sec - slave_sec
            logging.debug('{} {} = {}'.format('slave sec', i, slave_sec))
            logging.debug('{} {} = {}'.format('master sec', i, master_sec))
            logging.debug('{} {} = {}'.format('lag sec', i, seconds_behind))
            if seconds_behind < 0 or seconds_behind > crit:
                msg += 'replication error ({}) '.format(i)
                msg += '{} seconds behind '.format(seconds_behind)
                level = 2
            elif seconds_behind > warn:
                msg += 'WARNING: replication lagging, '
                msg += '{} seconds behind'.format(seconds_behind)
                level = 1
        if level == 0:
            msg = 'OK: {} and {} are in sync'.format(master, slave)

        print(msg)
        sys.exit(level)

        return

##############################################################################
# Main routine
##############################################################################


if __name__ == '__main__':

    parser = argparse.ArgumentParser(
        description='Check the state of ldap replication',
        usage=display_usage())

    # Switches
    parser.add_argument('--debug',
                        help='Display debugging output',
                        action="store_true")
    parser.add_argument('--warn',
                        type=int,
                        default=30,
                        help='Seconds behind master for warning')
    parser.add_argument('--crit',
                        type=int,
                        default=60,
                        help='Seconds behind master for critical')

    # Positional arguments
    parser.add_argument('slave',
                        default=None,
                        nargs='?',
                        help='Enter first LDAP server hostname')
    parser.add_argument('master',
                        default=None,
                        nargs='?',
                        help='Enter second LDAP server hostname')

    args = parser.parse_args()

    if args.slave == 'help':
        print(display_usage())
        sys.exit(0)
    if args.slave == 'manual':
        print(display_pod())
        sys.exit(0)

    # Turn on debugging
    if args.debug:
        logging.basicConfig(level=logging.DEBUG)
        logging.debug('Debugging display started')

    if args.warn > args.crit:
        print('CRITICAL: failure --crit must be larger than --warn')
        sys.exit(2)

    lrc = ldap_repl_check()

    if args.slave:
        this_slave = args.slave
    else:
        this_slave = '127.0.0.1'
    if args.master:
        this_master = args.master
    else:
        this_master = lrc.find_master(this_slave)

    lrc.check_replication(this_slave, this_master, args.crit, args.warn)

doc = '''

=head1 NAME

ldap-repl-check - check replication status for OpenLDAP slave

=head1 SYNOPSIS

ldap-repl-check [<slave hostname>|help|manual] [<master hostname>]
[--crit=<integer>] [--warn=[integer>] [--debug]

=head1 DESCRIPTION

This script reads the contextCSN's on two OpenLDAP directory
servers and compares them.  If they do not match a Nagios compatible
message is printed and a Nagios exit status is set.

The slave hostname defaults to localhost.  If a master server hostname
is not specified then the master hostname is read from the cn=config
LDAP structure.

=head1 ACTIONS

=over 4

=item help

Display a usage message.

=item manual

Display this documentation.

=back

=head1 OPTIONS

=over 4

=item --crit=<integer>

If a replica server is lagging the master server by more than <integer>
seconds generate a critical exit status and message.  The default is
60 seconds.

=item --warn=<integer>

If a replica server is lagging the master server by more than <integer>
seconds generate a warning exit status and message.  The default is
30 seconds.

=item --debug

Display debugging output.

=back

=head1 AUTHOR

Bill MacAllister <bill@ca-zephyr.org>

=head1 COPYRIGHT

Copyright (C) 2018 Dropbox Inc.

This code is free software; you can redistribute it and/or modify it
under the same terms as Perl. For more details, see the full
text of the at https://opensource.org/licenses/Artistic-2.0.

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.

Copyright 2023 CZ Software

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

=cut
'''
