Skip to content

Commit 4283a71

Browse files
author
Brian Monroe
committed
First Commit
0 parents  commit 4283a71

File tree

4 files changed

+319
-0
lines changed

4 files changed

+319
-0
lines changed

LICENSE

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
Copyright 2018 Brian Monroe
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4+
5+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6+
7+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

README.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Requirements: This tool requires that python3 be installed on your system with the requests, json, time, and configparser python libs available. You will also need network access to both your jamf and snipe environments. You will also need a jamf username and password that has access to the API and write permissions for computer assets. You will need a snipe api key for a user that has edit/create permissions for assets and models.
2+
3+
Overview: What does it do? This tool will sync assets between a JAMFPro instance and a snipe-it instance. The tool searches for assets based of of the serial number, and not the existing asset tag. If assets exist in JAMF and are not in snipe, the tool will create an asset, and try to match it with the model information available in snipe. If it can't find an appropriate model, it will create one. You need to set the manufacturer_id for Apple devices in the settings.conf file. When an asset is first created, it will fill out only the most basic information. When the asset alread exists in your Snipe Inventory, the tool will sync the information you specify in the settings.conf file and make sure that the asset_tag field in jamf matches the asset tag in snipe, where snipe's info is considered the authority. Lastly, if the asset_tag field is blank in JAMF when it is being created, then the tool will look for a 4 or more digit number in the computer name. If it fails to find one, it will use JAMFID-<jamfid#> as the asset tag in snipe. This way, you can easily filter this out and run scripts against it to correct in the future.
4+
5+
6+
Installation: Copy the files to your system (recommend installing to /opt/jamf2snipe/* ). Make sure you meet all the system requirement. Edit the settings.conf to match your current environment. The script will look for a valid settings.conf in /opt/jamf2snipe/settings.conf, /etc/jamf2snipe/settings.conf, or in the current folder (in that order): so either copy the file to one of those locations, or be sure that the user running the program is in the same folder as the settings.conf.
7+
8+
Configuration: All of the settings that are listed in the settings.conf are required except for the api-mapping section. It's recommended that you install these files to /opt/jamf2snipe/ and run them from there. You will need valid subsets of from JAMF's api to associate fields into snipe. More information can be found in the ./jamf2snipe file about associations and valid subsets.

jamf2snipe

+289
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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.')

settings.conf

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[jamf]
2+
url = https://yourinstance.jamfcloud.com
3+
username = a-valid-username
4+
password = a-valid-password
5+
6+
[snipe-it]
7+
url = http://FQDN.your.snipe.instance.com
8+
apikey = YOUR-API-KEY-HERE
9+
manufacturer_id = 1
10+
# Default status ID (4 = pending)
11+
defaultStatus = 4
12+
13+
[api-mapping]
14+
name = general name
15+
_snipeit_mac_address_1 = general mac_address

0 commit comments

Comments
 (0)