#!/usr/bin/python3

# --- BEGIN COPYRIGHT BLOCK ---
# Copyright (C) 2021 Red Hat, Inc.
# All rights reserved.
#
# License: GPL (version 3 or any later version).
# See LICENSE for details.
# --- END COPYRIGHT BLOCK ---

import re
import sys
import os, os.path

# regex that matches a BIND request line
regex_num = r'[-]?\d+' # matches numbers including negative
regex_new_conn = re.compile(r'^(\[.+\]) (conn=%s) (fd=%s) (slot=%s) (?:SSL )?connection from (\S+)' % (regex_num, regex_num, regex_num))
regex_sslinfo = re.compile(r'^\[.+\] (conn=%s) SSL (.+)$' % regex_num)
regex_bind_req = re.compile(r'^(\[.+\]) (conn=%s) (op=%s) BIND dn=(.+) method=(\S+) version=\d ?(?:mech=(\S+))?' % (regex_num, regex_num))
regex_bind_res = re.compile(r'^(\[.+\]) (conn=%s) (op=%s) RESULT err=(%s) tag=97 ' % (regex_num, regex_num, regex_num))
regex_unbind = re.compile(r'^\[.+\] (conn=%s) op=%s UNBIND' % (regex_num, regex_num))
regex_closed = re.compile(r'^(\[.+\]) (conn=%s) (op=%s) fd=%s closed' % (regex_num, regex_num, regex_num))
regex_ssl_map_fail = re.compile(r'^\[.+\] (conn=%s) (SSL failed to map client certificate.*)$' % regex_num)

# bind errors we can ignore
ignore_errors = {'0': 'Success',
                 '10': 'Referral',
                 '14': 'SASL Bind In Progress'
                 }

REQ = 0
RES = 1

class Conn:
    def __init__(self, timestamp, conn, fd, slot, ip):
        self.conn = conn
        self.fd = fd
        self.slot = slot
        self.ip = ip
        self.timestamp = timestamp
        self.ops = {}
        self.sslinfo = ''

    def addssl(self, sslinfo):
        if self.sslinfo and sslinfo:
            self.sslinfo += ' '
        self.sslinfo += sslinfo

    def addreq(self, timestamp, opnum, dn, method, mech='SIMPLE'):
        retval = None
        if opnum in self.ops: # result came before request?
            op = self.ops.pop(opnum) # grab the op and remove from list
            if op[RES]['errnum'] in ignore_errors: # don't care about this op
                return retval
            if not mech: mech = "SIMPLE"
            op[REQ] = {'dn': dn, 'method': method, 'timestamp': timestamp,
                       'mech': mech}
            retval = self.logstr(opnum, op)
        else: # store request until we get the result
            op = [None, None] # new empty list
            if not mech: mech = "SIMPLE"
            op[REQ] = {'dn': dn, 'method': method, 'timestamp': timestamp,
                       'mech': mech}
            self.ops[opnum] = op
        return retval

    def addres(self, timestamp, opnum, errnum):
        retval = None
        if opnum in self.ops:
            op = self.ops.pop(opnum) # grab the op and remove from list
            if errnum in ignore_errors: # don't care about this op
                return retval
            op[RES] = {'errnum': errnum, 'timestamp': timestamp}
            retval = self.logstr(opnum, op)
        else: # result came before request in access log - store until we find request
            op = [None, None] # new empty list
            op[RES] = {'errnum': errnum, 'timestamp': timestamp}
            self.ops[opnum] = op
        return retval

    def logstr(self, opnum, op):
        # timestamp connnum opnum err=X request timestamp dn=Y method=Z mech=W timestamp ip=ip
        logstr = '%s %s %s err=%s REQUEST %s dn=%s method=%s mech=%s %s ip=%s extra=%s' % (
            op[RES]['timestamp'], self.conn, opnum, op[RES]['errnum'],
            op[REQ]['timestamp'], op[REQ]['dn'], op[REQ]['method'], op[REQ]['mech'],
            self.timestamp, self.ip, self.sslinfo
            )
        return logstr
        
# key is conn=X
# val is ops hash
#  key is op=Y
#  value is list
#    list[0] is BIND request
#    list[1] is RESULT
conns = {}

# file to log failed binds to
logf = None

def pre(plgargs):
    global logf
    logfile = plgargs.get('logfile', None)
    if not logfile:
        print("Error: missing required argument failedbinds.logfile")
        return False
    needchmod = False
    if not os.path.isfile(logfile): needchmod = True
    if sys.version_info < (3, 0):
        logf = open(logfile, 'a', 0) # 0 for unbuffered output
    else:
        logf = open(logfile, 'a')
    if needchmod: os.chmod(logfile, 0o600)
    return True

def post():
    global logf
    logf.close()
    logf = None

def plugin(line):
    # is this a new conn line?
    match = regex_new_conn.match(line)
    if match:
        (timestamp, connid, fdid, slotid, ip) = match.groups()
        if connid in conns: conns.pop(connid) # remove old one, if any
        conn = Conn(timestamp, connid, fdid, slotid, ip)
        conns[connid] = conn
        return True

    # is this an UNBIND line?
    match = regex_unbind.match(line)
    if match:
        connid = match.group(1)
        if connid in conns: conns.pop(connid) # remove it
        return True

    # is this a closed line?
    match = regex_closed.match(line)
    if match:
        (timestamp, connid, opid) = match.groups()
        if connid in conns: conns.pop(connid) # remove it
        return True

    # is this an SSL info line?
    match = regex_sslinfo.match(line)
    if match:
        (connid, sslinfo) = match.groups()
        if connid in conns:
            conns[connid].addssl(sslinfo)
        return True

    # is this a line with extra SSL mapping info?
    match = regex_ssl_map_fail.match(line)
    if match:
        (connid, sslinfo) = match.groups()
        if connid in conns:
            conns[connid].addssl(sslinfo)
        return True

    # is this a REQUEST line?
    match = regex_bind_req.match(line)
    if match:
        (timestamp, connid, opnum, dn, method, mech) = match.groups()
        # should have seen new conn line - if not, have to create a dummy one
        conn = conns.get(connid, Conn('unknown', connid, '', '', 'unknown'))
        logmsg = conn.addreq(timestamp, opnum, dn, method, mech)
        if logmsg:
            logf.write(logmsg + "\n")
            logf.flush()
        return True

    # is this a RESULT line?
    match = regex_bind_res.match(line)
    if match:
        (timestamp, connid, opnum, errnum) = match.groups()
        # should have seen new conn line - if not, have to create a dummy one
        conn = conns.get(connid, Conn('unknown', connid, '', '', 'unknown'))
        logmsg = conn.addres(timestamp, opnum, errnum)
        if logmsg:
            logf.write(logmsg + "\n")
            logf.flush()
        return True

    return True # no match
