├── .gitignore ├── requirements.txt ├── unifiSnipeSync_Dry_Run_Screenshot.jpg ├── unifi.py ├── .github └── workflows │ └── main.yml ├── config_example.ini ├── snipe.py ├── README.md └── main.py /.gitignore: -------------------------------------------------------------------------------- 1 | config.ini 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.26.0 2 | ratelimiter==1.2.0 3 | tabulate==0.8.9 4 | pyunifi==2.21 5 | termcolor==2.2.0 -------------------------------------------------------------------------------- /unifiSnipeSync_Dry_Run_Screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RodneyLeeBrands/UnifiSnipeSync/HEAD/unifiSnipeSync_Dry_Run_Screenshot.jpg -------------------------------------------------------------------------------- /unifi.py: -------------------------------------------------------------------------------- 1 | from pyunifi.controller import Controller 2 | #I used this to test various connections to the controller. It was not well documented what "version" of the api Cloud Key Gen 2 Plus uses. You might need to use this as well to quickly try lots of optoins 3 | 4 | c = Controller('x.x.x.x', 'snipeitsync', 'password', 443, 'UDMP-unifiOS', 'default', False) 5 | for ap in c.get_aps(): 6 | print('AP named %s with MAC %s' % (ap.get('name'), ap['mac'])) -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: GitlabSync 2 | 3 | on: 4 | - push 5 | - delete 6 | 7 | jobs: 8 | sync: 9 | runs-on: ubuntu-latest 10 | name: Git Repo Sync 11 | steps: 12 | - uses: actions/checkout@v2 13 | with: 14 | fetch-depth: 0 15 | - uses: wangchucheng/git-repo-sync@v0.1.0 16 | with: 17 | # Such as https://github.com/wangchucheng/git-repo-sync.git 18 | target-url: ${{ secrets.TARGET_URL }} 19 | # Such as wangchucheng 20 | target-username: ${{ secrets.TARGET_USERNAME }} 21 | # You can store token in your project's 'Setting > Secrets' and reference the name here. Such as ${{ secrets.ACCESS\_TOKEN }} 22 | target-token: ${{ secrets.TARGET_TOKEN }} 23 | -------------------------------------------------------------------------------- /config_example.ini: -------------------------------------------------------------------------------- 1 | ; config.ini 2 | 3 | [UniFi] 4 | controller_url = unifi-controller.example.com 5 | username = your_unifi_username 6 | password = your_unifi_password 7 | port = 8443 8 | version = unifiOS 9 | site_id = default 10 | 11 | [SnipeIT] 12 | api_url = https://snipe-it.example.com/api/v1 13 | api_key = your_snipeit_api_key 14 | unifi_manufacturer = 3 15 | unifi_model_category_id = 16 16 | mac_address_field_name = _snipeit_mac_address_5 17 | ip_address_field_name = _snipeit_local_ip_address_2 18 | default_status_id = 2 19 | #what to do if the device name in snipe and unifi are different? Options are: snipe, unifi, nochange. - snipe will update unifi's name with snipe's name, unifi will update snipe's name with unifi's name, nochange will not update either 20 | #Updating unifi is not currently supported, so setting to snipe is essentially the same as nochange 21 | device_name_priority = snipe 22 | rate_limit = 120 23 | 24 | [unifi_model_mapping] 25 | UFLHD = UAP-FlexHD 26 | USC8 = US-8 27 | UXGPRO = UXG-Pro 28 | US16P150 = US-16-150W 29 | USL24P = USW-24-POE 30 | US24PRO = USW-Pro-24-PoE 31 | US24PRO2 = USW-Pro-24 32 | UHDIW = UAP-IW-HD 33 | U7HD = UAP-AC-HD 34 | UGW4 = USG-Pro-4 -------------------------------------------------------------------------------- /snipe.py: -------------------------------------------------------------------------------- 1 | # snipe.py 2 | import requests 3 | from ratelimiter import RateLimiter 4 | from time import sleep 5 | 6 | class Snipe: 7 | def __init__(self, api_url, api_key, rate_limit, timeout=30): 8 | self.api_url = api_url 9 | self.api_key = api_key 10 | self.timeout = timeout 11 | self.headers = { 12 | "Accept": "application/json", 13 | "Content-Type": "application/json", 14 | "Authorization": f"Bearer {self.api_key}" 15 | } 16 | self.rate_limiter = RateLimiter(max_calls=rate_limit, period=60) 17 | 18 | def fetch_paginated_results(self, url, params=None): 19 | results = [] 20 | page = 1 21 | 22 | while True: 23 | with self.rate_limiter: 24 | params = params or {} 25 | params["offset"] = (page - 1) * 500 26 | params["limit"] = 500 27 | response = requests.get(url, headers=self.headers, params=params, timeout=self.timeout) 28 | 29 | data = response.json() 30 | 31 | if data.get("status") == "error" and data.get("messages") == "Too Many Requests": 32 | sleep(60) 33 | self.rate_limiter.clear() 34 | continue 35 | 36 | response.raise_for_status() 37 | results.extend(data["rows"]) 38 | 39 | if data["total"] <= len(results): 40 | break 41 | 42 | page += 1 43 | 44 | return results 45 | 46 | 47 | def get_all_hardware(self, params=None): 48 | url = f"{self.api_url}/hardware" 49 | return self.fetch_paginated_results(url, params) 50 | 51 | def get_all_models(self, params=None): 52 | url = f"{self.api_url}/models" 53 | return self.fetch_paginated_results(url, params) 54 | 55 | def create_hardware(self, data): 56 | url = f"{self.api_url}/hardware" 57 | response = requests.post(url, headers=self.headers, json=data, timeout=self.timeout) 58 | response.raise_for_status() 59 | return response 60 | 61 | def update_hardware(self, device_id, data): 62 | url = f"{self.api_url}/hardware/{device_id}" 63 | response = requests.patch(url, headers=self.headers, json=data, timeout=self.timeout) 64 | response.raise_for_status() 65 | return response 66 | 67 | def create_model(self, data): 68 | url = f"{self.api_url}/models" 69 | response = requests.post(url, headers=self.headers, json=data, timeout=self.timeout) 70 | response.raise_for_status() 71 | return response -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UniFi to Snipe-IT 2 | 3 | This project is a Python script that synchronizes UniFi devices with Snipe-IT. It fetches devices from a UniFi controller, formats them, and adds or updates them in Snipe-IT. The script can also create new models in Snipe-IT if they don't already exist. 4 | 5 | ## Features 6 | 7 | * Fetch devices from UniFi controller 8 | * Fetch existing UniFi devices from Snipe-IT 9 | * Create new models in Snipe-IT if they don't already exist 10 | * Add or update devices in Snipe-IT based on their serial numbers. 11 | * Dry run mode to preview changes without modifying Snipe-IT 12 | 13 | ## Requirements 14 | 15 | * Python 3.6 or higher 16 | * `requests` library 17 | * `ratelimiter` library 18 | * `tabulate` library 19 | * `pyunifi` library 20 | * `termcolor` libary 21 | 22 | These can all be installed using requirements.txt discribed in the steps below. 23 | 24 | ## Installation 25 | 26 | 27 | 1. Clone the repository or download the source code. 28 | 29 | ```bash 30 | `git clone https://github.com/yourusername/unifi-to-snipeit.git cd unifi-to-snipeit` 31 | ``` 32 | 33 | 2. Install the required libraries. 34 | 35 | ```bash 36 | pip3 install -r requirements.txt 37 | 38 | ``` 39 | 40 | 3. Copy `config_example.ini` to `config.ini` and update the configuration settings with your UniFi and Snipe-IT credentials and preferences. 41 | 42 | ```bash 43 | cp config_example.ini config.ini 44 | ``` 45 | 46 | ## Prep Work In Snipe 47 | There are a few things to take care of in Snipe-IT before you start the configuration 48 | 49 | 1. Get an API Key 50 | 2. Create a manufacturer of Ubiquiti (if you have not already, or choose a default manufacturer) 51 | 3. Create a Networking Asset Category (if you have not already, or choose a default asset category) 52 | 4. Make sure the fieldset you are using has a ip address custom field 53 | 5. Make sure the fieldset you are using has a mac address custom field 54 | 55 | 56 | ## Configuration 57 | 58 | 59 | Update the `config.ini` file with your UniFi controller and Snipe-IT API credentials and preferences. The following sections are available for configuration: 60 | 61 | * `[UniFi]`: UniFi controller settings (URL, username, password, port, version, and site ID) 62 | * `[SnipeIT]`: Snipe-IT API settings (API URL, API key, manufacturer, model category ID, MAC address field name, IP address field name, default status ID, device name priority, and rate limit) 63 | * `[unifi_model_mapping]`: UniFi model mapping for converting UniFi model names to Snipe-IT model names 64 | 65 | ### Model Mapping 66 | Ubuiquiti has had some "fun" with their model numbers. When originally adding our Unifi devices into Snipe-IT, we used the model numbers shown of the devices detail page in the control panel. However, the API returns a completely different set of model numbers for devices. The unifi_model_mapping allows you to map the API model numbers to existing models numbers you might have set in Snipe-IT already. 67 | 68 | ### PyUnifi 69 | We used the PyUnifi libary to interact with the Unifi API. You might want to read through their docs to debug your particular connection. We included a unifi.py file where you can test your settings. https://github.com/finish06/pyunifi 70 | 71 | 72 | ## Usage 73 | 74 | 75 | To run the script, execute the following command: 76 | 77 | ```bash 78 | python main.py 79 | ``` 80 | 81 | ![Screen Shot Of Changes](unifiSnipeSync_Dry_Run_Screenshot.jpg) 82 | 83 | To perform a dry run without making changes to Snipe-IT, use the `--dry-run` option: 84 | 85 | ```bash 86 | python main.py --dry-run 87 | ``` 88 | 89 | During the dry run, the script will output a summary of the changes that would be made to Snipe-IT. 90 | 91 | To override the `site_id` specified in the config file, use the `--site-id` option followed by the new site ID: 92 | 93 | ```bash 94 | python main.py --site-id new_site_id 95 | ``` 96 | This is useful if your unifi controller has more than one site. There is not currently support for more than one controller. If you have multible controllers we recommend setting up another instance of the script with a different config file. 97 | 98 | Contributing 99 | ------------ 100 | 101 | Please feel free to open issues or submit pull requests with bug fixes or improvements. Your contributions are welcome and appreciated. 102 | 103 | License 104 | ------- 105 | 106 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. 107 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | 4 | import requests 5 | from requests.packages.urllib3.exceptions import InsecureRequestWarning 6 | 7 | warnings.filterwarnings("ignore", category=InsecureRequestWarning) 8 | from requests import Session 9 | 10 | from pyunifi.controller import Controller 11 | import configparser 12 | from snipe import Snipe 13 | import argparse 14 | from termcolor import colored, cprint 15 | from tabulate import tabulate 16 | 17 | 18 | # Read configurations from config.ini 19 | config = configparser.ConfigParser() 20 | config.read("config.ini") 21 | 22 | unifi_model_mapping = {v.lower(): k for k, v in config["unifi_model_mapping"].items()} 23 | mac_address_field_name = config.get('SnipeIT', 'mac_address_field_name') 24 | 25 | # Set up Snipe-IT API 26 | snipe = Snipe( 27 | config.get("SnipeIT", "api_url"), 28 | config.get("SnipeIT", "api_key"), 29 | int(config.get("SnipeIT", "rate_limit")), 30 | 30, 31 | ) 32 | 33 | 34 | # Function to create a UniFi Controller instance 35 | def create_unifi_controller(url, username, password, port, version, site_id, verify_ssl=False): 36 | return Controller(url, username, password, port, version, site_id, ssl_verify=verify_ssl) 37 | 38 | # Function to fetch device information from the UniFi Controller 39 | def fetch_devices(controller): 40 | return controller.get_aps() 41 | 42 | # Function to process and format the device information 43 | def format_devices_from_unifi(unifi_devices): 44 | 45 | formatted_devices = [] 46 | for device in unifi_devices: 47 | #print(device) 48 | formatted_device = { 49 | "name": device.get("name", device["mac"]), 50 | "mac_address": device["mac"], 51 | "model": device["model"], 52 | "serial": device.get("serial", ""), 53 | #"asset_tag": device.get("asset_tag", ""), 54 | #if an asset is a router, we want to get it's local IP address and not it's public ip. UXG at least, has a lan_ip feild that the other devices do not. 55 | "ip_address": device.get("lan_ip", device["ip"]), 56 | } 57 | formatted_devices.append(formatted_device) 58 | return formatted_devices 59 | 60 | # Function to fetch all UniFi devices from Snipe-IT using the manufacturer filter 61 | def fetch_unifi_devices_from_snipeit(manufacturer): 62 | params = {"manufacturer_id": manufacturer} 63 | return snipe.get_all_hardware(params=params) 64 | 65 | # Function to check if a device exists in the fetched UniFi devices array using its MAC address 66 | # Now returns the existing device if found 67 | def device_exists_in_snipeit(serial, unifi_devices): 68 | for device in unifi_devices: 69 | #Unifi's serial numbers are just mac-addresses. We have stored the serial numbers in Snipe with XX:XX:XX:XX:XX:XX formatting. The Unifi API returns XXXXXXXXXXXX. To keep from having to update snipe, we are going to normalize the data. 70 | #strip snipe-it seriel of colon and make lowercase. 71 | snipe_seriel = device["serial"].replace(':', '').lower() 72 | 73 | if serial.lower() == snipe_seriel: 74 | return device 75 | return None 76 | 77 | # Function to fetch all UniFi models from Snipe-IT 78 | def fetch_unifi_models_from_snipeit(manufacturer_id): 79 | all_models = snipe.get_all_models() 80 | 81 | unifi_models = [] 82 | 83 | for model in all_models: 84 | 85 | if model.get('manufacturer') and int(model['manufacturer']['id']) == int(manufacturer_id): 86 | mapped_model = model.copy() 87 | mapped_model['model_number'] = unifi_model_mapping.get(model['model_number'].lower(), model['model_number']) 88 | unifi_models.append(mapped_model) 89 | return unifi_models 90 | 91 | # Function to create a model in Snipe-IT if it doesn't exist 92 | def create_model_if_not_exists(model, unifi_models, dry_run): 93 | 94 | if not any(existing_model["model_number"].lower() == model["model_number"].lower() for existing_model in unifi_models): 95 | if dry_run: 96 | print(f"Would create model: {model['model_number']}") 97 | placeholder_model = model.copy() 98 | placeholder_model['id'] = 'placeholder_id' 99 | return placeholder_model 100 | else: 101 | #add model category to request 102 | model['category_id'] = config.get("SnipeIT", "unifi_model_category_id") 103 | response = snipe.create_model(model) 104 | new_model_json = response.json() 105 | status = new_model_json.get("status") 106 | 107 | if status == "success": 108 | print(f"Model {model['model_number']} created in Snipe-IT. Status: {response.status_code}") 109 | # return the payload of the response 110 | return new_model_json.get('payload') 111 | else: 112 | print(f"Error creating model {model['model_number']} in Snipe-IT. Status: {response.status_code}") 113 | print(f"Error details: {new_model_json.get('messages')}") 114 | return None 115 | else: 116 | print(f"Model {model['model_number']} already exists in Snipe-IT. Skipping creation.") 117 | matching_models = [existing_model for existing_model in unifi_models if existing_model["model_number"].lower() == model["model_number"].lower()] 118 | return matching_models[0] if matching_models else None 119 | 120 | # Function to add devices to Snipe-IT using its API 121 | def add_devices_to_snipeit(devices, unifi_devices_in_snipe, dry_run): 122 | unifi_models = fetch_unifi_models_from_snipeit(config.get("SnipeIT", "unifi_manufacturer_id")) 123 | 124 | changes = [] 125 | for device in devices: 126 | #add custom field to device for later use 127 | 128 | print("Checking device "+device['name']+" - ("+device['serial']+")") 129 | # print(device) 130 | existing_device = device_exists_in_snipeit(device["serial"], unifi_devices_in_snipe) 131 | remapped_model_number = unifi_model_mapping.get(device["model"], device["model"]) 132 | # print("remapped_model_number", remapped_model_number) 133 | model = { 134 | "name": remapped_model_number, 135 | "manufacturer_id": config.get("SnipeIT", "unifi_manufacturer_id"), 136 | "model_number": remapped_model_number 137 | } 138 | model_in_snipeit = create_model_if_not_exists(model, unifi_models, dry_run) 139 | 140 | if model_in_snipeit: 141 | device["model_id"] = model_in_snipeit["id"] 142 | 143 | if existing_device: 144 | changeForDevice = { 145 | "Action": "", 146 | "Unifi Device Name": "", 147 | "Snipe Name": "", 148 | "Device Serial": "", 149 | "Device MAC": "", 150 | "Model": "", 151 | "Snipe-IT Model ID": "", 152 | "IP Address": "" 153 | } 154 | #Compare Unfi and Snipe-IT device data and determain if either needs to be updated 155 | snipeUpdateNeeded = False 156 | 157 | #check if device name is different 158 | if(device['name'] != existing_device['name']): 159 | device_name_priority = config.get('SnipeIT', 'device_name_priority') 160 | if(device_name_priority == "unifi"): 161 | #we need to update Snipe-IT with the name set in Unifi 162 | snipeUpdateNeeded = True 163 | print("device name needed changing"); 164 | changeForDevice['Unifi Device Name'] = device['name'] 165 | changeForDevice['Snipe Name'] = (f"\033[32m"+device['name']+"\033[0m ("+existing_device['name']+")") 166 | else: 167 | #no change is needed, but we need to update the changeForDevice object 168 | changeForDevice['Unifi Device Name'] = device['name'] 169 | changeForDevice['Snipe Name'] = existing_device['name'] 170 | else: 171 | changeForDevice['Unifi Device Name'] = device['name'] 172 | changeForDevice['Snipe Name'] = existing_device['name'] 173 | 174 | #check if IP address needs to be updated 175 | ip_address_field_name = config.get('SnipeIT', 'ip_address_field_name') 176 | 177 | snipe_ip_address = "" 178 | for key, field in existing_device['custom_fields'].items(): 179 | if field['field'] == ip_address_field_name: 180 | snipe_ip_address = field['value'] 181 | break 182 | 183 | if(device['ip_address'] != snipe_ip_address): 184 | snipeUpdateNeeded = True 185 | print("device IP address needed changing"); 186 | changeForDevice['IP Address'] = (f"\033[32m"+device['ip_address']+"\033[0m ("+snipe_ip_address+")") 187 | #add IP address to custom fields 188 | device[ip_address_field_name] = device['ip_address'] 189 | 190 | 191 | else: 192 | changeForDevice['IP Address'] = device['ip_address'] 193 | 194 | #check if mac address needs to be updated 195 | mac_address_field_name = config.get('SnipeIT', 'mac_address_field_name') 196 | 197 | snipe_mac_address = "" 198 | for key, field in existing_device['custom_fields'].items(): 199 | if field['field'] == mac_address_field_name: 200 | snipe_mac_address = field['value'] 201 | break 202 | 203 | if(device['mac_address'].lower() != snipe_mac_address.lower()): 204 | snipeUpdateNeeded = True 205 | print("device MAC address needed changing") 206 | changeForDevice['Device MAC'] = (f"\033[32m"+device['mac_address']+"\033[0m ("+snipe_mac_address.lower()+")") 207 | #add MAC address to custom fields 208 | device[mac_address_field_name] = device['mac_address'] 209 | 210 | else: 211 | changeForDevice['Device MAC'] = device['mac_address'] 212 | 213 | 214 | if(snipeUpdateNeeded): 215 | changeForDevice['Action'] = (f"\033[32m Update \033[0m") 216 | changeForDevice['Device Serial'] = device['serial'] 217 | changeForDevice['Model'] = device['model'] 218 | changeForDevice['Snipe-IT Model ID'] = device['model_id'] 219 | changes.append(changeForDevice) 220 | else: 221 | changes.append({ 222 | "Action": (f"\033[33m Skipped \033[0m"), 223 | "Unifi Device Name": (f"\033[33m "+ device["name"] +"\033[0m"), 224 | "Snipe Name": (f"\033[33m "+ existing_device["name"] +"\033[0m"), 225 | "Device Serial": (f"\033[33m "+ device["serial"] +"\033[0m"), 226 | "Device MAC": (f"\033[33m "+ device["mac_address"] +"\033[0m"), 227 | "Model": (f"\033[33m "+ device["model"] +"\033[0m"), 228 | "Snipe-IT Model ID": (f"\033[33m "+ str(device["model_id"]) +"\033[0m"), 229 | "IP Address": (f"\033[33m "+ device['ip_address'] +"\033[0m") 230 | }) 231 | 232 | 233 | 234 | 235 | if not dry_run and snipeUpdateNeeded: 236 | 237 | response = snipe.update_hardware(existing_device["id"], device) 238 | print(f"Device {device['name']} updated in Snipe-IT. Status: {response.status_code}") 239 | else: 240 | #add device status to request 241 | device['status_id'] = config.get("SnipeIT", "default_status_id") 242 | #add empty asset tag to request 243 | # device['asset_tag'] = None 244 | 245 | changes.append({ 246 | "Action": "Create", 247 | "Unifi Device Name": device["name"], 248 | "Device Serial": device["serial"], 249 | "Device MAC": device["mac_address"], 250 | "Model": device["model"], 251 | "Snipe-IT Model ID": device["model_id"], 252 | "IP Address": device['ip_address'] 253 | }) 254 | 255 | 256 | if not dry_run: 257 | response = snipe.create_hardware(device) 258 | print(f"Device {device['name']} added to Snipe-IT. Status: {response.status_code}", response.json()) 259 | 260 | if dry_run: 261 | print("Dry run summary:") 262 | 263 | print(tabulate(changes, headers="keys")) 264 | 265 | 266 | # Main script 267 | def main(): 268 | parser = argparse.ArgumentParser(description="Unifi to Snipe-IT script") 269 | parser.add_argument("--dry-run", action="store_true", help="Perform a dry run without making changes to Snipe-IT") 270 | parser.add_argument("--site-id", type=str, help="Override the site_id specified in the config file") 271 | args = parser.parse_args() 272 | controller = create_unifi_controller( 273 | config.get("UniFi", "controller_url"), 274 | config.get("UniFi", "username"), 275 | config.get("UniFi", "password"), 276 | config.getint("UniFi", "port"), 277 | config.get("UniFi", "version"), 278 | args.site_id if args.site_id else config.get("UniFi", "site_id"), 279 | ) 280 | unifi_devices = fetch_devices(controller) 281 | formatted_devices = format_devices_from_unifi(unifi_devices) 282 | 283 | if args.dry_run: 284 | print("Devices found in Unifi:") 285 | devices_table = [{ 286 | "Device Name": device["name"], 287 | "Device MAC": device["mac_address"], 288 | "Model": device["model"], 289 | } for device in formatted_devices] 290 | print(tabulate(devices_table, headers="keys")) 291 | 292 | 293 | unifi_devices_in_snipeit = fetch_unifi_devices_from_snipeit(config.get("SnipeIT", "unifi_manufacturer_id")) 294 | add_devices_to_snipeit(formatted_devices, unifi_devices_in_snipeit, dry_run=args.dry_run) 295 | 296 | if __name__ == "__main__": 297 | main() 298 | --------------------------------------------------------------------------------