Skip to content

Commit 4f6a46f

Browse files
authored
Merge pull request #20 from runZeroInc/new/tailscale
NEW: Tailscale
2 parents ea2f078 + 42216c2 commit 4f6a46f

5 files changed

Lines changed: 332 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ If you need help setting up a custom integration, you can create an [issue](http
1919
# Existing Integrations
2020

2121
## Import to runZero
22+
- [Akamai Guardicore Centra](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/)
2223
- [Automox](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/automox/)
2324
- [Carbon Black](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/carbon-black/)
2425
- [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
2930
- [Drata](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/drata/)
3031
- [Extreme Networks CloudIQ](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/extreme-cloud-iq/)
3132
- [Ghost Security](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ghost/)
32-
- [Guardicore Centra](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/)
3333
- [JAMF](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/jamf/)
3434
- [Kandji](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/kandji/)
3535
- [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
4242
- [Snipe-IT](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snipe-it/)
4343
- [Snow License Manager](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/snow_license_manager/)
4444
- [Stairwell](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/stairwell/)
45+
- [Tailscale](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/)
4546
- [Tanium](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tanium/)
4647
- [Ubiquiti Unifi Network](https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/)
4748
## Export from runZero

docs/integrations.json

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"lastUpdated": "2025-11-10T18:36:52.667035Z",
3-
"totalIntegrations": 28,
2+
"lastUpdated": "2025-11-13T17:39:24.733491Z",
3+
"totalIntegrations": 29,
44
"integrationDetails": [
55
{
66
"name": "Lima Charlie",
@@ -14,12 +14,6 @@
1414
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/README.md",
1515
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/sumo-logic/custom-integration-sumo.star"
1616
},
17-
{
18-
"name": "Guardicore Centra",
19-
"type": "inbound",
20-
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/README.md",
21-
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai_guardicore_centra/centrav3.star"
22-
},
2317
{
2418
"name": "Cyberint",
2519
"type": "inbound",
@@ -68,6 +62,12 @@
6862
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/README.md",
6963
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/ubiquiti-unifi-network/custom-integration-ubiquiti-unifi-network.star"
7064
},
65+
{
66+
"name": "Tailscale",
67+
"type": "inbound",
68+
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/README.md",
69+
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/tailscale/custom-integration-tailscale.star"
70+
},
7171
{
7272
"name": "Automox",
7373
"type": "inbound",
@@ -86,6 +86,12 @@
8686
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/README.md",
8787
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/digital-ocean/custom-integration-digital-ocean.star"
8888
},
89+
{
90+
"name": "Akamai Guardicore Centra",
91+
"type": "inbound",
92+
"readme": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/README.md",
93+
"integration": "https://github.com/runZeroInc/runzero-custom-integrations/blob/main/akamai-guardicore-centra/custom-integration-centra-v3-api.star"
94+
},
8995
{
9096
"name": "Device42",
9197
"type": "inbound",

tailscale/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Custom Integration: Tailscale API
2+
3+
## runZero requirements
4+
5+
- Superuser access to the [Custom Integrations configuration](https://console.runzero.com/custom-integrations) in runZero.
6+
- A [Custom Integration Script Secret](https://console.runzero.com/credentials) credential configured with:
7+
- `access_key`: your **Tailscale OAuth Client ID** (leave blank if using a standard API key)
8+
- `access_secret`: your **Tailscale API key** or **OAuth Client Secret**
9+
10+
## Tailscale requirements
11+
12+
- Either:
13+
- A **User API Key** (`tskey-api-xxxxx`) created under **Settings → Keys** in the [Tailscale Admin Console](https://login.tailscale.com/admin/settings),
14+
**or**
15+
- An **OAuth Client ID / Secret** pair created under **Settings → OAuth Clients** with the following scopes:
16+
```
17+
devices:core:read
18+
devices:routes:read
19+
devices:posture_attributes:read
20+
```
21+
22+
- The integration script defines your tailnet globally:
23+
```python
24+
TAILNET_DEFAULT = "-"
25+
````
26+
27+
Update this value inside the script if your environment uses a specific tailnet ID (e.g., `"T1234CNTRL"`).
28+
29+
## Steps
30+
31+
### Tailscale configuration
32+
33+
1. Log into the [Tailscale Admin Console](https://login.tailscale.com/admin/settings).
34+
2. Choose one of the following:
35+
36+
* **Option 1 – API Key:**
37+
Create a new API key under **Settings → Keys → Create API key**.
38+
Ensure the user has admin access to the tailnet.
39+
* **Option 2 – OAuth Client:**
40+
Create a new OAuth client under **Settings → OAuth Clients**.
41+
Add the required scopes listed above and record the client ID and secret.
42+
43+
### runZero configuration
44+
45+
1. [Create the Credential for the Custom Integration](https://console.runzero.com/credentials).
46+
47+
* Select **Custom Integration Script Secrets**.
48+
* For `access_secret`, enter your **API key** or **OAuth client secret**.
49+
* For `access_key`, enter your **OAuth client ID**, or a placeholder value (e.g., `foo`) if using an API key.
50+
2. [Create the Custom Integration](https://console.runzero.com/custom-integrations/new).
51+
52+
* Add a descriptive name (e.g., `tailscale-sync`).
53+
* Toggle **Enable custom integration script** and paste the finalized script.
54+
* Click **Validate** to confirm syntax, then **Save**.
55+
3. [Create the Custom Integration task](https://console.runzero.com/ingest/custom/).
56+
57+
* Select the Credential and Custom Integration created above.
58+
* Adjust the task schedule to your preferred frequency.
59+
* Select an Explorer to execute the task.
60+
* Click **Save** to start the integration.
61+
62+
### What's next?
63+
64+
* The task will execute and retrieve device data from the Tailscale API.
65+
* Each Tailscale device will be imported as a `runZero ImportAsset`.
66+
* You can view the integration run under [Tasks](https://console.runzero.com/tasks) in the runZero console.
67+
68+
### Notes
69+
70+
* The integration automatically detects whether you’re using an **API key** or **OAuth client credentials**.
71+
* If you encounter a `403` error, verify your API key or OAuth client has the `devices:core:read` permission.
72+
* The `TAILNET_DEFAULT` variable can be modified in the script if your organization uses multiple tailnets.
73+
* Device metadata, tags, and IP addresses from Tailscale are mapped to `runZero` custom attributes and interfaces.

tailscale/config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{ "name": "Tailscale", "type": "inbound" }
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
# Tailscale API + OAuth2 Client -> runZero ImportAsset Integration
2+
#
3+
# Supports both:
4+
# - Direct API key (tskey-api-xxxxx)
5+
# - OAuth client credentials (access_key = client_id, access_secret = client_secret)
6+
#
7+
# Only two credential inputs are required:
8+
# access_key : client_id (if OAuth) or unused for API key mode
9+
# access_secret : client_secret (if OAuth) or API key (tskey-api-xxxxx)
10+
#
11+
# Tailnet ID is defined below as a global variable.
12+
13+
load("runzero.types", "ImportAsset", "NetworkInterface")
14+
load("json", json_decode="decode")
15+
load("net", "ip_address")
16+
load("http", http_get="get", http_post="post", "url_encode")
17+
load("time", "parse_time")
18+
19+
# --- Configuration ---
20+
TAILSCALE_API_BASE = "https://api.tailscale.com/api/v2"
21+
TAILSCALE_TOKEN_URL = "https://api.tailscale.com/api/v2/oauth/token"
22+
TAILNET_DEFAULT = "-" # change to your tailnet ID if needed, e.g. "T1234CNTRL"
23+
DEFAULT_SCOPE = "devices:core:read"
24+
INSECURE_SKIP_VERIFY_DEFAULT = False
25+
26+
27+
def _log(msg):
28+
print("[TAILSCALE] " + msg)
29+
30+
31+
def obtain_oauth_token(client_id, client_secret, scope, insecure_skip_verify):
32+
"""
33+
Request an OAuth2 access token from Tailscale.
34+
"""
35+
_log("Requesting OAuth2 token from Tailscale...")
36+
headers = {
37+
"Content-Type": "application/x-www-form-urlencoded",
38+
"Accept": "application/json",
39+
}
40+
form = {
41+
"grant_type": "client_credentials",
42+
"client_id": client_id,
43+
"client_secret": client_secret,
44+
"scope": scope,
45+
}
46+
47+
resp = http_post(
48+
url=TAILSCALE_TOKEN_URL,
49+
headers=headers,
50+
body=bytes(url_encode(form)),
51+
insecure_skip_verify=insecure_skip_verify,
52+
)
53+
54+
if resp == None:
55+
_log("ERROR: No response from OAuth token endpoint.")
56+
return None
57+
58+
_log("DEBUG: OAuth token response status: " + str(resp.status_code))
59+
if resp.status_code != 200:
60+
_log("ERROR: OAuth token request failed: " + str(resp.status_code))
61+
if resp.body != None:
62+
_log("ERROR: Response: " + str(resp.body))
63+
return None
64+
65+
body = json_decode(resp.body)
66+
token = body.get("access_token")
67+
expires = body.get("expires_in")
68+
if token == None:
69+
_log("ERROR: Missing access_token in OAuth response.")
70+
return None
71+
72+
_log("SUCCESS: Obtained access token (expires_in=" + str(expires) + "s)")
73+
return token
74+
75+
76+
def tailscale_get_devices(access_token, tailnet, insecure_skip_verify):
77+
"""
78+
Fetch device inventory for a tailnet using an access token or API key.
79+
"""
80+
url = TAILSCALE_API_BASE + "/tailnet/" + tailnet + "/devices"
81+
headers = {"Authorization": "Bearer " + access_token, "Accept": "application/json"}
82+
_log("DEBUG: Fetching devices from " + url)
83+
84+
resp = http_get(url=url, headers=headers, insecure_skip_verify=insecure_skip_verify)
85+
if resp == None:
86+
_log("ERROR: No response from Tailscale devices endpoint.")
87+
return None
88+
89+
_log("DEBUG: Devices response status: " + str(resp.status_code))
90+
91+
if resp.status_code == 401:
92+
_log("ERROR: Unauthorized (401) - invalid or expired token.")
93+
return None
94+
if resp.status_code == 403:
95+
_log("ERROR: Forbidden (403) - insufficient permissions or missing scope.")
96+
if resp.body != None:
97+
_log("ERROR: Body: " + str(resp.body))
98+
return None
99+
if resp.status_code == 404:
100+
_log("ERROR: Not Found (404) - invalid tailnet ID.")
101+
return None
102+
if resp.status_code != 200:
103+
_log("ERROR: Unexpected status: " + str(resp.status_code))
104+
if resp.body != None:
105+
_log("ERROR: Body: " + str(resp.body))
106+
return None
107+
108+
body = json_decode(resp.body)
109+
devices = body.get("devices", [])
110+
_log("SUCCESS: Retrieved " + str(len(devices)) + " devices.")
111+
return devices
112+
113+
114+
def _clean_address(addr):
115+
if addr == None:
116+
return None
117+
parts = addr.split("/")
118+
return parts[0]
119+
120+
121+
def build_network_interface_from_addresses(addresses, mac):
122+
if addresses == None:
123+
return None
124+
ipv4s = []
125+
ipv6s = []
126+
for a in addresses:
127+
ipstr = _clean_address(a)
128+
if ipstr == None:
129+
continue
130+
ipobj = ip_address(ipstr)
131+
if ipobj == None:
132+
continue
133+
if ipobj.version == 4:
134+
ipv4s.append(ipobj)
135+
else:
136+
ipv6s.append(ipobj)
137+
if len(ipv4s) + len(ipv6s) >= 99:
138+
break
139+
if len(ipv4s) == 0 and len(ipv6s) == 0 and mac == None:
140+
return None
141+
return NetworkInterface(macAddress=mac, ipv4Addresses=ipv4s, ipv6Addresses=ipv6s)
142+
143+
144+
def transform_device_to_importasset(device, tailnet):
145+
device_id = device.get("id", "")
146+
hostname = device.get("hostname", device.get("name", ""))
147+
addresses = device.get("addresses", [])
148+
os_name = device.get("os", "Unknown")
149+
150+
if device_id == "" or len(addresses) == 0:
151+
return None
152+
153+
netif = build_network_interface_from_addresses(addresses, None)
154+
if netif == None:
155+
return None
156+
157+
attrs = {
158+
"source": "Tailscale Integration",
159+
"tailscale_device_id": device_id,
160+
"tailscale_tailnet": tailnet,
161+
"tailscale_user": device.get("user", ""),
162+
"tailscale_os": os_name,
163+
"tailscale_client_version": device.get("clientVersion", ""),
164+
"tailscale_authorized": str(device.get("authorized", False)),
165+
"tailscale_update_available": str(device.get("updateAvailable", False)),
166+
"tailscale_key_expiry_disabled": str(device.get("keyExpiryDisabled", False)),
167+
"tailscale_is_external": str(device.get("isExternal", False)),
168+
"tailscale_blocks_incoming_connections": str(device.get("blocksIncomingConnections", False)),
169+
"tailscale_created": device.get("created", ""),
170+
}
171+
172+
parsed_time = device.get("created")
173+
if parsed_time != None and parsed_time != "":
174+
parsed = parse_time(parsed_time)
175+
if parsed != None:
176+
attrs["tailscale_created_ts"] = parsed.unix
177+
178+
tags = device.get("tags", [])
179+
if tags != None and len(tags) > 0:
180+
attrs["tailscale_tags"] = ", ".join(tags)
181+
182+
adv_routes = device.get("advertisedRoutes", [])
183+
if adv_routes != None and len(adv_routes) > 0:
184+
attrs["tailscale_advertised_routes"] = ", ".join(adv_routes)
185+
186+
en_routes = device.get("enabledRoutes", [])
187+
if en_routes != None and len(en_routes) > 0:
188+
attrs["tailscale_enabled_routes"] = ", ".join(en_routes)
189+
190+
asset_id = "tailscale-" + device_id
191+
hostnames = [hostname] if hostname != "" else []
192+
asset_tags = ["tailscale", "api"] + tags
193+
194+
return ImportAsset(
195+
id=asset_id,
196+
hostnames=hostnames,
197+
networkInterfaces=[netif],
198+
os=os_name,
199+
tags=asset_tags,
200+
customAttributes=attrs,
201+
)
202+
203+
204+
def main(*args, **kwargs):
205+
_log("=== TAILSCALE API / OAUTH INTEGRATION ===")
206+
207+
client_id = kwargs.get("access_key") # used only for OAuth
208+
secret = kwargs.get("access_secret") # API key or OAuth client_secret
209+
insecure_skip_verify = INSECURE_SKIP_VERIFY_DEFAULT
210+
211+
if secret == None or secret == "":
212+
_log("ERROR: Missing required access_secret (API key or client secret).")
213+
return []
214+
215+
# Detect auth type
216+
if client_id != None and client_id != "":
217+
_log("Detected OAuth client credentials mode.")
218+
token = obtain_oauth_token(client_id, secret, DEFAULT_SCOPE, insecure_skip_verify)
219+
if token == None:
220+
_log("ERROR: Failed to obtain OAuth access token.")
221+
return []
222+
else:
223+
_log("Detected API key mode.")
224+
token = secret
225+
226+
tailnet = TAILNET_DEFAULT
227+
_log("Fetching devices for tailnet: " + tailnet)
228+
229+
devices = tailscale_get_devices(token, tailnet, insecure_skip_verify)
230+
if devices == None or len(devices) == 0:
231+
_log("WARN: No devices found or API call failed.")
232+
return []
233+
234+
assets = []
235+
for d in devices:
236+
ia = transform_device_to_importasset(d, tailnet)
237+
if ia != None:
238+
assets.append(ia)
239+
240+
_log("SUCCESS: Prepared " + str(len(assets)) + " ImportAsset objects.")
241+
_log("=== INTEGRATION COMPLETE ===")
242+
return assets

0 commit comments

Comments
 (0)