Files
munki/code/client/appusaged
2020-01-01 08:53:37 -08:00

349 lines
12 KiB
Python
Executable File

#!/usr/local/munki/python
# encoding: utf-8
#
# Copyright 2018-2020 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 prefs
from munkilib.wrappers import readPlistFromString, PlistError, unicode_or_str
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'])
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.
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 = (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'))
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 int(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.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 = 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())