diff --git a/README.md b/README.md index 416046d..06131c7 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,39 @@ # jamf2snipe ## Import/Sync Computers from JAMF to Snipe-IT ``` -usage: jamf2snipe [-h] [-v] [--dryrun] [-d] [--do_not_verify_ssl] [-r] - [--no_search] [-u | -ui | -uf] [-m | -c] - -optional arguments: --h, --help show this help message and exit --v, --verbose Sets the logging level to INFO and gives you a better - idea of what the script is doing. ---dryrun This checks your config and tries to contact both the - JAMFPro and Snipe-it instances, but exits before - updating or syncing any assets. --d, --debug Sets logging to include additional DEBUG messages. ---do_not_update_jamf Does not update Jamf with the asset tags stored in - Snipe. ---do_not_verify_ssl Skips SSL verification for all requests. Helpful when - you use self-signed certificate. --r, --ratelimited Puts a half second delay between Snipe IT API calls to - adhere to the standard 120/minute rate limit --f, --force Updates the Snipe asset with information from Jamf - every time, despite what the timestamps indicate. --u, --users Checks out the item to the current user in Jamf if - it's not already deployed --ui, --users_inverse Checks out the item to the current user in Jamf if - it's already deployed --uf, --users_force Checks out the item to the user specified in Jamf no - matter what --uns, --users_no_search - Doesn't search for any users if the specified fields - in Jamf and Snipe don't match. (case insensitive) --m, --mobiles Runs against the Jamf mobiles endpoint only. --c, --computers Runs against the Jamf computers endpoint only. +usage: jamf2snipe [-h] [-v] [--auto_incrementing] [--dryrun] [-d] [--do_not_update_jamf] [--do_not_verify_ssl] [-r] [-f] [--version] [-u | -ui | -uf] [-uns] [-m | -c] + +options: + -h, --help show this help message and exit + -v, --verbose Sets the logging level to INFO and gives you a better + idea of what the script is doing. + --auto_incrementing You can use this if you have auto-incrementing + enabled in your snipe instance to utilize that + instead of adding the Jamf ID for the asset tag. + --dryrun This checks your config and tries to contact both + the JAMFPro and Snipe-it instances, but exits before + updating or syncing any assets. + -d, --debug Sets logging to include additional DEBUG messages. + --do_not_update_jamf Does not update Jamf with the asset tags stored in + Snipe. + --do_not_verify_ssl Skips SSL verification for all requests. Helpful when + you use self-signed certificate. + -r, --ratelimited Puts a half second delay between API calls to adhere + to the standard 120/minute rate limit + -f, --force Updates the Snipe asset with information from Jamf + every time, despite what the timestamps indicate. + --version Prints the version and exits. + -u, --users Checks out the item to the current user in Jamf if + it's not already deployed + -ui, --users_inverse Checks out the item to the current user in Jamf if + it's already deployed + -uf, --users_force Checks out the item to the user specified in Jamf no + matter what + -uns, --users_no_search + Doesn't search for any users if the specified fields + in Jamf and Snipe don't match. (case insensitive) + -m, --mobiles Runs against the Jamf mobiles endpoint only. + -c, --computers Runs against the Jamf computers endpoint only. ``` ## Overview: diff --git a/jamf2snipe b/jamf2snipe index 4f68397..1363fc8 100755 --- a/jamf2snipe +++ b/jamf2snipe @@ -30,6 +30,8 @@ # _snipeit_custom_name_1234567890 = subset jamf_key # # A list of valid subsets are: +version = "1.0.1" + validsubset = [ "general", "location", @@ -52,6 +54,7 @@ import time import configparser import argparse import logging +import datetime # Set us up for using runtime arguments by defining them. runtimeargs = argparse.ArgumentParser() @@ -61,8 +64,9 @@ runtimeargs.add_argument("--dryrun", help="This checks your config and tries to runtimeargs.add_argument("-d", "--debug", help="Sets logging to include additional DEBUG messages.", action="store_true") runtimeargs.add_argument("--do_not_update_jamf", help="Does not update Jamf with the asset tags stored in Snipe.", action="store_false") runtimeargs.add_argument('--do_not_verify_ssl', help="Skips SSL verification for all requests. Helpful when you use self-signed certificate.", action="store_false") -runtimeargs.add_argument("-r", "--ratelimited", help="Puts a half second delay between Snipe IT API calls to adhere to the standard 120/minute rate limit", action="store_true") +runtimeargs.add_argument("-r", "--ratelimited", help="Puts a half second delay between API calls to adhere to the standard 120/minute rate limit", action="store_true") runtimeargs.add_argument("-f", "--force", help="Updates the Snipe asset with information from Jamf every time, despite what the timestamps indicate.", action="store_true") +runtimeargs.add_argument("--version", help="Prints the version and exits.", action="store_true") user_opts = runtimeargs.add_mutually_exclusive_group() user_opts.add_argument("-u", "--users", help="Checks out the item to the current user in Jamf if it's not already deployed", action="store_true") user_opts.add_argument("-ui", "--users_inverse", help="Checks out the item to the current user in Jamf if it's already deployed", action="store_true") @@ -73,6 +77,10 @@ type_opts.add_argument("-m", "--mobiles", help="Runs against the Jamf mobiles en type_opts.add_argument("-c", "--computers", help="Runs against the Jamf computers endpoint only.", action="store_true") user_args = runtimeargs.parse_args() +if user_args.version: + print(version) + raise SystemExit + # Notify users they're going to get a wall of text in verbose mode. if user_args.verbose: logging.basicConfig(level=logging.INFO) @@ -101,31 +109,44 @@ if 'snipe-it' not in set(config): logging.error("No valid settings.conf was found. We'll need to quit while you figure out where the settings are at. You can check the README for valid locations.") raise SystemExit("Error: No valid settings.conf - Exiting.") -logging.info("Great, we found a settings file. Let's get started by parsing all fo the settings.") - -# Set some Variables from the settings.conf: -# This is the address, cname, or FQDN for your JamfPro instance. -jamfpro_base = config['jamf']['url'] -logging.info("The configured JAMFPro base url is: {}".format(jamfpro_base)) -jamf_apiKey = config['jamf']['apikey'] -logging.debug("The API key you provided for Jamf is: {}".format(jamf_apiKey)) - -# This is the address, cname, or FQDN for your snipe-it instance. -snipe_base = config['snipe-it']['url'] -logging.info("The configured Snipe-IT base url is: {}".format(snipe_base)) -snipe_apiKey = config['snipe-it']['apikey'] -logging.debug("The API key you provided for Snipe is: {}".format(snipe_apiKey)) -defaultStatus = config['snipe-it']['defaultStatus'] -logging.info("The default status we'll be setting updated computer to is: {} (I sure hope this is a number or something is probably wrong)".format(defaultStatus)) -apple_manufacturer_id = config['snipe-it']['manufacturer_id'] -logging.info("The configured JAMFPro base url is: {} (Pretty sure this needs to be a number too)".format(apple_manufacturer_id)) +logging.info("Great, we found a settings file. Let's get started by parsing all of the settings.") -# Headers for the API call. - -logging.info("Creating the headers we'll need for API calls") -jamfheaders = {'Authorization': 'Bearer {}'.format(jamf_apiKey),'Accept': 'application/json','Content-Type':'application/json'} -snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apiKey),'Accept': 'application/json','Content-Type':'application/json'} -logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfheaders, snipeheaders)) +# While setting the variables, use a try loop so we can raise a error if something goes wrong. +try: + # Set some Variables from the settings.conf: + # This is the address, cname, or FQDN for your JamfPro instance. + logging.info("Setting the Jamf Pro Base url.") + jamfpro_base = config['jamf']['url'] + logging.debug("The configured Jamf Pro base url is: {}".format(jamfpro_base)) + + logging.info("Setting the username to request an api key.") + jamf_user = config['jamf']['username'] + logging.debug("The user you provided for Jamf is: {}".format(jamf_user)) + + logging.info("Setting the password to request an api key.") + jamf_password = config['jamf']['password'] + logging.debug("The password you provided for Jamf is: {}".format(jamf_user)) + + # This is the address, cname, or FQDN for your snipe-it instance. + logging.info("Setting the base URL for SnipeIT.") + snipe_base = config['snipe-it']['url'] + logging.debug("The configured Snipe-IT base url is: {}".format(snipe_base)) + + logging.info("Setting the API key for SnipeIT.") + snipe_apiKey = config['snipe-it']['apikey'] + logging.debug("The API key you provided for Snipe is: {}".format(snipe_apiKey)) + + logging.info("Setting the default status for SnipeIT assets.") + defaultStatus = config['snipe-it']['defaultStatus'] + logging.debug("The default status we'll be setting updated assets to is: {} (I sure hope this is a number or something is probably wrong)".format(defaultStatus)) + + logging.info("Setting the Snipe ID for Apple Manufacturer devices.") + apple_manufacturer_id = config['snipe-it']['manufacturer_id'] + logging.debug("The configured manufacturer ID for Apple computers in snipe is: {} (Pretty sure this needs to be a number too)".format(apple_manufacturer_id)) + +except: + logging.error("Some of the required settings from the settings.conf were missing or invalid. Re-run jamf2snipe with the --verbose or --debug flag to get more details on which setting is missing or misconfigured.") + raise SystemExit("Error: Missing or invalid settings in settings.conf - Exiting.") # Check the config file for correct headers @@ -154,30 +175,84 @@ for key in config['computers-api-mapping']: raise SystemExit("Invalid Subset found in settings.conf") ### Setup Some Functions ### -snipe_api_count = 0 -first_snipe_call = None -# This function is run every time a request is made, handles rate limiting for Snipe IT. +api_count = 0 +first_api_call = None + +# Headers for the API call. +logging.info("Creating the headers we'll need for API calls") +jamfbasicheaders = {'Accept': 'application/json','Content-Type':'application/json'} +snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apiKey),'Accept': 'application/json','Content-Type':'application/json'} +logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfbasicheaders, snipeheaders)) + +# Use Basic Auth to request a Jamf Token. +def request_jamf_token(): + # Tokens expire after 60 minutes, but we can't be sure that we're in the same TZ as the Jamf server, so we'll set up a timer. + global token_request_time + global jamf_apiKey + global jamfheaders + global expires_time + token_request_time = time.time() + logging.info("Requesting a new token at {}.".format(token_request_time)) + api_url = '{0}/api/v1/auth/token'.format(jamfpro_base) + # No hook for this api call. + logging.debug('Calling for a token against: {}\n The username and password can be found earlier in the script.'.format(api_url)) + # No hook for this API call. + response = requests.post(api_url, auth=(jamf_user, jamf_password), headers=jamfbasicheaders, verify=user_args.do_not_verify_ssl) + if response.status_code == 200: + logging.debug("Got back a valid 200 response code.") + jsonresponse = response.json() + logging.debug(jsonresponse) + # So we have our token and Expires time. Set the expires time globably so we can reset later. + try: + expires_time = datetime.datetime.fromisoformat(jsonresponse['expires'].replace("Z", "+00:00")) + except: + # APIs are awful and Jamf doesn't always send enough ms digits. UGH. + try: + expires_time = datetime.datetime.fromisoformat(jsonresponse['expires'].replace("Z", "0+00:00")) + except: + logging.error("Jamf sent a malformed timestamp: {}\n Please feel free to complain to Jamf support.".format(jsonresponse['expires'])) + raise SystemExit("Unable to grok Jamf Timestamp - Exiting") + logging.debug("Token expires in: {}".format(expires_time - datetime.datetime.now(datetime.timezone.utc))) + # The headers are also global, because they get used elsewhere. + logging.info("Setting new jamf headers with bearer token") + jamfheaders = {'Authorization': 'Bearer {}'.format(jsonresponse['token']),'Accept': 'application/json','Content-Type':'application/json'} + logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfheaders, snipeheaders)) + else: + logging.error("Could not obtain a token for use with Jamf's classic API. Please check your username and password.") + raise SystemExit("Unable to obtain Jamf Token") + + +# This function is run every time a request is made, handles rate limiting for Snipe IT and keeps the token fresh for Jamf. def request_handler(r, *args, **kwargs): - global snipe_api_count - global first_snipe_call - if (snipe_base in r.url) and user_args.ratelimited: + global api_count + global first_api_call + global token_request_time + + # We need to check to see if we need to get a new token. + timeleft = expires_time - datetime.datetime.now(datetime.timezone.utc) + # If there's less than 5 minutes (300 seconds) left on the token, get a new one. + if timeleft < datetime.timedelta(seconds=300): + request_jamf_token() + + # Slow and steady wins the race. Limit all API calls (not just to snipe) to the Rate limit. + if user_args.ratelimited: if '"messages":429' in r.text: logging.warn("Despite respecting the rate limit of Snipe, we've still been limited. Trying again after sleeping for 2 seconds.") time.sleep(2) re_req = r.request s = requests.Session() return s.send(re_req) - if snipe_api_count == 0: - first_snipe_call = time.time() + if api_count == 0: + first_api_call = time.time() time.sleep(0.5) - snipe_api_count += 1 - time_elapsed = (time.time() - first_snipe_call) - snipe_api_rate = snipe_api_count / time_elapsed - if snipe_api_rate > 1.95: - sleep_time = 0.5 + (snipe_api_rate - 1.95) - logging.debug('Going over snipe rate limit of 120/minute ({}/minute), sleeping for {}'.format(snipe_api_rate,sleep_time)) + api_count += 1 + time_elapsed = (time.time() - first_api_call) + api_rate = api_count / time_elapsed + if api_rate > 1.95: + sleep_time = 0.5 + (api_rate - 1.95) + logging.debug('Going over snipe rate limit of 120/minute ({}/minute), sleeping for {}'.format(api_rate,sleep_time)) time.sleep(sleep_time) - logging.debug("Made {} requests to Snipe IT in {} seconds, with a request being sent every {} seconds".format(snipe_api_count, time_elapsed, snipe_api_rate)) + logging.debug("Made {} requests to Snipe IT in {} seconds, with a request being sent every {} seconds".format(api_count, time_elapsed, api_rate)) if '"messages":429' in r.text: logging.error(r.content) raise SystemExit("We've been rate limited. Use option -r to respect the built in Snipe IT API rate limit of 120/minute.") @@ -563,15 +638,15 @@ def checkout_snipe_asset(user, asset_id, checked_out_user=None): # Report if we're verifying SSL or not. logging.info("SSL Verification is set to: {}".format(user_args.do_not_verify_ssl)) -# Do some tests to see if the hosts are up. +# Do some tests to see if the hosts are up. Don't use hooks for these as we don't have tokens yet. logging.info("Running tests to see if hosts are up.") try: - SNIPE_UP = True if requests.get(snipe_base, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}).status_code == 200 else False + SNIPE_UP = True if requests.get(snipe_base, verify=user_args.do_not_verify_ssl).status_code == 200 else False except Exception as e: logging.exception(e) SNIPE_UP = False try: - JAMF_UP = True if requests.get(jamfpro_base, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}).status_code in (200, 401) else False + JAMF_UP = True if requests.get(jamfpro_base, verify=user_args.do_not_verify_ssl).status_code in (200, 401) else False except Exception as e: logging.exception(e) JAMF_UP = False @@ -589,8 +664,9 @@ else: if ( JAMF_UP == False ) or ( SNIPE_UP == False ): raise SystemExit("Error: Host could not be contacted.") -# Test that we can actually connect with the API keys. -##TODO Write some more tests here. ha! +# Test that we can actually connect with the API keys by getting a bearer token. +request_jamf_token() + logging.info("Finished running our tests.") @@ -673,6 +749,14 @@ for jamf_type in jamf_types: if jamf == None: continue + # If the entry doesn't contain a serial, then we need to skip this entry. + if jamf['general']['serial_number'] == 'Not Available': + logging.warning("The serial number is not available in JAMF. This is normal for DEP enrolled devices that have not yet checked in for the first time and for personal mobile devices. Since there's no serial number yet, we'll skip it for now.") + continue + if jamf['general']['serial_number'] == None: + logging.warning("The serial number is not available in JAMF. This is normal for DEP enrolled devices that have not yet checked in for the first time and for personal mobile devices. Since there's no serial number yet, we'll skip it for now.") + continue + # Check that the model number exists in snipe, if not create it. if jamf_type == 'computers': if jamf['hardware']['model_identifier'] not in modelnumbers: @@ -723,9 +807,6 @@ for jamf_type in jamf_types: elif jamf_type == 'computers': logging.debug("Payload is being made for a computer") 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']} - if jamf['general']['serial_number'] == 'Not Available': - logging.warning("The serial number is not available in JAMF. This is normal for DEP enrolled devices that have not yet checked in for the first time. Since there's no serial number yet, we'll skip it for now.") - continue else: for snipekey in config['{}-api-mapping'.format(jamf_type)]: jamfsplit = config['{}-api-mapping'.format(jamf_type)][snipekey].split() @@ -854,4 +935,4 @@ for jamf_type in jamf_types: update_jamf_mobiledevice_asset_tag("{}".format(jamf['general']['id']), '{}'.format(snipe['rows'][0]['asset_tag'])) logging.info("Device is a mobile device, updating the mobile device record") -logging.debug('Total amount of API calls made: {}'.format(snipe_api_count)) +logging.debug('Total amount of API calls made: {}'.format(api_count)) diff --git a/settings.conf.example b/settings.conf.example index 8bf7534..ddb4f2c 100644 --- a/settings.conf.example +++ b/settings.conf.example @@ -1,6 +1,8 @@ [jamf] +# This entire section is Required url = https://yourinstance.jamfcloud.com -apikey = YOUR-API-KEY-HERE +username = yourJamfUsername +password = $ecretJ@mfPassw0rd [snipe-it] #Required