├── .gitignore ├── requirements.txt ├── LICENSE ├── settings.conf.example ├── README.md └── jamf2snipe /.gitignore: -------------------------------------------------------------------------------- 1 | settings.conf 2 | /.idea 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | configparser 2 | requests -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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. 8 | -------------------------------------------------------------------------------- /settings.conf.example: -------------------------------------------------------------------------------- 1 | [jamf] 2 | # This entire section is Required 3 | url = https://yourinstance.jamfcloud.com 4 | client_id = yourClientID 5 | client_secret = yourClientSecret 6 | 7 | [snipe-it] 8 | #Required 9 | url = https://FQDN.your.snipe.instance.com 10 | apikey = YOUR-API-KEY-HERE 11 | manufacturer_id = 2 12 | defaultStatus = 2 13 | computer_model_category_id = 2 14 | mobile_model_category_id = 3 15 | #Not Required, uncomment to use 16 | #computer_custom_fieldset_id = 3 17 | #mobile_custom_fieldset_id = 4 18 | #asset_tag = general serial_number # If not specified, defaults to jamf-{id} or jamf-m-{id} 19 | 20 | # EDIT THESE FIELDS SO THEY CORRESPOND WITH THE CUSTOM FIELD DB COLUMN NAMES IN YOUR SNIPE-IT INSTANCE 21 | [computers-api-mapping] 22 | name = general name 23 | _snipeit_mac_address_1 = general mac_address 24 | _snipeit_ram_2 = hardware total_ram_mb 25 | _snipeit_hdd_3 = hardware storage drive_capacity_mb 26 | 27 | 28 | [mobile_devices-api-mapping] 29 | _snipeit_imei_4 = network imei 30 | name = general name 31 | 32 | [user-mapping] # The field from jamf that you want to search Snipe with 33 | jamf_api_field = location username 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jamf2snipe 2 | ## Import/Sync Computers from JAMF to Snipe-IT 3 | ``` 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. 37 | ``` 38 | 39 | ## Overview: 40 | This tool will sync assets between a JAMF Pro instance and a Snipe-IT instance. The tool searches for assets based on the serial number, not the existing asset tag. If assets exist in JAMF and are not in Snipe-IT, the tool will create an asset and try to match it with an existing Snipe model. This match is based on the Mac's model identifier (ex. MacBookAir7,1) being entered as the model number in Snipe, rather than the model name. If a matching model isn't found, it will create one. 41 | 42 | When an asset is first created, it will fill out only the most basic information. When the asset already 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. 43 | 44 | > Because it determines whether JAMF or Snipe has the most recently updated record, there is the potential to have blank data in Jamf overwrite good data in Snipe (ex. purchase date). 45 | 46 | Lastly, if the asset_tag field is blank in JAMF when it is being created in Snipe, 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- as the asset tag in Snipe. This way, you can easily filter this out and run scripts against it to correct in the future. 47 | 48 | ## Requirements: 49 | 50 | - Python3 (3.7 or higher) is installed on your system with the requests, json, time, and configparser python libs installed. 51 | - Network access to both your JAMF and Snipe-IT environments. 52 | - A JAMF client_id and client_secret for the JAMF API that has read & write permissions for computer assets, mobile device assets, and users. 53 | - Computers: Read, Update 54 | - Mobile Devices: Read, Update 55 | - Users: Read, Update 56 | - Snipe API key for a user that has edit/create permissions for assets and models. Snipe-IT documentation instructions for creating API keys: [https://snipe-it.readme.io/reference#generating-api-tokens](https://snipe-it.readme.io/reference#generating-api-tokens) 57 | 58 | ## Installation: 59 | 60 | ### Mac 61 | 62 | 1. Install Python 3.7 or later 63 | - Grab the latest PKG installer from the Python website and run it. 64 | 65 | 2. Add Python 3.7 or later to your PATH 66 | - Run the `Update Shell Profile.command` script in the `/Applications/Python 3.X` folder to add `python3.X` your PATH 67 | 68 | 3. Create a virtualenv for jamf2snipe 69 | - Create the virtualenv: `mkdir ~/.virtualenv` 70 | - Add `python3.X` to the virtualenv: `python3.X -m venv ~/.virtualenv/jamf2snipe` 71 | - Activate the virtualenv: `source ~/.virtualenv/jamf2snipe/bin/activate` 72 | 73 | 4. Install dependencies 74 | - `pip install -r /path/to/jamf2snipe/requirements.txt` 75 | 76 | 5. Configure settings.conf (you can start by copying settings.conf.example to settings.conf) 77 | 6. Run `python jamf2snipe` & profit 78 | 79 | ### Linux 80 | 81 | 1. Copy the files to your system (recommend installing to /opt/jamf2snipe/* ). Make sure you meet all the system requirements. 82 | 2. Edit the settings.conf to match your current environment - you can start by copying settings.conf.example to settings.conf. 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. 83 | 84 | ## Configuration - settings.conf: 85 | 86 | All of the settings that are listed in the [settings.conf.example](https://github.com/grokability/jamf2snipe/blob/main/settings.conf.example) 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 from [JAMF's API](https://developer.jamf.com/apis/classic-api/index) to associate fields into Snipe. 87 | 88 | ### Required 89 | 90 | Note: do not add `""` or `''` around any values. 91 | 92 | **[jamf]** 93 | 94 | - `url`: https://*your_jamf_instance*.com:*port* 95 | - `client_id`: Jamf API client ID 96 | - `client_secret`: Jamf API client secret 97 | 98 | **[snipe-it]** 99 | 100 | Check out the [settings.conf.example](https://github.com/grokability/jamf2snipe/blob/main/settings.conf.example) file for the full documentation 101 | 102 | - `url`: http://*your_snipe_instance*.com 103 | - `apikey`: API key generated via [these steps](https://snipe-it.readme.io/reference#generating-api-tokens). 104 | - `manufacturer_id`: The manufacturer database field id for the Apple in your Snipe-IT instance. You will probably have to create a Manufacturer in Snipe-IT and note its ID. 105 | - `defaultStatus`: The status database field id to assign to any assets created in Snipe-IT from JAMF. Usually you will want to pick a status like "Ready To Deploy" - look up its ID in Snipe-IT and put the ID here. 106 | - `computer_model_category_id`: The ID of the category you want to assign to JAMF computers. You will have to create this in Snipe-IT and note the Category ID 107 | - `mobile_model_category_id`: The ID of the category you want to assign to JAMF mobile devices. You will have to create this in Snipe-IT and note the Category ID 108 | 109 | ### API Mapping 110 | 111 | To get the database fields for Snipe-IT Custom Fields, go to Custom Fields, scroll down past Fieldsets to Custom Fields, click the column selection and button and select the unchecked 'DB Field' checkbox. Copy and paste the DB Field name for the Snipe under api-mapping in settings.conf. 112 | 113 | To get the database fields for Jamf, refer to Jamf's ["Classic" API documentation](https://developer.jamf.com/apis/classic-api/index). 114 | 115 | You need to set the manufacturer_id for Apple devices in the settings.conf file. To get this, go to Manufacturers, click the column selection button and select the `ID` checkbox. 116 | 117 | Some example API mappings can be found below: 118 | 119 | - Computer Name: `name = general name` 120 | - MAC Address: `_snipeit_mac_address_1 = general mac_address` 121 | - IPv4 Address: `_snipeit_ = general ip_address` 122 | - Purchase Cost: `purchase_cost = purchasing purchase_price` 123 | - Purchase Date: `purchase_date = purchasing po_date` 124 | - OS Version: `_snipeit_ = hardware os_version` 125 | - Extension Attribute: `_snipe_it_ = extension_attributes ` 126 | 127 | More information can be found in the ./jamf2snipe file about associations and [valid subsets](https://github.com/ParadoxGuitarist/jamf2snipe/blob/master/jamf2snipe#L33). 128 | 129 | ## Testing 130 | 131 | It is *always* a good idea to create a test environment to ensure everything works as expected before running anything in production. 132 | 133 | Because `jamf2snipe` only ever writes the asset_tag for a matching serial number back to Jamf, testing with your production JAMF Pro is OK. However, this can overwrite good data in Snipe. You can spin up a Snipe instance in Docker pretty quickly ([see the Snipe docs](https://snipe-it.readme.io/docs/docker)). 134 | 135 | ## Contributing 136 | 137 | Thanks to all of the people that have already contributed to this project! If you have something you'd like to add please help by forking this project then creating a pull request to the `devel` branch. When working on new features, please try to keep existing configs running in the same manner with no changes. When possible, open up an issue and reference it when you make your pull request. 138 | -------------------------------------------------------------------------------- /jamf2snipe: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env 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 | # MIT 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 | version = "1.0.7" 34 | 35 | validsubset = [ 36 | "general", 37 | "location", 38 | "purchasing", 39 | "peripherals", 40 | "hardware", 41 | "certificates", 42 | "software", 43 | "extension_attributes", 44 | "groups_accounts", 45 | "iphones", 46 | "configuration_profiles" 47 | ] 48 | 49 | 50 | # Import all the things 51 | import json 52 | import time 53 | import configparser 54 | import argparse 55 | import logging 56 | import datetime 57 | from requests import Session 58 | from requests.adapters import HTTPAdapter 59 | from urllib3 import Retry 60 | 61 | # Set us up for using runtime arguments by defining them. 62 | runtimeargs = argparse.ArgumentParser() 63 | runtimeargs.add_argument("-v", "--verbose", help="Sets the logging level to INFO and gives you a better idea of what the script is doing.", action="store_true") 64 | runtimeargs.add_argument("--auto_incrementing", help="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.", action="store_true") 65 | runtimeargs.add_argument("--dryrun", help="This checks your config and tries to contact both the JAMFPro and Snipe-it instances, and will generate the assets for debugging, but not update or sync anything but exits before updating or syncing any assets.", action="store_true") 66 | runtimeargs.add_argument("--connection_test", help="This checks your config and tries to contact both the JAMFPro and Snipe-it instances.", action="store_true") 67 | runtimeargs.add_argument("-d", "--debug", help="Sets logging to include additional DEBUG messages.", action="store_true") 68 | runtimeargs.add_argument("--do_not_update_jamf", help="Does not update Jamf with the asset tags stored in Snipe.", action="store_false") 69 | runtimeargs.add_argument('--do_not_verify_ssl', help="Skips SSL verification for all requests. Helpful when you use self-signed certificate.", action="store_false") 70 | 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") 71 | runtimeargs.add_argument("-f", "--force", help="Updates the Snipe asset with information from Jamf every time, despite what the timestamps indicate.", action="store_true") 72 | runtimeargs.add_argument("--version", help="Prints the version and exits.", action="store_true") 73 | user_opts = runtimeargs.add_mutually_exclusive_group() 74 | 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") 75 | 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") 76 | user_opts.add_argument("-uf", "--users_force", help="Checks out the item to the user specified in Jamf no matter what", action="store_true") 77 | runtimeargs.add_argument("-uns", "--users_no_search", help="Doesn't search for any users if the specified fields in Jamf and Snipe don't match. (case insensitive)", action="store_true") 78 | type_opts = runtimeargs.add_mutually_exclusive_group() 79 | type_opts.add_argument("-m", "--mobiles", help="Runs against the Jamf mobiles endpoint only.", action="store_true") 80 | type_opts.add_argument("-c", "--computers", help="Runs against the Jamf computers endpoint only.", action="store_true") 81 | user_args = runtimeargs.parse_args() 82 | 83 | if user_args.version: 84 | print(version) 85 | raise SystemExit 86 | 87 | # Notify users they're going to get a wall of text in verbose mode. 88 | if user_args.debug: 89 | logging.basicConfig(level=logging.DEBUG) 90 | elif user_args.verbose: 91 | logging.basicConfig(level=logging.INFO) 92 | else: 93 | logging.basicConfig(level=logging.WARNING) 94 | 95 | # Notify users if we're doing a connection test. 96 | if user_args.connection_test and user_args.dryrun: 97 | logging.error("You can't use --connection_test and --dryrun at the same time. Please choose one or the other.") 98 | raise SystemExit("Error: Invalid runtime arguments - Exiting.") 99 | if user_args.connection_test and user_args.force: 100 | logging.error("You can't use --connection_test and --force at the same time. Please choose one or the other.") 101 | raise SystemExit("Error: Invalid runtime arguments - Exiting.") 102 | if user_args.connection_test: 103 | print("Connection test: Starting jamf2snipe with a connection test where we'll try to contact both the JAMFPro and Snipe-it instances.") 104 | 105 | # Notify users if we're doing a dry run. 106 | if user_args.dryrun and user_args.force: 107 | print("Running a dry run with force enabled. This will generate assets for debugging, but not update or sync anything.") 108 | elif user_args.dryrun: 109 | print("Dryrun: Starting jamf2snipe with a dry run where no assets will be updated.") 110 | 111 | # Find a valid settings.conf file. 112 | logging.info("Searching for a valid settings.conf file.") 113 | config = configparser.ConfigParser() 114 | logging.debug("Checking for a settings.conf in /opt/jamf2snipe ...") 115 | config.read("/opt/jamf2snipe/settings.conf") 116 | if 'snipe-it' not in set(config): 117 | logging.debug("No valid config found in: /opt Checking for a settings.conf in /etc/jamf2snipe ...") 118 | config.read('/etc/jamf2snipe/settings.conf') 119 | if 'snipe-it' not in set(config): 120 | logging.debug("No valid config found in /etc Checking for a settings.conf in current directory ...") 121 | config.read("settings.conf") 122 | if 'snipe-it' not in set(config): 123 | logging.debug("No valid config found in current folder.") 124 | 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.") 125 | raise SystemExit("Error: No valid settings.conf - Exiting.") 126 | 127 | logging.info("Great, we found a settings file. Let's get started by parsing all of the settings.") 128 | 129 | # While setting the variables, use a try loop so we can raise a error if something goes wrong. 130 | try: 131 | # Set some Variables from the settings.conf: 132 | # This is the address, cname, or FQDN for your JamfPro instance. 133 | logging.info("Setting the Jamf Pro Base url.") 134 | jamfpro_base = config['jamf']['url'] 135 | logging.debug("The configured Jamf Pro base url is: {}".format(jamfpro_base)) 136 | 137 | logging.info("Setting the client_id to request an api key.") 138 | jamf_client_id = config['jamf']['client_id'] 139 | logging.debug("The client_id you provided for Jamf is: {}".format(jamf_client_id)) 140 | 141 | logging.info("Setting the client_secret to request an api key.") 142 | jamf_client_secret = config['jamf']['client_secret'] 143 | logging.debug("The client_secret you provided for Jamf is: {}".format(jamf_client_secret)) 144 | 145 | # This is the address, cname, or FQDN for your snipe-it instance. 146 | logging.info("Setting the base URL for SnipeIT.") 147 | snipe_base = config['snipe-it']['url'] 148 | logging.debug("The configured Snipe-IT base url is: {}".format(snipe_base)) 149 | 150 | logging.info("Setting the API key for SnipeIT.") 151 | snipe_apiKey = config['snipe-it']['apikey'] 152 | logging.debug("The API key you provided for Snipe is: {}".format(snipe_apiKey)) 153 | 154 | logging.info("Setting the default status for SnipeIT assets.") 155 | defaultStatus = config['snipe-it']['defaultStatus'] 156 | 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)) 157 | 158 | logging.info("Setting the Snipe ID for Apple Manufacturer devices.") 159 | apple_manufacturer_id = config['snipe-it']['manufacturer_id'] 160 | 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)) 161 | 162 | except: 163 | 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.") 164 | raise SystemExit("Error: Missing or invalid settings in settings.conf - Exiting.") 165 | 166 | # Check the config file for correct headers 167 | 168 | # Do some tests to see if the user has updated their settings.conf file 169 | SETTINGS_CORRECT = True 170 | if 'api-mapping' in config: 171 | logging.error("Looks like you're using the old method for api-mapping. Please use computers-api-mapping and mobile_devices-api-mapping.") 172 | SETTINGS_CORRECT = False 173 | if not 'user-mapping' in config and (user_args.users or user_args.users_force or user_args.users_inverse): 174 | logging.error("""You've chosen to check out assets to users in some capacity using a cmdline switch, but not specified how you want to 175 | search Snipe IT for the users from Jamf. Make sure you have a 'user-mapping' section in your settings.conf file.""") 176 | SETTINGS_CORRECT = False 177 | if snipe_base.endswith("/"): 178 | logging.error("""You have a trailing forward slash in the snipe url. Please remove it.""") 179 | SETTINGS_CORRECT = False 180 | if jamfpro_base.endswith("/"): 181 | logging.error("""You have a trailing forward slash in the JamfPro url. Please remove it.""") 182 | SETTINGS_CORRECT = False 183 | 184 | 185 | if not SETTINGS_CORRECT: 186 | raise SystemExit 187 | 188 | # Check the config file for valid jamf subsets. This is based off the JAMF API and if it's not right we can't map fields over to SNIPE properly. 189 | logging.debug("Checking the settings.conf file for valid JAMF subsets of the JAMF API so mapping can occur properly.") 190 | for key in config['computers-api-mapping']: 191 | jamfsplit = config['computers-api-mapping'][key].split() 192 | if jamfsplit[0] in validsubset: 193 | logging.info('Found subset {}: Acceptable'.format(jamfsplit[0])) 194 | continue 195 | else: 196 | logging.error("Found invalid subset: {} in the settings.conf file.\nThis is not in the acceptable list of subsets. Check your settings.conf\n Valid subsets are: {}".format(jamfsplit[0], ', '.join(validsubset))) 197 | raise SystemExit("Invalid Subset found in settings.conf") 198 | 199 | ### Setup Some Functions ### 200 | api_count = 0 201 | first_api_call = None 202 | 203 | # Headers for the API call. 204 | logging.info("Creating the headers we'll need for API calls") 205 | jamfbasicheaders = {'Accept': 'application/json','Content-Type':'application/json'} 206 | snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apiKey),'Accept': 'application/json','Content-Type':'application/json'} 207 | logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfbasicheaders, snipeheaders)) 208 | 209 | session = Session() 210 | retries = Retry( 211 | total=3, 212 | allowed_methods={'GET'}, 213 | ) 214 | session.mount('https://', HTTPAdapter(max_retries=retries)) 215 | 216 | # Use the client credentials to request a Jamf Token. 217 | def request_jamf_token(): 218 | # 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. 219 | global token_request_time 220 | global jamf_apiKey 221 | global jamfheaders 222 | global jamfxmlheaders 223 | global expires_time 224 | token_request_time = time.time() 225 | logging.info("Requesting a new token at {}.".format(token_request_time)) 226 | api_url = '{0}/api/v1/oauth/token'.format(jamfpro_base) 227 | # No hook for this api call. 228 | logging.debug('Calling for a token against: {}\n The username and password can be found earlier in the script.'.format(api_url)) 229 | # No hook for this API call. 230 | data = { 231 | 'client_id': jamf_client_id, 232 | 'client_secret': jamf_client_secret, 233 | "grant_type": "client_credentials" 234 | } 235 | logging.debug('The data being sent to JamfPro for token request is: {}'.format(data)) 236 | response = session.post(api_url, data=data, verify=user_args.do_not_verify_ssl) 237 | if response.status_code == 200: 238 | logging.debug("Got back a valid 200 response code.") 239 | jsonresponse = response.json() 240 | logging.debug(jsonresponse) 241 | # So we have our token and Expires time. Set the expires time globably so we can reset later. 242 | try: 243 | expires_in = int(jsonresponse['expires_in']) 244 | expires_time = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=expires_in) 245 | except: 246 | logging.error("Jamf sent a malformed timestamp: {}\n Please feel free to complain to Jamf support.".format(jsonresponse['expires_in'])) 247 | raise SystemExit("Unable to grok Jamf Timestamp - Exiting") 248 | logging.debug("Token expires in: {}".format(expires_in)) 249 | # The headers are also global, because they get used elsewhere. 250 | logging.info("Setting new jamf headers with bearer token") 251 | jamfheaders = {'Authorization': 'Bearer {}'.format(jsonresponse['access_token']),'Accept': 'application/json','Content-Type':'application/json'} 252 | jamfxmlheaders = {'Authorization': 'Bearer {}'.format(jsonresponse['access_token']),'Accept': 'application/xml','Content-Type':'application/xml'} 253 | logging.debug('Request headers for JamfPro will be: {}\nRequest headers for Snipe will be: {}'.format(jamfheaders, snipeheaders)) 254 | else: 255 | logging.error("Could not obtain a token for use with Jamf's classic API. Please check your client_id and client_secret.") 256 | logging.debug('Response code: {} - {}'.format(response.status_code, response.content)) 257 | raise SystemExit("Unable to obtain Jamf Token") 258 | 259 | 260 | # This function is run every time a request is made, handles rate limiting for Snipe IT and keeps the token fresh for Jamf. 261 | def request_handler(r, *args, **kwargs): 262 | global api_count 263 | global first_api_call 264 | global token_request_time 265 | 266 | # We need to check to see if we need to get a new token. 267 | timeleft = expires_time - datetime.datetime.now(datetime.timezone.utc) 268 | # If there's less than 5 minutes (300 seconds) left on the token, get a new one. 269 | if timeleft < datetime.timedelta(seconds=300): 270 | request_jamf_token() 271 | 272 | # Slow and steady wins the race. Limit all API calls (not just to snipe) to the Rate limit. 273 | if user_args.ratelimited: 274 | if '"messages":429' in r.text: 275 | logging.warn("Despite respecting the rate limit of Snipe, we've still been limited. Trying again after sleeping for 2 seconds.") 276 | time.sleep(2) 277 | re_req = r.request 278 | return session.send(re_req) 279 | if api_count == 0: 280 | first_api_call = time.time() 281 | time.sleep(0.5) 282 | api_count += 1 283 | time_elapsed = (time.time() - first_api_call) 284 | api_rate = api_count / time_elapsed 285 | if api_rate > 1.95: 286 | sleep_time = 0.5 + (api_rate - 1.95) 287 | logging.debug('Going over snipe rate limit of 120/minute ({}/minute), sleeping for {}'.format(api_rate,sleep_time)) 288 | time.sleep(sleep_time) 289 | logging.debug("Made {} requests to Snipe IT in {} seconds, with a request being sent every {} seconds".format(api_count, time_elapsed, api_rate)) 290 | if '"messages":429' in r.text: 291 | logging.error(r.content) 292 | raise SystemExit("We've been rate limited. Use option -r to respect the built in Snipe IT API rate limit of 120/minute.") 293 | return r 294 | 295 | # Function to make the API call for all JAMF devices 296 | def get_jamf_computers(): 297 | api_url = '{0}/JSSResource/computers'.format(jamfpro_base) 298 | logging.debug('Calling for JAMF computers against: {}\n The username, passwords, and headers for this GET requestcan be found near the beginning of the output.'.format(api_url)) 299 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 300 | if response.status_code == 200: 301 | logging.debug("Got back a valid 200 response code.") 302 | return response.json() 303 | elif b'policies.ratelimit.QuotaViolation' in response.content: 304 | logging.info('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to get a list of computers Waiting a bit to retry the lookup.'.format(response)) 305 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 306 | time.sleep(75) 307 | logging.info("Finished waiting. Retrying lookup...") 308 | newresponse = get_jamf_computers() 309 | return newresponse 310 | else: 311 | logging.warning('Received an invalid status code when trying to retreive JAMF Device list:{} - {}'.format(response.status_code, response.content)) 312 | logging.debug("Returning a null value for the function.") 313 | return None 314 | 315 | # Function to make the API call for JAMF devices in group 316 | def get_jamf_computers_by_group(jamf_id): 317 | api_url = '{0}/JSSResource/computergroups/id/{1}'.format(jamfpro_base, jamf_id) 318 | logging.debug('Calling for JAMF computers against: {}\n The username, passwords, and headers for this GET requestcan be found near the beginning of the output.'.format(api_url)) 319 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 320 | if response.status_code == 200: 321 | logging.debug("Got back a valid 200 response code.") 322 | jsonresponse = response.json() 323 | logging.debug("Returning: {}".format(jsonresponse['computer_group'])) 324 | return jsonresponse['computer_group'] 325 | elif b'policies.ratelimit.QuotaViolation' in response.content: 326 | logging.info('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to get a list of computers Waiting a bit to retry the lookup.'.format(response)) 327 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 328 | time.sleep(75) 329 | logging.info("Finished waiting. Retrying lookup...") 330 | newresponse = get_jamf_computers_by_group(jamf_id) 331 | return newresponse 332 | else: 333 | logging.warning('Received an invalid status code when trying to retreive JAMF Device list:{} - {}'.format(response.status_code, response.content)) 334 | logging.debug("Returning a null value for the function.") 335 | return None 336 | 337 | # Function to make the API call for all JAMF mobile devices 338 | def get_jamf_mobiles(): 339 | api_url = '{0}/JSSResource/mobiledevices'.format(jamfpro_base) 340 | logging.debug('Calling for JAMF mobiles against: {}\n The username, passwords, and headers for this GET requestcan be found near the beginning of the output.'.format(api_url)) 341 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 342 | if response.status_code == 200: 343 | logging.debug("Got back a valid 200 response code.") 344 | return response.json() 345 | elif b'policies.ratelimit.QuotaViolation' in response.content: 346 | logging.info('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to get a list of mobiles Waiting a bit to retry the lookup.'.format(response)) 347 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 348 | time.sleep(75) 349 | logging.info("Finished waiting. Retrying lookup...") 350 | newresponse = get_jamf_mobiles() 351 | return newresponse 352 | else: 353 | logging.warning('Received an invalid status code when trying to retreive JAMF Device list:{} - {}'.format(response.status_code, response.content)) 354 | logging.debug("Returning a null value for the function.") 355 | return None 356 | 357 | # Function to make the API call for all JAMF mobile devices in group 358 | def get_jamf_mobiles_by_group(jamf_id): 359 | api_url = '{0}/JSSResource/mobiledevicegroups/id/{1}'.format(jamfpro_base, jamf_id) 360 | logging.debug('Calling for JAMF mobiles against: {}\n The username, passwords, and headers for this GET requestcan be found near the beginning of the output.'.format(api_url)) 361 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 362 | if response.status_code == 200: 363 | logging.debug("Got back a valid 200 response code.") 364 | jsonresponse = response.json() 365 | logging.debug("Returning: {}".format(jsonresponse['mobile_device_group'])) 366 | return jsonresponse['mobile_device_group'] 367 | elif b'policies.ratelimit.QuotaViolation' in response.content: 368 | logging.info('JAMFPro responded with error code: {} - Policy Ratelimit Quota Violation - when we tried to get a list of mobiles Waiting a bit to retry the lookup.'.format(response)) 369 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 370 | time.sleep(75) 371 | logging.info("Finished waiting. Retrying lookup...") 372 | newresponse = get_jamf_mobiles_by_group(jamf_id) 373 | return newresponse 374 | else: 375 | logging.warning('Received an invalid status code when trying to retreive JAMF Device list:{} - {}'.format(response.status_code, response.content)) 376 | logging.debug("Returning a null value for the function.") 377 | return None 378 | 379 | # Function to lookup a JAMF asset by id. 380 | def search_jamf_asset(jamf_id): 381 | api_url = "{}/JSSResource/computers/id/{}".format(jamfpro_base, jamf_id) 382 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 383 | if response.status_code == 200: 384 | logging.debug("Got back a valid 200 response code.") 385 | jsonresponse = response.json() 386 | logging.debug("Returning: {}".format(jsonresponse['computer'])) 387 | return jsonresponse['computer'] 388 | elif b'policies.ratelimit.QuotaViolation' in response.content: 389 | logging.info('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)) 390 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 391 | time.sleep(75) 392 | logging.info("Finished waiting. Retrying lookup...") 393 | newresponse = search_jamf_asset(jamf_id) 394 | return newresponse 395 | else: 396 | logging.warning('JAMFPro responded with error code:{} when we tried to look up id: {}'.format(response, jamf_id)) 397 | logging.debug("Returning a null value for the function.") 398 | return None 399 | 400 | # Function to lookup a JAMF mobile asset by id. 401 | def search_jamf_mobile(jamf_id): 402 | api_url = "{}/JSSResource/mobiledevices/id/{}".format(jamfpro_base, jamf_id) 403 | response = session.get(api_url, headers=jamfheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 404 | if response.status_code == 200: 405 | logging.debug("Got back a valid 200 response code.") 406 | jsonresponse = response.json() 407 | logging.debug("Returning: {}".format(jsonresponse['mobile_device'])) 408 | return jsonresponse['mobile_device'] 409 | elif b'policies.ratelimit.QuotaViolation' in response.content: 410 | logging.info('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)) 411 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 412 | time.sleep(75) 413 | logging.info("Finished waiting. Retyring lookup...") 414 | newresponse = search_jamf_asset(jamf_id) 415 | return newresponse 416 | else: 417 | logging.warning('JAMFPro responded with error code:{} when we tried to look up id: {}'.format(response, jamf_id)) 418 | logging.debug("Returning a null value for the function.") 419 | return None 420 | 421 | # Function to update the asset tag of computers in JAMF with an number passed from Snipe. 422 | def update_jamf_asset_tag(jamf_id, asset_tag): 423 | if user_args.dryrun: 424 | logging.debug("Would have updated JAMF asset id: {} with asset tag: {}".format(jamf_id, asset_tag)) 425 | return True 426 | api_url = "{}/JSSResource/computers/id/{}".format(jamfpro_base, jamf_id) 427 | payload = """{}{}""".format(jamf_id, asset_tag) 428 | logging.debug('Making Get request against: {}\nPayload for the PUT request is: {}\nThe username, password, and headers can be found near the beginning of the output.'.format(api_url, payload)) 429 | response = session.put(api_url, data=payload, headers=jamfxmlheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 430 | if response.status_code == 201: 431 | logging.debug("Got a 201 response. Returning: True") 432 | return True 433 | elif b'policies.ratelimit.QuotaViolation' in response.content: 434 | logging.info('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)) 435 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 436 | time.sleep(75) 437 | logging.info("Finished waiting. Retrying update...") 438 | newresponse = update_jamf_asset_tag(jamf_id, asset_tag) 439 | return newresponse 440 | if response.status_code == 200: 441 | logging.debug("Got a 200 response code. Returning the response: {}".format(response)) 442 | return response.json() 443 | else: 444 | logging.warning('Got back an error response code:{} - {}'.format(response.status_code, response.content)) 445 | return None 446 | 447 | # Function to update the asset tag of mobile devices in JAMF with an number passed from Snipe. 448 | def update_jamf_mobiledevice_asset_tag(jamf_id, asset_tag): 449 | if user_args.dryrun: 450 | logging.debug("Would have updated JAMF asset id: {} with asset tag: {}".format(jamf_id, asset_tag)) 451 | return True 452 | api_url = "{}/JSSResource/mobiledevices/id/{}".format(jamfpro_base, jamf_id) 453 | payload = """{}{}""".format(jamf_id, asset_tag) 454 | logging.debug('Making Get request against: {}\nPayload for the PUT request is: {}\nThe username, password, and headers can be found near the beginning of the output.'.format(api_url, payload)) 455 | response = session.put(api_url, data=payload, headers=jamfxmlheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 456 | if response.status_code == 201: 457 | logging.debug("Got a 201 response. Returning: True") 458 | return True 459 | elif b'policies.ratelimit.QuotaViolation' in response.content: 460 | logging.info('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)) 461 | logging.warning('JAMFPro Ratelimit exceeded: pausing ') 462 | time.sleep(75) 463 | logging.info("Finished waiting. Retrying update...") 464 | newresponse = update_jamf_mobiledevice_asset_tag(jamf_id, asset_tag) 465 | return newresponse 466 | if response.status_code == 200: 467 | logging.debug("Got a 200 response code. Returning the response: {}".format(response)) 468 | return response.json() 469 | else: 470 | logging.warning('Got back an error response code:{} - {}'.format(response.status_code, response.content)) 471 | return None 472 | 473 | # Function to lookup a snipe asset by serial number. 474 | def search_snipe_asset(serial): 475 | api_url = '{}/api/v1/hardware/byserial/{}'.format(snipe_base, serial) 476 | response = session.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 477 | if response.status_code == 200: 478 | jsonresponse = response.json() 479 | # Check to make sure there's actually a result 480 | if "total" in jsonresponse: 481 | if jsonresponse['total'] == 1: 482 | return jsonresponse 483 | elif jsonresponse['total'] == 0: 484 | logging.info("No assets match {}".format(serial)) 485 | return "NoMatch" 486 | else: 487 | logging.warning('FOUND {} matching assets while searching for: {}'.format(jsonresponse['total'], serial)) 488 | return "MultiMatch" 489 | else: 490 | logging.info("No assets match {}".format(serial)) 491 | return "NoMatch" 492 | else: 493 | logging.warning('Snipe-IT responded with error code:{} when we tried to look up: {}'.format(response.text, serial)) 494 | logging.debug('{} - {}'.format(response.status_code, response.content)) 495 | return "ERROR" 496 | 497 | # Function to get all the asset models 498 | def get_snipe_models(): 499 | api_url = '{}/api/v1/models'.format(snipe_base) 500 | logging.debug('Calling against: {}'.format(api_url)) 501 | response = session.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 502 | if response.status_code == 200: 503 | jsonresponse = response.json() 504 | logging.info("Got a valid response that should have {} models.".format(jsonresponse['total'])) 505 | if jsonresponse['total'] <= len(jsonresponse['rows']) : 506 | return jsonresponse 507 | else: 508 | logging.info("We didn't get enough results so we need to get them again.") 509 | api_url = '{}/api/v1/models?limit={}'.format(snipe_base, jsonresponse['total']) 510 | newresponse = session.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 511 | if response.status_code == 200: 512 | newjsonresponse = newresponse.json() 513 | if newjsonresponse['total'] == len(newjsonresponse['rows']) : 514 | return newjsonresponse 515 | else: 516 | logging.error("We couldn't seem to get all of the model numbers") 517 | raise SystemExit("Unable to get all model objects from Snipe-IT instanace") 518 | else: 519 | logging.error('When we tried to retreive a list of models, Snipe-IT responded with error status code:{} - {}'.format(response.status_code, response.content)) 520 | raise SystemExit("Snipe models API endpoint failed.") 521 | else: 522 | logging.error('When we tried to retreive a list of models, Snipe-IT responded with error status code:{} - {}'.format(response.status_code, response.content)) 523 | raise SystemExit("Snipe models API endpoint failed.") 524 | 525 | # Recursive function returns all users in a Snipe Instance, 100 at a time. 526 | def get_snipe_users(previous=[]): 527 | user_id_url = '{}/api/v1/users'.format(snipe_base) 528 | payload = { 529 | 'limit': 100, 530 | 'offset': len(previous) 531 | } 532 | logging.debug('The payload for the snipe users GET is {}'.format(payload)) 533 | response = session.get(user_id_url, headers=snipeheaders, params=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 534 | response_json = response.json() 535 | current = response_json['rows'] 536 | if len(previous) != 0: 537 | current = previous + current 538 | if response_json['total'] > len(current): 539 | logging.debug('We have more than 100 users, get the next page - total: {} current: {}'.format(response_json['total'], len(current))) 540 | return get_snipe_users(current) 541 | else: 542 | return current 543 | 544 | # Function to search snipe for a user 545 | def get_snipe_user_id(username): 546 | if username == '': 547 | return "NotFound" 548 | username = username.lower() 549 | for user in snipe_users: 550 | for value in user.values(): 551 | if str(value).lower() == username: 552 | id = user['id'] 553 | return id 554 | if user_args.users_no_search: 555 | logging.debug("No matches in snipe_users for {}, not querying the API for the next closest match since we've been told not to".format(username)) 556 | return "NotFound" 557 | logging.debug('No matches in snipe_users for {}, querying the API for the next closest match'.format(username)) 558 | user_id_url = '{}/api/v1/users'.format(snipe_base) 559 | payload = { 560 | 'search':username, 561 | 'limit':1, 562 | 'sort':'username', 563 | 'order':'asc' 564 | } 565 | logging.debug('The payload for the snipe user search is: {}'.format(payload)) 566 | response = session.get(user_id_url, headers=snipeheaders, params=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 567 | try: 568 | return response.json()['rows'][0]['id'] 569 | except: 570 | return "NotFound" 571 | 572 | # Function that creates a new Snipe Model - not an asset - with a JSON payload 573 | def create_snipe_model(payload): 574 | api_url = '{}/api/v1/models'.format(snipe_base) 575 | logging.debug('Calling to create new snipe model type against: {}\nThe payload for the POST request is:{}\nThe request headers can be found near the start of the output.'.format(api_url, payload)) 576 | response = session.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 577 | if response.status_code == 200: 578 | jsonresponse = response.json() 579 | modelnumbers[jsonresponse['payload']['model_number']] = jsonresponse['payload']['id'] 580 | return True 581 | else: 582 | logging.warning('Error code: {} while trying to create a new model.'.format(response.status_code)) 583 | return False 584 | 585 | # Function to create a new asset by passing array 586 | def create_snipe_asset(payload): 587 | api_url = '{}/api/v1/hardware'.format(snipe_base) 588 | logging.debug('Calling to create a new asset against: {}\nThe payload for the POST request is:{}\nThe request headers can be found near the start of the output.'.format(api_url, payload)) 589 | response = session.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 590 | logging.debug(response.text) 591 | if response.status_code == 200: 592 | logging.debug("Got back status code: 200 - {}".format(response.content)) 593 | jsonresponse = response.json() 594 | if jsonresponse['status'] == "error": 595 | logging.error('Asset creation failed for asset {} with error {}'.format(payload['name'],jsonresponse['messages'])) 596 | return 'ERROR', response 597 | return 'AssetCreated', response 598 | else: 599 | logging.error('Asset creation failed for asset {} with error {}'.format(payload['name'],response.text)) 600 | return 'ERROR', response 601 | 602 | # Function that updates a snipe asset with a JSON payload 603 | def update_snipe_asset(snipe_id, payload): 604 | if user_args.dryrun: 605 | logging.debug("Dry run mode is enabled. We would have updated ID: {} with the following payload: {}".format(snipe_id, payload)) 606 | return True 607 | api_url = '{}/api/v1/hardware/{}'.format(snipe_base, snipe_id) 608 | logging.debug('The payload for the snipe update is: {}'.format(payload)) 609 | response = session.patch(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 610 | # Verify that the payload updated properly. 611 | goodupdate = True 612 | if response.status_code == 200: 613 | logging.debug("Got back status code: 200 - Checking the payload updated properly: If you error here it's because you configure the API mapping right.") 614 | jsonresponse = response.json() 615 | # Check if there's an Error and Log it, or parse the payload. 616 | if jsonresponse['status'] == "error": 617 | logging.error('Unable to update ID: {}. Error "{}"'.format(snipe_id, jsonresponse['messages'])) 618 | goodupdate = False 619 | else: 620 | for key in payload: 621 | if key == 'purchase_date': 622 | payload[key] = payload[key] + " 00:00:00" 623 | if payload[key] == '': 624 | payload[key] = None 625 | if jsonresponse['payload'][key] != payload[key]: 626 | logging.warning('Unable to update ID: {}. We failed to update the {} field with "{}"'.format(snipe_id, key, payload[key])) 627 | goodupdate = False 628 | else: 629 | logging.info("Sucessfully updated {} with: {}".format(key, payload[key])) 630 | return goodupdate 631 | else: 632 | logging.error('Whoops. Got an error status code while updating ID {}: {} - {}'.format(snipe_id, response.status_code, response.content)) 633 | return False 634 | 635 | # Function that checks in an asset in snipe 636 | def checkin_snipe_asset(asset_id): 637 | api_url = '{}/api/v1/hardware/{}/checkin'.format(snipe_base, asset_id) 638 | payload = { 639 | 'note':'checked in by script from Jamf' 640 | } 641 | logging.debug('The payload for the snipe checkin is: {}'.format(payload)) 642 | response = session.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 643 | logging.debug('The response from Snipe IT is: {}'.format(response.json())) 644 | if response.status_code == 200: 645 | logging.debug("Got back status code: 200 - {}".format(response.content)) 646 | return "CheckedOut" 647 | else: 648 | return response 649 | 650 | # Function that checks out an asset in snipe 651 | def checkout_snipe_asset(user, asset_id, checked_out_user=None): 652 | logging.debug('Asset {} is being checked out to {}'.format(user, asset_id)) 653 | user_id = get_snipe_user_id(user) 654 | if user_id == 'NotFound': 655 | logging.info("User {} not found".format(user)) 656 | return "NotFound" 657 | if checked_out_user == None: 658 | logging.info("Not checked out, checking out to {}".format(user)) 659 | elif checked_out_user == "NewAsset": 660 | logging.info("First time this asset will be checked out, checking out to {}".format(user)) 661 | elif checked_out_user['id'] == user_id: 662 | logging.info(str(asset_id) + " already checked out to user " + user) 663 | return 'CheckedOut' 664 | else: 665 | logging.info("Checking in {} to check it out to {}".format(asset_id,user)) 666 | checkin_snipe_asset(asset_id) 667 | api_url = '{}/api/v1/hardware/{}/checkout'.format(snipe_base, asset_id) 668 | logging.info("Checking out {} to check it out to {}".format(asset_id,user)) 669 | payload = { 670 | 'checkout_to_type':'user', 671 | 'assigned_user':user_id, 672 | 'note':'Assignment made automatically, via script from Jamf.' 673 | } 674 | logging.debug('The payload for the snipe checkin is: {}'.format(payload)) 675 | response = session.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler}) 676 | logging.debug('The response from Snipe IT is: {}'.format(response.json())) 677 | if response.status_code == 200: 678 | logging.debug("Got back status code: 200 - {}".format(response.content)) 679 | return "CheckedOut" 680 | else: 681 | logging.error('Asset checkout failed for asset {} with error {}'.format(asset_id,response.text)) 682 | return response 683 | 684 | ### Run Testing ### 685 | # Report if we're verifying SSL or not. 686 | logging.info("SSL Verification is set to: {}".format(user_args.do_not_verify_ssl)) 687 | 688 | # Do some tests to see if the hosts are up. Don't use hooks for these as we don't have tokens yet. 689 | logging.info("Running tests to see if hosts are up.") 690 | try: 691 | SNIPE_UP = True if session.get(snipe_base, verify=user_args.do_not_verify_ssl).status_code == 200 else False 692 | except Exception as e: 693 | logging.exception(e) 694 | SNIPE_UP = False 695 | try: 696 | JAMF_UP = True if session.get(jamfpro_base, verify=user_args.do_not_verify_ssl).status_code in (200, 401) else False 697 | except Exception as e: 698 | logging.exception(e) 699 | JAMF_UP = False 700 | 701 | if not SNIPE_UP: 702 | logging.error('Snipe-IT looks like it is down from here. \nPlease check your config in the settings.conf file, or your instance.') 703 | else: 704 | logging.info('We were able to get a good response from your Snipe-IT instance.') 705 | if not JAMF_UP: 706 | logging.error('JAMFPro looks down from here. \nPlease check the your config in the settings.conf file, or your hosted JAMFPro instance.') 707 | else: 708 | logging.info('We were able to get a good response from your JAMFPro instance.') 709 | 710 | # Exit if you can't contact SNIPE 711 | if ( JAMF_UP == False ) or ( SNIPE_UP == False ): 712 | raise SystemExit("Error: Host could not be contacted.") 713 | 714 | # Test that we can actually connect with the API keys by getting a bearer token. 715 | request_jamf_token() 716 | 717 | 718 | logging.info("Finished running our tests.") 719 | 720 | ### Get Started ### 721 | # Get a list of known models from Snipe 722 | logging.info("Getting a list of computer models that snipe knows about.") 723 | snipemodels = get_snipe_models() 724 | logging.debug("Parsing the {} model results for models with model numbers.".format(len(snipemodels['rows']))) 725 | modelnumbers = {} 726 | for model in snipemodels['rows']: 727 | if model['model_number'] == "": 728 | logging.debug("The model, {}, did not have a model number. Skipping.".format(model['name'])) 729 | continue 730 | modelnumbers[model['model_number']] = model['id'] 731 | logging.info("Our list of models has {} entries.".format(len(modelnumbers))) 732 | logging.debug("Here's the list of the {} models and their id's that we were able to collect:\n{}".format(len(modelnumbers), modelnumbers)) 733 | 734 | # Get the IDS of all active assets. 735 | if 'computer_group_id' in config['jamf'] and config['jamf']['computer_group_id']: 736 | logging.info("Getting list of computers from JAMF by computer group id.") 737 | jamf_computer_list = get_jamf_computers_by_group(config['jamf']['computer_group_id']) 738 | else: 739 | jamf_computer_list = get_jamf_computers() 740 | 741 | if 'mobile_group_id' in config['jamf'] and config['jamf']['mobile_group_id']: 742 | logging.info("Getting list of mobiles from JAMF by mobile group id.") 743 | jamf_mobile_list = get_jamf_mobiles_by_group(config['jamf']['mobile_group_id']) 744 | else: 745 | jamf_mobile_list = get_jamf_mobiles() 746 | jamf_types = { 747 | 'computers': jamf_computer_list, 748 | 'mobile_devices': jamf_mobile_list 749 | } 750 | 751 | # Get a list of users from Snipe if the user has specified 752 | # they're syncing users 753 | 754 | if user_args.users or user_args.users_force or user_args.users_inverse: 755 | snipe_users = get_snipe_users() 756 | 757 | TotalNumber = 0 758 | if user_args.computers: 759 | TotalNumber = len(jamf_types['computers']['computers']) 760 | elif user_args.mobiles: 761 | TotalNumber = len(jamf_types['mobile_devices']['mobile_devices']) 762 | else: 763 | for jamf_type in jamf_types: 764 | TotalNumber += len(jamf_types[jamf_type][jamf_type]) 765 | 766 | # Make sure we have a good list. 767 | if jamf_computer_list != None: 768 | logging.info('Received a list of JAMF assets that had {} entries.'.format(TotalNumber)) 769 | else: 770 | logging.error("We were not able to retreive a list of assets from your JAMF instance. It's likely that your settings, or credentials are incorrect. Check your settings.conf and verify you can make API calls outside of this system with the credentials found in your settings.conf") 771 | raise SystemExit("Unable to get JAMF Computers.") 772 | 773 | # After this point we start editing data, so quit if this is a dryrun 774 | if user_args.connection_test: 775 | raise SystemExit("Connection Test: Complete.") 776 | 777 | # From this point on, we're editing data. 778 | logging.info('Starting to Update Inventory') 779 | CurrentNumber = 0 780 | 781 | for jamf_type in jamf_types: 782 | if user_args.computers: 783 | if jamf_type != 'computers': 784 | continue 785 | if user_args.mobiles: 786 | if jamf_type != 'mobile_devices': 787 | continue 788 | for jamf_asset in jamf_types[jamf_type][jamf_type]: 789 | CurrentNumber += 1 790 | logging.info("Processing entry {} out of {} - JAMFID: {} - NAME: {}".format(CurrentNumber, TotalNumber, jamf_asset['id'], jamf_asset['name'])) 791 | # Search through the list by ID for all asset information\ 792 | if jamf_type == 'computers': 793 | jamf = search_jamf_asset(jamf_asset['id']) 794 | elif jamf_type == 'mobile_devices': 795 | jamf = search_jamf_mobile(jamf_asset['id']) 796 | if jamf == None: 797 | continue 798 | 799 | # If the entry doesn't contain a serial, then we need to skip this entry. 800 | if jamf['general']['serial_number'] == 'Not Available': 801 | 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.") 802 | continue 803 | if jamf['general']['serial_number'] == None: 804 | 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.") 805 | continue 806 | 807 | # Check that the model number exists in snipe, if not create it. 808 | if jamf_type == 'computers': 809 | if jamf['hardware']['model_identifier'] not in modelnumbers and jamf['hardware']['model_identifier']: 810 | logging.info("Could not find a model ID in snipe for: {}".format(jamf['hardware']['model_identifier'])) 811 | newmodel = {"category_id":config['snipe-it']['computer_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": jamf['hardware']['model'],"model_number":jamf['hardware']['model_identifier']} 812 | if 'computer_custom_fieldset_id' in config['snipe-it']: 813 | fieldset_split = config['snipe-it']['computer_custom_fieldset_id'] 814 | newmodel['fieldset_id'] = fieldset_split 815 | create_snipe_model(newmodel) 816 | elif jamf_type == 'mobile_devices': 817 | if jamf['general']['model_identifier'] not in modelnumbers and jamf['general']['model_identifier']: 818 | logging.info("Could not find a model ID in snipe for: {}".format(jamf['general']['model_identifier'])) 819 | newmodel = {"category_id":config['snipe-it']['mobile_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": jamf['general']['model'],"model_number":jamf['general']['model_identifier']} 820 | if 'mobile_custom_fieldset_id' in config['snipe-it']: 821 | fieldset_split = config['snipe-it']['mobile_custom_fieldset_id'] 822 | newmodel['fieldset_id'] = fieldset_split 823 | create_snipe_model(newmodel) 824 | 825 | # Pass the SN from JAMF to search for a match in Snipe 826 | snipe = search_snipe_asset(jamf['general']['serial_number']) 827 | 828 | # Create a new asset if there's no match: 829 | if snipe == 'NoMatch': 830 | logging.info("Creating a new asset in snipe for JAMF ID {} - {}".format(jamf['general']['id'], jamf['general']['name'])) 831 | # This section checks to see if the asset tag was already put into JAMF, if not it creates one with with Jamf's ID. 832 | if jamf['general']['asset_tag'] == '': 833 | jamf_asset_tag = None 834 | logging.debug('No asset tag found in Jamf, checking settings.conf for alternative specified field.') 835 | if 'asset_tag' in config['snipe-it']: 836 | tag_split = config['snipe-it']['asset_tag'].split() 837 | try: 838 | jamf_asset_tag = jamf['{}'.format(tag_split[0])]['{}'.format(tag_split[1])] 839 | except: 840 | if jamf_type == 'mobile_devices': 841 | jamf_asset_tag = 'jamfid-m-{}'.format(jamf['general']['id']) 842 | elif jamf_type == 'computers': 843 | jamf_asset_tag = 'jamfid-{}'.format(jamf['general']['id']) 844 | else: 845 | logging.error("Could not generate an asset tag for this device. Skipping") 846 | # Dump the object for debugging. 847 | logging.verbose(jamf) 848 | continue 849 | #raise SystemError('No such attribute {} in the jamf payload. Please check your settings.conf file'.format(tag_split)) 850 | if jamf_asset_tag == None or jamf_asset_tag == '': 851 | logging.debug('No custom configuration found in settings.conf for asset tag name upon asset creation.') 852 | if jamf_type == 'mobile_devices': 853 | jamf_asset_tag = 'jamfid-m-{}'.format(jamf['general']['id']) 854 | elif jamf_type == 'computers': 855 | jamf_asset_tag = 'jamfid-{}'.format(jamf['general']['id']) 856 | else: 857 | jamf_asset_tag = jamf['general']['asset_tag'] 858 | logging.info("Asset tag found in Jamf, setting it to: {}".format(jamf_asset_tag)) 859 | # Create the payload 860 | if jamf_type == 'mobile_devices': 861 | logging.debug("Payload is being made for a mobile device") 862 | newasset = {'asset_tag': jamf_asset_tag, 'model_id': modelnumbers['{}'.format(jamf['general']['model_identifier'])], 'name': jamf['general']['name'], 'status_id': defaultStatus,'serial': jamf['general']['serial_number']} 863 | elif jamf_type == 'computers': 864 | logging.debug("Payload is being made for a computer") 865 | 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']} 866 | else: 867 | for snipekey in config['{}-api-mapping'.format(jamf_type)]: 868 | jamfsplit = config['{}-api-mapping'.format(jamf_type)][snipekey].split() 869 | try: 870 | for i, item in enumerate(jamfsplit): 871 | try: 872 | item = int(item) 873 | except ValueError: 874 | logging.debug('{} is not an integer'.format(item)) 875 | if i == 0: 876 | jamf_value = jamf[item] 877 | else: 878 | if jamfsplit[0] == 'extension_attributes': 879 | for attribute in jamf_value: 880 | if attribute['id'] == item: 881 | jamf_value = attribute['value'] 882 | else: 883 | jamf_value = jamf_value[item] 884 | newasset[snipekey] = jamf_value 885 | except KeyError: 886 | continue 887 | # Reset the payload without the asset_tag if auto_incrementing flag is set. 888 | if user_args.auto_incrementing: 889 | newasset.pop('asset_tag', None) 890 | new_snipe_asset = create_snipe_asset(newasset) 891 | logging.debug(new_snipe_asset) 892 | if new_snipe_asset[0] != "AssetCreated": 893 | continue 894 | if user_args.users or user_args.users_force or user_args.users_inverse: 895 | jamfsplit = config['user-mapping']['jamf_api_field'].split() 896 | if jamfsplit[1] not in jamf[jamfsplit[0]]: 897 | logging.info("Couldn't find {} for this device in {}, not checking it out.".format(jamfsplit[1], jamfsplit[0])) 898 | continue 899 | logging.info('Checking out new item {} to user {}'.format(jamf['general']['name'], jamf['{}'.format(jamfsplit[0])]['{}'.format(jamfsplit[1])])) 900 | checkout_snipe_asset(jamf['{}'.format(jamfsplit[0])]['{}'.format(jamfsplit[1])],new_snipe_asset[1].json()['payload']['id'], "NewAsset") 901 | # Log an error if there's an issue, or more than once match. 902 | elif snipe == 'MultiMatch': 903 | logging.warning("WARN: 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 serial number {} for now.".format(jamf['general']['serial_number'])) 904 | elif snipe == 'ERROR': 905 | logging.error("We got an error when looking up serial number {} in snipe, which shouldn't happen at this point. Check your snipe instance and setup. Skipping for now.".format(jamf['general']['serial_number'])) 906 | 907 | else: 908 | # Only update if JAMF has more recent info. 909 | snipe_id = snipe['rows'][0]['id'] 910 | snipe_time = snipe['rows'][0]['updated_at']['datetime'] 911 | if jamf_type == 'computers': 912 | jamf_time = jamf['general']['report_date'] 913 | elif jamf_type == 'mobile_devices': 914 | jamf_time = jamf['general']['last_inventory_update'] 915 | # Check to see that the JAMF record is newer than the previous Snipe update, or if it is a new record in Snipe 916 | if ( jamf_time > snipe_time ) or ( user_args.force ): 917 | if user_args.force: 918 | logging.debug("Forced the Update regardless of the timestamps below.") 919 | logging.debug("Updating the Snipe asset because JAMF has a more recent timestamp: {} > {} or the Snipe Record is new".format(jamf_time, snipe_time)) 920 | updates = {} 921 | for snipekey in config['{}-api-mapping'.format(jamf_type)]: 922 | try: 923 | jamfsplit = config['{}-api-mapping'.format(jamf_type)][snipekey].split() 924 | for i, item in enumerate(jamfsplit): 925 | try: 926 | item = int(item) 927 | except ValueError: 928 | logging.debug('{} is not an integer'.format(item)) 929 | if i == 0: 930 | jamf_value = jamf[item] 931 | else: 932 | if jamfsplit[0] == 'extension_attributes': 933 | for attribute in jamf_value: 934 | if attribute['id'] == item: 935 | jamf_value = attribute['value'] 936 | else: 937 | jamf_value = jamf_value[item] 938 | payload = {snipekey: jamf_value} 939 | latestvalue = jamf_value 940 | except (KeyError, TypeError): 941 | logging.debug("Skipping the payload, because the JAMF key we're mapping to doesn't exist") 942 | continue 943 | 944 | # Need to check that we're not needlessly updating the asset. 945 | # If it's a custom value it'll fail the first section and send it to except section that will parse custom sections. 946 | try: 947 | if snipe['rows'][0][snipekey] != latestvalue: 948 | updates.update(payload) 949 | else: 950 | logging.debug("Skipping the payload, because it already exits.") 951 | except: 952 | logging.debug("The snipekey lookup failed, which means it's a custom field. Parsing those to see if it needs to be updated or not.") 953 | needsupdate = False 954 | for CustomField in snipe['rows'][0]['custom_fields']: 955 | if snipe['rows'][0]['custom_fields'][CustomField]['field'] == snipekey : 956 | if snipe['rows'][0]['custom_fields'][CustomField]['value'] != str(latestvalue): 957 | logging.debug("Found the field, and the value needs to be updated from {} to {}".format(snipe['rows'][0]['custom_fields'][CustomField]['value'], latestvalue)) 958 | needsupdate = True 959 | if needsupdate == True: 960 | updates.update(payload) 961 | else: 962 | logging.debug("Skipping the payload, because it already exists, or the Snipe key we're mapping to doesn't.") 963 | 964 | if updates: 965 | update_snipe_asset(snipe_id, updates) 966 | 967 | if ((user_args.users or user_args.users_inverse) and (snipe['rows'][0]['assigned_to'] == None) == user_args.users) or user_args.users_force: 968 | 969 | if snipe['rows'][0]['status_label']['status_meta'] in ('deployable', 'deployed'): 970 | jamfsplit = config['user-mapping']['jamf_api_field'].split() 971 | if jamfsplit[1] not in jamf[jamfsplit[0]]: 972 | logging.info("Couldn't find {} for this device in {}, not checking it out.".format(jamfsplit[1], jamfsplit[0])) 973 | continue 974 | checkout_snipe_asset(jamf['{}'.format(jamfsplit[0])]['{}'.format(jamfsplit[1])], snipe_id, snipe['rows'][0]['assigned_to']) 975 | else: 976 | logging.info("Can't checkout {} since the status isn't set to deployable".format(jamf['general']['name'])) 977 | 978 | else: 979 | logging.info("Snipe Record is newer than the JAMF record. Nothing to sync. If this wrong, then force an inventory update in JAMF") 980 | logging.debug("Not updating the Snipe asset because Snipe has a more recent timestamp: {} < {}".format(jamf_time, snipe_time)) 981 | 982 | # Update/Sync the Snipe Asset Tag Number back to JAMF 983 | # The user arg below is set to false if it's called, so this would fail if the user called it. 984 | if (jamf['general']['asset_tag'] != snipe['rows'][0]['asset_tag']) and user_args.do_not_update_jamf : 985 | logging.info("JAMF doesn't have the same asset tag as SNIPE so we'll update it because it should be authoritative.") 986 | if snipe['rows'][0]['asset_tag'][0]: 987 | if jamf_type == 'computers': 988 | update_jamf_asset_tag("{}".format(jamf['general']['id']), '{}'.format(snipe['rows'][0]['asset_tag'])) 989 | logging.info("Device is a computer, updating computer record") 990 | elif jamf_type == 'mobile_devices': 991 | update_jamf_mobiledevice_asset_tag("{}".format(jamf['general']['id']), '{}'.format(snipe['rows'][0]['asset_tag'])) 992 | logging.info("Device is a mobile device, updating the mobile device record") 993 | 994 | logging.debug('Total amount of API calls made: {}'.format(api_count)) 995 | --------------------------------------------------------------------------------