diff --git a/README.md b/README.md index 3b0be41..4c3d4ca 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ If you need help setting up a custom integration, you can create an [issue](http # Existing Integrations ## Import to runZero +- [Akamai Guardicore Centra](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/) - [Automox](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/) - [Carbon Black](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/) - [Cisco-ISE](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cisco-ise/) @@ -29,7 +30,6 @@ If you need help setting up a custom integration, you can create an [issue](http - [Drata](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/) - [Extreme Networks CloudIQ](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/) - [Ghost Security](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/) -- [Guardicore Centra](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/) - [JAMF](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/) - [Kandji](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/) - [Lima Charlie](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/lima-charlie/) @@ -42,6 +42,7 @@ If you need help setting up a custom integration, you can create an [issue](http - [Snipe-IT](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/) - [Snow License Manager](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow_license_manager/) - [Stairwell](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/) +- [Tailscale](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/) - [Tanium](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/) - [Ubiquiti Unifi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/) ## Export from runZero diff --git a/docs/integrations.json b/docs/integrations.json index 6bd5ad3..4638017 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2025-11-10T18:36:52.667035Z", - "totalIntegrations": 28, + "lastUpdated": "2025-11-13T17:39:24.733491Z", + "totalIntegrations": 29, "integrationDetails": [ { "name": "Lima Charlie", @@ -14,12 +14,6 @@ "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/README.md", "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/custom-integration-sumo.star" }, - { - "name": "Guardicore Centra", - "type": "inbound", - "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/README.md", - "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/centrav3.star" - }, { "name": "Cyberint", "type": "inbound", @@ -68,6 +62,12 @@ "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/README.md", "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star" }, + { + "name": "Tailscale", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/custom-integration-tailscale.star" + }, { "name": "Automox", "type": "inbound", @@ -86,6 +86,12 @@ "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/README.md", "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/custom-integration-digital-ocean.star" }, + { + "name": "Akamai Guardicore Centra", + "type": "inbound", + "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/README.md", + "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/custom-integration-centra-v3-api.star" + }, { "name": "Device42", "type": "inbound", diff --git a/tailscale/README.md b/tailscale/README.md new file mode 100644 index 0000000..5452a91 --- /dev/null +++ b/tailscale/README.md @@ -0,0 +1,73 @@ +# Custom Integration: Tailscale API + +## runZero requirements + +- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero. +- A [Custom Integration Script Secret](https://console.runzero.com/credentials) credential configured with: + - `access_key`: your **Tailscale OAuth Client ID** (leave blank if using a standard API key) + - `access_secret`: your **Tailscale API key** or **OAuth Client Secret** + +## Tailscale requirements + +- Either: + - A **User API Key** (`tskey-api-xxxxx`) created under **Settings → Keys** in the [Tailscale Admin Console](https://login.tailscale.com/admin/settings), + **or** + - An **OAuth Client ID / Secret** pair created under **Settings → OAuth Clients** with the following scopes: + ``` + devices:core:read + devices:routes:read + devices:posture_attributes:read + ``` + +- The integration script defines your tailnet globally: + ```python + TAILNET_DEFAULT = "-" +```` + +Update this value inside the script if your environment uses a specific tailnet ID (e.g., `"T1234CNTRL"`). + +## Steps + +### Tailscale configuration + +1. Log into the [Tailscale Admin Console](https://login.tailscale.com/admin/settings). +2. Choose one of the following: + + * **Option 1 – API Key:** + Create a new API key under **Settings → Keys → Create API key**. + Ensure the user has admin access to the tailnet. + * **Option 2 – OAuth Client:** + Create a new OAuth client under **Settings → OAuth Clients**. + Add the required scopes listed above and record the client ID and secret. + +### runZero configuration + +1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). + + * Select **Custom Integration Script Secrets**. + * For `access_secret`, enter your **API key** or **OAuth client secret**. + * For `access_key`, enter your **OAuth client ID**, or a placeholder value (e.g., `foo`) if using an API key. +2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new). + + * Add a descriptive name (e.g., `tailscale-sync`). + * Toggle **Enable custom integration script** and paste the finalized script. + * Click **Validate** to confirm syntax, then **Save**. +3. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/). + + * Select the Credential and Custom Integration created above. + * Adjust the task schedule to your preferred frequency. + * Select an Explorer to execute the task. + * Click **Save** to start the integration. + +### What's next? + +* The task will execute and retrieve device data from the Tailscale API. +* Each Tailscale device will be imported as a `runZero ImportAsset`. +* You can view the integration run under [Tasks](https://console.runzero.com/tasks) in the runZero console. + +### Notes + +* The integration automatically detects whether you’re using an **API key** or **OAuth client credentials**. +* If you encounter a `403` error, verify your API key or OAuth client has the `devices:core:read` permission. +* The `TAILNET_DEFAULT` variable can be modified in the script if your organization uses multiple tailnets. +* Device metadata, tags, and IP addresses from Tailscale are mapped to `runZero` custom attributes and interfaces. \ No newline at end of file diff --git a/tailscale/config.json b/tailscale/config.json new file mode 100644 index 0000000..33d1288 --- /dev/null +++ b/tailscale/config.json @@ -0,0 +1 @@ +{ "name": "Tailscale", "type": "inbound" } diff --git a/tailscale/custom-integration-tailscale.star b/tailscale/custom-integration-tailscale.star new file mode 100644 index 0000000..d92abf2 --- /dev/null +++ b/tailscale/custom-integration-tailscale.star @@ -0,0 +1,242 @@ +# Tailscale API + OAuth2 Client -> runZero ImportAsset Integration +# +# Supports both: +# - Direct API key (tskey-api-xxxxx) +# - OAuth client credentials (access_key = client_id, access_secret = client_secret) +# +# Only two credential inputs are required: +# access_key : client_id (if OAuth) or unused for API key mode +# access_secret : client_secret (if OAuth) or API key (tskey-api-xxxxx) +# +# Tailnet ID is defined below as a global variable. + +load("runzero.types", "ImportAsset", "NetworkInterface") +load("json", json_decode="decode") +load("net", "ip_address") +load("http", http_get="get", http_post="post", "url_encode") +load("time", "parse_time") + +# --- Configuration --- +TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2" +TAILSCALE_TOKEN_URL = "https://api.tailscale.com/api/v2/oauth/token" +TAILNET_DEFAULT = "-" # change to your tailnet ID if needed, e.g. "T1234CNTRL" +DEFAULT_SCOPE = "devices:core:read" +INSECURE_SKIP_VERIFY_DEFAULT = False + + +def _log(msg): + print("[TAILSCALE] " + msg) + + +def obtain_oauth_token(client_id, client_secret, scope, insecure_skip_verify): + """ + Request an OAuth2 access token from Tailscale. + """ + _log("Requesting OAuth2 token from Tailscale...") + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + form = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": scope, + } + + resp = http_post( + url=TAILSCALE_TOKEN_URL, + headers=headers, + body=bytes(url_encode(form)), + insecure_skip_verify=insecure_skip_verify, + ) + + if resp == None: + _log("ERROR: No response from OAuth token endpoint.") + return None + + _log("DEBUG: OAuth token response status: " + str(resp.status_code)) + if resp.status_code != 200: + _log("ERROR: OAuth token request failed: " + str(resp.status_code)) + if resp.body != None: + _log("ERROR: Response: " + str(resp.body)) + return None + + body = json_decode(resp.body) + token = body.get("access_token") + expires = body.get("expires_in") + if token == None: + _log("ERROR: Missing access_token in OAuth response.") + return None + + _log("SUCCESS: Obtained access token (expires_in=" + str(expires) + "s)") + return token + + +def tailscale_get_devices(access_token, tailnet, insecure_skip_verify): + """ + Fetch device inventory for a tailnet using an access token or API key. + """ + url = TAILSCALE_API_BASE + "/tailnet/" + tailnet + "/devices" + headers = {"Authorization": "Bearer " + access_token, "Accept": "application/json"} + _log("DEBUG: Fetching devices from " + url) + + resp = http_get(url=url, headers=headers, insecure_skip_verify=insecure_skip_verify) + if resp == None: + _log("ERROR: No response from Tailscale devices endpoint.") + return None + + _log("DEBUG: Devices response status: " + str(resp.status_code)) + + if resp.status_code == 401: + _log("ERROR: Unauthorized (401) - invalid or expired token.") + return None + if resp.status_code == 403: + _log("ERROR: Forbidden (403) - insufficient permissions or missing scope.") + if resp.body != None: + _log("ERROR: Body: " + str(resp.body)) + return None + if resp.status_code == 404: + _log("ERROR: Not Found (404) - invalid tailnet ID.") + return None + if resp.status_code != 200: + _log("ERROR: Unexpected status: " + str(resp.status_code)) + if resp.body != None: + _log("ERROR: Body: " + str(resp.body)) + return None + + body = json_decode(resp.body) + devices = body.get("devices", []) + _log("SUCCESS: Retrieved " + str(len(devices)) + " devices.") + return devices + + +def _clean_address(addr): + if addr == None: + return None + parts = addr.split("/") + return parts[0] + + +def build_network_interface_from_addresses(addresses, mac): + if addresses == None: + return None + ipv4s = [] + ipv6s = [] + for a in addresses: + ipstr = _clean_address(a) + if ipstr == None: + continue + ipobj = ip_address(ipstr) + if ipobj == None: + continue + if ipobj.version == 4: + ipv4s.append(ipobj) + else: + ipv6s.append(ipobj) + if len(ipv4s) + len(ipv6s) >= 99: + break + if len(ipv4s) == 0 and len(ipv6s) == 0 and mac == None: + return None + return NetworkInterface(macAddress=mac, ipv4Addresses=ipv4s, ipv6Addresses=ipv6s) + + +def transform_device_to_importasset(device, tailnet): + device_id = device.get("id", "") + hostname = device.get("hostname", device.get("name", "")) + addresses = device.get("addresses", []) + os_name = device.get("os", "Unknown") + + if device_id == "" or len(addresses) == 0: + return None + + netif = build_network_interface_from_addresses(addresses, None) + if netif == None: + return None + + attrs = { + "source": "Tailscale Integration", + "tailscale_device_id": device_id, + "tailscale_tailnet": tailnet, + "tailscale_user": device.get("user", ""), + "tailscale_os": os_name, + "tailscale_client_version": device.get("clientVersion", ""), + "tailscale_authorized": str(device.get("authorized", False)), + "tailscale_update_available": str(device.get("updateAvailable", False)), + "tailscale_key_expiry_disabled": str(device.get("keyExpiryDisabled", False)), + "tailscale_is_external": str(device.get("isExternal", False)), + "tailscale_blocks_incoming_connections": str(device.get("blocksIncomingConnections", False)), + "tailscale_created": device.get("created", ""), + } + + parsed_time = device.get("created") + if parsed_time != None and parsed_time != "": + parsed = parse_time(parsed_time) + if parsed != None: + attrs["tailscale_created_ts"] = parsed.unix + + tags = device.get("tags", []) + if tags != None and len(tags) > 0: + attrs["tailscale_tags"] = ", ".join(tags) + + adv_routes = device.get("advertisedRoutes", []) + if adv_routes != None and len(adv_routes) > 0: + attrs["tailscale_advertised_routes"] = ", ".join(adv_routes) + + en_routes = device.get("enabledRoutes", []) + if en_routes != None and len(en_routes) > 0: + attrs["tailscale_enabled_routes"] = ", ".join(en_routes) + + asset_id = "tailscale-" + device_id + hostnames = [hostname] if hostname != "" else [] + asset_tags = ["tailscale", "api"] + tags + + return ImportAsset( + id=asset_id, + hostnames=hostnames, + networkInterfaces=[netif], + os=os_name, + tags=asset_tags, + customAttributes=attrs, + ) + + +def main(*args, **kwargs): + _log("=== TAILSCALE API / OAUTH INTEGRATION ===") + + client_id = kwargs.get("access_key") # used only for OAuth + secret = kwargs.get("access_secret") # API key or OAuth client_secret + insecure_skip_verify = INSECURE_SKIP_VERIFY_DEFAULT + + if secret == None or secret == "": + _log("ERROR: Missing required access_secret (API key or client secret).") + return [] + + # Detect auth type + if client_id != None and client_id != "": + _log("Detected OAuth client credentials mode.") + token = obtain_oauth_token(client_id, secret, DEFAULT_SCOPE, insecure_skip_verify) + if token == None: + _log("ERROR: Failed to obtain OAuth access token.") + return [] + else: + _log("Detected API key mode.") + token = secret + + tailnet = TAILNET_DEFAULT + _log("Fetching devices for tailnet: " + tailnet) + + devices = tailscale_get_devices(token, tailnet, insecure_skip_verify) + if devices == None or len(devices) == 0: + _log("WARN: No devices found or API call failed.") + return [] + + assets = [] + for d in devices: + ia = transform_device_to_importasset(d, tailnet) + if ia != None: + assets.append(ia) + + _log("SUCCESS: Prepared " + str(len(assets)) + " ImportAsset objects.") + _log("=== INTEGRATION COMPLETE ===") + return assets