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