diff --git a/ubiquiti-unifi-network/README.md b/ubiquiti-unifi-network/README.md new file mode 100644 index 0000000..19c4b7e --- /dev/null +++ b/ubiquiti-unifi-network/README.md @@ -0,0 +1,48 @@ +# Custom Integration: Ubiquiti Unifi Network +Custom Integration for retrieving clients and Unifi devices from the Unifi Network API +## runZero requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero. + +## Ubiquiti Unifi Network requirements + +- Network API Key +- Unifi Site Name (default: `Default`) +- Unifi Gateway URL + +## Steps + +### Ubiquiti Unifi Network configuration + +1. Generate a API Token in Unifi at `Network -> Settings -> Control Plane -> Integrations` +3. Test your API token by making a sample request using a tool like `curl` or Postman to verify access. + +### runZero configuration + +1. Make any necessary changes to the script to align with your environment. + - Set `UNIFI_CONTROLLER_URL` + - Set `UNIFI_SITE_NAME` + - (OPTIONAL) Disable client extract with `EXTRACT_CLIENTS = False` + - (OPTIONAL) Disable device extract with `EXTRACT_DEVICES = False` + - (OPTIONAL) Modify `UNIFI_CLIENT_API_FILTER` +2. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). + - Select the type `Custom Integration Script Secrets`. + - Use the `access_secret` field for your Unifi API token. + - For `access_key`, input a placeholder value like `foo` (unused in this integration). +3. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). + - Add a Name and Icon for the integration (e.g., "unifi"). + - Toggle `Enable custom integration script` to input the finalized script. + - Click `Validate` to ensure it has valid syntax. + - Click `Save` to create the Custom Integration. +4. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). + - Select the Credential and Custom Integration created in steps 2 and 3. + - Update the task schedule to recur at the desired timeframes. + - Select the Explorer you'd like the Custom Integration to run from. + - Click `Save` to kick off the first task. + +### What's next? + +- You will see the task kick off on the [tasks](https://console.runzero.com/tasks) page like any other integration. +- The task will update the existing assets with the data pulled from the Custom Integration source. +- The task will create new assets for when there are no existing assets that meet merge criteria (hostname, MAC, etc). +- You can search for assets enriched by this custom integration with the runZero search `custom_integration:unifi`. diff --git a/ubiquiti-unifi-network/config.json b/ubiquiti-unifi-network/config.json new file mode 100644 index 0000000..a5ef724 --- /dev/null +++ b/ubiquiti-unifi-network/config.json @@ -0,0 +1 @@ +{ "name": "Ubiquiti Unifi Network", "type": "inbound" } diff --git a/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star b/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star new file mode 100644 index 0000000..a0de24e --- /dev/null +++ b/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star @@ -0,0 +1,302 @@ +# +# runZero Starlark script for Ubiquiti UniFi Network Integration API +# +# This version adds the ability to extract UniFi network devices (switches, APs) +# and provides toggles to enable/disable client and device extraction. +# + +# Load necessary runZero and Starlark libraries +load('runzero.types', 'ImportAsset', 'NetworkInterface') +load('json', json_decode='decode') +load('net', 'ip_address') +load('http', http_get='get', url_encode='url_encode') +load('time', 'parse_time') + +# --- USER CONFIGURATION --- +# IMPORTANT: Update these variables to match your UniFi Network Controller setup. + +# The base URL of your UniFi Network Controller (e.g., https://192.168.1.1) +UNIFI_CONTROLLER_URL = "https://" +# The NAME of the site you want to pull data from. +UNIFI_SITE_NAME = "Default" +# Set to True to extract client devices, False to skip. +EXTRACT_CLIENTS = True +# Set to True to extract UniFi network devices (switches, APs), False to skip. +EXTRACT_DEVICES = True +# (Optional) A filter to apply to the client query. Leave as "" to disable. +# Example: "ipAddress.eq('192.168.5.5')" or "type.eq('WIRED')" +UNIFI_CLIENT_API_FILTER = "" +# UniFi controllers often use self-signed certificates. Set to True to allow this. +INSECURE_SKIP_VERIFY = True +# The number of items to request per API call. 100 is a safe default. +PAGE_LIMIT = 100 + +# --- END OF USER CONFIGURATION --- + +def get_site_id(base_url, api_key, site_name): + """ + Finds the UUID for a given site name. + """ + sites_url = base_url + "/proxy/network/integration/v1/sites" + headers = { "X-API-KEY": api_key, "Accept": "application/json" } + + print("Attempting to find ID for site '{}'...".format(site_name)) + response = http_get(url=sites_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + + if response.status_code != 200: + print("Failed to get sites list. Status code: {}".format(response.status_code)) + return None + + response_json = json_decode(response.body) + + if type(response_json) != "dict" or "data" not in response_json: + print("API did not return a valid sites object.") + return None + + for site in response_json.get("data", []): + if site.get("name") == site_name: + site_id = site.get("id") + print("Found site ID: {}".format(site_id)) + return site_id + + print("Error: Could not find a site with the name '{}'.".format(site_name)) + return None + +def get_all_clients(base_url, api_key, site_id): + """ + Fetches all client devices from the UniFi API, handling pagination and an optional filter. + """ + all_clients = [] + offset = 0 + + while True: + params = {"offset": str(offset), "limit": str(PAGE_LIMIT)} + + if UNIFI_CLIENT_API_FILTER: + params["filter"] = UNIFI_CLIENT_API_FILTER + + clients_url = base_url + "/proxy/network/integration/v1/sites/{}/clients?".format(site_id) + url_encode(params) + headers = { "X-API-KEY": api_key, "Accept": "application/json" } + response = http_get(url=clients_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + + if response.status_code != 200: + print("Failed to retrieve clients. Status code: {}".format(response.status_code)) + break + + response_json = json_decode(response.body) + if type(response_json) != "dict": + print("API did not return a valid JSON object while fetching clients.") + break + + clients_batch = response_json.get("data", []) + if not clients_batch: + break + + all_clients.extend(clients_batch) + total_count = response_json.get("totalCount", 0) + current_count = len(all_clients) + print("Fetched {}/{} clients...".format(current_count, total_count)) + + if current_count >= total_count: + break + + offset += PAGE_LIMIT + + return all_clients + +def get_all_devices(base_url, api_key, site_id): + """ + Fetches all network devices (switches, APs, etc.) from the UniFi API, handling pagination. + """ + all_devices = [] + offset = 0 + + while True: + params = {"offset": str(offset), "limit": str(PAGE_LIMIT)} + devices_url = base_url + "/proxy/network/integration/v1/sites/{}/devices?".format(site_id) + url_encode(params) + headers = { "X-API-KEY": api_key, "Accept": "application/json" } + response = http_get(url=devices_url, headers=headers, insecure_skip_verify=INSECURE_SKIP_VERIFY) + + if response.status_code != 200: + print("Failed to retrieve devices. Status code: {}".format(response.status_code)) + break + + response_json = json_decode(response.body) + if type(response_json) != "dict": + print("API did not return a valid JSON object while fetching devices.") + break + + devices_batch = response_json.get("data", []) + if not devices_batch: + break + + all_devices.extend(devices_batch) + total_count = response_json.get("totalCount", 0) + current_count = len(all_devices) + print("Fetched {}/{} devices...".format(current_count, total_count)) + + if current_count >= total_count: + break + + offset += PAGE_LIMIT + + return all_devices + +def build_network_interface(ips, mac): + """ + A helper function to build a runZero NetworkInterface object. + """ + ip4s = [] + ip6s = [] + for ip in ips[:99]: + if ip: + ip_addr = ip_address(ip) + if ip_addr.version == 4: + ip4s.append(ip_addr) + elif ip_addr.version == 6: + ip6s.append(ip_addr) + return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) + +def build_client_assets(clients_json): + """ + Converts client data from UniFi into a list of runZero ImportAsset objects. + """ + assets = [] + for client in clients_json: + mac = client.get("macAddress") + hostname = client.get("name") + + if mac and hostname: + mac_parts = mac.split(":") + if len(mac_parts) == 6: + mac_suffix = " " + ":".join(mac_parts[4:]) + if hostname.endswith(mac_suffix): + hostname = hostname.removesuffix(mac_suffix) + + if not mac: + continue + + ip = client.get("ipAddress") + ips = [ip] if ip else [] + network = build_network_interface(ips=ips, mac=mac) + hostnames = [hostname] if hostname else [] + connectedAt = parse_time(client.get("connectedAt")) + + custom_attrs = { + "unifi_id": client.get("id", ""), + "connectionType": client.get("type", ""), + "connectedAt": connectedAt, + "connectedAtTS": connectedAt.unix, + "uplinkDeviceId": client.get("uplinkDeviceId", "") + } + + assets.append( + ImportAsset( + id=mac, + hostnames=hostnames, + networkInterfaces=[network], + customAttributes=custom_attrs + ) + ) + return assets + +def build_device_assets(devices_json): + """ + Converts UniFi device data into a list of runZero ImportAsset objects. + """ + assets = [] + for device in devices_json: + mac = device.get("macAddress") + if not mac: + continue + + ip = device.get("ipAddress") + ips = [ip] if ip else [] + network = build_network_interface(ips=ips, mac=mac) + + hostname = device.get("name") + hostnames = [hostname] if hostname else [] + + model = device.get("model", "UniFi Device") + device_type = "Unknown" + # Attempt to determine a more specific device type from the model name + if "USW" in model: + device_type = "Switch" + elif "UAP" in model or "U6" in model: + device_type = "WAP" + elif "UDM" in model or "USG" in model: + device_type = "Gateway" + + custom_attrs = { + "unifi_id": device.get("id", ""), + "unifi_state": device.get("state", ""), + "unifi_features": ", ".join(device.get("features", [])), + "unifi_interfaces": ", ".join(device.get("interfaces", [])) + } + + assets.append( + ImportAsset( + id=mac, + hostnames=hostnames, + networkInterfaces=[network], + manufacturer="Ubiquiti", + model=model, + deviceType=device_type, + customAttributes=custom_attrs + ) + ) + return assets + + +def main(**kwargs): + """ + The main entrypoint for the runZero custom integration script. + """ + api_key = kwargs.get('access_secret') + + if not api_key: + print("UniFi Network API Key (access_secret) not provided in credentials.") + return [] + + if UNIFI_CONTROLLER_URL == "https://": + print("ERROR: Please update the UNIFI_CONTROLLER_URL variable in the script.") + return [] + + # 1. Find the Site ID from the Site Name + site_id = get_site_id(UNIFI_CONTROLLER_URL, api_key, UNIFI_SITE_NAME) + if not site_id: + return [] + + all_assets = [] + + # 2. Get and process clients if enabled + if EXTRACT_CLIENTS: + print("--- Starting Client Extraction ---") + clients = get_all_clients(UNIFI_CONTROLLER_URL, api_key, site_id) + if not clients: + print("No clients returned. This could be due to the filter applied or none exist.") + else: + print("Total clients found: {}.".format(len(clients))) + client_assets = build_client_assets(clients) + all_assets.extend(client_assets) + print("Created {} client assets for import.".format(len(client_assets))) + else: + print("--- Skipping Client Extraction (EXTRACT_CLIENTS is False) ---") + + # 3. Get and process devices if enabled + if EXTRACT_DEVICES: + print("--- Starting Device Extraction ---") + devices = get_all_devices(UNIFI_CONTROLLER_URL, api_key, site_id) + if not devices: + print("No devices found.") + else: + print("Total devices found: {}.".format(len(devices))) + device_assets = build_device_assets(devices) + all_assets.extend(device_assets) + print("Created {} device assets for import.".format(len(device_assets))) + else: + print("--- Skipping Device Extraction (EXTRACT_DEVICES is False) ---") + + print("--- Import Summary ---") + print("Total assets created: {}.".format(len(all_assets))) + return all_assets