#!/usr/local/munki/munki-python
# encoding: utf-8
#
# Copyright 2018-2024 Greg Neagle.
#
# 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
#
#      https://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.
"""
appusaged

Created by Greg Neagle on 2018-04-07.

Much code based on and adapted from autopkgserver by Per Olofsson

A privileged daemon that records app usage events and install requests to our
app usage database.
"""
from __future__ import absolute_import, print_function

import os
import sys
import time
import stat
import logging
import logging.handlers
try:
    import SocketServer
except ImportError:
    import socketserver as SocketServer
import socket
import struct

from munkilib import app_usage
from munkilib import launchd
from munkilib import munkilog
from munkilib import prefs
from munkilib.wrappers import readPlistFromString, PlistError, unicode_or_str


APPNAME = 'appusaged'
VERSION = '0.1'


def print_error_and_exit(errmsg):
    '''Prints an error message to stderr, sleeps, and exits'''
    print(errmsg, file=sys.stderr)
    time.sleep(10)
    return 1


class AppUsageHandlerError(Exception):
    '''Exception to raise if there is any error in AppUsageHandler'''
    pass


class AppUsageHandler(object):
    '''Class for working with appusage'''
    def __init__(self, server, request, uid):
        '''Arguments:
        request A request in plist format.
        uid The uid of the requestor
        '''
        self.server = server
        self.log = server.log
        self.request = request
        self.uid = uid

    def verify_request(self):
        '''Make sure copy request has everything we need'''
        self.log.debug('Verifying request')
        for key in ['event']:
            if not key in self.request:
                raise AppUsageHandlerError('No %s in request' % key)
        if self.request['event'] in ['install', 'remove']:
            expected_keys = ['name', 'version']
        else:
            expected_keys = ['app_dict']
        for key in expected_keys:
            if not key in self.request:
                raise AppUsageHandlerError('No %s in request' % key)

    def handle(self):
        '''Handle our request'''
        self.verify_request()

        if self.request['event'] in ['install', 'remove']:
            self.log.info('App install/removal request from uid %s', self.uid)
            self.log.info('%s', self.request)
            self.server.usage.log_install_request(self.request)
        else:
            self.log.info('App usage event from uid %s', self.uid)
            self.log.info('%s', self.request)
            self.server.usage.log_application_usage(
                self.request['event'], self.request['app_dict'])
        return u""


class RunHandler(SocketServer.StreamRequestHandler):
    '''Handler for app_usage events'''

    def verify_request_syntax(self, plist):
        '''Verify the basic syntax of request plist.'''
        # pylint: disable=no-self-use
        # Keep a list of error messages.
        errors = list()

        # Root should be a dictionary.
        if not isinstance(plist, dict):
            errors.append('Request root is not a dictionary')
            # Bail out early if it's not.
            return False, errors

        syntax_ok = True
        # TO-DO: Actual verification!
        return syntax_ok, errors

    def getpeerid(self):
        '''
        Get peer credentials on a UNIX domain socket.
        Returns uid, gids.
        '''

        # /usr/include/sys/ucred.h
        #
        # struct xucred {
        #         u_int   cr_version;           /* structure layout version */
        #         uid_t   cr_uid;               /* effective user id */
        #         short   cr_ngroups;           /* number of advisory groups */
        #         gid_t   cr_groups[NGROUPS];   /* advisory group list */
        # };

        # pylint: disable=invalid-name
        LOCAL_PEERCRED = 0x001
        XUCRED_VERSION = 0
        NGROUPS = 16
        # pylint: enable=invalid-name
        cr_version = 0
        cr_uid = 1
        cr_ngroups = 2
        cr_groups = 3

        xucred_fmt = b'IIh%dI' % NGROUPS
        res = struct.unpack(
            xucred_fmt,
            self.request.getsockopt(
                0, LOCAL_PEERCRED, struct.calcsize(xucred_fmt)))

        if res[cr_version] != XUCRED_VERSION:
            raise OSError('Incompatible struct xucred version')

        return res[cr_uid], res[cr_groups:cr_groups + res[cr_ngroups]]

    def handle(self):
        '''Handle an incoming app_usage event.'''

        try:
            # Log through server parent.
            self.log = self.server.log
            self.log.debug('Handling request')

            # Get uid and primary gid of connecting peer.
            try:
                uid, gids = self.getpeerid()
            except OSError as err:
                self.log.error(u'Peerid failure: %s' % unicode_or_str(err))
                self.request.send(
                    (u'ERROR:Internal peerid error\n').encode('UTF-8'))
                return

            gid = gids[0]
            self.log.debug(
                'Got request from uid %d gid %d' % (uid, gid))

            # Receive a plist.
            plist_string = self.request.recv(8192)

            # Try to parse it.
            try:
                plist = readPlistFromString(plist_string)
            except PlistError as err:
                self.log.error('Malformed request')
                self.request.send(u'ERROR:Malformed request\n'.encode('UTF-8'))
                return
            self.log.debug('Parsed request plist')

            # Verify the plist syntax.
            syntax_ok, errors = self.verify_request_syntax(plist)
            if not syntax_ok:
                self.log.error('Plist syntax error')
                msg = (u''.join(
                    [u'ERROR:%s\n' % e for e in errors])).encode('UTF-8')
                self.request.send(msg)
                return

            self.log.debug(
                'Dispatching worker to process request for user %d' % (uid))
            try:
                appusagehandler = AppUsageHandler(self.server, plist, uid)
                result = appusagehandler.handle()
                self.request.send(
                    (u'OK:%s\n' % unicode_or_str(result)).encode('UTF-8'))
            except AppUsageHandlerError as err:
                self.request.send(
                    (u'ERROR:%s\n' % unicode_or_str(err)).encode('UTF-8'))
            except BaseException as err:
                self.log.error(u'Run failed: %s' % unicode_or_str(err))
                self.request.send(
                    (u'ERROR:%s\n' % unicode_or_str(err)).encode('UTF-8'))

        except BaseException as err:
            self.log.error(u'Caught exception: %s' % repr(err))
            self.request.send(
                (u'ERROR:Caught exception: %s' % repr(err)).encode('UTF-8'))

class AppUsageDaemonError(Exception):
    '''Exception to raise for AppUsageDaemon errors'''
    pass


class AppUsageDaemon(SocketServer.UnixStreamServer):
    '''Daemon that runs as root, receiving app_usage events.'''

    allow_reuse_address = True
    request_queue_size = 10
    timeout = 10

    def __init__(self, socket_fd, RequestHandlerClass):
        # Avoid initialization of UnixStreamServer as we need to open the
        # socket from a file descriptor instead of creating our own.
        # pylint: disable=super-init-not-called
        self.socket = socket.fromfd(
            socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
        self.socket.listen(self.request_queue_size)
        # now do the base class's init
        # pylint: disable=non-parent-init-called
        SocketServer.BaseServer.__init__(
            self, self.socket.getsockname(), RequestHandlerClass)
        self.usage = app_usage.ApplicationUsageRecorder()
        self.log = logging.getLogger(APPNAME)
        self.timed_out = False

    def setup_logging(self):
        '''Configure logging'''
        try:
            logging_level = logging.INFO
            if munkilog.logging_level() > 1:
                logging_level = logging.DEBUG
            self.log.setLevel(logging_level)

            log_console = logging.StreamHandler()
            log_console.setLevel(logging_level)
            # store the log in the same directory as ManagedSoftwareUpdate.log
            logfilepath = os.path.join(
                os.path.dirname(prefs.pref('LogFile')), APPNAME + '.log')
            log_file = logging.handlers.RotatingFileHandler(
                logfilepath, 'a', 100000, 9, 'utf-8')
            log_file.setLevel(logging_level)

            console_formatter = logging.Formatter('%(message)s')
            file_formatter = logging.Formatter(
                '%(asctime)s %(module)s[%(process)d]: '
                '%(message)s   (%(funcName)s)')

            log_console.setFormatter(console_formatter)
            log_file.setFormatter(file_formatter)

            self.log.addHandler(log_console)
            self.log.addHandler(log_file)
        except (OSError, IOError) as err:
            raise AppUsageDaemonError(
                'Can\'t open log: %s' % (err.strerror))

    def handle_timeout(self):
        self.timed_out = True


def main():
    '''Start our daemon, connect to socket and process events'''
    # Make sure we're launched as root
    if os.geteuid() != 0:
        print_error_and_exit('%s must be run as root.' % APPNAME)

    # Make sure that the executable and all containing directories are owned
    # by root:wheel or root:admin, and not writeable by other users.
    root_uid = 0
    wheel_gid = 0
    admin_gid = 80

    exepath = os.path.realpath(os.path.abspath(__file__))
    path_ok = True
    while True:
        info = os.stat(exepath)
        if info.st_uid != root_uid:
            print('%s must be owned by root.' % exepath, file=sys.stderr)
            path_ok = False
        if info.st_gid not in (wheel_gid, admin_gid):
            print('%s must have group wheel or admin.' % exepath,
                  file=sys.stderr)
            path_ok = False
        if info.st_mode & stat.S_IWOTH:
            print('%s mustn\'t be world writeable.' % exepath, file=sys.stderr)
            path_ok = False
        exepath = os.path.dirname(exepath)
        if exepath == '/':
            break

    if not path_ok:
        # Sleep to avoid immediate respawn.
        time.sleep(10)
        return 1

    # Keep track of time for launchd.
    start_time = time.time()

    # Get socket file descriptors from launchd.
    socket_fd = launchd.get_socket_fd(APPNAME.encode('UTF-8'))
    if not socket_fd:
        print_error_and_exit('No socket provided to us by launchd')

    # Create the daemon object.
    daemon = AppUsageDaemon(socket_fd, RunHandler)
    try:
        daemon.setup_logging()
    except AppUsageDaemonError as err:
        print_error_and_exit('%s' % err)

    daemon.log.debug('%s v%s starting', APPNAME, VERSION)

    # Process incoming requests
    while True:
        daemon.handle_request()
        if not daemon.timed_out:
            continue
        # we timed out, so we can exit
        # Keep running for at least 10 seconds to make launchd happy.
        run_time = time.time() - start_time
        daemon.log.debug("run time: %fs", run_time)
        if run_time < 10.0:
            # Only sleep for a short while in case new requests pop up.
            sleep_time = min(1.0, 10.0 - run_time)
            daemon.log.debug(
                "sleeping for %f seconds to make launchd happy",
                sleep_time)
            time.sleep(sleep_time)
        else:
            daemon.log.debug('Nothing to do: exiting')
            break


if __name__ == '__main__':
    sys.exit(main())
