#!/Library/installapplications/Python.framework/Versions/3.8/bin/python3 # encoding: utf-8 # # Copyright 2009-Present Erik Gomez. # # 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. # InstallApplications # This script uses munki's gurl.py to download the initial json and # subsequent packages securely, and then install them. This allows your DEP # bootstrap to be completely dynamic and easily updateable. # downloadfile function taken from: # https://gist.github.com/gregneagle/1816b650df8e3fbeb18f # gurl.py and gethash function taken from: # https://github.com/munki/munki # Notice a pattern? from distutils.version import LooseVersion from Foundation import NSLog from SystemConfiguration import SCDynamicStoreCopyConsoleUser import hashlib import json import optparse import os import plistlib import re import shutil import subprocess import sys import time import urllib.request, urllib.parse, urllib.error sys.path.append('/Library/installapplications') # PEP8 can really be annoying at times. import gurl # noqa g_dry_run = False def deplog(text): depnotify = '/private/var/tmp/depnotify.log' with open(depnotify, 'a+') as log: log.write(text + '\n') def iaslog(text): try: NSLog('[InstallApplications] ' + text) except Exception: print(text) pass def getconsoleuser(): cfuser = SCDynamicStoreCopyConsoleUser(None, None, None) return cfuser def pkgregex(pkgpath): try: # capture everything after last / in the pkg filepath pkgname = re.compile(r"[^/]+$").search(pkgpath).group(0) return pkgname except AttributeError as IndexError: return pkgpath def installpackage(packagepath): try: cmd = ['/usr/sbin/installer', '-verboseR', '-pkg', packagepath, '-target', '/'] if g_dry_run: iaslog('Dry run installing package: %s' % packagepath) return 0 proc = subprocess.Popen(cmd, shell=False, bufsize=-1, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, rcode = proc.communicate(), proc.returncode installlog = output[0].split('\n') # Filter all blank lines after the split. for line in [_f for _f in installlog if _f]: # Replace any instances of % with a space and any elipsis with # a blank line since NSLog can't handle these kinds of characters. # Hopefully this is the only bad characters we will ever run into. logline = line.replace('%', ' ').replace('\xe2\x80\xa6', '') iaslog(logline) return rcode except Exception: pass def checkreceipt(packageid): try: cmd = ['/usr/sbin/pkgutil', '--pkg-info-plist', packageid] proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output = proc.communicate() receiptout = output[0] if receiptout: plist = plistlib.readPlistFromString(receiptout) version = plist['pkg-version'] else: version = '0.0.0.0.0' return version except Exception: version = '0.0.0.0.0' return version def gethash(filename): hash_function = hashlib.sha256() if not os.path.isfile(filename): return 'NOT A FILE' fileref = open(filename, 'rb') while 1: chunk = fileref.read(2**16) if not chunk: break hash_function.update(chunk) fileref.close() return hash_function.hexdigest() def launchctl(*arg): # Use *arg to pass unlimited variables to command. cmd = arg run = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) output, err = run.communicate() return output def downloadfile(options): connection = gurl.Gurl.alloc().initWithOptions_(options) percent_complete = -1 bytes_received = 0 connection.start() try: filename = options['name'] except KeyError: iaslog('No \'name\' key defined in json for %s' % pkgregex(options['file'])) sys.exit(1) try: while not connection.isDone(): if connection.destination_path: # only print progress info if we are writing to a file if connection.percentComplete != -1: if connection.percentComplete != percent_complete: percent_complete = connection.percentComplete iaslog('Downloading %s - Percent complete: %s ' % ( filename, percent_complete)) elif connection.bytesReceived != bytes_received: bytes_received = connection.bytesReceived iaslog('Downloading %s - Bytes received: %s ' % ( filename, bytes_received)) except (KeyboardInterrupt, SystemExit): # safely kill the connection then fall through connection.cancel() except Exception: # too general, I know # Let us out! ... Safely! Unexpectedly quit dialogs are annoying ... connection.cancel() # Re-raise the error raise if connection.error is not None: iaslog('Error: %s %s ' % (str(connection.error.code()), str(connection.error.localizedDescription())) ) if connection.SSLerror: iaslog('SSL error: %s ' % (str(connection.SSLerror))) if connection.response is not None: iaslog('Status: %s ' % (str(connection.status))) iaslog('Headers: %s ' % (str(connection.headers))) if connection.redirection != []: iaslog('Redirection: %s ' % (str(connection.redirection))) def vararg_callback(option, opt_str, value, parser): # https://docs.python.org/3/library/optparse.html#callback-example-6- # variable-arguments assert value is None value = [] def floatable(str): try: float(str) return True except ValueError: return False for arg in parser.rargs: # stop on --foo like options if arg[:2] == "--" and len(arg) > 2: break value.append(arg) del parser.rargs[:len(value)] setattr(parser.values, option.dest, value) def runrootscript(pathname, donotwait): '''Runs script located at given pathname''' if g_dry_run: iaslog('Dry run executing root script: %s' % pathname) return True try: if donotwait: iaslog('Do not wait triggered') proc = subprocess.Popen(pathname) iaslog('Running Script: %s ' % (str(pathname))) else: proc = subprocess.Popen(pathname, stdout=subprocess.PIPE, stderr=subprocess.PIPE) iaslog('Running Script: %s ' % (str(pathname))) (out, err) = proc.communicate() if err and proc.returncode == 0: iaslog('Output from %s on stderr but ran successfully: %s' % (pathname, err)) elif proc.returncode > 0: iaslog('Received non-zero exit code: ' + str(err)) return False except OSError as err: iaslog('Failure running script: ' + str(err)) return False return True def runuserscript(iauserscriptpath): files = os.listdir(iauserscriptpath) for file in files: pathname = os.path.join(iauserscriptpath, file) if g_dry_run: iaslog('Dry run executing user script: %s' % pathname) os.remove(pathname) return True try: proc = subprocess.Popen(pathname, stdout=subprocess.PIPE, stderr=subprocess.PIPE) iaslog('Running Script: %s ' % (str(pathname))) (out, err) = proc.communicate() if err and proc.returncode == 0: iaslog( 'Output from %s on stderr but ran successfully: %s' % (pathname, err)) elif proc.returncode > 0: iaslog('Failure running script: ' + str(err)) return False except OSError as err: iaslog('Failure running script: ' + str(err)) return False os.remove(pathname) return True else: iaslog('No user scripts found!') return False def download_if_needed(item, stage, type, opts, depnotifystatus): # Check if the file exists and matches the expected hash. path = item['file'] name = item['name'] hash = item['hash'] itemurl = item['url'] while not (os.path.isfile(path) and hash == gethash(path)): # Check if additional headers are being passed and add # them to the dictionary. if opts.headers: item.update({'additional_headers': {'Authorization': opts.headers}}) # Download the file once: iaslog('Starting download: %s' % (urllib.parse.unquote(itemurl))) if opts.depnotify: if stage == 'setupassistant': iaslog('Skipping DEPNotify notification due to setupassistant.' ) else: if depnotifystatus: deplog('Status: Downloading %s' % (name)) downloadfile(item) # Wait half a second to process time.sleep(0.5) # Check the files hash and redownload until it's # correct. Bail after three times and log event. failsleft = 3 while not hash == gethash(path): iaslog('Hash failed for %s - received: %s expected' ': %s' % (name, gethash(path), hash)) downloadfile(item) failsleft -= 1 if failsleft == 0: iaslog('Hash retry failed for %s: exiting!' % name) cleanup(1) # Time to install. iaslog('Hash validated - received: %s expected: %s' % ( gethash(path), hash)) # Fix script permissions. if os.path.splitext(path)[1] != ".pkg": os.chmod(path, 0o755) if type == 'userscript': os.chmod(path, 0o777) def touch(path): try: touchfile = ['/usr/bin/touch', path] proc = subprocess.Popen(touchfile, stdout=subprocess.PIPE, stderr=subprocess.PIPE) touchfileoutput, err = proc.communicate() os.chmod(path, 0o777) return touchfileoutput except Exception: return None def cleanup(exit_code): # Attempt to remove the LaunchDaemon iaslog('Attempting to remove LaunchDaemon: ' + ialdpath) try: os.remove(ialdpath) except: # noqa pass # Attempt to remove the LaunchAgent iaslog('Attempting to remove LaunchAgent: ' + ialapath) try: os.remove(ialapath) except: # noqa pass # Attempt to remove the launchagent from the user's list iaslog('Targeting user id for LaunchAgent removal: ' + userid) iaslog('Attempting to remove LaunchAgent: ' + laidentifier) launchctl('/bin/launchctl', 'asuser', userid, '/bin/launchctl', 'remove', laidentifier) # Trigger a delayed reboot of 5 seconds if reboot: iaslog('Triggering reboot') rebootcmd = [ '/usr/bin/osascript', '-e', 'delay 5', '-e', 'tell application "System Events" to restart' ] try: subprocess.Popen(rebootcmd, preexec_fn=os.setpgrp) except: # noqa pass # Attempt to kill InstallApplications' path iaslog('Attempting to remove InstallApplications directory: ' + iapath) try: shutil.rmtree(iapath) except: # noqa pass iaslog('Attempting to remove LaunchDaemon: ' + ldidentifier) launchctl('/bin/launchctl', 'remove', ldidentifier) iaslog('Cleanup done. Exiting.') sys.exit(exit_code) def main(): # Options usage = '%prog [options]' o = optparse.OptionParser(usage=usage) o.add_option('--depnotify', default=None, dest="depnotify", action="callback", callback=vararg_callback, help=('Optional: Utilize DEPNotify and pass options to it.')) o.add_option('--headers', help=('Optional: Auth headers')) o.add_option('--jsonurl', help=('Required: URL to json file.')) o.add_option('--iapath', default='/Library/installapplications', help=('Optional: Specify InstallApplications package path.')) o.add_option('--ldidentifier', default='com.erikng.installapplications', help=('Optional: Specify LaunchDaemon identifier.')) o.add_option('--laidentifier', default='com.erikng.installapplications', help=('Optional: Specify LaunchAgent identifier.')) o.add_option('--reboot', default=False, help=('Optional: Trigger a reboot.'), action='store_true') o.add_option('--dry-run', help=('Optional: Dry run (for testing).'), action='store_true') o.add_option('--skip-validation', default=False, help=('Optional: Skip bootstrap.json validation.'), action='store_true') o.add_option('--userscript', default=None, help=('Optional: Trigger a user script run.'), action='store_true') opts, args = o.parse_args() # Dry run that doesn't actually run or install anything. if opts.dry_run: global g_dry_run g_dry_run = True # Check for root and json url. if opts.jsonurl: jsonurl = opts.jsonurl if not g_dry_run and (os.getuid() != 0): print('InstallApplications requires root!') sys.exit(1) else: if opts.userscript: pass else: iaslog('No JSON URL specified!') sys.exit(1) # Begin logging events iaslog('Beginning InstallApplications run') # installapplications variables global iapath iapath = opts.iapath iauserscriptpath = os.path.join(iapath, 'userscripts') iatmppath = '/var/tmp/installapplications' ialogpath = '/var/log/installapplications' iaslog('InstallApplications path: ' + str(iapath)) global ldidentifier ldidentifier = opts.ldidentifier ldidentifierplist = opts.ldidentifier + '.plist' global ialdpath ialdpath = os.path.join('/Library/LaunchDaemons', ldidentifierplist) iaslog('InstallApplications LaunchDaemon path: ' + str(ialdpath)) global laidentifier laidentifier = opts.laidentifier laidentifierplist = opts.laidentifier + '.plist' global ialapath ialapath = os.path.join('/Library/LaunchAgents', laidentifierplist) iaslog('InstallApplications LaunchAgent path: ' + str(ialapath)) depnotifystatus = True global userid userid = str(getconsoleuser()[1]) global reboot reboot = opts.reboot # hardcoded json fileurl path jsonpath = os.path.join(iapath, 'bootstrap.json') iaslog('InstallApplications json path: ' + str(jsonpath)) # User script touch path userscripttouchpath = '/var/tmp/installapplications/.userscript' if opts.userscript: iaslog('Running in userscript mode') uscript = runuserscript(iauserscriptpath) if uscript: os.remove(userscripttouchpath) sys.exit(0) else: iaslog('Failed to run script!') sys.exit(1) else: # Ensure the log path is writable by all before launchagent tries to do anything if os.path.isdir(ialogpath): os.chmod(ialogpath, 0o777) # Ensure the directories exist if not os.path.isdir(iauserscriptpath): for path in [iauserscriptpath, iatmppath]: if not os.path.isdir(path): os.makedirs(path) os.chmod(path, 0o777) # DEPNotify trigger commands that need to happen at the end of a run deptriggers = ['Command: Quit', 'Command: Restart', 'Command: Logout', 'DEPNotifyPath', 'DEPNotifyArguments', 'DEPNotifySkipStatus'] # Look for all the DEPNotify options but skip the ones that are usually # done after a full run. if opts.depnotify: for varg in opts.depnotify: notification = str(varg) if any(x in notification for x in deptriggers): if 'DEPNotifySkipStatus' in notification: depnotifystatus = False else: iaslog('Sending %s to DEPNotify' % (str(notification))) deplog(notification) # Make the temporary folder try: os.makedirs(iapath) except Exception: pass # json data for gurl download json_data = { 'url': jsonurl, 'file': jsonpath, 'name': 'Bootstrap.json' } # Grab auth headers if they exist and update the json_data dict. if opts.headers: headers = {'Authorization': opts.headers} json_data.update({'additional_headers': headers}) # Delete the bootstrap file if it exists, to ensure it's up to date. if not opts.skip_validation: if os.path.isfile(jsonpath): iaslog('Removing and redownloading bootstrap.json') os.remove(jsonpath) # If the file doesn't exist, grab it and wait half a second to save. while not os.path.isfile(jsonpath): iaslog('Starting download: %s' % (urllib.parse.unquote( json_data['url']))) downloadfile(json_data) time.sleep(0.5) # Load up file to grab all the items. iajson = json.loads(open(jsonpath).read()) # Set the stages stages = ['preflight', 'setupassistant', 'userland'] # Get the number of items for DEPNotify if opts.depnotify: numberofitems = 0 for stage in stages: if stage == 'setupassistant': iaslog('Skipping DEPNotify item count due to setupassistant.') else: # catch if there is a missing stage. mostly for preflight. try: numberofitems += int(len(iajson[stage])) except KeyError: iaslog('Malformed JSON - missing %s stage key' % stage) # Mulitply by two for download and installation status messages if depnotifystatus: deplog('Command: Determinate: %d' % (numberofitems*2)) # Process all stages for stage in stages: iaslog('Beginning %s' % (stage)) if stage == 'preflight': # Ensure we actually have a preflight key in the json try: iajson['preflight'] except KeyError: iaslog('No preflight stage found: skipping.') continue if stage == 'userland': # Open DEPNotify for the admin if they pass # condition. depnotifypath = None depnotifyarguments = None if opts.depnotify: for varg in opts.depnotify: depnstr = str(varg) if 'DEPNotifyPath:' in depnstr: depnotifypath = depnstr.split(' ', 1)[-1] if 'DEPNotifyArguments:' in depnstr: depnotifyarguments = depnstr.split(' ', 1)[-1] if depnotifypath: while (getconsoleuser()[0] is None or getconsoleuser()[0] == 'loginwindow' or getconsoleuser()[0] == '_mbsetupuser'): iaslog('Detected SetupAssistant in userland stage - ' 'delaying DEPNotify launch until user session.') time.sleep(1) iaslog('Creating DEPNotify Launcher') depnotifyscriptpath = os.path.join( iauserscriptpath, 'depnotifylauncher.py') if depnotifyarguments: if '-munki' in depnotifyarguments: # Touch Munki Logs if they do not exist so DEPNotify # can show them. mlogpath = '/Library/Managed Installs/Logs' mlogfile = os.path.join(mlogpath, 'ManagedSoftwareUpdate.log') if not os.path.isdir(mlogpath): os.makedirs(mlogpath, 0o755) if not os.path.isfile(mlogfile): touch(mlogfile) if len(depnotifyarguments) >= 2: totalarguments = [] splitarguments = depnotifyarguments.split(' ') for x in splitarguments: totalarguments.append(x) depnotifystring = 'depnotifycmd = ' \ """['/usr/bin/open', '""" + depnotifypath + "', '"\ + '--args' + "', '" + \ """', '""".join(map(str, totalarguments)) + "']" else: depnotifystring = 'depnotifycmd = ' \ """['/usr/bin/open', '""" + depnotifypath + "', '"\ + '--args' + """', '""" + depnotifyarguments + "']" else: depnotifystring = 'depnotifycmd = ' \ """['/usr/bin/open', '""" + depnotifypath + "']" iaslog('Launching DEPNotify with: %s' % (depnotifystring)) depnotifyscript = "#!/Library/installapplications/Python.framework/Versions/3.8/bin/python3" depnotifyscript += '\n' + "import subprocess" depnotifyscript += '\n' + depnotifystring depnotifyscript += '\n' + 'subprocess.call(depnotifycmd)' with open(depnotifyscriptpath, 'w') as f: f.write(depnotifyscript) os.chmod(depnotifyscriptpath, 0o777) touch(userscripttouchpath) while os.path.isfile(userscripttouchpath): iaslog('Waiting for DEPNotify script to complete') time.sleep(0.5) # Loop through the items and download/install/run them. for item in iajson[stage]: # Set the filepath, name and type. try: path = item['file'] name = item['name'] type = item['type'] except KeyError as e: iaslog('Invalid item %s: %s' % (repr(item), str(e))) continue iaslog('%s processing %s %s at %s' % (stage, type, name, path)) # On userland stage, we want to wait until we are actually # in the user's session. if stage == 'userland': if len(iajson['userland']) > 0: while (getconsoleuser()[0] is None or getconsoleuser()[0] == 'loginwindow' or getconsoleuser()[0] == '_mbsetupuser'): iaslog('Detected SetupAssistant in userland ' 'stage - delaying install until user ' 'session.') time.sleep(1) if type == 'package': packageid = item['packageid'] version = item['version'] try: pkg_required = item['required'] except KeyError: pkg_required = False # Compare version of package with installed version and ensure # pkg is not a required install if LooseVersion(checkreceipt(packageid)) >= LooseVersion( version) and not pkg_required: iaslog('Skipping %s - already installed.' % (name)) else: # Download the package if it isn't already on disk. download_if_needed(item, stage, type, opts, depnotifystatus) iaslog('Installing %s from %s' % (name, path)) if opts.depnotify: if stage == 'setupassistant': iaslog( 'Skipping DEPNotify notification due to ' 'setupassistant.') else: if depnotifystatus: deplog('Status: Installing: %s' % (name)) # Install the package installpackage(item['file']) elif type == 'rootscript': if 'url' in item: download_if_needed(item, stage, type, opts, depnotifystatus) iaslog('Starting root script: %s' % (path)) try: donotwait = item['donotwait'] except KeyError as e: donotwait = False if opts.depnotify: if depnotifystatus: deplog('Status: Installing: %s' % (name)) if stage == 'preflight': preflightrun = runrootscript(path, donotwait) if preflightrun: iaslog('Preflight passed all checks. Skipping run.') userid = str(getconsoleuser()[1]) cleanup(0) else: iaslog('Preflight did not pass all checks. ' 'Continuing run.') continue runrootscript(path, donotwait) elif type == 'userscript': if 'url' in item: download_if_needed(item, stage, type, opts, depnotifystatus) if stage == 'setupassistant': iaslog('Detected setupassistant and user script. ' 'User scripts cannot work in setupassistant stage! ' 'Removing %s' % (path)) os.remove(path) continue iaslog('Triggering LaunchAgent for user script: %s' % (path)) touch(userscripttouchpath) if opts.depnotify: if depnotifystatus: deplog('Status: Installing: %s' % (name)) while os.path.isfile(userscripttouchpath): iaslog('Waiting for user script to complete: %s' % (path)) time.sleep(0.5) # Trigger the final DEPNotify events if opts.depnotify: for varg in opts.depnotify: notification = str(varg) if any(x in notification for x in deptriggers): iaslog('Sending %s to DEPNotify' % (str(notification))) deplog(notification) else: iaslog( 'Skipping DEPNotify notification event due to completion.') # Cleanup and send good exit status cleanup(0) if __name__ == '__main__': main()