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
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Reference data MCP tools for supporting resources."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models.reference_data import Country, DeviceTag
|
|
8
|
+
from ..utils import get_logger, validate_limit_offset, validate_site_id
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def list_radius_profiles(
|
|
12
|
+
site_id: str,
|
|
13
|
+
settings: Settings,
|
|
14
|
+
limit: int | None = None,
|
|
15
|
+
offset: int | None = None,
|
|
16
|
+
) -> list[dict[str, Any]]:
|
|
17
|
+
"""List all RADIUS profiles in a site (read-only).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
site_id: Site identifier
|
|
21
|
+
settings: Application settings
|
|
22
|
+
limit: Maximum number of profiles to return
|
|
23
|
+
offset: Number of profiles to skip
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of RADIUS profile dictionaries
|
|
27
|
+
"""
|
|
28
|
+
site_id = validate_site_id(site_id)
|
|
29
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
30
|
+
logger = get_logger(__name__, settings.log_level)
|
|
31
|
+
|
|
32
|
+
async with UniFiClient(settings) as client:
|
|
33
|
+
await client.authenticate()
|
|
34
|
+
|
|
35
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/radius/profiles")
|
|
36
|
+
profiles_data: list[dict[str, Any]] = response.get("data", [])
|
|
37
|
+
|
|
38
|
+
# Apply pagination
|
|
39
|
+
paginated = profiles_data[offset : offset + limit]
|
|
40
|
+
|
|
41
|
+
logger.info(f"Retrieved {len(paginated)} RADIUS profiles for site '{site_id}'")
|
|
42
|
+
return paginated
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def list_device_tags(
|
|
46
|
+
site_id: str,
|
|
47
|
+
settings: Settings,
|
|
48
|
+
limit: int | None = None,
|
|
49
|
+
offset: int | None = None,
|
|
50
|
+
) -> list[dict[str, Any]]:
|
|
51
|
+
"""List all device tags in a site (read-only).
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
site_id: Site identifier
|
|
55
|
+
settings: Application settings
|
|
56
|
+
limit: Maximum number of tags to return
|
|
57
|
+
offset: Number of tags to skip
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
List of device tag dictionaries
|
|
61
|
+
"""
|
|
62
|
+
site_id = validate_site_id(site_id)
|
|
63
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
64
|
+
logger = get_logger(__name__, settings.log_level)
|
|
65
|
+
|
|
66
|
+
async with UniFiClient(settings) as client:
|
|
67
|
+
await client.authenticate()
|
|
68
|
+
|
|
69
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/device-tags")
|
|
70
|
+
tags_data: list[dict[str, Any]] = response.get("data", [])
|
|
71
|
+
|
|
72
|
+
# Apply pagination
|
|
73
|
+
paginated = tags_data[offset : offset + limit]
|
|
74
|
+
|
|
75
|
+
logger.info(f"Retrieved {len(paginated)} device tags for site '{site_id}'")
|
|
76
|
+
return [DeviceTag(**tag).model_dump() for tag in paginated]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def list_countries(
|
|
80
|
+
settings: Settings,
|
|
81
|
+
limit: int | None = None,
|
|
82
|
+
offset: int | None = None,
|
|
83
|
+
) -> list[dict[str, Any]]:
|
|
84
|
+
"""List all countries with ISO codes (read-only).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
settings: Application settings
|
|
88
|
+
limit: Maximum number of countries to return
|
|
89
|
+
offset: Number of countries to skip
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
List of country dictionaries
|
|
93
|
+
"""
|
|
94
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
95
|
+
logger = get_logger(__name__, settings.log_level)
|
|
96
|
+
|
|
97
|
+
async with UniFiClient(settings) as client:
|
|
98
|
+
await client.authenticate()
|
|
99
|
+
|
|
100
|
+
response = await client.get("/integration/v1/countries")
|
|
101
|
+
countries_data: list[dict[str, Any]] = response.get("data", [])
|
|
102
|
+
|
|
103
|
+
# Apply pagination
|
|
104
|
+
paginated = countries_data[offset : offset + limit]
|
|
105
|
+
|
|
106
|
+
logger.info(f"Retrieved {len(paginated)} countries")
|
|
107
|
+
return [Country(**country).model_dump() for country in paginated]
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""Site Manager API tools for multi-site management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api.site_manager_client import SiteManagerClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models.site_manager import (
|
|
8
|
+
CrossSitePerformanceComparison,
|
|
9
|
+
CrossSiteSearchResult,
|
|
10
|
+
CrossSiteStatistics,
|
|
11
|
+
InternetHealthMetrics,
|
|
12
|
+
SiteHealthSummary,
|
|
13
|
+
SiteInventory,
|
|
14
|
+
SitePerformanceMetrics,
|
|
15
|
+
VantagePoint,
|
|
16
|
+
)
|
|
17
|
+
from ..utils import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def list_all_sites_aggregated(settings: Settings) -> list[dict[str, Any]]:
|
|
23
|
+
"""List all sites with aggregated stats from Site Manager API.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
settings: Application settings
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of sites with aggregated statistics
|
|
30
|
+
"""
|
|
31
|
+
if not settings.site_manager_enabled:
|
|
32
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
33
|
+
|
|
34
|
+
async with SiteManagerClient(settings) as client:
|
|
35
|
+
logger.info("Retrieving aggregated site list from Site Manager API")
|
|
36
|
+
|
|
37
|
+
response = await client.list_sites()
|
|
38
|
+
sites_data = response.get("data", response.get("sites", []))
|
|
39
|
+
|
|
40
|
+
# Enhance with aggregated stats if available
|
|
41
|
+
sites: list[dict[str, Any]] = []
|
|
42
|
+
for site in sites_data:
|
|
43
|
+
sites.append(site)
|
|
44
|
+
|
|
45
|
+
return sites
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def get_internet_health(settings: Settings, site_id: str | None = None) -> dict[str, Any]:
|
|
49
|
+
"""Get internet health metrics across sites.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
settings: Application settings
|
|
53
|
+
site_id: Optional site identifier. If None, returns aggregate metrics.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Internet health metrics
|
|
57
|
+
"""
|
|
58
|
+
if not settings.site_manager_enabled:
|
|
59
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
60
|
+
|
|
61
|
+
async with SiteManagerClient(settings) as client:
|
|
62
|
+
logger.info(f"Retrieving internet health metrics (site_id={site_id})")
|
|
63
|
+
|
|
64
|
+
response = await client.get_internet_health(site_id)
|
|
65
|
+
data = response.get("data", response)
|
|
66
|
+
|
|
67
|
+
return InternetHealthMetrics(**data).model_dump() # type: ignore[no-any-return]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def get_site_health_summary(
|
|
71
|
+
settings: Settings, site_id: str | None = None
|
|
72
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
73
|
+
"""Get health summary for all sites or a specific site.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
settings: Application settings
|
|
77
|
+
site_id: Optional site identifier. If None, returns summary for all sites.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Health summary
|
|
81
|
+
"""
|
|
82
|
+
if not settings.site_manager_enabled:
|
|
83
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
84
|
+
|
|
85
|
+
async with SiteManagerClient(settings) as client:
|
|
86
|
+
logger.info(f"Retrieving site health summary (site_id={site_id})")
|
|
87
|
+
|
|
88
|
+
response = await client.get_site_health(site_id)
|
|
89
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
90
|
+
data = response
|
|
91
|
+
|
|
92
|
+
if site_id:
|
|
93
|
+
return SiteHealthSummary(**data).model_dump() # type: ignore[no-any-return]
|
|
94
|
+
else:
|
|
95
|
+
# Multiple sites - response is already a list or dict with sites
|
|
96
|
+
summaries = data.get("sites", []) if isinstance(data, dict) else data
|
|
97
|
+
return [SiteHealthSummary(**summary).model_dump() for summary in summaries]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def get_cross_site_statistics(settings: Settings) -> dict[str, Any]:
|
|
101
|
+
"""Get aggregate statistics across multiple sites.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
settings: Application settings
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Cross-site statistics
|
|
108
|
+
"""
|
|
109
|
+
if not settings.site_manager_enabled:
|
|
110
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
111
|
+
|
|
112
|
+
async with SiteManagerClient(settings) as client:
|
|
113
|
+
logger.info("Retrieving cross-site statistics")
|
|
114
|
+
|
|
115
|
+
# Get all sites with health
|
|
116
|
+
sites_response = await client.list_sites()
|
|
117
|
+
sites_data = sites_response.get("data", sites_response.get("sites", []))
|
|
118
|
+
|
|
119
|
+
health_response = await client.get_site_health()
|
|
120
|
+
health_data = health_response.get("data", health_response)
|
|
121
|
+
|
|
122
|
+
# Aggregate statistics
|
|
123
|
+
total_sites = len(sites_data)
|
|
124
|
+
sites_healthy = 0
|
|
125
|
+
sites_degraded = 0
|
|
126
|
+
sites_down = 0
|
|
127
|
+
total_devices = 0
|
|
128
|
+
devices_online = 0
|
|
129
|
+
total_clients = 0
|
|
130
|
+
total_bandwidth_up_mbps = 0.0
|
|
131
|
+
total_bandwidth_down_mbps = 0.0
|
|
132
|
+
|
|
133
|
+
site_summaries: list[SiteHealthSummary] = []
|
|
134
|
+
if isinstance(health_data, list):
|
|
135
|
+
for health in health_data:
|
|
136
|
+
status = health.get("status", "unknown")
|
|
137
|
+
if status == "healthy":
|
|
138
|
+
sites_healthy += 1
|
|
139
|
+
elif status == "degraded":
|
|
140
|
+
sites_degraded += 1
|
|
141
|
+
elif status == "down":
|
|
142
|
+
sites_down += 1
|
|
143
|
+
|
|
144
|
+
site_summaries.append(SiteHealthSummary(**health))
|
|
145
|
+
total_devices += health.get("devices_total", 0)
|
|
146
|
+
devices_online += health.get("devices_online", 0)
|
|
147
|
+
total_clients += health.get("clients_active", 0)
|
|
148
|
+
|
|
149
|
+
return CrossSiteStatistics( # type: ignore[no-any-return]
|
|
150
|
+
total_sites=total_sites,
|
|
151
|
+
sites_healthy=sites_healthy,
|
|
152
|
+
sites_degraded=sites_degraded,
|
|
153
|
+
sites_down=sites_down,
|
|
154
|
+
total_devices=total_devices,
|
|
155
|
+
devices_online=devices_online,
|
|
156
|
+
total_clients=total_clients,
|
|
157
|
+
total_bandwidth_up_mbps=total_bandwidth_up_mbps,
|
|
158
|
+
total_bandwidth_down_mbps=total_bandwidth_down_mbps,
|
|
159
|
+
site_summaries=site_summaries,
|
|
160
|
+
).model_dump()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
async def list_vantage_points(settings: Settings) -> list[dict[str, Any]]:
|
|
164
|
+
"""List all Vantage Points.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
settings: Application settings
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
List of Vantage Points
|
|
171
|
+
"""
|
|
172
|
+
if not settings.site_manager_enabled:
|
|
173
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
174
|
+
|
|
175
|
+
async with SiteManagerClient(settings) as client:
|
|
176
|
+
logger.info("Retrieving Vantage Points")
|
|
177
|
+
|
|
178
|
+
response = await client.list_vantage_points()
|
|
179
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
180
|
+
data = response.get("vantage_points", []) if isinstance(response, dict) else response
|
|
181
|
+
|
|
182
|
+
return [VantagePoint(**vp).model_dump() for vp in data]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
async def get_site_inventory(
|
|
186
|
+
settings: Settings, site_id: str | None = None
|
|
187
|
+
) -> dict[str, Any] | list[dict[str, Any]]:
|
|
188
|
+
"""Get comprehensive inventory for a site or all sites.
|
|
189
|
+
|
|
190
|
+
Provides detailed breakdown of resources including devices, clients,
|
|
191
|
+
networks, SSIDs, VPN tunnels, and firewall rules.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
settings: Application settings
|
|
195
|
+
site_id: Optional site identifier. If None, returns inventory for all sites.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Site inventory or list of site inventories
|
|
199
|
+
"""
|
|
200
|
+
if not settings.site_manager_enabled:
|
|
201
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
202
|
+
|
|
203
|
+
async with SiteManagerClient(settings) as client:
|
|
204
|
+
logger.info(f"Retrieving site inventory (site_id={site_id})")
|
|
205
|
+
|
|
206
|
+
if site_id:
|
|
207
|
+
# Get inventory for specific site
|
|
208
|
+
site_response = await client.get(f"sites/{site_id}")
|
|
209
|
+
site_data = site_response.get("data", site_response)
|
|
210
|
+
|
|
211
|
+
# Fetch detailed counts (these would come from various endpoints)
|
|
212
|
+
# For now, using available data from site response
|
|
213
|
+
inventory = SiteInventory(
|
|
214
|
+
site_id=site_id,
|
|
215
|
+
site_name=site_data.get("name", site_id),
|
|
216
|
+
device_count=site_data.get("device_count", 0),
|
|
217
|
+
device_types=site_data.get("device_types", {}),
|
|
218
|
+
client_count=site_data.get("client_count", 0),
|
|
219
|
+
network_count=site_data.get("network_count", 0),
|
|
220
|
+
ssid_count=site_data.get("ssid_count", 0),
|
|
221
|
+
uplink_count=site_data.get("uplink_count", 0),
|
|
222
|
+
vpn_tunnel_count=site_data.get("vpn_tunnel_count", 0),
|
|
223
|
+
firewall_rule_count=site_data.get("firewall_rule_count", 0),
|
|
224
|
+
last_updated=site_data.get("last_updated", ""),
|
|
225
|
+
)
|
|
226
|
+
return inventory.model_dump() # type: ignore[no-any-return]
|
|
227
|
+
else:
|
|
228
|
+
# Get inventory for all sites
|
|
229
|
+
sites_response = await client.list_sites()
|
|
230
|
+
sites_data = sites_response.get("data", sites_response.get("sites", []))
|
|
231
|
+
|
|
232
|
+
inventories = []
|
|
233
|
+
for site in sites_data:
|
|
234
|
+
inventory = SiteInventory(
|
|
235
|
+
site_id=site.get("site_id", ""),
|
|
236
|
+
site_name=site.get("name", ""),
|
|
237
|
+
device_count=site.get("device_count", 0),
|
|
238
|
+
device_types=site.get("device_types", {}),
|
|
239
|
+
client_count=site.get("client_count", 0),
|
|
240
|
+
network_count=site.get("network_count", 0),
|
|
241
|
+
ssid_count=site.get("ssid_count", 0),
|
|
242
|
+
uplink_count=site.get("uplink_count", 0),
|
|
243
|
+
vpn_tunnel_count=site.get("vpn_tunnel_count", 0),
|
|
244
|
+
firewall_rule_count=site.get("firewall_rule_count", 0),
|
|
245
|
+
last_updated=site.get("last_updated", ""),
|
|
246
|
+
)
|
|
247
|
+
inventories.append(inventory.model_dump())
|
|
248
|
+
|
|
249
|
+
return inventories
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def compare_site_performance(settings: Settings) -> dict[str, Any]:
|
|
253
|
+
"""Compare performance metrics across all sites.
|
|
254
|
+
|
|
255
|
+
Analyzes uptime, latency, bandwidth, and health status to identify
|
|
256
|
+
best and worst performing sites.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
settings: Application settings
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Performance comparison with rankings and metrics
|
|
263
|
+
"""
|
|
264
|
+
if not settings.site_manager_enabled:
|
|
265
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
266
|
+
|
|
267
|
+
async with SiteManagerClient(settings) as client:
|
|
268
|
+
logger.info("Comparing performance across sites")
|
|
269
|
+
|
|
270
|
+
# Get site health data
|
|
271
|
+
health_response = await client.get_site_health()
|
|
272
|
+
health_data = health_response.get("data", health_response)
|
|
273
|
+
|
|
274
|
+
# Get internet health data for bandwidth/latency
|
|
275
|
+
internet_response = await client.get_internet_health()
|
|
276
|
+
internet_data = internet_response.get("data", internet_response)
|
|
277
|
+
|
|
278
|
+
site_metrics: list[SitePerformanceMetrics] = []
|
|
279
|
+
|
|
280
|
+
# Process health data
|
|
281
|
+
if isinstance(health_data, list):
|
|
282
|
+
for health in health_data:
|
|
283
|
+
site_id = health.get("site_id", "")
|
|
284
|
+
|
|
285
|
+
# Calculate device online percentage
|
|
286
|
+
devices_total = health.get("devices_total", 0)
|
|
287
|
+
devices_online = health.get("devices_online", 0)
|
|
288
|
+
device_online_pct = (
|
|
289
|
+
(devices_online / devices_total * 100) if devices_total > 0 else 0.0
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Find matching internet health data
|
|
293
|
+
internet_health = None
|
|
294
|
+
if isinstance(internet_data, list):
|
|
295
|
+
internet_health = next(
|
|
296
|
+
(i for i in internet_data if i.get("site_id") == site_id), None
|
|
297
|
+
)
|
|
298
|
+
elif isinstance(internet_data, dict) and internet_data.get("site_id") == site_id:
|
|
299
|
+
internet_health = internet_data
|
|
300
|
+
|
|
301
|
+
metrics = SitePerformanceMetrics(
|
|
302
|
+
site_id=site_id,
|
|
303
|
+
site_name=health.get("site_name", site_id),
|
|
304
|
+
avg_latency_ms=internet_health.get("latency_ms") if internet_health else None,
|
|
305
|
+
avg_bandwidth_up_mbps=(
|
|
306
|
+
internet_health.get("bandwidth_up_mbps") if internet_health else None
|
|
307
|
+
),
|
|
308
|
+
avg_bandwidth_down_mbps=(
|
|
309
|
+
internet_health.get("bandwidth_down_mbps") if internet_health else None
|
|
310
|
+
),
|
|
311
|
+
uptime_percentage=health.get("uptime_percentage", 0.0),
|
|
312
|
+
device_online_percentage=device_online_pct,
|
|
313
|
+
client_count=health.get("clients_active", 0),
|
|
314
|
+
health_status=health.get("status", "down"),
|
|
315
|
+
)
|
|
316
|
+
site_metrics.append(metrics)
|
|
317
|
+
|
|
318
|
+
# Calculate best and worst performers
|
|
319
|
+
# Best = highest uptime and device online percentage
|
|
320
|
+
best_site = None
|
|
321
|
+
worst_site = None
|
|
322
|
+
|
|
323
|
+
if site_metrics:
|
|
324
|
+
# Sort by uptime (primary) and device online percentage (secondary)
|
|
325
|
+
sorted_sites = sorted(
|
|
326
|
+
site_metrics,
|
|
327
|
+
key=lambda s: (s.uptime_percentage, s.device_online_percentage),
|
|
328
|
+
reverse=True,
|
|
329
|
+
)
|
|
330
|
+
best_site = sorted_sites[0] if sorted_sites else None
|
|
331
|
+
worst_site = sorted_sites[-1] if sorted_sites else None
|
|
332
|
+
|
|
333
|
+
# Calculate average uptime
|
|
334
|
+
avg_uptime = (
|
|
335
|
+
sum(m.uptime_percentage for m in site_metrics) / len(site_metrics)
|
|
336
|
+
if site_metrics
|
|
337
|
+
else 0.0
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
# Calculate average latency (excluding None values)
|
|
341
|
+
latencies = [m.avg_latency_ms for m in site_metrics if m.avg_latency_ms is not None]
|
|
342
|
+
avg_latency = sum(latencies) / len(latencies) if latencies else None
|
|
343
|
+
|
|
344
|
+
comparison = CrossSitePerformanceComparison(
|
|
345
|
+
total_sites=len(site_metrics),
|
|
346
|
+
best_performing_site=best_site,
|
|
347
|
+
worst_performing_site=worst_site,
|
|
348
|
+
average_uptime=avg_uptime,
|
|
349
|
+
average_latency_ms=avg_latency,
|
|
350
|
+
site_metrics=site_metrics,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return comparison.model_dump() # type: ignore[no-any-return]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def search_across_sites(
|
|
357
|
+
settings: Settings,
|
|
358
|
+
query: str,
|
|
359
|
+
search_type: str = "all",
|
|
360
|
+
) -> dict[str, Any]:
|
|
361
|
+
"""Search for resources across all sites.
|
|
362
|
+
|
|
363
|
+
Search for devices, clients, or networks across all managed sites.
|
|
364
|
+
Useful for locating resources in multi-site deployments.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
settings: Application settings
|
|
368
|
+
query: Search query (device name, MAC address, client name, network name)
|
|
369
|
+
search_type: Type of search - "device", "client", "network", or "all"
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Search results with site context
|
|
373
|
+
"""
|
|
374
|
+
if not settings.site_manager_enabled:
|
|
375
|
+
raise ValueError("Site Manager API is not enabled. Set UNIFI_SITE_MANAGER_ENABLED=true")
|
|
376
|
+
|
|
377
|
+
valid_types = ["device", "client", "network", "all"]
|
|
378
|
+
if search_type not in valid_types:
|
|
379
|
+
raise ValueError(f"search_type must be one of {valid_types}, got '{search_type}'")
|
|
380
|
+
|
|
381
|
+
async with SiteManagerClient(settings) as client:
|
|
382
|
+
logger.info(f"Searching across sites: query='{query}', type={search_type}")
|
|
383
|
+
|
|
384
|
+
# Get all sites first
|
|
385
|
+
sites_response = await client.list_sites()
|
|
386
|
+
sites_data = sites_response.get("data", sites_response.get("sites", []))
|
|
387
|
+
|
|
388
|
+
results: list[dict[str, Any]] = []
|
|
389
|
+
query_lower = query.lower()
|
|
390
|
+
|
|
391
|
+
# Search across each site
|
|
392
|
+
for site in sites_data:
|
|
393
|
+
site_id = site.get("site_id", "")
|
|
394
|
+
site_name = site.get("name", site_id)
|
|
395
|
+
|
|
396
|
+
# Search devices
|
|
397
|
+
if search_type in ["device", "all"]:
|
|
398
|
+
try:
|
|
399
|
+
# This would query the devices endpoint for each site
|
|
400
|
+
# For now, checking if site data includes device information
|
|
401
|
+
devices = site.get("devices", [])
|
|
402
|
+
for device in devices:
|
|
403
|
+
device_name = device.get("name", "").lower()
|
|
404
|
+
device_mac = device.get("mac", "").lower()
|
|
405
|
+
if query_lower in device_name or query_lower in device_mac:
|
|
406
|
+
results.append(
|
|
407
|
+
{
|
|
408
|
+
"type": "device",
|
|
409
|
+
"site_id": site_id,
|
|
410
|
+
"site_name": site_name,
|
|
411
|
+
"resource": device,
|
|
412
|
+
}
|
|
413
|
+
)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.debug(f"Error searching devices in site {site_id}: {e}")
|
|
416
|
+
|
|
417
|
+
# Search clients
|
|
418
|
+
if search_type in ["client", "all"]:
|
|
419
|
+
try:
|
|
420
|
+
clients = site.get("clients", [])
|
|
421
|
+
for client_obj in clients:
|
|
422
|
+
client_name = client_obj.get("name", "").lower()
|
|
423
|
+
client_mac = client_obj.get("mac", "").lower()
|
|
424
|
+
client_ip = client_obj.get("ip", "").lower()
|
|
425
|
+
if (
|
|
426
|
+
query_lower in client_name
|
|
427
|
+
or query_lower in client_mac
|
|
428
|
+
or query_lower in client_ip
|
|
429
|
+
):
|
|
430
|
+
results.append(
|
|
431
|
+
{
|
|
432
|
+
"type": "client",
|
|
433
|
+
"site_id": site_id,
|
|
434
|
+
"site_name": site_name,
|
|
435
|
+
"resource": client_obj,
|
|
436
|
+
}
|
|
437
|
+
)
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.debug(f"Error searching clients in site {site_id}: {e}")
|
|
440
|
+
|
|
441
|
+
# Search networks
|
|
442
|
+
if search_type in ["network", "all"]:
|
|
443
|
+
try:
|
|
444
|
+
networks = site.get("networks", [])
|
|
445
|
+
for network in networks:
|
|
446
|
+
network_name = network.get("name", "").lower()
|
|
447
|
+
if query_lower in network_name:
|
|
448
|
+
results.append(
|
|
449
|
+
{
|
|
450
|
+
"type": "network",
|
|
451
|
+
"site_id": site_id,
|
|
452
|
+
"site_name": site_name,
|
|
453
|
+
"resource": network,
|
|
454
|
+
}
|
|
455
|
+
)
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.debug(f"Error searching networks in site {site_id}: {e}")
|
|
458
|
+
|
|
459
|
+
search_result = CrossSiteSearchResult(
|
|
460
|
+
total_results=len(results),
|
|
461
|
+
search_query=query,
|
|
462
|
+
result_type=search_type, # type: ignore[arg-type]
|
|
463
|
+
results=results,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
return search_result.model_dump() # type: ignore[no-any-return]
|
src/tools/site_vpn.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Site-to-Site VPN management MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models.vpn import SiteToSiteVPN
|
|
8
|
+
from ..utils import ResourceNotFoundError, get_logger, validate_site_id
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def list_site_to_site_vpns(site_id: str, settings: Settings) -> list[dict[str, Any]]:
|
|
12
|
+
"""List all site-to-site VPN configurations."""
|
|
13
|
+
site_id = validate_site_id(site_id)
|
|
14
|
+
logger = get_logger(__name__, settings.log_level)
|
|
15
|
+
|
|
16
|
+
async with UniFiClient(settings) as client:
|
|
17
|
+
await client.authenticate()
|
|
18
|
+
response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf")
|
|
19
|
+
networks = response if isinstance(response, list) else response.get("data", [])
|
|
20
|
+
vpns = [n for n in networks if n.get("purpose") == "site-vpn"]
|
|
21
|
+
logger.info(f"Retrieved {len(vpns)} site-to-site VPNs")
|
|
22
|
+
return [SiteToSiteVPN(**v).model_dump() for v in vpns]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def get_site_to_site_vpn(site_id: str, vpn_id: str, settings: Settings) -> dict[str, Any]:
|
|
26
|
+
"""Get details for a specific site-to-site VPN."""
|
|
27
|
+
site_id = validate_site_id(site_id)
|
|
28
|
+
logger = get_logger(__name__, settings.log_level)
|
|
29
|
+
|
|
30
|
+
async with UniFiClient(settings) as client:
|
|
31
|
+
await client.authenticate()
|
|
32
|
+
response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf")
|
|
33
|
+
networks = response if isinstance(response, list) else response.get("data", [])
|
|
34
|
+
|
|
35
|
+
for n in networks:
|
|
36
|
+
if n.get("_id") == vpn_id and n.get("purpose") == "site-vpn":
|
|
37
|
+
logger.info(f"Retrieved VPN {vpn_id}")
|
|
38
|
+
return SiteToSiteVPN(**n).model_dump()
|
|
39
|
+
|
|
40
|
+
raise ResourceNotFoundError("vpn", vpn_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def update_site_to_site_vpn(
|
|
44
|
+
site_id: str,
|
|
45
|
+
vpn_id: str,
|
|
46
|
+
settings: Settings,
|
|
47
|
+
*,
|
|
48
|
+
name: str | None = None,
|
|
49
|
+
enabled: bool | None = None,
|
|
50
|
+
ipsec_peer_ip: str | None = None,
|
|
51
|
+
remote_vpn_subnets: list[str] | None = None,
|
|
52
|
+
x_ipsec_pre_shared_key: str | None = None,
|
|
53
|
+
confirm: bool = False,
|
|
54
|
+
dry_run: bool = False,
|
|
55
|
+
) -> dict[str, Any]:
|
|
56
|
+
"""Update a site-to-site VPN configuration (requires confirm=True)."""
|
|
57
|
+
site_id = validate_site_id(site_id)
|
|
58
|
+
logger = get_logger(__name__, settings.log_level)
|
|
59
|
+
|
|
60
|
+
async with UniFiClient(settings) as client:
|
|
61
|
+
await client.authenticate()
|
|
62
|
+
|
|
63
|
+
# Get current config
|
|
64
|
+
response = await client.get(f"/proxy/network/api/s/{site_id}/rest/networkconf/{vpn_id}")
|
|
65
|
+
current = response if isinstance(response, dict) and "_id" in response else None
|
|
66
|
+
if not current:
|
|
67
|
+
resp_list = response if isinstance(response, list) else response.get("data", [])
|
|
68
|
+
current = resp_list[0] if resp_list else None
|
|
69
|
+
if not current or current.get("purpose") != "site-vpn":
|
|
70
|
+
raise ResourceNotFoundError("vpn", vpn_id)
|
|
71
|
+
|
|
72
|
+
# Build update payload
|
|
73
|
+
updates = {}
|
|
74
|
+
if name is not None:
|
|
75
|
+
updates["name"] = name
|
|
76
|
+
if enabled is not None:
|
|
77
|
+
updates["enabled"] = enabled
|
|
78
|
+
if ipsec_peer_ip is not None:
|
|
79
|
+
updates["ipsec_peer_ip"] = ipsec_peer_ip
|
|
80
|
+
if remote_vpn_subnets is not None:
|
|
81
|
+
updates["remote_vpn_subnets"] = remote_vpn_subnets
|
|
82
|
+
if x_ipsec_pre_shared_key is not None:
|
|
83
|
+
updates["x_ipsec_pre_shared_key"] = x_ipsec_pre_shared_key
|
|
84
|
+
|
|
85
|
+
if dry_run:
|
|
86
|
+
return {"dry_run": True, "vpn_id": vpn_id, "updates": updates}
|
|
87
|
+
|
|
88
|
+
if not confirm:
|
|
89
|
+
return {"error": "confirm=True required", "vpn_id": vpn_id, "updates": updates}
|
|
90
|
+
|
|
91
|
+
# Merge and update
|
|
92
|
+
payload = {**current, **updates}
|
|
93
|
+
await client.put(f"/proxy/network/api/s/{site_id}/rest/networkconf/{vpn_id}", payload)
|
|
94
|
+
logger.info(f"Updated VPN {vpn_id}")
|
|
95
|
+
return {"success": True, "vpn_id": vpn_id, "updates": updates}
|