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/wans.py ADDED
@@ -0,0 +1,30 @@
1
+ """WAN connection management tools."""
2
+
3
+ from ..api.client import UniFiClient
4
+ from ..config import Settings
5
+ from ..models import WANConnection
6
+ from ..utils import get_logger
7
+
8
+ logger = get_logger(__name__)
9
+
10
+
11
+ async def list_wan_connections(site_id: str, settings: Settings) -> list[dict]:
12
+ """List all WAN connections for a site.
13
+
14
+ Args:
15
+ site_id: Site identifier
16
+ settings: Application settings
17
+
18
+ Returns:
19
+ List of WAN connections
20
+ """
21
+ async with UniFiClient(settings) as client:
22
+ logger.info(f"Listing WAN connections for site {site_id}")
23
+
24
+ if not client.is_authenticated:
25
+ await client.authenticate()
26
+
27
+ response = await client.get(f"/integration/v1/sites/{site_id}/wans")
28
+ data = response.get("data", [])
29
+
30
+ return [WANConnection(**wan).model_dump() for wan in data]
src/tools/wifi.py ADDED
@@ -0,0 +1,498 @@
1
+ """WiFi network (SSID) management MCP tools."""
2
+
3
+ from typing import Any
4
+
5
+ from ..api import UniFiClient
6
+ from ..config import Settings
7
+ from ..utils import (
8
+ ResourceNotFoundError,
9
+ ValidationError,
10
+ get_logger,
11
+ log_audit,
12
+ validate_confirmation,
13
+ validate_limit_offset,
14
+ validate_site_id,
15
+ )
16
+
17
+
18
+ async def list_wlans(
19
+ site_id: str,
20
+ settings: Settings,
21
+ limit: int | None = None,
22
+ offset: int | None = None,
23
+ ) -> list[dict[str, Any]]:
24
+ """List all wireless networks (SSIDs) in a site (read-only).
25
+
26
+ Args:
27
+ site_id: Site identifier
28
+ settings: Application settings
29
+ limit: Maximum number of WLANs to return
30
+ offset: Number of WLANs to skip
31
+
32
+ Returns:
33
+ List of WLAN dictionaries
34
+ """
35
+ site_id = validate_site_id(site_id)
36
+ limit, offset = validate_limit_offset(limit, offset)
37
+ logger = get_logger(__name__, settings.log_level)
38
+
39
+ async with UniFiClient(settings) as client:
40
+ await client.authenticate()
41
+
42
+ response = await client.get(f"/ea/sites/{site_id}/rest/wlanconf")
43
+ # Handle both list and dict responses
44
+ wlans_data: list[dict[str, Any]] = (
45
+ response if isinstance(response, list) else response.get("data", [])
46
+ )
47
+
48
+ # Apply pagination
49
+ paginated = wlans_data[offset : offset + limit]
50
+
51
+ logger.info(f"Retrieved {len(paginated)} WLANs for site '{site_id}'")
52
+ return paginated
53
+
54
+
55
+ async def create_wlan(
56
+ site_id: str,
57
+ name: str,
58
+ security: str,
59
+ settings: Settings,
60
+ password: str | None = None,
61
+ enabled: bool = True,
62
+ is_guest: bool = False,
63
+ wpa_mode: str = "wpa2",
64
+ wpa_enc: str = "ccmp",
65
+ vlan_id: int | None = None,
66
+ hide_ssid: bool = False,
67
+ confirm: bool = False,
68
+ dry_run: bool = False,
69
+ ) -> dict[str, Any]:
70
+ """Create a new wireless network (SSID).
71
+
72
+ Args:
73
+ site_id: Site identifier
74
+ name: SSID name
75
+ security: Security type (open, wpapsk, wpaeap)
76
+ settings: Application settings
77
+ password: WiFi password (required for wpapsk)
78
+ enabled: Enable the WLAN immediately
79
+ is_guest: Mark as guest network
80
+ wpa_mode: WPA mode (wpa, wpa2, wpa3)
81
+ wpa_enc: WPA encryption (tkip, ccmp, ccmp-tkip)
82
+ vlan_id: VLAN ID for network isolation
83
+ hide_ssid: Hide SSID from broadcast
84
+ confirm: Confirmation flag (must be True to execute)
85
+ dry_run: If True, validate but don't create the WLAN
86
+
87
+ Returns:
88
+ Created WLAN dictionary or dry-run result
89
+
90
+ Raises:
91
+ ConfirmationRequiredError: If confirm is not True
92
+ ValidationError: If validation fails
93
+ """
94
+ site_id = validate_site_id(site_id)
95
+ validate_confirmation(confirm, "wifi operation")
96
+ logger = get_logger(__name__, settings.log_level)
97
+
98
+ # Validate security type
99
+ valid_security = ["open", "wpapsk", "wpaeap"]
100
+ if security not in valid_security:
101
+ raise ValidationError(
102
+ f"Invalid security type '{security}'. Must be one of: {valid_security}"
103
+ )
104
+
105
+ # Validate password required for WPA
106
+ if security == "wpapsk" and not password:
107
+ raise ValidationError("Password required for WPA/WPA2/WPA3 security")
108
+
109
+ # Validate WPA mode
110
+ valid_wpa_modes = ["wpa", "wpa2", "wpa3"]
111
+ if wpa_mode not in valid_wpa_modes:
112
+ raise ValidationError(f"Invalid WPA mode '{wpa_mode}'. Must be one of: {valid_wpa_modes}")
113
+
114
+ # Validate WPA encryption
115
+ valid_wpa_enc = ["tkip", "ccmp", "ccmp-tkip"]
116
+ if wpa_enc not in valid_wpa_enc:
117
+ raise ValidationError(
118
+ f"Invalid WPA encryption '{wpa_enc}'. Must be one of: {valid_wpa_enc}"
119
+ )
120
+
121
+ # Build WLAN data
122
+ wlan_data = {
123
+ "name": name,
124
+ "security": security,
125
+ "enabled": enabled,
126
+ "is_guest": is_guest,
127
+ "hide_ssid": hide_ssid,
128
+ }
129
+
130
+ if security == "wpapsk":
131
+ wlan_data["x_passphrase"] = password
132
+ wlan_data["wpa_mode"] = wpa_mode
133
+ wlan_data["wpa_enc"] = wpa_enc
134
+
135
+ if vlan_id is not None:
136
+ if not 1 <= vlan_id <= 4094:
137
+ raise ValidationError(f"Invalid VLAN ID {vlan_id}. Must be between 1 and 4094")
138
+ wlan_data["vlan"] = vlan_id
139
+ wlan_data["vlan_enabled"] = True
140
+
141
+ # Log parameters for audit (mask password)
142
+ parameters = {
143
+ "site_id": site_id,
144
+ "name": name,
145
+ "security": security,
146
+ "enabled": enabled,
147
+ "is_guest": is_guest,
148
+ "vlan_id": vlan_id,
149
+ "hide_ssid": hide_ssid,
150
+ "password": "***MASKED***" if password else None,
151
+ }
152
+
153
+ if dry_run:
154
+ logger.info(f"DRY RUN: Would create WLAN '{name}' in site '{site_id}'")
155
+ log_audit(
156
+ operation="create_wlan",
157
+ parameters=parameters,
158
+ result="dry_run",
159
+ site_id=site_id,
160
+ dry_run=True,
161
+ )
162
+ # Don't include password in dry-run output
163
+ safe_data = {k: v for k, v in wlan_data.items() if k != "x_passphrase"}
164
+ return {"dry_run": True, "would_create": safe_data}
165
+
166
+ try:
167
+ async with UniFiClient(settings) as client:
168
+ await client.authenticate()
169
+
170
+ response = await client.post(f"/ea/sites/{site_id}/rest/wlanconf", json_data=wlan_data)
171
+ created_wlan: dict[str, Any] = response.get("data", [{}])[0]
172
+
173
+ logger.info(f"Created WLAN '{name}' in site '{site_id}'")
174
+ log_audit(
175
+ operation="create_wlan",
176
+ parameters=parameters,
177
+ result="success",
178
+ site_id=site_id,
179
+ )
180
+
181
+ return created_wlan
182
+
183
+ except Exception as e:
184
+ logger.error(f"Failed to create WLAN '{name}': {e}")
185
+ log_audit(
186
+ operation="create_wlan",
187
+ parameters=parameters,
188
+ result="failed",
189
+ site_id=site_id,
190
+ )
191
+ raise
192
+
193
+
194
+ async def update_wlan(
195
+ site_id: str,
196
+ wlan_id: str,
197
+ settings: Settings,
198
+ name: str | None = None,
199
+ security: str | None = None,
200
+ password: str | None = None,
201
+ enabled: bool | None = None,
202
+ is_guest: bool | None = None,
203
+ wpa_mode: str | None = None,
204
+ wpa_enc: str | None = None,
205
+ vlan_id: int | None = None,
206
+ hide_ssid: bool | None = None,
207
+ confirm: bool = False,
208
+ dry_run: bool = False,
209
+ ) -> dict[str, Any]:
210
+ """Update an existing wireless network.
211
+
212
+ Args:
213
+ site_id: Site identifier
214
+ wlan_id: WLAN ID
215
+ settings: Application settings
216
+ name: New SSID name
217
+ security: New security type (open, wpapsk, wpaeap)
218
+ password: New WiFi password
219
+ enabled: Enable/disable the WLAN
220
+ is_guest: Mark as guest network
221
+ wpa_mode: New WPA mode (wpa, wpa2, wpa3)
222
+ wpa_enc: New WPA encryption (tkip, ccmp, ccmp-tkip)
223
+ vlan_id: New VLAN ID
224
+ hide_ssid: Hide/show SSID from broadcast
225
+ confirm: Confirmation flag (must be True to execute)
226
+ dry_run: If True, validate but don't update the WLAN
227
+
228
+ Returns:
229
+ Updated WLAN dictionary or dry-run result
230
+
231
+ Raises:
232
+ ConfirmationRequiredError: If confirm is not True
233
+ ResourceNotFoundError: If WLAN not found
234
+ """
235
+ site_id = validate_site_id(site_id)
236
+ validate_confirmation(confirm, "wifi operation")
237
+ logger = get_logger(__name__, settings.log_level)
238
+
239
+ # Validate security type if provided
240
+ if security is not None:
241
+ valid_security = ["open", "wpapsk", "wpaeap"]
242
+ if security not in valid_security:
243
+ raise ValidationError(
244
+ f"Invalid security type '{security}'. Must be one of: {valid_security}"
245
+ )
246
+
247
+ # Validate WPA mode if provided
248
+ if wpa_mode is not None:
249
+ valid_wpa_modes = ["wpa", "wpa2", "wpa3"]
250
+ if wpa_mode not in valid_wpa_modes:
251
+ raise ValidationError(
252
+ f"Invalid WPA mode '{wpa_mode}'. Must be one of: {valid_wpa_modes}"
253
+ )
254
+
255
+ # Validate WPA encryption if provided
256
+ if wpa_enc is not None:
257
+ valid_wpa_enc = ["tkip", "ccmp", "ccmp-tkip"]
258
+ if wpa_enc not in valid_wpa_enc:
259
+ raise ValidationError(
260
+ f"Invalid WPA encryption '{wpa_enc}'. Must be one of: {valid_wpa_enc}"
261
+ )
262
+
263
+ # Validate VLAN ID if provided
264
+ if vlan_id is not None and not 1 <= vlan_id <= 4094:
265
+ raise ValidationError(f"Invalid VLAN ID {vlan_id}. Must be between 1 and 4094")
266
+
267
+ parameters = {
268
+ "site_id": site_id,
269
+ "wlan_id": wlan_id,
270
+ "name": name,
271
+ "security": security,
272
+ "enabled": enabled,
273
+ "is_guest": is_guest,
274
+ "vlan_id": vlan_id,
275
+ "hide_ssid": hide_ssid,
276
+ "password": "***MASKED***" if password else None,
277
+ }
278
+
279
+ if dry_run:
280
+ logger.info(f"DRY RUN: Would update WLAN '{wlan_id}' in site '{site_id}'")
281
+ log_audit(
282
+ operation="update_wlan",
283
+ parameters=parameters,
284
+ result="dry_run",
285
+ site_id=site_id,
286
+ dry_run=True,
287
+ )
288
+ return {"dry_run": True, "would_update": parameters}
289
+
290
+ try:
291
+ async with UniFiClient(settings) as client:
292
+ await client.authenticate()
293
+
294
+ # Get existing WLAN
295
+ response = await client.get(f"/ea/sites/{site_id}/rest/wlanconf")
296
+ wlans_data: list[dict[str, Any]] = response.get("data", [])
297
+
298
+ existing_wlan = None
299
+ for wlan in wlans_data:
300
+ if wlan.get("_id") == wlan_id:
301
+ existing_wlan = wlan
302
+ break
303
+
304
+ if not existing_wlan:
305
+ raise ResourceNotFoundError("wlan", wlan_id)
306
+
307
+ # Build update data
308
+ update_data = existing_wlan.copy()
309
+
310
+ if name is not None:
311
+ update_data["name"] = name
312
+ if security is not None:
313
+ update_data["security"] = security
314
+ if password is not None:
315
+ update_data["x_passphrase"] = password
316
+ if enabled is not None:
317
+ update_data["enabled"] = enabled
318
+ if is_guest is not None:
319
+ update_data["is_guest"] = is_guest
320
+ if wpa_mode is not None:
321
+ update_data["wpa_mode"] = wpa_mode
322
+ if wpa_enc is not None:
323
+ update_data["wpa_enc"] = wpa_enc
324
+ if vlan_id is not None:
325
+ update_data["vlan"] = vlan_id
326
+ update_data["vlan_enabled"] = True
327
+ if hide_ssid is not None:
328
+ update_data["hide_ssid"] = hide_ssid
329
+
330
+ response = await client.put(
331
+ f"/ea/sites/{site_id}/rest/wlanconf/{wlan_id}", json_data=update_data
332
+ )
333
+ updated_wlan: dict[str, Any] = response.get("data", [{}])[0]
334
+
335
+ logger.info(f"Updated WLAN '{wlan_id}' in site '{site_id}'")
336
+ log_audit(
337
+ operation="update_wlan",
338
+ parameters=parameters,
339
+ result="success",
340
+ site_id=site_id,
341
+ )
342
+
343
+ return updated_wlan
344
+
345
+ except Exception as e:
346
+ logger.error(f"Failed to update WLAN '{wlan_id}': {e}")
347
+ log_audit(
348
+ operation="update_wlan",
349
+ parameters=parameters,
350
+ result="failed",
351
+ site_id=site_id,
352
+ )
353
+ raise
354
+
355
+
356
+ async def delete_wlan(
357
+ site_id: str,
358
+ wlan_id: str,
359
+ settings: Settings,
360
+ confirm: bool = False,
361
+ dry_run: bool = False,
362
+ ) -> dict[str, Any]:
363
+ """Delete a wireless network.
364
+
365
+ Args:
366
+ site_id: Site identifier
367
+ wlan_id: WLAN ID
368
+ settings: Application settings
369
+ confirm: Confirmation flag (must be True to execute)
370
+ dry_run: If True, validate but don't delete the WLAN
371
+
372
+ Returns:
373
+ Deletion result dictionary
374
+
375
+ Raises:
376
+ ConfirmationRequiredError: If confirm is not True
377
+ ResourceNotFoundError: If WLAN not found
378
+ """
379
+ site_id = validate_site_id(site_id)
380
+ validate_confirmation(confirm, "wifi operation")
381
+ logger = get_logger(__name__, settings.log_level)
382
+
383
+ parameters = {"site_id": site_id, "wlan_id": wlan_id}
384
+
385
+ if dry_run:
386
+ logger.info(f"DRY RUN: Would delete WLAN '{wlan_id}' from site '{site_id}'")
387
+ log_audit(
388
+ operation="delete_wlan",
389
+ parameters=parameters,
390
+ result="dry_run",
391
+ site_id=site_id,
392
+ dry_run=True,
393
+ )
394
+ return {"dry_run": True, "would_delete": wlan_id}
395
+
396
+ try:
397
+ async with UniFiClient(settings) as client:
398
+ await client.authenticate()
399
+
400
+ # Verify WLAN exists before deleting
401
+ response = await client.get(f"/ea/sites/{site_id}/rest/wlanconf")
402
+ wlans_data: list[dict[str, Any]] = response.get("data", [])
403
+
404
+ wlan_exists = any(wlan.get("_id") == wlan_id for wlan in wlans_data)
405
+ if not wlan_exists:
406
+ raise ResourceNotFoundError("wlan", wlan_id)
407
+
408
+ response = await client.delete(f"/ea/sites/{site_id}/rest/wlanconf/{wlan_id}")
409
+
410
+ logger.info(f"Deleted WLAN '{wlan_id}' from site '{site_id}'")
411
+ log_audit(
412
+ operation="delete_wlan",
413
+ parameters=parameters,
414
+ result="success",
415
+ site_id=site_id,
416
+ )
417
+
418
+ return {"success": True, "deleted_wlan_id": wlan_id}
419
+
420
+ except Exception as e:
421
+ logger.error(f"Failed to delete WLAN '{wlan_id}': {e}")
422
+ log_audit(
423
+ operation="delete_wlan",
424
+ parameters=parameters,
425
+ result="failed",
426
+ site_id=site_id,
427
+ )
428
+ raise
429
+
430
+
431
+ async def get_wlan_statistics(
432
+ site_id: str,
433
+ settings: Settings,
434
+ wlan_id: str | None = None,
435
+ ) -> dict[str, Any]:
436
+ """Get WiFi usage statistics.
437
+
438
+ Args:
439
+ site_id: Site identifier
440
+ settings: Application settings
441
+ wlan_id: Optional WLAN ID to filter statistics
442
+
443
+ Returns:
444
+ WLAN statistics dictionary
445
+ """
446
+ site_id = validate_site_id(site_id)
447
+ logger = get_logger(__name__, settings.log_level)
448
+
449
+ async with UniFiClient(settings) as client:
450
+ await client.authenticate()
451
+
452
+ # Get WLANs
453
+ wlans_response = await client.get(f"/ea/sites/{site_id}/rest/wlanconf")
454
+ wlans_data = wlans_response.get("data", [])
455
+
456
+ # Get active clients
457
+ clients_response = await client.get(f"/ea/sites/{site_id}/sta")
458
+ clients_data = clients_response.get("data", [])
459
+
460
+ # Calculate statistics per WLAN
461
+ wlan_stats = []
462
+ for wlan in wlans_data:
463
+ wlan_identifier = wlan.get("_id")
464
+ wlan_name = wlan.get("name")
465
+
466
+ # Skip if filtering by WLAN ID and this isn't it
467
+ if wlan_id and wlan_identifier != wlan_id:
468
+ continue
469
+
470
+ # Count clients on this WLAN (match by essid/name)
471
+ clients_on_wlan = [
472
+ c for c in clients_data if c.get("essid") == wlan_name or c.get("is_wired") is False
473
+ ]
474
+
475
+ # Calculate total bandwidth
476
+ total_tx = sum(c.get("tx_bytes", 0) for c in clients_on_wlan)
477
+ total_rx = sum(c.get("rx_bytes", 0) for c in clients_on_wlan)
478
+
479
+ wlan_stats.append(
480
+ {
481
+ "wlan_id": wlan_identifier,
482
+ "name": wlan_name,
483
+ "enabled": wlan.get("enabled", False),
484
+ "security": wlan.get("security"),
485
+ "is_guest": wlan.get("is_guest", False),
486
+ "client_count": len(clients_on_wlan),
487
+ "total_tx_bytes": total_tx,
488
+ "total_rx_bytes": total_rx,
489
+ "total_bytes": total_tx + total_rx,
490
+ }
491
+ )
492
+
493
+ logger.info(f"Retrieved WLAN statistics for site '{site_id}'")
494
+
495
+ if wlan_id:
496
+ return wlan_stats[0] if wlan_stats else {}
497
+ else:
498
+ return {"site_id": site_id, "wlans": wlan_stats}