├── .gitignore ├── support-files ├── Outset.png └── com.chilcote.outset.plist ├── custom-ondemand ├── scripts │ └── postinstall ├── pkgroot │ └── usr │ │ └── local │ │ └── outset │ │ ├── login-every │ │ └── login-every_example.py │ │ ├── login-once │ │ └── login-once_example.py │ │ └── on-demand │ │ └── on-demand_example.sh └── Makefile ├── custom-outset ├── pkgroot │ └── usr │ │ └── local │ │ └── outset │ │ ├── boot-every │ │ └── boot-every_example.py │ │ ├── boot-once │ │ └── boot-once_example.py │ │ ├── login-every │ │ └── login-every_example.py │ │ ├── login-once │ │ └── login-once_example.py │ │ ├── login-privileged-every │ │ └── login-privileged-every_example.py │ │ └── login-privileged-once │ │ └── login-privileged-once_example.py └── Makefile ├── Requirements.plist ├── pkgroot ├── Library │ ├── LaunchAgents │ │ ├── com.github.outset.login.plist │ │ └── com.github.outset.on-demand.plist │ └── LaunchDaemons │ │ ├── com.github.outset.boot.plist │ │ ├── com.github.outset.cleanup.plist │ │ └── com.github.outset.login-privileged.plist └── usr │ └── local │ └── outset │ └── outset ├── .flake8 ├── Makefile ├── scripts └── postinstall └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | sample* 3 | *.pkg 4 | *.dmg 5 | *.pyc 6 | outset.wiki 7 | -------------------------------------------------------------------------------- /support-files/Outset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chilcote/outset/HEAD/support-files/Outset.png -------------------------------------------------------------------------------- /custom-ondemand/scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | [[ $3 != "/" ]] && exit 0 4 | 5 | /usr/bin/touch /private/tmp/.com.github.outset.ondemand.launchd 6 | 7 | exit 0 8 | -------------------------------------------------------------------------------- /custom-ondemand/pkgroot/usr/local/outset/login-every/login-every_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts and/or profiles 4 | # which you want to run at every login. 5 | 6 | print("These scripts will run at every login.") 7 | -------------------------------------------------------------------------------- /custom-ondemand/pkgroot/usr/local/outset/login-once/login-once_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts and/or profiles 4 | # which you want to run at login, only once. 5 | 6 | print("These scripts will run at login, once.") 7 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/boot-every/boot-every_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts, profiles, and/or packages 4 | # which you want to run at every boot. 5 | 6 | print("These scripts will run at every boot.") 7 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/boot-once/boot-once_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts, profiles, and/or packages 4 | # which you want to run at boot, only once. 5 | 6 | print("These scripts will run at boot, only once.") 7 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/login-every/login-every_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts and/or profiles 4 | # which you want to run at every login, in the user context. 5 | 6 | print("These scripts will run at every login, in the user context.") 7 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/login-once/login-once_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your scripts and/or profiles 4 | # which you want to run at login, in the user context, only once. 5 | 6 | print("These scripts will run at login, in the user context, once.") 7 | -------------------------------------------------------------------------------- /Requirements.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | os 6 | 7 | 10.15 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/login-privileged-every/login-privileged-every_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your packages, scripts, and/or profiles 4 | # which you want to run at every login, in the root context. 5 | 6 | print("These scripts will run at every login, in the root context.") 7 | -------------------------------------------------------------------------------- /custom-outset/pkgroot/usr/local/outset/login-privileged-once/login-privileged-once_example.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | # Replace this script with your packages, scripts, and/or profiles 4 | # which you want to run at login, in the root context, only once. 5 | 6 | print("These scripts will run at login, in the root context, once.") 7 | -------------------------------------------------------------------------------- /support-files/com.chilcote.outset.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ignored_users 6 | 7 | super.sekrit.admin 8 | 9 | network_timeout 10 | 180 11 | wait_for_network 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /custom-ondemand/pkgroot/usr/local/outset/on-demand/on-demand_example.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Replace this script with your scripts 4 | # which you want to run on demand. 5 | 6 | /usr/bin/osascript -e 'display notification "Replace this script with scripts of your own!" with title "Outset"' 7 | 8 | # invoke login-once scripts during this on-demand run 9 | /usr/local/outset/outset --login-once 10 | 11 | # invoke login-every scripts during this on-demand run 12 | /usr/local/outset/outset --login-every 13 | -------------------------------------------------------------------------------- /pkgroot/Library/LaunchAgents/com.github.outset.login.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.github.outset.login 7 | ProgramArguments 8 | 9 | /usr/local/outset/outset 10 | --login 11 | 12 | RunAtLoad 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /pkgroot/Library/LaunchDaemons/com.github.outset.boot.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Label 6 | com.github.outset.boot 7 | ProgramArguments 8 | 9 | /usr/local/outset/outset 10 | --boot 11 | 12 | RunAtLoad 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /custom-ondemand/Makefile: -------------------------------------------------------------------------------- 1 | PKGTITLE="outset-ondemand" 2 | PKGVERSION="1.0.0" 3 | PKGID=com.github.outset.ondemand 4 | PROJECT="outset-ondemand" 5 | 6 | ################################################# 7 | 8 | ##Help - Show this help menu 9 | help: 10 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 11 | 12 | ## clean - Clean up temporary working directories 13 | clean: 14 | rm -f ./outset*.pkg 15 | rm -f ./pkgroot/usr/local/outset/on-demand/*.pyc 16 | 17 | ## pkg - Create a package using pkgbuild 18 | pkg: clean 19 | pkgbuild --root pkgroot --scripts scripts --identifier ${PKGID} --version ${PKGVERSION} --ownership recommended ./${PKGTITLE}-${PKGVERSION}.pkg 20 | -------------------------------------------------------------------------------- /pkgroot/Library/LaunchDaemons/com.github.outset.cleanup.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KeepAlive 6 | 7 | PathState 8 | 9 | /private/tmp/.com.github.outset.cleanup.launchd 10 | 11 | 12 | 13 | Label 14 | com.github.outset.cleanup 15 | OnDemand 16 | 17 | ProgramArguments 18 | 19 | /usr/local/outset/outset 20 | --cleanup 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /pkgroot/Library/LaunchAgents/com.github.outset.on-demand.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KeepAlive 6 | 7 | PathState 8 | 9 | /private/tmp/.com.github.outset.ondemand.launchd 10 | 11 | 12 | 13 | Label 14 | com.github.outset.on-demand 15 | OnDemand 16 | 17 | ProgramArguments 18 | 19 | /usr/local/outset/outset 20 | --on-demand 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /pkgroot/Library/LaunchDaemons/com.github.outset.login-privileged.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | KeepAlive 6 | 7 | PathState 8 | 9 | /private/tmp/.com.github.outset.login-privileged.launchd 10 | 11 | 12 | 13 | Label 14 | com.github.outset.login-privileged 15 | OnDemand 16 | 17 | ProgramArguments 18 | 19 | /usr/local/outset/outset 20 | --login-privileged 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /custom-outset/Makefile: -------------------------------------------------------------------------------- 1 | PKGTITLE="outset-custom" 2 | PKGVERSION="1.0.0" 3 | PKGID=com.github.outset.custom 4 | PROJECT="outset-custom" 5 | 6 | ################################################# 7 | 8 | ##Help - Show this help menu 9 | help: 10 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 11 | 12 | ## clean - Clean up temporary working directories 13 | clean: 14 | rm -f ./outset*.pkg 15 | rm -f ./pkgroot/usr/local/outset/*/*.pyc 16 | 17 | ## pkg - Create a package using pkgbuild 18 | pkg: clean 19 | pkgbuild --root pkgroot --identifier ${PKGID} --version ${PKGVERSION} --ownership recommended ./${PKGTITLE}-${PKGVERSION}.component.pkg 20 | productbuild --identifier ${PKGID}.${PKGVERSION} --package ./${PKGTITLE}-${PKGVERSION}.component.pkg ./${PKGTITLE}-${PKGVERSION}.pkg 21 | rm -f ./${PKGTITLE}-${PKGVERSION}.component.pkg 22 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | select = B,C,E,F,P,W,B9 3 | max-line-length = 80 4 | ### DEFAULT IGNORES FOR 4-space INDENTED PROJECTS ### 5 | # E127, E128 are hard to silence in certain nested formatting situations. 6 | # E203 doesn't work for slicing 7 | # E265, E266 talk about comment formatting which is too opinionated. 8 | # E402 warns on imports coming after statements. There are important use cases 9 | # that require statements before imports. 10 | # E501 is not flexible enough, we're using B950 instead. 11 | # E722 is a duplicate of B001. 12 | # P207 is a duplicate of B003. 13 | # P208 is a duplicate of C403. 14 | # W503 talks about operator formatting which is too opinionated. 15 | ignore = E127, E128, E203, E265, E266, E402, E501, E722, P207, P208, W503 16 | ### DEFAULT IGNORES FOR 2-space INDENTED PROJECTS (uncomment) ### 17 | # ignore = E111, E114, E121, E127, E128, E265, E266, E402, E501, P207, P208, W503 18 | exclude = 19 | .git, 20 | .hg, 21 | # This will be fine-tuned over time 22 | max-complexity = 65 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PKGTITLE="outset" 2 | PKGVERSION="3.0.3" 3 | PKGID=com.github.outset 4 | PROJECT="outset" 5 | 6 | ################################################# 7 | 8 | ##Help - Show this help menu 9 | help: 10 | @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' 11 | 12 | ## clean - Clean up temporary working directories 13 | clean: 14 | rm -f ./outset*.{dmg,pkg} 15 | 16 | ## pkg - Create a package using pkgbuild 17 | pkg: clean 18 | pkgbuild --root pkgroot --scripts scripts --identifier ${PKGID} --version ${PKGVERSION} --ownership recommended ./${PKGTITLE}-${PKGVERSION}.component.pkg 19 | productbuild --identifier ${PKGID}.${PKGVERSION} --product Requirements.plist --package ./${PKGTITLE}-${PKGVERSION}.component.pkg ./${PKGTITLE}-${PKGVERSION}.pkg 20 | rm -f ./${PKGTITLE}-${PKGVERSION}.component.pkg 21 | 22 | ## dmg - Wrap the package inside a dmg 23 | dmg: pkg 24 | rm -f ./${PROJECT}*.dmg 25 | rm -rf /tmp/${PROJECT}-build 26 | mkdir -p /tmp/${PROJECT}-build/ 27 | cp ./README.md /tmp/${PROJECT}-build 28 | cp -R ./${PKGTITLE}-${PKGVERSION}.pkg /tmp/${PROJECT}-build 29 | hdiutil create -srcfolder /tmp/${PROJECT}-build -volname "${PROJECT}" -format UDZO -o ${PROJECT}-${PKGVERSION}.dmg 30 | rm -rf /tmp/${PROJECT}-build 31 | -------------------------------------------------------------------------------- /scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -x 3 | 4 | # Only proceed if we are installing on the booted volume 5 | [[ $3 != "/" ]] && exit 0 6 | 7 | # Let's play python roulette, and choose from some popular options, in this order: 8 | # 1. python.org https://www.python.org/downloads/ 9 | # 2. MacAdmins https://github.com/macadmins/python 10 | # 3. Munki https://github.com/munki/munki 11 | # If none of these are on disk, then fall back to Apple's system python, 12 | # which can be installed via the Command Line Tools. 13 | 14 | # "What about python 2, which Apple still ships," you ask? 15 | # Outset does not support python 2, which was sunsetted on Jan 1, 2020. 16 | # See https://www.python.org/doc/sunset-python-2/. 17 | # If you choose to continue to use python 2, you'll want to create the symlink via other means, 18 | # with something like: /bin/ln -s /usr/bin/python /usr/local/outset/python3 19 | 20 | OUTSET_PYTHON=/usr/local/outset/python3 21 | ORG_PYTHON=/usr/local/bin/python3 22 | MACADMINS_PYTHON=/usr/local/bin/managed_python3 23 | MUNKI_MUNKI_PYTHON=/usr/local/munki/munki-python 24 | MUNKI_PYTHON=/usr/local/munki/python 25 | SYSTEM_PYTHON=/usr/bin/python3 26 | 27 | # Delete existing symlink 28 | [[ -L "${OUTSET_PYTHON}" ]] && /bin/rm "${OUTSET_PYTHON}" 29 | 30 | if [[ -L "${ORG_PYTHON}" ]]; then 31 | /bin/ln -s "${ORG_PYTHON}" "${OUTSET_PYTHON}" 32 | elif [[ -L "${MACADMINS_PYTHON}" ]]; then 33 | /bin/ln -s "${MACADMINS_PYTHON}" "${OUTSET_PYTHON}" 34 | elif [[ -L "${MUNKI_MUNKI_PYTHON}" ]]; then 35 | /bin/ln -s "${MUNKI_MUNKI_PYTHON}" "${OUTSET_PYTHON}" 36 | elif [[ -L "${MUNKI_PYTHON}" ]]; then 37 | /bin/ln -s "${MUNKI_PYTHON}" "${OUTSET_PYTHON}" 38 | else 39 | /bin/ln -s "${SYSTEM_PYTHON}" "${OUTSET_PYTHON}" 40 | fi 41 | 42 | # Load the LaunchDaemons 43 | /bin/launchctl load /Library/LaunchDaemons/com.github.outset.boot.plist 44 | /bin/launchctl load /Library/LaunchDaemons/com.github.outset.cleanup.plist 45 | /bin/launchctl load /Library/LaunchDaemons/com.github.outset.login-privileged.plist 46 | 47 | # Load the LaunchAgents 48 | 49 | user=$(/bin/echo "show State:/Users/ConsoleUser" | /usr/sbin/scutil | /usr/bin/awk '/Name :/&&!/loginwindow/{print $3}') 50 | console_user_uid=$(stat -f%u /dev/console) 51 | [[ -z "$user" ]] && exit 0 52 | /bin/launchctl asuser "$console_user_uid" /bin/launchctl load /Library/LaunchAgents/com.github.outset.login.plist 53 | /bin/launchctl asuser "$console_user_uid" /bin/launchctl load /Library/LaunchAgents/com.github.outset.on-demand.plist 54 | 55 | exit 0 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Outset 2 | ====== 3 | 4 | > NOTE: This instance of Outset is decommissioned and you should start using the Swift-based [Outset 4.0 available on the macadmins repo](https://github.com/macadmins/outset). 5 | 6 | Outset is a script which automatically processes packages, profiles, and scripts during the boot sequence, user logins, or on demand. 7 | 8 | Requirements 9 | ------------ 10 | + macOS 10.15+ 11 | + python 3.7+ 12 | 13 | If you need to support 10.14 or lower, stick with the 2.x version. 14 | 15 | python3 can be installed from one of these sources: 16 | - [python.org](https://www.python.org/downloads/) 17 | - [MacAdmins](https://github.com/macadmins/python) 18 | - [Munki](https://github.com/munki/munki) 19 | 20 | If none of these are on disk, then fall back to Apple's system python3, which can be installed via the Command Line Tools. 21 | 22 | Outset no longer supports python 2, which was [sunsetted on Jan 1, 2020](https://www.python.org/doc/sunset-python-2/). If you choose to continue to use python 2, you'll want to create the symlink via other means, with something like: 23 | 24 | `/bin/ln -s /usr/bin/python /usr/local/outset/python3` 25 | 26 | Usage 27 | ----- 28 | 29 | usage: outset [-h] 30 | (--boot | --login | --login-privileged | --on-demand | --login-every | --login-once | --cleanup | --version | --add-ignored-user username | --remove-ignored-user username | --add-override scripts | --remove-override scripts) 31 | 32 | This script automatically processes packages, profiles, and/or scripts at 33 | boot, on demand, and/or login. 34 | 35 | optional arguments: 36 | -h, --help show this help message and exit 37 | --boot Used by launchd for scheduled runs at boot 38 | --login Used by launchd for scheduled runs at login 39 | --login-privileged Used by launchd for scheduled privileged runs at login 40 | --on-demand Process scripts on demand 41 | --login-every Manually process scripts in login-every 42 | --login-once Manually process scripts in login-once 43 | --cleanup Used by launchd to clean up on-demand dir 44 | --version Show version number 45 | --add-ignored-user username 46 | Add user to ignored list 47 | --remove-ignored-user username 48 | Remove user from ignored list 49 | --add-override scripts 50 | Add scripts to override list 51 | --remove-override scripts 52 | Remove scripts from override list 53 | 54 | See the [wiki](https://github.com/chilcote/outset/wiki) for info on how to use Outset. 55 | 56 | Credits 57 | ------- 58 | This script was an excuse for me to try to learn python. I learn best when I can pull apart existing scripts. As such, this script is heavily based on the great work by [Nate Walck](https://github.com/natewalck/Scripts/blob/master/scriptRunner.py), [Allister Banks](https://gist.github.com/arubdesu/8271ba29ac5aff8f982c), [Rich Trouton](https://github.com/rtrouton/First-Boot-Package-Install), [Graham Gilbert](https://github.com/grahamgilbert/first-boot-pkg/blob/master/Resources/first-boot), and [Greg Neagle](https://github.com/munki/munki/blob/master/code/client/managedsoftwareupdate#L87). 59 | 60 | Special thanks to @homebysix for working on the python3 compatibility release. 61 | 62 | License 63 | ------- 64 | 65 | Copyright Joseph Chilcote 66 | 67 | Licensed under the Apache License, Version 2.0 (the "License"); 68 | you may not use this file except in compliance with the License. 69 | You may obtain a copy of the License at 70 | 71 | http://www.apache.org/licenses/LICENSE-2.0 72 | 73 | Unless required by applicable law or agreed to in writing, software 74 | distributed under the License is distributed on an "AS IS" BASIS, 75 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 76 | See the License for the specific language governing permissions and 77 | limitations under the License. 78 | -------------------------------------------------------------------------------- /pkgroot/usr/local/outset/outset: -------------------------------------------------------------------------------- 1 | #!/usr/local/outset/python3 2 | 3 | """ 4 | This script automatically processes packages, profiles, and/or scripts at 5 | boot, on demand, and/or login. 6 | """ 7 | 8 | ############################################################################## 9 | # Copyright 2014-Present Joseph Chilcote 10 | # 11 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 12 | # use this file except in compliance with the License. You may obtain a copy 13 | # of the License at 14 | # 15 | # http://www.apache.org/licenses/LICENSE-2.0 16 | # 17 | # Unless required by applicable law or agreed to in writing, software 18 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | # License for the specific language governing permissions and limitations 21 | # under the License. 22 | ############################################################################## 23 | 24 | from __future__ import absolute_import, division, print_function, unicode_literals 25 | 26 | import argparse 27 | import datetime 28 | import logging 29 | import os 30 | import platform 31 | import plistlib 32 | import pwd 33 | import shutil 34 | import subprocess 35 | import sys 36 | import time 37 | import warnings 38 | from distutils.version import StrictVersion as version 39 | from platform import mac_ver 40 | from stat import S_IWOTH, S_IXOTH 41 | 42 | __author__ = "Joseph Chilcote (chilcote@gmail.com)" 43 | __version__ = "3.0.3" 44 | 45 | if not sys.warnoptions: 46 | warnings.simplefilter("ignore") 47 | 48 | outset_dir = "/usr/local/outset" 49 | boot_every_dir = os.path.join(outset_dir, "boot-every") 50 | boot_once_dir = os.path.join(outset_dir, "boot-once") 51 | login_every_dir = os.path.join(outset_dir, "login-every") 52 | login_once_dir = os.path.join(outset_dir, "login-once") 53 | login_privileged_every_dir = os.path.join(outset_dir, "login-privileged-every") 54 | login_privileged_once_dir = os.path.join(outset_dir, "login-privileged-once") 55 | on_demand_dir = os.path.join(outset_dir, "on-demand") 56 | share_dir = os.path.join(outset_dir, "share") 57 | outset_preferences = os.path.join(share_dir, "com.chilcote.outset.plist") 58 | on_demand_trigger = "/private/tmp/.com.github.outset.ondemand.launchd" 59 | login_privileged_trigger = "/private/tmp/.com.github.outset.login-privileged.launchd" 60 | cleanup_trigger = "/private/tmp/.com.github.outset.cleanup.launchd" 61 | 62 | if os.geteuid() == 0: 63 | log_file = "/var/log/outset.log" 64 | console_uid = os.stat('/dev/console').st_uid 65 | run_once_plist = os.path.join( 66 | "/usr/local/outset/share", 67 | "com.github.outset.once." + str(console_uid) + ".plist", 68 | ) 69 | else: 70 | if not os.path.exists(os.path.expanduser("~/Library/Logs")): 71 | os.makedirs(os.path.expanduser("~/Library/Logs")) 72 | log_file = os.path.expanduser("~/Library/Logs/outset.log") 73 | run_once_plist = os.path.expanduser( 74 | "~/Library/Preferences/com.github.outset.once.plist" 75 | ) 76 | 77 | logging.basicConfig( 78 | format="%(asctime)s - %(levelname)s: %(message)s", 79 | datefmt="%Y-%m-%d %I:%M:%S %p", 80 | level=logging.DEBUG, 81 | filename=log_file, 82 | ) 83 | stdout_logging = logging.StreamHandler() 84 | stdout_logging.setFormatter(logging.Formatter()) 85 | logging.getLogger().addHandler(stdout_logging) 86 | 87 | 88 | def network_up(): 89 | """Returns True if network interfaces are none of localhost or 0.0.0.0""" 90 | cmd = ["/sbin/ifconfig", "-a", "inet"] 91 | out = subprocess.check_output(cmd).decode("utf-8") 92 | for line in out.splitlines(): 93 | if "inet" in line: 94 | address = line.split()[1] 95 | if not address in ["127.0.0.1", "0.0.0.0"]: 96 | return True 97 | return False 98 | 99 | 100 | def wait_for_network(timeout): 101 | """Waits for a valid IP before continuing""" 102 | for x in range(timeout): 103 | if network_up(): 104 | return True 105 | else: 106 | logging.info("Waiting for network") 107 | time.sleep(10) 108 | 109 | 110 | def disable_loginwindow(): 111 | """Disables the loginwindow process""" 112 | logging.info("Disabling loginwindow process") 113 | cmd = [ 114 | "/bin/launchctl", 115 | "unload", 116 | "/System/Library/LaunchDaemons/com.apple.loginwindow.plist", 117 | ] 118 | subprocess.call(cmd) 119 | 120 | 121 | def enable_loginwindow(): 122 | """Enables the loginwindow process""" 123 | logging.info("Enabling loginwindow process") 124 | cmd = [ 125 | "/bin/launchctl", 126 | "load", 127 | "/System/Library/LaunchDaemons/com.apple.loginwindow.plist", 128 | ] 129 | subprocess.call(cmd) 130 | 131 | 132 | def get_hardwaremodel(): 133 | """Returns the hardware model of the Mac""" 134 | cmd = ["/usr/sbin/sysctl", "-n", "hw.model"] 135 | return subprocess.check_output(cmd).decode('utf-8').strip() 136 | 137 | 138 | def get_serialnumber(): 139 | """Returns the serial number of the Mac""" 140 | out = subprocess.check_output( 141 | ["/usr/sbin/ioreg", "-c", "IOPlatformExpertDevice"] 142 | ).decode('utf-8') 143 | serial_line = [x for x in out.splitlines() if "IOPlatformSerialNumber" in x][0] 144 | return serial_line.split()[-1].strip('"') 145 | 146 | 147 | def get_buildversion(): 148 | """Returns the os build version of the Mac""" 149 | cmd = ["/usr/sbin/sysctl", "-n", "kern.osversion"] 150 | return subprocess.check_output(cmd).decode('utf-8').strip() 151 | 152 | 153 | def get_osversion(): 154 | """Returns macOS version, may be inconsistent depending on interpreter used""" 155 | return platform.mac_ver()[0] 156 | 157 | 158 | def sys_report(): 159 | """Logs system information to log file""" 160 | logging.debug("Model: %s", get_hardwaremodel()) 161 | logging.debug("Serial: %s", get_serialnumber()) 162 | logging.debug("OS: %s", get_osversion()) 163 | logging.debug("Build: %s", get_buildversion()) 164 | 165 | 166 | def cleanup(pathname): 167 | """Deletes given script""" 168 | try: 169 | os.remove(pathname) 170 | except: 171 | shutil.rmtree(pathname) 172 | 173 | 174 | def mount_dmg(dmg): 175 | """Attaches dmg""" 176 | dmg_path = os.path.join(dmg) 177 | cmd = [ 178 | "/usr/bin/hdiutil", 179 | "attach", 180 | "-nobrowse", 181 | "-noverify", 182 | "-noautoopen", 183 | dmg_path, 184 | ] 185 | logging.info("Attaching %s", dmg_path) 186 | out = subprocess.check_output(cmd).decode('utf-8') 187 | return out.split("\n")[-2].split("\t")[-1] 188 | 189 | 190 | def detach_dmg(dmg_mount): 191 | """Detaches dmg""" 192 | logging.info("Detaching %s", dmg_mount) 193 | cmd = ["/usr/bin/hdiutil", "detach", "-force", dmg_mount] 194 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 195 | (_, err) = proc.communicate() 196 | if err: 197 | logging.error("Unable to detach %s: %s", dmg_mount, err.decode('utf-8')) 198 | 199 | 200 | def check_perms(pathname): 201 | mode = os.stat(pathname).st_mode 202 | owner = os.stat(pathname).st_uid 203 | if pathname.lower().endswith(("pkg", "mpkg", "dmg", "mobileconfig")): 204 | if owner == 0 and not (mode & S_IWOTH): 205 | return True 206 | else: 207 | if owner == 0 and (mode & S_IXOTH) and not (mode & S_IWOTH): 208 | return True 209 | return False 210 | 211 | 212 | def install_package(pkg): 213 | """Installs pkg onto boot drive""" 214 | if pkg.lower().endswith("dmg"): 215 | dmg_mount = mount_dmg(pkg) 216 | for f in os.listdir(dmg_mount): 217 | if f.lower().endswith(("pkg", "mpkg")): 218 | pkg_to_install = os.path.join(dmg_mount, f) 219 | elif pkg.lower().endswith(("pkg", "mpkg")): 220 | dmg_mount = False 221 | pkg_to_install = pkg 222 | logging.info("Installing %s", pkg_to_install) 223 | cmd = ["/usr/sbin/installer", "-pkg", pkg_to_install, "-target", "/"] 224 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 225 | (_, err) = proc.communicate() 226 | if err: 227 | logging.info("Failure installing %s: %s", pkg_to_install, err.decode('utf-8')) 228 | return False 229 | if dmg_mount: 230 | time.sleep(5) 231 | detach_dmg(dmg_mount) 232 | return True 233 | 234 | 235 | def install_profile(pathname): 236 | """Install mobileconfig located at given pathname""" 237 | # profiles has new verbs in 10.13. 238 | if version(mac_ver()[0]) >= version("10.13"): 239 | cmd = ["/usr/bin/profiles", "install", "-path=%s" % pathname] 240 | else: 241 | cmd = ["/usr/bin/profiles", "-IF", pathname] 242 | 243 | try: 244 | proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 245 | logging.info("Installing profile %s", pathname) 246 | (_, err) = proc.communicate() 247 | if err: 248 | logging.error("Failure processing %s: %s", pathname, err.decode('utf-8')) 249 | return False 250 | except OSError as err: 251 | logging.error("Failure processing %s: %s", pathname, err.decode('utf-8')) 252 | return False 253 | return True 254 | 255 | 256 | def run_script(pathname): 257 | """Runs script located at given pathname""" 258 | logging.info("Processing %s", pathname) 259 | # first attempt, mostly via https://stackoverflow.com/a/4760517 260 | try: 261 | result = subprocess.run( 262 | pathname, stdout=subprocess.PIPE, stderr=subprocess.PIPE 263 | ) 264 | if result.stderr and result.returncode == 0: 265 | logging.info( 266 | "Output from %s on stderr but it still ran successfully: %s", 267 | pathname, 268 | result.stderr.decode('utf-8'), 269 | ) 270 | elif result.returncode > 0: 271 | logging.error("Failure processing %s: %s", pathname, result.stderr.decode('utf-8')) 272 | return False 273 | except OSError as err: 274 | logging.error("Failure processing %s: %s", pathname, err.decode('utf-8')) 275 | return False 276 | return True 277 | 278 | 279 | def process_items(path, delete_items=False, once=False, override={}): 280 | """Processes scripts/packages to run""" 281 | 282 | if not os.path.exists(path): 283 | logging.error("%s does not exist. Exiting", path) 284 | exit(1) 285 | 286 | items_to_process = [] 287 | packages = [] 288 | scripts = [] 289 | profiles = [] 290 | d = {} 291 | 292 | for dirpath, _, files in os.walk(path): 293 | items_to_process.extend(os.path.join(dirpath, f) for f in files) 294 | 295 | items_to_process = sorted( 296 | items_to_process, 297 | key=lambda file: (os.path.dirname(file), os.path.basename(file)), 298 | ) 299 | 300 | for pathname in items_to_process: 301 | if check_perms(pathname): 302 | if pathname.lower().endswith(("pkg", "mpkg", "dmg")): 303 | packages.append(pathname) 304 | elif pathname.lower().endswith("mobileconfig"): 305 | profiles.append(pathname) 306 | else: 307 | scripts.append(pathname) 308 | else: 309 | logging.error("Bad permissions: %s", pathname) 310 | 311 | if once: 312 | try: 313 | with open(run_once_plist, 'rb') as fp: 314 | d = plistlib.load(fp) 315 | except: 316 | d = {} 317 | 318 | for package in packages: 319 | if once: 320 | if package not in d: 321 | if install_package(package): 322 | d[package] = datetime.datetime.now() 323 | else: 324 | if package in override: 325 | if override[package] > d[package]: 326 | if install_package(package): 327 | d[package] = datetime.datetime.now() 328 | else: 329 | install_package(package) 330 | if delete_items: 331 | cleanup(package) 332 | 333 | for profile in profiles: 334 | if once: 335 | if profile not in d: 336 | if install_profile(profile): 337 | d[profile] = datetime.datetime.now() 338 | else: 339 | if profile in override: 340 | if override[profile] > d[profile]: 341 | if install_profile(profile): 342 | d[profile] = datetime.datetime.now() 343 | else: 344 | install_profile(profile) 345 | if delete_items: 346 | cleanup(profile) 347 | 348 | for script in scripts: 349 | if once: 350 | if script not in d: 351 | if run_script(script): 352 | d[script] = datetime.datetime.now() 353 | else: 354 | if script in override: 355 | if override[script] > d[script]: 356 | if run_script(script): 357 | d[script] = datetime.datetime.now() 358 | else: 359 | run_script(script) 360 | if delete_items: 361 | cleanup(script) 362 | 363 | if d: 364 | with open(run_once_plist, 'wb') as fp: 365 | plistlib.dump(d, fp) 366 | 367 | def main(): 368 | """Main method""" 369 | 370 | parser = argparse.ArgumentParser( 371 | description="This script automatically \ 372 | processes packages, profiles, and/or scripts at boot, on demand,\ 373 | and/or login." 374 | ) 375 | group = parser.add_mutually_exclusive_group(required=True) 376 | group.add_argument( 377 | "--boot", action="store_true", help="Used by launchd for scheduled runs at boot" 378 | ) 379 | group.add_argument( 380 | "--login", 381 | action="store_true", 382 | help="Used by launchd for scheduled runs at login", 383 | ) 384 | group.add_argument( 385 | "--login-privileged", 386 | action="store_true", 387 | help="Used by launchd for scheduled privileged runs at login", 388 | ) 389 | group.add_argument( 390 | "--on-demand", action="store_true", help="Process scripts on demand" 391 | ) 392 | group.add_argument( 393 | "--login-every", 394 | action="store_true", 395 | help="Manually process scripts in login-every", 396 | ) 397 | group.add_argument( 398 | "--login-once", 399 | action="store_true", 400 | help="Manually process scripts in login-once", 401 | ) 402 | group.add_argument( 403 | "--cleanup", 404 | action="store_true", 405 | help="Used by launchd to clean up on-demand dir", 406 | ) 407 | group.add_argument("--version", action="store_true", help="Show version number") 408 | group.add_argument( 409 | "--add-ignored-user", 410 | action="append", 411 | metavar="username", 412 | help="Add user to ignored list", 413 | ) 414 | group.add_argument( 415 | "--remove-ignored-user", 416 | action="append", 417 | metavar="username", 418 | help="Remove user from ignored list", 419 | ) 420 | group.add_argument( 421 | "--add-override", 422 | action="append", 423 | metavar="scripts", 424 | help="Add scripts to override list", 425 | ) 426 | group.add_argument( 427 | "--remove-override", 428 | action="append", 429 | metavar="scripts", 430 | help="Remove scripts from override list", 431 | ) 432 | args = parser.parse_args() 433 | 434 | loginwindow = True 435 | console_user = pwd.getpwuid(os.getuid())[0] 436 | network_wait = True 437 | network_timeout = 180 438 | ignored_users = [] 439 | override_login_once = {} 440 | continue_firstboot = True 441 | prefs = {} 442 | 443 | if os.path.exists(outset_preferences): 444 | with open(outset_preferences, 'rb') as fp: 445 | prefs = plistlib.load(fp) 446 | network_wait = prefs.get("wait_for_network", True) 447 | network_timeout = prefs.get("network_timeout", 180) 448 | ignored_users = prefs.get("ignored_users", []) 449 | override_login_once = prefs.get("override_login_once", {}) 450 | 451 | if args.boot: 452 | working_directories = [ 453 | boot_every_dir, 454 | boot_once_dir, 455 | login_every_dir, 456 | login_once_dir, 457 | login_privileged_every_dir, 458 | login_privileged_once_dir, 459 | on_demand_dir, 460 | share_dir, 461 | ] 462 | 463 | for directory in working_directories: 464 | if not os.path.exists(directory): 465 | logging.info("%s does not exist, creating now.", directory) 466 | os.makedirs(directory) 467 | 468 | if os.listdir(boot_once_dir): 469 | if network_wait: 470 | loginwindow = False 471 | disable_loginwindow() 472 | continue_firstboot = ( 473 | True if wait_for_network(timeout=network_timeout // 10) else False 474 | ) 475 | if continue_firstboot: 476 | sys_report() 477 | process_items(boot_once_dir, delete_items=True) 478 | else: 479 | logging.error( 480 | "Unable to connect to network. Skipping boot-once scripts..." 481 | ) 482 | if not loginwindow: 483 | enable_loginwindow() 484 | if os.listdir(boot_every_dir): 485 | process_items(boot_every_dir) 486 | 487 | if not os.path.exists(outset_preferences): 488 | logging.info("Initiating preference file: %s" % outset_preferences) 489 | prefs["wait_for_network"] = network_wait 490 | prefs["network_timeout"] = network_timeout 491 | prefs["ignored_users"] = ignored_users 492 | prefs["override_login_once"] = override_login_once 493 | with open(outset_preferences, 'wb') as fp: 494 | plistlib.dump(prefs, fp) 495 | 496 | logging.info("Boot processing complete") 497 | 498 | if args.login: 499 | if console_user not in ignored_users: 500 | if os.listdir(login_once_dir): 501 | process_items(login_once_dir, once=True, override=override_login_once) 502 | if os.listdir(login_every_dir): 503 | process_items(login_every_dir) 504 | if os.listdir(login_privileged_once_dir) or os.listdir( 505 | login_privileged_every_dir 506 | ): 507 | open(login_privileged_trigger, "a").close() 508 | else: 509 | logging.info("Skipping login scripts for user %s", console_user) 510 | 511 | if args.login_privileged: 512 | if os.path.exists(login_privileged_trigger): 513 | cleanup(login_privileged_trigger) 514 | 515 | if console_user not in ignored_users: 516 | if os.listdir(login_privileged_once_dir): 517 | process_items( 518 | login_privileged_once_dir, once=True, override=override_login_once 519 | ) 520 | if os.listdir(login_privileged_every_dir): 521 | process_items(login_privileged_every_dir) 522 | else: 523 | logging.info("Skipping login scripts for user %s", console_user) 524 | 525 | if args.on_demand: 526 | if os.listdir(on_demand_dir): 527 | if console_user not in ("root", "loginwindow"): 528 | current_user = os.environ["USER"] 529 | if console_user == current_user: 530 | process_items(on_demand_dir) 531 | else: 532 | logging.info( 533 | "User %s is not the current console user. Skipping on-demand run.", 534 | current_user, 535 | ) 536 | else: 537 | logging.info("No current user session. Skipping on-demand run.") 538 | open(cleanup_trigger, "w").close() 539 | time.sleep(0.5) 540 | if os.path.exists(cleanup_trigger): 541 | cleanup(cleanup_trigger) 542 | 543 | if args.login_every: 544 | if console_user not in ignored_users: 545 | if os.listdir(login_every_dir): 546 | process_items(login_every_dir) 547 | 548 | if args.login_once: 549 | if console_user not in ignored_users: 550 | if os.listdir(login_once_dir): 551 | process_items(login_once_dir, once=True) 552 | 553 | if args.cleanup: 554 | logging.info("Cleaning up on-demand directory.") 555 | if os.path.exists(on_demand_trigger): 556 | cleanup(on_demand_trigger) 557 | if os.listdir(on_demand_dir): 558 | for f in os.listdir(on_demand_dir): 559 | cleanup(os.path.join(on_demand_dir, f)) 560 | time.sleep(5) 561 | 562 | if args.add_ignored_user: 563 | if os.getuid() != 0: 564 | logging.error("Must be root to add users to ignored_users") 565 | exit(1) 566 | 567 | if not os.path.exists(share_dir): 568 | logging.info("%s does not exist, creating now.", share_dir) 569 | os.makedirs(share_dir) 570 | 571 | users_to_add = [i for i in args.add_ignored_user if i != ""] 572 | if users_to_add: 573 | users_to_add.extend(ignored_users) 574 | users_to_ignore = list(set(users_to_add)) if users_to_add else None 575 | if users_to_ignore: 576 | prefs["ignored_users"] = users_to_ignore 577 | with open(outset_preferences, 'wb') as fp: 578 | plistlib.dump(prefs, fp) 579 | 580 | if args.remove_ignored_user: 581 | if os.getuid() != 0: 582 | logging.error("Must be root to remove users from ignored_users") 583 | exit(1) 584 | 585 | users_to_remove = args.remove_ignored_user 586 | if prefs.get("ignored_users"): 587 | for user in users_to_remove: 588 | if user in prefs["ignored_users"]: 589 | prefs["ignored_users"].remove(user) 590 | with open(outset_preferences, 'wb') as fp: 591 | plistlib.dump(prefs, fp) 592 | 593 | if args.add_override: 594 | if os.getuid() != 0: 595 | logging.error("Must be root to add scripts to override_login_once") 596 | exit(1) 597 | 598 | if not os.path.exists(share_dir): 599 | logging.info("%s does not exist, creating now.", share_dir) 600 | os.makedirs(share_dir) 601 | 602 | overrides_to_add = [i for i in args.add_override if i != ""] 603 | if overrides_to_add: 604 | override_items = {} 605 | for override in overrides_to_add: 606 | override = os.path.join(login_once_dir, override) 607 | override_items[override] = datetime.datetime.now() 608 | if "override_login_once" in prefs.keys(): 609 | prefs["override_login_once"].update(override_items) 610 | else: 611 | prefs["override_login_once"] = override_items 612 | with open(outset_preferences, 'wb') as fp: 613 | plistlib.dump(prefs, fp) 614 | 615 | if args.remove_override: 616 | if os.getuid() != 0: 617 | logging.error("Must be root to remove scripts from override_login_once") 618 | exit(1) 619 | 620 | scripts_to_remove = args.remove_override 621 | if prefs.get("override_login_once"): 622 | for script in scripts_to_remove: 623 | script = os.path.join(login_once_dir, script) 624 | if script in prefs["override_login_once"]: 625 | del prefs["override_login_once"][script] 626 | with open(outset_preferences, 'wb') as fp: 627 | plistlib.dump(prefs, fp) 628 | 629 | if args.version: 630 | print(__version__) 631 | 632 | 633 | if __name__ == "__main__": 634 | main() 635 | --------------------------------------------------------------------------------