diff --git a/build_python_framework.sh b/build_python_framework.sh index 786aa28..835bb04 100755 --- a/build_python_framework.sh +++ b/build_python_framework.sh @@ -1,7 +1,7 @@ #!/bin/zsh # Build script for Python 3 framework for Sal scripts TOOLSDIR=$(dirname "$0") -PYTHON_VERSION=3.8.2 +PYTHON_VERSION=3.8.3 # build the framework /tmp/relocatable-python-git/make_relocatable_python_framework.py \ diff --git a/payload/usr/local/munki/postflight.d/sal-postflight b/payload/usr/local/munki/postflight.d/sal-postflight index e41baa8..a5043a3 100644 --- a/payload/usr/local/munki/postflight.d/sal-postflight +++ b/payload/usr/local/munki/postflight.d/sal-postflight @@ -57,21 +57,21 @@ def check_server_connection(): def check_server_online(): # is the offline report pref true? - if not sal.pref('SendOfflineReport'): + if not sal.sal_pref('SendOfflineReport'): return # read report report = munki_checkin.get_managed_install_report() # check for errors and warnings if not check_for_errors(report): - sal.set_pref('LastRunWasOffline', False) + sal.set_sal_pref('LastRunWasOffline', False) return # if they're there check is server is really offline if check_server_connection(): - sal.set_pref('LastRunWasOffline', True) + sal.set_sal_pref('LastRunWasOffline', True) return # If we get here, it's online - sal.set_pref('LastRunWasOffline', False) + sal.set_sal_pref('LastRunWasOffline', False) def write_touch_file(): if os.path.exists(TOUCH_FILE_PATH): diff --git a/payload/usr/local/munki/preflight.d/sal-preflight b/payload/usr/local/munki/preflight.d/sal-preflight index 3539902..fe53676 100755 --- a/payload/usr/local/munki/preflight.d/sal-preflight +++ b/payload/usr/local/munki/preflight.d/sal-preflight @@ -6,13 +6,13 @@ Retrieves plugin scripts to run on client. import argparse -import json import os import pathlib import shutil import sys import urllib +import requests.exceptions import sal sys.path.append('/usr/local/munki') from munkilib import munkicommon @@ -23,11 +23,12 @@ EXTERNAL_SCRIPTS_DIR = '/usr/local/sal/external_scripts' def main(): set_verbosity() + sal.setup_sal_client() - if sal.pref('SyncScripts') == True: + if sal.sal_pref('SyncScripts') == True: if not os.path.exists(EXTERNAL_SCRIPTS_DIR): os.makedirs(EXTERNAL_SCRIPTS_DIR) - server_scripts = get_checksum() + server_scripts = get_checksums() if server_scripts: create_dirs(server_scripts) download_scripts(server_scripts) @@ -39,8 +40,8 @@ def main(): def get_prefs(): # Check for mandatory prefs and bail if any are missing. required_prefs = {} - required_prefs["key"] = sal.pref('key') - required_prefs["ServerURL"] = sal.pref('ServerURL').rstrip('/') + required_prefs["key"] = sal.sal_pref('key') + required_prefs["ServerURL"] = sal.sal_pref('ServerURL').rstrip('/') for key, val in required_prefs.items(): if not val: @@ -48,25 +49,30 @@ def get_prefs(): return required_prefs -def get_checksum(): +def get_checksums(): """Downloads the checksum of existing scripts. Returns: A dict with the script name, plugin name and hash of the script or None if no external scripts are used. """ - preflight_url = f"{sal.pref('ServerURL')}/preflight-v2/" - stdout, stderr = sal.send_report(preflight_url, form_data={'os_family': 'Darwin'}) - - if stderr: - munkicommon.display_debug2(stderr) - stdout_list = stdout.split("\n") - if "

Page not found

" not in stdout_list: - munkicommon.display_debug2(stdout) + sal_client = sal.get_sal_client() + error_msg = None + try: + response = sal_client.post('preflight-v2/', data={'os_family': 'Darwin'}) + except requests.exceptions.RequestException as error: + munkicommon.display_debug2(str(error_msg)) + return + if response.status_code != requests.status_codes.codes.okay: + munkicommon.display_debug2(f'Request failed with HTTP {response.status_code}') + return + if response and "

Page not found

" in response.text: + munkicommon.display_debug2(response.text) + return try: - return json.loads(stdout) - except: + return response.json() + except ValueError: munkicommon.display_debug2("Didn't receive valid JSON.") return None @@ -91,22 +97,26 @@ def download_scripts(server_scripts): def download_and_write_script(server_script): """Gets script from the server and makes it execuatble.""" - script_url = ( - f"{sal.pref('ServerURL')}/preflight-v2/get-script/" - f"{server_script['plugin']}/{server_script['filename']}/") - stdout, stderr = sal.curl(script_url) - if stderr: + try: + response = sal.get_sal_client().get( + f"preflight-v2/get-script/{server_script['plugin']}/{server_script['filename']}/") + except requests.exceptions.RequestException as error: + munkicommon.display_debug2('Error received downloading script:') + munkicommon.display_debug2(str(error)) + return + + if response.status_code != requests.status_codes.codes.okay: munkicommon.display_debug2('Error received downloading script:') - munkicommon.display_debug2(stderr) + munkicommon.display_debug2(response.text) script = open( os.path.join(EXTERNAL_SCRIPTS_DIR, server_script['plugin'], server_script['filename']), 'w') try: - data = json.loads(stdout) - except: + data = response.json() + except ValueError: munkicommon.display_debug2('Did not receive valid JSON when requesting script content.') - return False + return script.write(data[0]['content']) script.close() diff --git a/payload/usr/local/sal/bin/sal-submit b/payload/usr/local/sal/bin/sal-submit index 446c1dd..8c85b11 100755 --- a/payload/usr/local/sal/bin/sal-submit +++ b/payload/usr/local/sal/bin/sal-submit @@ -16,7 +16,7 @@ import stat import subprocess import tempfile -from Foundation import CFPreferencesCopyAppValue +import requests.exceptions import sal @@ -25,9 +25,19 @@ CHECKIN_MODULES_DIR = '/usr/local/sal/checkin_modules' def main(): - logging.basicConfig( - level=get_log_level(), format='%(asctime)s %(levelname)s %(message)s') + args = get_args() + log_level = get_log_level(args) + logging.basicConfig(level=log_level, format='%(asctime)s %(levelname)s %(message)s') logging.info("%s Version: %s", os.path.basename(__file__), sal.__version__) + if log_level == logging.DEBUG: + logging.debug("Sal client prefs:") + prefs = sal.prefs_report() + if args.url: + prefs['ServerURL'] = {'value': args.url, 'forced': 'commandline'} + if args.key: + prefs['key'] = {'value': args.key, 'forced': 'commandline'} + for k, v in prefs.items(): + logging.debug(f'\t{k}: {v["value"]} ({"profile" if v["forced"] else "prefs"})') exit_if_not_root() if sal.wait_for_script('sal-submit'): @@ -48,32 +58,40 @@ def main(): remove_skipped_facts() sanitize_submission() - server_url, _, machine_group_key = sal.get_server_prefs() + report = sal.get_checkin_results() + sal.setup_sal_client() + if args.url: + sal.get_sal_client().base_url = args.url + logging.debug('Server URL overridden with %s', args.url) + + if args.key: + sesh = sal.get_sal_client().auth = ('sal', args.key) + # Override the key in the report, since it's used for querying. + report['Sal']['extra_data']['key'] = args.key + logging.debug('Machine group key overridden with %s', args.key) if logging.getLogger().level <= 10: - submission = sal.get_checkin_results() logging.debug('Checkin submission:') - logging.debug(json.dumps(submission, indent=4, default=sal.serializer)) - _, errors = send_checkin(server_url) + logging.debug(json.dumps(report, indent=4, default=sal.serializer)) + response = send_checkin(report) - if not errors: + if response and response.status_code == 200: sal.clean_results() # Speed up manual runs by skipping these potentially slow-running, # and infrequently changing tasks. if run_type != 'manual': - send_inventory(server_url, submission['Machine']['extra_data']['serial']) - send_catalogs(server_url, machine_group_key) - send_profiles(server_url, submission['Machine']['extra_data']['serial']) + send_inventory(submission['Machine']['extra_data']['serial']) + send_catalogs() + send_profiles(submission['Machine']['extra_data']['serial']) pathlib.Path('/Users/Shared/.com.salopensource.sal.run').unlink(missing_ok=True) logging.info('Checkin complete.') -def get_log_level(): +def get_log_level(args): """Set the verbosity based on options.""" loglevel = logging.CRITICAL - args = get_args() if args.debug: loglevel = logging.DEBUG elif args.verbose: @@ -88,6 +106,8 @@ def get_args(): "-d", "--debug", default=False, action="store_true", help="Enable full debug output.") parser.add_argument( "-v", "--verbose", default=False, action="store_true", help="Enable verbose output.") + parser.add_argument("-u", "--url", default=None, help="Override the server URL for testing.") + parser.add_argument("-k", "--key", default=None, help="Override the machine group key.") return parser.parse_args() @@ -148,7 +168,7 @@ def get_plugin_results(plugin_results_plist): def remove_blacklisted_messages(): - patterns = sal.pref('MessageBlacklistPatterns', []) + patterns = sal.sal_pref('MessageBlacklistPatterns', []) if patterns: compiled = [re.compile(p) for p in patterns] update = False @@ -176,7 +196,7 @@ def remove_blacklisted_messages(): def remove_skipped_facts(): - if skip_facts := sal.pref('SkipFacts'): + if skip_facts := sal.sal_pref('SkipFacts'): update = False submission = sal.get_checkin_results() @@ -210,56 +230,51 @@ def sanitize_submission(): sal.save_results(json.loads(submission_str)) -def send_checkin(server_url): - checkinurl = os.path.join(server_url, 'checkin', '') - logging.debug(f"Sending report to {checkinurl}") - logging.debug("Checkin Response:") - out, error = sal.send_report(checkinurl, json_path=sal.RESULTS_PATH) - log(out, error) - return out, error - - -def log(out, error): - if out: - logging.debug(out.strip()) - if error: - logging.debug(error.strip()) +def send_checkin(report): + logging.debug("Sending report") + try: + response = sal.get_sal_client().post('checkin/', json=report) + except requests.exceptions.RequestException as error: + logging.error('Failed to send report') + logging.debug(error) + response = None + return response -def send_inventory(server_url, serial): +def send_inventory(serial): logging.info('Processing inventory...') - hash_url = os.path.join(server_url, 'inventory/hash', serial, '') - inventory_submit_url = os.path.join(server_url, 'inventory/submit', '') - - managed_install_dir = ( - CFPreferencesCopyAppValue('ManagedInstallDir', 'ManagedInstalls') or - '/Library/Managed Installs') + managed_install_dir = sal.mac_pref( + 'ManagedInstalls', 'ManagedInstallDir', '/Library/Managed Installs') inventory_plist = pathlib.Path(managed_install_dir) / 'ApplicationInventory.plist' logging.debug('ApplicationInventory.plist Path: %s', inventory_plist) - if inventory:= inventory_plist.read_bytes(): + if inventory := inventory_plist.read_bytes(): inventory_hash = sal.get_hash(inventory_plist) + logging.debug(f"Inventory hash: {inventory_hash}") serverhash = None - serverhash, stderr = sal.curl(hash_url) - if stderr: + sal_client = sal.get_sal_client() + try: + response = sal_client.get(f'inventory/hash/{serial}/') + except requests.exceptions.RequestException as error: + logging.error('Failed to get inventory hash') + logging.debug(error) return - if serverhash != inventory_hash: + if response.status_code == 200 and response.text != inventory_hash: logging.info("Inventory is out of date; submitting...") inventory_submission = { 'serial': serial, 'base64bz2inventory': sal.submission_encode(inventory)} - logging.debug("Inventory report response:") - out, error = sal.send_report(inventory_submit_url, form_data=inventory_submission) - log(out, error) + try: + sal_client.post('inventory/submit/', data=inventory_submission) + except requests.exceptions.RequestException as error: + logging.error('Failed to submit inventory') + logging.debug(error) -def send_catalogs(server_url, machine_group_key): +def send_catalogs(): logging.info('Processing catalogs...') - hash_url = os.path.join(server_url, 'catalog/hash', '') - catalog_submit_url = os.path.join(server_url, 'catalog/submit', '') - managed_install_dir = ( - CFPreferencesCopyAppValue('ManagedInstallDir', 'ManagedInstalls') or - '/Library/Managed Installs') + managed_install_dir = sal.mac_pref( + 'ManagedInstalls', 'ManagedInstallDir', '/Library/Managed Installs') catalog_dir = pathlib.Path(managed_install_dir) / 'catalogs' check_list = [] @@ -272,38 +287,42 @@ def send_catalogs(server_url, machine_group_key): catalog_check_plist = plistlib.dumps(check_list) + sal_client = sal.get_sal_client() + machine_group_key = sal_client.auth[1] hash_submission = { 'key': machine_group_key, 'catalogs': sal.submission_encode(catalog_check_plist)} - response, stderr = sal.send_report(hash_url, form_data=hash_submission) + try: + response = sal_client.post('catalog/hash/', data=hash_submission) + except requests.exceptions.RequestException as error: + logging.error('Failed to get catalog hashes') + logging.debug(error) + return - if stderr is not None: - try: - remote_data = plistlib.loads(response.encode()) - except plistlib.InvalidFileException: - remote_data = [] - - for catalog in check_list: - if catalog not in remote_data: - contents = (pathlib.Path(catalog_dir) / catalog['name']).read_bytes() - catalog_submission = { - 'key': machine_group_key, - 'base64bz2catalog': sal.submission_encode(contents), - 'name': catalog['name'], - 'sha256hash': catalog['sha256hash']} - - logging.debug("Submitting Catalog: %s", catalog['name']) - try: - out, error = sal.send_report(catalog_submit_url, form_data=catalog_submission) - log(out, error) - except OSError: - logging.warning("Error while submitting Catalog: %s", catalog['name']) - - -def send_profiles(server_url, serial): + try: + remote_data = plistlib.loads(response.content) + except plistlib.InvalidFileException: + remote_data = [] + + for catalog in check_list: + if catalog not in remote_data: + contents = (pathlib.Path(catalog_dir) / catalog['name']).read_bytes() + catalog_submission = { + 'key': machine_group_key, + 'base64bz2catalog': sal.submission_encode(contents), + 'name': catalog['name'], + 'sha256hash': catalog['sha256hash']} + + logging.debug("Submitting Catalog: %s", catalog['name']) + try: + sal_client.post('catalog/submit/', data=catalog_submission) + except requests.exceptions.RequestException as error: + logging.error("Error while submitting Catalog: %s", catalog['name']) + logging.debug(error) + + +def send_profiles(serial): logging.info('Processing profiles...') - profile_submit_url = os.path.join(server_url, 'profiles/submit', '') - temp_dir = tempfile.mkdtemp() profile_out = pathlib.Path(temp_dir) / 'profiles.plist' @@ -315,14 +334,25 @@ def send_profiles(server_url, serial): logging.warning("Couldn't output profiles.") return - profiles = sal.submission_encode(profile_out.read_bytes()) + profiles = plistlib.loads(profile_out.read_bytes()) profile_out.unlink() + # Drop all of the payload info we're not going to actual store. + for profile in profiles['_computerlevel']: + cleansed_payloads = [_payload_cleanse(p) for p in profile.get('ProfileItems', [])] + profile['ProfileItems'] = cleansed_payloads + logging.debug(profiles) + profile_submission = { + 'serial': serial, 'base64bz2profiles': sal.submission_encode(plistlib.dumps(profiles))} + try: + sal.get_sal_client().post('profiles/submit/', data=profile_submission) + except requests.exceptions.RequestException as error: + logging.error('Failed to submit profiles') + logging.debug(error) - profile_submission = {'serial': serial, 'base64bz2profiles': profiles} - logging.debug("Profiles Response:") - out, error = sal.send_report(profile_submit_url, form_data=profile_submission) - log(out, error) +def _payload_cleanse(payload): + stored = ('PayloadIdentifier', 'PayloadUUID', 'PayloadType') + return {k: payload[k] for k in stored} if __name__ == "__main__": diff --git a/payload/usr/local/sal/checkin_modules/machine_checkin.py b/payload/usr/local/sal/checkin_modules/machine_checkin.py index bcc2cb9..2784abc 100755 --- a/payload/usr/local/sal/checkin_modules/machine_checkin.py +++ b/payload/usr/local/sal/checkin_modules/machine_checkin.py @@ -8,6 +8,7 @@ import sys from xml.etree import ElementTree +import macsesh from SystemConfiguration import ( SCDynamicStoreCreate, SCDynamicStoreCopyValue, SCDynamicStoreCopyConsoleUser) @@ -68,7 +69,7 @@ def process_system_profile(): def get_hostname(): - _, name_type, _ = sal.get_server_prefs() + name_type = sal.sal_pref('NameType', default='ComputerName') net_config = SCDynamicStoreCreate(None, "net", None, None) return get_machine_name(net_config, name_type) @@ -86,7 +87,8 @@ def get_friendly_model(serial): """Return friendly model name""" if not MODEL_PATH.exists(): model = cleanup_model(query_apple_support(serial)) - MODEL_PATH.write_text(model) + if model: + MODEL_PATH.write_text(model) else: try: model = MODEL_PATH.read_text().strip() @@ -105,23 +107,24 @@ def get_model_code(serial): # Remove S prefix from scanned codes. serial = serial[1:] return serial[8:].upper() + + elif 11 <= len(serial) <= 12: + # 2010 Mac Pros starting with H or Y are 11 characters + return serial[8:].upper() + return def query_apple_support(serial): model_code = get_model_code(serial) tree = ElementTree.ElementTree() + session = macsesh.Session() + response = session.get(f"https://support-sp.apple.com/sp/product?cc={model_code}&lang=en_US") try: - response = subprocess.check_output( - ['curl', f"https://support-sp.apple.com/sp/product?cc={model_code}&lang=en_US"], - text=True) - except subprocess.CalledProcessError: - pass - try: - tree = ElementTree.fromstring(response) + tree = ElementTree.fromstring(response.text) except ElementTree.ParseError: - pass - return tree.findtext("configCode") + tree = None + return tree.findtext("configCode") if tree else None def cleanup_model(model): diff --git a/payload/usr/local/sal/checkin_modules/munki_checkin.py b/payload/usr/local/sal/checkin_modules/munki_checkin.py index 1a8244d..7e7ba69 100755 --- a/payload/usr/local/sal/checkin_modules/munki_checkin.py +++ b/payload/usr/local/sal/checkin_modules/munki_checkin.py @@ -2,7 +2,6 @@ import datetime -import os import pathlib import plistlib import sys @@ -81,24 +80,30 @@ def main(): munki_submission['managed_items'][item] = submission_item # Process InstallResults and RemovalResults into update history - for report_key, result_type in (('InstallResults', 'PRESENT'), ('RemovalResults', 'ABSENT')): + for report_key in ('InstallResults', 'RemovalResults'): for item in munki_report.get(report_key, []): # Skip Apple software update items. if item.get('applesus'): continue - history = {} - # history = {'update_type': 'apple' if item.get('applesus') else 'third_party'} - history['status'] = 'ERROR' if item.get('status') != 0 else result_type + # Construct key; we pop the name off because we don't need + # to submit it again when we stuff `item` into `data`. + name = f'{item.pop("name")} {item["version"]}' + submission_item = munki_submission['managed_items'].get(name, {'name': name}) + if item.get('status') != 0: + # Something went wrong, so change the status. + submission_item['status'] = 'ERROR' + if 'data' in submission_item: + submission_item['data'].update(item) + else: + submission_item['data'] = item + if 'type' not in submission_item['data']: + submission_item['data']['type'] = ( + 'ManagedInstalls' if report_key == 'InstallResults' else 'ManagedUninstalls') # This UTC datetime gets converted to a naive datetime by # plistlib. Fortunately, we can just tell it that it's UTC. - history['date_managed'] = item['time'].replace( + submission_item['date_managed'] = item['time'].replace( tzinfo=datetime.timezone.utc).isoformat() - history['data'] = {'version': item.get('version', '0')} - # Add over top of any pending items we may have already built. - if item['name'] in munki_submission['managed_items']: - munki_submission['managed_items'][item['name']].update(history) - else: - munki_submission['managed_items'][item['name']] = history + munki_submission['managed_items'][name] = submission_item sal.set_checkin_results('Munki', munki_submission) diff --git a/payload/usr/local/sal/checkin_modules/profile_checkin.py b/payload/usr/local/sal/checkin_modules/profile_checkin.py index a4eeed3..2165425 100755 --- a/payload/usr/local/sal/checkin_modules/profile_checkin.py +++ b/payload/usr/local/sal/checkin_modules/profile_checkin.py @@ -32,7 +32,7 @@ def main(): data['payload_types'] = ', '.join(p['PayloadType'] for p in payloads) data['profile_description'] = profile.get('ProfileDescription', 'None') data['identifier'] = profile['ProfileIdentifier'] - data['organization'] = profile['ProfileOrganization'] or 'None' + data['organization'] = profile.get('ProfileOrganization' or 'None') data['uuid'] = profile['ProfileUUID'] data['verification_state'] = profile.get('ProfileVerificationState', '') submission_item['data'] = data diff --git a/payload/usr/local/sal/checkin_modules/sal_checkin.py b/payload/usr/local/sal/checkin_modules/sal_checkin.py index f273d75..72e6638 100755 --- a/payload/usr/local/sal/checkin_modules/sal_checkin.py +++ b/payload/usr/local/sal/checkin_modules/sal_checkin.py @@ -8,11 +8,10 @@ def main(): - _, _, bu_key = sal.get_server_prefs() sal_submission = { 'extra_data': { 'sal_version': sal.__version__, - 'key': bu_key,}, + 'key': sal.sal_pref('key'),}, 'facts': {'checkin_module_version': __version__}} sal.set_checkin_results('Sal', sal_submission) diff --git a/requirements.txt b/requirements.txt index 17bb28c..55b1cfa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ sal_python_pkg/ -pyobjc==6.2 +pyobjc==6.2.2 +urllib3==1.25.10 +requests==2.24.0 +MacSesh==0.3.0 diff --git a/sal_python_pkg/sal/__init__.py b/sal_python_pkg/sal/__init__.py index 789f79b..2e584bd 100644 --- a/sal_python_pkg/sal/__init__.py +++ b/sal_python_pkg/sal/__init__.py @@ -1,2 +1,8 @@ +from sal.client import MacKeychainClient, SalClient, get_sal_client +try: + from sal.mac_utils import * +except ImportError: + # Allow non-macOS to import safely. + pass from sal.utils import * from sal.version import __version__ diff --git a/sal_python_pkg/sal/client.py b/sal_python_pkg/sal/client.py new file mode 100644 index 0000000..cae3fd1 --- /dev/null +++ b/sal_python_pkg/sal/client.py @@ -0,0 +1,105 @@ +import logging +import os + +try: + from macsesh import Session as MacSeshSession +except ImportError: + MacSeshSession = None +import requests + + +_client_instance = None + + +class SalClient: + + session_class = requests.Session + _base_url = '' + _auth = None + _cert = None + _verify = None + basic_timeout = (3.05, 4) + post_timeout = (3.05, 8) + + def __init__(self): + self.create_session() + + def create_session(self): + self.session = self.session_class() + if self.auth: + self.session.auth = self._auth + if self.cert: + self.session.cert = self._cert + if self.verify: + self.session.verify = self._verify + + # self.session.cert = (self._cert, self._key) if self._key else self._cert + @property + def base_url(self): + return self._base_url + + @base_url.setter + def base_url(self, base_url): + self._base_url = base_url if not base_url.endswith('/') else base_url[:-1] + + @property + def auth(self): + return self._auth + + @auth.setter + def auth(self, creds): + self._auth = creds + self.create_session() + + @property + def cert(self): + return self._cert + + @cert.setter + def cert(self, cert, key=None): + self._cert = (cert, key) if key else cert + self.create_session() + + @property + def verify(self): + return self._verify + + @verify.setter + def verify(self, path): + self._verify = path + self.create_session() + + def get(self, url): + url = self.build_url(url) + return self.log_response(self.session.get(url, timeout=self.basic_timeout)) + + def post(self, url, data=None, json=None): + url = self.build_url(url) + kwargs = {'timeout': self.post_timeout} + if json: + kwargs['json'] = json + else: + kwargs['data'] = data + return self.log_response(self.session.post(url, **kwargs)) + + def log_response(self, response): + logging.debug(f'Response HTTP {response.status_code}: {response.text}') + return response + + def build_url(self, url): + url = url[1:] if url.startswith('/') else url + url = url[:-1] if url.endswith('/') else url + return '/'.join((self.base_url, url)) + '/' + + +class MacKeychainClient(SalClient): + + session_class = MacSeshSession + + +def get_sal_client(with_client_class=None): + global _client_instance + if _client_instance is None or ( + with_client_class is not None and not isinstance(_client_instance, with_client_class)): + _client_instance = with_client_class() if with_client_class is not None else SalClient() + return _client_instance diff --git a/sal_python_pkg/sal/exceptions.py b/sal_python_pkg/sal/exceptions.py new file mode 100644 index 0000000..32cd949 --- /dev/null +++ b/sal_python_pkg/sal/exceptions.py @@ -0,0 +1,2 @@ +class SalClientError(Exception): + pass diff --git a/sal_python_pkg/sal/mac_utils.py b/sal_python_pkg/sal/mac_utils.py new file mode 100644 index 0000000..4acfe76 --- /dev/null +++ b/sal_python_pkg/sal/mac_utils.py @@ -0,0 +1,250 @@ +import binascii +import datetime +import logging +import os +import pathlib +import subprocess +import time + +from Foundation import ( + kCFPreferencesAnyUser, kCFPreferencesCurrentHost, CFPreferencesSetValue, + CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, CFPreferencesAppValueIsForced, NSDate, + NSArray, NSDictionary, NSData, NSNull) + +from sal.client import get_sal_client, MacKeychainClient + + +BUNDLE_ID = 'com.github.salopensource.sal' +ISO_TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' + + +def setup_sal_client(): + ca_cert = sal_pref('CACert', '') + cert = sal_pref('SSLClientCertificate', '') + key = sal_pref('SSLClientKey', '') + exists = map(os.path.exists, (ca_cert, cert, key)) + if any(exists): + if not all(exists): + logging.warning( + 'Argument warning! If using the `CACert`, `SSLClientCertificate`, or ' + '`SSLClientKey` prefs, they must all be either paths to cert files or the ' + 'common name of the certs to find in the keychain.') + + # If any of the above have been passed as a path, we have to + # use a vanilla Session. + logging.debug('Using SalClient') + client = get_sal_client() + else: + # Assume that any passed certs are by CN since they don't + # exist as files anywhere. + # If we're going to use the keychain, we need to use a + # macsesh + logging.debug('Using MacKeychainClient') + client = get_sal_client(MacKeychainClient) + + if ca_cert: + client.verify = ca_cert + if cert: + client.cert = (cert, key) if key else cert + + basic_auth = sal_pref('BasicAuth') + if basic_auth: + key = sal_pref('key', '') + client.auth = ('sal', key) + + client.base_url = sal_pref('ServerURL') + + +def mac_pref(domain, key, default=None): + val = CFPreferencesCopyAppValue(key, domain) + return val if val is not None else default + + +def set_sal_pref(pref_name, pref_value): + """Sets a Sal preference. + + The preference file on disk is located at + /Library/Preferences/com.github.salopensource.sal.plist. This should + normally be used only for 'bookkeeping' values; values that control + the behavior of munki may be overridden elsewhere (by MCX, for + example) + """ + try: + CFPreferencesSetValue( + pref_name, pref_value, BUNDLE_ID, kCFPreferencesAnyUser, kCFPreferencesCurrentHost) + CFPreferencesAppSynchronize(BUNDLE_ID) + except Exception: + pass + + +def sal_pref(pref_name, default=None): + """Return a preference value. + + Since this uses CFPreferencesCopyAppValue, Preferences can be defined + several places. Precedence is: + - MCX + - /var/root/Library/Preferences/com.github.salopensource.sal.plist + - /Library/Preferences/com.github.salopensource.sal.plist + - default_prefs defined here. + + Returned values are all converted to native python types through the + `unobjctify` function; e.g. dates are returned as aware-datetimes, + NSDictionary to dict, etc. + """ + default_prefs = { + 'ServerURL': 'http://sal', + 'osquery_launchd': 'com.facebook.osqueryd.plist', + 'SkipFacts': [], + 'SyncScripts': True, + 'BasicAuth': True, + 'GetGrains': False, + 'GetOhai': False, + 'LastRunWasOffline': False, + 'SendOfflineReport': False, + } + + pref_value = mac_pref(BUNDLE_ID, pref_name, default) + if pref_value is None and pref_name in default_prefs: + # If we got here, the pref value was either set to None or never + # set, AND the default was also None. Fall back to auto prefs. + pref_value = default_prefs.get(pref_name) + # we're using a default value. We'll write it out to + # /Library/Preferences/.plist for admin + # discoverability + set_sal_pref(pref_name, pref_value) + + return unobjctify(pref_value) + + +def forced(pref, bundle_identifier=BUNDLE_ID): + return CFPreferencesAppValueIsForced(pref, bundle_identifier) + + +def prefs_report(): + prefs = ( + 'ServerURL', 'key', 'BasicAuth', 'SyncScripts', 'SkipFacts', 'CACert', 'SendOfflineReport', + 'SSLClientCertificate', 'SSLClientKey', 'MessageBlacklistPatterns') + return {k: {'value': sal_pref(k), 'forced': forced(k)} for k in prefs} + + +def unobjctify(element, safe=False): + """Recursively convert nested elements to native python datatypes. + + Types accepted include str, bytes, int, float, bool, None, list, + dict, set, tuple, NSArray, NSDictionary, NSData, NSDate, NSNull. + + element: Some (potentially) nested data you want to convert. + + safe: Bool (defaults to False) whether you want printable + representations instead of the python equivalent. e.g. NSDate + safe=True becomes a str, safe=False becomes a datetime.datetime. + NSData safe=True bcomes a hex str, safe=False becomes bytes. Any + type not explicitly handled by this module will raise an + exception unless safe=True, where it will instead replace the + data with a str of '' + + This is primarily for safety in serialization to plists or + output. + + returns: Python equivalent of the original input. + e.g. NSArray -> List, NSDictionary -> Dict, etc. + + raises: ValueError for any data that isn't supported (yet!) by this + function. + """ + supported_types = (str, bytes, int, float, bool, datetime.datetime) + if isinstance(element, supported_types): + return element + elif isinstance(element, (dict, NSDictionary)): + return {k: unobjctify(v, safe=safe) for k, v in element.items()} + elif isinstance(element, (list, NSArray)): + return [unobjctify(i, safe=safe) for i in element] + elif isinstance(element, set): + return {unobjctify(i, safe=safe) for i in element} + elif isinstance(element, tuple): + return tuple([unobjctify(i, safe=safe) for i in element]) + elif isinstance(element, NSData): + return binascii.hexlify(element) if safe else bytes(element) + elif isinstance(element, NSDate): + return str(element) if safe else datetime.datetime.strptime( + element.description(), ISO_TIME_FORMAT) + elif isinstance(element, NSNull) or element is None: + return '' if safe else None + elif safe: + return '' + raise ValueError(f"Element type '{type(element)}' is not supported!") + + +def script_is_running(scriptname): + """Returns Process ID for a running python script. + + Not at all stolen from Munki. Honest. + """ + cmd = ['/bin/ps', '-eo', 'pid=,command='] + proc = subprocess.Popen( + cmd, bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + out, _ = proc.communicate() + mypid = os.getpid() + for line in out.splitlines(): + try: + pid, process = line.split(maxsplit=1) + except ValueError: + # funky process line, so we'll skip it + pass + else: + args = process.split() + try: + # first look for Python processes + if 'MacOS/Python' in args[0] or 'python' in args[0]: + # look for first argument being scriptname + if scriptname in args[1]: + try: + if int(pid) != mypid: + return True + except ValueError: + # pid must have some funky characters + pass + except IndexError: + pass + + # if we get here we didn't find a Python script with scriptname + # (other than ourselves) + return False + + +def run_scripts(dir_path, cli_args=None, error=False): + results = [] + skip_names = {'__pycache__'} + scripts = (p for p in pathlib.Path(dir_path).iterdir() if p.name not in skip_names) + for script in scripts: + if not os.access(script, os.X_OK): + results.append(f"'{script}' is not executable or has bad permissions") + continue + + cmd = [script] + if cli_args: + cmd.append(cli_args) + try: + subprocess.check_call(cmd) + results.append(f"'{script}' ran successfully") + except (OSError, subprocess.CalledProcessError): + errormsg = f"'{script}' had errors during execution!" + if not error: + results.append(errormsg) + else: + raise RuntimeError(errormsg) + + return results + + +def wait_for_script(scriptname, repeat=3, pause=1): + """Tries a few times to wait for a script to finish.""" + count = 0 + while count < repeat: + if script_is_running(scriptname): + time.sleep(pause) + count += 1 + else: + return False + return True + diff --git a/sal_python_pkg/sal/utils.py b/sal_python_pkg/sal/utils.py index b98e17c..51eaa8a 100644 --- a/sal_python_pkg/sal/utils.py +++ b/sal_python_pkg/sal/utils.py @@ -2,178 +2,17 @@ import base64 -import binascii import bz2 import datetime import hashlib import json import os +import platform import pathlib import plistlib -import stat -import subprocess -import time -import urllib.parse -from Foundation import (kCFPreferencesAnyUser, kCFPreferencesCurrentHost, CFPreferencesSetValue, - CFPreferencesAppSynchronize, CFPreferencesCopyAppValue, NSDate, NSArray, - NSDictionary, NSData, NSNull) -import sal.version - - -BUNDLE_ID = 'com.github.salopensource.sal' -RESULTS_PATH = '/usr/local/sal/checkin_results.json' -ISO_TIME_FORMAT = '%Y-%m-%d %H:%M:%S %z' - - -def set_pref(pref_name, pref_value): - """Sets a Sal preference. - - The preference file on disk is located at - /Library/Preferences/com.github.salopensource.sal.plist. This should - normally be used only for 'bookkeeping' values; values that control - the behavior of munki may be overridden elsewhere (by MCX, for - example) - """ - try: - CFPreferencesSetValue( - pref_name, pref_value, BUNDLE_ID, kCFPreferencesAnyUser, kCFPreferencesCurrentHost) - CFPreferencesAppSynchronize(BUNDLE_ID) - except Exception: - pass - - -def pref(pref_name, default=None): - """Return a preference value. - - Since this uses CFPreferencesCopyAppValue, Preferences can be defined - several places. Precedence is: - - MCX - - /var/root/Library/Preferences/com.github.salopensource.sal.plist - - /Library/Preferences/com.github.salopensource.sal.plist - - default_prefs defined here. - - Returned values are all converted to native python types through the - `unobjctify` function; e.g. dates are returned as aware-datetimes, - NSDictionary to dict, etc. - """ - default_prefs = { - 'ServerURL': 'http://sal', - 'osquery_launchd': 'com.facebook.osqueryd.plist', - 'SkipFacts': [], - 'SyncScripts': True, - 'BasicAuth': True, - 'GetGrains': False, - 'GetOhai': False, - 'LastRunWasOffline': False, - 'SendOfflineReport': False, - } - - pref_value = CFPreferencesCopyAppValue(pref_name, BUNDLE_ID) - if pref_value is None and default is not None: - pref_value = default - elif pref_value is None and pref_name in default_prefs: - pref_value = default_prefs.get(pref_name) - # we're using a default value. We'll write it out to - # /Library/Preferences/.plist for admin - # discoverability - set_pref(pref_name, pref_value) - - return unobjctify(pref_value) - - -def wait_for_script(scriptname, repeat=3, pause=1): - """Tries a few times to wait for a script to finish.""" - count = 0 - while count < repeat: - if script_is_running(scriptname): - time.sleep(pause) - count += 1 - else: - return False - return True - - -def script_is_running(scriptname): - """Returns Process ID for a running python script. - - Not at all stolen from Munki. Honest. - """ - cmd = ['/bin/ps', '-eo', 'pid=,command='] - proc = subprocess.Popen( - cmd, bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - out, _ = proc.communicate() - mypid = os.getpid() - for line in out.splitlines(): - try: - pid, process = line.split(maxsplit=1) - except ValueError: - # funky process line, so we'll skip it - pass - else: - args = process.split() - try: - # first look for Python processes - if 'MacOS/Python' in args[0] or 'python' in args[0]: - # look for first argument being scriptname - if scriptname in args[1]: - try: - if int(pid) != mypid: - return True - except ValueError: - # pid must have some funky characters - pass - except IndexError: - pass - - # if we get here we didn't find a Python script with scriptname - # (other than ourselves) - return False - - -def curl(url, data=None, json_path=None): - cmd = ['/usr/bin/curl', '--silent', '--show-error', '--connect-timeout', '2'] - - # Use a PEM format certificate file to verify the peer. This is - # useful primarily to support self-signed certificates, which are - # rejected on 10.13's bundled curl. In cases where you have a cert - # signed by an internal or external trusted CA, curl will happily - # use the keychain. - ca_cert = pref('CACert') - if ca_cert: - cmd += ['--cacert', ca_cert] - - basic_auth = pref('BasicAuth') - if basic_auth: - key = pref('key') - user_pass = f'sal:{key}' - cmd += ['--user', user_pass] - - ssl_client_cert = pref('SSLClientCertificate') - ssl_client_key = pref('SSLClientKey') - if ssl_client_cert: - cmd += ['--cert', ssl_client_cert] - if ssl_client_key: - cmd += ['--key', ssl_client_key] - - max_time = '8' if data else '4' - cmd += ['--max-time', max_time] - - cmd += ['--header', f'SalScript-Version: {sal.version.__version__}'] - - if data: - cmd += ['--data', data] - elif json_path: - cmd += ['--header', 'Content-Type: application/json'] - # Use the @ syntax for curl to open the file and do any required - # encoding for us. - cmd += ['--data', f'@{json_path}'] - - cmd.append(url) - - task = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) - return task.communicate() +RESULTS_PATH = {'Darwin': '/usr/local/sal/checkin_results.json'}.get(platform.system()) def get_hash(file_path): @@ -184,18 +23,6 @@ def get_hash(file_path): return hashlib.sha256(text).hexdigest() -def send_report(url, form_data=None, json_data=None, json_path=None): - if form_data: - # urlencode allows bytes and str in its dict arg. - stdout, stderr = curl(url, data=urllib.parse.urlencode(form_data)) - elif json_data: - raise NotImplementedError - elif json_path: - stdout, stderr = curl(url, json_path=RESULTS_PATH) - - return stdout, stderr - - def add_plugin_results(plugin, data, historical=False): """Add data to the shared plugin results plist. @@ -208,7 +35,10 @@ def add_plugin_results(plugin, data, historical=False): historical (bool): Whether to keep only one record (False) or all results (True). Optional, defaults to False. """ - plist_path = pathlib.Path('/usr/local/sal/plugin_results.plist') + if platform.system() == 'Darwin': + plist_path = pathlib.Path('/usr/local/sal/plugin_results.plist') + else: + raise NotImplementedError('Please PR a plugin results path for your platform!') if plist_path.exists(): plugin_results = plistlib.loads(plist_path.read_bytes()) else: @@ -221,7 +51,10 @@ def add_plugin_results(plugin, data, historical=False): def get_checkin_results(): if os.path.exists(RESULTS_PATH): with open(RESULTS_PATH) as results_handle: - results = json.load(results_handle) + try: + results = json.load(results_handle) + except json.decoder.JSONDecodeError: + results = {} else: results = {} @@ -263,97 +96,6 @@ def serializer(obj): return obj -def run_scripts(dir_path, cli_args=None): - results = [] - for script in os.listdir(dir_path): - script_stat = os.stat(os.path.join(dir_path, script)) - if not script_stat.st_mode & stat.S_IWOTH: - cmd = [os.path.join(dir_path, script)] - if cli_args: - cmd.append(cli_args) - try: - subprocess.check_call(cmd, stdin=None) - results.append("'{}' ran successfully") - except (OSError, subprocess.CalledProcessError): - errormsg = "'{}' had error during execution!".format(script) - if not error: - results.append(errormsg) - else: - raise RuntimeError(errormsg) - else: - results.append(f"'{script}' is not executable or has bad permissions") - return results - - -def get_server_prefs(): - """Get Sal preferences, bailing if required info is missing. - - Returns: - Tuple of (Server URL, NameType, and key (business unit key) - """ - # Check for mandatory prefs and bail if any are missing. - required_prefs = { - 'key': pref('key'), - 'server_url': pref('ServerURL').rstrip('/')} - - for key, val in required_prefs.items(): - if not val: - exit(f'Required Sal preference "{key}" is not set.') - - # Get optional preferences. - name_type = pref('NameType', default='ComputerName') - - return required_prefs["server_url"], name_type, required_prefs["key"] - - -def unobjctify(element, safe=False): - """Recursively convert nested elements to native python datatypes. - - Types accepted include str, bytes, int, float, bool, None, list, - dict, set, tuple, NSArray, NSDictionary, NSData, NSDate, NSNull. - - element: Some (potentially) nested data you want to convert. - - safe: Bool (defaults to False) whether you want printable - representations instead of the python equivalent. e.g. NSDate - safe=True becomes a str, safe=False becomes a datetime.datetime. - NSData safe=True bcomes a hex str, safe=False becomes bytes. Any - type not explicitly handled by this module will raise an - exception unless safe=True, where it will instead replace the - data with a str of '' - - This is primarily for safety in serialization to plists or - output. - - returns: Python equivalent of the original input. - e.g. NSArray -> List, NSDictionary -> Dict, etc. - - raises: ValueError for any data that isn't supported (yet!) by this - function. - """ - supported_types = (str, bytes, int, float, bool, datetime.datetime) - if isinstance(element, supported_types): - return element - elif isinstance(element, (dict, NSDictionary)): - return {k: unobjctify(v, safe=safe) for k, v in element.items()} - elif isinstance(element, (list, NSArray)): - return [unobjctify(i, safe=safe) for i in element] - elif isinstance(element, set): - return set([unobjctify(i, safe=safe) for i in element]) - elif isinstance(element, tuple): - return tuple([unobjctify(i, safe=safe) for i in element]) - elif isinstance(element, NSData): - return binascii.hexlify(element) if safe else bytes(element) - elif isinstance(element, NSDate): - return str(element) if safe else datetime.datetime.strptime( - element.description(), ISO_TIME_FORMAT) - elif isinstance(element, NSNull) or element is None: - return '' if safe else None - elif safe: - return '' - raise ValueError(f"Element type '{type(element)}' is not supported!") - - def submission_encode(data: bytes) -> bytes: """Return a b64 encoded, bz2 compressed copy of text.""" return base64.b64encode(bz2.compress(data)) diff --git a/sal_python_pkg/setup.py b/sal_python_pkg/setup.py index 797fa57..3af1887 100644 --- a/sal_python_pkg/setup.py +++ b/sal_python_pkg/setup.py @@ -10,4 +10,8 @@ name='sal', version=namespace['__version__'], description='Sal client utilities', + install_requires=[ + 'pyobjc == 6.2 ; platform_system=="Darwin"', + 'macsesh == 0.3.0 ; platform_system=="Darwin"', + 'requests >= 2.23.0'], packages=['sal']) \ No newline at end of file