#!/usr/bin/python # encoding: utf-8 # # Copyright 2018-2019 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 import SocketServer import socket import plistlib import struct from munkilib import app_usage from munkilib import launchd from munkilib import prefs APPNAME = 'appusaged' VERSION = '0.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']) 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 = '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. 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 = plistlib.readPlistFromString(plist_string) except BaseException as err: self.log.error('Malformed request') self.request.send('ERROR:Malformed request\n') 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') self.request.send(''.join(['ERROR:%s\n' % e for e in errors])) 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' % result) except AppUsageHandlerError as err: self.request.send(u'ERROR:%s\n' % unicode(err)) except BaseException as err: self.log.error('Run failed: %s' % unicode(err)) self.request.send(u'ERROR:%s\n' % unicode(err)) except BaseException as err: self.log.error('Caught exception: %s' % repr(err)) self.request.send('ERROR:Caught exception: %s' % repr(err)) return 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 prefs.pref('LoggingLevel') > 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('%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(APPNAME) 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 = AppUsageDaemon(socket_fd, RunHandler) daemon.setup_logging() 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())