Skip to content

Commit 8da3c99

Browse files
authored
Merge pull request #90 from grokability/devel
Release 1.0.1
2 parents b63fc8e + e539f4c commit 8da3c99

File tree

3 files changed

+166
-80
lines changed

3 files changed

+166
-80
lines changed

README.md

Lines changed: 33 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,39 @@
11
# jamf2snipe
22
## Import/Sync Computers from JAMF to Snipe-IT
33
```
4-
usage: jamf2snipe [-h] [-v] [--dryrun] [-d] [--do_not_verify_ssl] [-r]
5-
[--no_search] [-u | -ui | -uf] [-m | -c]
6-
7-
optional arguments:
8-
-h, --help show this help message and exit
9-
-v, --verbose Sets the logging level to INFO and gives you a better
10-
idea of what the script is doing.
11-
--dryrun This checks your config and tries to contact both the
12-
JAMFPro and Snipe-it instances, but exits before
13-
updating or syncing any assets.
14-
-d, --debug Sets logging to include additional DEBUG messages.
15-
--do_not_update_jamf Does not update Jamf with the asset tags stored in
16-
Snipe.
17-
--do_not_verify_ssl Skips SSL verification for all requests. Helpful when
18-
you use self-signed certificate.
19-
-r, --ratelimited Puts a half second delay between Snipe IT API calls to
20-
adhere to the standard 120/minute rate limit
21-
-f, --force Updates the Snipe asset with information from Jamf
22-
every time, despite what the timestamps indicate.
23-
-u, --users Checks out the item to the current user in Jamf if
24-
it's not already deployed
25-
-ui, --users_inverse Checks out the item to the current user in Jamf if
26-
it's already deployed
27-
-uf, --users_force Checks out the item to the user specified in Jamf no
28-
matter what
29-
-uns, --users_no_search
30-
Doesn't search for any users if the specified fields
31-
in Jamf and Snipe don't match. (case insensitive)
32-
-m, --mobiles Runs against the Jamf mobiles endpoint only.
33-
-c, --computers Runs against the Jamf computers endpoint only.
4+
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]
5+
6+
options:
7+
-h, --help show this help message and exit
8+
-v, --verbose Sets the logging level to INFO and gives you a better
9+
idea of what the script is doing.
10+
--auto_incrementing You can use this if you have auto-incrementing
11+
enabled in your snipe instance to utilize that
12+
instead of adding the Jamf ID for the asset tag.
13+
--dryrun This checks your config and tries to contact both
14+
the JAMFPro and Snipe-it instances, but exits before
15+
updating or syncing any assets.
16+
-d, --debug Sets logging to include additional DEBUG messages.
17+
--do_not_update_jamf Does not update Jamf with the asset tags stored in
18+
Snipe.
19+
--do_not_verify_ssl Skips SSL verification for all requests. Helpful when
20+
you use self-signed certificate.
21+
-r, --ratelimited Puts a half second delay between API calls to adhere
22+
to the standard 120/minute rate limit
23+
-f, --force Updates the Snipe asset with information from Jamf
24+
every time, despite what the timestamps indicate.
25+
--version Prints the version and exits.
26+
-u, --users Checks out the item to the current user in Jamf if
27+
it's not already deployed
28+
-ui, --users_inverse Checks out the item to the current user in Jamf if
29+
it's already deployed
30+
-uf, --users_force Checks out the item to the user specified in Jamf no
31+
matter what
32+
-uns, --users_no_search
33+
Doesn't search for any users if the specified fields
34+
in Jamf and Snipe don't match. (case insensitive)
35+
-m, --mobiles Runs against the Jamf mobiles endpoint only.
36+
-c, --computers Runs against the Jamf computers endpoint only.
3437
```
3538

3639
## Overview:

jamf2snipe

Lines changed: 130 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
# _snipeit_custom_name_1234567890 = subset jamf_key
3131
#
3232
# A list of valid subsets are:
33+
version = "1.0.1"
34+
3335
validsubset = [
3436
"general",
3537
"location",
@@ -52,6 +54,7 @@ import time
5254
import configparser
5355
import argparse
5456
import logging
57+
import datetime
5558

5659
# Set us up for using runtime arguments by defining them.
5760
runtimeargs = argparse.ArgumentParser()
@@ -61,8 +64,9 @@ runtimeargs.add_argument("--dryrun", help="This checks your config and tries to
6164
runtimeargs.add_argument("-d", "--debug", help="Sets logging to include additional DEBUG messages.", action="store_true")
6265
runtimeargs.add_argument("--do_not_update_jamf", help="Does not update Jamf with the asset tags stored in Snipe.", action="store_false")
6366
runtimeargs.add_argument('--do_not_verify_ssl', help="Skips SSL verification for all requests. Helpful when you use self-signed certificate.", action="store_false")
64-
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")
67+
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")
6568
runtimeargs.add_argument("-f", "--force", help="Updates the Snipe asset with information from Jamf every time, despite what the timestamps indicate.", action="store_true")
69+
runtimeargs.add_argument("--version", help="Prints the version and exits.", action="store_true")
6670
user_opts = runtimeargs.add_mutually_exclusive_group()
6771
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")
6872
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
7377
type_opts.add_argument("-c", "--computers", help="Runs against the Jamf computers endpoint only.", action="store_true")
7478
user_args = runtimeargs.parse_args()
7579

80+
if user_args.version:
81+
print(version)
82+
raise SystemExit
83+
7684
# Notify users they're going to get a wall of text in verbose mode.
7785
if user_args.verbose:
7886
logging.basicConfig(level=logging.INFO)
@@ -101,31 +109,44 @@ if 'snipe-it' not in set(config):
101109
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.")
102110
raise SystemExit("Error: No valid settings.conf - Exiting.")
103111

104-
logging.info("Great, we found a settings file. Let's get started by parsing all fo the settings.")
105-
106-
# Set some Variables from the settings.conf:
107-
# This is the address, cname, or FQDN for your JamfPro instance.
108-
jamfpro_base = config['jamf']['url']
109-
logging.info("The configured JAMFPro base url is: {}".format(jamfpro_base))
110-
jamf_apiKey = config['jamf']['apikey']
111-
logging.debug("The API key you provided for Jamf is: {}".format(jamf_apiKey))
112-
113-
# This is the address, cname, or FQDN for your snipe-it instance.
114-
snipe_base = config['snipe-it']['url']
115-
logging.info("The configured Snipe-IT base url is: {}".format(snipe_base))
116-
snipe_apiKey = config['snipe-it']['apikey']
117-
logging.debug("The API key you provided for Snipe is: {}".format(snipe_apiKey))
118-
defaultStatus = config['snipe-it']['defaultStatus']
119-
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))
120-
apple_manufacturer_id = config['snipe-it']['manufacturer_id']
121-
logging.info("The configured JAMFPro base url is: {} (Pretty sure this needs to be a number too)".format(apple_manufacturer_id))
112+
logging.info("Great, we found a settings file. Let's get started by parsing all of the settings.")
122113

123-
# Headers for the API call.
124-
125-
logging.info("Creating the headers we'll need for API calls")
126-
jamfheaders = {'Authorization': 'Bearer {}'.format(jamf_apiKey),'Accept': 'application/json','Content-Type':'application/json'}
127-
snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apiKey),'Accept': 'application/json','Content-Type':'application/json'}
128-
logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfheaders, snipeheaders))
114+
# While setting the variables, use a try loop so we can raise a error if something goes wrong.
115+
try:
116+
# Set some Variables from the settings.conf:
117+
# This is the address, cname, or FQDN for your JamfPro instance.
118+
logging.info("Setting the Jamf Pro Base url.")
119+
jamfpro_base = config['jamf']['url']
120+
logging.debug("The configured Jamf Pro base url is: {}".format(jamfpro_base))
121+
122+
logging.info("Setting the username to request an api key.")
123+
jamf_user = config['jamf']['username']
124+
logging.debug("The user you provided for Jamf is: {}".format(jamf_user))
125+
126+
logging.info("Setting the password to request an api key.")
127+
jamf_password = config['jamf']['password']
128+
logging.debug("The password you provided for Jamf is: {}".format(jamf_user))
129+
130+
# This is the address, cname, or FQDN for your snipe-it instance.
131+
logging.info("Setting the base URL for SnipeIT.")
132+
snipe_base = config['snipe-it']['url']
133+
logging.debug("The configured Snipe-IT base url is: {}".format(snipe_base))
134+
135+
logging.info("Setting the API key for SnipeIT.")
136+
snipe_apiKey = config['snipe-it']['apikey']
137+
logging.debug("The API key you provided for Snipe is: {}".format(snipe_apiKey))
138+
139+
logging.info("Setting the default status for SnipeIT assets.")
140+
defaultStatus = config['snipe-it']['defaultStatus']
141+
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))
142+
143+
logging.info("Setting the Snipe ID for Apple Manufacturer devices.")
144+
apple_manufacturer_id = config['snipe-it']['manufacturer_id']
145+
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))
146+
147+
except:
148+
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.")
149+
raise SystemExit("Error: Missing or invalid settings in settings.conf - Exiting.")
129150

130151
# Check the config file for correct headers
131152

@@ -154,30 +175,84 @@ for key in config['computers-api-mapping']:
154175
raise SystemExit("Invalid Subset found in settings.conf")
155176

156177
### Setup Some Functions ###
157-
snipe_api_count = 0
158-
first_snipe_call = None
159-
# This function is run every time a request is made, handles rate limiting for Snipe IT.
178+
api_count = 0
179+
first_api_call = None
180+
181+
# Headers for the API call.
182+
logging.info("Creating the headers we'll need for API calls")
183+
jamfbasicheaders = {'Accept': 'application/json','Content-Type':'application/json'}
184+
snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apiKey),'Accept': 'application/json','Content-Type':'application/json'}
185+
logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfbasicheaders, snipeheaders))
186+
187+
# Use Basic Auth to request a Jamf Token.
188+
def request_jamf_token():
189+
# 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.
190+
global token_request_time
191+
global jamf_apiKey
192+
global jamfheaders
193+
global expires_time
194+
token_request_time = time.time()
195+
logging.info("Requesting a new token at {}.".format(token_request_time))
196+
api_url = '{0}/api/v1/auth/token'.format(jamfpro_base)
197+
# No hook for this api call.
198+
logging.debug('Calling for a token against: {}\n The username and password can be found earlier in the script.'.format(api_url))
199+
# No hook for this API call.
200+
response = requests.post(api_url, auth=(jamf_user, jamf_password), headers=jamfbasicheaders, verify=user_args.do_not_verify_ssl)
201+
if response.status_code == 200:
202+
logging.debug("Got back a valid 200 response code.")
203+
jsonresponse = response.json()
204+
logging.debug(jsonresponse)
205+
# So we have our token and Expires time. Set the expires time globably so we can reset later.
206+
try:
207+
expires_time = datetime.datetime.fromisoformat(jsonresponse['expires'].replace("Z", "+00:00"))
208+
except:
209+
# APIs are awful and Jamf doesn't always send enough ms digits. UGH.
210+
try:
211+
expires_time = datetime.datetime.fromisoformat(jsonresponse['expires'].replace("Z", "0+00:00"))
212+
except:
213+
logging.error("Jamf sent a malformed timestamp: {}\n Please feel free to complain to Jamf support.".format(jsonresponse['expires']))
214+
raise SystemExit("Unable to grok Jamf Timestamp - Exiting")
215+
logging.debug("Token expires in: {}".format(expires_time - datetime.datetime.now(datetime.timezone.utc)))
216+
# The headers are also global, because they get used elsewhere.
217+
logging.info("Setting new jamf headers with bearer token")
218+
jamfheaders = {'Authorization': 'Bearer {}'.format(jsonresponse['token']),'Accept': 'application/json','Content-Type':'application/json'}
219+
logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfheaders, snipeheaders))
220+
else:
221+
logging.error("Could not obtain a token for use with Jamf's classic API. Please check your username and password.")
222+
raise SystemExit("Unable to obtain Jamf Token")
223+
224+
225+
# This function is run every time a request is made, handles rate limiting for Snipe IT and keeps the token fresh for Jamf.
160226
def request_handler(r, *args, **kwargs):
161-
global snipe_api_count
162-
global first_snipe_call
163-
if (snipe_base in r.url) and user_args.ratelimited:
227+
global api_count
228+
global first_api_call
229+
global token_request_time
230+
231+
# We need to check to see if we need to get a new token.
232+
timeleft = expires_time - datetime.datetime.now(datetime.timezone.utc)
233+
# If there's less than 5 minutes (300 seconds) left on the token, get a new one.
234+
if timeleft < datetime.timedelta(seconds=300):
235+
request_jamf_token()
236+
237+
# Slow and steady wins the race. Limit all API calls (not just to snipe) to the Rate limit.
238+
if user_args.ratelimited:
164239
if '"messages":429' in r.text:
165240
logging.warn("Despite respecting the rate limit of Snipe, we've still been limited. Trying again after sleeping for 2 seconds.")
166241
time.sleep(2)
167242
re_req = r.request
168243
s = requests.Session()
169244
return s.send(re_req)
170-
if snipe_api_count == 0:
171-
first_snipe_call = time.time()
245+
if api_count == 0:
246+
first_api_call = time.time()
172247
time.sleep(0.5)
173-
snipe_api_count += 1
174-
time_elapsed = (time.time() - first_snipe_call)
175-
snipe_api_rate = snipe_api_count / time_elapsed
176-
if snipe_api_rate > 1.95:
177-
sleep_time = 0.5 + (snipe_api_rate - 1.95)
178-
logging.debug('Going over snipe rate limit of 120/minute ({}/minute), sleeping for {}'.format(snipe_api_rate,sleep_time))
248+
api_count += 1
249+
time_elapsed = (time.time() - first_api_call)
250+
api_rate = api_count / time_elapsed
251+
if api_rate > 1.95:
252+
sleep_time = 0.5 + (api_rate - 1.95)
253+
logging.debug('Going over snipe rate limit of 120/minute ({}/minute), sleeping for {}'.format(api_rate,sleep_time))
179254
time.sleep(sleep_time)
180-
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))
255+
logging.debug("Made {} requests to Snipe IT in {} seconds, with a request being sent every {} seconds".format(api_count, time_elapsed, api_rate))
181256
if '"messages":429' in r.text:
182257
logging.error(r.content)
183258
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):
563638
# Report if we're verifying SSL or not.
564639
logging.info("SSL Verification is set to: {}".format(user_args.do_not_verify_ssl))
565640

566-
# Do some tests to see if the hosts are up.
641+
# Do some tests to see if the hosts are up. Don't use hooks for these as we don't have tokens yet.
567642
logging.info("Running tests to see if hosts are up.")
568643
try:
569-
SNIPE_UP = True if requests.get(snipe_base, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}).status_code == 200 else False
644+
SNIPE_UP = True if requests.get(snipe_base, verify=user_args.do_not_verify_ssl).status_code == 200 else False
570645
except Exception as e:
571646
logging.exception(e)
572647
SNIPE_UP = False
573648
try:
574-
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
649+
JAMF_UP = True if requests.get(jamfpro_base, verify=user_args.do_not_verify_ssl).status_code in (200, 401) else False
575650
except Exception as e:
576651
logging.exception(e)
577652
JAMF_UP = False
@@ -589,8 +664,9 @@ else:
589664
if ( JAMF_UP == False ) or ( SNIPE_UP == False ):
590665
raise SystemExit("Error: Host could not be contacted.")
591666

592-
# Test that we can actually connect with the API keys.
593-
##TODO Write some more tests here. ha!
667+
# Test that we can actually connect with the API keys by getting a bearer token.
668+
request_jamf_token()
669+
594670

595671
logging.info("Finished running our tests.")
596672

@@ -673,6 +749,14 @@ for jamf_type in jamf_types:
673749
if jamf == None:
674750
continue
675751

752+
# If the entry doesn't contain a serial, then we need to skip this entry.
753+
if jamf['general']['serial_number'] == 'Not Available':
754+
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.")
755+
continue
756+
if jamf['general']['serial_number'] == None:
757+
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.")
758+
continue
759+
676760
# Check that the model number exists in snipe, if not create it.
677761
if jamf_type == 'computers':
678762
if jamf['hardware']['model_identifier'] not in modelnumbers:
@@ -723,9 +807,6 @@ for jamf_type in jamf_types:
723807
elif jamf_type == 'computers':
724808
logging.debug("Payload is being made for a computer")
725809
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']}
726-
if jamf['general']['serial_number'] == 'Not Available':
727-
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.")
728-
continue
729810
else:
730811
for snipekey in config['{}-api-mapping'.format(jamf_type)]:
731812
jamfsplit = config['{}-api-mapping'.format(jamf_type)][snipekey].split()
@@ -854,4 +935,4 @@ for jamf_type in jamf_types:
854935
update_jamf_mobiledevice_asset_tag("{}".format(jamf['general']['id']), '{}'.format(snipe['rows'][0]['asset_tag']))
855936
logging.info("Device is a mobile device, updating the mobile device record")
856937

857-
logging.debug('Total amount of API calls made: {}'.format(snipe_api_count))
938+
logging.debug('Total amount of API calls made: {}'.format(api_count))

0 commit comments

Comments
 (0)