Files
munki/code/client/appusaged
Greg Neagle b9f9fffccc Revert "munki: rename "/usr/local/munki/python" symlink to "munki-python" (#997)"
This change is still a good future goal, but is causing problems that are too difficult to work around right now and is delaying the vital release of Munki 5.1 for Big Sur compatibility.

This reverts commit 3bb91cabca.
2020-09-15 09:04:47 -07:00

361 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 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())