├── .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 |
--------------------------------------------------------------------------------