mirror of
https://github.com/munki/munki.git
synced 2025-12-30 19:20:10 -06:00
337 lines
11 KiB
Python
Executable File
337 lines
11 KiB
Python
Executable File
#!/usr/bin/python
|
|
# encoding: utf-8
|
|
#
|
|
# Copyright 2018 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.
|
|
"""
|
|
|
|
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 >> sys.stderr, '%s must be run as root.' % APPNAME
|
|
# 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 >> sys.stderr, '%s must be owned by root.' % exepath
|
|
path_ok = False
|
|
if info.st_gid not in (wheel_gid, admin_gid):
|
|
print >> sys.stderr, '%s must have group wheel or admin.' % exepath
|
|
path_ok = False
|
|
if info.st_mode & stat.S_IWOTH:
|
|
print >> sys.stderr, '%s mustn\'t be world writeable.' % exepath
|
|
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 >> sys.stderr, 'No socket provided to us by launchd'
|
|
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())
|