#!/usr/bin/env python

# -*- coding: utf-8 -*-

"""ospi-client: performs operation for opsi clients on opsi server via JSON-RPC."""

from __future__ import print_function

__author__ = "Joerg Steffens"
__copyright__ = "Copyright 2012-2021, dass IT GmbH"
__license__ = "GPL"
__version__ = "1.3"
__email__ = "joerg.steffens@dass-it.de"

#self.command("opsi-admin -d method host_createOpsiClient "+ \
        #computername + " null " + "\\'"+description+"\\'" + \
        #" \\'created by dassadmin\\' " + mac_address + " " + \
        #ip_address)
#self.command("opsi-admin -d method configState_create clientconfig.depot.id " + \
        #computername + " " + depotName)

import argparse
from   datetime import datetime, timedelta
from   dateutil import parser as dateparser
import logging
import os
from   pprint import pprint, pformat
import sys
import ssl
import time

from tinyrpc.protocols.jsonrpc import JSONRPCProtocol
from tinyrpc.transports.http import HttpPostClientTransport
from tinyrpc import RPCClient

UrlJsonRpc="https://<username>:<password>@opsi:4447/rpc"

HelpEpilog="WARNING: json-rpc is known to have problems with HTTP proxies. In case of problems, make sure, the environment variables http_proxy and/or https_proxy are *not* set. It might also be necessary to set variable PYTHONHTTPSVERIFY=0."

class Nrpe:
    OK = 0
    WARNING = 1
    CRITICAL = 2
    UNKNOWN = 3
    
    def toString(self, status):
        if status == self.OK:
            return 'OK'
        elif status == self.WARNING:
            return 'WARNING'
        elif status == self.CRITICAL:
            return 'CRITICAL'
        else:
            return 'UNKNOWN'

class Output(Nrpe):
    def __init__(self, nagios=False):
        self.nagios = nagios
        self.result = Nrpe.UNKNOWN
        self.message = ''

    def setStatus(self, status, message = None):
        self.result = status
        if message is not None:
            self.setMessage(message)

    def setMessage(self, string):
        self.message = string

    def finalize(self):
        if self.nagios:
            print('{0} - {1}'.format(self.toString(self.result), self.message))
            sys.exit(self.result)
        else:
            print('{0} - {1}'.format(self.toString(self.result), self.message))
            return (self.result == Nrpe.OK)

class OpsiRpc:

    UrlJsonRpcDefault="https://opsi:4447/rpc"

    ProductAttributesCopy = ['actionRequest','actionResult','installationStatus','packageVersion','productVersion']

    def __init__(self, urlJsonRpc = UrlJsonRpcDefault, debug=False, nagios=False):
        self.logger=logging.getLogger(__name__)
        self.debug=debug
        self.nagios=nagios
        self.urlJsonRpc=urlJsonRpc
        self.rpc = RPCClient(JSONRPCProtocol(), HttpPostClientTransport(self.urlJsonRpc, verify=False)).get_proxy()

        self.logger.debug( "initialized: " + self.urlJsonRpc )
        self.logger.debug(dir(self.rpc))


    def list(self):
        exceptions = []
        if 'jsonrpc' in sys.modules:
            exceptions = [ jsonrpc.json.JSONDecodeException ]
        try:
            print( "\n".join( self.rpc.getClientIds_list() ) )
        except exceptions as e:
            self.logger.debug( pformat(self.rpc.getClientIds_list()) )
            self.logger.exception( "failed" )
            return True


    def getClientsWithProduct( self, product ):
        return self.rpc.productOnClient_getObjects( [], { "productId": product, "installationStatus": "installed" } )

    def getHardwareSerialNumber(self, src):
        serialNumbers  = self.rpc.auditHardwareOnHost_getHashes( [], {"hostId":src, "hardwareClass":"BIOS", "serialNumber":"*"} )
        serialNumbers += self.rpc.auditHardwareOnHost_getHashes( [], {"hostId":src, "hardwareClass":"CHASSIS", "serialNumber":"*"} )
        result = set()
        for i in serialNumbers:
            if i['serialNumber']:
                result.add(i['serialNumber'])
        if len(result) == 1:
            return result.pop()
        elif len(result) > 1:
            self.logger.warning("found more then one serial number")
            return list(result)

    def listClients( self, product ):
        if product:
            for client in self.getClientsWithProduct( product ):
                print(client['clientId'])
        else:
            return self.list()
        return True

    def exists(self, src):
        return len( self.rpc.host_getObjects( [], {"id":src} ) ) == 1

    def info(self, src):
        if not self.exists( src ):
            print("failed: opsi client", src, "does not exist")
            return False
        print(src + ":")
        host = self.rpc.host_getHashes( [], {"id":src} )[0]
        print("  IP:", host["ipAddress"])
        print("  MAC:", host["hardwareAddress"])
        print("  inventory:", host["inventoryNumber"])
        print("  serial number:", self.getHardwareSerialNumber(src))
        print("  last seen:", host["lastSeen"])
        print("  notes:", host["notes"])
        print("  depot:", self.clientGetDepot(src))

        print("  products:")
        products = self.getProductOnClient( src, [] )
        for i in products:
            print("    " + i['productId'] + ":")
            print("      " + i['installationStatus'], "(", end='')
            if i['actionRequest']:
                print(i['actionRequest'], end='')
                if i['actionProgress']:
                    print(i['actionProgress'], end='')
            print(")")
            print("      ", end='')
            pprint( i, indent=8 )
        return True

    def clean(self, src):
        if not self.exists( src ):
            return False
        products = self.rpc.productOnClient_getObjects( [], { 'clientId': src } )
        self.rpc.productOnClient_deleteObjects( products )

        products = self.rpc.productPropertyState_getObjects( [], { 'objectId': src } )
        self.rpc.productPropertyState_deleteObjects( products )

        if self.debug:
            pprint( self.getProductOnClient( src ) )
        return True

    def getOpsiConfigserverId(self):
        # there should always be only one OpsiConfigserver
        opsiConfigservers=self.rpc.host_getHashes( [], { "type": "OpsiConfigserver" } )
        try:
            return opsiConfigservers[0]['id']
        except (KeyError,IndexError) as e:
            self.logger.error( "failed to retreive OpsiConfigserver" )

    def createClient(self, name, opsiHostKey, description, notes, hardwareAddress, ipAddress):
        #    self.rpc.host_createOpsiClient( name, opsiHostKey, description, notes, hardwareAddress, ipAddress )
        self.updateClient( name, opsiHostKey, description, notes, None, hardwareAddress, ipAddress )

    def deleteClient(self, name):
        self.rpc.host_delete( name )

    def updateClient(self, src, opsiHostKey = None, description = None, notes = None, inventoryNumber = None, hardwareAddress = None, ipAddress = None, depot = None ):
        obj = {
          "id" : src,
          "type" : "OpsiClient",
        }
        if opsiHostKey:
            obj['opsiHostKey'] = opsiHostKey
        if description:
            obj['description'] = description
        if notes:
            obj['notes'] = notes
        if inventoryNumber:
            obj['inventoryNumber'] = inventoryNumber
        if hardwareAddress:
            obj['hardwareAddress'] = hardwareAddress
        if ipAddress:
            obj['ipAddress'] = ipAddress

        if self.exists( src ):
            self.rpc.host_updateObject(obj)
        else:
            self.rpc.host_insertObject(obj)

        if depot:
            self.clientSetDepot(src,depot)
        return True

    def clientGetDepot(self, name):
        depot = self.rpc.configState_getHashes( [], {
            "configId": "clientconfig.depot.id",
            "objectId": name } )
        try:
            return depot[0]["values"][0]
        except (IndexError,KeyError):
            return self.getOpsiConfigserverId()

    def clientSetDepot(self, name, depot):
        self.rpc.configState_create( "clientconfig.depot.id", name, depot )

    def copyClient( self, src, dst, ipAddress = None, hardwareAddress = None, depot = None, description = "", copyProperties = True ):

        print("create/update", dst, "from template", src + ":", end='')
        obj = {
          "id" : dst,
          "type" : "OpsiClient",
          "notes" : "copy of " + src,
          "description" : description,
          #"inventoryNumber" : "",
        }
        if hardwareAddress:
            obj['hardwareAddress'] = hardwareAddress
        if ipAddress:
            obj['ipAddress'] = ipAddress

        if self.exists( dst ):
            self.rpc.host_updateObject(obj)
        else:
            self.rpc.host_insertObject(obj)

        if depot:
            self.clientSetDepot(dst,depot)

        if self.debug:
            pprint( self.getProductOnClient( src ) )
        self.copyProductOnClient( src, dst )
        if copyProperties:
            if self.debug:
                print("copy product properties")
            if not depot:
                # get default Properties from Master Depot Server (OpsiConfigserver)
                depot = self.getOpsiConfigserverId()
            self.copyProductPropertyState( src, dst, depot )
        print("done")
        return True

    def getProductOnClient( self, client, attributes = ProductAttributesCopy ):
        return self.rpc.productOnClient_getHashes( [], { 'clientId': client } )

    def copyProductOnClient( self, src, dst, attributes = ProductAttributesCopy ):
        products_src = self.rpc.productOnClient_getHashes( attributes, { 'clientId': src } )
        products_dst = []
        for i in products_src:
            if self.debug: 
                print(i['productId'])
                pprint( i )
            i['clientId'] = dst
            products_dst.append(i)
        self.rpc.productOnClient_createObjects( products_dst )
        if self.debug:
            pprint( self.getProductOnClient( dst ) )


    def getProductPropertyState( self, client, attributes = [] ):
        return self.rpc.productPropertyState_getHashes( [], { 'objectId': client } )


    def copyProductPropertyState( self, src, dst, default = None, attributes = [] ):
        if default:
            productProperties_default = self.getProductPropertyState( default, attributes )
        else:
            productProperties_default = []
        productProperties_src = self.getProductPropertyState( src, attributes )
        productProperties_dst = []
        for i in productProperties_src:
            use_default=False
            default_value=None
            for j in productProperties_default:
                if i['productId'] == j['productId'] and i["propertyId"] == j["propertyId"]:
                    default_value = j['values']
                    if i['values'] == j['values']:
                        use_default=True
            if self.debug:
                print(i['productId'], "-", i["propertyId"] + ": ", pformat(i["values"]), end='')
                if use_default:
                    print("(use default)")
                else:
                    print("(set, default:", default_value, ")")
            if not use_default:
                i['objectId'] = dst
                productProperties_dst.append(i)
        self.rpc.productPropertyState_createObjects( productProperties_dst )
        if self.debug:
            pprint( self.getProductPropertyState( dst ) )


    def getClientProductProperty( self, client, product ):
        return self.rpc.getProductProperties_hash( product, [ client ] )

    def setProductPropertiesOnClient( self, dst, product, properties ):
        self.rpc.setProductProperties(product,properties,dst)

    def setProductPropertyOnClient( self, dst, product, prop, value):
        self.rpc.setProductProperty(product,prop,value,dst)



    def write_value_conf(self, fd, key, properties, default=None):
        value = None
        comment = ''
        try:
            value = properties[key.lower()]
        except KeyError:
            pass
        if not value and default:
            value = default
        if not value:
            # prevent a None to be written
            value = ''
            comment = '# '
        fd.write('  {}{} = "{}"\n'.format(comment, key, value))



    def write_client_conf( self, fd, client, properties ):
        #Client {
        #Name = ting-fd
        #Address = ting.dass-it
        #FDPort = 9102
        #Password = "D5w2V5w6B8a9H5Z"
        #Catalog = MyCatalog
        #File Retention = 6 months
        #Job Retention = 6 months
        #AutoPrune = yes
        #}
        params = [ 'catalog', "FDPort", "FileRetention", "JobRetention", "AutoPrune" ]
        fd.write( "Client {\n" )
        fd.write( '  Name     = "' + properties['filedaemon_full_name'] + '"' + "\n" )
        fd.write( '  Address  = "' + properties['filedaemon_client_address'] + '"' + "\n" )
        # ipAddress: method host_getObjects [] '{"id":client['clientId']}'
        #print("  # Address =", ipAddress)
        fd.write( '  Password = "' + properties['filedaemon_full_password'] + '"' + "\n" )
        for i in params:
            self.write_value_conf(fd, i, properties)
        fd.write( "}\n")
        fd.write( "\n" )


    def write_job_conf(self, fd, client, properties, defaultjobdefs, defaultfileset):
        #Job {
        #FileSet = "tingfileset"
        #Name = "ting"
        #Client = ting-fd
        #JobDefs = "LaptopJob"
        ## Write Bootstrap = "/var/lib/bacula/ting.bsr"
        #}
        params = [ "JobDefs", "FileSet" ]
        fd.write( "Job {" + "\n" )
        fd.write( '  Name    = "' + client['clientId'] + '-job"' + "\n" )
        fd.write( '  Client  = "' + properties['filedaemon_full_name'] + '"' + "\n" )
        self.write_value_conf(fd, 'JobDefs', properties, defaultjobdefs)
        self.write_value_conf(fd, 'FileSet', properties, defaultfileset)
        fd.write( "}" + "\n" )
        fd.write( "\n" )


    def write_config_file_header( self, fd ):
        try:
            fd.write( "#\n" )
            fd.write( "# automatically generated at {0}\n".format( time.asctime() ) )
            fd.write( "#\n\n" )
        except BaseException as e:
            self.logger.exception( "failed to create files" )
            return False
        return True



    def createBareosConfigFiles(self, defaultjobdefs, defaultfileset):
        bareosDirConfigPath = '/etc/bareos/bareos-dir.d/'
        clientsWithBacula=self.getClientsWithProduct('winbareos')
        if clientsWithBacula:
            try:
                configfile = bareosDirConfigPath + 'client/opsi-clients-generated.conf'
                file_opsi_clients = open(configfile, 'w')
                self.write_config_file_header( file_opsi_clients )
            except (BaseException, IOError) as e:
                self.logger.exception( "failed to create configuration file {}".format(configfile) )
                return False

            try:
                configfile = bareosDirConfigPath + 'job/opsi-jobs-generated.conf'
                file_opsi_jobs = open(configfile, 'w')
                self.write_config_file_header( file_opsi_jobs )
            except (BaseException, IOError) as e:
                self.logger.exception( "failed to create configuration file {}".format(configfile) )
                return False

            for client in clientsWithBacula:
                clientId = client['clientId']
                try:
                    clientBaculaProperties=self.getClientProductProperty( clientId, 'winbareos' )
                except ValueError as e:
                    self.logger.warn( "%s: no valid information found: %s" %(clientId, e) )
                else:
                    if clientBaculaProperties:
                        #pprint( clientBaculaProperties )
                        self.write_client_conf(file_opsi_clients, client, clientBaculaProperties)
                        self.write_job_conf(file_opsi_jobs, client, clientBaculaProperties, defaultjobdefs, defaultfileset)
                        self.logger.info( "%s: OK" % clientId )
                    else:
                        self.logger.warn( "%s: failed: no product properties defined" %(clientId) )
            return True

    def __getVersionString(self, product):
        return '{productVersion}-{packageVersion}'.format(**product)

    def getProductCurrentVersion(self, productId):
        products = self.rpc.product_getHashes( [], { 'id': productId } )
        if products:
            return self.__getVersionString(products[0])
        else:
            return None

    def __getClientId(self, d):
        return d.get("clientId")

    def listInstalled(self, productId):
        productVersion = self.getProductCurrentVersion(productId)
        self.logger.debug('version: {0}'.format(productVersion))
        products = self.rpc.productOnClient_getHashes( [], { 'productId': productId } )
        for i in sorted(products, key=self.__getClientId):
            i['version'] = self.__getVersionString(i)
            if i.get('installationStatus') == 'installed':
                if productVersion != i['version']:
                    i['proposedAction'] = 'update'
                else:
                    i['proposedAction'] = 'None'
                print('{clientId}: {version} (proposed action={proposedAction}) request={actionRequest}, result={actionResult}'.format(**i))
            else:
                i['proposedAction'] = 'install'
                print('{clientId}: (proposed action={proposedAction})'.format(**i))
                self.logger.debug('{clientId}: {actionRequest} {installationStatus} {version}'.format(**i))
            #pprint( i, indent=8 )
        return True

    def isInstalled(self, clientId, productId):
        """
        CRITICAL: not installed
        WARNING: installed, but not current version
        OK: current version is installed
        UNKNOWN: otherwise
        """
        output = Output(self.nagios)
        if not self.exists(clientId):
            output.setMessage("failed: opsi client {0} does not exist".format(clientId))
            return output.finalize()
        productVersion = self.getProductCurrentVersion(productId)
        if not productVersion:
            output.setMessage("failed: product {0} does not exist".format(productId))
            return output.finalize()
        self.logger.debug('version: {0}'.format(productVersion))
        products = self.rpc.productOnClient_getHashes( [], { "clientId": clientId,'productId': productId } )
        if len(products) != 1:
            print("failed: opsi client ({0}) product ({1}) combination does not exist".format(clientId, productId))
            return False            
        for i in sorted(products, key=self.__getClientId):
            i['version'] = self.__getVersionString(i)
            if i.get('installationStatus') == 'installed':
                if productVersion != i['version']:
                    i['proposedAction'] = 'update'
                    output.setStatus(Nrpe.WARNING)
                else:
                    i['proposedAction'] = 'None'
                    output.setStatus(Nrpe.OK)
                output.setMessage('{version} (proposed action={proposedAction})'.format(**i))
            else:
                i['proposedAction'] = 'install'
                output.setStatus(Nrpe.CRITICAL, 'not installed (proposed action={proposedAction})'.format(**i))
                self.logger.debug('{clientId}: {actionRequest} {installationStatus} {version}'.format(**i))
        return output.finalize()


    def clientLastSeen(self, clientId):
        """
        < 1 day: OK
        < 2 days: WARNING
        otherwise: CRITICAL
        """
        output = Output(self.nagios)
        if not self.exists(clientId):
            output.setMessage("failed: opsi client {0} does not exist".format(clientId))
            return output.finalize()
        host = self.rpc.host_getHashes( [], {"id":clientId} )[0]
        lastSeen = dateparser.parse(host["lastSeen"])
        output.setMessage(str(lastSeen))
        diff = datetime.now() - lastSeen
        output.setMessage('{0} ({1} ago)'.format(str(lastSeen), diff))
        if diff < timedelta(1):
            output.setStatus(Nrpe.OK)
        elif diff < timedelta(2):
            output.setStatus(Nrpe.WARNING)
        else:
            output.setStatus(Nrpe.CRITICAL)
        return output.finalize()



if __name__ == '__main__':
    logging.basicConfig(format='%(message)s')
    logger = logging.getLogger(__name__)
    logger.setLevel(logging.INFO)
    
    parser = argparse.ArgumentParser(
        description='Command line tool for OPSI configuration.',
        epilog=HelpEpilog
    )

    parser.add_argument('--debug', action='store_true', help="enable debugging output")
    parser.add_argument('--nagios', action='store_true', help='output in Nagios NRPE format')

    parser_url = parser.add_mutually_exclusive_group(required=True)
    parser_url.add_argument( '--url', help="OPSI Server JSON-RPC url, in following format: " + UrlJsonRpc )
    
    parser_url.add_argument( '--server', help="OPSI Server (instead of URL)" )
    username_default=os.getlogin()
    
    parser.add_argument( '--username', help="username (instead of URL), default: " + username_default, default=username_default )
    parser.add_argument( '--password', help="password (instead of URL)" )
    
    subparsers = parser.add_subparsers(title='subcommands',
        description='valid subcommands',
        help='additional help',
        dest='subcommand' )

    parser_clean = subparsers.add_parser('clean', help='remove all product states from a opsi client' )
    parser_clean.add_argument( 'src', help="source opsi client to clean" )

    parser_copy = subparsers.add_parser('copy', help='copy/create a opsi client from a template opsi client')
    parser_copy.add_argument( 'src', help="source/template opsi client" )
    parser_copy.add_argument( 'dst', help="opsi client to be created" )
    parser_copy.add_argument( '--ip', help="IP address of the new opsi client" )
    parser_copy.add_argument( '--mac', help="MAC address of the new opsi client" )
    parser_copy.add_argument( '--depot', help="depot server the new opsi client should be located" )
    #parser_copy.add_argument( '--no-properties', action='store_false', help="don't copy product properties" )

    parser_createBareosConfigFiles = subparsers.add_parser(
        'createBareosConfigFiles',
        help='create Bareos config files for all clients that have winbareos installed',
        formatter_class=argparse.ArgumentDefaultsHelpFormatter
    )
    parser_createBareosConfigFiles.add_argument(
        '--defaultjobdefs',
        metavar='JobDefs',
        default='DefaultJob',
        help="use this JobDefs if no other is defined for a client"
    )
    parser_createBareosConfigFiles.add_argument(
        '--defaultfileset',
        metavar='FileSet',
        help="use this FileSet if no other is defined for a client"
    )

    parser_exists = subparsers.add_parser('exists', help='check, if a opsi clients exists' )
    parser_exists.add_argument( 'src', help="source opsi client" )
    #parser_list = subparsers.add_parser('list', help='list all opsi clients' )

    parser_listClients = subparsers.add_parser('listClients', help='list opsi clients')
    parser_listClients.add_argument( '--product', help="only list clients, that have product installed" )

    parser_info = subparsers.add_parser('info', help='print information about a opsi client' )
    parser_info.add_argument( 'src', help="opsi client" )
    
    parser_clientLastSeen = subparsers.add_parser('clientLastSeen', help='print information about a opsi client' )
    parser_clientLastSeen.add_argument( 'client', help="opsi client" )

    parser_listInstalled = subparsers.add_parser('listInstalled', help='check if product is installed on client')
    parser_listInstalled.add_argument('product', help='opsi product')

    parser_isInstalled = subparsers.add_parser('isInstalled', help='check if product is installed on client')
    parser_isInstalled.add_argument('client', help='opsi client')
    parser_isInstalled.add_argument('product', help='opsi product')

    parser_update = subparsers.add_parser('update', help='update/create a opsi client')
    parser_update.add_argument( 'src', help="opsi client to be created/updated" )
    parser_update.add_argument( '--ip', help="IP address of the opsi client" )
    parser_update.add_argument( '--mac', help="MAC address of the opsi client" )
    parser_update.add_argument( '--description', help="a description of the client" )
    parser_update.add_argument( '--notes', help="notes about the client" )
    parser_update.add_argument( '--inventory', help="inventory number" )
    parser_update.add_argument( '--depot', help="depot server the opsi client should be located" )

    args = parser.parse_args()

    if args.debug:
        logger.setLevel(logging.DEBUG)

    url=args.url
    if (not url):
        if args.server:
            account=""
            if args.username and args.password:
                account=args.username + ":" + args.password + "@"
            elif args.username:
                account=args.username + "@"
            url="https://" + account + args.server + ":4447/rpc"
        else:
            parser.error( "argument --url is required" )

    opsi=OpsiRpc(url, args.debug, args.nagios)

    result = True

    try:
        if args.subcommand == "clean":
            result = opsi.clean( args.src )
        elif args.subcommand == "copy": 
            result = opsi.copyClient( args.src, args.dst, args.ip, args.mac, args.depot )
        elif args.subcommand == "createBareosConfigFiles": 
            result = opsi.createBareosConfigFiles(args.defaultjobdefs, args.defaultfileset)
        elif args.subcommand == "exists":
            result = opsi.exists( args.src )
        elif args.subcommand == "list":
            result = opsi.list()
        elif args.subcommand == "listClients":
            result = opsi.listClients( args.product )
        elif args.subcommand == "info":
            result = opsi.info( args.src )
        elif args.subcommand == "update": 
            result = opsi.updateClient( args.src, None, args.description, args.notes, args.inventory, args.mac, args.ip, args.depot )
        elif args.subcommand == 'listInstalled': 
            result = opsi.listInstalled(args.product)
        elif args.subcommand == 'isInstalled': 
            result = opsi.isInstalled(args.client, args.product)
        elif args.subcommand == 'clientLastSeen': 
            result = opsi.clientLastSeen(args.client)
        else:
            print("not yet implemented")
    except IOError as e:
        result = False
        # connection refused
        print("failed:", e)

    if args.debug: print(result)

    if result:
        exit(0)
    else:
        exit(1)
