iflow-mcp_enuno-unifi-mcp-server 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/METADATA +1282 -0
  2. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/RECORD +81 -0
  3. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/WHEEL +4 -0
  4. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/entry_points.txt +2 -0
  5. iflow_mcp_enuno_unifi_mcp_server-0.2.1.dist-info/licenses/LICENSE +201 -0
  6. src/__init__.py +3 -0
  7. src/__main__.py +6 -0
  8. src/api/__init__.py +5 -0
  9. src/api/client.py +727 -0
  10. src/api/site_manager_client.py +176 -0
  11. src/cache.py +483 -0
  12. src/config/__init__.py +5 -0
  13. src/config/config.py +321 -0
  14. src/main.py +2234 -0
  15. src/models/__init__.py +126 -0
  16. src/models/acl.py +41 -0
  17. src/models/backup.py +272 -0
  18. src/models/client.py +74 -0
  19. src/models/device.py +53 -0
  20. src/models/dpi.py +50 -0
  21. src/models/firewall_policy.py +123 -0
  22. src/models/firewall_zone.py +28 -0
  23. src/models/network.py +62 -0
  24. src/models/qos_profile.py +458 -0
  25. src/models/radius.py +141 -0
  26. src/models/reference_data.py +34 -0
  27. src/models/site.py +59 -0
  28. src/models/site_manager.py +120 -0
  29. src/models/topology.py +138 -0
  30. src/models/traffic_flow.py +137 -0
  31. src/models/traffic_matching_list.py +56 -0
  32. src/models/voucher.py +42 -0
  33. src/models/vpn.py +73 -0
  34. src/models/wan.py +48 -0
  35. src/models/zbf_matrix.py +49 -0
  36. src/resources/__init__.py +8 -0
  37. src/resources/clients.py +111 -0
  38. src/resources/devices.py +102 -0
  39. src/resources/networks.py +93 -0
  40. src/resources/site_manager.py +64 -0
  41. src/resources/sites.py +86 -0
  42. src/tools/__init__.py +25 -0
  43. src/tools/acls.py +328 -0
  44. src/tools/application.py +42 -0
  45. src/tools/backups.py +1173 -0
  46. src/tools/client_management.py +505 -0
  47. src/tools/clients.py +203 -0
  48. src/tools/device_control.py +325 -0
  49. src/tools/devices.py +354 -0
  50. src/tools/dpi.py +241 -0
  51. src/tools/dpi_tools.py +89 -0
  52. src/tools/firewall.py +417 -0
  53. src/tools/firewall_policies.py +430 -0
  54. src/tools/firewall_zones.py +515 -0
  55. src/tools/network_config.py +388 -0
  56. src/tools/networks.py +190 -0
  57. src/tools/port_forwarding.py +263 -0
  58. src/tools/qos.py +1070 -0
  59. src/tools/radius.py +763 -0
  60. src/tools/reference_data.py +107 -0
  61. src/tools/site_manager.py +466 -0
  62. src/tools/site_vpn.py +95 -0
  63. src/tools/sites.py +187 -0
  64. src/tools/topology.py +406 -0
  65. src/tools/traffic_flows.py +1062 -0
  66. src/tools/traffic_matching_lists.py +371 -0
  67. src/tools/vouchers.py +249 -0
  68. src/tools/vpn.py +76 -0
  69. src/tools/wans.py +30 -0
  70. src/tools/wifi.py +498 -0
  71. src/tools/zbf_matrix.py +326 -0
  72. src/utils/__init__.py +88 -0
  73. src/utils/audit.py +213 -0
  74. src/utils/exceptions.py +114 -0
  75. src/utils/helpers.py +159 -0
  76. src/utils/logger.py +105 -0
  77. src/utils/sanitize.py +244 -0
  78. src/utils/validators.py +160 -0
  79. src/webhooks/__init__.py +6 -0
  80. src/webhooks/handlers.py +196 -0
  81. src/webhooks/receiver.py +290 -0
src/tools/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"}