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
@@ -0,0 +1,515 @@
1
+ """Firewall zone management tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api.client import UniFiClient
6
+ from ..config import APIType, Settings
7
+ from ..models.zbf_matrix import ZoneNetworkAssignment
8
+ from ..utils import ValidationError, audit_action, get_logger, validate_confirmation
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ def _ensure_local_api(settings: Settings) -> None:
14
+ """Ensure the UniFi controller is accessed via the local API for ZBF operations."""
15
+ if settings.api_type != APIType.LOCAL:
16
+ raise ValidationError(
17
+ "Zone-Based Firewall endpoints are only available when UNIFI_API_TYPE='local'. "
18
+ "Please configure a local UniFi gateway connection to use these tools."
19
+ )
20
+
21
+
22
+ async def list_firewall_zones(
23
+ site_id: str,
24
+ settings: Settings,
25
+ ) -> list[dict[str, Any]]:
26
+ """List all firewall zones for a site.
27
+
28
+ Args:
29
+ site_id: Site identifier
30
+ settings: Application settings
31
+
32
+ Returns:
33
+ List of firewall zones
34
+ """
35
+ _ensure_local_api(settings)
36
+
37
+ async with UniFiClient(settings) as client:
38
+ logger.info(f"Listing firewall zones for site {site_id}")
39
+
40
+ if not client.is_authenticated:
41
+ await client.authenticate()
42
+
43
+ resolved_site_id = await client.resolve_site_id(site_id)
44
+ endpoint = settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones")
45
+ response = await client.get(endpoint)
46
+ # Handle both list and dict responses
47
+ data = response if isinstance(response, list) else response.get("data", [])
48
+
49
+ # Return raw data - API response may not match model exactly
50
+ return data # type: ignore[no-any-return]
51
+
52
+
53
+ async def create_firewall_zone(
54
+ site_id: str,
55
+ name: str,
56
+ settings: Settings,
57
+ description: str | None = None,
58
+ network_ids: list[str] | None = None,
59
+ confirm: bool = False,
60
+ dry_run: bool = False,
61
+ ) -> dict[str, Any]:
62
+ """Create a new firewall zone.
63
+
64
+ Args:
65
+ site_id: Site identifier
66
+ name: Zone name
67
+ settings: Application settings
68
+ description: Zone description
69
+ network_ids: Network IDs to assign to this zone
70
+ confirm: Confirmation flag (required)
71
+ dry_run: If True, validate but don't execute
72
+
73
+ Returns:
74
+ Created firewall zone
75
+ """
76
+ validate_confirmation(confirm, "create firewall zone")
77
+
78
+ _ensure_local_api(settings)
79
+
80
+ async with UniFiClient(settings) as client:
81
+ logger.info(f"Creating firewall zone '{name}' for site {site_id}")
82
+
83
+ if not client.is_authenticated:
84
+ await client.authenticate()
85
+
86
+ # Build request payload
87
+ # Note: networkIds is required by API (even if empty list)
88
+ payload: dict[str, Any] = {
89
+ "name": name,
90
+ "networkIds": network_ids if network_ids else [],
91
+ }
92
+
93
+ if description:
94
+ payload["description"] = description
95
+
96
+ if dry_run:
97
+ logger.info(f"[DRY RUN] Would create firewall zone with payload: {payload}")
98
+ return {"dry_run": True, "payload": payload}
99
+
100
+ resolved_site_id = await client.resolve_site_id(site_id)
101
+ response = await client.post(
102
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones"),
103
+ json_data=payload,
104
+ )
105
+ data = response.get("data", response)
106
+
107
+ # Audit the action
108
+ await audit_action(
109
+ settings,
110
+ action_type="create_firewall_zone",
111
+ resource_type="firewall_zone",
112
+ resource_id=data.get("_id", "unknown"),
113
+ site_id=site_id,
114
+ details={"name": name},
115
+ )
116
+
117
+ # Return raw data - API response may not match model exactly
118
+ return data # type: ignore[no-any-return]
119
+
120
+
121
+ async def update_firewall_zone(
122
+ site_id: str,
123
+ firewall_zone_id: str,
124
+ settings: Settings,
125
+ name: str | None = None,
126
+ description: str | None = None,
127
+ network_ids: list[str] | None = None,
128
+ confirm: bool = False,
129
+ dry_run: bool = False,
130
+ ) -> dict[str, Any]:
131
+ """Update an existing firewall zone.
132
+
133
+ Args:
134
+ site_id: Site identifier
135
+ firewall_zone_id: Firewall zone identifier
136
+ settings: Application settings
137
+ name: Zone name
138
+ description: Zone description
139
+ network_ids: Network IDs to assign to this zone
140
+ confirm: Confirmation flag (required)
141
+ dry_run: If True, validate but don't execute
142
+
143
+ Returns:
144
+ Updated firewall zone
145
+ """
146
+ validate_confirmation(confirm, "update firewall zone")
147
+
148
+ _ensure_local_api(settings)
149
+
150
+ async with UniFiClient(settings) as client:
151
+ logger.info(f"Updating firewall zone {firewall_zone_id} for site {site_id}")
152
+
153
+ if not client.is_authenticated:
154
+ await client.authenticate()
155
+
156
+ resolved_site_id = await client.resolve_site_id(site_id)
157
+
158
+ # Fetch current zone to get existing networkIds if not provided
159
+ # API requires networkIds field to always be present
160
+ current_zone_response = await client.get(
161
+ settings.get_integration_path(
162
+ f"sites/{resolved_site_id}/firewall/zones/{firewall_zone_id}"
163
+ )
164
+ )
165
+ current_zone = current_zone_response.get("data", current_zone_response)
166
+ current_network_ids = current_zone.get("networkIds", [])
167
+
168
+ # Build request payload - networkIds is required by API
169
+ payload: dict[str, Any] = {
170
+ "networkIds": network_ids if network_ids is not None else current_network_ids
171
+ }
172
+
173
+ if name is not None:
174
+ payload["name"] = name
175
+ if description is not None:
176
+ payload["description"] = description
177
+
178
+ if dry_run:
179
+ logger.info(f"[DRY RUN] Would update firewall zone with payload: {payload}")
180
+ return {"dry_run": True, "payload": payload}
181
+
182
+ response = await client.put(
183
+ settings.get_integration_path(
184
+ f"sites/{resolved_site_id}/firewall/zones/{firewall_zone_id}"
185
+ ),
186
+ json_data=payload,
187
+ )
188
+ data = response.get("data", response)
189
+
190
+ # Audit the action
191
+ await audit_action(
192
+ settings,
193
+ action_type="update_firewall_zone",
194
+ resource_type="firewall_zone",
195
+ resource_id=firewall_zone_id,
196
+ site_id=site_id,
197
+ details=payload,
198
+ )
199
+
200
+ # Return raw data - API response may not match model exactly
201
+ return data # type: ignore[no-any-return]
202
+
203
+
204
+ async def assign_network_to_zone(
205
+ site_id: str,
206
+ zone_id: str,
207
+ network_id: str,
208
+ settings: Settings,
209
+ confirm: bool = False,
210
+ dry_run: bool = False,
211
+ ) -> dict[str, Any]:
212
+ """Dynamically assign a network to a zone.
213
+
214
+ Args:
215
+ site_id: Site identifier
216
+ zone_id: Zone identifier
217
+ network_id: Network identifier to assign
218
+ settings: Application settings
219
+ confirm: Confirmation flag (required)
220
+ dry_run: If True, validate but don't execute
221
+
222
+ Returns:
223
+ Network assignment information
224
+ """
225
+ validate_confirmation(confirm, "assign network to zone")
226
+
227
+ _ensure_local_api(settings)
228
+
229
+ async with UniFiClient(settings) as client:
230
+ logger.info(f"Assigning network {network_id} to zone {zone_id} on site {site_id}")
231
+
232
+ if not client.is_authenticated:
233
+ await client.authenticate()
234
+
235
+ resolved_site_id = await client.resolve_site_id(site_id)
236
+
237
+ # Get network name
238
+ network_name = None
239
+ try:
240
+ network_response = await client.get(
241
+ settings.get_integration_path(f"sites/{resolved_site_id}/networks/{network_id}")
242
+ )
243
+ network_data = network_response.get("data", {})
244
+ network_name = network_data.get("name")
245
+ except Exception:
246
+ logger.warning(f"Could not fetch network name for {network_id}")
247
+
248
+ # Update zone to include this network
249
+ zone_response = await client.get(
250
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}")
251
+ )
252
+ zone_data = zone_response.get("data", {})
253
+ current_networks = zone_data.get("networks", [])
254
+
255
+ if network_id in current_networks:
256
+ logger.info(f"Network {network_id} already assigned to zone {zone_id}")
257
+ return ZoneNetworkAssignment( # type: ignore[no-any-return]
258
+ zone_id=zone_id,
259
+ network_id=network_id,
260
+ network_name=network_name,
261
+ ).model_dump()
262
+
263
+ updated_networks = list(current_networks) + [network_id]
264
+
265
+ payload = {"networks": updated_networks}
266
+
267
+ if dry_run:
268
+ logger.info(f"[DRY RUN] Would assign network {network_id} to zone {zone_id}")
269
+ return {"dry_run": True, "payload": payload}
270
+
271
+ await client.put(
272
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}"),
273
+ json_data=payload,
274
+ )
275
+
276
+ # Audit the action
277
+ await audit_action(
278
+ settings,
279
+ action_type="assign_network_to_zone",
280
+ resource_type="zone_network_assignment",
281
+ resource_id=network_id,
282
+ site_id=site_id,
283
+ details={"zone_id": zone_id, "network_id": network_id},
284
+ )
285
+
286
+ return ZoneNetworkAssignment( # type: ignore[no-any-return]
287
+ zone_id=zone_id,
288
+ network_id=network_id,
289
+ network_name=network_name,
290
+ ).model_dump()
291
+
292
+
293
+ async def get_zone_networks(site_id: str, zone_id: str, settings: Settings) -> list[dict[str, Any]]:
294
+ """List all networks in a zone.
295
+
296
+ Args:
297
+ site_id: Site identifier
298
+ zone_id: Zone identifier
299
+ settings: Application settings
300
+
301
+ Returns:
302
+ List of networks in the zone
303
+ """
304
+ _ensure_local_api(settings)
305
+
306
+ async with UniFiClient(settings) as client:
307
+ logger.info(f"Listing networks in zone {zone_id} on site {site_id}")
308
+
309
+ if not client.is_authenticated:
310
+ await client.authenticate()
311
+
312
+ resolved_site_id = await client.resolve_site_id(site_id)
313
+
314
+ response = await client.get(
315
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}")
316
+ )
317
+ zone_data = response.get("data", {})
318
+ network_ids = zone_data.get("networks", [])
319
+
320
+ # Fetch network details for each network ID
321
+ networks = []
322
+ for network_id in network_ids:
323
+ try:
324
+ network_response = await client.get(
325
+ settings.get_integration_path(f"sites/{resolved_site_id}/networks/{network_id}")
326
+ )
327
+ network_data = network_response.get("data", {})
328
+ networks.append(
329
+ ZoneNetworkAssignment(
330
+ zone_id=zone_id,
331
+ network_id=network_id,
332
+ network_name=network_data.get("name"),
333
+ ).model_dump()
334
+ )
335
+ except Exception:
336
+ # If network fetch fails, still include the assignment with just IDs
337
+ networks.append(
338
+ ZoneNetworkAssignment(
339
+ zone_id=zone_id,
340
+ network_id=network_id,
341
+ ).model_dump()
342
+ )
343
+
344
+ return networks
345
+
346
+
347
+ async def delete_firewall_zone(
348
+ site_id: str,
349
+ zone_id: str,
350
+ settings: Settings,
351
+ confirm: bool = False,
352
+ dry_run: bool = False,
353
+ ) -> dict[str, Any]:
354
+ """Delete a firewall zone.
355
+
356
+ Args:
357
+ site_id: Site identifier
358
+ zone_id: Zone identifier to delete
359
+ settings: Application settings
360
+ confirm: Confirmation flag (required)
361
+ dry_run: If True, validate but don't execute
362
+
363
+ Returns:
364
+ Deletion confirmation
365
+
366
+ Raises:
367
+ ValueError: If confirmation not provided
368
+ """
369
+ validate_confirmation(confirm, "delete firewall zone")
370
+
371
+ _ensure_local_api(settings)
372
+
373
+ async with UniFiClient(settings) as client:
374
+ logger.info(f"Deleting firewall zone {zone_id} from site {site_id}")
375
+
376
+ if not client.is_authenticated:
377
+ await client.authenticate()
378
+
379
+ if dry_run:
380
+ logger.info(f"[DRY RUN] Would delete firewall zone {zone_id}")
381
+ return {"dry_run": True, "zone_id": zone_id, "action": "would_delete"}
382
+
383
+ resolved_site_id = await client.resolve_site_id(site_id)
384
+ await client.delete(
385
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}")
386
+ )
387
+
388
+ # Audit the action
389
+ await audit_action(
390
+ settings,
391
+ action_type="delete_firewall_zone",
392
+ resource_type="firewall_zone",
393
+ resource_id=zone_id,
394
+ site_id=site_id,
395
+ details={"zone_id": zone_id},
396
+ )
397
+
398
+ return {"status": "success", "zone_id": zone_id, "action": "deleted"}
399
+
400
+
401
+ async def unassign_network_from_zone(
402
+ site_id: str,
403
+ zone_id: str,
404
+ network_id: str,
405
+ settings: Settings,
406
+ confirm: bool = False,
407
+ dry_run: bool = False,
408
+ ) -> dict[str, Any]:
409
+ """Remove a network from a firewall zone.
410
+
411
+ Args:
412
+ site_id: Site identifier
413
+ zone_id: Zone identifier
414
+ network_id: Network identifier to remove
415
+ settings: Application settings
416
+ confirm: Confirmation flag (required)
417
+ dry_run: If True, validate but don't execute
418
+
419
+ Returns:
420
+ Network unassignment confirmation
421
+
422
+ Raises:
423
+ ValueError: If confirmation not provided or network not in zone
424
+ """
425
+ validate_confirmation(confirm, "unassign network from zone")
426
+
427
+ _ensure_local_api(settings)
428
+
429
+ async with UniFiClient(settings) as client:
430
+ logger.info(f"Unassigning network {network_id} from zone {zone_id} on site {site_id}")
431
+
432
+ if not client.is_authenticated:
433
+ await client.authenticate()
434
+
435
+ resolved_site_id = await client.resolve_site_id(site_id)
436
+
437
+ # Get current zone configuration
438
+ zone_response = await client.get(
439
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}")
440
+ )
441
+ zone_data = zone_response.get("data", {})
442
+ current_networks = zone_data.get("networks", [])
443
+
444
+ if network_id not in current_networks:
445
+ raise ValueError(f"Network {network_id} is not assigned to zone {zone_id}")
446
+
447
+ # Remove network from list
448
+ updated_networks = [nid for nid in current_networks if nid != network_id]
449
+
450
+ payload = {"networks": updated_networks}
451
+
452
+ if dry_run:
453
+ logger.info(f"[DRY RUN] Would remove network {network_id} from zone {zone_id}")
454
+ return {"dry_run": True, "payload": payload}
455
+
456
+ await client.put(
457
+ settings.get_integration_path(f"sites/{resolved_site_id}/firewall/zones/{zone_id}"),
458
+ json_data=payload,
459
+ )
460
+
461
+ # Audit the action
462
+ await audit_action(
463
+ settings,
464
+ action_type="unassign_network_from_zone",
465
+ resource_type="zone_network_assignment",
466
+ resource_id=network_id,
467
+ site_id=site_id,
468
+ details={"zone_id": zone_id, "network_id": network_id},
469
+ )
470
+
471
+ return {
472
+ "status": "success",
473
+ "zone_id": zone_id,
474
+ "network_id": network_id,
475
+ "action": "unassigned",
476
+ }
477
+
478
+
479
+ async def get_zone_statistics(
480
+ site_id: str,
481
+ zone_id: str,
482
+ settings: Settings,
483
+ ) -> dict[str, Any]:
484
+ """Get traffic statistics for a firewall zone.
485
+
486
+ ⚠️ **DEPRECATED - ENDPOINT DOES NOT EXIST**
487
+
488
+ This endpoint has been verified to NOT EXIST in UniFi Network API v10.0.156.
489
+ Tested on UniFi Express 7 and UDM Pro on 2025-11-18.
490
+
491
+ Zone traffic statistics are not available via the API.
492
+ Monitor traffic via /sites/{siteId}/clients endpoint instead.
493
+
494
+ See tests/verification/PHASE2_FINDINGS.md for details.
495
+
496
+ Args:
497
+ site_id: Site identifier
498
+ zone_id: Zone identifier
499
+ settings: Application settings
500
+
501
+ Returns:
502
+ Zone traffic statistics including bandwidth usage and connection counts
503
+
504
+ Raises:
505
+ NotImplementedError: This endpoint does not exist in the UniFi API
506
+ """
507
+ logger.warning(
508
+ f"get_zone_statistics called for zone {zone_id} but endpoint does not exist in UniFi API v10.0.156."
509
+ )
510
+ raise NotImplementedError(
511
+ "Zone statistics endpoint does not exist in UniFi Network API v10.0.156. "
512
+ "Verified on U7 Express and UDM Pro (2025-11-18). "
513
+ "Monitor traffic via /sites/{siteId}/clients endpoint instead. "
514
+ "See tests/verification/PHASE2_FINDINGS.md for details."
515
+ )