Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 33 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
179 changes: 130 additions & 49 deletions jamf2snipe
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
# _snipeit_custom_name_1234567890 = subset jamf_key
#
# A list of valid subsets are:
version = "1.0.1"

validsubset = [
"general",
"location",
Expand All @@ -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()
Expand All @@ -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")
Expand All @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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
Expand All @@ -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.")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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))
Loading