From 346b0dc1b2a50ee413ff9cfe4d2c70b4f104a47a Mon Sep 17 00:00:00 2001 From: Tyler Diderich Date: Mon, 10 Nov 2025 11:00:35 -0600 Subject: [PATCH 1/4] tailscale --- tailscale/README.md | 146 +++++++++++++++ tailscale/config.json | 1 + tailscale/custom-integration-tailscale.star | 194 ++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100644 tailscale/README.md create mode 100644 tailscale/config.json create mode 100644 tailscale/custom-integration-tailscale.star diff --git a/tailscale/README.md b/tailscale/README.md new file mode 100644 index 0000000..f90ac95 --- /dev/null +++ b/tailscale/README.md @@ -0,0 +1,146 @@ +# 🧩 Tailscale API β†’ runZero Integration + +A custom [runZero](https://www.runzero.com/) Starlark integration that imports assets directly from the [Tailscale API](https://tailscale.com/api). +This version uses a single API token or client secret β€” **no OAuth2 handshake required**. + +--- + +## πŸš€ Overview + +This integration connects to the Tailscale REST API using your **API token** (or **client secret**) and retrieves all devices in a tailnet. +Each Tailscale device is converted into a `runZero ImportAsset` with metadata, IP addresses, and custom attributes. + +--- + +## πŸ”‘ Requirements + +You’ll need: + +| Requirement | Description | +|--------------|-------------| +| **Tailscale API Key or Client Secret** | A `tskey-api-xxxxx` or `tskey-client-xxxxx` token from the [Tailscale admin console β†’ Keys page](https://login.tailscale.com/admin/settings/keys). | +| **Tailnet ID** | Usually found on the General Settings page of your Tailscale admin console (e.g., `T1234CNTRL`). You may also use `-` to reference the default tailnet. | +| **runZero Organization Role** | You must have permission to upload or manage integrations. | + +--- + + +## πŸ”’ API Key Permissions + +Tailscale has **two different API credential types**, and they have different permission behaviors. + +### πŸ”Ή **User API Keys** (`tskey-api-...`) +- Created under **Settings β†’ Keys β†’ Create API key** +- Have the **same permissions as the user who created them** +- Work automatically with this integration if: + - The user has **admin or owner** access to the tailnet + - The user can view devices in the Tailscale admin console + +βœ… **Recommended for most users** β€” easiest to use and requires no special scopes. + + +### πŸ”Ή **OAuth Client Secrets** (`tskey-client-...`) +- Created under **Settings β†’ OAuth Clients** +- Require explicit permission scopes to be set +- To fetch devices successfully, your OAuth client must include: + +```text +devices:core:read +devices:routes:read +devices:posture_attributes:read +``` + +## βš™οΈ Configuration + +When adding this integration in **runZero β†’ Integrations β†’ Custom Integration**, configure the following fields: + +| Parameter | Key | Required | Example | Description | +|------------|-----|-----------|----------|--------------| +| **Access Secret** | `access_secret` | βœ… | `tskey-api-abc123def456` | The API key or client secret used for authentication. | +| **Tailnet** | `tailnet` | βœ… | `T1234CNTRL` or `-` | The Tailnet ID or `-` for the default tailnet. | +| **Insecure Skip Verify** | `insecure_skip_verify` | ❌ | `false` | Set to `true` to disable SSL verification (not recommended). | + +--- + +## πŸ” Data Collected + +Each Tailscale device is imported as a `runZero ImportAsset` with these details: + +| Field | Source | Example | +|--------|---------|---------| +| `hostnames` | `device.hostname` | `laptop01` | +| `networkInterfaces` | `device.addresses` | IPv4 and IPv6 addresses | +| `os` | `device.os` | `linux` | +| `customAttributes.tailscale_user` | `device.user` | `amelie@example.com` | +| `customAttributes.tailscale_client_version` | `device.clientVersion` | `v1.36.0` | +| `customAttributes.tailscale_authorized` | `device.authorized` | `true` | +| `customAttributes.tailscale_advertised_routes` | `device.advertisedRoutes` | `10.0.0.0/16` | +| `customAttributes.tailscale_enabled_routes` | `device.enabledRoutes` | `192.168.1.0/24` | +| `customAttributes.tailscale_tags` | `device.tags` | `tag:prod, tag:subnetrouter` | + +--- + +## 🧠 How It Works + +1. **Authenticate** – The integration reads your `access_secret` and sets it as a `Bearer` token: +``` + +Authorization: Bearer tskey-api-xxxxx + +``` +2. **Fetch Devices** – Calls: +``` + +GET [https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices](https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices) + +```` +3. **Transform** – Each device is normalized into a `runZero ImportAsset`. +4. **Import** – Assets are returned to runZero for inventory enrichment. + +--- + +## πŸ§ͺ Testing + +You can test the script using the **Custom Integrations** feature in the runZero console or by using the local `runzero script` tool. + +Example test run: + +```bash +runzero script run tailscale_integration.star \ +--access_secret tskey-api-abc123def456 \ +--tailnet T1234CNTRL +```` + +--- + +## ⚠️ Troubleshooting + +| Symptom | Cause | Fix | +| -------------------- | -------------------------------------------- | ------------------------------------------------------------------ | +| `401 Unauthorized` | Invalid or expired API token | Regenerate token in the Tailscale admin console. | +| `404 Not Found` | Tailnet name is incorrect | Use the Tailnet ID (e.g., `T1234CNTRL`) or `-` for default. | +| `0 devices returned` | Tailnet has no active devices or wrong scope | Ensure devices exist and token has `devices:core:read` permission. | +| `SSL Error` | Certificate issue | Use `insecure_skip_verify=true` (only for diagnostics). | + +--- + +## 🧰 Example Output + +``` +[TAILSCALE] SUCCESS: Retrieved 6 devices. +[TAILSCALE] SUCCESS: Prepared 6 ImportAsset objects. +[TAILSCALE] === INTEGRATION COMPLETE === +``` + +--- + +## πŸͺͺ Licensing + +This integration is provided under the BSD 3-Clause license terms of the Tailscale API and runZero’s integration platform. + +--- + +## πŸ“š References + +* [Tailscale API Documentation](https://tailscale.com/api) +* [runZero Custom Integrations Guide](https://www.runzero.com/docs/integrations/custom/) \ 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..dc17d16 --- /dev/null +++ b/tailscale/custom-integration-tailscale.star @@ -0,0 +1,194 @@ +# Tailscale API -> runZero ImportAsset Starlark integration (DEBUG VERSION) +# Uses a single API token or client secret (tskey-client-xxxxx / tskey-api-xxxxx) +# +# Required kwargs: +# access_secret : Tailscale API key or OAuth client secret +# tailnet : tailnet ID (e.g., T1234CNTRL or "-" for default) +# Optional: +# insecure_skip_verify : bool (default False) + +load("runzero.types", "ImportAsset", "NetworkInterface") +load("json", json_decode="decode") +load("net", "ip_address") +load("http", http_get="get") +load("time", "parse_time") + +TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2" +INSECURE_SKIP_VERIFY_DEFAULT = False + + +def _log(msg): + print("[TAILSCALE] " + msg) + + +def tailscale_get_devices(api_token, tailnet, insecure_skip_verify): + url = TAILSCALE_API_BASE + "/tailnet/" + tailnet + "/devices" + headers = { + "Authorization": "Bearer " + api_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 API.") + return None + + _log("DEBUG: Devices response status: " + str(resp.status_code)) + + if resp.status_code == 401: + _log("ERROR: Unauthorized (401) - invalid or expired API token") + return None + if resp.status_code == 404: + _log("ERROR: Tailnet '" + tailnet + "' not found") + return None + if resp.status_code != 200: + _log("ERROR: Unexpected response " + 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.") + if len(devices) > 0: + _log("DEBUG: First device hostname: " + str(devices[0].get("hostname", "N/A"))) + 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): + ipv4s = [] + ipv6s = [] + + if addresses == None: + return None + + 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: + 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") + + _log("DEBUG: Transforming device " + hostname + " (" + device_id + ")") + + if device_id == "" or len(addresses) == 0: + _log("WARN: Skipping device missing id or addresses") + return None + + network = build_network_interface_from_addresses(addresses, None) + if network == None: + return None + + attrs = { + "source": "Tailscale API 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", ""), + "tailscale_oauth_authentication": "false", + } + + created_raw = device.get("created") + if created_raw != None and created_raw != "": + parsed = parse_time(created_raw) + if parsed != None: + attrs["tailscale_created_ts"] = parsed.unix + + tags = device.get("tags", []) + if tags == None: + tags = [] + if 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-token"] + tags + + return ImportAsset( + id=asset_id, + hostnames=hostnames, + networkInterfaces=[network], + os=os_name, + tags=asset_tags, + customAttributes=attrs, + ) + + +def main(*args, **kwargs): + _log("=== TAILSCALE API TOKEN INTEGRATION ===") + _log("DEBUG: kwargs: " + str(kwargs.keys())) + + api_token = kwargs.get("access_secret") + tailnet = kwargs.get("tailnet") + if tailnet == None or tailnet == "": + tailnet = "-" # default tailnet + insecure_skip_verify = kwargs.get("insecure_skip_verify") + if insecure_skip_verify == None: + insecure_skip_verify = INSECURE_SKIP_VERIFY_DEFAULT + + if api_token == None or api_token == "": + _log("ERROR: Missing required 'access_secret' (API key)") + return [] + + _log("Starting Tailscale API sync for tailnet: " + tailnet) + + devices = tailscale_get_devices(api_token, tailnet, insecure_skip_verify) + if devices == None: + _log("ERROR: Failed to retrieve devices.") + return [] + + if len(devices) == 0: + _log("WARN: No devices found.") + 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 From f3a710f5cdb7b739d3647b5dd0943f96893fe6a9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 10 Nov 2025 17:01:32 +0000 Subject: [PATCH 2/4] Auto: update integrations JSON and README --- README.md | 1 + docs/integrations.json | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4c8862a..09e7d60 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ If you need help setting up a custom integration, you can create an [issue](http - [Scale Computing](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/scale-computing/) - [Snipe-IT](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/) - [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/) ## Export from runZero - [Audit Log to Webhook](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/audit-events-to-webhook/) diff --git a/docs/integrations.json b/docs/integrations.json index d1d5dde..b07cec0 100644 --- a/docs/integrations.json +++ b/docs/integrations.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2025-10-29T13:28:53.818462Z", - "totalIntegrations": 25, + "lastUpdated": "2025-11-10T17:01:32.826754Z", + "totalIntegrations": 26, "integrationDetails": [ { "name": "Lima Charlie", @@ -56,6 +56,12 @@ "readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/README.md", "integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/cortex-xdr/custom-integration-cortex-xdr.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", From eaa3f0d909aaa9062b097f333c822281d4efb637 Mon Sep 17 00:00:00 2001 From: Tyler Diderich Date: Mon, 10 Nov 2025 13:43:44 -0600 Subject: [PATCH 3/4] add oauth --- tailscale/README.md | 189 ++++++-------------- tailscale/custom-integration-tailscale.star | 168 ++++++++++------- 2 files changed, 166 insertions(+), 191 deletions(-) diff --git a/tailscale/README.md b/tailscale/README.md index f90ac95..5452a91 100644 --- a/tailscale/README.md +++ b/tailscale/README.md @@ -1,146 +1,73 @@ -# 🧩 Tailscale API β†’ runZero Integration - -A custom [runZero](https://www.runzero.com/) Starlark integration that imports assets directly from the [Tailscale API](https://tailscale.com/api). -This version uses a single API token or client secret β€” **no OAuth2 handshake required**. - ---- - -## πŸš€ Overview - -This integration connects to the Tailscale REST API using your **API token** (or **client secret**) and retrieves all devices in a tailnet. -Each Tailscale device is converted into a `runZero ImportAsset` with metadata, IP addresses, and custom attributes. - ---- - -## πŸ”‘ Requirements - -You’ll need: - -| Requirement | Description | -|--------------|-------------| -| **Tailscale API Key or Client Secret** | A `tskey-api-xxxxx` or `tskey-client-xxxxx` token from the [Tailscale admin console β†’ Keys page](https://login.tailscale.com/admin/settings/keys). | -| **Tailnet ID** | Usually found on the General Settings page of your Tailscale admin console (e.g., `T1234CNTRL`). You may also use `-` to reference the default tailnet. | -| **runZero Organization Role** | You must have permission to upload or manage integrations. | - ---- - - -## πŸ”’ API Key Permissions - -Tailscale has **two different API credential types**, and they have different permission behaviors. - -### πŸ”Ή **User API Keys** (`tskey-api-...`) -- Created under **Settings β†’ Keys β†’ Create API key** -- Have the **same permissions as the user who created them** -- Work automatically with this integration if: - - The user has **admin or owner** access to the tailnet - - The user can view devices in the Tailscale admin console - -βœ… **Recommended for most users** β€” easiest to use and requires no special scopes. - - -### πŸ”Ή **OAuth Client Secrets** (`tskey-client-...`) -- Created under **Settings β†’ OAuth Clients** -- Require explicit permission scopes to be set -- To fetch devices successfully, your OAuth client must include: - -```text -devices:core:read -devices:routes:read -devices:posture_attributes:read -``` - -## βš™οΈ Configuration - -When adding this integration in **runZero β†’ Integrations β†’ Custom Integration**, configure the following fields: - -| Parameter | Key | Required | Example | Description | -|------------|-----|-----------|----------|--------------| -| **Access Secret** | `access_secret` | βœ… | `tskey-api-abc123def456` | The API key or client secret used for authentication. | -| **Tailnet** | `tailnet` | βœ… | `T1234CNTRL` or `-` | The Tailnet ID or `-` for the default tailnet. | -| **Insecure Skip Verify** | `insecure_skip_verify` | ❌ | `false` | Set to `true` to disable SSL verification (not recommended). | - ---- - -## πŸ” Data Collected - -Each Tailscale device is imported as a `runZero ImportAsset` with these details: - -| Field | Source | Example | -|--------|---------|---------| -| `hostnames` | `device.hostname` | `laptop01` | -| `networkInterfaces` | `device.addresses` | IPv4 and IPv6 addresses | -| `os` | `device.os` | `linux` | -| `customAttributes.tailscale_user` | `device.user` | `amelie@example.com` | -| `customAttributes.tailscale_client_version` | `device.clientVersion` | `v1.36.0` | -| `customAttributes.tailscale_authorized` | `device.authorized` | `true` | -| `customAttributes.tailscale_advertised_routes` | `device.advertisedRoutes` | `10.0.0.0/16` | -| `customAttributes.tailscale_enabled_routes` | `device.enabledRoutes` | `192.168.1.0/24` | -| `customAttributes.tailscale_tags` | `device.tags` | `tag:prod, tag:subnetrouter` | - ---- - -## 🧠 How It Works - -1. **Authenticate** – The integration reads your `access_secret` and sets it as a `Bearer` token: -``` - -Authorization: Bearer tskey-api-xxxxx - -``` -2. **Fetch Devices** – Calls: -``` - -GET [https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices](https://api.tailscale.com/api/v2/tailnet/{tailnet}/devices) - +# 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 = "-" ```` -3. **Transform** – Each device is normalized into a `runZero ImportAsset`. -4. **Import** – Assets are returned to runZero for inventory enrichment. ---- +Update this value inside the script if your environment uses a specific tailnet ID (e.g., `"T1234CNTRL"`). -## πŸ§ͺ Testing - -You can test the script using the **Custom Integrations** feature in the runZero console or by using the local `runzero script` tool. - -Example test run: - -```bash -runzero script run tailscale_integration.star \ ---access_secret tskey-api-abc123def456 \ ---tailnet T1234CNTRL -```` +## Steps ---- +### Tailscale configuration -## ⚠️ Troubleshooting +1. Log into the [Tailscale Admin Console](https://login.tailscale.com/admin/settings). +2. Choose one of the following: -| Symptom | Cause | Fix | -| -------------------- | -------------------------------------------- | ------------------------------------------------------------------ | -| `401 Unauthorized` | Invalid or expired API token | Regenerate token in the Tailscale admin console. | -| `404 Not Found` | Tailnet name is incorrect | Use the Tailnet ID (e.g., `T1234CNTRL`) or `-` for default. | -| `0 devices returned` | Tailnet has no active devices or wrong scope | Ensure devices exist and token has `devices:core:read` permission. | -| `SSL Error` | Certificate issue | Use `insecure_skip_verify=true` (only for diagnostics). | + * **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 -## 🧰 Example Output +1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials). -``` -[TAILSCALE] SUCCESS: Retrieved 6 devices. -[TAILSCALE] SUCCESS: Prepared 6 ImportAsset objects. -[TAILSCALE] === INTEGRATION COMPLETE === -``` + * 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/). -## πŸͺͺ Licensing + * 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. -This integration is provided under the BSD 3-Clause license terms of the Tailscale API and runZero’s integration platform. +### 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. -## πŸ“š References +### Notes -* [Tailscale API Documentation](https://tailscale.com/api) -* [runZero Custom Integrations Guide](https://www.runzero.com/docs/integrations/custom/) \ No newline at end of file +* 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/custom-integration-tailscale.star b/tailscale/custom-integration-tailscale.star index dc17d16..d92abf2 100644 --- a/tailscale/custom-integration-tailscale.star +++ b/tailscale/custom-integration-tailscale.star @@ -1,19 +1,26 @@ -# Tailscale API -> runZero ImportAsset Starlark integration (DEBUG VERSION) -# Uses a single API token or client secret (tskey-client-xxxxx / tskey-api-xxxxx) +# Tailscale API + OAuth2 Client -> runZero ImportAsset Integration # -# Required kwargs: -# access_secret : Tailscale API key or OAuth client secret -# tailnet : tailnet ID (e.g., T1234CNTRL or "-" for default) -# Optional: -# insecure_skip_verify : bool (default False) +# 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") +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 @@ -21,29 +28,79 @@ def _log(msg): print("[TAILSCALE] " + msg) -def tailscale_get_devices(api_token, tailnet, insecure_skip_verify): - url = TAILSCALE_API_BASE + "/tailnet/" + tailnet + "/devices" +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 = { - "Authorization": "Bearer " + api_token, - "Accept": "application/json" + "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 API.") + _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 API token") + _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: Tailnet '" + tailnet + "' not found") + _log("ERROR: Not Found (404) - invalid tailnet ID.") return None if resp.status_code != 200: - _log("ERROR: Unexpected response " + str(resp.status_code)) + _log("ERROR: Unexpected status: " + str(resp.status_code)) if resp.body != None: _log("ERROR: Body: " + str(resp.body)) return None @@ -51,8 +108,6 @@ def tailscale_get_devices(api_token, tailnet, insecure_skip_verify): body = json_decode(resp.body) devices = body.get("devices", []) _log("SUCCESS: Retrieved " + str(len(devices)) + " devices.") - if len(devices) > 0: - _log("DEBUG: First device hostname: " + str(devices[0].get("hostname", "N/A"))) return devices @@ -64,12 +119,10 @@ def _clean_address(addr): def build_network_interface_from_addresses(addresses, mac): - ipv4s = [] - ipv6s = [] - if addresses == None: return None - + ipv4s = [] + ipv6s = [] for a in addresses: ipstr = _clean_address(a) if ipstr == None: @@ -83,10 +136,8 @@ def build_network_interface_from_addresses(addresses, mac): ipv6s.append(ipobj) if len(ipv4s) + len(ipv6s) >= 99: break - - if len(ipv4s) == 0 and len(ipv6s) == 0: + if len(ipv4s) == 0 and len(ipv6s) == 0 and mac == None: return None - return NetworkInterface(macAddress=mac, ipv4Addresses=ipv4s, ipv6Addresses=ipv6s) @@ -96,18 +147,15 @@ def transform_device_to_importasset(device, tailnet): addresses = device.get("addresses", []) os_name = device.get("os", "Unknown") - _log("DEBUG: Transforming device " + hostname + " (" + device_id + ")") - if device_id == "" or len(addresses) == 0: - _log("WARN: Skipping device missing id or addresses") return None - network = build_network_interface_from_addresses(addresses, None) - if network == None: + netif = build_network_interface_from_addresses(addresses, None) + if netif == None: return None attrs = { - "source": "Tailscale API Integration", + "source": "Tailscale Integration", "tailscale_device_id": device_id, "tailscale_tailnet": tailnet, "tailscale_user": device.get("user", ""), @@ -119,19 +167,16 @@ def transform_device_to_importasset(device, tailnet): "tailscale_is_external": str(device.get("isExternal", False)), "tailscale_blocks_incoming_connections": str(device.get("blocksIncomingConnections", False)), "tailscale_created": device.get("created", ""), - "tailscale_oauth_authentication": "false", } - created_raw = device.get("created") - if created_raw != None and created_raw != "": - parsed = parse_time(created_raw) + 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: - tags = [] - if len(tags) > 0: + if tags != None and len(tags) > 0: attrs["tailscale_tags"] = ", ".join(tags) adv_routes = device.get("advertisedRoutes", []) @@ -144,12 +189,12 @@ def transform_device_to_importasset(device, tailnet): asset_id = "tailscale-" + device_id hostnames = [hostname] if hostname != "" else [] - asset_tags = ["tailscale", "api-token"] + tags + asset_tags = ["tailscale", "api"] + tags return ImportAsset( id=asset_id, hostnames=hostnames, - networkInterfaces=[network], + networkInterfaces=[netif], os=os_name, tags=asset_tags, customAttributes=attrs, @@ -157,30 +202,33 @@ def transform_device_to_importasset(device, tailnet): def main(*args, **kwargs): - _log("=== TAILSCALE API TOKEN INTEGRATION ===") - _log("DEBUG: kwargs: " + str(kwargs.keys())) - - api_token = kwargs.get("access_secret") - tailnet = kwargs.get("tailnet") - if tailnet == None or tailnet == "": - tailnet = "-" # default tailnet - insecure_skip_verify = kwargs.get("insecure_skip_verify") - if insecure_skip_verify == None: - insecure_skip_verify = INSECURE_SKIP_VERIFY_DEFAULT - - if api_token == None or api_token == "": - _log("ERROR: Missing required 'access_secret' (API key)") - return [] + _log("=== TAILSCALE API / OAUTH INTEGRATION ===") - _log("Starting Tailscale API sync for tailnet: " + tailnet) + 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 - devices = tailscale_get_devices(api_token, tailnet, insecure_skip_verify) - if devices == None: - _log("ERROR: Failed to retrieve devices.") + if secret == None or secret == "": + _log("ERROR: Missing required access_secret (API key or client secret).") return [] - if len(devices) == 0: - _log("WARN: No devices found.") + # 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 = [] From 42216c24fcd13325dcdf735a076be57776d7170c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 13 Nov 2025 17:39:24 +0000 Subject: [PATCH 4/4] Auto: update integrations JSON and README --- README.md | 2 +- docs/integrations.json | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e7fee79..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/) 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",