├── .gitattributes ├── local_credentials_template.py ├── LICENSE ├── README.md ├── .gitignore ├── find_the_jamf_id_of_this_machine.py ├── uapi_demo.py ├── copy_config_profile.py └── jamf_duplicate_detection.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /local_credentials_template.py: -------------------------------------------------------------------------------- 1 | jamf_user = '' 2 | jamf_password = '' 3 | jamf_hostname = '' 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Todd McDaniel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jamf Pro API Sampler 2 | 3 | The Jamf Pro API has really improved, though is considered still in beta. This project demonstrates the key difference between the two APIs: authentication. With the Classic API, every call to the API needed to include a valid username and password. With the Pro API, an API access token is requested once for the life of the script, and then used to authenticate going forward. 4 | 5 | In the future, as I find additional interesting techniques or methods, I'll add them here. 6 | 7 | Thanks for stopping by! 8 | 9 | 10 | 11 | ## Included Scripts 12 | 13 | ##### local_credentials_template.py 14 | 15 | Add your specific JAMF server info and rename to local_credentials.py. 16 | 17 | There are many ways of removing sensitive info from a script. I'm not saying this is the best way, but it works for me. Be sure to include it in your .gitignore! 18 | 19 | 20 | 21 | ##### uapi_demo.py 22 | 23 | This is a bare bones demonstration of how to use the new Pro API token login. 24 | 25 | 26 | 27 | ##### jamf_duplicate_detection.py 28 | 29 | This is a Pro API rewrite of my Classic API script found here: https://apple.lib.utah.edu/using-the-jamf-pro-api-and-python-to-detect-duplicated-attributes/ 30 | 31 | 32 | 33 | ##### copy_config_profile.py 34 | 35 | This is an experimental script that didn't pan out as well as I had hoped, YMMV. Classic API. Copies config profiles. 36 | 37 | 38 | 39 | ##### find_the_jamf_id_of_this_machine.py 40 | 41 | Using the Pro API, find the JAMF ID of the computer the script is running on. It returns the full computer record, but I just show the ID. 42 | 43 | 44 | 45 | ## Author 46 | 47 | Todd McDaniel, lazymutt@mac.com 48 | 49 | 50 | 51 | ## License 52 | 53 | [MIT License](LICENSE) 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | 116 | # Project specific 117 | local_credentials.py 118 | .DS_Store 119 | -------------------------------------------------------------------------------- /find_the_jamf_id_of_this_machine.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import json 3 | import subprocess 4 | import requests 5 | from local_credentials import jamf_user, jamf_password, jamf_hostname 6 | 7 | def get_pro_api_token(): 8 | ''' 9 | Fetch a Pro API Bearer Token 10 | ''' 11 | jamf_test_url = jamf_hostname + "/api/v1/auth/token" 12 | headers = {'Accept': 'application/json', } 13 | response = requests.post(url=jamf_test_url, headers=headers, auth=(jamf_user, jamf_password), timeout=10) 14 | response_json = response.json() 15 | 16 | return response_json['token'] 17 | 18 | 19 | def main(): 20 | ''' 21 | The previous version of the this script was quicker, because you could use the Classic API to directly call for inventory with a UDID. 22 | That call was deprecated so we have to brute force things. We page through the entire inventory until we find the specific UDID. 23 | Not awesome. 24 | ''' 25 | local_hardware_raw = subprocess.check_output(['/usr/sbin/system_profiler', 'SPHardwareDataType', '-json']).decode('utf-8') 26 | hardware = json.loads(local_hardware_raw) 27 | local_uuid = hardware['SPHardwareDataType'][0]['platform_UUID'] 28 | 29 | pro_api_token = get_pro_api_token() 30 | 31 | total_consumed = 0 32 | current_page = 0 33 | page_size = 100 34 | stop_paging = False 35 | 36 | while not stop_paging: 37 | headers = {'Accept': 'application/json', 'Authorization': 'Bearer ' + pro_api_token} 38 | response = requests.get(url=jamf_hostname + "/api/v1/computers-inventory/?section=GENERAL&&page-size=" + str(page_size) + "&page=" + str(current_page), headers=headers, timeout=10) 39 | 40 | response_json = response.json() 41 | total_computers = response_json["totalCount"] 42 | 43 | clients_raw = response_json['results'] 44 | for client in clients_raw: 45 | if client['udid'] == local_uuid: 46 | print(f"JAMF ID: {client['id']}") 47 | break 48 | 49 | current_page += 1 50 | total_consumed += len(clients_raw) 51 | 52 | stop_paging = total_computers == total_consumed 53 | 54 | 55 | if __name__ == '__main__': 56 | main() 57 | -------------------------------------------------------------------------------- /uapi_demo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # MIT License 4 | # 5 | # Copyright (c) 2022 Todd McDaniel 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | import requests 26 | from local_credentials import jamf_user, jamf_password, jamf_hostname 27 | 28 | 29 | def get_uapi_token(): 30 | 31 | jamf_test_url = jamf_hostname + "/api/v1/auth/token" 32 | headers = {'Accept': 'application/json', } 33 | response = requests.post(url=jamf_test_url, headers=headers, auth=(jamf_user, jamf_password)) 34 | response_json = response.json() 35 | 36 | return response_json['token'] 37 | 38 | 39 | def invalidate_uapi_token(uapi_token): 40 | 41 | jamf_test_url = jamf_hostname + "/api/v1/auth/invalidate-token" 42 | headers = {'Accept': '*/*', 'Authorization': 'Bearer ' + uapi_token} 43 | response = requests.post(url=jamf_test_url, headers=headers) 44 | 45 | if response.status_code == 204: 46 | print('Token invalidated!') 47 | else: 48 | print('Error invalidating token.') 49 | 50 | 51 | def main(): 52 | 53 | # fetch Jamf Pro (ex-universal) api token 54 | uapi_token = get_uapi_token() 55 | 56 | # fetch sample Jamf Pro api call 57 | headers = {'Accept': 'application/json', 'Authorization': 'Bearer ' + uapi_token} 58 | response = requests.get(url=jamf_hostname + "/api/v1/jamf-pro-version", headers=headers) 59 | response_json = response.json() 60 | 61 | print(response_json['version']) 62 | 63 | # invalidating token 64 | print('invalidating token...') 65 | invalidate_uapi_token(uapi_token) 66 | 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /copy_config_profile.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # MIT License 4 | # 5 | # Copyright (c) 2022 Todd McDaniel 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | # Notes ######################################################################## 26 | # 27 | # Blanks scope for safety 28 | # 29 | # boils down the payload to an xml file, limiting further editing 30 | # 31 | # Pro API doesn't contain these features yet 32 | # 33 | # I wouldn't use this in production, but it may be a useful starting point 34 | # for someone 35 | # 36 | # 37 | # 38 | ################################################################################ 39 | 40 | import html 41 | import requests 42 | from local_credentials import jamf_user, jamf_password, jamf_hostname 43 | 44 | XML_STRING = '' 45 | 46 | 47 | def xml_from_dict(data): 48 | """ 49 | recursively builds xml document from dictionary 50 | """ 51 | 52 | global XML_STRING 53 | 54 | if isinstance(data, list): 55 | for item in data: 56 | xml_from_dict(item) 57 | elif isinstance(data, dict): 58 | for key, value in data.items(): 59 | XML_STRING += (f"<{key}>") 60 | if key == 'payloads': 61 | value = html.escape(value) 62 | if key == 'scope': 63 | value = {} 64 | if key == 'uuid': 65 | value = "" 66 | else: 67 | xml_from_dict(value) 68 | XML_STRING += (f"") 69 | else: 70 | XML_STRING += (f"{data}") 71 | 72 | return XML_STRING 73 | 74 | 75 | def main(): 76 | # Set this value to the ID of the policy you want to experiment with 77 | test_policy = str(1) 78 | 79 | jamf_test_url = jamf_hostname + "/JSSResource/osxconfigurationprofiles/id/" + test_policy 80 | headers = {'Accept': 'application/json', } 81 | response = requests.get(url=jamf_test_url, headers=headers, auth=(jamf_user, jamf_password)) 82 | 83 | print("Download: ", response.status_code) 84 | this_profile = response.json()['os_x_configuration_profile'] 85 | 86 | # Adding 'copied' to the name to make it easy to find 87 | this_profile['general']['id'] = '' 88 | this_profile['general']['name'] = 'Copied ' + this_profile['general']['name'] 89 | this_profile['self_service']['self_service_display_name'] = 'Copied ' + this_profile['self_service']['self_service_display_name'] 90 | 91 | this_xml = '' + xml_from_dict(this_profile) + '' 92 | 93 | # Useful for debugging and seeing what's going on 94 | # print() 95 | # print(this_xml) 96 | # print() 97 | 98 | # the 0 id in the URL is incredibly important, it tells the JSS to create a new item 99 | jamf_test_url = jamf_hostname + "/JSSResource/osxconfigurationprofiles/id/" + str(0) 100 | headers = {'Accept': 'application/xml', } 101 | response = requests.post(url=jamf_test_url, headers=headers, data=this_xml.encode("utf-8"), auth=(jamf_user, jamf_password)) 102 | 103 | # response.text will include the ID of the new profile, or the error text 104 | print("Upload: ", response.status_code) 105 | print("Upload: ", response.text) 106 | 107 | 108 | if __name__ == '__main__': 109 | main() 110 | -------------------------------------------------------------------------------- /jamf_duplicate_detection.py: -------------------------------------------------------------------------------- 1 | #!/usr/local/ec/bin/python3 2 | ''' 3 | Part of my JAMF Pro API sampler repo. This script demonstrates pagination. 4 | Python 2/Classic API seen here: https://apple.lib.utah.edu/using-the-jamf-pro-api-and-python-to-detect-duplicated-attributes/ 5 | ''' 6 | 7 | # MIT License 8 | # 9 | # Copyright (c) 2022 Todd McDaniel 10 | # 11 | # Permission is hereby granted, free of charge, to any person obtaining a copy 12 | # of this software and associated documentation files (the "Software"), to deal 13 | # in the Software without restriction, including without limitation the rights 14 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | # copies of the Software, and to permit persons to whom the Software is 16 | # furnished to do so, subject to the following conditions: 17 | # 18 | # The above copyright notice and this permission notice shall be included in all 19 | # copies or substantial portions of the Software. 20 | # 21 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | # SOFTWARE. 28 | 29 | import requests 30 | from local_credentials import jamf_user, jamf_password, jamf_hostname 31 | 32 | def parse_jss(uapi_token): 33 | ''' 34 | pages through all machines in jss, parses computername, udid and serial numbers 35 | prints collisions 36 | ''' 37 | total_consumed = 0 38 | current_page = 0 39 | page_size = 100 40 | stop_paging = False 41 | 42 | serial_dict = {} 43 | udid_dict = {} 44 | name_dict = {} 45 | 46 | while not stop_paging: 47 | headers = {'Accept': 'application/json', 'Authorization': 'Bearer ' + uapi_token} 48 | response = requests.get(url=jamf_hostname + "/api/v1/computers-inventory/?section=GENERAL§ion=HARDWARE&page-size=" + str(page_size) + "&page=" + str(current_page) + "&sort=id%3Aasc", headers=headers) 49 | 50 | response_json = response.json() 51 | total_computers = response_json["totalCount"] 52 | 53 | clients_raw = response_json['results'] 54 | for client in clients_raw: 55 | try: 56 | if serial_dict[client['hardware']['serialNumber']]: 57 | serial_dict[client['hardware']['serialNumber']].append(client['id']) 58 | except KeyError: 59 | serial_dict[client['hardware']['serialNumber']] = [client['id']] 60 | 61 | try: 62 | if udid_dict[client['udid']]: 63 | udid_dict[client['udid']].append(client['id']) 64 | except KeyError: 65 | udid_dict[client['udid']] = [client['id']] 66 | 67 | try: 68 | if name_dict[client['general']['name']]: 69 | name_dict[client['general']['name']].append(client['id']) 70 | except KeyError: 71 | name_dict[client['general']['name']] = [client['id']] 72 | 73 | current_page += 1 74 | total_consumed += len(clients_raw) 75 | 76 | stop_paging = (total_computers == total_consumed) 77 | 78 | print("Serial Collisions:") 79 | for key, value in serial_dict.items(): 80 | if len(value) > 1: 81 | print(f"[{len(value)}] {key} {value}") 82 | 83 | print("\nUDID Collisions:") 84 | for key, value in udid_dict.items(): 85 | if len(value) > 1: 86 | print(f"[{len(value)}] {key} {value}") 87 | 88 | print("\nName Collisions:") 89 | for key, value in name_dict.items(): 90 | if len(value) > 1: 91 | print(f"[{len(value)}] {key} {value}") 92 | 93 | 94 | def get_uapi_token(): 95 | ''' 96 | fetches api token 97 | ''' 98 | jamf_test_url = jamf_hostname + "/api/v1/auth/token" 99 | headers = {'Accept': 'application/json', } 100 | response = requests.post(url=jamf_test_url, headers=headers, auth=(jamf_user, jamf_password)) 101 | response_json = response.json() 102 | 103 | return response_json['token'] 104 | 105 | 106 | def invalidate_uapi_token(uapi_token): 107 | ''' 108 | invalidates api token 109 | ''' 110 | jamf_test_url = jamf_hostname + "/api/v1/auth/invalidate-token" 111 | headers = {'Accept': '*/*', 'Authorization': 'Bearer ' + uapi_token} 112 | _ = requests.post(url=jamf_test_url, headers=headers) 113 | 114 | # if response.status_code == 204: 115 | # print('Token invalidated!') 116 | # else: 117 | # print('Error invalidating token.') 118 | 119 | 120 | def main(): 121 | uapi_token = get_uapi_token() 122 | 123 | parse_jss(uapi_token) 124 | 125 | # invalidating token 126 | print() 127 | print('invalidating token...') 128 | invalidate_uapi_token(uapi_token) 129 | 130 | 131 | if __name__ == '__main__': 132 | main() 133 | --------------------------------------------------------------------------------