Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand All @@ -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/)
Expand All @@ -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
Expand Down
22 changes: 14 additions & 8 deletions docs/integrations.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions tailscale/README.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions tailscale/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "name": "Tailscale", "type": "inbound" }
242 changes: 242 additions & 0 deletions tailscale/custom-integration-tailscale.star
Original file line number Diff line number Diff line change
@@ -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