|
| 1 | +#!/bin/python3 |
| 2 | +# jamf2snipe - Inventory Import |
| 3 | +# |
| 4 | +# ABOUT: |
| 5 | +# This program is designed to import inventory information from a |
| 6 | +# JAMFPro into snipe-it using api calls. For more information |
| 7 | +# about both of these products, please visit their respecitive |
| 8 | +# websites: |
| 9 | +# https://jamf.com |
| 10 | +# https://snipeitapp.com |
| 11 | +# |
| 12 | +# LICENSE: |
| 13 | +# GLPv3 |
| 14 | +# |
| 15 | +# CONFIGURATION: |
| 16 | +# These settings are commonly found in the settings.conf file. |
| 17 | +# |
| 18 | +# This setting sets the Snipe Asset status when creating a new asset. By default it's set to 4 (Pending). |
| 19 | +# defaultStatus = 4 |
| 20 | +# |
| 21 | +# You can associate snipe hardware keys in the [api-mapping] section, to to a JAMF keys so it associates |
| 22 | +# the jamf values into snipe. The default example associates information that exists by default in both |
| 23 | +# Snipe and JAMF. The Key value is the exact name of the snipe key name. |
| 24 | +# Value1 is the "Subset" (JAMF's wording not mine) name, and the Value2 is the JAMF key name. |
| 25 | +# Note that MAC Address are a custom value in SNIPE by default and you can use it as an example. |
| 26 | +# |
| 27 | +# [api-mapping] |
| 28 | +# name = general name |
| 29 | +# _snipeit_mac_address_1 = general mac_address |
| 30 | +# _snipeit_custom_name_1234567890 = subset jamf_key |
| 31 | +# |
| 32 | +# A list of valid subsets are: |
| 33 | +validsubset = ( |
| 34 | + "general", |
| 35 | + "location", |
| 36 | + "purchasing", |
| 37 | + "peripherals", |
| 38 | + "hardware", |
| 39 | + "certificates", |
| 40 | + "software", |
| 41 | + "extension_attributes", |
| 42 | + "groups_accounts", |
| 43 | + "iphones", |
| 44 | + "configuration_profiles" |
| 45 | +) |
| 46 | + |
| 47 | + |
| 48 | +# Import all the things |
| 49 | +import json |
| 50 | +import requests |
| 51 | +import time |
| 52 | +import configparser |
| 53 | + |
| 54 | +# Find a valid settings.conf file. |
| 55 | +config = configparser.ConfigParser() |
| 56 | +config.read("/opt/jamf2snipe/settings.conf") |
| 57 | +if 'snipe-it' not in set(config): |
| 58 | + print("No valid config: /opt/jamf2snipe/settings.conf") |
| 59 | + config.read('/etc/jamf2snipe/settings.conf') |
| 60 | +if 'snipe-it' not in set(config): |
| 61 | + print("No valid config: /etc/jamf2snipe/settings.conf") |
| 62 | + config.read("settings.conf") |
| 63 | +if 'snipe-it' not in set(config): |
| 64 | + print("No valid config found in current folder") |
| 65 | + raise SystemExit("Error: Unable to find valid settings.conf file. Exiting.") |
| 66 | + |
| 67 | + |
| 68 | +# Set some Variables: |
| 69 | +# This is the address, cname, or FQDN for your JamfPro instance. |
| 70 | +jamfpro_base = config['jamf']['url'] |
| 71 | +jamf_api_user = config['jamf']['username'] |
| 72 | +jamf_api_password = config['jamf']['password'] |
| 73 | +# This is the address, cname, or FQDN for your snipe-it instance. |
| 74 | +snipe_base = config['snipe-it']['url'] |
| 75 | +apiKey = config['snipe-it']['apiKey'] |
| 76 | +defaultStatus = config['snipe-it']['defaultStatus'] |
| 77 | +apple_manufacturer_id = config['snipe-it']['manufacturer_id'] |
| 78 | +# Headers for the API call. |
| 79 | +jamfheaders = {'Accept': 'application/json'} |
| 80 | +snipeheaders = {'Authorization': 'Bearer {}'.format(apiKey),'Accept': 'application/json','Content-Type':'application/json'} |
| 81 | + |
| 82 | +# Check the config file for valid jamf subsets. |
| 83 | +print("Checking the conf file") |
| 84 | +for key in config['api-mapping']: |
| 85 | + jamfsplit = config['api-mapping'][key].split() |
| 86 | + if jamfsplit[0] in validsubset: |
| 87 | + print('Found subset {}: Good'.format(jamfsplit[0])) |
| 88 | + continue |
| 89 | + else: |
| 90 | + print("Found subset {}: This is not in the acceptable list of subsets. Check your settings.conf\n Valid subsets are:".format(jamfsplit[0])) |
| 91 | + print(validsubset) |
| 92 | + raise SystemExit("Invalid Subset found in settings.conf") |
| 93 | + |
| 94 | +### Setup Some Functions ### |
| 95 | +# Function to make the API call for all JAMF devices |
| 96 | +def get_jamf_computers(): |
| 97 | + api_url = '{0}/JSSResource/computers'.format(jamfpro_base) |
| 98 | + response = requests.get(api_url, auth=(jamf_api_user, jamf_api_password), headers=jamfheaders) |
| 99 | + if response.status_code == 200: |
| 100 | + return response.json() |
| 101 | + else: |
| 102 | + print('Error code:{}'.format(response.status_code)) |
| 103 | + return None |
| 104 | + |
| 105 | +# Function to lookup a JAMF asset by id. |
| 106 | +def search_jamf_asset(jamf_id): |
| 107 | + api_url = "{}/JSSResource/computers/id/{}".format(jamfpro_base, jamf_id) |
| 108 | + response = requests.get(api_url, auth=(jamf_api_user, jamf_api_password), headers=jamfheaders) |
| 109 | + if response.status_code == 200: |
| 110 | + jsonresponse = response.json() |
| 111 | + return jsonresponse['computer'] |
| 112 | + elif b'policies.ratelimit.QuotaViolation' in response.content: |
| 113 | + print('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to look up id: {} Waiting a bit to retry the lookup.'.format(response, jamf_id)) |
| 114 | + time.sleep(75) |
| 115 | + newresponse = search_jamf_asset(jamf_id, asset_tag) |
| 116 | + return newresponse |
| 117 | + else: |
| 118 | + print('JAMFPro responded with error code:{} when we tried to look up id: {}'.format(response, jamf_id)) |
| 119 | + return None |
| 120 | + |
| 121 | +# Function to update the asset tag in JAMF with an number passed from Snipe. |
| 122 | +def update_jamf_asset_tag(jamf_id, asset_tag): |
| 123 | + api_url = "{}/JSSResource/computers/id/{}".format(jamfpro_base, jamf_id) |
| 124 | + payload = """<?xml version="1.0" encoding="UTF-8"?><computer><general><id>{}</id><asset_tag>{}</asset_tag></general></computer>""".format(jamf_id, asset_tag) |
| 125 | + response = requests.put(api_url, auth=(jamf_api_user, jamf_api_password), data=payload, headers=jamfheaders) |
| 126 | + if response.status_code == 201: |
| 127 | + return True |
| 128 | + elif b'policies.ratelimit.QuotaViolation' in response.content: |
| 129 | + print('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to look up id: {} Waiting a bit to retry the lookup.'.format(response, jamf_id)) |
| 130 | + time.sleep(75) |
| 131 | + newupdate = update_jamf_asset_tag(jamf_id, asset_tag) |
| 132 | + return newupdate |
| 133 | + else: |
| 134 | + print('JAMFPro responded with error code:{} when we tried to update id: {}'.format(response, jamf_id)) |
| 135 | + return False |
| 136 | + |
| 137 | +# Function to lookup a snipe asset by serial number or other identifier. |
| 138 | +def search_snipe_asset(search_term): |
| 139 | + api_url = '{}/api/v1/hardware?search={}'.format(snipe_base, search_term) |
| 140 | + response = requests.get(api_url, headers=snipeheaders) |
| 141 | + if response.status_code == 200: |
| 142 | + jsonresponse = response.json() |
| 143 | + # Check to make sure there's actually a result |
| 144 | + if jsonresponse['total'] == 1: |
| 145 | + return jsonresponse |
| 146 | + elif jsonresponse['total'] == 0: |
| 147 | + print("No assets match {}".format(search_term)) |
| 148 | + return "NoMatch" |
| 149 | + else: |
| 150 | + print('WARNING: FOUND {} matching assets while searching for: {}'.format(jsonresponse['total'], search_term)) |
| 151 | + return "MultiMatch" |
| 152 | + else: |
| 153 | + print('Snipe-IT responded with error code:{} when we tried to look up: {}'.format(response, search_term)) |
| 154 | + return "ERROR" |
| 155 | + |
| 156 | +# Function to get all the asset models |
| 157 | +def get_snipe_models(): |
| 158 | + api_url = '{}/api/v1/models'.format(snipe_base) |
| 159 | + # print ('Calling against: {}'.format(api_url)) |
| 160 | + response = requests.get(api_url, headers=snipeheaders) |
| 161 | + if response.status_code == 200: |
| 162 | + return response.json() |
| 163 | + else: |
| 164 | + print('Error code:{}'.format(response.status_code)) |
| 165 | + return None |
| 166 | + |
| 167 | +# Function that creates a new Snipe Model - not an asset - with a JSON payload |
| 168 | +def create_snipe_model(payload): |
| 169 | + api_url = '{}/api/v1/models'.format(snipe_base) |
| 170 | + response = requests.post(api_url, headers=snipeheaders, json=payload) |
| 171 | + if response.status_code == 200: |
| 172 | + jsonresponse = response.json() |
| 173 | + modelnumbers[jsonresponse['payload']['model_number']] = jsonresponse['payload']['id'] |
| 174 | + return True |
| 175 | + else: |
| 176 | + print('Error code: {} while trying to create a new model.'.format(response.status_code)) |
| 177 | + return False |
| 178 | + |
| 179 | +# Function to create a new asset by passing array |
| 180 | +def create_snipe_asset(payload): |
| 181 | + api_url = '{}/api/v1/hardware'.format(snipe_base) |
| 182 | + #print ('Calling against: {}'.format(api_url)) |
| 183 | + response = requests.post(api_url, headers=snipeheaders, json=payload) |
| 184 | + if response.status_code == 200: |
| 185 | + #print(response.content) |
| 186 | + return "AssetCreated" |
| 187 | + else: |
| 188 | + return response |
| 189 | + |
| 190 | +# Function that updates a snipe asset with a JSON payload |
| 191 | +def update_snipe_asset(snipe_id, payload): |
| 192 | + api_url = '{}/api/v1/hardware/{}'.format(snipe_base, snipe_id) |
| 193 | + response = requests.patch(api_url, headers=snipeheaders, json=payload) |
| 194 | + # Verify that the payload updated properly. |
| 195 | + if response.status_code == 200: |
| 196 | + check = json.dumps(next(iter(payload.values()))) |
| 197 | + bytecheck = check.encode('utf-8') |
| 198 | + if bytecheck not in response.content: |
| 199 | + print('Error: Did not update ID: {}. The payload was: {}'.format(snipe_id, json.dumps(payload))) |
| 200 | + return response |
| 201 | + else: |
| 202 | + return "AssetUpdated" |
| 203 | + else: |
| 204 | + print('Whoops. Got an error code while updating ID {}: {}'.format(snipe_id, response.status_code)) |
| 205 | + return response |
| 206 | + |
| 207 | + |
| 208 | +### Get Started ### |
| 209 | +# Get a list of known models from Snipe |
| 210 | +snipemodels = get_snipe_models() |
| 211 | +modelnumbers = {} |
| 212 | +for model in snipemodels['rows']: |
| 213 | + modelnumbers[model['model_number']] = model['id'] |
| 214 | + |
| 215 | +# Get the IDS of all active assets. |
| 216 | +jamf_computer_list = get_jamf_computers() |
| 217 | + |
| 218 | +# Make sure we have a good list. |
| 219 | +if jamf_computer_list is not None: |
| 220 | + print('Received a list of JAMF assets.\nUpdating inventory') |
| 221 | + for jamf_asset in jamf_computer_list['computers']: |
| 222 | + |
| 223 | + # Search through the list by ID for all asset information |
| 224 | + jamf = search_jamf_asset(jamf_asset['id']) |
| 225 | + if jamf is None: |
| 226 | + continue |
| 227 | + |
| 228 | + # Check that the model number exists in snipe, if not create it. |
| 229 | + if jamf['hardware']['model_identifier'] not in modelnumbers: |
| 230 | + newmodel = {"category_id":1,"manufacturer_id":apple_manufacturer_id,"name": jamf['hardware']['model'],"model_number":jamf['hardware']['model_identifier']} |
| 231 | + create_snipe_model(newmodel) |
| 232 | + |
| 233 | + # Pass the SN from JAMF to search for a match in Snipe |
| 234 | + snipe = search_snipe_asset(jamf['general']['serial_number']) |
| 235 | + |
| 236 | + # Create a new asset if there's no match: |
| 237 | + if snipe is 'NoMatch': |
| 238 | + print("Creating a new asset in snipe.") |
| 239 | + # This section checks to see if the asset tag was already put into JAMF, if not it creates one with with Jamf's ID. |
| 240 | + if jamf['general']['asset_tag'] is '': |
| 241 | + jamf_asset_tag = 'jamfid-{}'.format(jamf['general']['id']) |
| 242 | + else: |
| 243 | + jamf_asset_tag = jamf['general']['asset_tag'] |
| 244 | + # Create the payload |
| 245 | + newasset = {'asset_tag': jamf_asset_tag,'model_id': modelnumbers['{}'.format(jamf['hardware']['model_identifier'])], 'name': jamf['general']['name'], 'status_id': defaultStatus,'serial': jamf['general']['serial_number']} |
| 246 | + if jamf['general']['serial_number'] == 'Not Available': |
| 247 | + print("Serial is not available, skipping.") |
| 248 | + else: |
| 249 | + create_snipe_asset(newasset) |
| 250 | + |
| 251 | + # Log an error if there's an issue, or more than once match. |
| 252 | + elif snipe is 'MultiMatch': |
| 253 | + print("ERROR: You need to resolve multiple assets with the same serial number in your inventory. If you can't find them in your inventory, you might need to purge your deleted records. You can find that in the Snipe Admin settings. Skipping this serial number for now.") |
| 254 | + elif snipe is 'ERROR': |
| 255 | + print("Check your snipe instance and setup. Skipping for now.") |
| 256 | + |
| 257 | + else: |
| 258 | + # Only update if JAMF has more recent info. |
| 259 | + snipe_id = snipe['rows'][0]['id'] |
| 260 | + snipe_time = snipe['rows'][0]['updated_at']['datetime'] |
| 261 | + jamf_time = jamf['general']['report_date'] |
| 262 | + # Check to see that the JAMF record is newer than the previous snipe update. |
| 263 | + if jamf_time > snipe_time: |
| 264 | + for snipekey in config['api-mapping']: |
| 265 | + jamfsplit = config['api-mapping'][snipekey].split() |
| 266 | + payload = {snipekey: jamf['{}'.format(jamfsplit[0])]['{}'.format(jamfsplit[1])]} |
| 267 | + update_snipe_asset(snipe_id, payload) |
| 268 | + # Update/Sync the Snipe Asset Tag Number back to JAMF |
| 269 | + if jamf['general']['asset_tag'] is not snipe['rows'][0]['asset_tag']: |
| 270 | + if snipe['rows'][0]['asset_tag'][0].isdigit(): |
| 271 | + update_jamf_asset_tag("{}".format(jamf['general']['id']), '{}'.format(snipe['rows'][0]['asset_tag'])) |
| 272 | + |
| 273 | +### Some Error Investigation ### |
| 274 | +else: |
| 275 | + print("Couldn't get a list of computers from JAMF. Maybe your username and password are bad?") |
| 276 | + |
| 277 | + # Do some tests to see if the hosts are up. |
| 278 | + try: |
| 279 | + SNIPE_UP = True if requests.get(snipe_base).status_code is 200 else False |
| 280 | + except: |
| 281 | + SNIPE_UP = False |
| 282 | + try: |
| 283 | + JAMF_UP = True if requests.get(jamf_base).status_code is 200 or 401 else False |
| 284 | + except: |
| 285 | + JAMF_UP = False |
| 286 | + if SNIPE_UP is False: |
| 287 | + print('Snipe-IT looks like it is down from here. Please check your config or your instance.') |
| 288 | + if SNIPE_UP is False: |
| 289 | + print('JAMFPro looks down from here. Please check the your config or your hosted JAMFPro instance.') |
0 commit comments