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/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
|
+
}
|