From 6ee74ac92b33dbbc1a44533b6c7e468a181d8260 Mon Sep 17 00:00:00 2001 From: FideliusFalcon <47396661+FideliusFalcon@users.noreply.github.com> Date: Tue, 9 Sep 2025 15:31:26 +0200 Subject: [PATCH 1/2] added unifi integration --- ubiquiti-unifi-network-clients/README.md | 46 +++++ ubiquiti-unifi-network-clients/config.json | 1 + ...ration-ubiquiti-unifi-network-clients.star | 189 ++++++++++++++++++ 3 files changed, 236 insertions(+) create mode 100644 ubiquiti-unifi-network-clients/README.md create mode 100644 ubiquiti-unifi-network-clients/config.json create mode 100644 ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star diff --git a/ubiquiti-unifi-network-clients/README.md b/ubiquiti-unifi-network-clients/README.md new file mode 100644 index 0000000..19cb492 --- /dev/null +++ b/ubiquiti-unifi-network-clients/README.md @@ -0,0 +1,46 @@ +# Custom Integration: Ubiquiti Unifi Network +Custom Integration for retrieving clients 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) Modify Search 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-clients"). + - 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-clients`. diff --git a/ubiquiti-unifi-network-clients/config.json b/ubiquiti-unifi-network-clients/config.json new file mode 100644 index 0000000..d09d8f1 --- /dev/null +++ b/ubiquiti-unifi-network-clients/config.json @@ -0,0 +1 @@ +{ "name": "Ubiquiti Unifi Network Clients", "type": "inbound" } diff --git a/ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star b/ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star new file mode 100644 index 0000000..3c369f8 --- /dev/null +++ b/ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star @@ -0,0 +1,189 @@ +# +# runZero Starlark script for retrieving clients from the Ubiquiti UniFi Network Integration API +# + +# 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 clients from. +UNIFI_SITE_NAME = "Default" +# (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_API_FILTER = "" +# UniFi controllers often use self-signed certificates. Set to True to allow this. +INSECURE_SKIP_VERIFY = True +# The number of clients 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_API_FILTER: + params["filter"] = UNIFI_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 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_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 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 [] + + # 2. Get the list of all clients, handling pagination and the filter + 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.") + return [] + + print("Total clients found: {}.".format(len(clients))) + + # 3. Build assets for runZero + assets = build_assets(clients) + + print("Created {} assets for import.".format(len(assets))) + return assets \ No newline at end of file From 04f53db28d189371538fee1437faa592bd91cdd7 Mon Sep 17 00:00:00 2001 From: FideliusFalcon <47396661+FideliusFalcon@users.noreply.github.com> Date: Tue, 9 Sep 2025 16:38:50 +0200 Subject: [PATCH 2/2] rewrite to also extract unifi devices --- ubiquiti-unifi-network-clients/config.json | 1 - .../README.md | 10 +- ubiquiti-unifi-network/config.json | 1 + ...om-integration-ubiquiti-unifi-network.star | 147 ++++++++++++++++-- 4 files changed, 137 insertions(+), 22 deletions(-) delete mode 100644 ubiquiti-unifi-network-clients/config.json rename {ubiquiti-unifi-network-clients => ubiquiti-unifi-network}/README.md (84%) create mode 100644 ubiquiti-unifi-network/config.json rename ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star => ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star (53%) diff --git a/ubiquiti-unifi-network-clients/config.json b/ubiquiti-unifi-network-clients/config.json deleted file mode 100644 index d09d8f1..0000000 --- a/ubiquiti-unifi-network-clients/config.json +++ /dev/null @@ -1 +0,0 @@ -{ "name": "Ubiquiti Unifi Network Clients", "type": "inbound" } diff --git a/ubiquiti-unifi-network-clients/README.md b/ubiquiti-unifi-network/README.md similarity index 84% rename from ubiquiti-unifi-network-clients/README.md rename to ubiquiti-unifi-network/README.md index 19cb492..19c4b7e 100644 --- a/ubiquiti-unifi-network-clients/README.md +++ b/ubiquiti-unifi-network/README.md @@ -1,5 +1,5 @@ # Custom Integration: Ubiquiti Unifi Network -Custom Integration for retrieving clients from the Unifi Network API +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. @@ -22,13 +22,15 @@ Custom Integration for retrieving clients from the Unifi Network API 1. Make any necessary changes to the script to align with your environment. - Set `UNIFI_CONTROLLER_URL` - Set `UNIFI_SITE_NAME` - - (OPTIONAL) Modify Search Filter + - (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-clients"). + - 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. @@ -43,4 +45,4 @@ Custom Integration for retrieving clients from the Unifi Network API - 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-clients`. +- 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-clients/custom-integration-ubiquiti-unifi-network-clients.star b/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star similarity index 53% rename from ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star rename to ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star index 3c369f8..a0de24e 100644 --- a/ubiquiti-unifi-network-clients/custom-integration-ubiquiti-unifi-network-clients.star +++ b/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star @@ -1,5 +1,8 @@ # -# runZero Starlark script for retrieving clients from the Ubiquiti UniFi Network Integration API +# 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 @@ -14,14 +17,18 @@ load('time', 'parse_time') # 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 clients from. +# 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_API_FILTER = "" +UNIFI_CLIENT_API_FILTER = "" # UniFi controllers often use self-signed certificates. Set to True to allow this. INSECURE_SKIP_VERIFY = True -# The number of clients to request per API call. 100 is a safe default. +# The number of items to request per API call. 100 is a safe default. PAGE_LIMIT = 100 # --- END OF USER CONFIGURATION --- @@ -65,8 +72,8 @@ def get_all_clients(base_url, api_key, site_id): while True: params = {"offset": str(offset), "limit": str(PAGE_LIMIT)} - if UNIFI_API_FILTER: - params["filter"] = UNIFI_API_FILTER + 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" } @@ -97,6 +104,44 @@ def get_all_clients(base_url, api_key, site_id): 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. @@ -112,7 +157,7 @@ def build_network_interface(ips, mac): ip6s.append(ip_addr) return NetworkInterface(macAddress=mac, ipv4Addresses=ip4s, ipv6Addresses=ip6s) -def build_assets(clients_json): +def build_client_assets(clients_json): """ Converts client data from UniFi into a list of runZero ImportAsset objects. """ @@ -155,6 +200,54 @@ def build_assets(clients_json): ) 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. @@ -174,16 +267,36 @@ def main(**kwargs): if not site_id: return [] - # 2. Get the list of all clients, handling pagination and the filter - 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.") - return [] + all_assets = [] - print("Total clients found: {}.".format(len(clients))) + # 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. Build assets for runZero - assets = build_assets(clients) + # 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("Created {} assets for import.".format(len(assets))) - return assets \ No newline at end of file + print("--- Import Summary ---") + print("Total assets created: {}.".format(len(all_assets))) + return all_assets