#!/usr/local/munki/munki-python
# encoding: utf-8
#
# Copyright 2017-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.
"""
authrestartd

Created by Greg Neagle on 2017-04-15.

Much code based on and adapted from autopkgserver by Per Olofsson

A helper tool for FileVault authorized restarts.  Allows non-privileged users
to get certain info about FileVault, and to store a password for authrestart.
Root user can trigger an authrestart, possibly using the password stored
earlier by a non-privileged user.

"""
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 authrestart
from munkilib import launchd
from munkilib import prefs
from munkilib.wrappers import readPlistFromString, PlistError, unicode_or_str


APPNAME = 'authrestartd'
VERSION = '0.2'


class FDEUtilError(Exception):
    '''Exception to raise if there is any error in FDEUtil'''
    pass


class FDEUtil(object):
    '''Class for working with fdesetup'''
    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 ['task']:
            if not key in self.request:
                raise FDEUtilError('No %s in request' % key)

    def store_password(self, password, username=None):
        '''Stores a password for later use for authrestart'''
        self.server.stored_password = password
        if username:
            self.server.stored_username = username

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

        if self.request['task'] == 'restart':
            # Attempt to perform an authrestart, falling back to a regular
            # restart
            self.log.info('Restart request from uid %s', self.uid)
            if self.uid == 0:
                self.log.info('Stored username for authrestart: %s',
                              self.server.stored_username)
                authrestart.do_authorized_or_normal_restart(
                    password=self.server.stored_password,
                    username=self.server.stored_username)
                return 'RESTARTING'
            else:
                self.log.info('Restart request denied')
                raise FDEUtilError('Restart may only be triggered by root')

        if self.request['task'] == 'delayed_authrestart':
            # set up a delayed authrestart. Defaults to waiting indefinitely,
            # so the next "normal" restart becomes an authrestart.
            self.log.info('Delayed restart request from uid %s', self.uid)
            if self.uid == 0:
                self.log.info('Stored username for authrestart: %s',
                              self.server.stored_username)
                delayminutes = self.request.get('delayminutes', -1)
                authrestart.perform_auth_restart(
                    password=self.server.stored_password,
                    username=self.server.stored_username,
                    delayminutes=delayminutes)
                return 'DONE'
            else:
                self.log.info('Delayed restart request denied')
                raise FDEUtilError(
                    'Delayed restart may only be triggered by root')

        if self.request['task'] == 'store_password':
            # store a password for later fdesetup authrestart
            self.log.info('Store password request')
            password = self.request.get('password')
            if not password:
                self.log.info('No password in request')
                raise FDEUtilError('No password provided')
            username = self.request.get('username')
            self.log.info('Request username: %s', username)
            # don't store the password if the user isn't enabled for FileVault
            if (username and
                    not authrestart.can_attempt_auth_restart_for(username)):
                self.log.info('User %s can\'t do auth restart', username)
                raise FDEUtilError(
                    'User %s can\'t do FileVault authrestart' % username)
            self.store_password(password, username=username)
            self.log.info('Password stored.')
            return 'DONE'

        if self.request['task'] == 'verify_can_attempt_auth_restart':
            # Check if we have all the required bits to attempt an auth
            # restart.
            self.log.info('Verify ready for auth restart')
            if authrestart.can_attempt_auth_restart(
                    have_password=bool(self.server.stored_password)):
                self.log.info('Ready for auth restart attempt')
                return 'READY'
            else:
                self.log.info('Not ready for auth restart attempt')
                raise FDEUtilError('Not ready for auth restart attempt')

        if self.request['task'] == 'verify_recovery_key_present':
            # Check if a plist containing a recovery key or password is
            # present.
            self.log.info('Verify recovery key request')
            if authrestart.get_auth_restart_key(quiet=True) == '':
                self.log.info('No valid recovery key plist')
                raise FDEUtilError('No recovery key plist')
            self.log.info('Valid recovery key plist found')
            return 'PRESENT'

        if self.request['task'] == 'verify_user':
            # Check to see if we can perform an authrestart for this user.
            # FileVault must be active, the hardware must support authrestart,
            # and the user must be enabled for FileVault.
            username = self.request.get('username')
            if not username:
                self.log.info('Verify user request with no username')
                raise FDEUtilError('No username provided')
            self.log.info('Verify user request for %s', username)
            if not authrestart.can_attempt_auth_restart_for(username):
                self.log.info(
                    'User %s can\'t do FileVault authrestart' % username)
                raise FDEUtilError('User not authorized for FileVault')
            self.log.info('User %s ok for auth restart', username)
            return 'USER VERIFIED'

        if self.request['task'] == 'verify_filevault':
            # check if FileVault is active
            self.log.info('Verify FileVault request')
            if not authrestart.filevault_is_active():
                self.log.info('FileVault is not active.')
                raise FDEUtilError('FileVault is not active')
            self.log.info('FileVault is active.')
            return 'FILEVAULT ON'

        # the task is not one we know how to handle
        self.log.info('Unknown task request: %s', self.request['task'])
        raise FDEUtilError('Unknown task')


class RunHandler(SocketServer.StreamRequestHandler):
    '''Handler for restarthelper run requests'''

    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 run request.'''

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

            # Get uid and primary gid of connecting peer.
            uid, gids = self.getpeerid()
            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 = (''.join(
                    ['ERROR:%s\n' % e for e in errors])).encode('UTF-8')
                self.request.send(msg)
                return

            self.log.info(
                'Dispatching worker to process request for user %d' % (uid))
            try:
                fdeutil = FDEUtil(self.server, plist, uid)
                result = fdeutil.handle()
                self.request.send(
                    (u'OK:%s\n' % unicode_or_str(result)).encode('UTF-8'))
            except FDEUtilError as err:
                self.request.send(
                    (u'ERROR:%s\n' % unicode_or_str(err)).encode('UTF-8'))
            except Exception as err:
                raise
                #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 Exception as err:
            self.log.error('Caught exception: %s' % repr(err))
            self.request.send(
                (u'ERROR:Caught exception: %s' % repr(err)).encode('UTF-8'))
            raise


class RestartHelperDaemonError(Exception):
    '''Exception to raise for RestartHelperDaemon errors'''
    pass


class RestartHelperDaemon(SocketServer.UnixStreamServer):
    '''Daemon that runs as root,
    receiving requests for fdesetup tasks including authrestart.'''

    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.timed_out = False
        self.stored_password = None
        self.stored_username = None
        self.log = logging.getLogger(APPNAME)

    def setup_logging(self):
        '''Configure logging'''
        try:
            self.log.setLevel(logging.DEBUG)

            log_console = logging.StreamHandler()
            log_console.setLevel(logging.DEBUG)
            # 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.DEBUG)

            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 RestartHelperDaemonError(
                '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 requests'''
    # Make sure we're launched as root
    if os.geteuid() != 0:
        print('%s must be run as root.' % APPNAME, file=sys.stderr)
        # Sleep to avoid respawn.
        time.sleep(10)
        return 1

    # 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('authrestartd'.encode("UTF-8"))
    if not socket_fd:
        print('No socket provided to us by launchd', file=sys.stderr)
        time.sleep(10)
        return 1

    # Create the daemon object.
    daemon = RestartHelperDaemon(socket_fd, RunHandler)
    daemon.setup_logging()

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

    # Process incoming requests
    while True:
        daemon.handle_request()
        if not daemon.timed_out or daemon.stored_password:
            continue
        # we timed out and we don't have a stored password, so we can exit
        # Keep running for at least 10 seconds make launchd happy.
        run_time = time.time() - start_time
        daemon.log.info("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.info('Nothing to do: exiting')
            break


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