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/tools/qos.py ADDED
@@ -0,0 +1,1070 @@
1
+ """QoS (Quality of Service) management tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api.client import UniFiClient
6
+ from ..config import Settings
7
+ from ..models.qos_profile import PROAV_TEMPLATES, REFERENCE_PROFILES, QoSProfile, TrafficRoute
8
+ from ..utils import ValidationError, audit_action, get_logger, validate_confirmation
9
+
10
+ logger = get_logger(__name__)
11
+
12
+
13
+ # ============================================================================
14
+ # QoS Profile Management (5 tools)
15
+ # ============================================================================
16
+
17
+
18
+ async def list_qos_profiles(
19
+ site_id: str,
20
+ settings: Settings,
21
+ limit: int = 100,
22
+ offset: int = 0,
23
+ ) -> list[dict[str, Any]]:
24
+ """List all QoS profiles for a site.
25
+
26
+ Args:
27
+ site_id: Site identifier
28
+ settings: Application settings
29
+ limit: Maximum number of profiles to return
30
+ offset: Number of profiles to skip
31
+
32
+ Returns:
33
+ List of QoS profiles
34
+ """
35
+ async with UniFiClient(settings) as client:
36
+ logger.info(f"Listing QoS profiles for site {site_id} (limit={limit}, offset={offset})")
37
+
38
+ if not client.is_authenticated:
39
+ await client.authenticate()
40
+
41
+ resolved_site_id = await client.resolve_site_id(site_id)
42
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/qosprofile")
43
+ response = await client.get(endpoint)
44
+ data = response.get("data", [])
45
+
46
+ # Apply pagination
47
+ paginated_data = data[offset : offset + limit]
48
+
49
+ return [QoSProfile(**profile).model_dump() for profile in paginated_data] # type: ignore[no-any-return]
50
+
51
+
52
+ async def get_qos_profile(
53
+ site_id: str,
54
+ profile_id: str,
55
+ settings: Settings,
56
+ ) -> dict[str, Any]:
57
+ """Get a specific QoS profile by ID.
58
+
59
+ Args:
60
+ site_id: Site identifier
61
+ profile_id: QoS profile ID
62
+ settings: Application settings
63
+
64
+ Returns:
65
+ QoS profile details
66
+ """
67
+ async with UniFiClient(settings) as client:
68
+ logger.info(f"Getting QoS profile {profile_id} for site {site_id}")
69
+
70
+ if not client.is_authenticated:
71
+ await client.authenticate()
72
+
73
+ resolved_site_id = await client.resolve_site_id(site_id)
74
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/qosprofile/{profile_id}")
75
+ response = await client.get(endpoint)
76
+ data = response.get("data", [])
77
+
78
+ if not data:
79
+ raise ValidationError(f"QoS profile {profile_id} not found")
80
+
81
+ return QoSProfile(**data[0]).model_dump() # type: ignore[no-any-return]
82
+
83
+
84
+ async def create_qos_profile(
85
+ site_id: str,
86
+ name: str,
87
+ priority_level: int,
88
+ settings: Settings,
89
+ description: str | None = None,
90
+ dscp_marking: int | None = None,
91
+ bandwidth_limit_down_kbps: int | None = None,
92
+ bandwidth_limit_up_kbps: int | None = None,
93
+ bandwidth_guaranteed_down_kbps: int | None = None,
94
+ bandwidth_guaranteed_up_kbps: int | None = None,
95
+ ports: list[int] | None = None,
96
+ protocols: list[str] | None = None,
97
+ applications: list[str] | None = None,
98
+ categories: list[str] | None = None,
99
+ schedule_enabled: bool = False,
100
+ schedule_days: list[str] | None = None,
101
+ schedule_time_start: str | None = None,
102
+ schedule_time_end: str | None = None,
103
+ enabled: bool = True,
104
+ confirm: bool = False,
105
+ dry_run: bool = False,
106
+ ) -> dict[str, Any]:
107
+ """Create a new QoS profile with comprehensive traffic shaping.
108
+
109
+ Args:
110
+ site_id: Site identifier
111
+ name: Profile name
112
+ priority_level: Priority level (0-7, where 7 is highest)
113
+ settings: Application settings
114
+ description: Profile description
115
+ dscp_marking: DSCP value to mark packets (0-63)
116
+ bandwidth_limit_down_kbps: Download bandwidth limit in kbps
117
+ bandwidth_limit_up_kbps: Upload bandwidth limit in kbps
118
+ bandwidth_guaranteed_down_kbps: Guaranteed download bandwidth in kbps
119
+ bandwidth_guaranteed_up_kbps: Guaranteed upload bandwidth in kbps
120
+ ports: Port numbers to match
121
+ protocols: Protocols to match (tcp, udp, icmp)
122
+ applications: Application IDs to match
123
+ categories: Category IDs to match
124
+ schedule_enabled: Enable time-based schedule
125
+ schedule_days: Days active (mon, tue, wed, thu, fri, sat, sun)
126
+ schedule_time_start: Start time (HH:MM format)
127
+ schedule_time_end: End time (HH:MM format)
128
+ enabled: Profile enabled
129
+ confirm: Confirmation flag (required for creation)
130
+ dry_run: If True, validate but don't execute
131
+
132
+ Returns:
133
+ Created QoS profile
134
+ """
135
+ validate_confirmation(confirm, "create QoS profile")
136
+
137
+ # Validate priority level
138
+ if not 0 <= priority_level <= 7:
139
+ raise ValidationError(f"Priority level must be 0-7, got {priority_level}")
140
+
141
+ # Validate DSCP marking
142
+ if dscp_marking is not None and not 0 <= dscp_marking <= 63:
143
+ raise ValidationError(f"DSCP marking must be 0-63, got {dscp_marking}")
144
+
145
+ # Build profile data
146
+ profile_data: dict[str, Any] = {
147
+ "name": name,
148
+ "priority_level": priority_level,
149
+ "enabled": enabled,
150
+ }
151
+
152
+ if description:
153
+ profile_data["description"] = description
154
+ if dscp_marking is not None:
155
+ profile_data["dscp_marking"] = dscp_marking
156
+ if bandwidth_limit_down_kbps is not None:
157
+ profile_data["bandwidth_limit_down_kbps"] = bandwidth_limit_down_kbps
158
+ if bandwidth_limit_up_kbps is not None:
159
+ profile_data["bandwidth_limit_up_kbps"] = bandwidth_limit_up_kbps
160
+ if bandwidth_guaranteed_down_kbps is not None:
161
+ profile_data["bandwidth_guaranteed_down_kbps"] = bandwidth_guaranteed_down_kbps
162
+ if bandwidth_guaranteed_up_kbps is not None:
163
+ profile_data["bandwidth_guaranteed_up_kbps"] = bandwidth_guaranteed_up_kbps
164
+ if ports:
165
+ profile_data["ports"] = ports
166
+ if protocols:
167
+ profile_data["protocols"] = protocols
168
+ if applications:
169
+ profile_data["applications"] = applications
170
+ if categories:
171
+ profile_data["categories"] = categories
172
+
173
+ # Schedule configuration
174
+ if schedule_enabled:
175
+ profile_data["schedule_enabled"] = True
176
+ if schedule_days:
177
+ profile_data["schedule_days"] = schedule_days
178
+ if schedule_time_start:
179
+ profile_data["schedule_time_start"] = schedule_time_start
180
+ if schedule_time_end:
181
+ profile_data["schedule_time_end"] = schedule_time_end
182
+
183
+ if dry_run:
184
+ logger.info(f"[DRY RUN] Would create QoS profile: {profile_data}")
185
+ return {"dry_run": True, "profile": profile_data}
186
+
187
+ async with UniFiClient(settings) as client:
188
+ logger.info(f"Creating QoS profile '{name}' for site {site_id}")
189
+
190
+ if not client.is_authenticated:
191
+ await client.authenticate()
192
+
193
+ resolved_site_id = await client.resolve_site_id(site_id)
194
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/qosprofile")
195
+ response = await client.post(endpoint, json=profile_data)
196
+
197
+ data = response.get("data", [])
198
+ if not data:
199
+ raise ValidationError("Failed to create QoS profile")
200
+
201
+ result = QoSProfile(**data[0]).model_dump()
202
+
203
+ await audit_action(
204
+ settings,
205
+ action="create_qos_profile",
206
+ resource_type="qos_profile",
207
+ resource_id=result.get("id", "unknown"),
208
+ details={
209
+ "name": name,
210
+ "priority_level": priority_level,
211
+ "dscp_marking": dscp_marking,
212
+ },
213
+ site_id=resolved_site_id,
214
+ )
215
+
216
+ return result # type: ignore[no-any-return]
217
+
218
+
219
+ async def update_qos_profile(
220
+ site_id: str,
221
+ profile_id: str,
222
+ settings: Settings,
223
+ name: str | None = None,
224
+ priority_level: int | None = None,
225
+ description: str | None = None,
226
+ dscp_marking: int | None = None,
227
+ bandwidth_limit_down_kbps: int | None = None,
228
+ bandwidth_limit_up_kbps: int | None = None,
229
+ bandwidth_guaranteed_down_kbps: int | None = None,
230
+ bandwidth_guaranteed_up_kbps: int | None = None,
231
+ enabled: bool | None = None,
232
+ confirm: bool = False,
233
+ dry_run: bool = False,
234
+ ) -> dict[str, Any]:
235
+ """Update an existing QoS profile.
236
+
237
+ Args:
238
+ site_id: Site identifier
239
+ profile_id: QoS profile ID to update
240
+ settings: Application settings
241
+ name: New profile name
242
+ priority_level: New priority level (0-7)
243
+ description: New description
244
+ dscp_marking: New DSCP marking (0-63)
245
+ bandwidth_limit_down_kbps: New download limit
246
+ bandwidth_limit_up_kbps: New upload limit
247
+ bandwidth_guaranteed_down_kbps: New guaranteed download
248
+ bandwidth_guaranteed_up_kbps: New guaranteed upload
249
+ enabled: New enabled state
250
+ confirm: Confirmation flag (required for updates)
251
+ dry_run: If True, validate but don't execute
252
+
253
+ Returns:
254
+ Updated QoS profile
255
+ """
256
+ validate_confirmation(confirm, "update QoS profile")
257
+
258
+ # Validate priority level if provided
259
+ if priority_level is not None and not 0 <= priority_level <= 7:
260
+ raise ValidationError(f"Priority level must be 0-7, got {priority_level}")
261
+
262
+ # Validate DSCP marking if provided
263
+ if dscp_marking is not None and not 0 <= dscp_marking <= 63:
264
+ raise ValidationError(f"DSCP marking must be 0-63, got {dscp_marking}")
265
+
266
+ # Build update data (only include provided fields)
267
+ update_data: dict[str, Any] = {}
268
+ if name is not None:
269
+ update_data["name"] = name
270
+ if priority_level is not None:
271
+ update_data["priority_level"] = priority_level
272
+ if description is not None:
273
+ update_data["description"] = description
274
+ if dscp_marking is not None:
275
+ update_data["dscp_marking"] = dscp_marking
276
+ if bandwidth_limit_down_kbps is not None:
277
+ update_data["bandwidth_limit_down_kbps"] = bandwidth_limit_down_kbps
278
+ if bandwidth_limit_up_kbps is not None:
279
+ update_data["bandwidth_limit_up_kbps"] = bandwidth_limit_up_kbps
280
+ if bandwidth_guaranteed_down_kbps is not None:
281
+ update_data["bandwidth_guaranteed_down_kbps"] = bandwidth_guaranteed_down_kbps
282
+ if bandwidth_guaranteed_up_kbps is not None:
283
+ update_data["bandwidth_guaranteed_up_kbps"] = bandwidth_guaranteed_up_kbps
284
+ if enabled is not None:
285
+ update_data["enabled"] = enabled
286
+
287
+ if not update_data:
288
+ raise ValidationError("No update fields provided")
289
+
290
+ if dry_run:
291
+ logger.info(f"[DRY RUN] Would update QoS profile {profile_id}: {update_data}")
292
+ return {"dry_run": True, "profile_id": profile_id, "updates": update_data}
293
+
294
+ async with UniFiClient(settings) as client:
295
+ logger.info(f"Updating QoS profile {profile_id} for site {site_id}")
296
+
297
+ if not client.is_authenticated:
298
+ await client.authenticate()
299
+
300
+ resolved_site_id = await client.resolve_site_id(site_id)
301
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/qosprofile/{profile_id}")
302
+ response = await client.put(endpoint, json=update_data)
303
+
304
+ data = response.get("data", [])
305
+ if not data:
306
+ raise ValidationError(f"Failed to update QoS profile {profile_id}")
307
+
308
+ result = QoSProfile(**data[0]).model_dump()
309
+
310
+ await audit_action(
311
+ settings,
312
+ action="update_qos_profile",
313
+ resource_type="qos_profile",
314
+ resource_id=profile_id,
315
+ details=update_data,
316
+ site_id=resolved_site_id,
317
+ )
318
+
319
+ return result # type: ignore[no-any-return]
320
+
321
+
322
+ async def delete_qos_profile(
323
+ site_id: str,
324
+ profile_id: str,
325
+ settings: Settings,
326
+ confirm: bool = False,
327
+ ) -> dict[str, Any]:
328
+ """Delete a QoS profile.
329
+
330
+ Args:
331
+ site_id: Site identifier
332
+ profile_id: QoS profile ID to delete
333
+ settings: Application settings
334
+ confirm: Confirmation flag (required for deletion)
335
+
336
+ Returns:
337
+ Deletion confirmation
338
+ """
339
+ validate_confirmation(confirm, "delete QoS profile")
340
+
341
+ async with UniFiClient(settings) as client:
342
+ logger.info(f"Deleting QoS profile {profile_id} for site {site_id}")
343
+
344
+ if not client.is_authenticated:
345
+ await client.authenticate()
346
+
347
+ resolved_site_id = await client.resolve_site_id(site_id)
348
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/qosprofile/{profile_id}")
349
+ await client.delete(endpoint)
350
+
351
+ await audit_action(
352
+ settings,
353
+ action_type="delete_qos_profile",
354
+ resource_type="qos_profile",
355
+ resource_id=profile_id,
356
+ details={"deleted": True},
357
+ site_id=resolved_site_id,
358
+ )
359
+
360
+ return { # type: ignore[no-any-return]
361
+ "success": True,
362
+ "message": f"QoS profile {profile_id} deleted successfully",
363
+ "profile_id": profile_id,
364
+ }
365
+
366
+
367
+ # ============================================================================
368
+ # ProAV Profile Management (3 tools)
369
+ # ============================================================================
370
+
371
+
372
+ async def list_proav_templates(settings: Settings) -> list[dict[str, Any]]:
373
+ """List available ProAV protocol templates.
374
+
375
+ Returns predefined templates for professional audio/video protocols:
376
+ - Dante (Audinate)
377
+ - Q-SYS (QSC)
378
+ - SDVoE Alliance
379
+ - AVB (IEEE 802.1)
380
+ - AES67/RAVENNA
381
+ - NDI (NewTek)
382
+ - SMPTE ST 2110
383
+
384
+ Args:
385
+ settings: Application settings
386
+
387
+ Returns:
388
+ List of ProAV templates with recommended settings
389
+ """
390
+ logger.info("Listing ProAV templates")
391
+
392
+ # Return all ProAV templates from models
393
+ templates = []
394
+ for protocol_key, template_data in PROAV_TEMPLATES.items():
395
+ templates.append(
396
+ {
397
+ "protocol": protocol_key,
398
+ **template_data,
399
+ }
400
+ )
401
+
402
+ # Also include reference profiles
403
+ for profile_key, profile_data in REFERENCE_PROFILES.items():
404
+ templates.append(
405
+ {
406
+ "profile_type": "reference",
407
+ "key": profile_key,
408
+ **profile_data,
409
+ }
410
+ )
411
+
412
+ return templates
413
+
414
+
415
+ async def create_proav_profile(
416
+ site_id: str,
417
+ protocol: str,
418
+ settings: Settings,
419
+ name: str | None = None,
420
+ customize_ports: list[int] | None = None,
421
+ customize_bandwidth_down_kbps: int | None = None,
422
+ customize_bandwidth_up_kbps: int | None = None,
423
+ customize_dscp: int | None = None,
424
+ enabled: bool = True,
425
+ confirm: bool = False,
426
+ dry_run: bool = False,
427
+ ) -> dict[str, Any]:
428
+ """Create a QoS profile from a ProAV template.
429
+
430
+ Creates a QoS profile using predefined settings for professional audio/video protocols.
431
+ Supports customization of recommended settings while maintaining best practices.
432
+
433
+ Args:
434
+ site_id: Site identifier
435
+ protocol: ProAV protocol (dante, q-sys, sdvoe, avb, ravenna, ndi, smpte-2110)
436
+ or reference profile (voice-first, video-conferencing, etc.)
437
+ settings: Application settings
438
+ name: Custom profile name (uses template name if not provided)
439
+ customize_ports: Override default ports
440
+ customize_bandwidth_down_kbps: Override download bandwidth
441
+ customize_bandwidth_up_kbps: Override upload bandwidth
442
+ customize_dscp: Override DSCP marking
443
+ enabled: Profile enabled
444
+ confirm: Confirmation flag (required for creation)
445
+ dry_run: If True, validate but don't execute
446
+
447
+ Returns:
448
+ Created QoS profile
449
+ """
450
+ validate_confirmation(confirm, "create ProAV profile")
451
+
452
+ # Check if it's a ProAV template or reference profile
453
+ if protocol in PROAV_TEMPLATES:
454
+ template = PROAV_TEMPLATES[protocol]
455
+ is_reference = False
456
+ elif protocol in REFERENCE_PROFILES:
457
+ template = REFERENCE_PROFILES[protocol]
458
+ is_reference = True
459
+ else:
460
+ available = list(PROAV_TEMPLATES.keys()) + list(REFERENCE_PROFILES.keys())
461
+ raise ValidationError(
462
+ f"Unknown protocol '{protocol}'. " f"Available options: {', '.join(available)}"
463
+ )
464
+
465
+ # Build profile from template
466
+ profile_name = name or template["name"]
467
+ priority_level = template["priority_level"]
468
+ dscp_marking = customize_dscp if customize_dscp is not None else template["dscp_marking"]
469
+
470
+ # Ports from template or customization
471
+ if is_reference:
472
+ ports = customize_ports or template.get("ports", [])
473
+ protocols_list = template.get("protocols", ["tcp", "udp"])
474
+ else:
475
+ # ProAV template has separate TCP/UDP ports
476
+ if customize_ports:
477
+ ports = customize_ports
478
+ else:
479
+ ports = template.get("udp_ports", []) + template.get("tcp_ports", [])
480
+ protocols_list = []
481
+ if template.get("udp_ports"):
482
+ protocols_list.append("udp")
483
+ if template.get("tcp_ports"):
484
+ protocols_list.append("tcp")
485
+
486
+ # Bandwidth settings
487
+ bandwidth_down_kbps = customize_bandwidth_down_kbps
488
+ bandwidth_up_kbps = customize_bandwidth_up_kbps
489
+
490
+ # Convert template bandwidth from Mbps to kbps if needed
491
+ if not bandwidth_down_kbps and template.get("min_bandwidth_mbps"):
492
+ bandwidth_down_kbps = template["min_bandwidth_mbps"] * 1000
493
+
494
+ # Use guaranteed bandwidth from reference profiles
495
+ if not bandwidth_down_kbps and template.get("bandwidth_guaranteed_down_kbps"):
496
+ bandwidth_down_kbps = template["bandwidth_guaranteed_down_kbps"]
497
+ if not bandwidth_up_kbps and template.get("bandwidth_guaranteed_up_kbps"):
498
+ bandwidth_up_kbps = template["bandwidth_guaranteed_up_kbps"]
499
+
500
+ # Use bandwidth limits from reference profiles
501
+ bandwidth_limit_down = template.get("bandwidth_limit_down_kbps")
502
+ bandwidth_limit_up = template.get("bandwidth_limit_up_kbps")
503
+
504
+ # Create the profile
505
+ return await create_qos_profile(
506
+ site_id=site_id,
507
+ name=profile_name,
508
+ priority_level=priority_level,
509
+ settings=settings,
510
+ description=template["description"],
511
+ dscp_marking=dscp_marking,
512
+ bandwidth_guaranteed_down_kbps=bandwidth_down_kbps,
513
+ bandwidth_guaranteed_up_kbps=bandwidth_up_kbps,
514
+ bandwidth_limit_down_kbps=bandwidth_limit_down,
515
+ bandwidth_limit_up_kbps=bandwidth_limit_up,
516
+ ports=ports,
517
+ protocols=protocols_list,
518
+ enabled=enabled,
519
+ confirm=confirm,
520
+ dry_run=dry_run,
521
+ )
522
+
523
+
524
+ async def validate_proav_profile(
525
+ protocol: str,
526
+ settings: Settings,
527
+ bandwidth_mbps: int | None = None,
528
+ ) -> dict[str, Any]:
529
+ """Validate ProAV profile requirements and provide recommendations.
530
+
531
+ Checks if the network meets minimum requirements for the specified ProAV protocol.
532
+ Provides warnings and recommendations for optimal performance.
533
+
534
+ Args:
535
+ protocol: ProAV protocol to validate
536
+ settings: Application settings
537
+ bandwidth_mbps: Available bandwidth in Mbps (optional, for validation)
538
+
539
+ Returns:
540
+ Validation results with warnings and recommendations
541
+ """
542
+ logger.info(f"Validating ProAV profile for protocol: {protocol}")
543
+
544
+ if protocol not in PROAV_TEMPLATES:
545
+ raise ValidationError(
546
+ f"Unknown ProAV protocol '{protocol}'. "
547
+ f"Available: {', '.join(PROAV_TEMPLATES.keys())}"
548
+ )
549
+
550
+ template = PROAV_TEMPLATES[protocol]
551
+ min_bandwidth = template.get("min_bandwidth_mbps", 0)
552
+ max_latency = template.get("max_latency_ms", 100)
553
+
554
+ # Validation results
555
+ validation = {
556
+ "protocol": protocol,
557
+ "valid": True,
558
+ "warnings": [],
559
+ "recommendations": [],
560
+ }
561
+
562
+ # Check bandwidth requirements
563
+ if bandwidth_mbps is not None and bandwidth_mbps < min_bandwidth:
564
+ validation["valid"] = False
565
+ validation["warnings"].append(
566
+ f"Insufficient bandwidth: {bandwidth_mbps} Mbps available, "
567
+ f"{min_bandwidth} Mbps required"
568
+ )
569
+
570
+ # Protocol-specific recommendations
571
+ if template.get("ptp_enabled"):
572
+ validation["recommendations"].append(
573
+ f"Enable PTP (Precision Time Protocol) with domain {template.get('ptp_domain', 0)}"
574
+ )
575
+
576
+ if template.get("multicast_enabled"):
577
+ validation["recommendations"].append(
578
+ f"Configure multicast routing for {template.get('multicast_range', 'N/A')}"
579
+ )
580
+
581
+ if max_latency <= 10:
582
+ validation["recommendations"].append("Use dedicated VLAN for time-sensitive traffic")
583
+ validation["recommendations"].append("Enable hardware offload on network switches")
584
+
585
+ if min_bandwidth >= 1000: # >= 1 Gbps
586
+ validation["recommendations"].append(
587
+ "Use 10 Gbps network infrastructure for optimal performance"
588
+ )
589
+
590
+ return validation
591
+
592
+
593
+ # ============================================================================
594
+ # Smart Queue Management (3 tools)
595
+ # ============================================================================
596
+
597
+
598
+ async def get_smart_queue_config(
599
+ site_id: str,
600
+ settings: Settings,
601
+ ) -> dict[str, Any]:
602
+ """Get Smart Queue Management (SQM) configuration.
603
+
604
+ Returns the current SQM configuration for bufferbloat mitigation.
605
+
606
+ Args:
607
+ site_id: Site identifier
608
+ settings: Application settings
609
+
610
+ Returns:
611
+ SQM configuration
612
+ """
613
+ async with UniFiClient(settings) as client:
614
+ logger.info(f"Getting SQM configuration for site {site_id}")
615
+
616
+ if not client.is_authenticated:
617
+ await client.authenticate()
618
+
619
+ resolved_site_id = await client.resolve_site_id(site_id)
620
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/wanconf")
621
+ response = await client.get(endpoint)
622
+ data = response.get("data", [])
623
+
624
+ if not data:
625
+ raise ValidationError("No WAN configuration found")
626
+
627
+ # Return first WAN config (primary)
628
+ wan_config = data[0]
629
+
630
+ return { # type: ignore[no-any-return]
631
+ "wan_id": wan_config.get("_id"),
632
+ "enabled": wan_config.get("sqm_enabled", False),
633
+ "algorithm": wan_config.get("sqm_algorithm", "fq_codel"),
634
+ "download_kbps": wan_config.get("sqm_download_kbps", 0),
635
+ "upload_kbps": wan_config.get("sqm_upload_kbps", 0),
636
+ "overhead_bytes": wan_config.get("sqm_overhead_bytes", 44),
637
+ }
638
+
639
+
640
+ async def configure_smart_queue(
641
+ site_id: str,
642
+ wan_id: str,
643
+ download_kbps: int,
644
+ upload_kbps: int,
645
+ settings: Settings,
646
+ algorithm: str = "fq_codel",
647
+ overhead_bytes: int = 44,
648
+ confirm: bool = False,
649
+ dry_run: bool = False,
650
+ ) -> dict[str, Any]:
651
+ """Configure Smart Queue Management (SQM) for bufferbloat mitigation.
652
+
653
+ Enables and configures SQM using fq_codel or CAKE algorithms to reduce bufferbloat.
654
+ Set bandwidth limits to 90-95% of actual line rate for optimal performance.
655
+
656
+ Args:
657
+ site_id: Site identifier
658
+ wan_id: WAN interface ID
659
+ download_kbps: Download bandwidth in kbps (set to 90-95% of line rate)
660
+ upload_kbps: Upload bandwidth in kbps (set to 90-95% of line rate)
661
+ settings: Application settings
662
+ algorithm: Queue algorithm (fq_codel or cake)
663
+ overhead_bytes: Per-packet overhead in bytes (default: 44 for PPPoE)
664
+ confirm: Confirmation flag (required for configuration)
665
+ dry_run: If True, validate but don't execute
666
+
667
+ Returns:
668
+ SQM configuration
669
+ """
670
+ validate_confirmation(confirm, "configure Smart Queue Management")
671
+
672
+ # Validate bandwidth
673
+ if download_kbps <= 0 or upload_kbps <= 0:
674
+ raise ValidationError("Bandwidth must be greater than 0 kbps")
675
+
676
+ # Validate algorithm
677
+ if algorithm not in ["fq_codel", "cake"]:
678
+ raise ValidationError(f"Invalid algorithm '{algorithm}'. Use 'fq_codel' or 'cake'")
679
+
680
+ # Performance warnings
681
+ warnings = []
682
+ if download_kbps > 300000: # > 300 Mbps
683
+ warnings.append(
684
+ "SQM may not be effective above 300 Mbps. "
685
+ "Consider hardware-based QoS for gigabit+ connections."
686
+ )
687
+
688
+ sqm_config = {
689
+ "sqm_enabled": True,
690
+ "sqm_algorithm": algorithm,
691
+ "sqm_download_kbps": download_kbps,
692
+ "sqm_upload_kbps": upload_kbps,
693
+ "sqm_overhead_bytes": overhead_bytes,
694
+ }
695
+
696
+ if dry_run:
697
+ logger.info(f"[DRY RUN] Would configure SQM: {sqm_config}")
698
+ return {
699
+ "dry_run": True,
700
+ "wan_id": wan_id,
701
+ "config": sqm_config,
702
+ "warnings": warnings,
703
+ }
704
+
705
+ async with UniFiClient(settings) as client:
706
+ logger.info(f"Configuring SQM for WAN {wan_id} on site {site_id}")
707
+
708
+ if not client.is_authenticated:
709
+ await client.authenticate()
710
+
711
+ resolved_site_id = await client.resolve_site_id(site_id)
712
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/wanconf/{wan_id}")
713
+ response = await client.put(endpoint, json=sqm_config)
714
+
715
+ data = response.get("data", [])
716
+ if not data:
717
+ raise ValidationError(f"Failed to configure SQM for WAN {wan_id}")
718
+
719
+ await audit_action(
720
+ settings,
721
+ action="configure_smart_queue",
722
+ resource_type="wan_config",
723
+ resource_id=wan_id,
724
+ details=sqm_config,
725
+ site_id=resolved_site_id,
726
+ )
727
+
728
+ result = {
729
+ "success": True,
730
+ "wan_id": wan_id,
731
+ "config": sqm_config,
732
+ "warnings": warnings,
733
+ }
734
+
735
+ return result # type: ignore[no-any-return]
736
+
737
+
738
+ async def disable_smart_queue(
739
+ site_id: str,
740
+ wan_id: str,
741
+ settings: Settings,
742
+ confirm: bool = False,
743
+ ) -> dict[str, Any]:
744
+ """Disable Smart Queue Management (SQM).
745
+
746
+ Args:
747
+ site_id: Site identifier
748
+ wan_id: WAN interface ID
749
+ settings: Application settings
750
+ confirm: Confirmation flag (required for changes)
751
+
752
+ Returns:
753
+ Disabling confirmation
754
+ """
755
+ validate_confirmation(confirm, "disable Smart Queue Management")
756
+
757
+ async with UniFiClient(settings) as client:
758
+ logger.info(f"Disabling SQM for WAN {wan_id} on site {site_id}")
759
+
760
+ if not client.is_authenticated:
761
+ await client.authenticate()
762
+
763
+ resolved_site_id = await client.resolve_site_id(site_id)
764
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/wanconf/{wan_id}")
765
+ response = await client.put(endpoint, json={"sqm_enabled": False})
766
+
767
+ data = response.get("data", [])
768
+ if not data:
769
+ raise ValidationError(f"Failed to disable SQM for WAN {wan_id}")
770
+
771
+ await audit_action(
772
+ settings,
773
+ action="disable_smart_queue",
774
+ resource_type="wan_config",
775
+ resource_id=wan_id,
776
+ details={"sqm_enabled": False},
777
+ site_id=resolved_site_id,
778
+ )
779
+
780
+ return { # type: ignore[no-any-return]
781
+ "success": True,
782
+ "message": f"SQM disabled for WAN {wan_id}",
783
+ "wan_id": wan_id,
784
+ }
785
+
786
+
787
+ # ============================================================================
788
+ # Traffic Route Management (4 tools)
789
+ # ============================================================================
790
+
791
+
792
+ async def list_traffic_routes(
793
+ site_id: str,
794
+ settings: Settings,
795
+ limit: int = 100,
796
+ offset: int = 0,
797
+ ) -> list[dict[str, Any]]:
798
+ """List all traffic routing policies for a site.
799
+
800
+ Args:
801
+ site_id: Site identifier
802
+ settings: Application settings
803
+ limit: Maximum number of routes to return
804
+ offset: Number of routes to skip
805
+
806
+ Returns:
807
+ List of traffic routing policies
808
+ """
809
+ async with UniFiClient(settings) as client:
810
+ logger.info(f"Listing traffic routes for site {site_id} (limit={limit}, offset={offset})")
811
+
812
+ if not client.is_authenticated:
813
+ await client.authenticate()
814
+
815
+ resolved_site_id = await client.resolve_site_id(site_id)
816
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/routing")
817
+ response = await client.get(endpoint)
818
+ data = response.get("data", [])
819
+
820
+ # Apply pagination
821
+ paginated_data = data[offset : offset + limit]
822
+
823
+ return [TrafficRoute(**route).model_dump() for route in paginated_data] # type: ignore[no-any-return]
824
+
825
+
826
+ async def create_traffic_route(
827
+ site_id: str,
828
+ name: str,
829
+ action: str,
830
+ settings: Settings,
831
+ description: str | None = None,
832
+ source_ip: str | None = None,
833
+ destination_ip: str | None = None,
834
+ source_port: int | None = None,
835
+ destination_port: int | None = None,
836
+ protocol: str | None = None,
837
+ vlan_id: int | None = None,
838
+ dscp_marking: int | None = None,
839
+ bandwidth_limit_kbps: int | None = None,
840
+ priority: int = 100,
841
+ enabled: bool = True,
842
+ confirm: bool = False,
843
+ dry_run: bool = False,
844
+ ) -> dict[str, Any]:
845
+ """Create a new traffic routing policy.
846
+
847
+ Args:
848
+ site_id: Site identifier
849
+ name: Route name
850
+ action: Route action (allow, deny, mark, shape)
851
+ settings: Application settings
852
+ description: Route description
853
+ source_ip: Source IP address or CIDR
854
+ destination_ip: Destination IP address or CIDR
855
+ source_port: Source port (1-65535)
856
+ destination_port: Destination port (1-65535)
857
+ protocol: Protocol (tcp, udp, icmp, all)
858
+ vlan_id: VLAN ID (1-4094)
859
+ dscp_marking: DSCP value to mark packets (0-63, for mark action)
860
+ bandwidth_limit_kbps: Bandwidth limit in kbps (for shape action)
861
+ priority: Route priority (1-1000, lower = higher priority)
862
+ enabled: Route enabled
863
+ confirm: Confirmation flag (required for creation)
864
+ dry_run: If True, validate but don't execute
865
+
866
+ Returns:
867
+ Created traffic route
868
+ """
869
+ validate_confirmation(confirm, "create traffic route")
870
+
871
+ # Validate action
872
+ valid_actions = ["allow", "deny", "mark", "shape"]
873
+ if action not in valid_actions:
874
+ raise ValidationError(f"Invalid action '{action}'. Use: {', '.join(valid_actions)}")
875
+
876
+ # Validate DSCP marking
877
+ if dscp_marking is not None and not 0 <= dscp_marking <= 63:
878
+ raise ValidationError(f"DSCP marking must be 0-63, got {dscp_marking}")
879
+
880
+ # Validate priority
881
+ if not 1 <= priority <= 1000:
882
+ raise ValidationError(f"Priority must be 1-1000, got {priority}")
883
+
884
+ # Build match criteria
885
+ match_criteria = {}
886
+ if source_ip:
887
+ match_criteria["source_ip"] = source_ip
888
+ if destination_ip:
889
+ match_criteria["destination_ip"] = destination_ip
890
+ if source_port:
891
+ match_criteria["source_port"] = source_port
892
+ if destination_port:
893
+ match_criteria["destination_port"] = destination_port
894
+ if protocol:
895
+ match_criteria["protocol"] = protocol
896
+ if vlan_id:
897
+ match_criteria["vlan_id"] = vlan_id
898
+
899
+ # Build route data
900
+ route_data: dict[str, Any] = {
901
+ "name": name,
902
+ "action": action,
903
+ "match_criteria": match_criteria,
904
+ "priority": priority,
905
+ "enabled": enabled,
906
+ }
907
+
908
+ if description:
909
+ route_data["description"] = description
910
+ if dscp_marking is not None:
911
+ route_data["dscp_marking"] = dscp_marking
912
+ if bandwidth_limit_kbps is not None:
913
+ route_data["bandwidth_limit_kbps"] = bandwidth_limit_kbps
914
+
915
+ if dry_run:
916
+ logger.info(f"[DRY RUN] Would create traffic route: {route_data}")
917
+ return {"dry_run": True, "route": route_data}
918
+
919
+ async with UniFiClient(settings) as client:
920
+ logger.info(f"Creating traffic route '{name}' for site {site_id}")
921
+
922
+ if not client.is_authenticated:
923
+ await client.authenticate()
924
+
925
+ resolved_site_id = await client.resolve_site_id(site_id)
926
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/routing")
927
+ response = await client.post(endpoint, json=route_data)
928
+
929
+ data = response.get("data", [])
930
+ if not data:
931
+ raise ValidationError("Failed to create traffic route")
932
+
933
+ result = TrafficRoute(**data[0]).model_dump()
934
+
935
+ await audit_action(
936
+ settings,
937
+ action="create_traffic_route",
938
+ resource_type="traffic_route",
939
+ resource_id=result.get("id", "unknown"),
940
+ details={"name": name, "action": action},
941
+ site_id=resolved_site_id,
942
+ )
943
+
944
+ return result # type: ignore[no-any-return]
945
+
946
+
947
+ async def update_traffic_route(
948
+ site_id: str,
949
+ route_id: str,
950
+ settings: Settings,
951
+ name: str | None = None,
952
+ action: str | None = None,
953
+ description: str | None = None,
954
+ enabled: bool | None = None,
955
+ priority: int | None = None,
956
+ confirm: bool = False,
957
+ dry_run: bool = False,
958
+ ) -> dict[str, Any]:
959
+ """Update an existing traffic routing policy.
960
+
961
+ Args:
962
+ site_id: Site identifier
963
+ route_id: Traffic route ID to update
964
+ settings: Application settings
965
+ name: New route name
966
+ action: New route action (allow, deny, mark, shape)
967
+ description: New description
968
+ enabled: New enabled state
969
+ priority: New priority (1-1000)
970
+ confirm: Confirmation flag (required for updates)
971
+ dry_run: If True, validate but don't execute
972
+
973
+ Returns:
974
+ Updated traffic route
975
+ """
976
+ validate_confirmation(confirm, "update traffic route")
977
+
978
+ # Build update data
979
+ update_data: dict[str, Any] = {}
980
+ if name is not None:
981
+ update_data["name"] = name
982
+ if action is not None:
983
+ update_data["action"] = action
984
+ if description is not None:
985
+ update_data["description"] = description
986
+ if enabled is not None:
987
+ update_data["enabled"] = enabled
988
+ if priority is not None:
989
+ if not 1 <= priority <= 1000:
990
+ raise ValidationError(f"Priority must be 1-1000, got {priority}")
991
+ update_data["priority"] = priority
992
+
993
+ if not update_data:
994
+ raise ValidationError("No update fields provided")
995
+
996
+ if dry_run:
997
+ logger.info(f"[DRY RUN] Would update traffic route {route_id}: {update_data}")
998
+ return {"dry_run": True, "route_id": route_id, "updates": update_data}
999
+
1000
+ async with UniFiClient(settings) as client:
1001
+ logger.info(f"Updating traffic route {route_id} for site {site_id}")
1002
+
1003
+ if not client.is_authenticated:
1004
+ await client.authenticate()
1005
+
1006
+ resolved_site_id = await client.resolve_site_id(site_id)
1007
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/routing/{route_id}")
1008
+ response = await client.put(endpoint, json=update_data)
1009
+
1010
+ data = response.get("data", [])
1011
+ if not data:
1012
+ raise ValidationError(f"Failed to update traffic route {route_id}")
1013
+
1014
+ result = TrafficRoute(**data[0]).model_dump()
1015
+
1016
+ await audit_action(
1017
+ settings,
1018
+ action="update_traffic_route",
1019
+ resource_type="traffic_route",
1020
+ resource_id=route_id,
1021
+ details=update_data,
1022
+ site_id=resolved_site_id,
1023
+ )
1024
+
1025
+ return result # type: ignore[no-any-return]
1026
+
1027
+
1028
+ async def delete_traffic_route(
1029
+ site_id: str,
1030
+ route_id: str,
1031
+ settings: Settings,
1032
+ confirm: bool = False,
1033
+ ) -> dict[str, Any]:
1034
+ """Delete a traffic routing policy.
1035
+
1036
+ Args:
1037
+ site_id: Site identifier
1038
+ route_id: Traffic route ID to delete
1039
+ settings: Application settings
1040
+ confirm: Confirmation flag (required for deletion)
1041
+
1042
+ Returns:
1043
+ Deletion confirmation
1044
+ """
1045
+ validate_confirmation(confirm, "delete traffic route")
1046
+
1047
+ async with UniFiClient(settings) as client:
1048
+ logger.info(f"Deleting traffic route {route_id} for site {site_id}")
1049
+
1050
+ if not client.is_authenticated:
1051
+ await client.authenticate()
1052
+
1053
+ resolved_site_id = await client.resolve_site_id(site_id)
1054
+ endpoint = settings.get_api_path(f"s/{resolved_site_id}/rest/routing/{route_id}")
1055
+ await client.delete(endpoint)
1056
+
1057
+ await audit_action(
1058
+ settings,
1059
+ action_type="delete_traffic_route",
1060
+ resource_type="traffic_route",
1061
+ resource_id=route_id,
1062
+ details={"deleted": True},
1063
+ site_id=resolved_site_id,
1064
+ )
1065
+
1066
+ return { # type: ignore[no-any-return]
1067
+ "success": True,
1068
+ "message": f"Traffic route {route_id} deleted successfully",
1069
+ "route_id": route_id,
1070
+ }