iflow-mcp_enuno-unifi-mcp-server 0.2.1__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.
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
- iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
- src/__init__.py +3 -0
- src/__main__.py +6 -0
- src/api/__init__.py +5 -0
- src/api/client.py +727 -0
- src/api/site_manager_client.py +176 -0
- src/cache.py +483 -0
- src/config/__init__.py +5 -0
- src/config/config.py +321 -0
- src/main.py +2234 -0
- src/models/__init__.py +126 -0
- src/models/acl.py +41 -0
- src/models/backup.py +272 -0
- src/models/client.py +74 -0
- src/models/device.py +53 -0
- src/models/dpi.py +50 -0
- src/models/firewall_policy.py +123 -0
- src/models/firewall_zone.py +28 -0
- src/models/network.py +62 -0
- src/models/qos_profile.py +458 -0
- src/models/radius.py +141 -0
- src/models/reference_data.py +34 -0
- src/models/site.py +59 -0
- src/models/site_manager.py +120 -0
- src/models/topology.py +138 -0
- src/models/traffic_flow.py +137 -0
- src/models/traffic_matching_list.py +56 -0
- src/models/voucher.py +42 -0
- src/models/vpn.py +73 -0
- src/models/wan.py +48 -0
- src/models/zbf_matrix.py +49 -0
- src/resources/__init__.py +8 -0
- src/resources/clients.py +111 -0
- src/resources/devices.py +102 -0
- src/resources/networks.py +93 -0
- src/resources/site_manager.py +64 -0
- src/resources/sites.py +86 -0
- src/tools/__init__.py +25 -0
- src/tools/acls.py +328 -0
- src/tools/application.py +42 -0
- src/tools/backups.py +1173 -0
- src/tools/client_management.py +505 -0
- src/tools/clients.py +203 -0
- src/tools/device_control.py +325 -0
- src/tools/devices.py +354 -0
- src/tools/dpi.py +241 -0
- src/tools/dpi_tools.py +89 -0
- src/tools/firewall.py +417 -0
- src/tools/firewall_policies.py +430 -0
- src/tools/firewall_zones.py +515 -0
- src/tools/network_config.py +388 -0
- src/tools/networks.py +190 -0
- src/tools/port_forwarding.py +263 -0
- src/tools/qos.py +1070 -0
- src/tools/radius.py +763 -0
- src/tools/reference_data.py +107 -0
- src/tools/site_manager.py +466 -0
- src/tools/site_vpn.py +95 -0
- src/tools/sites.py +187 -0
- src/tools/topology.py +406 -0
- src/tools/traffic_flows.py +1062 -0
- src/tools/traffic_matching_lists.py +371 -0
- src/tools/vouchers.py +249 -0
- src/tools/vpn.py +76 -0
- src/tools/wans.py +30 -0
- src/tools/wifi.py +498 -0
- src/tools/zbf_matrix.py +326 -0
- src/utils/__init__.py +88 -0
- src/utils/audit.py +213 -0
- src/utils/exceptions.py +114 -0
- src/utils/helpers.py +159 -0
- src/utils/logger.py +105 -0
- src/utils/sanitize.py +244 -0
- src/utils/validators.py +160 -0
- src/webhooks/__init__.py +6 -0
- src/webhooks/handlers.py +196 -0
- src/webhooks/receiver.py +290 -0
src/api/client.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""UniFi API client with authentication, rate limiting, and error handling."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from ..config import APIType, Settings
|
|
12
|
+
from ..utils import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
NetworkError,
|
|
16
|
+
RateLimitError,
|
|
17
|
+
ResourceNotFoundError,
|
|
18
|
+
get_logger,
|
|
19
|
+
log_api_request,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RateLimiter:
|
|
24
|
+
"""Token bucket rate limiter for API requests."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, requests_per_period: int, period_seconds: int) -> None:
|
|
27
|
+
"""Initialize rate limiter.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
requests_per_period: Maximum requests allowed in the period
|
|
31
|
+
period_seconds: Time period in seconds
|
|
32
|
+
"""
|
|
33
|
+
self.requests_per_period = requests_per_period
|
|
34
|
+
self.period_seconds = period_seconds
|
|
35
|
+
self.tokens: float = float(requests_per_period)
|
|
36
|
+
self.last_update = time.time()
|
|
37
|
+
self._lock = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
async def acquire(self) -> None:
|
|
40
|
+
"""Acquire a token, waiting if necessary."""
|
|
41
|
+
async with self._lock:
|
|
42
|
+
now = time.time()
|
|
43
|
+
time_passed = now - self.last_update
|
|
44
|
+
self.tokens = min(
|
|
45
|
+
self.requests_per_period,
|
|
46
|
+
self.tokens + (time_passed * self.requests_per_period / self.period_seconds),
|
|
47
|
+
)
|
|
48
|
+
self.last_update = now
|
|
49
|
+
|
|
50
|
+
if self.tokens < 1:
|
|
51
|
+
sleep_time = (1 - self.tokens) * self.period_seconds / self.requests_per_period
|
|
52
|
+
await asyncio.sleep(sleep_time)
|
|
53
|
+
self.tokens = 0
|
|
54
|
+
else:
|
|
55
|
+
self.tokens -= 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UniFiClient:
|
|
59
|
+
"""Async HTTP client for UniFi API with authentication and rate limiting."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, settings: Settings) -> None:
|
|
62
|
+
"""Initialize UniFi API client.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
settings: Application settings
|
|
66
|
+
"""
|
|
67
|
+
self.settings = settings
|
|
68
|
+
self.logger = get_logger(__name__, settings.log_level)
|
|
69
|
+
|
|
70
|
+
# Initialize HTTP client
|
|
71
|
+
# Note: We construct full URLs explicitly in _request() to ensure HTTPS is preserved
|
|
72
|
+
# Using base_url can cause protocol downgrade issues with httpx
|
|
73
|
+
self.client = httpx.AsyncClient(
|
|
74
|
+
headers=settings.get_headers(),
|
|
75
|
+
timeout=settings.request_timeout,
|
|
76
|
+
verify=settings.verify_ssl,
|
|
77
|
+
follow_redirects=False, # Prevent HTTP redirects that might downgrade protocol
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Initialize rate limiter
|
|
81
|
+
self.rate_limiter = RateLimiter(
|
|
82
|
+
settings.rate_limit_requests,
|
|
83
|
+
settings.rate_limit_period,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self._authenticated = False
|
|
87
|
+
self._site_id_cache: dict[str, str] = {}
|
|
88
|
+
# Cache for site UUID -> internalReference mapping (needed for local API)
|
|
89
|
+
self._site_uuid_to_name: dict[str, str] = {}
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self) -> "UniFiClient":
|
|
92
|
+
"""Async context manager entry."""
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
96
|
+
"""Async context manager exit."""
|
|
97
|
+
await self.close()
|
|
98
|
+
|
|
99
|
+
async def close(self) -> None:
|
|
100
|
+
"""Close the HTTP client."""
|
|
101
|
+
await self.client.aclose()
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def is_authenticated(self) -> bool:
|
|
105
|
+
"""Check if client is authenticated.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
True if authenticated, False otherwise
|
|
109
|
+
"""
|
|
110
|
+
return self._authenticated
|
|
111
|
+
|
|
112
|
+
def _translate_endpoint(self, endpoint: str) -> str:
|
|
113
|
+
"""Translate endpoints based on API type.
|
|
114
|
+
|
|
115
|
+
This method handles endpoint translation for different API modes:
|
|
116
|
+
- cloud-v1: Stable v1 API (no translation for /ea/ endpoints, maps to /v1/)
|
|
117
|
+
- cloud-ea: Early Access API (no translation needed)
|
|
118
|
+
- local: Gateway API (translates cloud format to local format)
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
endpoint: API endpoint (e.g., /ea/sites/{site_id}/devices or /v1/hosts)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Translated endpoint appropriate for the configured API type
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
Cloud EA: /ea/sites/default/devices -> /ea/sites/default/devices (unchanged)
|
|
128
|
+
Cloud V1: /v1/hosts -> /v1/hosts (unchanged)
|
|
129
|
+
Local: /ea/sites/default/devices -> /proxy/network/api/s/default/devices
|
|
130
|
+
Local: /ea/sites -> /proxy/network/integration/v1/sites (special case)
|
|
131
|
+
"""
|
|
132
|
+
if self.settings.api_type in (APIType.CLOUD_V1, APIType.CLOUD_EA):
|
|
133
|
+
# Cloud APIs - no translation needed
|
|
134
|
+
return endpoint
|
|
135
|
+
|
|
136
|
+
# Local API - translate cloud format to local format
|
|
137
|
+
import re
|
|
138
|
+
|
|
139
|
+
# Special case: /ea/sites (without site_id) -> Integration API
|
|
140
|
+
if endpoint == "/ea/sites":
|
|
141
|
+
return "/proxy/network/integration/v1/sites"
|
|
142
|
+
|
|
143
|
+
# Pattern: /ea/sites/{site_id}/{rest_of_path}
|
|
144
|
+
# Transform to: /proxy/network/api/s/{site_name}/{local_path}
|
|
145
|
+
# Note: Local API uses site names (e.g., 'default'), not UUIDs
|
|
146
|
+
# AND different endpoint paths than cloud API
|
|
147
|
+
match = re.match(r"^/ea/sites/([^/]+)/(.+)$", endpoint)
|
|
148
|
+
if match:
|
|
149
|
+
site_id, cloud_path = match.groups()
|
|
150
|
+
# Translate UUID to site name if we have the mapping
|
|
151
|
+
site_name = self._site_uuid_to_name.get(site_id, site_id)
|
|
152
|
+
if site_id != site_name:
|
|
153
|
+
self.logger.debug(f"Translated site ID: {site_id} -> {site_name}")
|
|
154
|
+
|
|
155
|
+
# Map cloud API paths to local API paths
|
|
156
|
+
# Cloud API uses different endpoint naming than local API
|
|
157
|
+
path_mapping = {
|
|
158
|
+
"devices": "stat/device",
|
|
159
|
+
"sta": "stat/sta", # clients
|
|
160
|
+
"rest/networkconf": "rest/networkconf", # VLANs/networks
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
local_path = path_mapping.get(cloud_path, cloud_path)
|
|
164
|
+
if cloud_path != local_path:
|
|
165
|
+
self.logger.debug(f"Translated path: {cloud_path} -> {local_path}")
|
|
166
|
+
|
|
167
|
+
return f"/proxy/network/api/s/{site_name}/{local_path}"
|
|
168
|
+
|
|
169
|
+
# Pattern: /ea/sites/{site_id} (no trailing path)
|
|
170
|
+
match = re.match(r"^/ea/sites/([^/]+)$", endpoint)
|
|
171
|
+
if match:
|
|
172
|
+
site_id = match.group(1)
|
|
173
|
+
# Translate UUID to site name if we have the mapping
|
|
174
|
+
site_name = self._site_uuid_to_name.get(site_id, site_id)
|
|
175
|
+
if site_id != site_name:
|
|
176
|
+
self.logger.debug(f"Translated site ID: {site_id} -> {site_name}")
|
|
177
|
+
return f"/proxy/network/api/s/{site_name}/self"
|
|
178
|
+
|
|
179
|
+
# If no pattern matches, check if it's already a local endpoint
|
|
180
|
+
if endpoint.startswith("/proxy/network/"):
|
|
181
|
+
return endpoint
|
|
182
|
+
|
|
183
|
+
# If not recognized, return as-is and log warning
|
|
184
|
+
self.logger.warning(f"Endpoint does not match known patterns: {endpoint}")
|
|
185
|
+
return endpoint
|
|
186
|
+
|
|
187
|
+
async def authenticate(self) -> None:
|
|
188
|
+
"""Authenticate with the UniFi API.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
AuthenticationError: If authentication fails
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
# Test authentication with a simple API call
|
|
195
|
+
# Use appropriate endpoint based on API type
|
|
196
|
+
if self.settings.api_type == APIType.CLOUD_V1:
|
|
197
|
+
test_endpoint = "/v1/hosts" # V1 stable API
|
|
198
|
+
else:
|
|
199
|
+
test_endpoint = "/ea/sites" # EA API or local (will be auto-translated)
|
|
200
|
+
|
|
201
|
+
response = await self._request("GET", test_endpoint)
|
|
202
|
+
|
|
203
|
+
# Handle both dict and list responses
|
|
204
|
+
# Local API (after normalization) returns list directly
|
|
205
|
+
# Cloud API returns dict with "meta", "data", etc.
|
|
206
|
+
if isinstance(response, list):
|
|
207
|
+
# List response means we got data successfully (local API)
|
|
208
|
+
self._authenticated = True
|
|
209
|
+
# Build site UUID -> name mapping for local API
|
|
210
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
211
|
+
self._build_site_uuid_map(response)
|
|
212
|
+
elif isinstance(response, dict):
|
|
213
|
+
# Dict response - check for success indicators
|
|
214
|
+
self._authenticated = (
|
|
215
|
+
response.get("meta", {}).get("rc") == "ok"
|
|
216
|
+
or response.get("data") is not None
|
|
217
|
+
or response.get("count") is not None
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
self._authenticated = False
|
|
221
|
+
|
|
222
|
+
self.logger.info(
|
|
223
|
+
f"Successfully authenticated with UniFi API (response type: {type(response).__name__})"
|
|
224
|
+
)
|
|
225
|
+
except Exception as e:
|
|
226
|
+
self.logger.error(f"Authentication failed: {e}")
|
|
227
|
+
raise AuthenticationError(f"Failed to authenticate with UniFi API: {e}") from e
|
|
228
|
+
|
|
229
|
+
def _build_site_uuid_map(self, sites: list[dict[str, Any]]) -> None:
|
|
230
|
+
"""Build a mapping of site UUIDs to internal reference names.
|
|
231
|
+
|
|
232
|
+
This is required for local API, which uses site names (e.g., 'default')
|
|
233
|
+
instead of UUIDs in endpoint paths.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
sites: List of site objects from /ea/sites endpoint
|
|
237
|
+
"""
|
|
238
|
+
self._site_uuid_to_name.clear()
|
|
239
|
+
for site in sites:
|
|
240
|
+
site_id = site.get("id")
|
|
241
|
+
internal_ref = site.get("internalReference")
|
|
242
|
+
if site_id and internal_ref:
|
|
243
|
+
self._site_uuid_to_name[site_id] = internal_ref
|
|
244
|
+
|
|
245
|
+
self.logger.info(f"Built site UUID mapping: {len(self._site_uuid_to_name)} sites")
|
|
246
|
+
|
|
247
|
+
async def _request(
|
|
248
|
+
self,
|
|
249
|
+
method: str,
|
|
250
|
+
endpoint: str,
|
|
251
|
+
params: dict[str, Any] | None = None,
|
|
252
|
+
json_data: dict[str, Any] | None = None,
|
|
253
|
+
retry_count: int = 0,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Make an HTTP request with retries and error handling.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
method: HTTP method (GET, POST, PUT, DELETE)
|
|
259
|
+
endpoint: API endpoint path
|
|
260
|
+
params: Query parameters
|
|
261
|
+
json_data: JSON request body
|
|
262
|
+
retry_count: Current retry attempt number
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Response data as dictionary
|
|
266
|
+
|
|
267
|
+
Raises:
|
|
268
|
+
APIError: If API returns an error
|
|
269
|
+
RateLimitError: If rate limit is exceeded
|
|
270
|
+
NetworkError: If network communication fails
|
|
271
|
+
"""
|
|
272
|
+
# Apply rate limiting
|
|
273
|
+
await self.rate_limiter.acquire()
|
|
274
|
+
|
|
275
|
+
start_time = time.time()
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
# Automatically translate endpoint based on API type
|
|
279
|
+
translated_endpoint = self._translate_endpoint(endpoint)
|
|
280
|
+
|
|
281
|
+
# ENHANCED LOGGING - Use INFO level to ensure visibility
|
|
282
|
+
if endpoint != translated_endpoint:
|
|
283
|
+
self.logger.info(f"Endpoint translation: {endpoint} -> {translated_endpoint}")
|
|
284
|
+
|
|
285
|
+
# Construct full URL explicitly to ensure HTTPS protocol is preserved
|
|
286
|
+
# httpx's base_url joining can have issues with protocol handling
|
|
287
|
+
full_url = (
|
|
288
|
+
f"{self.settings.base_url}{translated_endpoint}"
|
|
289
|
+
if translated_endpoint.startswith("/")
|
|
290
|
+
else translated_endpoint
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# CRITICAL: Ensure HTTPS scheme - force replace http:// with https://
|
|
294
|
+
if full_url.startswith("http://"):
|
|
295
|
+
full_url = full_url.replace("http://", "https://", 1)
|
|
296
|
+
self.logger.warning(f"Force-corrected HTTP to HTTPS: {full_url}")
|
|
297
|
+
|
|
298
|
+
# ENHANCED LOGGING - Show actual URL being requested
|
|
299
|
+
self.logger.info(f"Making {method} request to: {full_url}")
|
|
300
|
+
|
|
301
|
+
response = await self.client.request(
|
|
302
|
+
method=method,
|
|
303
|
+
url=full_url,
|
|
304
|
+
params=params,
|
|
305
|
+
json=json_data,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
309
|
+
|
|
310
|
+
# Log request if enabled
|
|
311
|
+
if self.settings.log_api_requests:
|
|
312
|
+
log_api_request(
|
|
313
|
+
self.logger,
|
|
314
|
+
method=method,
|
|
315
|
+
url=translated_endpoint, # Log the translated endpoint, not the original
|
|
316
|
+
status_code=response.status_code,
|
|
317
|
+
duration_ms=duration_ms,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Handle rate limiting
|
|
321
|
+
if response.status_code == 429:
|
|
322
|
+
retry_after = int(response.headers.get("Retry-After", 60))
|
|
323
|
+
|
|
324
|
+
# Retry if we haven't exceeded max retries
|
|
325
|
+
if retry_count < self.settings.max_retries:
|
|
326
|
+
self.logger.warning(f"Rate limited, retrying after {retry_after}s")
|
|
327
|
+
await asyncio.sleep(retry_after)
|
|
328
|
+
return await self._request(method, endpoint, params, json_data, retry_count + 1)
|
|
329
|
+
|
|
330
|
+
raise RateLimitError(retry_after=retry_after)
|
|
331
|
+
|
|
332
|
+
# Handle not found
|
|
333
|
+
if response.status_code == 404:
|
|
334
|
+
raise ResourceNotFoundError("resource", endpoint)
|
|
335
|
+
|
|
336
|
+
# Handle authentication errors
|
|
337
|
+
if response.status_code in (401, 403):
|
|
338
|
+
raise AuthenticationError(f"Authentication failed: {response.text}")
|
|
339
|
+
|
|
340
|
+
# Handle other errors
|
|
341
|
+
if response.status_code >= 400:
|
|
342
|
+
error_data = None
|
|
343
|
+
try:
|
|
344
|
+
error_data = response.json()
|
|
345
|
+
except Exception:
|
|
346
|
+
pass
|
|
347
|
+
|
|
348
|
+
raise APIError(
|
|
349
|
+
message=f"API request failed: {response.text}",
|
|
350
|
+
status_code=response.status_code,
|
|
351
|
+
response_data=error_data,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Parse response - handle empty responses from local gateway
|
|
355
|
+
try:
|
|
356
|
+
if response.text and response.text.strip():
|
|
357
|
+
json_response: dict[str, Any] = response.json()
|
|
358
|
+
|
|
359
|
+
# Normalize response format based on API type
|
|
360
|
+
# Cloud V1 API returns: {"data": [...], "httpStatusCode": 200, "traceId": "..."}
|
|
361
|
+
# Local API returns: {"data": [...], "count": N, "totalCount": N}
|
|
362
|
+
# Cloud EA API returns: {...} or [...] directly
|
|
363
|
+
if isinstance(json_response, dict) and "data" in json_response:
|
|
364
|
+
# Both cloud v1 and local API wrap data in a "data" field
|
|
365
|
+
data = json_response["data"]
|
|
366
|
+
api_type = (
|
|
367
|
+
self.settings.api_type.value
|
|
368
|
+
if hasattr(self.settings.api_type, "value")
|
|
369
|
+
else str(self.settings.api_type)
|
|
370
|
+
)
|
|
371
|
+
self.logger.debug(
|
|
372
|
+
f"Normalized {api_type} API response: extracted {len(data) if isinstance(data, list) else 'N/A'} items"
|
|
373
|
+
)
|
|
374
|
+
# Return the data directly for consistency across all APIs
|
|
375
|
+
# If data is a list, return it; if single object, return as-is
|
|
376
|
+
return data if isinstance(data, list) else {"data": data}
|
|
377
|
+
else:
|
|
378
|
+
# Empty response body - treat as success with empty data
|
|
379
|
+
self.logger.debug(f"Empty response body for {endpoint}, returning empty dict")
|
|
380
|
+
json_response = {}
|
|
381
|
+
return json_response
|
|
382
|
+
except (ValueError, json.JSONDecodeError) as e:
|
|
383
|
+
# Invalid JSON - log and return empty dict for successful status codes
|
|
384
|
+
self.logger.warning(f"Invalid JSON in response for {endpoint}: {e}")
|
|
385
|
+
return {}
|
|
386
|
+
|
|
387
|
+
except httpx.TimeoutException as e:
|
|
388
|
+
# Retry on timeout
|
|
389
|
+
if retry_count < self.settings.max_retries:
|
|
390
|
+
backoff = self.settings.retry_backoff_factor**retry_count
|
|
391
|
+
self.logger.warning(f"Request timeout, retrying in {backoff}s")
|
|
392
|
+
await asyncio.sleep(backoff)
|
|
393
|
+
return await self._request(method, endpoint, params, json_data, retry_count + 1)
|
|
394
|
+
|
|
395
|
+
raise NetworkError(f"Request timeout: {e}") from e
|
|
396
|
+
|
|
397
|
+
except httpx.NetworkError as e:
|
|
398
|
+
# Retry on network error
|
|
399
|
+
if retry_count < self.settings.max_retries:
|
|
400
|
+
backoff = self.settings.retry_backoff_factor**retry_count
|
|
401
|
+
self.logger.warning(f"Network error, retrying in {backoff}s")
|
|
402
|
+
await asyncio.sleep(backoff)
|
|
403
|
+
return await self._request(method, endpoint, params, json_data, retry_count + 1)
|
|
404
|
+
|
|
405
|
+
raise NetworkError(f"Network communication failed: {e}") from e
|
|
406
|
+
|
|
407
|
+
except (RateLimitError, AuthenticationError, APIError, ResourceNotFoundError):
|
|
408
|
+
# Re-raise our custom exceptions
|
|
409
|
+
raise
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
self.logger.error(f"Unexpected error during API request: {e}")
|
|
413
|
+
raise APIError(f"Unexpected error: {e}") from e
|
|
414
|
+
|
|
415
|
+
async def get(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
416
|
+
"""Make a GET request.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
endpoint: API endpoint path
|
|
420
|
+
params: Query parameters
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Response data
|
|
424
|
+
"""
|
|
425
|
+
return await self._request("GET", endpoint, params=params)
|
|
426
|
+
|
|
427
|
+
async def post(
|
|
428
|
+
self,
|
|
429
|
+
endpoint: str,
|
|
430
|
+
json_data: dict[str, Any],
|
|
431
|
+
params: dict[str, Any] | None = None,
|
|
432
|
+
) -> dict[str, Any]:
|
|
433
|
+
"""Make a POST request.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
endpoint: API endpoint path
|
|
437
|
+
json_data: JSON request body
|
|
438
|
+
params: Query parameters
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
Response data
|
|
442
|
+
"""
|
|
443
|
+
return await self._request("POST", endpoint, params=params, json_data=json_data)
|
|
444
|
+
|
|
445
|
+
async def put(
|
|
446
|
+
self,
|
|
447
|
+
endpoint: str,
|
|
448
|
+
json_data: dict[str, Any],
|
|
449
|
+
params: dict[str, Any] | None = None,
|
|
450
|
+
) -> dict[str, Any]:
|
|
451
|
+
"""Make a PUT request.
|
|
452
|
+
|
|
453
|
+
Args:
|
|
454
|
+
endpoint: API endpoint path
|
|
455
|
+
json_data: JSON request body
|
|
456
|
+
params: Query parameters
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
Response data
|
|
460
|
+
"""
|
|
461
|
+
return await self._request("PUT", endpoint, params=params, json_data=json_data)
|
|
462
|
+
|
|
463
|
+
async def delete(self, endpoint: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
464
|
+
"""Make a DELETE request.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
endpoint: API endpoint path
|
|
468
|
+
params: Query parameters
|
|
469
|
+
|
|
470
|
+
Returns:
|
|
471
|
+
Response data
|
|
472
|
+
"""
|
|
473
|
+
return await self._request("DELETE", endpoint, params=params)
|
|
474
|
+
|
|
475
|
+
@staticmethod
|
|
476
|
+
def _looks_like_uuid(value: str | None) -> bool:
|
|
477
|
+
"""Determine whether a string value appears to be a UUID."""
|
|
478
|
+
if not value:
|
|
479
|
+
return False
|
|
480
|
+
|
|
481
|
+
try:
|
|
482
|
+
UUID(value)
|
|
483
|
+
return True
|
|
484
|
+
except (ValueError, TypeError):
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
async def resolve_site_id(self, site_identifier: str | None) -> str:
|
|
488
|
+
"""Resolve a user-provided site identifier to the controller's UUID format.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
site_identifier: Friendly site identifier (e.g., "default" or UUID)
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Resolved UUID for the site (or the original identifier for cloud API)
|
|
495
|
+
|
|
496
|
+
Raises:
|
|
497
|
+
ResourceNotFoundError: If the site cannot be located
|
|
498
|
+
"""
|
|
499
|
+
if not site_identifier:
|
|
500
|
+
site_identifier = self.settings.default_site
|
|
501
|
+
|
|
502
|
+
if self.settings.api_type == APIType.CLOUD or self._looks_like_uuid(site_identifier):
|
|
503
|
+
return site_identifier
|
|
504
|
+
|
|
505
|
+
cached = self._site_id_cache.get(site_identifier)
|
|
506
|
+
if cached:
|
|
507
|
+
return cached
|
|
508
|
+
|
|
509
|
+
sites_endpoint = self.settings.get_integration_path("sites")
|
|
510
|
+
response = await self.get(sites_endpoint)
|
|
511
|
+
# Handle both list (when API returns {"data": [...]}) and dict responses
|
|
512
|
+
if isinstance(response, list):
|
|
513
|
+
sites = response
|
|
514
|
+
else:
|
|
515
|
+
sites = response.get("data", response.get("sites", []))
|
|
516
|
+
|
|
517
|
+
for site in sites:
|
|
518
|
+
site_id = site.get("id") or site.get("_id")
|
|
519
|
+
if not site_id:
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
identifiers = {
|
|
523
|
+
site_id,
|
|
524
|
+
site.get("internalReference"),
|
|
525
|
+
site.get("name"),
|
|
526
|
+
site.get("shortName"),
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if site_identifier in {value for value in identifiers if value}:
|
|
530
|
+
self._site_id_cache[site_identifier] = site_id
|
|
531
|
+
return site_id
|
|
532
|
+
|
|
533
|
+
raise ResourceNotFoundError("site", site_identifier)
|
|
534
|
+
|
|
535
|
+
# Backup and Restore Operations
|
|
536
|
+
|
|
537
|
+
async def trigger_backup(
|
|
538
|
+
self,
|
|
539
|
+
site_id: str,
|
|
540
|
+
backup_type: str = "network",
|
|
541
|
+
days: int = -1,
|
|
542
|
+
) -> dict[str, Any]:
|
|
543
|
+
"""Trigger a backup operation on the UniFi controller.
|
|
544
|
+
|
|
545
|
+
Args:
|
|
546
|
+
site_id: Site identifier
|
|
547
|
+
backup_type: Type of backup ("network" for network-only, "system" for full)
|
|
548
|
+
days: Number of days to retain backup (-1 for indefinite)
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
Backup operation response including download URL
|
|
552
|
+
|
|
553
|
+
Note:
|
|
554
|
+
For local API, use: /proxy/network/api/s/{site}/cmd/backup
|
|
555
|
+
Response contains a URL in data.url for downloading the backup file
|
|
556
|
+
"""
|
|
557
|
+
site_id = await self.resolve_site_id(site_id)
|
|
558
|
+
|
|
559
|
+
# For local API, translate to local endpoint format
|
|
560
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
561
|
+
# Use site name (e.g., "default") not UUID for local API
|
|
562
|
+
site_name = self._site_uuid_to_name.get(site_id, site_id)
|
|
563
|
+
endpoint = f"/proxy/network/api/s/{site_name}/cmd/backup"
|
|
564
|
+
else:
|
|
565
|
+
# Cloud API
|
|
566
|
+
endpoint = f"/ea/sites/{site_id}/cmd/backup"
|
|
567
|
+
|
|
568
|
+
payload = {
|
|
569
|
+
"cmd": "backup",
|
|
570
|
+
"days": str(days),
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return await self.post(endpoint, json_data=payload)
|
|
574
|
+
|
|
575
|
+
async def list_backups(self, site_id: str) -> list[dict[str, Any]]:
|
|
576
|
+
"""List all available backups for a site.
|
|
577
|
+
|
|
578
|
+
Args:
|
|
579
|
+
site_id: Site identifier
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
List of backup metadata dictionaries
|
|
583
|
+
|
|
584
|
+
Note:
|
|
585
|
+
For local API, use: /proxy/network/api/backup/list-backups
|
|
586
|
+
For cloud API, endpoint may differ
|
|
587
|
+
"""
|
|
588
|
+
site_id = await self.resolve_site_id(site_id)
|
|
589
|
+
|
|
590
|
+
# For local API
|
|
591
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
592
|
+
site_name = self._site_uuid_to_name.get(site_id, site_id)
|
|
593
|
+
endpoint = f"/proxy/network/api/backup/list-backups?site={site_name}"
|
|
594
|
+
else:
|
|
595
|
+
# Cloud API
|
|
596
|
+
endpoint = f"/ea/sites/{site_id}/backups"
|
|
597
|
+
|
|
598
|
+
response = await self.get(endpoint)
|
|
599
|
+
|
|
600
|
+
# Handle different response formats
|
|
601
|
+
if isinstance(response, list):
|
|
602
|
+
return response
|
|
603
|
+
return response.get("data", response.get("backups", []))
|
|
604
|
+
|
|
605
|
+
async def download_backup(
|
|
606
|
+
self,
|
|
607
|
+
site_id: str,
|
|
608
|
+
backup_filename: str,
|
|
609
|
+
) -> bytes:
|
|
610
|
+
"""Download a backup file.
|
|
611
|
+
|
|
612
|
+
Args:
|
|
613
|
+
site_id: Site identifier
|
|
614
|
+
backup_filename: Backup filename to download
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
Backup file content as bytes
|
|
618
|
+
|
|
619
|
+
Note:
|
|
620
|
+
This method downloads the actual backup file content.
|
|
621
|
+
For local API: /proxy/network/data/backup/{filename}
|
|
622
|
+
"""
|
|
623
|
+
site_id = await self.resolve_site_id(site_id)
|
|
624
|
+
|
|
625
|
+
# For local API
|
|
626
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
627
|
+
endpoint = f"/proxy/network/data/backup/{backup_filename}"
|
|
628
|
+
else:
|
|
629
|
+
# Cloud API
|
|
630
|
+
endpoint = f"/ea/sites/{site_id}/backups/{backup_filename}/download"
|
|
631
|
+
|
|
632
|
+
# Use direct HTTP client for binary download
|
|
633
|
+
full_url = f"{self.settings.base_url}{endpoint}"
|
|
634
|
+
|
|
635
|
+
response = await self.client.get(full_url)
|
|
636
|
+
response.raise_for_status()
|
|
637
|
+
|
|
638
|
+
return response.content
|
|
639
|
+
|
|
640
|
+
async def delete_backup(
|
|
641
|
+
self,
|
|
642
|
+
site_id: str,
|
|
643
|
+
backup_filename: str,
|
|
644
|
+
) -> dict[str, Any]:
|
|
645
|
+
"""Delete a specific backup file.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
site_id: Site identifier
|
|
649
|
+
backup_filename: Backup filename to delete
|
|
650
|
+
|
|
651
|
+
Returns:
|
|
652
|
+
Deletion confirmation response
|
|
653
|
+
|
|
654
|
+
Note:
|
|
655
|
+
For local API: DELETE /proxy/network/api/backup/delete-backup/{filename}
|
|
656
|
+
"""
|
|
657
|
+
site_id = await self.resolve_site_id(site_id)
|
|
658
|
+
|
|
659
|
+
# For local API
|
|
660
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
661
|
+
endpoint = f"/proxy/network/api/backup/delete-backup/{backup_filename}"
|
|
662
|
+
else:
|
|
663
|
+
# Cloud API
|
|
664
|
+
endpoint = f"/ea/sites/{site_id}/backups/{backup_filename}"
|
|
665
|
+
|
|
666
|
+
return await self.delete(endpoint)
|
|
667
|
+
|
|
668
|
+
async def restore_backup(
|
|
669
|
+
self,
|
|
670
|
+
site_id: str,
|
|
671
|
+
backup_filename: str,
|
|
672
|
+
) -> dict[str, Any]:
|
|
673
|
+
"""Restore the controller from a backup file.
|
|
674
|
+
|
|
675
|
+
Args:
|
|
676
|
+
site_id: Site identifier
|
|
677
|
+
backup_filename: Backup filename to restore from
|
|
678
|
+
|
|
679
|
+
Returns:
|
|
680
|
+
Restore operation response
|
|
681
|
+
|
|
682
|
+
Warning:
|
|
683
|
+
This is a destructive operation that will restore the controller
|
|
684
|
+
to the state captured in the backup. Use with extreme caution.
|
|
685
|
+
|
|
686
|
+
Note:
|
|
687
|
+
For local API: POST /proxy/network/api/backup/restore
|
|
688
|
+
Controller may restart during restore process
|
|
689
|
+
"""
|
|
690
|
+
site_id = await self.resolve_site_id(site_id)
|
|
691
|
+
|
|
692
|
+
# For local API
|
|
693
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
694
|
+
endpoint = "/proxy/network/api/backup/restore"
|
|
695
|
+
payload = {"filename": backup_filename}
|
|
696
|
+
else:
|
|
697
|
+
# Cloud API
|
|
698
|
+
endpoint = f"/ea/sites/{site_id}/backups/{backup_filename}/restore"
|
|
699
|
+
payload = {"backup_id": backup_filename}
|
|
700
|
+
|
|
701
|
+
return await self.post(endpoint, json_data=payload)
|
|
702
|
+
|
|
703
|
+
async def get_backup_status(
|
|
704
|
+
self,
|
|
705
|
+
site_id: str,
|
|
706
|
+
operation_id: str,
|
|
707
|
+
) -> dict[str, Any]:
|
|
708
|
+
"""Get the status of an ongoing backup operation.
|
|
709
|
+
|
|
710
|
+
Args:
|
|
711
|
+
site_id: Site identifier
|
|
712
|
+
operation_id: Backup operation ID
|
|
713
|
+
|
|
714
|
+
Returns:
|
|
715
|
+
Operation status including progress and any errors
|
|
716
|
+
"""
|
|
717
|
+
site_id = await self.resolve_site_id(site_id)
|
|
718
|
+
|
|
719
|
+
# For local API
|
|
720
|
+
if self.settings.api_type == APIType.LOCAL:
|
|
721
|
+
site_name = self._site_uuid_to_name.get(site_id, site_id)
|
|
722
|
+
endpoint = f"/proxy/network/api/s/{site_name}/stat/backup/{operation_id}"
|
|
723
|
+
else:
|
|
724
|
+
# Cloud API
|
|
725
|
+
endpoint = f"/ea/sites/{site_id}/operations/{operation_id}"
|
|
726
|
+
|
|
727
|
+
return await self.get(endpoint)
|