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,505 @@
|
|
|
1
|
+
"""Client 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
|
+
get_logger,
|
|
10
|
+
log_audit,
|
|
11
|
+
sanitize_log_message,
|
|
12
|
+
validate_confirmation,
|
|
13
|
+
validate_mac_address,
|
|
14
|
+
validate_site_id,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def block_client(
|
|
19
|
+
site_id: str,
|
|
20
|
+
client_mac: str,
|
|
21
|
+
settings: Settings,
|
|
22
|
+
confirm: bool = False,
|
|
23
|
+
dry_run: bool = False,
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""Block a client from accessing the network.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
site_id: Site identifier
|
|
29
|
+
client_mac: Client MAC address
|
|
30
|
+
settings: Application settings
|
|
31
|
+
confirm: Confirmation flag (must be True to execute)
|
|
32
|
+
dry_run: If True, validate but don't block the client
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Block result dictionary
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ConfirmationRequiredError: If confirm is not True
|
|
39
|
+
ResourceNotFoundError: If client not found
|
|
40
|
+
"""
|
|
41
|
+
site_id = validate_site_id(site_id)
|
|
42
|
+
client_mac = validate_mac_address(client_mac)
|
|
43
|
+
validate_confirmation(confirm, "client management operation")
|
|
44
|
+
logger = get_logger(__name__, settings.log_level)
|
|
45
|
+
|
|
46
|
+
parameters = {"site_id": site_id, "client_mac": client_mac}
|
|
47
|
+
|
|
48
|
+
if dry_run:
|
|
49
|
+
logger.info(
|
|
50
|
+
sanitize_log_message(f"DRY RUN: Would block client '{client_mac}' in site '{site_id}'")
|
|
51
|
+
)
|
|
52
|
+
log_audit(
|
|
53
|
+
operation="block_client",
|
|
54
|
+
parameters=parameters,
|
|
55
|
+
result="dry_run",
|
|
56
|
+
site_id=site_id,
|
|
57
|
+
dry_run=True,
|
|
58
|
+
)
|
|
59
|
+
return {"dry_run": True, "would_block": client_mac}
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
async with UniFiClient(settings) as client:
|
|
63
|
+
await client.authenticate()
|
|
64
|
+
|
|
65
|
+
# Verify client exists (check both active and all users)
|
|
66
|
+
response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
|
|
67
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
68
|
+
clients_data: list[dict[str, Any]] = (
|
|
69
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
client_exists = any(
|
|
73
|
+
validate_mac_address(c.get("mac", "")) == client_mac for c in clients_data
|
|
74
|
+
)
|
|
75
|
+
if not client_exists:
|
|
76
|
+
raise ResourceNotFoundError("client", client_mac)
|
|
77
|
+
|
|
78
|
+
# Block the client
|
|
79
|
+
block_data = {"mac": client_mac, "cmd": "block-sta"}
|
|
80
|
+
response = await client.post(f"/ea/sites/{site_id}/cmd/stamgr", json_data=block_data)
|
|
81
|
+
|
|
82
|
+
logger.info(sanitize_log_message(f"Blocked client '{client_mac}' in site '{site_id}'"))
|
|
83
|
+
log_audit(
|
|
84
|
+
operation="block_client",
|
|
85
|
+
parameters=parameters,
|
|
86
|
+
result="success",
|
|
87
|
+
site_id=site_id,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
"success": True,
|
|
92
|
+
"client_mac": client_mac,
|
|
93
|
+
"message": "Client blocked from network",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
logger.error(sanitize_log_message(f"Failed to block client '{client_mac}': {e}"))
|
|
98
|
+
log_audit(
|
|
99
|
+
operation="block_client",
|
|
100
|
+
parameters=parameters,
|
|
101
|
+
result="failed",
|
|
102
|
+
site_id=site_id,
|
|
103
|
+
)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def unblock_client(
|
|
108
|
+
site_id: str,
|
|
109
|
+
client_mac: str,
|
|
110
|
+
settings: Settings,
|
|
111
|
+
confirm: bool = False,
|
|
112
|
+
dry_run: bool = False,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Unblock a previously blocked client.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
site_id: Site identifier
|
|
118
|
+
client_mac: Client MAC address
|
|
119
|
+
settings: Application settings
|
|
120
|
+
confirm: Confirmation flag (must be True to execute)
|
|
121
|
+
dry_run: If True, validate but don't unblock the client
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Unblock result dictionary
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ConfirmationRequiredError: If confirm is not True
|
|
128
|
+
"""
|
|
129
|
+
site_id = validate_site_id(site_id)
|
|
130
|
+
client_mac = validate_mac_address(client_mac)
|
|
131
|
+
validate_confirmation(confirm, "client management operation")
|
|
132
|
+
logger = get_logger(__name__, settings.log_level)
|
|
133
|
+
|
|
134
|
+
parameters = {"site_id": site_id, "client_mac": client_mac}
|
|
135
|
+
|
|
136
|
+
if dry_run:
|
|
137
|
+
logger.info(
|
|
138
|
+
sanitize_log_message(
|
|
139
|
+
f"DRY RUN: Would unblock client '{client_mac}' in site '{site_id}'"
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
log_audit(
|
|
143
|
+
operation="unblock_client",
|
|
144
|
+
parameters=parameters,
|
|
145
|
+
result="dry_run",
|
|
146
|
+
site_id=site_id,
|
|
147
|
+
dry_run=True,
|
|
148
|
+
)
|
|
149
|
+
return {"dry_run": True, "would_unblock": client_mac}
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
async with UniFiClient(settings) as client:
|
|
153
|
+
await client.authenticate()
|
|
154
|
+
|
|
155
|
+
# Unblock the client
|
|
156
|
+
unblock_data = {"mac": client_mac, "cmd": "unblock-sta"}
|
|
157
|
+
await client.post(f"/ea/sites/{site_id}/cmd/stamgr", json_data=unblock_data)
|
|
158
|
+
|
|
159
|
+
logger.info(
|
|
160
|
+
sanitize_log_message(f"Unblocked client '{client_mac}' in site '{site_id}'")
|
|
161
|
+
)
|
|
162
|
+
log_audit(
|
|
163
|
+
operation="unblock_client",
|
|
164
|
+
parameters=parameters,
|
|
165
|
+
result="success",
|
|
166
|
+
site_id=site_id,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"success": True,
|
|
171
|
+
"client_mac": client_mac,
|
|
172
|
+
"message": "Client unblocked",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.error(sanitize_log_message(f"Failed to unblock client '{client_mac}': {e}"))
|
|
177
|
+
log_audit(
|
|
178
|
+
operation="unblock_client",
|
|
179
|
+
parameters=parameters,
|
|
180
|
+
result="failed",
|
|
181
|
+
site_id=site_id,
|
|
182
|
+
)
|
|
183
|
+
raise
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def reconnect_client(
|
|
187
|
+
site_id: str,
|
|
188
|
+
client_mac: str,
|
|
189
|
+
settings: Settings,
|
|
190
|
+
confirm: bool = False,
|
|
191
|
+
dry_run: bool = False,
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
"""Force a client to reconnect (disconnect and re-authenticate).
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
site_id: Site identifier
|
|
197
|
+
client_mac: Client MAC address
|
|
198
|
+
settings: Application settings
|
|
199
|
+
confirm: Confirmation flag (must be True to execute)
|
|
200
|
+
dry_run: If True, validate but don't force reconnection
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Reconnect result dictionary
|
|
204
|
+
|
|
205
|
+
Raises:
|
|
206
|
+
ConfirmationRequiredError: If confirm is not True
|
|
207
|
+
ResourceNotFoundError: If client not found
|
|
208
|
+
"""
|
|
209
|
+
site_id = validate_site_id(site_id)
|
|
210
|
+
client_mac = validate_mac_address(client_mac)
|
|
211
|
+
validate_confirmation(confirm, "client management operation")
|
|
212
|
+
logger = get_logger(__name__, settings.log_level)
|
|
213
|
+
|
|
214
|
+
parameters = {"site_id": site_id, "client_mac": client_mac}
|
|
215
|
+
|
|
216
|
+
if dry_run:
|
|
217
|
+
logger.info(
|
|
218
|
+
sanitize_log_message(
|
|
219
|
+
f"DRY RUN: Would force reconnect for client '{client_mac}' in site '{site_id}'"
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
log_audit(
|
|
223
|
+
operation="reconnect_client",
|
|
224
|
+
parameters=parameters,
|
|
225
|
+
result="dry_run",
|
|
226
|
+
site_id=site_id,
|
|
227
|
+
dry_run=True,
|
|
228
|
+
)
|
|
229
|
+
return {"dry_run": True, "would_reconnect": client_mac}
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
async with UniFiClient(settings) as client:
|
|
233
|
+
await client.authenticate()
|
|
234
|
+
|
|
235
|
+
# Verify client is currently connected
|
|
236
|
+
response = await client.get(f"/ea/sites/{site_id}/sta")
|
|
237
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
238
|
+
clients_data: list[dict[str, Any]] = (
|
|
239
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
client_exists = any(
|
|
243
|
+
validate_mac_address(c.get("mac", "")) == client_mac for c in clients_data
|
|
244
|
+
)
|
|
245
|
+
if not client_exists:
|
|
246
|
+
raise ResourceNotFoundError("active client", client_mac)
|
|
247
|
+
|
|
248
|
+
# Force client reconnection
|
|
249
|
+
reconnect_data = {"mac": client_mac, "cmd": "kick-sta"}
|
|
250
|
+
response = await client.post(
|
|
251
|
+
f"/ea/sites/{site_id}/cmd/stamgr", json_data=reconnect_data
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
logger.info(
|
|
255
|
+
sanitize_log_message(
|
|
256
|
+
f"Forced reconnect for client '{client_mac}' in site '{site_id}'"
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
log_audit(
|
|
260
|
+
operation="reconnect_client",
|
|
261
|
+
parameters=parameters,
|
|
262
|
+
result="success",
|
|
263
|
+
site_id=site_id,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
"success": True,
|
|
268
|
+
"client_mac": client_mac,
|
|
269
|
+
"message": "Client forced to reconnect",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.error(sanitize_log_message(f"Failed to reconnect client '{client_mac}': {e}"))
|
|
274
|
+
log_audit(
|
|
275
|
+
operation="reconnect_client",
|
|
276
|
+
parameters=parameters,
|
|
277
|
+
result="failed",
|
|
278
|
+
site_id=site_id,
|
|
279
|
+
)
|
|
280
|
+
raise
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
async def authorize_guest(
|
|
284
|
+
site_id: str,
|
|
285
|
+
client_mac: str,
|
|
286
|
+
duration: int,
|
|
287
|
+
settings: Settings,
|
|
288
|
+
upload_limit_kbps: int | None = None,
|
|
289
|
+
download_limit_kbps: int | None = None,
|
|
290
|
+
confirm: bool = False,
|
|
291
|
+
dry_run: bool = False,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""Authorize a guest client for network access.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
site_id: Site identifier
|
|
297
|
+
client_mac: Client MAC address
|
|
298
|
+
duration: Access duration in seconds
|
|
299
|
+
settings: Application settings
|
|
300
|
+
upload_limit_kbps: Upload speed limit in kbps
|
|
301
|
+
download_limit_kbps: Download speed limit in kbps
|
|
302
|
+
confirm: Confirmation flag (must be True to execute)
|
|
303
|
+
dry_run: If True, validate but don't authorize
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Authorization result dictionary
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
ConfirmationRequiredError: If confirm is not True
|
|
310
|
+
"""
|
|
311
|
+
site_id = validate_site_id(site_id)
|
|
312
|
+
client_mac = validate_mac_address(client_mac)
|
|
313
|
+
validate_confirmation(confirm, "client management operation")
|
|
314
|
+
logger = get_logger(__name__, settings.log_level)
|
|
315
|
+
|
|
316
|
+
parameters = {
|
|
317
|
+
"site_id": site_id,
|
|
318
|
+
"client_mac": client_mac,
|
|
319
|
+
"duration": duration,
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if dry_run:
|
|
323
|
+
logger.info(
|
|
324
|
+
sanitize_log_message(
|
|
325
|
+
f"DRY RUN: Would authorize guest client '{client_mac}' for {duration}s in site '{site_id}'"
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
log_audit(
|
|
329
|
+
operation="authorize_guest",
|
|
330
|
+
parameters=parameters,
|
|
331
|
+
result="dry_run",
|
|
332
|
+
site_id=site_id,
|
|
333
|
+
dry_run=True,
|
|
334
|
+
)
|
|
335
|
+
return {"dry_run": True, "would_authorize": client_mac, "duration": duration}
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
async with UniFiClient(settings) as client:
|
|
339
|
+
await client.authenticate()
|
|
340
|
+
|
|
341
|
+
# Build authorization payload
|
|
342
|
+
auth_data = {
|
|
343
|
+
"action": "authorize-guest",
|
|
344
|
+
"params": {
|
|
345
|
+
"duration": duration,
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if upload_limit_kbps is not None:
|
|
350
|
+
auth_data["params"]["uploadLimit"] = upload_limit_kbps # type: ignore[index]
|
|
351
|
+
if download_limit_kbps is not None:
|
|
352
|
+
auth_data["params"]["downloadLimit"] = download_limit_kbps # type: ignore[index]
|
|
353
|
+
|
|
354
|
+
# Authorize guest using new API endpoint
|
|
355
|
+
await client.post(
|
|
356
|
+
f"/integration/v1/sites/{site_id}/clients/{client_mac}/action",
|
|
357
|
+
json_data=auth_data,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
logger.info(
|
|
361
|
+
sanitize_log_message(
|
|
362
|
+
f"Authorized guest client '{client_mac}' for {duration}s in site '{site_id}'"
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
log_audit(
|
|
366
|
+
operation="authorize_guest",
|
|
367
|
+
parameters=parameters,
|
|
368
|
+
result="success",
|
|
369
|
+
site_id=site_id,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"success": True,
|
|
374
|
+
"client_mac": client_mac,
|
|
375
|
+
"duration": duration,
|
|
376
|
+
"message": f"Guest authorized for {duration} seconds",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
except Exception as e:
|
|
380
|
+
logger.error(sanitize_log_message(f"Failed to authorize guest client '{client_mac}': {e}"))
|
|
381
|
+
log_audit(
|
|
382
|
+
operation="authorize_guest",
|
|
383
|
+
parameters=parameters,
|
|
384
|
+
result="failed",
|
|
385
|
+
site_id=site_id,
|
|
386
|
+
)
|
|
387
|
+
raise
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
async def limit_bandwidth(
|
|
391
|
+
site_id: str,
|
|
392
|
+
client_mac: str,
|
|
393
|
+
settings: Settings,
|
|
394
|
+
upload_limit_kbps: int | None = None,
|
|
395
|
+
download_limit_kbps: int | None = None,
|
|
396
|
+
confirm: bool = False,
|
|
397
|
+
dry_run: bool = False,
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Apply bandwidth restrictions to a client.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
site_id: Site identifier
|
|
403
|
+
client_mac: Client MAC address
|
|
404
|
+
settings: Application settings
|
|
405
|
+
upload_limit_kbps: Upload speed limit in kbps
|
|
406
|
+
download_limit_kbps: Download speed limit in kbps
|
|
407
|
+
confirm: Confirmation flag (must be True to execute)
|
|
408
|
+
dry_run: If True, validate but don't apply limits
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
Bandwidth limit result dictionary
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
ConfirmationRequiredError: If confirm is not True
|
|
415
|
+
"""
|
|
416
|
+
site_id = validate_site_id(site_id)
|
|
417
|
+
client_mac = validate_mac_address(client_mac)
|
|
418
|
+
validate_confirmation(confirm, "client management operation")
|
|
419
|
+
logger = get_logger(__name__, settings.log_level)
|
|
420
|
+
|
|
421
|
+
# Validate bandwidth limits
|
|
422
|
+
if upload_limit_kbps is not None and upload_limit_kbps <= 0:
|
|
423
|
+
raise ValueError("Upload limit must be positive")
|
|
424
|
+
if download_limit_kbps is not None and download_limit_kbps <= 0:
|
|
425
|
+
raise ValueError("Download limit must be positive")
|
|
426
|
+
|
|
427
|
+
parameters = {
|
|
428
|
+
"site_id": site_id,
|
|
429
|
+
"client_mac": client_mac,
|
|
430
|
+
"upload_limit_kbps": upload_limit_kbps,
|
|
431
|
+
"download_limit_kbps": download_limit_kbps,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if dry_run:
|
|
435
|
+
logger.info(
|
|
436
|
+
sanitize_log_message(
|
|
437
|
+
f"DRY RUN: Would apply bandwidth limits to client '{client_mac}' in site '{site_id}'"
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
log_audit(
|
|
441
|
+
operation="limit_bandwidth",
|
|
442
|
+
parameters=parameters,
|
|
443
|
+
result="dry_run",
|
|
444
|
+
site_id=site_id,
|
|
445
|
+
dry_run=True,
|
|
446
|
+
)
|
|
447
|
+
return {
|
|
448
|
+
"dry_run": True,
|
|
449
|
+
"would_limit": client_mac,
|
|
450
|
+
"upload_limit_kbps": upload_limit_kbps,
|
|
451
|
+
"download_limit_kbps": download_limit_kbps,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try:
|
|
455
|
+
async with UniFiClient(settings) as client:
|
|
456
|
+
await client.authenticate()
|
|
457
|
+
|
|
458
|
+
# Build bandwidth limit payload
|
|
459
|
+
limit_data = {
|
|
460
|
+
"action": "limit-bandwidth",
|
|
461
|
+
"params": {},
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if upload_limit_kbps is not None:
|
|
465
|
+
limit_data["params"]["uploadLimit"] = upload_limit_kbps # type: ignore[index]
|
|
466
|
+
if download_limit_kbps is not None:
|
|
467
|
+
limit_data["params"]["downloadLimit"] = download_limit_kbps # type: ignore[index]
|
|
468
|
+
|
|
469
|
+
# Apply bandwidth limits using new API endpoint
|
|
470
|
+
await client.post(
|
|
471
|
+
f"/integration/v1/sites/{site_id}/clients/{client_mac}/action",
|
|
472
|
+
json_data=limit_data,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
logger.info(
|
|
476
|
+
sanitize_log_message(
|
|
477
|
+
f"Applied bandwidth limits to client '{client_mac}' in site '{site_id}'"
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
log_audit(
|
|
481
|
+
operation="limit_bandwidth",
|
|
482
|
+
parameters=parameters,
|
|
483
|
+
result="success",
|
|
484
|
+
site_id=site_id,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
return {
|
|
488
|
+
"success": True,
|
|
489
|
+
"client_mac": client_mac,
|
|
490
|
+
"upload_limit_kbps": upload_limit_kbps,
|
|
491
|
+
"download_limit_kbps": download_limit_kbps,
|
|
492
|
+
"message": "Bandwidth limits applied",
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
except Exception as e:
|
|
496
|
+
logger.error(
|
|
497
|
+
sanitize_log_message(f"Failed to apply bandwidth limits to client '{client_mac}': {e}")
|
|
498
|
+
)
|
|
499
|
+
log_audit(
|
|
500
|
+
operation="limit_bandwidth",
|
|
501
|
+
parameters=parameters,
|
|
502
|
+
result="failed",
|
|
503
|
+
site_id=site_id,
|
|
504
|
+
)
|
|
505
|
+
raise
|
src/tools/clients.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Client management MCP tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models import Client
|
|
8
|
+
from ..utils import (
|
|
9
|
+
ResourceNotFoundError,
|
|
10
|
+
get_logger,
|
|
11
|
+
sanitize_log_message,
|
|
12
|
+
validate_limit_offset,
|
|
13
|
+
validate_mac_address,
|
|
14
|
+
validate_site_id,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def get_client_details(site_id: str, client_mac: str, settings: Settings) -> dict[str, Any]:
|
|
19
|
+
"""Get detailed information for a specific client.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
site_id: Site identifier
|
|
23
|
+
client_mac: Client MAC address
|
|
24
|
+
settings: Application settings
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Client details dictionary
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ResourceNotFoundError: If client not found
|
|
31
|
+
"""
|
|
32
|
+
site_id = validate_site_id(site_id)
|
|
33
|
+
client_mac = validate_mac_address(client_mac)
|
|
34
|
+
logger = get_logger(__name__, settings.log_level)
|
|
35
|
+
|
|
36
|
+
async with UniFiClient(settings) as client:
|
|
37
|
+
await client.authenticate()
|
|
38
|
+
|
|
39
|
+
# Try active clients first
|
|
40
|
+
response = await client.get(f"/ea/sites/{site_id}/sta")
|
|
41
|
+
clients_data = response.get("data", []) if isinstance(response, dict) else response
|
|
42
|
+
|
|
43
|
+
for client_data in clients_data:
|
|
44
|
+
if validate_mac_address(client_data.get("mac", "")) == client_mac:
|
|
45
|
+
client_obj = Client(**client_data)
|
|
46
|
+
logger.info(sanitize_log_message(f"Retrieved client details for {client_mac}"))
|
|
47
|
+
return client_obj.model_dump() # type: ignore[no-any-return]
|
|
48
|
+
|
|
49
|
+
# If not found in active, try all users
|
|
50
|
+
response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
|
|
51
|
+
clients_data = response.get("data", []) if isinstance(response, dict) else response
|
|
52
|
+
|
|
53
|
+
for client_data in clients_data:
|
|
54
|
+
if validate_mac_address(client_data.get("mac", "")) == client_mac:
|
|
55
|
+
client_obj = Client(**client_data)
|
|
56
|
+
logger.info(sanitize_log_message(f"Retrieved client details for {client_mac}"))
|
|
57
|
+
return client_obj.model_dump() # type: ignore[no-any-return]
|
|
58
|
+
|
|
59
|
+
raise ResourceNotFoundError("client", client_mac)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def get_client_statistics(
|
|
63
|
+
site_id: str, client_mac: str, settings: Settings
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Retrieve bandwidth and connection statistics for a client.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
site_id: Site identifier
|
|
69
|
+
client_mac: Client MAC address
|
|
70
|
+
settings: Application settings
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Client statistics dictionary
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ResourceNotFoundError: If client not found
|
|
77
|
+
"""
|
|
78
|
+
site_id = validate_site_id(site_id)
|
|
79
|
+
client_mac = validate_mac_address(client_mac)
|
|
80
|
+
logger = get_logger(__name__, settings.log_level)
|
|
81
|
+
|
|
82
|
+
async with UniFiClient(settings) as client:
|
|
83
|
+
await client.authenticate()
|
|
84
|
+
|
|
85
|
+
# Get from active clients
|
|
86
|
+
response = await client.get(f"/ea/sites/{site_id}/sta")
|
|
87
|
+
clients_data = response.get("data", []) if isinstance(response, dict) else response
|
|
88
|
+
|
|
89
|
+
for client_data in clients_data:
|
|
90
|
+
if validate_mac_address(client_data.get("mac", "")) == client_mac:
|
|
91
|
+
# Extract statistics
|
|
92
|
+
stats = {
|
|
93
|
+
"mac": client_mac,
|
|
94
|
+
"tx_bytes": client_data.get("tx_bytes", 0),
|
|
95
|
+
"rx_bytes": client_data.get("rx_bytes", 0),
|
|
96
|
+
"tx_packets": client_data.get("tx_packets", 0),
|
|
97
|
+
"rx_packets": client_data.get("rx_packets", 0),
|
|
98
|
+
"tx_rate": client_data.get("tx_rate"),
|
|
99
|
+
"rx_rate": client_data.get("rx_rate"),
|
|
100
|
+
"signal": client_data.get("signal"),
|
|
101
|
+
"rssi": client_data.get("rssi"),
|
|
102
|
+
"noise": client_data.get("noise"),
|
|
103
|
+
"uptime": client_data.get("uptime", 0),
|
|
104
|
+
"is_wired": client_data.get("is_wired", False),
|
|
105
|
+
}
|
|
106
|
+
logger.info(sanitize_log_message(f"Retrieved statistics for client {client_mac}"))
|
|
107
|
+
return stats
|
|
108
|
+
|
|
109
|
+
raise ResourceNotFoundError("client", client_mac)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
async def list_active_clients(
|
|
113
|
+
site_id: str,
|
|
114
|
+
settings: Settings,
|
|
115
|
+
limit: int | None = None,
|
|
116
|
+
offset: int | None = None,
|
|
117
|
+
) -> list[dict[str, Any]]:
|
|
118
|
+
"""List currently connected clients.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
site_id: Site identifier
|
|
122
|
+
settings: Application settings
|
|
123
|
+
limit: Maximum number of clients to return
|
|
124
|
+
offset: Number of clients to skip
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
List of active client dictionaries
|
|
128
|
+
"""
|
|
129
|
+
site_id = validate_site_id(site_id)
|
|
130
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
131
|
+
logger = get_logger(__name__, settings.log_level)
|
|
132
|
+
|
|
133
|
+
async with UniFiClient(settings) as client:
|
|
134
|
+
await client.authenticate()
|
|
135
|
+
|
|
136
|
+
response = await client.get(f"/ea/sites/{site_id}/sta")
|
|
137
|
+
clients_data = response.get("data", []) if isinstance(response, dict) else response
|
|
138
|
+
|
|
139
|
+
# Apply pagination
|
|
140
|
+
paginated = clients_data[offset : offset + limit]
|
|
141
|
+
|
|
142
|
+
# Parse into Client models
|
|
143
|
+
clients = [Client(**c).model_dump() for c in paginated]
|
|
144
|
+
|
|
145
|
+
logger.info(
|
|
146
|
+
sanitize_log_message(f"Retrieved {len(clients)} active clients for site '{site_id}'")
|
|
147
|
+
)
|
|
148
|
+
return clients
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def search_clients(
|
|
152
|
+
site_id: str,
|
|
153
|
+
query: str,
|
|
154
|
+
settings: Settings,
|
|
155
|
+
limit: int | None = None,
|
|
156
|
+
offset: int | None = None,
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
"""Search clients by MAC, IP, or hostname.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
site_id: Site identifier
|
|
162
|
+
query: Search query string
|
|
163
|
+
settings: Application settings
|
|
164
|
+
limit: Maximum number of clients to return
|
|
165
|
+
offset: Number of clients to skip
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
List of matching client dictionaries
|
|
169
|
+
"""
|
|
170
|
+
site_id = validate_site_id(site_id)
|
|
171
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
172
|
+
logger = get_logger(__name__, settings.log_level)
|
|
173
|
+
|
|
174
|
+
async with UniFiClient(settings) as client:
|
|
175
|
+
await client.authenticate()
|
|
176
|
+
|
|
177
|
+
# Search in all users
|
|
178
|
+
response = await client.get(f"/ea/sites/{site_id}/stat/alluser")
|
|
179
|
+
clients_data = response.get("data", []) if isinstance(response, dict) else response
|
|
180
|
+
|
|
181
|
+
# Search by MAC, IP, hostname, or name
|
|
182
|
+
query_lower = query.lower()
|
|
183
|
+
filtered = [
|
|
184
|
+
c
|
|
185
|
+
for c in clients_data
|
|
186
|
+
if query_lower in c.get("mac", "").lower()
|
|
187
|
+
or query_lower in c.get("ip", "").lower()
|
|
188
|
+
or query_lower in c.get("hostname", "").lower()
|
|
189
|
+
or query_lower in c.get("name", "").lower()
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
# Apply pagination
|
|
193
|
+
paginated = filtered[offset : offset + limit]
|
|
194
|
+
|
|
195
|
+
# Parse into Client models
|
|
196
|
+
clients = [Client(**c).model_dump() for c in paginated]
|
|
197
|
+
|
|
198
|
+
logger.info(
|
|
199
|
+
sanitize_log_message(
|
|
200
|
+
f"Found {len(clients)} clients matching '{query}' in site '{site_id}'"
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return clients
|