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.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. 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)