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/firewall.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Firewall rules 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
|
+
validate_confirmation,
|
|
12
|
+
validate_limit_offset,
|
|
13
|
+
validate_site_id,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def list_firewall_rules(
|
|
18
|
+
site_id: str,
|
|
19
|
+
settings: Settings,
|
|
20
|
+
limit: int | None = None,
|
|
21
|
+
offset: int | None = None,
|
|
22
|
+
) -> list[dict[str, Any]]:
|
|
23
|
+
"""List all firewall rules in a site (read-only).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
site_id: Site identifier
|
|
27
|
+
settings: Application settings
|
|
28
|
+
limit: Maximum number of rules to return
|
|
29
|
+
offset: Number of rules to skip
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
List of firewall rule dictionaries
|
|
33
|
+
"""
|
|
34
|
+
site_id = validate_site_id(site_id)
|
|
35
|
+
limit, offset = validate_limit_offset(limit, offset)
|
|
36
|
+
logger = get_logger(__name__, settings.log_level)
|
|
37
|
+
|
|
38
|
+
async with UniFiClient(settings) as client:
|
|
39
|
+
await client.authenticate()
|
|
40
|
+
|
|
41
|
+
response = await client.get(f"/ea/sites/{site_id}/rest/firewallrule")
|
|
42
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
43
|
+
rules_data: list[dict[str, Any]] = (
|
|
44
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Apply pagination
|
|
48
|
+
paginated = rules_data[offset : offset + limit]
|
|
49
|
+
|
|
50
|
+
logger.info(f"Retrieved {len(paginated)} firewall rules for site '{site_id}'")
|
|
51
|
+
return paginated
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def create_firewall_rule(
|
|
55
|
+
site_id: str,
|
|
56
|
+
name: str,
|
|
57
|
+
action: str,
|
|
58
|
+
settings: Settings,
|
|
59
|
+
source: str | None = None,
|
|
60
|
+
destination: str | None = None,
|
|
61
|
+
protocol: str | None = None,
|
|
62
|
+
port: int | None = None,
|
|
63
|
+
enabled: bool = True,
|
|
64
|
+
ruleset: str = "WAN_IN",
|
|
65
|
+
rule_index: int = 2000,
|
|
66
|
+
confirm: bool = False,
|
|
67
|
+
dry_run: bool = False,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Create a new firewall rule.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
site_id: Site identifier
|
|
73
|
+
name: Rule name
|
|
74
|
+
action: Action to take (accept, drop, reject)
|
|
75
|
+
settings: Application settings
|
|
76
|
+
source: Source network/IP (CIDR notation)
|
|
77
|
+
destination: Destination network/IP (CIDR notation)
|
|
78
|
+
protocol: Protocol (tcp, udp, icmp, all)
|
|
79
|
+
port: Destination port number
|
|
80
|
+
enabled: Enable the rule immediately
|
|
81
|
+
ruleset: Ruleset to apply rule to (WAN_IN, WAN_OUT, LAN_IN, etc.)
|
|
82
|
+
rule_index: Position in firewall chain (higher = lower priority)
|
|
83
|
+
confirm: Confirmation flag (must be True to execute)
|
|
84
|
+
dry_run: If True, validate but don't create the rule
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Created firewall rule dictionary or dry-run result
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ConfirmationRequiredError: If confirm is not True
|
|
91
|
+
ValidationError: If validation fails
|
|
92
|
+
"""
|
|
93
|
+
site_id = validate_site_id(site_id)
|
|
94
|
+
validate_confirmation(confirm, "firewall operation")
|
|
95
|
+
logger = get_logger(__name__, settings.log_level)
|
|
96
|
+
|
|
97
|
+
# Validate action
|
|
98
|
+
valid_actions = ["accept", "drop", "reject"]
|
|
99
|
+
if action.lower() not in valid_actions:
|
|
100
|
+
raise ValueError(f"Invalid action '{action}'. Must be one of: {valid_actions}")
|
|
101
|
+
|
|
102
|
+
# Validate protocol if provided
|
|
103
|
+
if protocol:
|
|
104
|
+
valid_protocols = ["tcp", "udp", "icmp", "all"]
|
|
105
|
+
if protocol.lower() not in valid_protocols:
|
|
106
|
+
raise ValueError(f"Invalid protocol '{protocol}'. Must be one of: {valid_protocols}")
|
|
107
|
+
|
|
108
|
+
# Build rule data with required defaults
|
|
109
|
+
rule_data = {
|
|
110
|
+
"name": name,
|
|
111
|
+
"action": action.lower(),
|
|
112
|
+
"enabled": enabled,
|
|
113
|
+
"ruleset": ruleset,
|
|
114
|
+
"rule_index": rule_index,
|
|
115
|
+
# Required default fields
|
|
116
|
+
"setting_preference": "auto",
|
|
117
|
+
"src_networkconf_type": "NETv4",
|
|
118
|
+
"dst_networkconf_type": "NETv4",
|
|
119
|
+
"state_new": False,
|
|
120
|
+
"state_established": False,
|
|
121
|
+
"state_invalid": False,
|
|
122
|
+
"state_related": False,
|
|
123
|
+
"logging": False,
|
|
124
|
+
"protocol_match_excepted": False,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if source:
|
|
128
|
+
rule_data["src_address"] = source
|
|
129
|
+
|
|
130
|
+
if destination:
|
|
131
|
+
rule_data["dst_address"] = destination
|
|
132
|
+
|
|
133
|
+
if protocol:
|
|
134
|
+
rule_data["protocol"] = protocol.lower()
|
|
135
|
+
|
|
136
|
+
if port is not None:
|
|
137
|
+
rule_data["dst_port"] = port
|
|
138
|
+
|
|
139
|
+
# Log parameters for audit
|
|
140
|
+
parameters = {
|
|
141
|
+
"site_id": site_id,
|
|
142
|
+
"name": name,
|
|
143
|
+
"action": action,
|
|
144
|
+
"source": source,
|
|
145
|
+
"destination": destination,
|
|
146
|
+
"protocol": protocol,
|
|
147
|
+
"port": port,
|
|
148
|
+
"enabled": enabled,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if dry_run:
|
|
152
|
+
logger.info(f"DRY RUN: Would create firewall rule '{name}' in site '{site_id}'")
|
|
153
|
+
log_audit(
|
|
154
|
+
operation="create_firewall_rule",
|
|
155
|
+
parameters=parameters,
|
|
156
|
+
result="dry_run",
|
|
157
|
+
site_id=site_id,
|
|
158
|
+
dry_run=True,
|
|
159
|
+
)
|
|
160
|
+
return {"dry_run": True, "would_create": rule_data}
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
async with UniFiClient(settings) as client:
|
|
164
|
+
await client.authenticate()
|
|
165
|
+
|
|
166
|
+
response = await client.post(
|
|
167
|
+
f"/ea/sites/{site_id}/rest/firewallrule", json_data=rule_data
|
|
168
|
+
)
|
|
169
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
170
|
+
if isinstance(response, list):
|
|
171
|
+
created_rule: dict[str, Any] = response[0]
|
|
172
|
+
else:
|
|
173
|
+
data_list = response.get("data", [{}])
|
|
174
|
+
created_rule = data_list[0] if isinstance(data_list, list) else {}
|
|
175
|
+
|
|
176
|
+
logger.info(f"Created firewall rule '{name}' in site '{site_id}'")
|
|
177
|
+
log_audit(
|
|
178
|
+
operation="create_firewall_rule",
|
|
179
|
+
parameters=parameters,
|
|
180
|
+
result="success",
|
|
181
|
+
site_id=site_id,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return created_rule
|
|
185
|
+
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(f"Failed to create firewall rule '{name}': {e}")
|
|
188
|
+
log_audit(
|
|
189
|
+
operation="create_firewall_rule",
|
|
190
|
+
parameters=parameters,
|
|
191
|
+
result="failed",
|
|
192
|
+
site_id=site_id,
|
|
193
|
+
)
|
|
194
|
+
raise
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def update_firewall_rule(
|
|
198
|
+
site_id: str,
|
|
199
|
+
rule_id: str,
|
|
200
|
+
settings: Settings,
|
|
201
|
+
name: str | None = None,
|
|
202
|
+
action: str | None = None,
|
|
203
|
+
source: str | None = None,
|
|
204
|
+
destination: str | None = None,
|
|
205
|
+
protocol: str | None = None,
|
|
206
|
+
port: int | None = None,
|
|
207
|
+
enabled: bool | None = None,
|
|
208
|
+
confirm: bool = False,
|
|
209
|
+
dry_run: bool = False,
|
|
210
|
+
) -> dict[str, Any]:
|
|
211
|
+
"""Update an existing firewall rule.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
site_id: Site identifier
|
|
215
|
+
rule_id: Firewall rule ID
|
|
216
|
+
settings: Application settings
|
|
217
|
+
name: New rule name
|
|
218
|
+
action: New action (accept, drop, reject)
|
|
219
|
+
source: New source network/IP
|
|
220
|
+
destination: New destination network/IP
|
|
221
|
+
protocol: New protocol (tcp, udp, icmp, all)
|
|
222
|
+
port: New destination port
|
|
223
|
+
enabled: Enable/disable the rule
|
|
224
|
+
confirm: Confirmation flag (must be True to execute)
|
|
225
|
+
dry_run: If True, validate but don't update the rule
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
Updated firewall rule dictionary or dry-run result
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
ConfirmationRequiredError: If confirm is not True
|
|
232
|
+
ResourceNotFoundError: If rule not found
|
|
233
|
+
"""
|
|
234
|
+
site_id = validate_site_id(site_id)
|
|
235
|
+
validate_confirmation(confirm, "firewall operation")
|
|
236
|
+
logger = get_logger(__name__, settings.log_level)
|
|
237
|
+
|
|
238
|
+
# Validate action if provided
|
|
239
|
+
if action:
|
|
240
|
+
valid_actions = ["accept", "drop", "reject"]
|
|
241
|
+
if action.lower() not in valid_actions:
|
|
242
|
+
raise ValueError(f"Invalid action '{action}'. Must be one of: {valid_actions}")
|
|
243
|
+
|
|
244
|
+
# Validate protocol if provided
|
|
245
|
+
if protocol:
|
|
246
|
+
valid_protocols = ["tcp", "udp", "icmp", "all"]
|
|
247
|
+
if protocol.lower() not in valid_protocols:
|
|
248
|
+
raise ValueError(f"Invalid protocol '{protocol}'. Must be one of: {valid_protocols}")
|
|
249
|
+
|
|
250
|
+
parameters = {
|
|
251
|
+
"site_id": site_id,
|
|
252
|
+
"rule_id": rule_id,
|
|
253
|
+
"name": name,
|
|
254
|
+
"action": action,
|
|
255
|
+
"source": source,
|
|
256
|
+
"destination": destination,
|
|
257
|
+
"protocol": protocol,
|
|
258
|
+
"port": port,
|
|
259
|
+
"enabled": enabled,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if dry_run:
|
|
263
|
+
logger.info(f"DRY RUN: Would update firewall rule '{rule_id}' in site '{site_id}'")
|
|
264
|
+
log_audit(
|
|
265
|
+
operation="update_firewall_rule",
|
|
266
|
+
parameters=parameters,
|
|
267
|
+
result="dry_run",
|
|
268
|
+
site_id=site_id,
|
|
269
|
+
dry_run=True,
|
|
270
|
+
)
|
|
271
|
+
return {"dry_run": True, "would_update": parameters}
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
async with UniFiClient(settings) as client:
|
|
275
|
+
await client.authenticate()
|
|
276
|
+
|
|
277
|
+
# Get existing rule
|
|
278
|
+
response = await client.get(f"/ea/sites/{site_id}/rest/firewallrule")
|
|
279
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
280
|
+
rules_data: list[dict[str, Any]] = (
|
|
281
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
existing_rule = None
|
|
285
|
+
for rule in rules_data:
|
|
286
|
+
if rule.get("_id") == rule_id:
|
|
287
|
+
existing_rule = rule
|
|
288
|
+
break
|
|
289
|
+
|
|
290
|
+
if not existing_rule:
|
|
291
|
+
raise ResourceNotFoundError("firewall_rule", rule_id)
|
|
292
|
+
|
|
293
|
+
# Build update data
|
|
294
|
+
update_data = existing_rule.copy()
|
|
295
|
+
|
|
296
|
+
if name is not None:
|
|
297
|
+
update_data["name"] = name
|
|
298
|
+
if action is not None:
|
|
299
|
+
update_data["action"] = action.lower()
|
|
300
|
+
if source is not None:
|
|
301
|
+
update_data["src_address"] = source
|
|
302
|
+
if destination is not None:
|
|
303
|
+
update_data["dst_address"] = destination
|
|
304
|
+
if protocol is not None:
|
|
305
|
+
update_data["protocol"] = protocol.lower()
|
|
306
|
+
if port is not None:
|
|
307
|
+
update_data["dst_port"] = port
|
|
308
|
+
if enabled is not None:
|
|
309
|
+
update_data["enabled"] = enabled
|
|
310
|
+
|
|
311
|
+
response = await client.put(
|
|
312
|
+
f"/ea/sites/{site_id}/rest/firewallrule/{rule_id}", json_data=update_data
|
|
313
|
+
)
|
|
314
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
315
|
+
if isinstance(response, list):
|
|
316
|
+
updated_rule: dict[str, Any] = response[0]
|
|
317
|
+
else:
|
|
318
|
+
data_list = response.get("data", [{}])
|
|
319
|
+
updated_rule = data_list[0] if isinstance(data_list, list) else {}
|
|
320
|
+
|
|
321
|
+
logger.info(f"Updated firewall rule '{rule_id}' in site '{site_id}'")
|
|
322
|
+
log_audit(
|
|
323
|
+
operation="update_firewall_rule",
|
|
324
|
+
parameters=parameters,
|
|
325
|
+
result="success",
|
|
326
|
+
site_id=site_id,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return updated_rule
|
|
330
|
+
|
|
331
|
+
except Exception as e:
|
|
332
|
+
logger.error(f"Failed to update firewall rule '{rule_id}': {e}")
|
|
333
|
+
log_audit(
|
|
334
|
+
operation="update_firewall_rule",
|
|
335
|
+
parameters=parameters,
|
|
336
|
+
result="failed",
|
|
337
|
+
site_id=site_id,
|
|
338
|
+
)
|
|
339
|
+
raise
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def delete_firewall_rule(
|
|
343
|
+
site_id: str,
|
|
344
|
+
rule_id: str,
|
|
345
|
+
settings: Settings,
|
|
346
|
+
confirm: bool = False,
|
|
347
|
+
dry_run: bool = False,
|
|
348
|
+
) -> dict[str, Any]:
|
|
349
|
+
"""Delete a firewall rule.
|
|
350
|
+
|
|
351
|
+
Args:
|
|
352
|
+
site_id: Site identifier
|
|
353
|
+
rule_id: Firewall rule ID
|
|
354
|
+
settings: Application settings
|
|
355
|
+
confirm: Confirmation flag (must be True to execute)
|
|
356
|
+
dry_run: If True, validate but don't delete the rule
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Deletion result dictionary
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
ConfirmationRequiredError: If confirm is not True
|
|
363
|
+
ResourceNotFoundError: If rule not found
|
|
364
|
+
"""
|
|
365
|
+
site_id = validate_site_id(site_id)
|
|
366
|
+
validate_confirmation(confirm, "firewall operation")
|
|
367
|
+
logger = get_logger(__name__, settings.log_level)
|
|
368
|
+
|
|
369
|
+
parameters = {"site_id": site_id, "rule_id": rule_id}
|
|
370
|
+
|
|
371
|
+
if dry_run:
|
|
372
|
+
logger.info(f"DRY RUN: Would delete firewall rule '{rule_id}' from site '{site_id}'")
|
|
373
|
+
log_audit(
|
|
374
|
+
operation="delete_firewall_rule",
|
|
375
|
+
parameters=parameters,
|
|
376
|
+
result="dry_run",
|
|
377
|
+
site_id=site_id,
|
|
378
|
+
dry_run=True,
|
|
379
|
+
)
|
|
380
|
+
return {"dry_run": True, "would_delete": rule_id}
|
|
381
|
+
|
|
382
|
+
try:
|
|
383
|
+
async with UniFiClient(settings) as client:
|
|
384
|
+
await client.authenticate()
|
|
385
|
+
|
|
386
|
+
# Verify rule exists before deleting
|
|
387
|
+
response = await client.get(f"/ea/sites/{site_id}/rest/firewallrule")
|
|
388
|
+
# Client now auto-unwraps the "data" field, so response is the actual data
|
|
389
|
+
rules_data: list[dict[str, Any]] = (
|
|
390
|
+
response if isinstance(response, list) else response.get("data", [])
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
rule_exists = any(rule.get("_id") == rule_id for rule in rules_data)
|
|
394
|
+
if not rule_exists:
|
|
395
|
+
raise ResourceNotFoundError("firewall_rule", rule_id)
|
|
396
|
+
|
|
397
|
+
response = await client.delete(f"/ea/sites/{site_id}/rest/firewallrule/{rule_id}")
|
|
398
|
+
|
|
399
|
+
logger.info(f"Deleted firewall rule '{rule_id}' from site '{site_id}'")
|
|
400
|
+
log_audit(
|
|
401
|
+
operation="delete_firewall_rule",
|
|
402
|
+
parameters=parameters,
|
|
403
|
+
result="success",
|
|
404
|
+
site_id=site_id,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return {"success": True, "deleted_rule_id": rule_id}
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"Failed to delete firewall rule '{rule_id}': {e}")
|
|
411
|
+
log_audit(
|
|
412
|
+
operation="delete_firewall_rule",
|
|
413
|
+
parameters=parameters,
|
|
414
|
+
result="failed",
|
|
415
|
+
site_id=site_id,
|
|
416
|
+
)
|
|
417
|
+
raise
|