├── .gitignore
├── LICENSE
├── README.md
├── images
└── kandji-api-requirements.png
├── kandji2snipe
├── requirements.txt
└── settings.conf.example
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | kandji2snipe.log
3 | settings.conf
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2023
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # kandji2snipe
2 |
3 | ## About
4 |
5 | This `python3` script leverages the [Kandji](https://kandji.io) and [Snipe-IT](https://snipeitapp.com) APIs to accomplish two things:
6 | * Sync device details from Kandji to Snipe-IT
7 | * Sync asset tags from Snipe-IT to Kandji.
8 |
9 | ## Overview:
10 | kandji2snipe is designed to sync assets between your Kandji and Snipe-IT instances. The tool searches for assets based on the serial number, not the existing asset tag. If assets exist in Kandji and are not in Snipe-IT, the tool will create an asset and try to match it with an existing Snipe-IT model. This match is based on the device's model identifier (ex. MacBookAir7,1) being entered as the model number in Snipe-IT, rather than the model name. If a matching model isn't found, it will create one.
11 |
12 | When an asset is first created, it will fill out only the most basic information. If the asset already exists in your Snipe-IT inventory, the tool will sync the information you specify in the settings.conf file and make sure that the asset tag in Kandji matches the asset tag in Snipe-IT, where Snipe-IT's asset tag for that device is considered the authority.
13 |
14 | If the asset tag field is blank in Kandji when the record is being created in Snipe-IT, the script will create an asset tag with `KANDJI-$SERIAL_NUMBER` unless you enable `use_custom_pattern` in your `settings.conf` file.
15 |
16 | ## Requirements:
17 |
18 | - Python3
19 | - Python dependencies - Can be installed using the included `requirements.txt` using the command `python3 -m pip install -r requirements.txt` or individually using the commands `python3 -m pip install requests` and `python3 -m pip install pytz`.
20 | - A [Kandji API token](https://support.kandji.io/support/solutions/articles/72000560412-kandji-api) with the following permissions:
21 | - Devices
22 | - Update a device
23 | - Device details
24 | - Device list
25 | - Device ID
26 | - Device Activity
27 | - A [Snipe-IT API key](https://snipe-it.readme.io/reference#generating-api-tokens) for a user with the following permissions:
28 | - Assets
29 | - View
30 | - Create
31 | - Edit
32 | - Checkin
33 | - Checkout
34 | - Users
35 | - View
36 | - Models
37 | - View
38 | - Create
39 | - Edit
40 | - Self
41 | - Two-Factor Authentication *(recommended, but not required)*
42 | - Create API Keys
43 |
44 | ## Configuration - settings.conf:
45 |
46 | All of the keys highlighted in **bold** below are *required* in your `settings.conf` file. Please review the [settings.conf.example](https://github.com/grokability/kandji2snipe/blob/main/settings.conf.example) file for example settings and additional details.
47 |
48 | Note: do not add `""` or `''` around any values.
49 |
50 | API tokens can also be provided via environment variables for additional security.
51 | ```bash
52 | export KANDJI_APITOKEN=kandji-api-bearer-token-here
53 | export SNIPE_APIKEY=snipe-api-key-here
54 | ```
55 |
56 | **[kandji]**
57 |
58 | - **`tenant`**: Your tenant name, for example accuhive.kandji.io would be **accuhive**
59 | - **`region`**: **us** or **eu**
60 | - **`apitoken`**: Your [Kandji API token](https://support.kandji.io/support/solutions/articles/72000560412-kandji-api)
61 |
62 | **[snipe-it]**
63 |
64 | - **`url`**: The base URL for your Snipe-IT instance (ie https://snipeit.example.com)
65 | - **`apikey`**: Your [Snipe-IT API key](https://snipe-it.readme.io/reference#generating-api-tokens)
66 | - **`time_zone`**: The time zone that your Snipe-IT instance is set to. Refer to `APP_TIMEZONE=` in your Snipe-IT .env file.
67 | - **`manufacturer_id`**: The manufacturer database field id for Apple in your Snipe-IT instance.
68 | - **`defaultStatus`**: The status database field id to assign to new assets created in Snipe-IT from Kandji. Typically you will want to pick a status like "Ready To Deploy".
69 | - **`mac_model_category_id`**: The ID of the category you want to assign to Mac computers. You will have to create this in Snipe-IT and note the Category ID.
70 | - **`iphone_model_category_id`**: The ID of the category you want to assign to iPhones. You will have to create this in Snipe-IT and note the Category ID.
71 | - **`ipad_model_category_id`**: The ID of the category you want to assign to iPads. You will have to create this in Snipe-IT and note the Category ID.
72 | - **`appletv_model_category_id`**: The ID of the category you want to assign to Apple TVs. You will have to create this in Snipe-IT and note the Category ID.
73 | - `mac_custom_fieldset_id`: The ID of the Custom Fieldset you want to assign to Mac computers. You will have to create this fieldset in Snipe-IT and note the Fieldset ID.
74 | - `iphone_custom_fieldset_id`: The ID of the Custom Fieldset you want to assign to iPhones. You will have to create this fieldset in Snipe-IT and note the Fieldset ID.
75 | - `ipad_custom_fieldset_id`: The ID of the Custom Fieldset you want to assign to iPads. You will have to create this fieldset in Snipe-IT and note the Fieldset ID.
76 | - `appletv_custom_fieldset_id`: The ID of the Custom Fieldset you want to assign to Apple TVs. You will have to create this fieldset in Snipe-IT and note the Fieldset ID.
77 |
78 |
79 | **[asset-tag]**
80 | - **`use_custom_pattern`**: Set to **yes** to set your own patterns, if set to **no**, devices with no existing asset tag in Kandji will default to `KANDJI-$SERIAL_NUMBER`.
81 | - `pattern_mac`: The pattern to use when creating new Macs in Snipe-IT that do not have an asset tag in Kandji.
82 | - `pattern_iphone`: The pattern to use when creating new iPhones in Snipe-IT that do not have an asset tag in Kandji.
83 | - `pattern_ipad`: The pattern to use when creating new iPads in Snipe-IT that do not have an asset tag in Kandji.
84 | - `pattern_appletv`: The pattern to use when creating new Apple TVs in Snipe-IT that do not have an asset tag in Kandji.
85 |
86 |
87 |
88 | >**Note:**
89 | >The following sections require custom fields to be created in Snipe-IT. Please see the [API Mapping](#api-mapping) section for details.
90 |
91 | **[mac-api-mapping]**
92 |
93 | **[iphone-api-mapping]**
94 |
95 | **[ipad-api-mapping]**
96 |
97 | **[appletv-api-mapping]**
98 |
99 |
100 |
101 | ### API Mapping
102 |
103 | To get the database fields for Snipe-IT Custom Fields, go to Settings and then Custom Fields inside of your Snipe-IT instance, scroll down past Fieldsets to Custom Fields, click the column selection button and make sure the 'DB Field' checkbox is checked. Copy and paste the DB Field name for Snipe-IT under *platform*-api-mapping sections in your `settings.conf` file.
104 |
105 | To get the API mapping fields for Kandji, refer to Kandji's [Device Details](https://api-docs.kandji.io/#efa2170d-e5f7-4b97-8f4c-da6f84ba58b5) API example response.
106 |
107 | Some example API mappings can be found below:
108 |
109 | - MAC Address: `_snipeit_ = general mac_address`
110 | - IPv4 Address: `_snipeit_ = general ip_address`
111 | - Blueprint Name: `_snipeit__ = general blueprint_name`
112 | - FileVault Status: `_snipeit_ = filevault filevault_enabled`
113 | - Kandji Device ID: `_snipeit_ = general device_id`
114 |
115 | You need to set the `manufacturer_id` for Apple devices in the `settings.conf` file. You can grab the `manufacturer_id` in Snipe-IT by going to Manufacturers > click the column selection button > select the `ID` checkbox.
116 |
117 | ## Usage
118 | ```
119 | usage: kandji2snipe [-h] [-l] [-v] [-d] [--dryrun] [--version] [--auto_incrementing] [--do_not_update_kandji] [--do_not_verify_ssl] [-r] [-f] [-u] [-uns] [--mac | --iphone | --ipad | --appletv]
120 |
121 | optional arguments:
122 | -h, --help Shows this help message and exits.
123 | -l, --logfile Saves logging messages to kandji2snipe.log instead of displaying on screen.
124 | -v, --verbose Sets the logging level to INFO and gives you a better idea of what the script is doing.
125 | -d, --debug Sets logging to include additional DEBUG messages.
126 | --dryrun This checks your config and tries to contact both the Kandji and Snipe-IT instances, but exits before updating or syncing any assets.
127 | --version Shows the version of this script and exits.
128 | --auto_incrementing You can use this if you have auto-incrementing enabled in your Snipe-IT instance to utilize that instead of using KANDJI- for the asset tag.
129 | --do_not_update_kandji Does not update Kandji with the asset tags stored in Snipe-IT.
130 | --do_not_verify_ssl Skips SSL verification for all Snipe-IT requests. Helpful when you use self-signed certificate.
131 | -r, --ratelimited Puts a half second delay between Snipe-IT API calls to adhere to the standard 120/minute rate limit
132 | -f, --force Updates the Snipe-IT asset with information from Kandji every time, despite what the timestamps indicate.
133 | -u, --users Checks in/out assets based on the user assignment in Kandji.
134 | -uns, --users_no_search Doesn't search for any users if the specified fields in Kandji and Snipe-IT don't match. (case insensitive)
135 | --mac Runs against Kandji Mac computers only.
136 | --iphone Runs against Kandji iPhones only.
137 | --ipad Runs against Kandji iPads only.
138 | --appletv Runs against Kandji Apple TVs only.
139 |
140 | ```
141 |
142 | ## Testing
143 |
144 | It is recommended that you use a test/dev Snipe-IT instance for testing and that your backups are up to date. You can spin up a Snipe-IT instance in Docker pretty quickly ([see the Snipe-IT docs](https://snipe-it.readme.io/docs/docker)).
145 |
146 | If you do not have a test/dev Kandji tenant, you can test kandji2snipe using the `--do_not_update_kandji` argument to prevent data being written back to Kandji.
147 |
148 | ## Acknowledgements
149 |
150 | **kandji2snipe** is inspired by and forked from [jamf2snipe](https://github.com/grokability/jamf2snipe), created by [Brian Monroe](https://github.com/ParadoxGuitarist). Thank you for your contributions to the Mac Admin and Open Source communities!
151 |
152 | ## Contributing
153 |
154 | If you have something you'd like to add please help by forking this project then creating a pull request to the `develop` 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.
155 |
--------------------------------------------------------------------------------
/images/kandji-api-requirements.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/grokability/kandji2snipe/bd8f525d1a7f97e1f838a19399bc272928d6dda0/images/kandji-api-requirements.png
--------------------------------------------------------------------------------
/kandji2snipe:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # kandji2snipe
3 | #
4 | # ABOUT:
5 | # This python3 script leverages the Kandji and Snipe-IT APIs to sync device details
6 | # from Kandji to Snipe-IT and sync asset tags from Snipe-IT to Kandji.
7 | #
8 | # https://kandji.io
9 | # https://snipeitapp.com
10 | #
11 | # LICENSE:
12 | # MIT
13 | #
14 | # CONFIGURATION:
15 | # kandji2snipe settings are set in the settings.conf file. For more detais please see
16 | # the README at https://github.com/grokability/kandji2snipe
17 | #
18 |
19 | version = "1.0.0"
20 |
21 | # Standard Library Imports
22 | import json
23 | import time
24 | import configparser
25 | import argparse
26 | import logging
27 | import sys
28 | import html
29 | from datetime import datetime
30 | import os
31 |
32 | # 3rd Party Imports
33 | try:
34 | import pytz
35 | except ImportError as import_error:
36 | print(import_error)
37 | sys.exit(
38 | "Looks like you need to install the pytz module. Open a Terminal and run "
39 | "\033[1m python3 -m pip install pytz\033[0m."
40 | )
41 |
42 | try:
43 | import requests
44 | except ImportError as import_error:
45 | print(import_error)
46 | sys.exit(
47 | "Looks like you need to install the requests module. Open a Terminal and run "
48 | "\033[1m python3 -m pip install requests\033[0m."
49 | )
50 |
51 | from requests.adapters import HTTPAdapter
52 |
53 | # Define runtime arguments
54 | runtimeargs = argparse.ArgumentParser()
55 | runtimeargs.add_argument("-l", "--logfile", help="Saves logging messages to kandji2snipe.log instead of displaying on screen.", action="store_true")
56 | 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")
57 | runtimeargs.add_argument("-d", "--debug", help="Sets logging to include additional DEBUG messages.", action="store_true")
58 | runtimeargs.add_argument("--dryrun", help="This checks your config and tries to contact both the Kandji and Snipe-IT instances, but exits before updating or syncing any assets.", action="store_true")
59 | runtimeargs.add_argument("--version", help="Prints the version of this script and exits.", action="store_true")
60 | runtimeargs.add_argument("--auto_incrementing", help="You can use this if you have auto-incrementing enabled in your Snipe-IT instance to utilize that instead of using KANDJI- for the asset tag.", action="store_true")
61 | runtimeargs.add_argument("--do_not_update_kandji", help="Does not update Kandji with the asset tags stored in Snipe-IT.", action="store_false")
62 | runtimeargs.add_argument('--do_not_verify_ssl', help="Skips SSL verification for all Snipe-IT requests. Helpful when you use self-signed certificate.", action="store_false")
63 | 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")
64 | runtimeargs.add_argument("-f", "--force", help="Updates the Snipe-IT asset with information from Kandji every time, despite what the timestamps indicate.", action="store_true")
65 | runtimeargs.add_argument("-u", "--users", help="Checks in/out assets based on the user assignment in Kandji.", action="store_true")
66 | runtimeargs.add_argument("-uns", "--users_no_search", help="Doesn't search for any users if the specified fields in Kandji and Snipe-IT don't match. (case insensitive)", action="store_true")
67 | type_opts = runtimeargs.add_mutually_exclusive_group()
68 | type_opts.add_argument("--mac", help="Runs against Kandji Mac computers only.", action="store_true")
69 | type_opts.add_argument("--iphone", help="Runs against Kandji iPhones only.", action="store_true")
70 | type_opts.add_argument("--ipad", help="Runs against Kandji iPads only.", action="store_true")
71 | type_opts.add_argument("--appletv", help="Runs against Kandji Apple TVs only.", action="store_true")
72 | user_args = runtimeargs.parse_args()
73 |
74 | validarrays = [
75 | "general",
76 | "mdm",
77 | "activation_lock",
78 | "filevault",
79 | "automated_device_enrollment",
80 | "kandji_agent",
81 | "hardware_overview",
82 | "volumes",
83 | "network",
84 | "recovery_information",
85 | "users",
86 | "installed_profiles",
87 | "apple_business_manager",
88 | "security_information"
89 | ]
90 |
91 | # Define Functions
92 |
93 | # Find and validate the settings.conf file
94 | def get_settings():
95 | # Find a valid settings.conf file.
96 | logging.info("Searching for a valid settings.conf file.")
97 | global config
98 | config = configparser.ConfigParser()
99 | logging.debug("Checking for a settings.conf in /opt/kandji2snipe ...")
100 | config.read("/opt/kandji2snipe/settings.conf")
101 | if 'snipe-it' not in set(config):
102 | logging.debug("No valid config found in: /opt Checking for a settings.conf in /etc/kandji2snipe ...")
103 | config.read('/etc/kandji2snipe/settings.conf')
104 | if 'snipe-it' not in set(config):
105 | logging.debug("No valid config found in /etc Checking for a settings.conf in current directory ...")
106 | config.read("settings.conf")
107 | if 'snipe-it' not in set(config):
108 | logging.debug("No valid config found in current folder.")
109 | logging.error("No valid settings.conf was found. Refer to the README for valid locations.")
110 | sys.exit(exit_error_message)
111 |
112 | logging.info("Settings.conf found.")
113 |
114 | # Settings.conf Value Validation - Ensuring some important settings are not empty or default values
115 | logging.debug("Checking the settings.conf file for valid values.")
116 |
117 | if config['kandji']['tenant'] == "TENANTNAME" or config['kandji']['tenant'] == "":
118 | logging.error('Error: Invalid Kandji Tenant, check your settings.conf and try again.')
119 | sys.exit(exit_error_message)
120 |
121 | if config['kandji']['region'] != "us" and config['kandji']['region'] != "eu":
122 | logging.error('Invalid Kandji Region, check your settings.conf and try again.')
123 | sys.exit(exit_error_message)
124 |
125 | if os.environ.get("KANDJI_APITOKEN") == "":
126 | if config['kandji']['apitoken'] == "kandji-api-bearer-token-here" or config['kandji']['apitoken'] == "" :
127 | logging.error('Invalid Kandji API Token, check your settings.conf or environment variables and try again.')
128 | sys.exit(exit_error_message)
129 |
130 | if config['snipe-it']['url'] == "https://your_snipe_instance.com"or config['snipe-it']['url'] == "":
131 | logging.error('Invalid Snipe-IT URL, check your settings.conf and try again.')
132 | sys.exit(exit_error_message)
133 |
134 | if os.environ.get("SNIPE_APIKEY") == "":
135 | if config['snipe-it']['apikey'] == "snipe-api-key-here" or config['snipe-it']['apikey'] == "" :
136 | logging.error('Invalid Snipe-IT API Key, check your settings.conf or environment variables and try again.')
137 | sys.exit(exit_error_message)
138 |
139 | if config['snipe-it']['mac_custom_fieldset_id'] != "":
140 | for key in config['mac-api-mapping']:
141 | kandjisplit = config['mac-api-mapping'][key].split()
142 | if kandjisplit[0] in validarrays:
143 | logging.debug('Found valid array: {}'.format(kandjisplit[0]))
144 | continue
145 | else:
146 | logging.error("Found invalid array: {} in the settings.conf file.\nThis is not in the acceptable list of arrays. Check your settings.conf\n Valid arrays are: {}".format(kandjisplit[0], ', '.join(validarrays)))
147 | sys.exit(exit_error_message)
148 | if config['snipe-it']['iphone_custom_fieldset_id'] != "":
149 | for key in config['iphone-api-mapping']:
150 | kandjisplit = config['iphone-api-mapping'][key].split()
151 | if kandjisplit[0] in validarrays:
152 | logging.debug('Found valid array: {}'.format(kandjisplit[0]))
153 | continue
154 | else:
155 | logging.error("Found invalid array: {} in the settings.conf file.\nThis is not in the acceptable list of arrays. Check your settings.conf\n Valid arrays are: {}".format(kandjisplit[0], ', '.join(validarrays)))
156 | sys.exit(exit_error_message)
157 | if config['snipe-it']['ipad_custom_fieldset_id'] != "":
158 | for key in config['ipad-api-mapping']:
159 | kandjisplit = config['ipad-api-mapping'][key].split()
160 | if kandjisplit[0] in validarrays:
161 | logging.debug('Found valid array: {}'.format(kandjisplit[0]))
162 | continue
163 | else:
164 | logging.error("Found invalid array: {} in the settings.conf file.\nThis is not in the acceptable list of arrays. Check your settings.conf\n Valid arrays are: {}".format(kandjisplit[0], ', '.join(validarrays)))
165 | sys.exit(exit_error_message)
166 | if config['snipe-it']['appletv_custom_fieldset_id'] != "":
167 | for key in config['appletv-api-mapping']:
168 | kandjisplit = config['appletv-api-mapping'][key].split()
169 | if kandjisplit[0] in validarrays:
170 | logging.debug('Found valid array: {}'.format(kandjisplit[0]))
171 | continue
172 | else:
173 | logging.error("Found invalid array: {} in the settings.conf file.\nThis is not in the acceptable list of arrays. Check your settings.conf\n Valid arrays are: {}".format(kandjisplit[0], ', '.join(validarrays)))
174 | sys.exit(exit_error_message)
175 |
176 | # Create variables based on setings.conf values
177 | def create_variables():
178 | # Create Global Variables
179 | global kandji_base
180 | global kandji_apitoken
181 | global kandjiheaders
182 | global snipe_base
183 | global snipe_apikey
184 | global snipeheaders
185 | global defaultStatus
186 | global apple_manufacturer_id
187 |
188 | logging.info('Creating variables from settings.conf')
189 |
190 | # Kandji Base URL
191 | if config['kandji']['region'] == "eu":
192 | kandji_base = f"https://{config['kandji']['tenant']}.api.eu.kandji.io"
193 | logging.info("The Kandji base url is: {}".format(kandji_base))
194 | else:
195 | kandji_base = f"https://{config['kandji']['tenant']}.api.kandji.io"
196 | logging.info("The Kandji base url is: {}".format(kandji_base))
197 |
198 | # Kandji API Token
199 | kandji_apitoken = os.environ.get("KANDJI_APITOKEN", config['kandji']['apitoken'])
200 | logging.debug("The Kandji API token is: {}".format(kandji_apitoken))
201 |
202 | # Snipe-IT base URL, API key, default status, and Apple manufacturer ID
203 | snipe_base = config['snipe-it']['url']
204 | logging.info("The Snipe-IT base url is: {}".format(snipe_base))
205 |
206 | # Snipe API Key
207 | snipe_apikey = os.environ.get("SNIPE_APIKEY",config['snipe-it']['apikey'])
208 | logging.debug("The Snipe-IT API key is: {}".format(snipe_apikey))
209 |
210 | defaultStatus = config['snipe-it']['defaultStatus']
211 | logging.info("Status ID for new assets created in Snipe-IT: {}".format(defaultStatus))
212 | apple_manufacturer_id = config['snipe-it']['manufacturer_id']
213 | logging.info("The Snipe-IT manufacturer ID for Apple is: {}".format(apple_manufacturer_id))
214 |
215 | # Headers for the API calls
216 | logging.info("Creating the headers we'll need for API calls")
217 | kandjiheaders = {'Authorization': 'Bearer {}'.format(kandji_apitoken),'Accept': 'application/json','Content-Type':'application/json;charset=utf-8','Cache-Control': 'no-cache'}
218 | snipeheaders = {'Authorization': 'Bearer {}'.format(snipe_apikey),'Accept': 'application/json','Content-Type':'application/json'}
219 | logging.debug('Request headers for Kandji will be: {}\nRequest headers for Snipe-IT will be: {}'.format(kandjiheaders, snipeheaders))
220 |
221 | # Configure logging and set logging level
222 | def set_logging():
223 | global exit_error_message
224 | # Configure logging
225 | if user_args.logfile:
226 | log_file = 'kandji2snipe.log'
227 | exit_error_message = 'kandji2snipe exited due to an erorr. Please check kandji2snipe.log for details.'
228 | else:
229 | log_file = ''
230 | exit_error_message = 1
231 |
232 |
233 | log_format = '%(asctime)s - %(levelname)s - %(message)s'
234 | date_format = '%Y-%m-%d %H:%M:%S'
235 |
236 | # Set logging level
237 | if user_args.verbose:
238 | logging.basicConfig(level=logging.INFO,format=log_format,datefmt=date_format,filename=log_file)
239 | logging.info('Verbose Logging Enabled...')
240 | elif user_args.debug:
241 | logging.basicConfig(level=logging.DEBUG,format=log_format,datefmt=date_format,filename=log_file)
242 | logging.info('Debug Logging Enabled...')
243 | else:
244 | logging.basicConfig(level=logging.WARNING,format=log_format,datefmt=date_format,filename=log_file)
245 |
246 | # Verify that Snipe-IT is accessible
247 | def snipe_access_test():
248 | try:
249 | SNIPE_UP = True if requests.get(snipe_base, verify=user_args.do_not_verify_ssl).status_code == 200 else False
250 | except Exception as e:
251 | logging.exception(e)
252 | SNIPE_UP = False
253 | if not SNIPE_UP:
254 | logging.error('Snipe-IT cannot be reached from here. \nPlease check the Snipe-IT url in the settings.conf file.')
255 | sys.exit(exit_error_message)
256 | else:
257 | logging.info('We were able to get a good response from your Snipe-IT instance.')
258 |
259 | # Verify that Kandji is accessible
260 | def kandji_access_test():
261 | try:
262 | api_url = '{0}/api/v1/devices'.format(kandji_base)
263 | KANDJI_UP = True if requests.get(api_url).status_code in (200, 401) else False
264 | except Exception as e:
265 | logging.exception(e)
266 | KANDJI_UP = False
267 | if not KANDJI_UP:
268 | logging.error('Kandji cannot be reached from here. \nPlease check the Kandji tenant and region in the settings.conf file.')
269 | sys.exit(exit_error_message)
270 | else:
271 | logging.info('We were able to get a good response from your Kandji instance.')
272 |
273 | # This function is run every time a request is made, handles rate limiting for Snipe-IT.
274 | def request_handler(r, *args, **kwargs):
275 | global snipe_api_count
276 | global first_snipe_call
277 | snipe_api_count = 0
278 | first_snipe_call = None
279 |
280 | if (snipe_base in r.url) and user_args.ratelimited:
281 | if '"messages":429' in r.text:
282 | logging.warning("Despite respecting the rate limit of Snipe-IT, we've still been limited. Trying again after sleeping for 2 seconds.")
283 | time.sleep(2)
284 | re_req = r.request
285 | s = requests.Session()
286 | return s.send(re_req)
287 | if snipe_api_count == 0:
288 | first_snipe_call = time.time()
289 | time.sleep(0.5)
290 | snipe_api_count += 1
291 | time_elapsed = (time.time() - first_snipe_call)
292 | snipe_api_rate = snipe_api_count / time_elapsed
293 | if snipe_api_rate > 1.95:
294 | sleep_time = 0.5 + (snipe_api_rate - 1.95)
295 | logging.debug('Going over Snipe-IT rate limit of 120/minute ({}/minute), sleeping for {}'.format(snipe_api_rate,sleep_time))
296 | time.sleep(sleep_time)
297 | 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))
298 | if '"messages":429' in r.text:
299 | logging.error(r.content)
300 | logging.error("We've been rate limited. Use option -r to respect the built in Snipe-IT API rate limit of 120/minute.")
301 | sys.exit(exit_error_message)
302 | return r
303 |
304 | # Kandji API Error Handling
305 | def kandji_error_handling(resp, resp_code, err_msg):
306 | """Handle HTTP errors."""
307 | # 400
308 | if resp_code == requests.codes["bad_request"]:
309 | logging.error(f"{err_msg}")
310 | logging.error(f"\tResponse msg: {resp.text}\n")
311 | sys.exit(exit_error_message)
312 | # 401
313 | elif resp_code == requests.codes["unauthorized"]:
314 | logging.error(f"{err_msg}")
315 | logging.error(
316 | "This error can occur if the token is incorrect, was revoked, the required "
317 | "permissions are missing, or the token has expired.")
318 | sys.exit(exit_error_message)
319 | # 403
320 | elif resp_code == requests.codes["forbidden"]:
321 | logging.error(f"{err_msg}")
322 | logging.error("The api key may be invalid or missing.")
323 | sys.exit(exit_error_message)
324 | # 404
325 | elif resp_code == requests.codes["not_found"]:
326 | logging.error("\nWe cannot find the one that you are looking for...")
327 | logging.error(f"\tError: {err_msg}")
328 | logging.error(f"\tResponse msg: {resp}")
329 | logging.error(
330 | "\tPossible reason: If this is a device it could be because the device is "
331 | "no longer\n"
332 | "\t\t\t enrolled in Kandji. This would prevent the MDM command from being\n"
333 | "\t\t\t sent successfully.\n"
334 | )
335 | sys.exit(exit_error_message)
336 | # 429
337 | elif resp_code == requests.codes["too_many_requests"]:
338 | logging.error(f"{err_msg}")
339 | logging.error("You have reached the rate limit ...")
340 | print("Try again later ...")
341 | sys.exit(exit_error_message)
342 | # 500
343 | elif resp_code == requests.codes["internal_server_error"]:
344 | logging.error(f"{err_msg}")
345 | logging.error("The service is having a problem...")
346 | sys.exit(exit_error_message)
347 | # 503
348 | elif resp_code == requests.codes["service_unavailable"]:
349 | logging.error(f"{err_msg}")
350 | logging.error("Unable to reach the service. Try again later...")
351 | sys.exit(exit_error_message)
352 | else:
353 | logging.error("Something really bad must have happened...")
354 | logging.error(f"{err_msg}")
355 | sys.exit(exit_error_message)
356 |
357 | # Function to use the Kandji API
358 | def kandji_api(method, endpoint, params=None, payload=None):
359 | """Make an API request and return data.
360 | method - an HTTP Method (GET, POST, PATCH, DELETE).
361 | endpoint - the API URL endpoint to target.
362 | params - optional parameters can be passed as a dict.
363 | payload - optional payload is passed as a dict and used with PATCH and POST
364 | methods.
365 | Returns a JSON data object.
366 | """
367 | attom_adapter = HTTPAdapter(max_retries=3)
368 | session = requests.Session()
369 | session.mount(kandji_base, attom_adapter)
370 |
371 | try:
372 | response = session.request(
373 | method,
374 | kandji_base + endpoint,
375 | data=payload,
376 | headers=kandjiheaders,
377 | params=params,
378 | timeout=30,
379 | )
380 |
381 | # If a successful status code is returned (200 and 300 range)
382 | if response:
383 | try:
384 | data = response.json()
385 | except Exception:
386 | data = response.text
387 |
388 | # If the request is successful exceptions will not be raised
389 | response.raise_for_status()
390 |
391 | except requests.exceptions.RequestException as err:
392 | kandji_error_handling(resp=response, resp_code=response.status_code, err_msg=err)
393 | data = {"error": f"{response.status_code}", "api resp": f"{err}"}
394 |
395 | return data
396 |
397 | # Function to get device records from Kandji
398 | def get_kandji_devices(platform):
399 | count = 0
400 | # dict placeholder for params passed to api requests
401 | params = {}
402 | # limit - set the number of records to return per API call
403 | limit = 300
404 | # offset - set the starting point within a list of resources
405 | offset = 0
406 | # inventory
407 | data = []
408 |
409 | while True:
410 | # update params
411 | params.update(
412 | {"platform": f"{platform}", "limit": f"{limit}", "offset": f"{offset}"}
413 | )
414 |
415 | # get devices
416 | endpoint="/api/v1/devices"
417 | logging.debug('Calling for all devices in Kandji against: {}'.format(kandji_base + endpoint))
418 | response = kandji_api(method="GET", endpoint=endpoint, params=params)
419 | count += len(response)
420 | offset += limit
421 | if len(response) == 0:
422 | break
423 |
424 | # breakout the response then append to the data list
425 | for record in response:
426 | data.append(record)
427 |
428 | return data
429 |
430 | # Function to lookup details of a specific Kandji asset using the Device ID.
431 | def get_kandji_device_details(kandji_id):
432 | endpoint=f"/api/v1/devices/{kandji_id}/details"
433 | logging.debug('Calling for device details in Kandji against: {}'.format(kandji_base + endpoint))
434 | response = kandji_api(method="GET", endpoint=endpoint)
435 | return response
436 |
437 | # Function to lookup last activity date and time for a Kandji asset.
438 | def get_kandji_device_activity_date(kandji_id):
439 | endpoint=f"/api/v1/devices/{kandji_id}/activity"
440 | logging.debug('Calling for device activity in Kandji against: {}'.format(kandji_base + endpoint))
441 | response = kandji_api(method="GET", endpoint=endpoint)
442 | return response
443 |
444 | # Function to update the asset tag of devices in Kandji with an number passed from Snipe-IT.
445 | def update_kandji_asset_tag(kandji_id, asset_tag):
446 | endpoint=f"/api/v1/devices/{kandji_id}"
447 | payload = '{{"asset_tag": "{}"}}'.format(asset_tag)
448 | logging.debug('Making PATCH request against: {}\n\tPayload for the request is: {}'.format(kandji_base + endpoint, payload))
449 | response = kandji_api(method="PATCH", endpoint=endpoint,payload=payload)
450 | return response
451 |
452 | # Function to lookup a Snipe-IT asset by serial number.
453 | def search_snipe_asset(serial):
454 | api_url = '{}/api/v1/hardware/byserial/{}'.format(snipe_base, serial)
455 | response = requests.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
456 | if response.status_code == 200:
457 | jsonresponse = response.json()
458 | # Check to make sure there's actually a result
459 | if "total" in jsonresponse:
460 | if jsonresponse['total'] == 1:
461 | return jsonresponse
462 | elif jsonresponse['total'] == 0:
463 | logging.info("No assets match {}".format(serial))
464 | return "NoMatch"
465 | else:
466 | logging.warning('FOUND {} matching assets while searching for: {}'.format(jsonresponse['total'], serial))
467 | return "MultiMatch"
468 | else:
469 | logging.info("No assets match {}".format(serial))
470 | return "NoMatch"
471 | else:
472 | logging.warning('Snipe-IT responded with error code:{} when we tried to look up: {}'.format(response.text, serial))
473 | logging.debug('{} - {}'.format(response.status_code, response.content))
474 | return "ERROR"
475 |
476 | # Function to get all the asset models from Snipe-IT
477 | def get_snipe_models():
478 | api_url = '{}/api/v1/models'.format(snipe_base)
479 | logging.debug('Calling against: {}'.format(api_url))
480 | response = requests.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
481 | if response.status_code == 200:
482 | jsonresponse = response.json()
483 | logging.info("Got a valid response that should have {} models.".format(jsonresponse['total']))
484 | if jsonresponse['total'] <= len(jsonresponse['rows']) :
485 | return jsonresponse
486 | else:
487 | logging.info("We didn't get enough results so we need to get them again.")
488 | api_url = '{}/api/v1/models?limit={}'.format(snipe_base, jsonresponse['total'])
489 | newresponse = requests.get(api_url, headers=snipeheaders, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
490 | if response.status_code == 200:
491 | newjsonresponse = newresponse.json()
492 | if newjsonresponse['total'] == len(newjsonresponse['rows']) :
493 | return newjsonresponse
494 | else:
495 | logging.error("Unable to get all models from Snipe-IT")
496 | sys.exit(exit_error_message)
497 | else:
498 | logging.error('When we tried to retrieve a list of models, Snipe-IT responded with error status code:{} - {}'.format(response.status_code, response.content))
499 | sys.exit(exit_error_message)
500 | else:
501 | logging.error('When we tried to retrieve a list of models, Snipe-IT responded with error status code:{} - {}'.format(response.status_code, response.content))
502 | sys.exit(exit_error_message)
503 |
504 | # Recursive function returns all users in a Snipe-IT Instance, 100 at a time.
505 | def get_snipe_users(previous=[]):
506 | user_id_url = '{}/api/v1/users'.format(snipe_base)
507 | payload = {
508 | 'limit': 100,
509 | 'offset': len(previous)
510 | }
511 | logging.debug('The payload for the Snipe-IT users GET is {}'.format(payload))
512 | response = requests.get(user_id_url, headers=snipeheaders, params=payload, hooks={'response': request_handler})
513 | response_json = response.json()
514 | current = response_json['rows']
515 | if len(previous) != 0:
516 | current = previous + current
517 | if response_json['total'] > len(current):
518 | logging.debug('We have more than 100 users, get the next page - total: {} current: {}'.format(response_json['total'], len(current)))
519 | return get_snipe_users(current)
520 | else:
521 | return current
522 |
523 | # Function to search Snipe-IT for a user
524 | def get_snipe_user_id(username):
525 | if username == '':
526 | return "NotFound"
527 | username = username.lower()
528 | for user in snipe_users:
529 | for value in user.values():
530 | if str(value).lower() == username:
531 | id = user['id']
532 | return id
533 | if user_args.users_no_search:
534 | 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))
535 | return "NotFound"
536 | logging.debug('No matches in snipe_users for {}, querying the API for the next closest match'.format(username))
537 | user_id_url = '{}/api/v1/users'.format(snipe_base)
538 | payload = {
539 | 'search':username,
540 | 'limit':1,
541 | 'sort':'username',
542 | 'order':'asc'
543 | }
544 | logging.debug('The payload for the Snipe-IT user search is: {}'.format(payload))
545 | response = requests.get(user_id_url, headers=snipeheaders, params=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
546 | try:
547 | return response.json()['rows'][0]['id']
548 | except:
549 | return "NotFound"
550 |
551 | # Function that creates a new Snipe-IT model - not an asset - with a JSON payload
552 | def create_snipe_model(payload):
553 | api_url = '{}/api/v1/models'.format(snipe_base)
554 | 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))
555 | response = requests.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
556 | if response.status_code == 200:
557 | jsonresponse = response.json()
558 | modelnumbers[jsonresponse['payload']['model_number']] = jsonresponse['payload']['id']
559 | return True
560 | else:
561 | logging.warning('Error code: {} while trying to create a new model.'.format(response.status_code))
562 | return False
563 |
564 | # Function to create a new asset by passing array
565 | def create_snipe_asset(payload):
566 | api_url = '{}/api/v1/hardware'.format(snipe_base)
567 | 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))
568 | response = requests.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
569 | logging.debug(response.text)
570 | if response.status_code == 200:
571 | logging.debug("Got back status code: 200 - {}".format(response.content))
572 | jsonresponse = response.json()
573 | if jsonresponse['status'] == "error":
574 | logging.error('Asset creation failed for asset {} with error {}'.format(payload['name'],jsonresponse['messages']))
575 | return 'ERROR', response
576 | return 'AssetCreated', response
577 | else:
578 | logging.error('Asset creation failed for asset {} with error {}'.format(payload['name'],response.text))
579 | return 'ERROR', response
580 |
581 | # Function that updates a Snipe-IT asset with a JSON payload
582 | def update_snipe_asset(snipe_id, payload):
583 | api_url = '{}/api/v1/hardware/{}'.format(snipe_base, snipe_id)
584 | logging.debug('The payload for the Snipe-IT update is: {}'.format(payload))
585 | response = requests.patch(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
586 | # Verify that the payload updated properly.
587 | goodupdate = True
588 | if response.status_code == 200:
589 | 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.")
590 | jsonresponse = response.json()
591 | # Check if there's an Error and Log it, or parse the payload.
592 | if jsonresponse['status'] == "error":
593 | logging.error('Unable to update ID: {}. Error "{}"'.format(snipe_id, jsonresponse['messages']))
594 | goodupdate = False
595 | else:
596 | for key in payload:
597 | if payload[key] == '':
598 | payload[key] = None
599 | if jsonresponse['payload'][key] != payload[key]:
600 | logging.warning('Unable to update ID: {}. We failed to update the {} field with "{}"'.format(snipe_id, key, payload[key]))
601 | goodupdate = False
602 | else:
603 | logging.info("Sucessfully updated {} with: {}".format(key, payload[key]))
604 | return goodupdate
605 | else:
606 | logging.error('Whoops. Got an error status code while updating ID {}: {} - {}'.format(snipe_id, response.status_code, response.content))
607 | return False
608 |
609 | # Function that checks in an asset in Snipe-IT
610 | def checkin_snipe_asset(asset_id):
611 | api_url = '{}/api/v1/hardware/{}/checkin'.format(snipe_base, asset_id)
612 | payload = {
613 | 'note':'Checked in by kandji2snipe'
614 | }
615 | logging.debug('The payload for the Snipe-IT checkin is: {}'.format(payload))
616 | response = requests.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
617 | logging.debug('The response from Snipe-IT is: {}'.format(response.json()))
618 | if response.status_code == 200:
619 | logging.debug("Got back status code: 200 - {}".format(response.content))
620 | return "CheckedOut"
621 | else:
622 | return response
623 |
624 | # Function that checks out an asset in Snipe-IT
625 | def checkout_snipe_asset(user, asset_id, asset_name, checked_out_user=None):
626 | logging.debug('Checking out {} (ID: {}) to {}'.format(asset_name,asset_id,user))
627 | user_id = get_snipe_user_id(user)
628 | if user_id == 'NotFound':
629 | logging.info("User {} not found in Snipe-IT, skipping check out".format(user))
630 | return "NotFound"
631 | if checked_out_user == None:
632 | logging.info("Not checked out, checking out {} (ID: {}) to {}.".format(asset_name,asset_id,user))
633 | elif checked_out_user == "NewAsset":
634 | logging.info("First time this asset will be checked out, checking out to {}".format(user))
635 | else:
636 | logging.info("Checking in {} (ID: {}) to check it out to {}".format(asset_name,asset_id,user))
637 | checkin_snipe_asset(asset_id)
638 | api_url = '{}/api/v1/hardware/{}/checkout'.format(snipe_base, asset_id)
639 | logging.info("Checking out {} (ID: {}) to {}.".format(asset_name,asset_id,user))
640 | payload = {
641 | 'checkout_to_type':'user',
642 | 'assigned_user':user_id,
643 | 'note':'Checked out by kandji2snipe.'
644 | }
645 | logging.debug('The payload for the Snipe-IT checkin is: {}'.format(payload))
646 | response = requests.post(api_url, headers=snipeheaders, json=payload, verify=user_args.do_not_verify_ssl, hooks={'response': request_handler})
647 | logging.debug('The response from Snipe-IT is: {}'.format(response.json()))
648 | if response.status_code == 200:
649 | logging.debug("Got back status code: 200 - {}".format(response.content))
650 | return "CheckedOut"
651 | else:
652 | logging.error('Asset checkout failed for asset {} with error {}'.format(asset_id,response.text))
653 | return response
654 |
655 | ### Main Logic ###
656 |
657 | # Print version and exit
658 | if user_args.version:
659 | print('kandji2snipe v'+version)
660 | sys.exit()
661 |
662 | # Configure logging and set logging level
663 | set_logging()
664 |
665 | # Notify if we're doing a dry run.
666 | if user_args.dryrun and user_args.logfile:
667 | logging.info('Dry Run: Starting')
668 | print("Dry Run: Starting")
669 | elif user_args.dryrun:
670 | logging.info('Dry Run: Starting')
671 |
672 | # Validate User Sync Options
673 | if user_args.users_no_search and not user_args.users:
674 | logging.error("The -uns option requires the use of -u for user syncing.")
675 | sys.exit(exit_error_message)
676 |
677 | # Find and validate the settings.conf file
678 | get_settings()
679 |
680 | # Create variables based on setings.conf values
681 | create_variables()
682 |
683 | # Report if we're verifying SSL or not for Snipe-IT
684 | logging.info("SSL Verification for Snipe-IT is set to: {}".format(user_args.do_not_verify_ssl))
685 |
686 | # Do some tests to see if the hosts are accessible
687 | logging.info("Running tests to see if hosts are up.")
688 |
689 | # Verify that Snipe-IT is accessible
690 | snipe_access_test()
691 |
692 | # Verify that Kandji is accessible
693 | kandji_access_test()
694 |
695 | logging.info("Setup and testing complete. Let's get started...")
696 |
697 | ### Get Started ###
698 |
699 | # Get a list of known models from Snipe-IT
700 | logging.info("Getting a list of models from Snipe-IT.")
701 | snipemodels = get_snipe_models()
702 | logging.debug("Parsing the {} model results for models with model numbers.".format(len(snipemodels['rows'])))
703 | modelnumbers = {}
704 | for model in snipemodels['rows']:
705 | if model['model_number'] == "":
706 | logging.debug("The model, {}, did not have a model number. Skipping.".format(model['name']))
707 | continue
708 | modelnumbers[model['model_number']] = model['id']
709 | logging.info("Our list of models has {} entries.".format(len(modelnumbers)))
710 | logging.debug("Here's the list of the {} models and their id's that we were able to collect:\n{}".format(len(modelnumbers), modelnumbers))
711 |
712 | # Get a list of users from Snipe-IT if the user argument was used
713 | if user_args.users:
714 | snipe_users = get_snipe_users()
715 |
716 | # Get the device_ids of all active assets.
717 | kandji_mac_list = get_kandji_devices(platform ='mac')
718 | kandji_iphone_list = get_kandji_devices(platform ='iphone')
719 | kandji_ipad_list = get_kandji_devices(platform ='ipad')
720 | kandji_appletv_list = get_kandji_devices(platform ='appletv')
721 | kandji_device_types = {
722 | 'mac': kandji_mac_list,
723 | 'iphone': kandji_iphone_list,
724 | 'ipad': kandji_ipad_list,
725 | 'appletv': kandji_appletv_list
726 | }
727 |
728 | TotalNumber = 0
729 | if user_args.mac:
730 | TotalNumber = len(kandji_device_types['mac'])
731 | elif user_args.iphone:
732 | TotalNumber = len(kandji_device_types['iphone'])
733 | elif user_args.ipad:
734 | TotalNumber = len(kandji_device_types['ipad'])
735 | elif user_args.appletv:
736 | TotalNumber = len(kandji_device_types['appletv'])
737 | else:
738 | for kandji_device_type in kandji_device_types:
739 | TotalNumber += len(kandji_device_types[kandji_device_type])
740 |
741 | # Make sure we have a good list.
742 | if TotalNumber != None:
743 | logging.info('Received a list of Kandji assets that had {} entries.'.format(TotalNumber))
744 | else:
745 | logging.error("We were not able to retrieve a list of assets from your Kandji 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")
746 | sys.exit(exit_error_message)
747 |
748 | # After this point we start editing data, so quit if this is a dry run
749 | if user_args.dryrun and user_args.logfile:
750 | logging.info('Dry Run: Complete')
751 | sys.exit('Dry Run: Complete')
752 | elif user_args.dryrun:
753 | logging.info('Dry Run: Complete')
754 | sys.exit()
755 |
756 | # From this point on, we're editing data.
757 | logging.info('Starting to Update Inventory')
758 | CurrentNumber = 0
759 |
760 | for kandji_device_type in kandji_device_types:
761 | if user_args.mac:
762 | if kandji_device_type != 'mac':
763 | continue
764 | if user_args.iphone:
765 | if kandji_device_type != 'iphone':
766 | continue
767 | if user_args.ipad:
768 | if kandji_device_type != 'ipad':
769 | continue
770 | if user_args.appletv:
771 | if kandji_device_type != 'appletv':
772 | continue
773 | for kandji_asset in kandji_device_types[kandji_device_type]:
774 | CurrentNumber += 1
775 | logging.info("Processing entry {} out of {} - Device Name: {} - Device ID: {}".format(CurrentNumber, TotalNumber, kandji_asset['device_name'], kandji_asset['device_id']))
776 |
777 | # Search through the list by device_id for all asset information
778 | kandji = get_kandji_device_details(kandji_asset['device_id'])
779 | if kandji == None:
780 | continue
781 |
782 | # Check that the model number exists in Snipe-IT, if not create it.
783 | if kandji_device_type == 'mac':
784 | if kandji['hardware_overview']['model_identifier'] not in modelnumbers:
785 | logging.info("Could not find a model ID in Snipe-IT for: {}".format(kandji['general']['model']))
786 | newmodel = {"category_id":config['snipe-it']['mac_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": kandji['general']['model'],"model_number":kandji['hardware_overview']['model_identifier']}
787 | if 'mac_custom_fieldset_id' in config['snipe-it']:
788 | fieldset_split = config['snipe-it']['mac_custom_fieldset_id']
789 | newmodel['fieldset_id'] = fieldset_split
790 | create_snipe_model(newmodel)
791 | elif kandji_device_type == 'iphone':
792 | if kandji['hardware_overview']['model_identifier'] not in modelnumbers:
793 | logging.info("Could not find a model ID in Snipe-IT for: {}".format(kandji['general']['model']))
794 | newmodel = {"category_id":config['snipe-it']['iphone_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": kandji['general']['model'],"model_number":kandji['hardware_overview']['model_identifier']}
795 | if 'iphone_custom_fieldset_id' in config['snipe-it']:
796 | fieldset_split = config['snipe-it']['iphone_custom_fieldset_id']
797 | newmodel['fieldset_id'] = fieldset_split
798 | create_snipe_model(newmodel)
799 | elif kandji_device_type == 'ipad':
800 | if kandji['hardware_overview']['model_identifier'] not in modelnumbers:
801 | logging.info("Could not find a model ID in Snipe-IT for: {}".format(kandji['general']['model']))
802 | newmodel = {"category_id":config['snipe-it']['ipad_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": kandji['general']['model'],"model_number":kandji['hardware_overview']['model_identifier']}
803 | if 'ipad_custom_fieldset_id' in config['snipe-it']:
804 | fieldset_split = config['snipe-it']['ipad_custom_fieldset_id']
805 | newmodel['fieldset_id'] = fieldset_split
806 | create_snipe_model(newmodel)
807 | elif kandji_device_type == 'appletv':
808 | if kandji['hardware_overview']['model_identifier'] not in modelnumbers:
809 | logging.info("Could not find a model ID in Snipe-IT for: {}".format(kandji['general']['model']))
810 | newmodel = {"category_id":config['snipe-it']['appletv_model_category_id'],"manufacturer_id":apple_manufacturer_id,"name": kandji['general']['model'],"model_number":kandji['hardware_overview']['model_identifier']}
811 | if 'appletv_custom_fieldset_id' in config['snipe-it']:
812 | fieldset_split = config['snipe-it']['appletv_custom_fieldset_id']
813 | newmodel['fieldset_id'] = fieldset_split
814 | create_snipe_model(newmodel)
815 |
816 |
817 | # Pass the SN from Kandji to search for a match in Snipe-IT
818 | snipe = search_snipe_asset(kandji['hardware_overview']['serial_number'])
819 |
820 | # Create a new asset if there's no match:
821 | if snipe == 'NoMatch':
822 | logging.info("Creating a new asset in Snipe-IT for Kandji ID {} - {}".format(kandji['general']['device_id'], kandji['general']['device_name']))
823 | # This section checks to see if an asset tag exists in Kandji, if not it creates one.
824 | if kandji['general']['asset_tag'] == '':
825 | logging.debug('No asset tag found in Kandji, checking settings.conf for custom asset tag patterns.')
826 | # Check for custom patterns and use them if enabled, otherwise use the default pattern.
827 | if config['asset-tag']['use_custom_pattern'] == 'yes':
828 | logging.debug('Custom asset tag patterns found.')
829 | if kandji_device_type == 'mac':
830 | tag_split = config['asset-tag']['pattern_mac'].split()
831 | kandji_asset_tag = tag_split[0]+kandji['{}'.format(tag_split[1])]['{}'.format(tag_split[2])]
832 | elif kandji_device_type == 'iphone':
833 | tag_split = config['asset-tag']['pattern_iphone'].split()
834 | kandji_asset_tag = tag_split[0]+kandji['{}'.format(tag_split[1])]['{}'.format(tag_split[2])]
835 | elif kandji_device_type == 'ipad':
836 | tag_split = config['asset-tag']['pattern_ipad'].split()
837 | kandji_asset_tag = tag_split[0]+kandji['{}'.format(tag_split[1])]['{}'.format(tag_split[2])]
838 | elif kandji_device_type == 'appletv':
839 | tag_split = config['asset-tag']['pattern_appletv'].split()
840 | kandji_asset_tag = tag_split[0]+kandji['{}'.format(tag_split[1])]['{}'.format(tag_split[2])]
841 | else:
842 | logging.debug('No custom asset tag patterns found in settings.conf, using default.')
843 | kandji_asset_tag = 'KANDJI-{}'.format(kandji['hardware_overview']['serial_number'])
844 | else:
845 | kandji_asset_tag = kandji['general']['asset_tag']
846 | logging.info("Asset tag found in Kandji, setting it to: {}".format(kandji_asset_tag))
847 | # Create the payload
848 | logging.debug("Payload is being made.")
849 | newasset = {'asset_tag': kandji_asset_tag,'model_id': modelnumbers['{}'.format(kandji['hardware_overview']['model_identifier'])], 'name': kandji['general']['device_name'], 'status_id': defaultStatus,'serial': kandji['hardware_overview']['serial_number']}
850 | for snipekey in config['{}-api-mapping'.format(kandji_device_type)]:
851 | kandjisplit = config['{}-api-mapping'.format(kandji_device_type)][snipekey].split()
852 | try:
853 | for i, item in enumerate(kandjisplit):
854 | try:
855 | item = int(item)
856 | except ValueError:
857 | logging.debug('{} is not an integer'.format(item))
858 | if i == 0:
859 | kandji_value = kandji[item]
860 | else:
861 | kandji_value = kandji_value[item]
862 | newasset[snipekey] = kandji_value
863 | except KeyError:
864 | continue
865 | # Reset the payload without the asset_tag if auto_incrementing flag is set.
866 | if user_args.auto_incrementing:
867 | newasset.pop('asset_tag', None)
868 | new_snipe_asset = create_snipe_asset(newasset)
869 | if new_snipe_asset[0] != "AssetCreated":
870 | continue
871 | if user_args.users:
872 | if not kandji['general']['assigned_user']:
873 | logging.info("No user is assigned to {} in Kandji, not checking it out.".format(kandji['general']['device_name']))
874 | continue
875 | logging.info('Checking out new item {} to user {}'.format(kandji['general']['device_name'], kandji['general']['assigned_user']['email']))
876 | checkout_snipe_asset(kandji['general']['assigned_user']['email'], new_snipe_asset[1].json()['payload']['id'], "NewAsset")
877 |
878 | # Log an error if there's an issue, or more than once match.
879 | elif snipe == 'MultiMatch':
880 | 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-IT Admin settings. Skipping serial number {} for now.".format(kandji['hardware_overview']['serial_number']))
881 | elif snipe == 'ERROR':
882 | logging.error("We got an error when looking up serial number {} in Snipe-IT, which shouldn't happen at this point. Check your Snipe-IT instance and setup. Skipping for now.".format(kandji['hardware_overview']['serial_number']))
883 |
884 | else:
885 | # Only update if Kandji has more recent info.
886 | snipe_id = snipe['rows'][0]['id']
887 | snipe_time = snipe['rows'][0]['updated_at']['datetime']
888 | kandji_device_activity = get_kandji_device_activity_date(kandji['general']['device_id'])
889 | kandji_time_conversion = datetime.strptime(kandji_device_activity['activity']['results'][-1]['created_at'], '%Y-%m-%dT%H:%M:%S.%fZ')
890 | kandji_time_conversion = pytz.timezone('UTC').localize(kandji_time_conversion)
891 | kandji_time_conversion = kandji_time_conversion.astimezone(pytz.timezone(config['snipe-it']['timezone']))
892 | kandji_time = kandji_time_conversion.strftime('%Y-%m-%d %H:%M:%S')
893 |
894 | # Check to see that the Kandji record is newer than the previous Snipe-IT update, or if it is a new record in Snipe-IT
895 | if ( kandji_time > snipe_time ) or ( user_args.force ):
896 | if user_args.force:
897 | logging.info("Forcing the update regardless of the timestamps due to -f being used.")
898 | logging.debug("Updating the Snipe-IT asset because Kandji has a more recent timestamp: {} > {} or the Snipe-IT record is new".format(kandji_time, snipe_time))
899 | updates = {}
900 |
901 | if html.unescape(snipe['rows'][0]['name']) != kandji['general']['device_name']:
902 | logging.info('Device name changed in Kandji... Updating Snipe-IT')
903 | updates={'name': kandji['general']['device_name']}
904 |
905 | for snipekey in config['{}-api-mapping'.format(kandji_device_type)]:
906 | try:
907 | kandjisplit = config['{}-api-mapping'.format(kandji_device_type)][snipekey].split()
908 | for i, item in enumerate(kandjisplit):
909 | try:
910 | item = int(item)
911 | except ValueError:
912 | logging.debug('{} is not an integer'.format(item))
913 | if i == 0:
914 | kandji_value = kandji[item]
915 | else:
916 | kandji_value = kandji_value[item]
917 | payload = {snipekey: kandji_value}
918 | latestvalue = kandji_value
919 | except KeyError:
920 | logging.debug("Skipping the payload, because the Kandji key we're mapping to doesn't exist")
921 | continue
922 |
923 | # Need to check that we're not needlessly updating the asset.
924 | # If it's a custom value it'll fail the first section and send it to except section that will parse custom sections.
925 | try:
926 | if snipe['rows'][0][snipekey] != latestvalue:
927 | updates.update(payload)
928 | else:
929 | logging.debug("Skipping the payload, because it already exits.")
930 | except:
931 | 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.")
932 | needsupdate = False
933 | for CustomField in snipe['rows'][0]['custom_fields']:
934 | if snipe['rows'][0]['custom_fields'][CustomField]['field'] == snipekey :
935 | if snipe['rows'][0]['custom_fields'][CustomField]['value'] != str(latestvalue):
936 | logging.debug("Found the field, and the value needs to be updated from {} to {}".format(snipe['rows'][0]['custom_fields'][CustomField]['value'], latestvalue))
937 | needsupdate = True
938 | if needsupdate == True:
939 | updates.update(payload)
940 | else:
941 | logging.debug("Skipping the payload, because it already exists, or the Snipe-IT key we're mapping to doesn't.")
942 |
943 | if updates:
944 | update_snipe_asset(snipe_id, updates)
945 |
946 | if user_args.users:
947 | if snipe['rows'][0]['status_label']['status_meta'] in ('deployable', 'deployed'):
948 | if snipe['rows'][0]['assigned_to'] and not kandji['general']['assigned_user']:
949 | logging.info("No user is assigned to {} in Kandji, checking it in.".format(kandji['general']['device_name']))
950 | checkin_snipe_asset(snipe_id)
951 | elif not kandji['general']['assigned_user']:
952 | logging.info("No user is assigned to {} in Kandji, skipping check out.".format(kandji['general']['device_name']))
953 | continue
954 | elif snipe['rows'][0]['assigned_to'] == None or snipe['rows'][0]['assigned_to']['email'] != kandji['general']['assigned_user']['email']:
955 | logging.info('Checking out {} to user {}'.format(kandji['general']['device_name'], kandji['general']['assigned_user']['email']))
956 | checkout_snipe_asset(kandji['general']['assigned_user']['email'], snipe_id, kandji['general']['device_name'], snipe['rows'][0]['assigned_to'])
957 | elif snipe['rows'][0]['assigned_to']['email'] == kandji['general']['assigned_user']['email']:
958 | logging.info("{} is already checked out to {}, skipping check out.".format(kandji['general']['device_name'],snipe['rows'][0]['assigned_to']['email']))
959 | continue
960 | else:
961 | logging.info("Failed checking out {} to {}.".format(kandji['general']['device_name'],snipe['rows'][0]['assigned_to']['email']))
962 | else:
963 | logging.info("Can't checkout {} since the status isn't set to deployable".format(kandji['general']['device_name']))
964 |
965 | else:
966 | logging.info("Snipe-IT record is newer than the Kandji record. Nothing to sync. If this wrong, then force an inventory update in Kandji")
967 | logging.debug("Not updating the Snipe-IT asset because Snipe-IT has a more recent timestamp: {} < {}".format(kandji_time, snipe_time))
968 |
969 | # Sync the Snipe-IT Asset Tag Number back to Kandji if needed
970 | # The user arg below is set to false if it's called, so this would fail if the user called it.
971 | if (kandji['general']['asset_tag'] != snipe['rows'][0]['asset_tag']) and user_args.do_not_update_kandji :
972 | logging.info("Asset tag changed in Snipe-IT... Updating Kandji")
973 | if snipe['rows'][0]['asset_tag'][0]:
974 | update_kandji_asset_tag("{}".format(kandji['general']['device_id']), '{}'.format(snipe['rows'][0]['asset_tag']))
975 | logging.info("Updating device record")
976 |
977 | if user_args.ratelimited:
978 | logging.debug('Total amount of API calls made: {}'.format(snipe_api_count))
979 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | pytz
--------------------------------------------------------------------------------
/settings.conf.example:
--------------------------------------------------------------------------------
1 | # For details on configuring your settings.conf file, please read the
2 | # README at https://github.com/grokability/kandji2snipe
3 |
4 | [kandji]
5 | tenant = TENANTNAME
6 | region = us
7 | apitoken = kandji-api-bearer-token-here
8 |
9 | [snipe-it]
10 | url = https://your_snipe_instance.com
11 | apikey = snipe-api-key-here
12 | # timezone should match APP_TIMEZONE="timezone" in your Snipe-IT .env file
13 | timezone = America/Los_Angeles
14 | manufacturer_id = 1
15 | defaultStatus = 2
16 | mac_model_category_id = 5
17 | iphone_model_category_id = 6
18 | ipad_model_category_id = 7
19 | appletv_model_category_id = 8
20 | # Custom fieldset values are not required unless mapping Kandji data to Snipe-IT custom fields
21 | mac_custom_fieldset_id =
22 | iphone_custom_fieldset_id =
23 | ipad_custom_fieldset_id =
24 | appletv_custom_fieldset_id =
25 |
26 | [asset-tag]
27 | # If an asset tag does not exist in Kandji and Snipe-IT, the script will create one
28 | # using the pattern KANDJI-. To use the custom patterns, change the
29 | # line below to "use_custom_pattern = yes" and adjust the patterns as desired.
30 | use_custom_pattern = no
31 |
32 | # This is only required if use_custom_pattern = yes
33 | # Patterns must contain a prefix and variable.
34 | # Examples of Kandji device attribute variables:
35 | # Serial Number = hardware_overview serial_number
36 | # Device ID = general device_id
37 | # Device Name = general device_name
38 | pattern_mac = MAC- hardware_overview serial_number
39 | pattern_iphone = IPHONE- hardware_overview serial_number
40 | pattern_ipad = IPAD- hardware_overview serial_number
41 | pattern_appletv = ATV- hardware_overview serial_number
42 |
43 | # API Mappings are not required unless mapping Kandji data to Snipe-IT custom fields
44 |
45 | [mac-api-mapping]
46 | _snipeit_mac_address_1 = network mac_address
47 | _snipeit_blueprint_2 = general blueprint_name
48 | _snipeit_filevault_enabled_3 = filevault filevault_enabled
49 | _snipeit_kandji_device_id_4 = general device_id
50 |
51 | [iphone-api-mapping]
52 | _snipeit_mac_address_1 = network mac_address
53 | _snipeit_blueprint_2 = general blueprint_name
54 | _snipeit_kandji_device_id_4 = general device_id
55 |
56 | [ipad-api-mapping]
57 | _snipeit_mac_address_1 = network mac_address
58 | _snipeit_blueprint_2 = general blueprint_name
59 | _snipeit_kandji_device_id_4 = general device_id
60 |
61 | [appletv-api-mapping]
62 | _snipeit_mac_address_1 = network mac_address
63 | _snipeit_blueprint_2 = general blueprint_name
64 | _snipeit_kandji_device_id_4 = general device_id
65 |
--------------------------------------------------------------------------------