Add support for installing a subset of configuration profiles as localmcx on Big Sur

This commit is contained in:
Greg Neagle
2020-07-11 10:06:28 -07:00
parent 2a12d3d452
commit b73bf34b16
3 changed files with 363 additions and 22 deletions

View File

@@ -0,0 +1,6 @@
'''Make names in .core available as profiles.foo'''
from __future__ import absolute_import
# pylint: disable=wildcard-import
from .core import *
# pylint: enable=wildcard-import

View File

@@ -23,11 +23,12 @@ import os
import subprocess
import tempfile
from . import display
from . import munkihash
from . import osutils
from . import prefs
from . import FoundationPlist
from . import localmcx
from .. import display
from .. import munkihash
from .. import osutils
from .. import prefs
from .. import FoundationPlist
def profiles_supported():
@@ -208,7 +209,11 @@ def get_profile_receipt(profile_identifier):
def install_profile(profile_path, profile_identifier):
'''Installs a profile. Returns True on success, False otherwise'''
if not profile_install_supported():
display.display_info("Cannot install profiles in this macOS version.")
# create some localmcx instead
profile_data = read_profile(profile_path)
if localmcx.install_profile(profile_data):
record_profile_receipt(profile_path, profile_identifier)
return True
return False
cmd = ['/usr/bin/profiles', '-IF', profile_path]
# /usr/bin/profiles likes to output errors to stdout instead of stderr
@@ -236,18 +241,28 @@ def remove_profile(identifier):
if not profiles_supported():
display.display_info("No support for profiles in this macOS version.")
return False
cmd = ['/usr/bin/profiles', '-Rp', identifier]
# /usr/bin/profiles likes to output errors to stdout instead of stderr
# so let's redirect everything to stdout and just use that
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
stdout = proc.communicate()[0].decode('UTF-8')
if proc.returncode != 0:
display.display_error(
'Profile %s removal failed: %s' % (identifier, stdout))
if in_config_profile_info(identifier):
cmd = ['/usr/bin/profiles', '-Rp', identifier]
# /usr/bin/profiles likes to output errors to stdout instead of stderr
# so let's redirect everything to stdout and just use that
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
stdout = proc.communicate()[0].decode('UTF-8')
if proc.returncode != 0:
display.display_error(
'Profile %s removal failed: %s' % (identifier, stdout))
return False
remove_profile_receipt(identifier)
return True
elif localmcx.profile_is_installed(identifier):
if localmcx.remove_profile(identifier):
remove_profile_receipt(identifier)
return True
return False
remove_profile_receipt(identifier)
return True
else:
# no evidence it's installed at all!
remove_profile_receipt(identifier)
return True
def profile_needs_to_be_installed(identifier, hash_value):
@@ -256,10 +271,8 @@ def profile_needs_to_be_installed(identifier, hash_value):
2) We don't have a receipt for this profile identifier
3) receipt's hash_value for identifier does not match ours
4) ProfileInstallDate doesn't match the receipt'''
if not profile_install_supported():
display.display_info("Cannot install profiles in this macOS version.")
return False
if not in_config_profile_info(identifier):
if (not in_config_profile_info(identifier) and
not localmcx.profile_is_installed(identifier)):
display.display_debug2(
'Profile identifier %s is not installed.' % identifier)
return True
@@ -275,7 +288,8 @@ def profile_needs_to_be_installed(identifier, hash_value):
% identifier)
return True
installed_dict = info_for_installed_identifier(identifier)
if (installed_dict.get('ProfileInstallDate')
if (installed_dict and
installed_dict.get('ProfileInstallDate')
!= receipt.get('ProfileInstallDate')):
display.display_debug2(
'Receipt ProfileInstallDate for profile identifier %s does not '
@@ -294,6 +308,10 @@ def profile_is_installed(identifier):
display.display_debug2(
'Profile identifier %s is installed.' % identifier)
return True
if localmcx.profile_is_installed(identifier):
display.display_debug2(
'Profile identifier %s is installed as localmcx.' % identifier)
return True
display.display_debug2(
'Profile identifier %s is not installed.' % identifier)
return False

View File

@@ -0,0 +1,317 @@
# encoding: utf-8
#
# Copyright 2014-2020 Greg Neagle.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
localmcx.py
Munki module for installing managed preferences from configuration profiles
on Big Sur+
"""
from __future__ import absolute_import, print_function
import os
import subprocess
import tempfile
# PyLint cannot properly find names inside Cocoa libraries, so issues bogus
# No name 'Foo' in module 'Bar' warnings. Disable them.
# pylint: disable=E0611
from Foundation import NSBundle
from SystemConfiguration import (
SCNetworkInterfaceCopyAll,
SCNetworkInterfaceGetBSDName,
SCNetworkInterfaceGetHardwareAddressString
)
# pylint: enable=E0611
from .. import display
from .. import osutils
from .. import utils
from .. import FoundationPlist
def install_profile(profile):
'''Extracts managed preferences from a configuration profile and installs
them as local MCX. Returns a boolean indicating success (or not)'''
if not profile.get('PayloadType') == 'Configuration':
display.display_error('Unsupported profile PayloadType: %s'
% profile.get('PayloadType'))
return False
mcx_data = profile_to_mcx(profile)
if not mcx_data:
display.display_error(
'No Managed Preferences found in profile %s: %s'
% (profile.get('PayloadIdentifier'),
profile.get('PayloadDisplayName'))
)
return False
groupname = profile.get('PayloadIdentifier')
if not groupname:
display.display_error('Profile is missing PayloadIdentifier')
return False
if create_computer_group_with_mcx(groupname, mcx_data):
refresh_mcx()
return True
return False
def remove_profile(identifier):
'''Removes the computer group containing the managed preferences for
identifier. Returns a boolean indicating success (or not)'''
if delete_computer_group(identifier):
refresh_mcx()
return True
return False
def profile_is_installed(identifier):
'''Returns true if the list of/Local/Default ComputerGroups contain
identifier'''
cmd = ['/usr/bin/dscl', '.', 'list', '/ComputerGroups']
output, err, exitcode = run(cmd)
if exitcode:
display.display_warning(
'Could not get list of local ComputerGroups: %s' % err)
return False
return identifier in output.splitlines()
def profile_to_mcx(profile):
'''Converts a configuration profile to a plist we can use with
dscl. mcximport'''
mcx = {}
for payload_content in profile.get('PayloadContent'):
mcx.update(convert(payload_content))
return mcx
def convert(payload_content):
'''Converts config profile PayloadContent into mcximport-able data'''
payload_metadata_keys = (
'PayloadType',
'PayloadContent',
'PayloadVersion',
'PayloadIdentifier',
'PayloadEnabled',
'PayloadUUID',
'PayloadDisplayName',
'PayloadOrganization',
)
state_mapping = {
'Forced': 'always',
'Set-Once': 'often',
}
mcx = {}
payload_type = payload_content.get('PayloadType')
if payload_type == 'com.apple.ManagedClient.preferences':
prefs_content = payload_content.get('PayloadContent', {})
for prefs_domain in prefs_content:
mcx[prefs_domain] = {}
for state_key, prefs_list in prefs_content[prefs_domain].items():
state = state_mapping.get(state_key)
if not state:
continue
for item in prefs_list:
if 'mcx_data_timestamp' in item:
state = 'once'
settings = item.get('mcx_preference_settings', {})
for key, value in settings.items():
mcx[prefs_domain][key] = {}
mcx[prefs_domain][key]['state'] = state
mcx[prefs_domain][key]['value'] = value
if 'mcx_union_policy_keys' in item:
mcx[prefs_domain][key]['upk'] = item['mcx_union_policy_keys']
elif domain_is_handled_by_plugin(payload_type):
display.display_warning(
'Can\'t handle configuration profile PayloadType %s' % payload_type)
display.display_warning(
'Configuration profile support is limited to managed preferences.')
elif payload_type:
prefs_domain = payload_type
mcx[prefs_domain] = {}
for key, value in payload_content.items():
if key not in payload_metadata_keys:
mcx[prefs_domain][key] = {}
mcx[prefs_domain][key]['state'] = 'always'
mcx[prefs_domain][key]['value'] = value
return mcx
def get_en0_mac():
'''Returns the MAC layer address of en0'''
for interface in SCNetworkInterfaceCopyAll():
if SCNetworkInterfaceGetBSDName(interface) == "en0":
return SCNetworkInterfaceGetHardwareAddressString(interface)
return None
def run(cmd):
'''Runs a command using subprocess.
Returns a tuple of stdout, stderr, exitcode'''
proc = subprocess.Popen(cmd,
shell=False,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE)
output, errors = proc.communicate()
return (output.decode("UTF-8"), errors.decode("UTF-8"), proc.returncode)
def refresh_mcx():
'''Attempt to refresh the MCX cache'''
_, err, exitcode = run(['/usr/bin/mcxrefresh', '-u', '0'])
if exitcode:
display.display_warning("mcxrefresh error: %s" % err)
def add_mcx_to_local_computer_record(recordname):
'''Adds some dummy MCX data to the local computer record to prevent
MCX compositor from deleting it'''
cmd = ['/usr/bin/dscl', '.', 'mcxset', '/Computers/' + recordname,
'com.github.munki.munki', 'LocalMCX', 'always', '-int', '1']
run(cmd)
def local_computer_record():
'''Finds or creates a local computer record we can use for LocalMCX'''
mac_layer_addr = get_en0_mac()
if not mac_layer_addr:
display.display_error("Could not get MAC layer address for en0")
display.display_error("Cannot create local mcx computer record")
return None
cmd = ['/usr/bin/dscl', '.', 'search', '/Computers',
'ENetAddress', mac_layer_addr]
output, _, exitcode = run(cmd)
if exitcode == 0 and output:
recordname = output.split()[0]
else:
recordname = 'mcx_computer'
cmd = ['/usr/bin/dscl', '.', 'create', '/Computers/' + recordname,
'ENetAddress', mac_layer_addr]
_, err, exitcode = run(cmd)
if exitcode:
display.display_error(
"Error creating local mcx computer record: %s" % err)
display.display_error("Cannot create local mcx computer record")
return None
add_mcx_to_local_computer_record(recordname)
return recordname
def add_local_computer_record_to_computer_group(groupname):
'''Adds the lcoal computer record to the computer group.
Returns a boolean indicating success (or not)'''
local_computer_record_name = local_computer_record()
if not local_computer_record_name:
display.display_error("Could not get local computer record")
return False
cmd = ['/usr/sbin/dseditgroup', '-o', 'edit',
'-a', local_computer_record_name, '-t', 'computer',
'-T', 'computergroup', groupname]
_, err, exitcode = run(cmd)
if exitcode:
display.display_error(
"Error adding local mcx computer to %s: %s" % (groupname, err))
return False
return True
def create_computer_group_with_mcx(groupname, mcx_data):
'''Creates or replaces a computer group containing MCX data
Returns a boolean indicating success (or not)'''
cmd = ['/usr/sbin/dseditgroup', '-q', '-o', 'create',
'-T', 'computergroup', groupname]
_, err, exitcode = run(cmd)
if exitcode:
display.display_error(
"Error creating computergroup %s: %s" % (groupname, err))
return False
mcx_plist = os.path.join(
tempfile.mkdtemp(dir=osutils.tmpdir()), 'mcx')
FoundationPlist.writePlist(mcx_data, mcx_plist)
computer_group_path = "/ComputerGroups/" + groupname
cmd = ['/usr/bin/dscl', '.', 'mcximport', computer_group_path, mcx_plist]
_, err, exitcode = run(cmd)
try:
os.unlink(mcx_plist)
except OSError:
pass
if exitcode:
display.display_error(
"Error importing mcx into computergroup %s: %s" % (groupname, err))
return False
return add_local_computer_record_to_computer_group(groupname)
def delete_computer_group(groupname):
'''Deletes a computer group.
Returns a boolean indicating success (or not)'''
cmd = ['/usr/bin/dscl', '.', 'delete', '/ComputerGroups/' + groupname]
_, err, exitcode = run(cmd)
if exitcode:
display.display_error(
"Error deleting computergroup %s: %s" % (groupname, err))
return False
return True
@utils.Memoize
def domains_handled_by_plugins():
'''Returns a list of profile PayloadTypes handled by plugins'''
xpcservices = ('/System/Library/PrivateFrameworks/'
'ConfigurationProfiles.framework/XPCServices')
domain_list = []
for item in os.listdir(xpcservices):
info_plist = os.path.join(xpcservices, item, "Contents/Info.plist")
try:
info = FoundationPlist.readPlist(info_plist)
domains_supported = info.get(
'ProfileDomainService', {}).get('DomainsSupported')
if domains_supported:
domain_list.extend(domains_supported)
except FoundationPlist.FoundationPlistException:
pass
plugin_dirs = (
'/System/Library/CoreServices/ManagedClient.app/Contents/PlugIns',
'/System/Library/ConfigurationProfiles/PlugIns',
)
ignore_plugins = (
'mcx.profileDomainPlugin',
'loginwindow.profileDomainPlugin',
)
for plugin_dir in plugin_dirs:
for item in os.listdir(plugin_dir):
if item in ignore_plugins:
continue
if not item.endswith('.profileDomainPlugin'):
continue
plugin_path = os.path.join(plugin_dir, item)
plugin = NSBundle.bundleWithPath_(plugin_path)
principal_class = plugin.principalClass()
domains_supported = list(
principal_class.new().pdp_pluginDomainsSupported())
if domains_supported:
domain_list.extend(domains_supported)
return domain_list
def domain_is_handled_by_plugin(domain):
'''Returns a boolean -- True if the domain/PayloadType is handled by
a plugin'''
return domain in domains_handled_by_plugins()