ts-proxy 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ts_proxy/__init__.py +5 -0
- ts_proxy/api.py +1251 -0
- ts_proxy/cli.py +1171 -0
- ts_proxy/config.py +241 -0
- ts_proxy/config.yaml +8 -0
- ts_proxy/doc.py +122 -0
- ts_proxy/exceptions.py +14 -0
- ts_proxy/hitl.py +335 -0
- ts_proxy/logger.py +47 -0
- ts_proxy/models.py +129 -0
- ts_proxy/template/config-edit.html +198 -0
- ts_proxy/template/default.html +169 -0
- ts_proxy/template/update_acl.html +216 -0
- ts_proxy-1.1.0.dist-info/METADATA +91 -0
- ts_proxy-1.1.0.dist-info/RECORD +17 -0
- ts_proxy-1.1.0.dist-info/WHEEL +4 -0
- ts_proxy-1.1.0.dist-info/entry_points.txt +3 -0
ts_proxy/api.py
ADDED
|
@@ -0,0 +1,1251 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tailscale Proxy API Client.
|
|
3
|
+
|
|
4
|
+
RULES FOR CONTRIBUTORS:
|
|
5
|
+
1. ALPHABETICAL ORDER: All methods in TailscaleClient and commands in cli.py MUST be kept in strict ascending alphabetical order.
|
|
6
|
+
2. DOCSTRING STRUCTURE: Every method MUST have exactly three sections in its docstring:
|
|
7
|
+
- Description (Summary + Body)
|
|
8
|
+
- Parameters: (List of fields or "- None")
|
|
9
|
+
- Examples: (Usage examples, AT LEAST 3)
|
|
10
|
+
3. ZERO TOLERANCE: Any deviation from these rules will break the dynamic documentation engine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import httpx
|
|
16
|
+
from typing import Optional, Dict, Any, List
|
|
17
|
+
|
|
18
|
+
from .models import (
|
|
19
|
+
ACLUpdatePayload,
|
|
20
|
+
AuthKeyPayload,
|
|
21
|
+
ContactPayload,
|
|
22
|
+
DeviceIDPayload,
|
|
23
|
+
DeviceKeyExpiryPayload,
|
|
24
|
+
DeviceNamePayload,
|
|
25
|
+
DeviceUpdatePayload,
|
|
26
|
+
DNSPreferencesPayload,
|
|
27
|
+
InvitationPayload,
|
|
28
|
+
NameserversPayload,
|
|
29
|
+
PostureCheckPayload,
|
|
30
|
+
SearchPathsPayload,
|
|
31
|
+
SettingsPayload,
|
|
32
|
+
SubnetRoutesPayload,
|
|
33
|
+
UserIDPayload,
|
|
34
|
+
UserRolePayload,
|
|
35
|
+
WebhookPayload,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
import logging
|
|
40
|
+
from .exceptions import SecureProxyError
|
|
41
|
+
from .config import PERSISTED_SECRETS_PATH, PROD_SECRET_MOUNT
|
|
42
|
+
from .hitl import require_approval
|
|
43
|
+
|
|
44
|
+
logger = logging.getLogger("ts_proxy")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AuthManager:
|
|
48
|
+
def __init__(self, auth_file: Optional[str] = None):
|
|
49
|
+
self.client_id: Optional[str] = None
|
|
50
|
+
self.client_secret: Optional[str] = None
|
|
51
|
+
self.tailnet: str = "-" # Default to the token's associated tailnet
|
|
52
|
+
self._token: Optional[str] = None
|
|
53
|
+
self._load_credentials(auth_file)
|
|
54
|
+
|
|
55
|
+
def _load_credentials(self, auth_file: Optional[str]):
|
|
56
|
+
# 1. Explicit CLI Path
|
|
57
|
+
if auth_file and os.path.exists(auth_file):
|
|
58
|
+
self._load_from_json(auth_file)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# 2. Persisted session (from admin login)
|
|
62
|
+
if os.path.exists(PERSISTED_SECRETS_PATH):
|
|
63
|
+
self._load_from_json(PERSISTED_SECRETS_PATH)
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
# 3. Local Environment Variables
|
|
67
|
+
env_id = os.environ.get("TS_CLIENT_ID")
|
|
68
|
+
env_secret = os.environ.get("TS_CLIENT_SECRET")
|
|
69
|
+
if env_id and env_secret:
|
|
70
|
+
self.client_id = env_id
|
|
71
|
+
self.client_secret = env_secret
|
|
72
|
+
self.tailnet = os.environ.get("TS_TAILNET", "-")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
# 4. Docker Secret Mount (Production)
|
|
76
|
+
if os.path.exists(PROD_SECRET_MOUNT):
|
|
77
|
+
self._load_from_json(PROD_SECRET_MOUNT)
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
# 5. Fallback
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def _load_from_json(self, path: str):
|
|
84
|
+
try:
|
|
85
|
+
with open(path, "r") as f:
|
|
86
|
+
data = json.load(f)
|
|
87
|
+
self.client_id = data.get("client_id")
|
|
88
|
+
self.client_secret = data.get("client_secret")
|
|
89
|
+
self.tailnet = data.get("tailnet", "-")
|
|
90
|
+
except Exception:
|
|
91
|
+
raise SecureProxyError("Failed to parse credentials file.")
|
|
92
|
+
|
|
93
|
+
if not self.client_id or not self.client_secret:
|
|
94
|
+
raise SecureProxyError(
|
|
95
|
+
"Credentials file missing client_id or client_secret."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
async def get_access_token(self) -> str:
|
|
99
|
+
if self._token:
|
|
100
|
+
return self._token
|
|
101
|
+
|
|
102
|
+
async with httpx.AsyncClient() as client:
|
|
103
|
+
resp = await client.post(
|
|
104
|
+
"https://api.tailscale.com/api/v2/oauth/token",
|
|
105
|
+
data={
|
|
106
|
+
"client_id": self.client_id,
|
|
107
|
+
"client_secret": self.client_secret,
|
|
108
|
+
"grant_type": "client_credentials",
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
if resp.status_code != 200:
|
|
112
|
+
logger.error(
|
|
113
|
+
f"OAuth token fetch failed: {resp.status_code} - {resp.text}"
|
|
114
|
+
)
|
|
115
|
+
raise SecureProxyError(
|
|
116
|
+
"OAuth token fetch failed. Verify credentials and scopes."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
data = resp.json()
|
|
120
|
+
self._token = data.get("access_token")
|
|
121
|
+
if not self._token:
|
|
122
|
+
raise SecureProxyError(
|
|
123
|
+
"API returned a successful response but no access_token was found."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return self._token
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TailscaleClient:
|
|
130
|
+
BASE_URL = "https://api.tailscale.com/api/v2"
|
|
131
|
+
|
|
132
|
+
def __init__(self, auth: AuthManager):
|
|
133
|
+
self.auth = auth
|
|
134
|
+
|
|
135
|
+
async def _request(
|
|
136
|
+
self,
|
|
137
|
+
method: str,
|
|
138
|
+
endpoint: str,
|
|
139
|
+
json_data: Optional[Dict] = None,
|
|
140
|
+
data: Optional[Any] = None,
|
|
141
|
+
) -> httpx.Response:
|
|
142
|
+
token = await self.auth.get_access_token()
|
|
143
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
async with httpx.AsyncClient() as client:
|
|
147
|
+
url = f"{self.BASE_URL}{endpoint}"
|
|
148
|
+
resp = await client.request(
|
|
149
|
+
method, url, headers=headers, json=json_data, data=data
|
|
150
|
+
)
|
|
151
|
+
logger.info(f"API Request: {method} {endpoint} -> {resp.status_code}")
|
|
152
|
+
|
|
153
|
+
if resp.status_code >= 400:
|
|
154
|
+
err_msg = (
|
|
155
|
+
f"API Request failed: {method} {endpoint} -> {resp.status_code}"
|
|
156
|
+
)
|
|
157
|
+
try:
|
|
158
|
+
detail = resp.json().get("message", "")
|
|
159
|
+
if detail:
|
|
160
|
+
err_msg += f" | {detail}"
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
raise SecureProxyError(err_msg)
|
|
164
|
+
return resp
|
|
165
|
+
except SecureProxyError:
|
|
166
|
+
raise
|
|
167
|
+
except Exception as e:
|
|
168
|
+
raise SecureProxyError(f"Unexpected error: {str(e)}")
|
|
169
|
+
|
|
170
|
+
# --- Tailscale Operations (STRICT ALPHABETICAL ORDER) ---
|
|
171
|
+
|
|
172
|
+
@require_approval()
|
|
173
|
+
async def authorize_device(
|
|
174
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
175
|
+
) -> Dict:
|
|
176
|
+
"""
|
|
177
|
+
Authorize a pending device.
|
|
178
|
+
|
|
179
|
+
Approves a device that is waiting for manual authorization to join the tailnet.
|
|
180
|
+
Requires Human-in-the-Loop approval.
|
|
181
|
+
|
|
182
|
+
Parameters:
|
|
183
|
+
- device_id (str): The unique identifier (ID) of the device to authorize.
|
|
184
|
+
|
|
185
|
+
Examples:
|
|
186
|
+
- Authorize node:
|
|
187
|
+
`ts-proxy do authorize-device '{"device_id": "12345"}'`
|
|
188
|
+
- Authorize node from file:
|
|
189
|
+
`ts-proxy do authorize-device ./node.json`
|
|
190
|
+
- Authorize with specific rationale:
|
|
191
|
+
`ts-proxy do authorize-device '{"device_id": "12345"}' --rationale "New server"`
|
|
192
|
+
"""
|
|
193
|
+
resp = await self._request(
|
|
194
|
+
"POST",
|
|
195
|
+
f"/device/{payload.device_id}/authorized",
|
|
196
|
+
json_data={"authorized": True},
|
|
197
|
+
)
|
|
198
|
+
return {"status": resp.status_code, "detail": "Device authorized"}
|
|
199
|
+
|
|
200
|
+
async def create_authkey(self, payload: AuthKeyPayload) -> Dict:
|
|
201
|
+
"""
|
|
202
|
+
Create a new authentication key.
|
|
203
|
+
|
|
204
|
+
Generates a new auth key for adding devices to the tailnet without manual login.
|
|
205
|
+
|
|
206
|
+
Parameters:
|
|
207
|
+
- capabilities (Dict): Key capabilities.
|
|
208
|
+
- expirySeconds (int): Key expiry time in seconds.
|
|
209
|
+
|
|
210
|
+
Examples:
|
|
211
|
+
- Create reusable key:
|
|
212
|
+
`ts-proxy do create-authkey '{"capabilities": {"devices": {"create": {"reusable": true, "preauthorized": true}}}}'`
|
|
213
|
+
- Create single-use key:
|
|
214
|
+
`ts-proxy do create-authkey '{"capabilities": {"devices": {"create": {"reusable": false, "preauthorized": false}}}}'`
|
|
215
|
+
- Create key with specific tags:
|
|
216
|
+
`ts-proxy do create-authkey '{"capabilities": {"devices": {"create": {"tags": ["tag:server"]}}}}'`
|
|
217
|
+
"""
|
|
218
|
+
resp = await self._request(
|
|
219
|
+
"POST",
|
|
220
|
+
f"/tailnet/{self.auth.tailnet}/keys",
|
|
221
|
+
json_data=payload.model_dump(exclude_none=True),
|
|
222
|
+
)
|
|
223
|
+
return resp.json()
|
|
224
|
+
|
|
225
|
+
@require_approval()
|
|
226
|
+
async def create_invitation(
|
|
227
|
+
self, payload: InvitationPayload, rationale: str = ""
|
|
228
|
+
) -> Dict:
|
|
229
|
+
"""
|
|
230
|
+
Create a new tailnet invitation.
|
|
231
|
+
|
|
232
|
+
Generates an invitation for a new user to join the tailnet.
|
|
233
|
+
Requires Human-in-the-Loop approval.
|
|
234
|
+
|
|
235
|
+
Parameters:
|
|
236
|
+
- email (str): Email of the invited user.
|
|
237
|
+
- role (str): Role for the invited user.
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
- Invite a member:
|
|
241
|
+
`ts-proxy do create-invitation '{"email": "user@example.com", "role": "member"}'`
|
|
242
|
+
- Invite an admin:
|
|
243
|
+
`ts-proxy do create-invitation '{"email": "admin@example.com", "role": "admin"}'`
|
|
244
|
+
- Invite with rationale:
|
|
245
|
+
`ts-proxy do create-invitation '{"email": "guest@example.com"}' --rationale "External consultant"`
|
|
246
|
+
"""
|
|
247
|
+
resp = await self._request(
|
|
248
|
+
"POST",
|
|
249
|
+
f"/tailnet/{self.auth.tailnet}/user-invites",
|
|
250
|
+
json_data=payload.model_dump(),
|
|
251
|
+
)
|
|
252
|
+
return resp.json()
|
|
253
|
+
|
|
254
|
+
@require_approval()
|
|
255
|
+
async def create_posture_check(
|
|
256
|
+
self, payload: PostureCheckPayload, rationale: str = ""
|
|
257
|
+
) -> Dict:
|
|
258
|
+
"""
|
|
259
|
+
Create a new posture check.
|
|
260
|
+
|
|
261
|
+
Defines a security posture requirement for nodes in the tailnet.
|
|
262
|
+
Requires Human-in-the-Loop approval.
|
|
263
|
+
|
|
264
|
+
Parameters:
|
|
265
|
+
- type (str): Type of posture check.
|
|
266
|
+
- description (str): Description of the check.
|
|
267
|
+
- value (Any): Value for the check.
|
|
268
|
+
|
|
269
|
+
Examples:
|
|
270
|
+
- Create file check:
|
|
271
|
+
`ts-proxy do create-posture-check '{"type": "file", "description": "Check secret file", "value": {"path": "/etc/secret"}}'`
|
|
272
|
+
- Create registry check:
|
|
273
|
+
`ts-proxy do create-posture-check '{"type": "registry", "description": "Check reg key", "value": {"path": "HKLM\\Software\\Proxy"}}'`
|
|
274
|
+
- Create version check:
|
|
275
|
+
`ts-proxy do create-posture-check '{"type": "os_version", "description": "Min OS version", "value": "22.04"}'`
|
|
276
|
+
"""
|
|
277
|
+
resp = await self._request(
|
|
278
|
+
"POST",
|
|
279
|
+
f"/tailnet/{self.auth.tailnet}/posture",
|
|
280
|
+
json_data=payload.model_dump(exclude_none=True),
|
|
281
|
+
)
|
|
282
|
+
return resp.json()
|
|
283
|
+
|
|
284
|
+
async def create_webhook(self, payload: WebhookPayload) -> Dict:
|
|
285
|
+
"""
|
|
286
|
+
Create a new webhook.
|
|
287
|
+
|
|
288
|
+
Sets up a new webhook endpoint to receive real-time notifications about tailnet events.
|
|
289
|
+
|
|
290
|
+
Parameters:
|
|
291
|
+
- endpointUrl (str): The destination URL.
|
|
292
|
+
- subscriptions (List[str]): Event types (e.g., ["nodeCreated"]).
|
|
293
|
+
|
|
294
|
+
Examples:
|
|
295
|
+
- Create webhook:
|
|
296
|
+
`ts-proxy do create-webhook '{"endpointUrl": "https://n8n.labs/webhook", "subscriptions": ["nodeCreated"]}'`
|
|
297
|
+
- Create webhook for all events:
|
|
298
|
+
`ts-proxy do create-webhook '{"endpointUrl": "https://hooks.com/all", "subscriptions": ["nodeCreated", "nodeDeleted"]}'`
|
|
299
|
+
- Create with file payload:
|
|
300
|
+
`ts-proxy do create-webhook ./webhook_config.json`
|
|
301
|
+
"""
|
|
302
|
+
resp = await self._request(
|
|
303
|
+
"POST",
|
|
304
|
+
f"/tailnet/{self.auth.tailnet}/webhooks",
|
|
305
|
+
json_data=payload.model_dump(),
|
|
306
|
+
)
|
|
307
|
+
return resp.json()
|
|
308
|
+
|
|
309
|
+
@require_approval()
|
|
310
|
+
async def delete_authkey(
|
|
311
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
312
|
+
) -> Dict:
|
|
313
|
+
"""
|
|
314
|
+
Delete an authentication key.
|
|
315
|
+
|
|
316
|
+
Invalidates an existing authentication key immediately.
|
|
317
|
+
|
|
318
|
+
Parameters:
|
|
319
|
+
- device_id (str): The ID of the key to delete.
|
|
320
|
+
|
|
321
|
+
Examples:
|
|
322
|
+
- Delete key:
|
|
323
|
+
`ts-proxy do delete-authkey '{"device_id": "k12345"}'`
|
|
324
|
+
- Delete key with rationale:
|
|
325
|
+
`ts-proxy do delete-authkey '{"device_id": "k12345"}' --rationale "Key compromised"`
|
|
326
|
+
- Delete key from file payload:
|
|
327
|
+
`ts-proxy do delete-authkey ./key_to_delete.json`
|
|
328
|
+
"""
|
|
329
|
+
resp = await self._request(
|
|
330
|
+
"DELETE", f"/tailnet/{self.auth.tailnet}/keys/{payload.device_id}"
|
|
331
|
+
)
|
|
332
|
+
return {"status": resp.status_code, "detail": "Auth key deleted"}
|
|
333
|
+
|
|
334
|
+
@require_approval()
|
|
335
|
+
async def delete_device(
|
|
336
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
337
|
+
) -> Dict:
|
|
338
|
+
"""
|
|
339
|
+
Delete a device from the tailnet.
|
|
340
|
+
|
|
341
|
+
Permanently removes a node from your tailnet. This action is destructive
|
|
342
|
+
and requires Human-in-the-Loop approval.
|
|
343
|
+
|
|
344
|
+
Parameters:
|
|
345
|
+
- device_id (str): The unique identifier (ID) of the device to remove.
|
|
346
|
+
|
|
347
|
+
Examples:
|
|
348
|
+
- Simple deletion:
|
|
349
|
+
`ts-proxy do delete-device '{"device_id": "12345"}'`
|
|
350
|
+
- Deletion with manual output save:
|
|
351
|
+
`ts-proxy do delete-device '{"device_id": "12345"}' -o audit.json`
|
|
352
|
+
- Quiet deletion (still requires HITL):
|
|
353
|
+
`ts-proxy do delete-device '{"device_id": "12345"}' > /dev/null`
|
|
354
|
+
"""
|
|
355
|
+
resp = await self._request("DELETE", f"/device/{payload.device_id}")
|
|
356
|
+
return {"status": resp.status_code, "detail": "Device deleted"}
|
|
357
|
+
|
|
358
|
+
@require_approval()
|
|
359
|
+
async def delete_invitation(
|
|
360
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
361
|
+
) -> Dict:
|
|
362
|
+
"""
|
|
363
|
+
Delete a tailnet invitation.
|
|
364
|
+
|
|
365
|
+
Revokes a pending invitation before it is accepted.
|
|
366
|
+
Requires Human-in-the-Loop approval.
|
|
367
|
+
|
|
368
|
+
Parameters:
|
|
369
|
+
- device_id (str): The ID of the invitation to delete.
|
|
370
|
+
|
|
371
|
+
Examples:
|
|
372
|
+
- Revoke invitation:
|
|
373
|
+
`ts-proxy do delete-invitation '{"device_id": "inv_123"}'`
|
|
374
|
+
- Revoke with rationale:
|
|
375
|
+
`ts-proxy do delete-invitation '{"device_id": "inv_123"}' --rationale "Mistake"`
|
|
376
|
+
- Bulk revoke from JSON:
|
|
377
|
+
`ts-proxy do delete-invitation ./revokes.json`
|
|
378
|
+
"""
|
|
379
|
+
resp = await self._request("DELETE", f"/user-invites/{payload.device_id}")
|
|
380
|
+
return {"status": resp.status_code, "detail": "Invitation deleted"}
|
|
381
|
+
|
|
382
|
+
@require_approval()
|
|
383
|
+
async def delete_posture_check(
|
|
384
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
385
|
+
) -> Dict:
|
|
386
|
+
"""
|
|
387
|
+
Delete a posture check.
|
|
388
|
+
|
|
389
|
+
Removes a security posture requirement from the tailnet.
|
|
390
|
+
Requires Human-in-the-Loop approval.
|
|
391
|
+
|
|
392
|
+
Parameters:
|
|
393
|
+
- device_id (str): The ID of the posture check to delete.
|
|
394
|
+
|
|
395
|
+
Examples:
|
|
396
|
+
- Remove check:
|
|
397
|
+
`ts-proxy do delete-posture-check '{"device_id": "post_123"}'`
|
|
398
|
+
- Remove with rationale:
|
|
399
|
+
`ts-proxy do delete-posture-check '{"device_id": "post_123"}' --rationale "Policy change"`
|
|
400
|
+
- Remove from file:
|
|
401
|
+
`ts-proxy do delete-posture-check ./obsolete_checks.json`
|
|
402
|
+
"""
|
|
403
|
+
resp = await self._request(
|
|
404
|
+
"DELETE", f"/tailnet/{self.auth.tailnet}/posture/{payload.device_id}"
|
|
405
|
+
)
|
|
406
|
+
return {"status": resp.status_code, "detail": "Posture check deleted"}
|
|
407
|
+
|
|
408
|
+
@require_approval()
|
|
409
|
+
async def delete_webhook(
|
|
410
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
411
|
+
) -> Dict:
|
|
412
|
+
"""
|
|
413
|
+
Delete a webhook.
|
|
414
|
+
|
|
415
|
+
Removes a webhook configuration from your tailnet.
|
|
416
|
+
|
|
417
|
+
Parameters:
|
|
418
|
+
- device_id (str): The ID of the webhook to delete.
|
|
419
|
+
|
|
420
|
+
Examples:
|
|
421
|
+
- Delete webhook:
|
|
422
|
+
`ts-proxy do delete-webhook '{"device_id": "wh123"}'`
|
|
423
|
+
- Delete with rationale:
|
|
424
|
+
`ts-proxy do delete-webhook '{"device_id": "wh123"}' --rationale "Endpoint rotation"`
|
|
425
|
+
- Delete via payload file:
|
|
426
|
+
`ts-proxy do delete-webhook ./old_webhook.json`
|
|
427
|
+
"""
|
|
428
|
+
resp = await self._request(
|
|
429
|
+
"DELETE", f"/tailnet/{self.auth.tailnet}/webhooks/{payload.device_id}"
|
|
430
|
+
)
|
|
431
|
+
return {"status": resp.status_code, "detail": "Webhook deleted"}
|
|
432
|
+
|
|
433
|
+
@require_approval()
|
|
434
|
+
async def expire_device(
|
|
435
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
436
|
+
) -> Dict:
|
|
437
|
+
"""
|
|
438
|
+
Expire a device's node key.
|
|
439
|
+
|
|
440
|
+
Forces a device to re-authenticate by expiring its current node key.
|
|
441
|
+
Requires Human-in-the-Loop approval.
|
|
442
|
+
|
|
443
|
+
Parameters:
|
|
444
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
445
|
+
|
|
446
|
+
Examples:
|
|
447
|
+
- Expire node:
|
|
448
|
+
`ts-proxy do expire-device '{"device_id": "node123"}'`
|
|
449
|
+
- Expire with rationale:
|
|
450
|
+
`ts-proxy do expire-device '{"device_id": "node123"}' --rationale "Security audit"`
|
|
451
|
+
- Expire from file:
|
|
452
|
+
`ts-proxy do expire-device ./expire_target.json`
|
|
453
|
+
"""
|
|
454
|
+
resp = await self._request("POST", f"/device/{payload.device_id}/expire")
|
|
455
|
+
return {"status": resp.status_code, "detail": "Device key expired"}
|
|
456
|
+
|
|
457
|
+
async def get_acl(self) -> str:
|
|
458
|
+
"""
|
|
459
|
+
Retrieve the current Access Control List (ACL).
|
|
460
|
+
|
|
461
|
+
Fetches the HuJSON representation of your tailnet's security policy.
|
|
462
|
+
|
|
463
|
+
Parameters:
|
|
464
|
+
- None
|
|
465
|
+
|
|
466
|
+
Examples:
|
|
467
|
+
- Fetch and view:
|
|
468
|
+
`ts-proxy do get-acl`
|
|
469
|
+
- Save to file for editing:
|
|
470
|
+
`ts-proxy do get-acl -o policy.hujson`
|
|
471
|
+
- Pipe to parser:
|
|
472
|
+
`ts-proxy do get-acl | jq .`
|
|
473
|
+
"""
|
|
474
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/acl")
|
|
475
|
+
return resp.text
|
|
476
|
+
|
|
477
|
+
async def get_contacts(self) -> Dict:
|
|
478
|
+
"""
|
|
479
|
+
Retrieve tailnet contact information.
|
|
480
|
+
|
|
481
|
+
Fetches the technical, billing, support, and security contacts for the tailnet.
|
|
482
|
+
|
|
483
|
+
Parameters:
|
|
484
|
+
- None
|
|
485
|
+
|
|
486
|
+
Examples:
|
|
487
|
+
- View all contacts:
|
|
488
|
+
`ts-proxy do get-contacts`
|
|
489
|
+
- Extract support contact:
|
|
490
|
+
`ts-proxy do get-contacts | jq '.support'`
|
|
491
|
+
- Save contacts to JSON:
|
|
492
|
+
`ts-proxy do get-contacts -o contacts.json`
|
|
493
|
+
"""
|
|
494
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/contacts")
|
|
495
|
+
return resp.json().get("contacts", {})
|
|
496
|
+
|
|
497
|
+
async def get_device(self, payload: DeviceIDPayload) -> Dict:
|
|
498
|
+
"""
|
|
499
|
+
Retrieve details for a specific device.
|
|
500
|
+
|
|
501
|
+
Fetches full metadata for a single device, including its capabilities,
|
|
502
|
+
last seen timestamp, and assigned tags.
|
|
503
|
+
|
|
504
|
+
Parameters:
|
|
505
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
506
|
+
|
|
507
|
+
Examples:
|
|
508
|
+
- Get by ID:
|
|
509
|
+
`ts-proxy do get-device '{"device_id": "12345"}'`
|
|
510
|
+
- Using a payload file:
|
|
511
|
+
`ts-proxy do get-device ./device_query.json`
|
|
512
|
+
- View specific attribute:
|
|
513
|
+
`ts-proxy do get-device '{"device_id": "12345"}' | jq '.hostname'`
|
|
514
|
+
"""
|
|
515
|
+
resp = await self._request("GET", f"/device/{payload.device_id}")
|
|
516
|
+
return resp.json()
|
|
517
|
+
|
|
518
|
+
async def get_dns_nameservers(self) -> List[str]:
|
|
519
|
+
"""
|
|
520
|
+
Retrieve global DNS nameservers.
|
|
521
|
+
|
|
522
|
+
Returns the list of DNS nameservers configured for the tailnet.
|
|
523
|
+
|
|
524
|
+
Parameters:
|
|
525
|
+
- None
|
|
526
|
+
|
|
527
|
+
Examples:
|
|
528
|
+
- List nameservers:
|
|
529
|
+
`ts-proxy do get-dns-nameservers`
|
|
530
|
+
- Save to file:
|
|
531
|
+
`ts-proxy do get-dns-nameservers -o nameservers.json`
|
|
532
|
+
- Count nameservers:
|
|
533
|
+
`ts-proxy do get-dns-nameservers | jq length`
|
|
534
|
+
"""
|
|
535
|
+
resp = await self._request(
|
|
536
|
+
"GET", f"/tailnet/{self.auth.tailnet}/dns/nameservers"
|
|
537
|
+
)
|
|
538
|
+
return resp.json().get("dns", [])
|
|
539
|
+
|
|
540
|
+
async def get_dns_preferences(self) -> Dict:
|
|
541
|
+
"""
|
|
542
|
+
Retrieve DNS preferences for the tailnet.
|
|
543
|
+
|
|
544
|
+
Fetches settings such as MagicDNS status.
|
|
545
|
+
|
|
546
|
+
Parameters:
|
|
547
|
+
- None
|
|
548
|
+
|
|
549
|
+
Examples:
|
|
550
|
+
- Check preferences:
|
|
551
|
+
`ts-proxy do get-dns-preferences`
|
|
552
|
+
- Check MagicDNS specifically:
|
|
553
|
+
`ts-proxy do get-dns-preferences | jq '.magicDNS'`
|
|
554
|
+
- Save preferences:
|
|
555
|
+
`ts-proxy do get-dns-preferences -o dns_prefs.json`
|
|
556
|
+
"""
|
|
557
|
+
resp = await self._request(
|
|
558
|
+
"GET", f"/tailnet/{self.auth.tailnet}/dns/preferences"
|
|
559
|
+
)
|
|
560
|
+
return resp.json()
|
|
561
|
+
|
|
562
|
+
async def get_invitation(self, payload: DeviceIDPayload) -> Dict:
|
|
563
|
+
"""
|
|
564
|
+
Retrieve details for a tailnet invitation.
|
|
565
|
+
|
|
566
|
+
Fetches metadata for a specific pending invitation.
|
|
567
|
+
|
|
568
|
+
Parameters:
|
|
569
|
+
- device_id (str): The ID of the invitation.
|
|
570
|
+
|
|
571
|
+
Examples:
|
|
572
|
+
- Get invitation details:
|
|
573
|
+
`ts-proxy do get-invitation '{"device_id": "inv_123"}'`
|
|
574
|
+
- Get details from file:
|
|
575
|
+
`ts-proxy do get-invitation ./inv_id.json`
|
|
576
|
+
- View invitation email:
|
|
577
|
+
`ts-proxy do get-invitation '{"device_id": "inv_123"}' | jq '.email'`
|
|
578
|
+
"""
|
|
579
|
+
resp = await self._request("GET", f"/user-invites/{payload.device_id}")
|
|
580
|
+
return resp.json()
|
|
581
|
+
|
|
582
|
+
async def get_posture_check(self, payload: DeviceIDPayload) -> Dict:
|
|
583
|
+
"""
|
|
584
|
+
Retrieve details for a posture check.
|
|
585
|
+
|
|
586
|
+
Fetches the configuration of a specific security posture check.
|
|
587
|
+
|
|
588
|
+
Parameters:
|
|
589
|
+
- device_id (str): The ID of the posture check.
|
|
590
|
+
|
|
591
|
+
Examples:
|
|
592
|
+
- View check config:
|
|
593
|
+
`ts-proxy do get-posture-check '{"device_id": "post_123"}'`
|
|
594
|
+
- View description:
|
|
595
|
+
`ts-proxy do get-posture-check '{"device_id": "post_123"}' | jq '.description'`
|
|
596
|
+
- Save to file:
|
|
597
|
+
`ts-proxy do get-posture-check '{"device_id": "post_123"}' -o check.json`
|
|
598
|
+
"""
|
|
599
|
+
resp = await self._request(
|
|
600
|
+
"GET", f"/tailnet/{self.auth.tailnet}/posture/{payload.device_id}"
|
|
601
|
+
)
|
|
602
|
+
return resp.json()
|
|
603
|
+
|
|
604
|
+
async def get_search_paths(self) -> List[str]:
|
|
605
|
+
"""
|
|
606
|
+
Retrieve DNS search paths.
|
|
607
|
+
|
|
608
|
+
Returns the search domains configured for the tailnet.
|
|
609
|
+
|
|
610
|
+
Parameters:
|
|
611
|
+
- None
|
|
612
|
+
|
|
613
|
+
Examples:
|
|
614
|
+
- List paths:
|
|
615
|
+
`ts-proxy do get-search-paths`
|
|
616
|
+
- Check specific path:
|
|
617
|
+
`ts-proxy do get-search-paths | grep "internal.lan"`
|
|
618
|
+
- Save paths:
|
|
619
|
+
`ts-proxy do get-search-paths -o search_paths.json`
|
|
620
|
+
"""
|
|
621
|
+
resp = await self._request(
|
|
622
|
+
"GET", f"/tailnet/{self.auth.tailnet}/dns/searchpaths"
|
|
623
|
+
)
|
|
624
|
+
return resp.json().get("searchPaths", [])
|
|
625
|
+
|
|
626
|
+
async def get_settings(self) -> Dict:
|
|
627
|
+
"""
|
|
628
|
+
Retrieve tailnet settings.
|
|
629
|
+
|
|
630
|
+
Fetches global configuration settings for the tailnet.
|
|
631
|
+
|
|
632
|
+
Parameters:
|
|
633
|
+
- None
|
|
634
|
+
|
|
635
|
+
Examples:
|
|
636
|
+
- View all settings:
|
|
637
|
+
`ts-proxy do get-settings`
|
|
638
|
+
- Extract specific setting:
|
|
639
|
+
`ts-proxy do get-settings | jq '.devices.expiry'`
|
|
640
|
+
- Save settings to file:
|
|
641
|
+
`ts-proxy do get-settings -o settings.json`
|
|
642
|
+
"""
|
|
643
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/settings")
|
|
644
|
+
return resp.json()
|
|
645
|
+
|
|
646
|
+
@require_approval()
|
|
647
|
+
async def rename_device(
|
|
648
|
+
self, payload: DeviceNamePayload, rationale: str = ""
|
|
649
|
+
) -> Dict:
|
|
650
|
+
"""
|
|
651
|
+
Rename a device.
|
|
652
|
+
|
|
653
|
+
Changes the hostname/display name of a device in the tailnet.
|
|
654
|
+
Requires Human-in-the-Loop approval.
|
|
655
|
+
|
|
656
|
+
Parameters:
|
|
657
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
658
|
+
- name (str): The new name for the device.
|
|
659
|
+
|
|
660
|
+
Examples:
|
|
661
|
+
- Rename device:
|
|
662
|
+
`ts-proxy do rename-device '{"device_id": "d123", "name": "KpihX-PVE"}'`
|
|
663
|
+
- Renaming with rationale:
|
|
664
|
+
`ts-proxy do rename-device '{"device_id": "d123", "name": "KpihX-PVE"}' --rationale "Standardizing names"`
|
|
665
|
+
"""
|
|
666
|
+
resp = await self._request(
|
|
667
|
+
"POST",
|
|
668
|
+
f"/device/{payload.device_id}/name",
|
|
669
|
+
json_data={"name": payload.name},
|
|
670
|
+
)
|
|
671
|
+
return {
|
|
672
|
+
"status": resp.status_code,
|
|
673
|
+
"detail": f"Device renamed to {payload.name}",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async def get_user(self, payload: UserIDPayload) -> Dict:
|
|
677
|
+
"""
|
|
678
|
+
Retrieve details for a specific user.
|
|
679
|
+
|
|
680
|
+
Fetches metadata for a single user in the tailnet.
|
|
681
|
+
|
|
682
|
+
Parameters:
|
|
683
|
+
- user_id (str): The unique identifier (ID) of the user.
|
|
684
|
+
|
|
685
|
+
Examples:
|
|
686
|
+
- View user info:
|
|
687
|
+
`ts-proxy do get-user '{"user_id": "u123"}'`
|
|
688
|
+
- View user role:
|
|
689
|
+
`ts-proxy do get-user '{"user_id": "u123"}' | jq '.role'`
|
|
690
|
+
- Get user by file payload:
|
|
691
|
+
`ts-proxy do get-user ./user_id.json`
|
|
692
|
+
"""
|
|
693
|
+
resp = await self._request("GET", f"/users/{payload.user_id}")
|
|
694
|
+
return resp.json()
|
|
695
|
+
|
|
696
|
+
async def get_webhook(self, payload: DeviceIDPayload) -> Dict:
|
|
697
|
+
"""
|
|
698
|
+
Retrieve details for a specific webhook.
|
|
699
|
+
|
|
700
|
+
Returns configuration and subscription details for a webhook endpoint.
|
|
701
|
+
|
|
702
|
+
Parameters:
|
|
703
|
+
- device_id (str): The ID of the webhook to retrieve.
|
|
704
|
+
|
|
705
|
+
Examples:
|
|
706
|
+
- Get webhook details:
|
|
707
|
+
`ts-proxy do get-webhook '{"device_id": "wh123"}'`
|
|
708
|
+
- Extract subscriptions:
|
|
709
|
+
`ts-proxy do get-webhook '{"device_id": "wh123"}' | jq '.subscriptions'`
|
|
710
|
+
- Get webhook by file:
|
|
711
|
+
`ts-proxy do get-webhook ./webhook_id.json`
|
|
712
|
+
"""
|
|
713
|
+
resp = await self._request("GET", f"/webhooks/{payload.device_id}")
|
|
714
|
+
return resp.json()
|
|
715
|
+
|
|
716
|
+
async def list_authkeys(self) -> List[Dict]:
|
|
717
|
+
"""
|
|
718
|
+
List all active auth keys.
|
|
719
|
+
|
|
720
|
+
Returns a list of all currently valid authentication keys.
|
|
721
|
+
|
|
722
|
+
Parameters:
|
|
723
|
+
- None
|
|
724
|
+
|
|
725
|
+
Examples:
|
|
726
|
+
- List keys:
|
|
727
|
+
`ts-proxy do list-authkeys`
|
|
728
|
+
- Save key list:
|
|
729
|
+
`ts-proxy do list-authkeys -o keys.json`
|
|
730
|
+
- Count active keys:
|
|
731
|
+
`ts-proxy do list-authkeys | jq length`
|
|
732
|
+
"""
|
|
733
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/keys")
|
|
734
|
+
return resp.json().get("keys", [])
|
|
735
|
+
|
|
736
|
+
async def list_invitations(self) -> List[Dict]:
|
|
737
|
+
"""
|
|
738
|
+
List all pending invitations.
|
|
739
|
+
|
|
740
|
+
Returns a list of all user invitations that have not yet been accepted.
|
|
741
|
+
|
|
742
|
+
Parameters:
|
|
743
|
+
- None
|
|
744
|
+
|
|
745
|
+
Examples:
|
|
746
|
+
- List invitations:
|
|
747
|
+
`ts-proxy do list-invitations`
|
|
748
|
+
- Save invitations to file:
|
|
749
|
+
`ts-proxy do list-invitations -o pending.json`
|
|
750
|
+
- Filter by email:
|
|
751
|
+
`ts-proxy do list-invitations | jq '.[] | select(.email == "user@test.com")'`
|
|
752
|
+
"""
|
|
753
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/user-invites")
|
|
754
|
+
return resp.json().get("invites", [])
|
|
755
|
+
|
|
756
|
+
async def list_posture_checks(self) -> List[Dict]:
|
|
757
|
+
"""
|
|
758
|
+
List all posture checks.
|
|
759
|
+
|
|
760
|
+
Returns a list of all security posture requirements configured for the tailnet.
|
|
761
|
+
|
|
762
|
+
Parameters:
|
|
763
|
+
- None
|
|
764
|
+
|
|
765
|
+
Examples:
|
|
766
|
+
- List all checks:
|
|
767
|
+
`ts-proxy do list-posture-checks`
|
|
768
|
+
- Filter by type:
|
|
769
|
+
`ts-proxy do list-posture-checks | jq '.[] | select(.type == "file")'`
|
|
770
|
+
- Count posture checks:
|
|
771
|
+
`ts-proxy do list-posture-checks | jq length`
|
|
772
|
+
"""
|
|
773
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/posture")
|
|
774
|
+
return resp.json().get("postureChecks", [])
|
|
775
|
+
|
|
776
|
+
async def list_devices(self) -> List[Dict]:
|
|
777
|
+
"""
|
|
778
|
+
List all devices in the tailnet.
|
|
779
|
+
|
|
780
|
+
Retrieves a comprehensive list of all devices (nodes) currently registered
|
|
781
|
+
in your tailnet, including their IP addresses, hostnames, and status.
|
|
782
|
+
|
|
783
|
+
Parameters:
|
|
784
|
+
- None
|
|
785
|
+
|
|
786
|
+
Examples:
|
|
787
|
+
- Basic List:
|
|
788
|
+
`ts-proxy do list-devices`
|
|
789
|
+
- Table view:
|
|
790
|
+
`ts-proxy do list-devices --format table`
|
|
791
|
+
- Filter online devices:
|
|
792
|
+
`ts-proxy do list-devices | jq '.[] | select(.online == true)'`
|
|
793
|
+
"""
|
|
794
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/devices")
|
|
795
|
+
return resp.json().get("devices", [])
|
|
796
|
+
|
|
797
|
+
async def list_webhooks(self) -> List[Dict]:
|
|
798
|
+
"""
|
|
799
|
+
List all configured webhooks.
|
|
800
|
+
|
|
801
|
+
Returns a list of all webhooks registered in the tailnet.
|
|
802
|
+
|
|
803
|
+
Parameters:
|
|
804
|
+
- None
|
|
805
|
+
|
|
806
|
+
Examples:
|
|
807
|
+
- List webhooks:
|
|
808
|
+
`ts-proxy do list-webhooks`
|
|
809
|
+
- Table view:
|
|
810
|
+
`ts-proxy do list-webhooks --format table`
|
|
811
|
+
- Count webhooks:
|
|
812
|
+
`ts-proxy do list-webhooks | jq length`
|
|
813
|
+
"""
|
|
814
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/webhooks")
|
|
815
|
+
return resp.json().get("webhooks", [])
|
|
816
|
+
|
|
817
|
+
async def list_users(self) -> List[Dict]:
|
|
818
|
+
"""
|
|
819
|
+
List all users in the tailnet.
|
|
820
|
+
|
|
821
|
+
Returns a list of all users registered in the tailnet, including their roles and status.
|
|
822
|
+
|
|
823
|
+
Parameters:
|
|
824
|
+
- None
|
|
825
|
+
|
|
826
|
+
Examples:
|
|
827
|
+
- List all users:
|
|
828
|
+
`ts-proxy do list-users`
|
|
829
|
+
- Table view:
|
|
830
|
+
`ts-proxy do list-users --format table`
|
|
831
|
+
- Filter admins:
|
|
832
|
+
`ts-proxy do list-users | jq '.[] | select(.role == "admin")'`
|
|
833
|
+
"""
|
|
834
|
+
resp = await self._request("GET", f"/tailnet/{self.auth.tailnet}/users")
|
|
835
|
+
return resp.json().get("users", [])
|
|
836
|
+
|
|
837
|
+
@require_approval()
|
|
838
|
+
async def restore_user(self, payload: UserIDPayload, rationale: str = "") -> Dict:
|
|
839
|
+
"""
|
|
840
|
+
Restore a suspended user.
|
|
841
|
+
|
|
842
|
+
Re-activates a previously suspended user account.
|
|
843
|
+
Requires Human-in-the-Loop approval.
|
|
844
|
+
|
|
845
|
+
Parameters:
|
|
846
|
+
- user_id (str): The unique identifier (ID) of the user.
|
|
847
|
+
|
|
848
|
+
Examples:
|
|
849
|
+
- Restore user:
|
|
850
|
+
`ts-proxy do restore_user '{"user_id": "u123"}'`
|
|
851
|
+
- Restore with rationale:
|
|
852
|
+
`ts-proxy do restore_user '{"user_id": "u123"}' --rationale "Review complete"`
|
|
853
|
+
- Restore from file:
|
|
854
|
+
`ts-proxy do restore_user ./user_to_restore.json`
|
|
855
|
+
"""
|
|
856
|
+
resp = await self._request("POST", f"/users/{payload.user_id}/restore")
|
|
857
|
+
return {"status": resp.status_code, "detail": "User restored"}
|
|
858
|
+
|
|
859
|
+
@require_approval()
|
|
860
|
+
async def rotate_webhook_secret(
|
|
861
|
+
self, payload: DeviceIDPayload, rationale: str = ""
|
|
862
|
+
) -> Dict:
|
|
863
|
+
"""
|
|
864
|
+
Rotate the shared secret for a webhook.
|
|
865
|
+
|
|
866
|
+
Generates a new shared secret for the specified webhook endpoint.
|
|
867
|
+
Requires Human-in-the-Loop approval.
|
|
868
|
+
|
|
869
|
+
Parameters:
|
|
870
|
+
- device_id (str): The ID of the webhook to rotate.
|
|
871
|
+
|
|
872
|
+
Examples:
|
|
873
|
+
- Rotate secret:
|
|
874
|
+
`ts-proxy do rotate-webhook-secret '{"device_id": "wh123"}' --rationale "Scheduled rotation"`
|
|
875
|
+
- Force rotation:
|
|
876
|
+
`ts-proxy do rotate-webhook-secret ./webhook.json -r "Suspected leak"`
|
|
877
|
+
- Quiet rotation:
|
|
878
|
+
`ts-proxy do rotate-webhook-secret '{"device_id": "wh123"}' | jq '.secret'`
|
|
879
|
+
"""
|
|
880
|
+
resp = await self._request("POST", f"/webhooks/{payload.device_id}/rotate")
|
|
881
|
+
return resp.json()
|
|
882
|
+
|
|
883
|
+
async def set_device_key_expiry(
|
|
884
|
+
self, payload: DeviceKeyExpiryPayload, rationale: str = ""
|
|
885
|
+
) -> Dict:
|
|
886
|
+
"""
|
|
887
|
+
Disable/Enable node key expiry for a device.
|
|
888
|
+
|
|
889
|
+
Configures whether a device's node key is allowed to expire.
|
|
890
|
+
Requires Human-in-the-Loop approval.
|
|
891
|
+
|
|
892
|
+
Parameters:
|
|
893
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
894
|
+
- keyExpiryDisabled (bool): Whether to disable expiry.
|
|
895
|
+
|
|
896
|
+
Examples:
|
|
897
|
+
- Disable expiry:
|
|
898
|
+
`ts-proxy do set-device-key-expiry '{"device_id": "n123", "keyExpiryDisabled": true}'`
|
|
899
|
+
- Enable expiry:
|
|
900
|
+
`ts-proxy do set-device-key-expiry '{"device_id": "n123", "keyExpiryDisabled": false}'`
|
|
901
|
+
- Disable with rationale:
|
|
902
|
+
`ts-proxy do set-device-key-expiry '{"device_id": "n123", "keyExpiryDisabled": true}' --rationale "Long-lived server"`
|
|
903
|
+
"""
|
|
904
|
+
resp = await self._request(
|
|
905
|
+
"POST",
|
|
906
|
+
f"/device/{payload.device_id}/key",
|
|
907
|
+
json_data={"keyExpiryDisabled": payload.keyExpiryDisabled},
|
|
908
|
+
)
|
|
909
|
+
return {"status": resp.status_code, "detail": "Device key expiry updated"}
|
|
910
|
+
|
|
911
|
+
@require_approval()
|
|
912
|
+
async def set_subnet_routes(
|
|
913
|
+
self, payload: SubnetRoutesPayload, rationale: str = ""
|
|
914
|
+
) -> Dict:
|
|
915
|
+
"""
|
|
916
|
+
Configure subnet routes for a device.
|
|
917
|
+
|
|
918
|
+
Enables or disables specific CIDR ranges that this device is allowed to
|
|
919
|
+
route for the tailnet.
|
|
920
|
+
|
|
921
|
+
Parameters:
|
|
922
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
923
|
+
- routes (List[str]): List of CIDR strings (e.g., ["10.0.0.0/24"]).
|
|
924
|
+
|
|
925
|
+
Examples:
|
|
926
|
+
- Enable route:
|
|
927
|
+
`ts-proxy do set-subnet-routes '{"device_id": "123", "routes": ["192.168.1.0/24"]}'`
|
|
928
|
+
- Disable all routes:
|
|
929
|
+
`ts-proxy do set-subnet-routes '{"device_id": "123", "routes": []}'`
|
|
930
|
+
- Set multiple routes:
|
|
931
|
+
`ts-proxy do set-subnet-routes '{"device_id": "123", "routes": ["10.0.0.0/24", "172.16.0.0/16"]}'`
|
|
932
|
+
"""
|
|
933
|
+
resp = await self._request(
|
|
934
|
+
"POST",
|
|
935
|
+
f"/device/{payload.device_id}/routes",
|
|
936
|
+
json_data={"routes": payload.routes},
|
|
937
|
+
)
|
|
938
|
+
return {"status": resp.status_code, "detail": "Subnet routes updated"}
|
|
939
|
+
|
|
940
|
+
@require_approval()
|
|
941
|
+
async def suspend_user(self, payload: UserIDPayload, rationale: str = "") -> Dict:
|
|
942
|
+
"""
|
|
943
|
+
Suspend a user account.
|
|
944
|
+
|
|
945
|
+
De-activates a user account, preventing them from accessing the tailnet.
|
|
946
|
+
Requires Human-in-the-Loop approval.
|
|
947
|
+
|
|
948
|
+
Parameters:
|
|
949
|
+
- user_id (str): The unique identifier (ID) of the user.
|
|
950
|
+
|
|
951
|
+
Examples:
|
|
952
|
+
- Suspend user:
|
|
953
|
+
`ts-proxy do suspend_user '{"user_id": "u123"}'`
|
|
954
|
+
- Suspend with rationale:
|
|
955
|
+
`ts-proxy do suspend_user '{"user_id": "u123"}' --rationale "Employee offboarding"`
|
|
956
|
+
- Suspend from file:
|
|
957
|
+
`ts-proxy do suspend_user ./suspend_target.json`
|
|
958
|
+
"""
|
|
959
|
+
resp = await self._request("POST", f"/users/{payload.user_id}/suspend")
|
|
960
|
+
return {"status": resp.status_code, "detail": "User suspended"}
|
|
961
|
+
|
|
962
|
+
async def test_webhook(self, payload: DeviceIDPayload) -> Dict:
|
|
963
|
+
"""
|
|
964
|
+
Send a test event to a webhook.
|
|
965
|
+
|
|
966
|
+
Triggers a test notification to verify the webhook endpoint connectivity.
|
|
967
|
+
|
|
968
|
+
Parameters:
|
|
969
|
+
- device_id (str): The ID of the webhook to test.
|
|
970
|
+
|
|
971
|
+
Examples:
|
|
972
|
+
- Send test event:
|
|
973
|
+
`ts-proxy do test-webhook '{"device_id": "wh123"}'`
|
|
974
|
+
- Verify response:
|
|
975
|
+
`ts-proxy do test-webhook '{"device_id": "wh123"}' | jq '.status'`
|
|
976
|
+
- Test from file:
|
|
977
|
+
`ts-proxy do test-webhook ./webhook.json`
|
|
978
|
+
"""
|
|
979
|
+
resp = await self._request("POST", f"/webhooks/{payload.device_id}/test")
|
|
980
|
+
return {"status": resp.status_code, "detail": "Test event sent"}
|
|
981
|
+
|
|
982
|
+
@require_approval()
|
|
983
|
+
async def update_acl(
|
|
984
|
+
self,
|
|
985
|
+
payload: ACLUpdatePayload,
|
|
986
|
+
rationale: str = "",
|
|
987
|
+
original_payload: Optional[Dict] = None,
|
|
988
|
+
) -> bool:
|
|
989
|
+
"""
|
|
990
|
+
Update the tailnet Access Control List (ACL).
|
|
991
|
+
|
|
992
|
+
Uploads a new security policy in HuJSON format. This action is critical
|
|
993
|
+
and requires Human-in-the-Loop approval.
|
|
994
|
+
|
|
995
|
+
Parameters:
|
|
996
|
+
- hujson_payload (str): The raw HuJSON text content of the policy.
|
|
997
|
+
|
|
998
|
+
Examples:
|
|
999
|
+
- Update from file:
|
|
1000
|
+
`ts-proxy do update-acl ./policy.hujson`
|
|
1001
|
+
- Update with specific rationale:
|
|
1002
|
+
`ts-proxy do update-acl ./policy.hujson --rationale "Restricting IoT tags"`
|
|
1003
|
+
- Inline update (not recommended for large policies):
|
|
1004
|
+
`ts-proxy do update-acl '{"hujson_payload": "// comment\n{...}"}'`
|
|
1005
|
+
"""
|
|
1006
|
+
headers = {
|
|
1007
|
+
"Authorization": f"Bearer {await self.auth.get_access_token()}",
|
|
1008
|
+
"Content-Type": "application/hujson",
|
|
1009
|
+
}
|
|
1010
|
+
async with httpx.AsyncClient() as client:
|
|
1011
|
+
url = f"{self.BASE_URL}/tailnet/{self.auth.tailnet}/acl"
|
|
1012
|
+
resp = await client.post(
|
|
1013
|
+
url, headers=headers, content=payload.hujson_payload
|
|
1014
|
+
)
|
|
1015
|
+
if resp.status_code >= 400:
|
|
1016
|
+
raise SecureProxyError(f"Failed to update ACL: {resp.text}")
|
|
1017
|
+
return {"status": resp.status_code, "detail": "ACL updated successfully"}
|
|
1018
|
+
|
|
1019
|
+
@require_approval()
|
|
1020
|
+
async def update_contacts(
|
|
1021
|
+
self, payload: ContactPayload, rationale: str = ""
|
|
1022
|
+
) -> Dict:
|
|
1023
|
+
"""
|
|
1024
|
+
Update tailnet contact information.
|
|
1025
|
+
|
|
1026
|
+
Modifies the technical, billing, support, or security contacts for the tailnet.
|
|
1027
|
+
Requires Human-in-the-Loop approval.
|
|
1028
|
+
|
|
1029
|
+
Parameters:
|
|
1030
|
+
- account (Dict): Account contact info.
|
|
1031
|
+
- support (Dict): Support contact info.
|
|
1032
|
+
- security (Dict): Security contact info.
|
|
1033
|
+
|
|
1034
|
+
Examples:
|
|
1035
|
+
- Update support email:
|
|
1036
|
+
`ts-proxy do update-contacts '{"support": {"email": "help@corp.com"}}'`
|
|
1037
|
+
- Update security contact:
|
|
1038
|
+
`ts-proxy do update-contacts '{"security": {"email": "soc@corp.com", "phone": "+123"}}'`
|
|
1039
|
+
- Bulk update from file:
|
|
1040
|
+
`ts-proxy do update-contacts ./contacts_update.json`
|
|
1041
|
+
"""
|
|
1042
|
+
resp = await self._request(
|
|
1043
|
+
"PATCH",
|
|
1044
|
+
f"/tailnet/{self.auth.tailnet}/contacts",
|
|
1045
|
+
json_data=payload.model_dump(exclude_none=True),
|
|
1046
|
+
)
|
|
1047
|
+
return {"status": resp.status_code, "detail": "Contacts updated"}
|
|
1048
|
+
|
|
1049
|
+
@require_approval()
|
|
1050
|
+
async def update_device(
|
|
1051
|
+
self,
|
|
1052
|
+
payload: DeviceUpdatePayload,
|
|
1053
|
+
rationale: str = "",
|
|
1054
|
+
original_payload: Optional[Dict] = None,
|
|
1055
|
+
) -> Dict:
|
|
1056
|
+
"""
|
|
1057
|
+
Update device attributes.
|
|
1058
|
+
|
|
1059
|
+
Modifies tags and other properties of an existing device.
|
|
1060
|
+
Requires Human-in-the-Loop approval.
|
|
1061
|
+
|
|
1062
|
+
Parameters:
|
|
1063
|
+
- device_id (str): The unique identifier (ID) of the device.
|
|
1064
|
+
- tags (List[str]): List of tags to assign (e.g., ["tag:prod"]).
|
|
1065
|
+
|
|
1066
|
+
Examples:
|
|
1067
|
+
- Update tags:
|
|
1068
|
+
`ts-proxy do update-device '{"device_id": "123", "tags": ["tag:prod", "tag:web"]}'`
|
|
1069
|
+
- Clear tags:
|
|
1070
|
+
`ts-proxy do update-device '{"device_id": "123", "tags": []}'`
|
|
1071
|
+
- Update via file:
|
|
1072
|
+
`ts-proxy do update-device ./device_patch.json`
|
|
1073
|
+
"""
|
|
1074
|
+
resp = await self._request(
|
|
1075
|
+
"POST",
|
|
1076
|
+
f"/device/{payload.device_id}/attributes",
|
|
1077
|
+
json_data={"tags": payload.tags},
|
|
1078
|
+
)
|
|
1079
|
+
return {"status": resp.status_code, "detail": "Device updated"}
|
|
1080
|
+
|
|
1081
|
+
@require_approval()
|
|
1082
|
+
async def update_dns_nameservers(
|
|
1083
|
+
self, payload: NameserversPayload, rationale: str = ""
|
|
1084
|
+
) -> Dict:
|
|
1085
|
+
"""
|
|
1086
|
+
Update global DNS nameservers.
|
|
1087
|
+
|
|
1088
|
+
Configures the DNS nameservers used by all nodes in the tailnet.
|
|
1089
|
+
Requires Human-in-the-Loop approval.
|
|
1090
|
+
|
|
1091
|
+
Parameters:
|
|
1092
|
+
- nameservers (List[str]): List of nameserver IPs.
|
|
1093
|
+
|
|
1094
|
+
Examples:
|
|
1095
|
+
- Set Cloudflare DNS:
|
|
1096
|
+
`ts-proxy do update-dns-nameservers '{"nameservers": ["1.1.1.1", "1.0.0.1"]}'`
|
|
1097
|
+
- Set Google DNS:
|
|
1098
|
+
`ts-proxy do update-dns-nameservers '{"nameservers": ["8.8.8.8", "8.8.4.4"]}'`
|
|
1099
|
+
- Update with rationale:
|
|
1100
|
+
`ts-proxy do update-dns-nameservers ./dns.json --rationale "New DNS provider"`
|
|
1101
|
+
"""
|
|
1102
|
+
resp = await self._request(
|
|
1103
|
+
"POST",
|
|
1104
|
+
f"/tailnet/{self.auth.tailnet}/dns/nameservers",
|
|
1105
|
+
json_data={"dns": payload.nameservers},
|
|
1106
|
+
)
|
|
1107
|
+
return {"status": resp.status_code, "detail": "DNS nameservers updated"}
|
|
1108
|
+
|
|
1109
|
+
@require_approval()
|
|
1110
|
+
async def update_dns_preferences(
|
|
1111
|
+
self, payload: DNSPreferencesPayload, rationale: str = ""
|
|
1112
|
+
) -> Dict:
|
|
1113
|
+
"""
|
|
1114
|
+
Update DNS preferences.
|
|
1115
|
+
|
|
1116
|
+
Changes global DNS behavior for your tailnet, such as MagicDNS.
|
|
1117
|
+
Requires Human-in-the-Loop approval.
|
|
1118
|
+
|
|
1119
|
+
Parameters:
|
|
1120
|
+
- magicDNS (bool): Whether to enable MagicDNS.
|
|
1121
|
+
|
|
1122
|
+
Examples:
|
|
1123
|
+
- Enable MagicDNS:
|
|
1124
|
+
`ts-proxy do update-dns-preferences '{"magicDNS": true}'`
|
|
1125
|
+
- Disable MagicDNS:
|
|
1126
|
+
`ts-proxy do update-dns-preferences '{"magicDNS": false}'`
|
|
1127
|
+
- Set with rationale:
|
|
1128
|
+
`ts-proxy do update-dns-preferences '{"magicDNS": true}' --rationale "Internal rollout"`
|
|
1129
|
+
"""
|
|
1130
|
+
resp = await self._request(
|
|
1131
|
+
"POST",
|
|
1132
|
+
f"/tailnet/{self.auth.tailnet}/dns/preferences",
|
|
1133
|
+
json_data=payload.model_dump(),
|
|
1134
|
+
)
|
|
1135
|
+
return {"status": resp.status_code, "detail": "DNS preferences updated"}
|
|
1136
|
+
|
|
1137
|
+
@require_approval()
|
|
1138
|
+
async def update_posture_check(
|
|
1139
|
+
self, payload: PostureCheckPayload, rationale: str = ""
|
|
1140
|
+
) -> Dict:
|
|
1141
|
+
"""
|
|
1142
|
+
Update an existing posture check.
|
|
1143
|
+
|
|
1144
|
+
Modifies the configuration of a security posture requirement.
|
|
1145
|
+
Requires Human-in-the-Loop approval.
|
|
1146
|
+
|
|
1147
|
+
Parameters:
|
|
1148
|
+
- id (str): The ID of the posture check.
|
|
1149
|
+
- type (str): Type of posture check.
|
|
1150
|
+
- description (str): Description.
|
|
1151
|
+
- value (Any): Value.
|
|
1152
|
+
|
|
1153
|
+
Examples:
|
|
1154
|
+
- Update check description:
|
|
1155
|
+
`ts-proxy do update-posture-check '{"id": "post123", "description": "New desc"}'`
|
|
1156
|
+
- Update file path:
|
|
1157
|
+
`ts-proxy do update-posture-check '{"id": "post123", "value": {"path": "/tmp/new"}}'`
|
|
1158
|
+
- Update via file:
|
|
1159
|
+
`ts-proxy do update-posture-check ./check_patch.json`
|
|
1160
|
+
"""
|
|
1161
|
+
if not payload.id:
|
|
1162
|
+
raise SecureProxyError("Posture check ID is required for updates.")
|
|
1163
|
+
resp = await self._request(
|
|
1164
|
+
"PATCH",
|
|
1165
|
+
f"/tailnet/{self.auth.tailnet}/posture/{payload.id}",
|
|
1166
|
+
json_data=payload.model_dump(exclude_none=True, exclude={"id"}),
|
|
1167
|
+
)
|
|
1168
|
+
return resp.json()
|
|
1169
|
+
|
|
1170
|
+
@require_approval()
|
|
1171
|
+
async def update_search_paths(
|
|
1172
|
+
self, payload: SearchPathsPayload, rationale: str = ""
|
|
1173
|
+
) -> Dict:
|
|
1174
|
+
"""
|
|
1175
|
+
Update DNS search paths.
|
|
1176
|
+
|
|
1177
|
+
Configures the list of search domains for nodes in the tailnet.
|
|
1178
|
+
Requires Human-in-the-Loop approval.
|
|
1179
|
+
|
|
1180
|
+
Parameters:
|
|
1181
|
+
- paths (List[str]): List of DNS search paths.
|
|
1182
|
+
|
|
1183
|
+
Examples:
|
|
1184
|
+
- Set search paths:
|
|
1185
|
+
`ts-proxy do update-search-paths '{"paths": ["internal.lan"]}'`
|
|
1186
|
+
- Set multiple paths:
|
|
1187
|
+
`ts-proxy do update-search-paths '{"paths": ["a.lan", "b.lan"]}'`
|
|
1188
|
+
- Update via file:
|
|
1189
|
+
`ts-proxy do update-search-paths ./paths.json`
|
|
1190
|
+
"""
|
|
1191
|
+
resp = await self._request(
|
|
1192
|
+
"POST",
|
|
1193
|
+
f"/tailnet/{self.auth.tailnet}/dns/searchpaths",
|
|
1194
|
+
json_data={"searchPaths": payload.paths},
|
|
1195
|
+
)
|
|
1196
|
+
return {"status": resp.status_code, "detail": "DNS search paths updated"}
|
|
1197
|
+
|
|
1198
|
+
@require_approval()
|
|
1199
|
+
async def update_settings(
|
|
1200
|
+
self, payload: SettingsPayload, rationale: str = ""
|
|
1201
|
+
) -> Dict:
|
|
1202
|
+
"""
|
|
1203
|
+
Update tailnet settings.
|
|
1204
|
+
|
|
1205
|
+
Modifies global configuration settings for the tailnet.
|
|
1206
|
+
Requires Human-in-the-Loop approval.
|
|
1207
|
+
|
|
1208
|
+
Parameters:
|
|
1209
|
+
- payload (Dict): Dictionary of settings to update.
|
|
1210
|
+
|
|
1211
|
+
Examples:
|
|
1212
|
+
- Disable node key expiry globally:
|
|
1213
|
+
`ts-proxy do update-settings '{"devices": {"expiry": "disabled"}}'`
|
|
1214
|
+
- Enable MagicDNS:
|
|
1215
|
+
`ts-proxy do update-settings '{"dns": {"magicDNS": true}}'`
|
|
1216
|
+
- Bulk update via file:
|
|
1217
|
+
`ts-proxy do update-settings ./settings_patch.json`
|
|
1218
|
+
"""
|
|
1219
|
+
resp = await self._request(
|
|
1220
|
+
"PATCH",
|
|
1221
|
+
f"/tailnet/{self.auth.tailnet}/settings",
|
|
1222
|
+
json_data=payload.model_dump(),
|
|
1223
|
+
)
|
|
1224
|
+
return {"status": resp.status_code, "detail": "Settings updated"}
|
|
1225
|
+
|
|
1226
|
+
@require_approval()
|
|
1227
|
+
async def update_user_role(
|
|
1228
|
+
self, payload: UserRolePayload, rationale: str = ""
|
|
1229
|
+
) -> Dict:
|
|
1230
|
+
"""
|
|
1231
|
+
Update a user's role.
|
|
1232
|
+
|
|
1233
|
+
Changes the role (e.g., admin, member) for a specific user.
|
|
1234
|
+
Requires Human-in-the-Loop approval.
|
|
1235
|
+
|
|
1236
|
+
Parameters:
|
|
1237
|
+
- user_id (str): The unique identifier (ID) of the user.
|
|
1238
|
+
- role (str): The new role.
|
|
1239
|
+
|
|
1240
|
+
Examples:
|
|
1241
|
+
- Promote to admin:
|
|
1242
|
+
`ts-proxy do update-user-role '{"user_id": "u123", "role": "admin"}'`
|
|
1243
|
+
- Demote to member:
|
|
1244
|
+
`ts-proxy do update-user-role '{"user_id": "u123", "role": "member"}'`
|
|
1245
|
+
- Promoting with rationale:
|
|
1246
|
+
`ts-proxy do update-user-role '{"user_id": "u123", "role": "admin"}' --rationale "New team lead"`
|
|
1247
|
+
"""
|
|
1248
|
+
resp = await self._request(
|
|
1249
|
+
"POST", f"/users/{payload.user_id}/role", json_data={"role": payload.role}
|
|
1250
|
+
)
|
|
1251
|
+
return {"status": resp.status_code, "detail": "User role updated"}
|