First commit

This commit is contained in:
Greg Neagle 2017-11-09 14:12:03 -08:00
commit 7de1ecab2b
5 changed files with 693 additions and 0 deletions

11
LICENSE.md Normal file
View File

@ -0,0 +1,11 @@
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this source code 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.

4
README.md Normal file
View File

@ -0,0 +1,4 @@
macadmin-scripts
Some scripts that might be of use to macOS admins. Might be related to Munki;
might not.

210
createbootvolfromautonbi.py Executable file
View File

@ -0,0 +1,210 @@
#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2017 Greg Neagle.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://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.
'''A tool to make bootable disk volumes from the output of autonbi. Especially
useful to make bootable disks containing Imagr and the 'SIP-ignoring' kernel,
which allows Imagr to run scripts that affect SIP state, set UAKEL options, and
run the `startosinstall` component, all of which might otherwise require network
booting from a NetInstall-style nbi.'''
import argparse
import os
import plistlib
import subprocess
import sys
import urlparse
# dmg helpers
def mountdmg(dmgpath):
"""
Attempts to mount the dmg at dmgpath and returns first mountpoint
"""
mountpoints = []
dmgname = os.path.basename(dmgpath)
cmd = ['/usr/bin/hdiutil', 'attach', dmgpath,
'-mountRandom', '/tmp', '-nobrowse', '-plist',
'-owners', 'on']
proc = subprocess.Popen(cmd, bufsize=-1,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(pliststr, err) = proc.communicate()
if proc.returncode:
print >> sys.stderr, 'Error: "%s" while mounting %s.' % (err, dmgname)
return None
if pliststr:
plist = plistlib.readPlistFromString(pliststr)
for entity in plist['system-entities']:
if 'mount-point' in entity:
mountpoints.append(entity['mount-point'])
return mountpoints[0]
def unmountdmg(mountpoint):
"""
Unmounts the dmg at mountpoint
"""
proc = subprocess.Popen(['/usr/bin/hdiutil', 'detach', mountpoint],
bufsize=-1, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(dummy_output, err) = proc.communicate()
if proc.returncode:
print >> sys.stderr, 'Polite unmount failed: %s' % err
print >> sys.stderr, 'Attempting to force unmount %s' % mountpoint
# try forcing the unmount
retcode = subprocess.call(['/usr/bin/hdiutil', 'detach', mountpoint,
'-force'])
if retcode:
print >> sys.stderr, 'Failed to unmount %s' % mountpoint
def locate_basesystem_dmg(nbi):
'''Finds and returns the relative path to the BaseSystem.dmg within the
NetInstall.dmg'''
source_boot_plist = os.path.join(nbi, 'i386/com.apple.Boot.plist')
try:
boot_args = plistlib.readPlist(source_boot_plist)
except Exception, err:
print >> sys.stderr, err
sys.exit(-1)
kernel_flags = boot_args.get('Kernel Flags')
if not kernel_flags:
print >> sys.stderr, 'i386/com.apple.Boot.plist is missing Kernel Flags'
sys.exit(-1)
# kernel flags should in the form 'root-dmg=file:///path'
if not kernel_flags.startswith('root-dmg='):
print >> sys.stderr, 'Unexpected Kernel Flags: %s' % kernel_flags
sys.exit(-1)
file_url = kernel_flags[9:]
dmg_path = urlparse.unquote(urlparse.urlparse(file_url).path)
# return path minus leading slash
return dmg_path.lstrip('/')
def copy_system_version_plist(nbi, target_volume):
'''Copies System/Library/CoreServices/SystemVersion.plist from the
BaseSystem.dmg to the target volume.'''
netinstall_dmg = os.path.join(nbi, 'NetInstall.dmg')
if not os.path.exists(netinstall_dmg):
print >> sys.stderr, "Missing NetInstall.dmg from nbi folder"
sys.exit(-1)
print 'Mounting %s...' % netinstall_dmg
netinstall_mount = mountdmg(netinstall_dmg)
if not netinstall_mount:
sys.exit(-1)
basesystem_dmg = os.path.join(netinstall_mount, locate_basesystem_dmg(nbi))
print 'Mounting %s...' % basesystem_dmg
basesystem_mount = mountdmg(basesystem_dmg)
if not basesystem_mount:
unmountdmg(netinstall_mount)
sys.exit(-1)
source = os.path.join(
basesystem_mount, 'System/Library/CoreServices/SystemVersion.plist')
dest = os.path.join(
target_volume, 'System/Library/CoreServices/SystemVersion.plist')
try:
subprocess.check_call(
['/usr/bin/ditto', '-V', source, dest])
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
unmountdmg(basesystem_mount)
unmountdmg(netinstall_mount)
sys.exit(-1)
unmountdmg(basesystem_mount)
unmountdmg(netinstall_mount)
def copy_boot_files(nbi, target_volume):
'''Copies some boot files, yo'''
files_to_copy = [
['NetInstall.dmg', 'NetInstall.dmg'],
['i386/PlatformSupport.plist',
'System/Library/CoreServices/PlatformSupport.plist'],
['i386/booter', 'System/Library/CoreServices/boot.efi'],
['i386/booter', 'usr/standalone/i386/boot.efi'],
['i386/x86_64/kernelcache',
'System/Library/PrelinkedKernels/prelinkedkernel']
]
for source, dest in files_to_copy:
full_source = os.path.join(nbi, source)
full_dest = os.path.join(target_volume, dest)
try:
subprocess.check_call(
['/usr/bin/ditto', '-V', full_source, full_dest])
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
sys.exit(-1)
def make_boot_plist(nbi, target_volume):
'''Creates our com.apple.Boot.plist'''
source_boot_plist = os.path.join(nbi, 'i386/com.apple.Boot.plist')
try:
boot_args = plistlib.readPlist(source_boot_plist)
except Exception, err:
print >> sys.stderr, err
sys.exit(-1)
kernel_flags = boot_args.get('Kernel Flags')
if not kernel_flags:
print >> sys.stderr, 'i386/com.apple.Boot.plist is missing Kernel Flags'
sys.exit(-1)
# prepend the container-dmg path
boot_args['Kernel Flags'] = (
'container-dmg=file:///NetInstall.dmg ' + kernel_flags)
boot_plist = os.path.join(
target_volume,
'Library/Preferences/SystemConfiguration/com.apple.Boot.plist')
plist_dir = os.path.dirname(boot_plist)
if not os.path.exists(plist_dir):
os.makedirs(plist_dir)
try:
plistlib.writePlist(boot_args, boot_plist)
except Exception, err:
print >> sys.stderr, err
sys.exit(-1)
def bless(target_volume, label=None):
'''Bless the target volume'''
blessfolder = os.path.join(target_volume, 'System/Library/CoreServices')
if not label:
label = os.path.basename(target_volume)
try:
subprocess.check_call(
['/usr/sbin/bless', '--folder', blessfolder, '--label', label])
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
sys.exit(-1)
def main():
'''Do the thing we were made for'''
parser = argparse.ArgumentParser()
parser.add_argument('--nbi', required=True, metavar='path_to_nbi',
help='Path to nbi folder created by autonbi.')
parser.add_argument('--volume', required=True,
metavar='path_to_disk_volume',
help='Path to disk volume.')
args = parser.parse_args()
copy_system_version_plist(args.nbi, args.volume)
copy_boot_files(args.nbi, args.volume)
make_boot_plist(args.nbi, args.volume)
bless(args.volume)
if __name__ == '__main__':
main()

417
installinstallmacos.py Executable file
View File

@ -0,0 +1,417 @@
#!/usr/bin/python
# encoding: utf-8
#
# Copyright 2017 Greg Neagle.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://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.
#
# Thanks to Tim Sutton for ideas, suggestions, and sample code.
#
'''installinstallmacos.py
A tool to download the parts for an Install macOS app from Apple's
softwareupdate servers and install a functioning Install macOS app onto an
empty disk image'''
import argparse
import os
import plistlib
import subprocess
import sys
import urlparse
from xml.dom import minidom
from xml.parsers.expat import ExpatError
DEFAULT_SUCATALOG = (
'https://swscan.apple.com/content/catalogs/others/'
'index-10.13seed-10.13-10.12-10.11-10.10-10.9'
'-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog')
def make_sparse_image(volume_name, output_path):
'''Make a sparse disk image we can install a product to'''
cmd = ['/usr/bin/hdiutil', 'create', '-size', '8g', '-fs', 'HFS+',
'-volname', volume_name, '-type', 'SPARSE', '-plist', output_path]
try:
output = subprocess.check_output(cmd)
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
exit(-1)
try:
return plistlib.readPlistFromString(output)[0]
except IndexError, err:
print >> sys.stderr, 'Unexpected output from hdiutil: %s' % output
exit(-1)
except ExpatError, err:
print >> sys.stderr, 'Malformed output from hdiutil: %s' % output
print >> sys.stderr, err
exit(-1)
def make_compressed_dmg(app_path, diskimagepath):
"""Returns path to newly-created compressed r/o disk image containing
Install macOS.app"""
print ('Making read-only compressed disk image containing %s...'
% os.path.basename(app_path))
cmd = ['/usr/bin/hdiutil', 'create', '-fs', 'HFS+',
'-srcfolder', app_path, diskimagepath]
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
else:
print 'Disk image created at: %s' % diskimagepath
def mountdmg(dmgpath):
"""
Attempts to mount the dmg at dmgpath and returns first mountpoint
"""
mountpoints = []
dmgname = os.path.basename(dmgpath)
cmd = ['/usr/bin/hdiutil', 'attach', dmgpath,
'-mountRandom', '/tmp', '-nobrowse', '-plist',
'-owners', 'on']
proc = subprocess.Popen(cmd, bufsize=-1,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
(pliststr, err) = proc.communicate()
if proc.returncode:
print >> sys.stderr, 'Error: "%s" while mounting %s.' % (err, dmgname)
return None
if pliststr:
plist = plistlib.readPlistFromString(pliststr)
for entity in plist['system-entities']:
if 'mount-point' in entity:
mountpoints.append(entity['mount-point'])
return mountpoints[0]
def unmountdmg(mountpoint):
"""
Unmounts the dmg at mountpoint
"""
proc = subprocess.Popen(['/usr/bin/hdiutil', 'detach', mountpoint],
bufsize=-1, stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
(dummy_output, err) = proc.communicate()
if proc.returncode:
print >> sys.stderr, 'Polite unmount failed: %s' % err
print >> sys.stderr, 'Attempting to force unmount %s' % mountpoint
# try forcing the unmount
retcode = subprocess.call(['/usr/bin/hdiutil', 'detach', mountpoint,
'-force'])
if retcode:
print >> sys.stderr, 'Failed to unmount %s' % mountpoint
def install_product(dist_path, target_vol):
'''Install a product to a target volume'''
cmd = ['/usr/sbin/installer', '-pkg', dist_path, '-target', target_vol]
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError, err:
print >> sys.stderr, err
class ReplicationError(Exception):
'''A custom error when replication fails'''
pass
def replicate_url(full_url, root_dir='/tmp',
show_progress=False, ignore_cache=False):
'''Downloads a URL and stores it in the same relative path on our
filesystem. Returns a path to the replicated file.'''
path = urlparse.urlsplit(full_url)[2]
relative_url = path.lstrip('/')
relative_url = os.path.normpath(relative_url)
local_file_path = os.path.join(root_dir, relative_url)
if show_progress:
options = '-fL'
else:
options = '-sfL'
curl_cmd = ['/usr/bin/curl', options, '--create-dirs',
'-o', local_file_path]
if not ignore_cache and os.path.exists(local_file_path):
curl_cmd.extend(['-z', local_file_path])
curl_cmd.append(full_url)
print "Downloading %s..." % full_url
try:
subprocess.check_call(curl_cmd)
except subprocess.CalledProcessError, err:
raise ReplicationError(err)
return local_file_path
def parse_server_metadata(filename):
'''Parses a softwareupdate server metadata file, looking for information
of interest.
Returns a dictionary containing title, version, and description.'''
title = ''
vers = ''
try:
md_plist = plistlib.readPlist(filename)
except (OSError, IOError, ExpatError), err:
print >> sys.stderr, 'Error reading %s: %s' % (filename, err)
return {}
vers = md_plist.get('CFBundleShortVersionString', '')
localization = md_plist.get('localization', {})
preferred_localization = (localization.get('English') or
localization.get('en'))
if preferred_localization:
title = preferred_localization.get('title', '')
metadata = {}
metadata['title'] = title
metadata['version'] = vers
return metadata
def get_server_metadata(catalog, product_key, workdir, ignore_cache=False):
'''Replicate ServerMetaData'''
try:
url = catalog['Products'][product_key]['ServerMetadataURL']
try:
smd_path = replicate_url(
url, root_dir=workdir, ignore_cache=ignore_cache)
return smd_path
except ReplicationError, err:
print >> sys.stderr, (
'Could not replicate %s: %s' % (url, err))
return None
except KeyError:
print >> sys.stderr, 'Malformed catalog.'
return None
def parse_dist(filename):
'''Parses a softwareupdate dist file, returning a dict of info of
interest'''
dist_info = {}
try:
dom = minidom.parse(filename)
except ExpatError:
print >> sys.stderr, 'Invalid XML in %s' % filename
return dist_info
except IOError, err:
print >> sys.stderr, 'Error reading %s: %s' % (filename, err)
return dist_info
auxinfos = dom.getElementsByTagName('auxinfo')
if not auxinfos:
return dist_info
auxinfo = auxinfos[0]
key = None
value = None
for node in auxinfo.childNodes:
if node.nodeType == node.ELEMENT_NODE and node.tagName == 'key':
key = node.firstChild.wholeText
if node.nodeType == node.ELEMENT_NODE and node.tagName == 'string':
value = node.firstChild.wholeText
if key and value:
dist_info[key] = value
key = None
value = None
return dist_info
def download_and_parse_sucatalog(sucatalog, workdir, ignore_cache=False):
'''Downloads and returns a parsed softwareupdate catalog'''
try:
localcatalogpath = replicate_url(
sucatalog, root_dir=workdir, ignore_cache=ignore_cache)
except ReplicationError, err:
print >> sys.stderr, 'Could not replicate %s: %s' % (sucatalog, err)
exit(-1)
try:
catalog = plistlib.readPlist(localcatalogpath)
return catalog
except (OSError, IOError, ExpatError), err:
print >> sys.stderr, (
'Error reading %s: %s' % (localcatalogpath, err))
exit(-1)
def find_mac_os_installers(catalog):
'''Return a list of product identifiers for what appear to be macOS
installers'''
mac_os_installer_products = []
if 'Products' in catalog:
product_keys = list(catalog['Products'].keys())
for product_key in product_keys:
product = catalog['Products'][product_key]
try:
if product['ExtendedMetaInfo'][
'InstallAssistantPackageIdentifiers'][
'OSInstall'] == 'com.apple.mpkg.OSInstall':
mac_os_installer_products.append(product_key)
except KeyError:
continue
return mac_os_installer_products
def os_installer_product_info(catalog, workdir, ignore_cache=False):
'''Returns a dict of info about products that look like macOS installers'''
product_info = {}
installer_products = find_mac_os_installers(catalog)
for product_key in installer_products:
product_info[product_key] = {}
filename = get_server_metadata(catalog, product_key, workdir)
product_info[product_key] = parse_server_metadata(filename)
product = catalog['Products'][product_key]
product_info[product_key]['PostDate'] = str(product['PostDate'])
distributions = product['Distributions']
dist_url = distributions.get('English') or distributions.get('en')
try:
dist_path = replicate_url(
dist_url, root_dir=workdir, ignore_cache=ignore_cache)
except ReplicationError, err:
print >> sys.stderr, 'Could not replicate %s: %s' % (dist_url, err)
dist_info = parse_dist(dist_path)
product_info[product_key]['DistributionPath'] = dist_path
product_info[product_key].update(dist_info)
return product_info
def replicate_product(catalog, product_id, workdir, ignore_cache=False):
'''Downloads all the packages for a product'''
product = catalog['Products'][product_id]
for package in product.get('Packages', []):
# TO-DO: Check 'Size' attribute and make sure
# we have enough space on the target
# filesystem before attempting to download
if 'URL' in package:
try:
replicate_url(
package['URL'], root_dir=workdir,
show_progress=True, ignore_cache=ignore_cache)
except ReplicationError, err:
print >> sys.stderr, (
'Could not replicate %s: %s' % (package['URL'], err))
exit(-1)
if 'MetadataURL' in package:
try:
replicate_url(package['MetadataURL'], root_dir=workdir,
ignore_cache=ignore_cache)
except ReplicationError, err:
print >> sys.stderr, (
'Could not replicate %s: %s'
% (package['MetadataURL'], err))
exit(-1)
def main():
'''Do the main thing here'''
if os.getuid() != 0:
sys.exit('This command requires root (to install packages), so please '
'run again with sudo or as root.')
parser = argparse.ArgumentParser()
parser.add_argument('--catalogurl', metavar='sucatalog_url',
default=DEFAULT_SUCATALOG,
help='Software Update catalog URL.')
parser.add_argument('--workdir', metavar='path_to_working_dir',
default='.',
help='Path to working directory on a volume with over '
'10G of available space. Defaults to current working '
'directory.')
parser.add_argument('--compress', action='store_true',
help='Output a read-only compressed disk image with '
'the Install macOS app at the root.')
parser.add_argument('--ignore-cache', action='store_true',
help='Ignore any previously cached files.')
args = parser.parse_args()
# download sucatalog and look for products that are for macOS installers
catalog = download_and_parse_sucatalog(
args.catalogurl, args.workdir, ignore_cache=args.ignore_cache)
product_info = os_installer_product_info(
catalog, args.workdir, ignore_cache=args.ignore_cache)
if not product_info:
print >> sys.stderr, (
'No macOS installer products found in the sucatalog.')
exit(-1)
# display a menu of choices (some seed catalogs have multiple installers)
print '%2s %12s %10s %8s %s' % ('#', 'ProductID', 'Version',
'Build', 'Title')
for index, product_id in enumerate(product_info):
print '%2s %12s %10s %8s %s' % (index+1,
product_id,
product_info[product_id]['version'],
product_info[product_id]['BUILD'],
product_info[product_id]['title'])
answer = raw_input(
'\nChoose a product to download (1-%s): ' % len(product_info))
try:
index = int(answer) - 1
if index < 0:
raise ValueError
product_id = product_info.keys()[index]
except (ValueError, IndexError):
print 'Exiting.'
exit(0)
# download all the packages for the selected product
replicate_product(
catalog, product_id, args.workdir, ignore_cache=args.ignore_cache)
# generate a name for the sparseimage
volname = ('Install_macOS_%s-%s'
% (product_info[product_id]['version'],
product_info[product_id]['BUILD']))
sparse_diskimage_path = os.path.join(args.workdir, volname + '.sparseimage')
if os.path.exists(sparse_diskimage_path):
os.unlink(sparse_diskimage_path)
# make an empty sparseimage and mount it
print 'Making empty sparseimage...'
sparse_diskimage_path = make_sparse_image(volname, sparse_diskimage_path)
mountpoint = mountdmg(sparse_diskimage_path)
if mountpoint:
# install the product to the mounted sparseimage volume
install_product(
product_info[product_id]['DistributionPath'],
mountpoint)
print 'Product downloaded and installed to %s' % sparse_diskimage_path
if not args.compress:
unmountdmg(mountpoint)
else:
# if --compress option given, create a r/o compressed diskimage
# containing the Install macOS app
compressed_diskimagepath = os.path.join(
args.workdir, volname + '.dmg')
if os.path.exists(compressed_diskimagepath):
os.unlink(compressed_diskimagepath)
applications_dir = os.path.join(mountpoint, 'Applications')
for item in os.listdir(applications_dir):
if item.endswith('.app'):
app_path = os.path.join(applications_dir, item)
make_compressed_dmg(app_path, compressed_diskimagepath)
break
# unmount sparseimage
unmountdmg(mountpoint)
# delete sparseimage since we don't need it any longer
os.unlink(sparse_diskimage_path)
if __name__ == '__main__':
main()

51
make_firmwareupdater_pkg.sh Executable file
View File

@ -0,0 +1,51 @@
#!/bin/sh
# Based on investigations and work by Pepijn Bruienne
# Expects a single /Applications/Install macOS High Sierra*.app on disk
#
# 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
#
# http://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.
IDENTIFIER="com.foo.FirmwareUpdateStandalone"
VERSION=1.0
# find the Install macOS High Sierra.app and mount the embedded InstallESD disk image
echo "Mounting High Sierra ESD disk image..."
/usr/bin/hdiutil mount /Applications/Install\ macOS\ High\ Sierra*.app/Contents/SharedSupport/InstallESD.dmg
# expand the FirmwareUpdate.pkg so we can copy resources from it
echo "Expanding FirmwareUpdate.pkg"
/usr/sbin/pkgutil --expand /Volumes/InstallESD/Packages/FirmwareUpdate.pkg /tmp/FirmwareUpdate
# we don't need the disk image any more
echo "Ejecting disk image..."
/usr/bin/hdiutil eject /Volumes/InstallESD
# make a place to stage our pkg resources
/bin/mkdir -p /tmp/FirmwareUpdateStandalone/scripts
# copy the needed resources
echo "Copying package resources..."
/bin/cp /tmp/FirmwareUpdate/Scripts/postinstall_actions/update /tmp/FirmwareUpdateStandalone/scripts/postinstall
# add an exit 0 at the end of the script
echo "" >> /tmp/FirmwareUpdateStandalone/scripts/postinstall
echo "" >> /tmp/FirmwareUpdateStandalone/scripts/postinstall
echo "exit 0" >> /tmp/FirmwareUpdateStandalone/scripts/postinstall
/bin/cp -R /tmp/FirmwareUpdate/Scripts/Tools /tmp/FirmwareUpdateStandalone/scripts/
# build the package
echo "Building standalone package..."
/usr/bin/pkgbuild --nopayload --scripts /tmp/FirmwareUpdateStandalone/scripts --identifier "$IDENTIFIER" --version "$VERSION" /tmp/FirmwareUpdateStandalone/FirmwareUpdateStandalone.pkg
# clean up
/bin/rm -r /tmp/FirmwareUpdate
/bin/rm -r /tmp/FirmwareUpdateStandalone/scripts