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/radius.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""RADIUS profile and guest portal management tools."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from ..api.client import UniFiClient
|
|
6
|
+
from ..config import Settings
|
|
7
|
+
from ..models.radius import GuestPortalConfig, HotspotPackage, RADIUSAccount, RADIUSProfile
|
|
8
|
+
from ..utils import audit_action, get_logger, validate_confirmation
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# =============================================================================
|
|
14
|
+
# RADIUS Profile Management
|
|
15
|
+
# =============================================================================
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def list_radius_profiles(
|
|
19
|
+
site_id: str,
|
|
20
|
+
settings: Settings,
|
|
21
|
+
) -> list[dict]:
|
|
22
|
+
"""List all RADIUS profiles for a site.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
site_id: Site identifier
|
|
26
|
+
settings: Application settings
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
List of RADIUS profiles
|
|
30
|
+
"""
|
|
31
|
+
async with UniFiClient(settings) as client:
|
|
32
|
+
logger.info(f"Listing RADIUS profiles for site {site_id}")
|
|
33
|
+
|
|
34
|
+
if not client.is_authenticated:
|
|
35
|
+
await client.authenticate()
|
|
36
|
+
|
|
37
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/radius/profiles")
|
|
38
|
+
data = response.get("data", [])
|
|
39
|
+
|
|
40
|
+
return [RADIUSProfile(**profile).model_dump() for profile in data]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def get_radius_profile(
|
|
44
|
+
site_id: str,
|
|
45
|
+
profile_id: str,
|
|
46
|
+
settings: Settings,
|
|
47
|
+
) -> dict:
|
|
48
|
+
"""Get details for a specific RADIUS profile.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
site_id: Site identifier
|
|
52
|
+
profile_id: RADIUS profile ID
|
|
53
|
+
settings: Application settings
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
RADIUS profile details
|
|
57
|
+
"""
|
|
58
|
+
async with UniFiClient(settings) as client:
|
|
59
|
+
logger.info(f"Getting RADIUS profile {profile_id} for site {site_id}")
|
|
60
|
+
|
|
61
|
+
if not client.is_authenticated:
|
|
62
|
+
await client.authenticate()
|
|
63
|
+
|
|
64
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/radius/profiles/{profile_id}")
|
|
65
|
+
data = response.get("data", response)
|
|
66
|
+
|
|
67
|
+
return RADIUSProfile(**data).model_dump() # type: ignore[no-any-return]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def create_radius_profile(
|
|
71
|
+
site_id: str,
|
|
72
|
+
name: str,
|
|
73
|
+
auth_server: str,
|
|
74
|
+
auth_secret: str,
|
|
75
|
+
settings: Settings,
|
|
76
|
+
auth_port: int = 1812,
|
|
77
|
+
acct_server: str | None = None,
|
|
78
|
+
acct_port: int = 1813,
|
|
79
|
+
acct_secret: str | None = None,
|
|
80
|
+
use_same_secret: bool = True,
|
|
81
|
+
vlan_enabled: bool = False,
|
|
82
|
+
confirm: bool = False,
|
|
83
|
+
dry_run: bool = False,
|
|
84
|
+
) -> dict:
|
|
85
|
+
"""Create a new RADIUS profile.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
site_id: Site identifier
|
|
89
|
+
name: Profile name
|
|
90
|
+
auth_server: Authentication server IP/hostname
|
|
91
|
+
auth_secret: Shared secret for authentication
|
|
92
|
+
settings: Application settings
|
|
93
|
+
auth_port: Authentication port (default: 1812)
|
|
94
|
+
acct_server: Accounting server IP/hostname (optional)
|
|
95
|
+
acct_port: Accounting port (default: 1813)
|
|
96
|
+
acct_secret: Accounting server secret (optional)
|
|
97
|
+
use_same_secret: Use auth_secret for accounting
|
|
98
|
+
vlan_enabled: Enable VLAN assignment
|
|
99
|
+
confirm: Confirmation flag (required)
|
|
100
|
+
dry_run: If True, validate but don't execute
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Created RADIUS profile
|
|
104
|
+
"""
|
|
105
|
+
validate_confirmation(confirm, "create RADIUS profile")
|
|
106
|
+
|
|
107
|
+
async with UniFiClient(settings) as client:
|
|
108
|
+
logger.info(f"Creating RADIUS profile '{name}' for site {site_id}")
|
|
109
|
+
|
|
110
|
+
if not client.is_authenticated:
|
|
111
|
+
await client.authenticate()
|
|
112
|
+
|
|
113
|
+
# Build request payload
|
|
114
|
+
payload: dict[str, Any] = {
|
|
115
|
+
"name": name,
|
|
116
|
+
"auth_server": auth_server,
|
|
117
|
+
"auth_port": auth_port,
|
|
118
|
+
"auth_secret": auth_secret,
|
|
119
|
+
"acct_port": acct_port,
|
|
120
|
+
"use_same_secret": use_same_secret,
|
|
121
|
+
"vlan_enabled": vlan_enabled,
|
|
122
|
+
"enabled": True,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if acct_server:
|
|
126
|
+
payload["acct_server"] = acct_server
|
|
127
|
+
if acct_secret:
|
|
128
|
+
payload["acct_secret"] = acct_secret
|
|
129
|
+
|
|
130
|
+
if dry_run:
|
|
131
|
+
# Build safe payload without secrets for logging
|
|
132
|
+
payload_safe = {
|
|
133
|
+
"name": name,
|
|
134
|
+
"auth_server": auth_server,
|
|
135
|
+
"auth_port": auth_port,
|
|
136
|
+
"auth_secret": "***REDACTED***",
|
|
137
|
+
"acct_port": acct_port,
|
|
138
|
+
"use_same_secret": use_same_secret,
|
|
139
|
+
"vlan_enabled": vlan_enabled,
|
|
140
|
+
"enabled": True,
|
|
141
|
+
}
|
|
142
|
+
if acct_server:
|
|
143
|
+
payload_safe["acct_server"] = acct_server
|
|
144
|
+
if acct_secret:
|
|
145
|
+
payload_safe["acct_secret"] = "***REDACTED***"
|
|
146
|
+
logger.info(f"[DRY RUN] Would create RADIUS profile with payload: {payload_safe}")
|
|
147
|
+
return {"dry_run": True, "payload": payload_safe}
|
|
148
|
+
|
|
149
|
+
response = await client.post(
|
|
150
|
+
f"/integration/v1/sites/{site_id}/radius/profiles", json_data=payload
|
|
151
|
+
)
|
|
152
|
+
data = response.get("data", response)
|
|
153
|
+
|
|
154
|
+
# Audit the action
|
|
155
|
+
await audit_action(
|
|
156
|
+
settings,
|
|
157
|
+
action_type="create_radius_profile",
|
|
158
|
+
resource_type="radius_profile",
|
|
159
|
+
resource_id=data.get("_id", "unknown"),
|
|
160
|
+
site_id=site_id,
|
|
161
|
+
details={"name": name, "auth_server": auth_server},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return RADIUSProfile(**data).model_dump() # type: ignore[no-any-return]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def update_radius_profile(
|
|
168
|
+
site_id: str,
|
|
169
|
+
profile_id: str,
|
|
170
|
+
settings: Settings,
|
|
171
|
+
name: str | None = None,
|
|
172
|
+
auth_server: str | None = None,
|
|
173
|
+
auth_secret: str | None = None,
|
|
174
|
+
auth_port: int | None = None,
|
|
175
|
+
acct_server: str | None = None,
|
|
176
|
+
acct_port: int | None = None,
|
|
177
|
+
acct_secret: str | None = None,
|
|
178
|
+
vlan_enabled: bool | None = None,
|
|
179
|
+
enabled: bool | None = None,
|
|
180
|
+
confirm: bool = False,
|
|
181
|
+
dry_run: bool = False,
|
|
182
|
+
) -> dict:
|
|
183
|
+
"""Update an existing RADIUS profile.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
site_id: Site identifier
|
|
187
|
+
profile_id: RADIUS profile ID
|
|
188
|
+
settings: Application settings
|
|
189
|
+
name: Profile name
|
|
190
|
+
auth_server: Authentication server IP/hostname
|
|
191
|
+
auth_secret: Shared secret for authentication
|
|
192
|
+
auth_port: Authentication port
|
|
193
|
+
acct_server: Accounting server IP/hostname
|
|
194
|
+
acct_port: Accounting port
|
|
195
|
+
acct_secret: Accounting server secret
|
|
196
|
+
vlan_enabled: Enable VLAN assignment
|
|
197
|
+
enabled: Profile enabled status
|
|
198
|
+
confirm: Confirmation flag (required)
|
|
199
|
+
dry_run: If True, validate but don't execute
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Updated RADIUS profile
|
|
203
|
+
"""
|
|
204
|
+
validate_confirmation(confirm, "update RADIUS profile")
|
|
205
|
+
|
|
206
|
+
async with UniFiClient(settings) as client:
|
|
207
|
+
logger.info(f"Updating RADIUS profile {profile_id} for site {site_id}")
|
|
208
|
+
|
|
209
|
+
if not client.is_authenticated:
|
|
210
|
+
await client.authenticate()
|
|
211
|
+
|
|
212
|
+
# Build update payload with only provided fields
|
|
213
|
+
payload: dict[str, Any] = {}
|
|
214
|
+
|
|
215
|
+
if name is not None:
|
|
216
|
+
payload["name"] = name
|
|
217
|
+
if auth_server is not None:
|
|
218
|
+
payload["auth_server"] = auth_server
|
|
219
|
+
if auth_secret is not None:
|
|
220
|
+
payload["auth_secret"] = auth_secret
|
|
221
|
+
if auth_port is not None:
|
|
222
|
+
payload["auth_port"] = auth_port
|
|
223
|
+
if acct_server is not None:
|
|
224
|
+
payload["acct_server"] = acct_server
|
|
225
|
+
if acct_port is not None:
|
|
226
|
+
payload["acct_port"] = acct_port
|
|
227
|
+
if acct_secret is not None:
|
|
228
|
+
payload["acct_secret"] = acct_secret
|
|
229
|
+
if vlan_enabled is not None:
|
|
230
|
+
payload["vlan_enabled"] = vlan_enabled
|
|
231
|
+
if enabled is not None:
|
|
232
|
+
payload["enabled"] = enabled
|
|
233
|
+
|
|
234
|
+
if dry_run:
|
|
235
|
+
# Build safe payload without secrets for logging
|
|
236
|
+
payload_safe = {}
|
|
237
|
+
if name is not None:
|
|
238
|
+
payload_safe["name"] = name
|
|
239
|
+
if auth_server is not None:
|
|
240
|
+
payload_safe["auth_server"] = auth_server
|
|
241
|
+
if auth_secret is not None:
|
|
242
|
+
payload_safe["auth_secret"] = "***REDACTED***"
|
|
243
|
+
if auth_port is not None:
|
|
244
|
+
payload_safe["auth_port"] = auth_port
|
|
245
|
+
if acct_server is not None:
|
|
246
|
+
payload_safe["acct_server"] = acct_server
|
|
247
|
+
if acct_port is not None:
|
|
248
|
+
payload_safe["acct_port"] = acct_port
|
|
249
|
+
if acct_secret is not None:
|
|
250
|
+
payload_safe["acct_secret"] = "***REDACTED***"
|
|
251
|
+
if vlan_enabled is not None:
|
|
252
|
+
payload_safe["vlan_enabled"] = vlan_enabled
|
|
253
|
+
if enabled is not None:
|
|
254
|
+
payload_safe["enabled"] = enabled
|
|
255
|
+
logger.info(f"[DRY RUN] Would update RADIUS profile with payload: {payload_safe}")
|
|
256
|
+
return {"dry_run": True, "profile_id": profile_id, "payload": payload_safe}
|
|
257
|
+
|
|
258
|
+
response = await client.put(
|
|
259
|
+
f"/integration/v1/sites/{site_id}/radius/profiles/{profile_id}", json_data=payload
|
|
260
|
+
)
|
|
261
|
+
data = response.get("data", response)
|
|
262
|
+
|
|
263
|
+
# Audit the action
|
|
264
|
+
await audit_action(
|
|
265
|
+
settings,
|
|
266
|
+
action_type="update_radius_profile",
|
|
267
|
+
resource_type="radius_profile",
|
|
268
|
+
resource_id=profile_id,
|
|
269
|
+
site_id=site_id,
|
|
270
|
+
details=payload,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return RADIUSProfile(**data).model_dump() # type: ignore[no-any-return]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
async def delete_radius_profile(
|
|
277
|
+
site_id: str,
|
|
278
|
+
profile_id: str,
|
|
279
|
+
settings: Settings,
|
|
280
|
+
confirm: bool = False,
|
|
281
|
+
dry_run: bool = False,
|
|
282
|
+
) -> dict:
|
|
283
|
+
"""Delete a RADIUS profile.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
site_id: Site identifier
|
|
287
|
+
profile_id: RADIUS profile ID
|
|
288
|
+
settings: Application settings
|
|
289
|
+
confirm: Confirmation flag (required)
|
|
290
|
+
dry_run: If True, validate but don't execute
|
|
291
|
+
|
|
292
|
+
Returns:
|
|
293
|
+
Deletion status
|
|
294
|
+
"""
|
|
295
|
+
validate_confirmation(confirm, "delete RADIUS profile")
|
|
296
|
+
|
|
297
|
+
async with UniFiClient(settings) as client:
|
|
298
|
+
logger.info(f"Deleting RADIUS profile {profile_id} for site {site_id}")
|
|
299
|
+
|
|
300
|
+
if not client.is_authenticated:
|
|
301
|
+
await client.authenticate()
|
|
302
|
+
|
|
303
|
+
if dry_run:
|
|
304
|
+
logger.info(f"[DRY RUN] Would delete RADIUS profile {profile_id}")
|
|
305
|
+
return {"dry_run": True, "profile_id": profile_id}
|
|
306
|
+
|
|
307
|
+
await client.delete(f"/integration/v1/sites/{site_id}/radius/profiles/{profile_id}")
|
|
308
|
+
|
|
309
|
+
# Audit the action
|
|
310
|
+
await audit_action(
|
|
311
|
+
settings,
|
|
312
|
+
action_type="delete_radius_profile",
|
|
313
|
+
resource_type="radius_profile",
|
|
314
|
+
resource_id=profile_id,
|
|
315
|
+
site_id=site_id,
|
|
316
|
+
details={},
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
return {"success": True, "message": f"RADIUS profile {profile_id} deleted successfully"}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# =============================================================================
|
|
323
|
+
# RADIUS Account Management
|
|
324
|
+
# =============================================================================
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def list_radius_accounts(
|
|
328
|
+
site_id: str,
|
|
329
|
+
settings: Settings,
|
|
330
|
+
) -> list[dict]:
|
|
331
|
+
"""List all RADIUS accounts for a site.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
site_id: Site identifier
|
|
335
|
+
settings: Application settings
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
List of RADIUS accounts
|
|
339
|
+
"""
|
|
340
|
+
async with UniFiClient(settings) as client:
|
|
341
|
+
logger.info(f"Listing RADIUS accounts for site {site_id}")
|
|
342
|
+
|
|
343
|
+
if not client.is_authenticated:
|
|
344
|
+
await client.authenticate()
|
|
345
|
+
|
|
346
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/radius/accounts")
|
|
347
|
+
data = response.get("data", [])
|
|
348
|
+
|
|
349
|
+
# Redact passwords in response
|
|
350
|
+
for account in data:
|
|
351
|
+
if "password" in account:
|
|
352
|
+
account["password"] = "***REDACTED***"
|
|
353
|
+
|
|
354
|
+
return [RADIUSAccount(**account).model_dump() for account in data]
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def create_radius_account(
|
|
358
|
+
site_id: str,
|
|
359
|
+
username: str,
|
|
360
|
+
password: str,
|
|
361
|
+
settings: Settings,
|
|
362
|
+
vlan_id: int | None = None,
|
|
363
|
+
enabled: bool = True,
|
|
364
|
+
note: str | None = None,
|
|
365
|
+
confirm: bool = False,
|
|
366
|
+
dry_run: bool = False,
|
|
367
|
+
) -> dict:
|
|
368
|
+
"""Create a new RADIUS account.
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
site_id: Site identifier
|
|
372
|
+
username: Account username
|
|
373
|
+
password: Account password
|
|
374
|
+
settings: Application settings
|
|
375
|
+
vlan_id: Assigned VLAN ID
|
|
376
|
+
enabled: Account enabled status
|
|
377
|
+
note: Admin notes
|
|
378
|
+
confirm: Confirmation flag (required)
|
|
379
|
+
dry_run: If True, validate but don't execute
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Created RADIUS account
|
|
383
|
+
"""
|
|
384
|
+
validate_confirmation(confirm, "create RADIUS account")
|
|
385
|
+
|
|
386
|
+
async with UniFiClient(settings) as client:
|
|
387
|
+
logger.info(f"Creating RADIUS account '{username}' for site {site_id}")
|
|
388
|
+
|
|
389
|
+
if not client.is_authenticated:
|
|
390
|
+
await client.authenticate()
|
|
391
|
+
|
|
392
|
+
# Build request payload
|
|
393
|
+
payload: dict[str, Any] = {
|
|
394
|
+
"name": username,
|
|
395
|
+
"password": password,
|
|
396
|
+
"enabled": enabled,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if vlan_id is not None:
|
|
400
|
+
payload["vlan_id"] = vlan_id
|
|
401
|
+
if note:
|
|
402
|
+
payload["note"] = note
|
|
403
|
+
|
|
404
|
+
if dry_run:
|
|
405
|
+
logger.info(f"[DRY RUN] Would create RADIUS account with username: {username}")
|
|
406
|
+
payload_safe = payload.copy()
|
|
407
|
+
payload_safe["password"] = "***REDACTED***"
|
|
408
|
+
return {"dry_run": True, "payload": payload_safe}
|
|
409
|
+
|
|
410
|
+
response = await client.post(
|
|
411
|
+
f"/integration/v1/sites/{site_id}/radius/accounts", json_data=payload
|
|
412
|
+
)
|
|
413
|
+
data = response.get("data", response)
|
|
414
|
+
|
|
415
|
+
# Audit the action
|
|
416
|
+
await audit_action(
|
|
417
|
+
settings,
|
|
418
|
+
action_type="create_radius_account",
|
|
419
|
+
resource_type="radius_account",
|
|
420
|
+
resource_id=data.get("_id", "unknown"),
|
|
421
|
+
site_id=site_id,
|
|
422
|
+
details={"username": username, "vlan_id": vlan_id},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# Redact password before returning
|
|
426
|
+
data["password"] = "***REDACTED***"
|
|
427
|
+
|
|
428
|
+
return RADIUSAccount(**data).model_dump() # type: ignore[no-any-return]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
async def delete_radius_account(
|
|
432
|
+
site_id: str,
|
|
433
|
+
account_id: str,
|
|
434
|
+
settings: Settings,
|
|
435
|
+
confirm: bool = False,
|
|
436
|
+
dry_run: bool = False,
|
|
437
|
+
) -> dict:
|
|
438
|
+
"""Delete a RADIUS account.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
site_id: Site identifier
|
|
442
|
+
account_id: RADIUS account ID
|
|
443
|
+
settings: Application settings
|
|
444
|
+
confirm: Confirmation flag (required)
|
|
445
|
+
dry_run: If True, validate but don't execute
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
Deletion status
|
|
449
|
+
"""
|
|
450
|
+
validate_confirmation(confirm, "delete RADIUS account")
|
|
451
|
+
|
|
452
|
+
async with UniFiClient(settings) as client:
|
|
453
|
+
logger.info(f"Deleting RADIUS account {account_id} for site {site_id}")
|
|
454
|
+
|
|
455
|
+
if not client.is_authenticated:
|
|
456
|
+
await client.authenticate()
|
|
457
|
+
|
|
458
|
+
if dry_run:
|
|
459
|
+
logger.info(f"[DRY RUN] Would delete RADIUS account {account_id}")
|
|
460
|
+
return {"dry_run": True, "account_id": account_id}
|
|
461
|
+
|
|
462
|
+
await client.delete(f"/integration/v1/sites/{site_id}/radius/accounts/{account_id}")
|
|
463
|
+
|
|
464
|
+
# Audit the action
|
|
465
|
+
await audit_action(
|
|
466
|
+
settings,
|
|
467
|
+
action_type="delete_radius_account",
|
|
468
|
+
resource_type="radius_account",
|
|
469
|
+
resource_id=account_id,
|
|
470
|
+
site_id=site_id,
|
|
471
|
+
details={},
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
return {"success": True, "message": f"RADIUS account {account_id} deleted successfully"}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# =============================================================================
|
|
478
|
+
# Guest Portal Configuration
|
|
479
|
+
# =============================================================================
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def get_guest_portal_config(
|
|
483
|
+
site_id: str,
|
|
484
|
+
settings: Settings,
|
|
485
|
+
) -> dict:
|
|
486
|
+
"""Get guest portal configuration for a site.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
site_id: Site identifier
|
|
490
|
+
settings: Application settings
|
|
491
|
+
|
|
492
|
+
Returns:
|
|
493
|
+
Guest portal configuration
|
|
494
|
+
"""
|
|
495
|
+
async with UniFiClient(settings) as client:
|
|
496
|
+
logger.info(f"Getting guest portal config for site {site_id}")
|
|
497
|
+
|
|
498
|
+
if not client.is_authenticated:
|
|
499
|
+
await client.authenticate()
|
|
500
|
+
|
|
501
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/guest-portal/config")
|
|
502
|
+
data = response.get("data", response)
|
|
503
|
+
|
|
504
|
+
return GuestPortalConfig(**data).model_dump() # type: ignore[no-any-return]
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
async def configure_guest_portal(
|
|
508
|
+
site_id: str,
|
|
509
|
+
settings: Settings,
|
|
510
|
+
portal_title: str | None = None,
|
|
511
|
+
auth_method: str | None = None,
|
|
512
|
+
password: str | None = None,
|
|
513
|
+
session_timeout: int | None = None,
|
|
514
|
+
redirect_enabled: bool | None = None,
|
|
515
|
+
redirect_url: str | None = None,
|
|
516
|
+
terms_of_service_enabled: bool | None = None,
|
|
517
|
+
terms_of_service_text: str | None = None,
|
|
518
|
+
confirm: bool = False,
|
|
519
|
+
dry_run: bool = False,
|
|
520
|
+
) -> dict:
|
|
521
|
+
"""Configure guest portal settings.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
site_id: Site identifier
|
|
525
|
+
settings: Application settings
|
|
526
|
+
portal_title: Portal page title
|
|
527
|
+
auth_method: Authentication method (none/password/voucher/radius/external)
|
|
528
|
+
password: Portal password (if auth_method=password)
|
|
529
|
+
session_timeout: Session timeout in minutes
|
|
530
|
+
redirect_enabled: Enable redirect after authentication
|
|
531
|
+
redirect_url: Redirect URL
|
|
532
|
+
terms_of_service_enabled: Require ToS acceptance
|
|
533
|
+
terms_of_service_text: Terms of service text
|
|
534
|
+
confirm: Confirmation flag (required)
|
|
535
|
+
dry_run: If True, validate but don't execute
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Updated guest portal configuration
|
|
539
|
+
"""
|
|
540
|
+
validate_confirmation(confirm, "configure guest portal")
|
|
541
|
+
|
|
542
|
+
async with UniFiClient(settings) as client:
|
|
543
|
+
logger.info(f"Configuring guest portal for site {site_id}")
|
|
544
|
+
|
|
545
|
+
if not client.is_authenticated:
|
|
546
|
+
await client.authenticate()
|
|
547
|
+
|
|
548
|
+
# Build update payload
|
|
549
|
+
payload: dict[str, Any] = {}
|
|
550
|
+
|
|
551
|
+
if portal_title is not None:
|
|
552
|
+
payload["portal_title"] = portal_title
|
|
553
|
+
if auth_method is not None:
|
|
554
|
+
payload["auth_method"] = auth_method
|
|
555
|
+
if password is not None:
|
|
556
|
+
payload["password"] = password
|
|
557
|
+
if session_timeout is not None:
|
|
558
|
+
payload["session_timeout"] = session_timeout
|
|
559
|
+
if redirect_enabled is not None:
|
|
560
|
+
payload["redirect_enabled"] = redirect_enabled
|
|
561
|
+
if redirect_url is not None:
|
|
562
|
+
payload["redirect_url"] = redirect_url
|
|
563
|
+
if terms_of_service_enabled is not None:
|
|
564
|
+
payload["terms_of_service_enabled"] = terms_of_service_enabled
|
|
565
|
+
if terms_of_service_text is not None:
|
|
566
|
+
payload["terms_of_service_text"] = terms_of_service_text
|
|
567
|
+
|
|
568
|
+
if dry_run:
|
|
569
|
+
# Build safe payload without secrets for logging
|
|
570
|
+
payload_safe = {}
|
|
571
|
+
if portal_title is not None:
|
|
572
|
+
payload_safe["portal_title"] = portal_title
|
|
573
|
+
if auth_method is not None:
|
|
574
|
+
payload_safe["auth_method"] = auth_method
|
|
575
|
+
if password is not None:
|
|
576
|
+
payload_safe["password"] = "***REDACTED***"
|
|
577
|
+
if session_timeout is not None:
|
|
578
|
+
payload_safe["session_timeout"] = session_timeout
|
|
579
|
+
if redirect_enabled is not None:
|
|
580
|
+
payload_safe["redirect_enabled"] = redirect_enabled
|
|
581
|
+
if redirect_url is not None:
|
|
582
|
+
payload_safe["redirect_url"] = redirect_url
|
|
583
|
+
if terms_of_service_enabled is not None:
|
|
584
|
+
payload_safe["terms_of_service_enabled"] = terms_of_service_enabled
|
|
585
|
+
if terms_of_service_text is not None:
|
|
586
|
+
payload_safe["terms_of_service_text"] = terms_of_service_text
|
|
587
|
+
logger.info(f"[DRY RUN] Would configure guest portal with payload: {payload_safe}")
|
|
588
|
+
return {"dry_run": True, "payload": payload_safe}
|
|
589
|
+
|
|
590
|
+
response = await client.put(
|
|
591
|
+
f"/integration/v1/sites/{site_id}/guest-portal/config", json_data=payload
|
|
592
|
+
)
|
|
593
|
+
data = response.get("data", response)
|
|
594
|
+
|
|
595
|
+
# Audit the action
|
|
596
|
+
await audit_action(
|
|
597
|
+
settings,
|
|
598
|
+
action_type="configure_guest_portal",
|
|
599
|
+
resource_type="guest_portal_config",
|
|
600
|
+
resource_id=site_id,
|
|
601
|
+
site_id=site_id,
|
|
602
|
+
details=payload,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
return GuestPortalConfig(**data).model_dump() # type: ignore[no-any-return]
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
# =============================================================================
|
|
609
|
+
# Hotspot Package Management
|
|
610
|
+
# =============================================================================
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
async def list_hotspot_packages(
|
|
614
|
+
site_id: str,
|
|
615
|
+
settings: Settings,
|
|
616
|
+
) -> list[dict]:
|
|
617
|
+
"""List all hotspot packages for a site.
|
|
618
|
+
|
|
619
|
+
Args:
|
|
620
|
+
site_id: Site identifier
|
|
621
|
+
settings: Application settings
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
List of hotspot packages
|
|
625
|
+
"""
|
|
626
|
+
async with UniFiClient(settings) as client:
|
|
627
|
+
logger.info(f"Listing hotspot packages for site {site_id}")
|
|
628
|
+
|
|
629
|
+
if not client.is_authenticated:
|
|
630
|
+
await client.authenticate()
|
|
631
|
+
|
|
632
|
+
response = await client.get(f"/integration/v1/sites/{site_id}/hotspot/packages")
|
|
633
|
+
data = response.get("data", [])
|
|
634
|
+
|
|
635
|
+
return [HotspotPackage(**package).model_dump() for package in data]
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
async def create_hotspot_package(
|
|
639
|
+
site_id: str,
|
|
640
|
+
name: str,
|
|
641
|
+
duration_minutes: int,
|
|
642
|
+
settings: Settings,
|
|
643
|
+
download_limit_kbps: int | None = None,
|
|
644
|
+
upload_limit_kbps: int | None = None,
|
|
645
|
+
download_quota_mb: int | None = None,
|
|
646
|
+
upload_quota_mb: int | None = None,
|
|
647
|
+
price: float | None = None,
|
|
648
|
+
currency: str = "USD",
|
|
649
|
+
confirm: bool = False,
|
|
650
|
+
dry_run: bool = False,
|
|
651
|
+
) -> dict:
|
|
652
|
+
"""Create a new hotspot package.
|
|
653
|
+
|
|
654
|
+
Args:
|
|
655
|
+
site_id: Site identifier
|
|
656
|
+
name: Package name
|
|
657
|
+
duration_minutes: Duration in minutes
|
|
658
|
+
settings: Application settings
|
|
659
|
+
download_limit_kbps: Download speed limit in kbps
|
|
660
|
+
upload_limit_kbps: Upload speed limit in kbps
|
|
661
|
+
download_quota_mb: Download quota in MB
|
|
662
|
+
upload_quota_mb: Upload quota in MB
|
|
663
|
+
price: Package price
|
|
664
|
+
currency: Currency code
|
|
665
|
+
confirm: Confirmation flag (required)
|
|
666
|
+
dry_run: If True, validate but don't execute
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Created hotspot package
|
|
670
|
+
"""
|
|
671
|
+
validate_confirmation(confirm, "create hotspot package")
|
|
672
|
+
|
|
673
|
+
async with UniFiClient(settings) as client:
|
|
674
|
+
logger.info(f"Creating hotspot package '{name}' for site {site_id}")
|
|
675
|
+
|
|
676
|
+
if not client.is_authenticated:
|
|
677
|
+
await client.authenticate()
|
|
678
|
+
|
|
679
|
+
# Build request payload
|
|
680
|
+
payload: dict[str, Any] = {
|
|
681
|
+
"name": name,
|
|
682
|
+
"duration_minutes": duration_minutes,
|
|
683
|
+
"currency": currency,
|
|
684
|
+
"enabled": True,
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if download_limit_kbps is not None:
|
|
688
|
+
payload["download_limit_kbps"] = download_limit_kbps
|
|
689
|
+
if upload_limit_kbps is not None:
|
|
690
|
+
payload["upload_limit_kbps"] = upload_limit_kbps
|
|
691
|
+
if download_quota_mb is not None:
|
|
692
|
+
payload["download_quota_mb"] = download_quota_mb
|
|
693
|
+
if upload_quota_mb is not None:
|
|
694
|
+
payload["upload_quota_mb"] = upload_quota_mb
|
|
695
|
+
if price is not None:
|
|
696
|
+
payload["price"] = price
|
|
697
|
+
|
|
698
|
+
if dry_run:
|
|
699
|
+
logger.info(f"[DRY RUN] Would create hotspot package with payload: {payload}")
|
|
700
|
+
return {"dry_run": True, "payload": payload}
|
|
701
|
+
|
|
702
|
+
response = await client.post(
|
|
703
|
+
f"/integration/v1/sites/{site_id}/hotspot/packages", json_data=payload
|
|
704
|
+
)
|
|
705
|
+
data = response.get("data", response)
|
|
706
|
+
|
|
707
|
+
# Audit the action
|
|
708
|
+
await audit_action(
|
|
709
|
+
settings,
|
|
710
|
+
action_type="create_hotspot_package",
|
|
711
|
+
resource_type="hotspot_package",
|
|
712
|
+
resource_id=data.get("_id", "unknown"),
|
|
713
|
+
site_id=site_id,
|
|
714
|
+
details={"name": name, "duration_minutes": duration_minutes},
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
return HotspotPackage(**data).model_dump() # type: ignore[no-any-return]
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
async def delete_hotspot_package(
|
|
721
|
+
site_id: str,
|
|
722
|
+
package_id: str,
|
|
723
|
+
settings: Settings,
|
|
724
|
+
confirm: bool = False,
|
|
725
|
+
dry_run: bool = False,
|
|
726
|
+
) -> dict:
|
|
727
|
+
"""Delete a hotspot package.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
site_id: Site identifier
|
|
731
|
+
package_id: Hotspot package ID
|
|
732
|
+
settings: Application settings
|
|
733
|
+
confirm: Confirmation flag (required)
|
|
734
|
+
dry_run: If True, validate but don't execute
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
Deletion status
|
|
738
|
+
"""
|
|
739
|
+
validate_confirmation(confirm, "delete hotspot package")
|
|
740
|
+
|
|
741
|
+
async with UniFiClient(settings) as client:
|
|
742
|
+
logger.info(f"Deleting hotspot package {package_id} for site {site_id}")
|
|
743
|
+
|
|
744
|
+
if not client.is_authenticated:
|
|
745
|
+
await client.authenticate()
|
|
746
|
+
|
|
747
|
+
if dry_run:
|
|
748
|
+
logger.info(f"[DRY RUN] Would delete hotspot package {package_id}")
|
|
749
|
+
return {"dry_run": True, "package_id": package_id}
|
|
750
|
+
|
|
751
|
+
await client.delete(f"/integration/v1/sites/{site_id}/hotspot/packages/{package_id}")
|
|
752
|
+
|
|
753
|
+
# Audit the action
|
|
754
|
+
await audit_action(
|
|
755
|
+
settings,
|
|
756
|
+
action_type="delete_hotspot_package",
|
|
757
|
+
resource_type="hotspot_package",
|
|
758
|
+
resource_id=package_id,
|
|
759
|
+
site_id=site_id,
|
|
760
|
+
details={},
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
return {"success": True, "message": f"Hotspot package {package_id} deleted successfully"}
|