ui-cli 2.0.0__tar.gz → 2.1.0__tar.gz
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.
- {ui_cli-2.0.0 → ui_cli-2.1.0}/CHANGELOG.md +8 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/PKG-INFO +20 -1
- {ui_cli-2.0.0 → ui_cli-2.1.0}/README.md +19 -0
- ui_cli-2.1.0/VERSION +1 -0
- ui_cli-2.1.0/src/ui_cli/commands/local/firewall.py +673 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/local_client.py +41 -5
- ui_cli-2.0.0/VERSION +0 -1
- ui_cli-2.0.0/src/ui_cli/commands/local/firewall.py +0 -285
- {ui_cli-2.0.0 → ui_cli-2.1.0}/.gitignore +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/LICENSE +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/pyproject.toml +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/__init__.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/client.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/__init__.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/devices.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/groups.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/hosts.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/isp.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/__init__.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/apgroups.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/clients.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/config.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/devices.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/dpi.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/events.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/health.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/networks.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/portfwd.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/stats.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/utils.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/vouchers.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/wan.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/wlans.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/sdwan.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/sites.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/speedtest.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/status.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/version.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/config.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/groups.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/main.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/models.py +0 -0
- {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/output.py +0 -0
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-06-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `ui lo firewall add` for creating classic local-controller firewall rules, including
|
|
15
|
+
IPv4 source/destination filters, port filters, logging, dry-run output, and
|
|
16
|
+
`--before`/`--after` ordering helpers.
|
|
17
|
+
|
|
10
18
|
## [2.0.0] - 2026-04-16
|
|
11
19
|
|
|
12
20
|
### Removed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ui-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: CLI utility for UniFi Site Manager and local controller APIs
|
|
5
5
|
Project-URL: Homepage, https://github.com/vedanta/ui-cli
|
|
6
6
|
Project-URL: Documentation, https://vedanta.github.io/ui-cli
|
|
@@ -540,6 +540,21 @@ Requires `UNIFI_CONTROLLER_URL`, `UNIFI_CONTROLLER_USERNAME`, and `UNIFI_CONTROL
|
|
|
540
540
|
./ui lo firewall list -v # Verbose with full details
|
|
541
541
|
./ui lo firewall list -o json # JSON output
|
|
542
542
|
|
|
543
|
+
# Create a targeted inter-VLAN allow rule
|
|
544
|
+
./ui lo firewall add "Allow Hermes to MacBook MoneyMoney MCP" \
|
|
545
|
+
--ruleset LAN_IN \
|
|
546
|
+
--action accept \
|
|
547
|
+
--protocol tcp \
|
|
548
|
+
--src 192.168.60.63/32 \
|
|
549
|
+
--dst 192.168.2.120/32 \
|
|
550
|
+
--dst-port 3850 \
|
|
551
|
+
--before "VLAN60" \
|
|
552
|
+
--logging \
|
|
553
|
+
-y
|
|
554
|
+
|
|
555
|
+
# Preview the UniFi payload without changing the controller
|
|
556
|
+
./ui lo firewall add "Allow MCP" --protocol tcp --dst-port 3850 --dry-run -o json
|
|
557
|
+
|
|
543
558
|
# Address and port groups
|
|
544
559
|
./ui lo firewall groups # List all groups
|
|
545
560
|
./ui lo firewall groups -v # Show group members
|
|
@@ -992,6 +1007,10 @@ NO_COLOR=1 ./ui lo health
|
|
|
992
1007
|
├── list # List firewall rules
|
|
993
1008
|
│ ├── --ruleset # Filter by ruleset
|
|
994
1009
|
│ └── -v # Verbose
|
|
1010
|
+
├── add <name> # Create a firewall rule
|
|
1011
|
+
│ ├── --src/--dst # IPv4 address/CIDR filters
|
|
1012
|
+
│ ├── --dst-port # Destination port/list/range
|
|
1013
|
+
│ └── --before # Place before rule ID/name
|
|
995
1014
|
└── groups # List address/port groups
|
|
996
1015
|
└── -v # Show members
|
|
997
1016
|
|
|
@@ -504,6 +504,21 @@ Requires `UNIFI_CONTROLLER_URL`, `UNIFI_CONTROLLER_USERNAME`, and `UNIFI_CONTROL
|
|
|
504
504
|
./ui lo firewall list -v # Verbose with full details
|
|
505
505
|
./ui lo firewall list -o json # JSON output
|
|
506
506
|
|
|
507
|
+
# Create a targeted inter-VLAN allow rule
|
|
508
|
+
./ui lo firewall add "Allow Hermes to MacBook MoneyMoney MCP" \
|
|
509
|
+
--ruleset LAN_IN \
|
|
510
|
+
--action accept \
|
|
511
|
+
--protocol tcp \
|
|
512
|
+
--src 192.168.60.63/32 \
|
|
513
|
+
--dst 192.168.2.120/32 \
|
|
514
|
+
--dst-port 3850 \
|
|
515
|
+
--before "VLAN60" \
|
|
516
|
+
--logging \
|
|
517
|
+
-y
|
|
518
|
+
|
|
519
|
+
# Preview the UniFi payload without changing the controller
|
|
520
|
+
./ui lo firewall add "Allow MCP" --protocol tcp --dst-port 3850 --dry-run -o json
|
|
521
|
+
|
|
507
522
|
# Address and port groups
|
|
508
523
|
./ui lo firewall groups # List all groups
|
|
509
524
|
./ui lo firewall groups -v # Show group members
|
|
@@ -956,6 +971,10 @@ NO_COLOR=1 ./ui lo health
|
|
|
956
971
|
├── list # List firewall rules
|
|
957
972
|
│ ├── --ruleset # Filter by ruleset
|
|
958
973
|
│ └── -v # Verbose
|
|
974
|
+
├── add <name> # Create a firewall rule
|
|
975
|
+
│ ├── --src/--dst # IPv4 address/CIDR filters
|
|
976
|
+
│ ├── --dst-port # Destination port/list/range
|
|
977
|
+
│ └── --before # Place before rule ID/name
|
|
959
978
|
└── groups # List address/port groups
|
|
960
979
|
└── -v # Show members
|
|
961
980
|
|
ui_cli-2.1.0/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2.1.0
|
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""Firewall commands for local controller."""
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
9
|
+
from ui_cli.output import (
|
|
10
|
+
OutputFormat,
|
|
11
|
+
console,
|
|
12
|
+
output_csv,
|
|
13
|
+
output_json,
|
|
14
|
+
print_error,
|
|
15
|
+
print_success,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="firewall", help="Firewall rules and groups", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Ruleset display names
|
|
22
|
+
RULESET_NAMES = {
|
|
23
|
+
"WAN_IN": "WAN In",
|
|
24
|
+
"WAN_OUT": "WAN Out",
|
|
25
|
+
"WAN_LOCAL": "WAN Local",
|
|
26
|
+
"LAN_IN": "LAN In",
|
|
27
|
+
"LAN_OUT": "LAN Out",
|
|
28
|
+
"LAN_LOCAL": "LAN Local",
|
|
29
|
+
"GUEST_IN": "Guest In",
|
|
30
|
+
"GUEST_OUT": "Guest Out",
|
|
31
|
+
"GUEST_LOCAL": "Guest Local",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
VALID_ACTIONS = {"accept", "drop", "reject"}
|
|
35
|
+
VALID_PROTOCOLS = {"all", "tcp", "udp", "tcp_udp", "icmp"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_action(action: str) -> tuple[str, str]:
|
|
39
|
+
"""Format action with color."""
|
|
40
|
+
action_lower = action.lower()
|
|
41
|
+
if action_lower == "accept":
|
|
42
|
+
return "accept", "green"
|
|
43
|
+
elif action_lower == "drop":
|
|
44
|
+
return "drop", "red"
|
|
45
|
+
elif action_lower == "reject":
|
|
46
|
+
return "reject", "yellow"
|
|
47
|
+
return action, "white"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def format_protocol(rule: dict[str, Any]) -> str:
|
|
51
|
+
"""Format protocol for display."""
|
|
52
|
+
protocol = rule.get("protocol", "all")
|
|
53
|
+
if protocol == "all":
|
|
54
|
+
return "any"
|
|
55
|
+
return protocol.upper()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def format_address(rule: dict[str, Any], prefix: str) -> str:
|
|
59
|
+
"""Format source or destination address."""
|
|
60
|
+
# Check for network type
|
|
61
|
+
net_type = rule.get(f"{prefix}_network_type", "")
|
|
62
|
+
if net_type == "ADDRv4":
|
|
63
|
+
addr = rule.get(f"{prefix}_address", "")
|
|
64
|
+
return addr if addr else "any"
|
|
65
|
+
|
|
66
|
+
# Check for firewall group
|
|
67
|
+
group = rule.get(f"{prefix}_firewallgroup_ids", [])
|
|
68
|
+
if group:
|
|
69
|
+
return f"group:{len(group)}"
|
|
70
|
+
|
|
71
|
+
# Check for specific network
|
|
72
|
+
network = rule.get(f"{prefix}_network", "")
|
|
73
|
+
if network:
|
|
74
|
+
return network
|
|
75
|
+
|
|
76
|
+
return "any"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_port(rule: dict[str, Any], prefix: str) -> str:
|
|
80
|
+
"""Format port information."""
|
|
81
|
+
port = rule.get(f"{prefix}_port", "")
|
|
82
|
+
if port:
|
|
83
|
+
return str(port)
|
|
84
|
+
return "*"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_ruleset_order(ruleset: str) -> int:
|
|
88
|
+
"""Get sort order for rulesets."""
|
|
89
|
+
order = [
|
|
90
|
+
"WAN_IN",
|
|
91
|
+
"WAN_OUT",
|
|
92
|
+
"WAN_LOCAL",
|
|
93
|
+
"LAN_IN",
|
|
94
|
+
"LAN_OUT",
|
|
95
|
+
"LAN_LOCAL",
|
|
96
|
+
"GUEST_IN",
|
|
97
|
+
"GUEST_OUT",
|
|
98
|
+
"GUEST_LOCAL",
|
|
99
|
+
]
|
|
100
|
+
try:
|
|
101
|
+
return order.index(ruleset)
|
|
102
|
+
except ValueError:
|
|
103
|
+
return 100
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize_ruleset(ruleset: str) -> str:
|
|
107
|
+
"""Normalize and validate a classic firewall ruleset name."""
|
|
108
|
+
normalized = ruleset.upper().replace("-", "_")
|
|
109
|
+
if normalized not in RULESET_NAMES:
|
|
110
|
+
valid = ", ".join(RULESET_NAMES)
|
|
111
|
+
raise ValueError(f"Unsupported ruleset '{ruleset}'. Valid rulesets: {valid}")
|
|
112
|
+
return normalized
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def normalize_action(action: str) -> str:
|
|
116
|
+
"""Normalize and validate a firewall action."""
|
|
117
|
+
normalized = action.lower()
|
|
118
|
+
if normalized not in VALID_ACTIONS:
|
|
119
|
+
valid = ", ".join(sorted(VALID_ACTIONS))
|
|
120
|
+
raise ValueError(f"Unsupported action '{action}'. Valid actions: {valid}")
|
|
121
|
+
return normalized
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def normalize_protocol(protocol: str) -> str:
|
|
125
|
+
"""Normalize and validate a firewall protocol."""
|
|
126
|
+
normalized = protocol.lower().replace("-", "_")
|
|
127
|
+
if normalized in ("any", "*"):
|
|
128
|
+
normalized = "all"
|
|
129
|
+
elif normalized == "both":
|
|
130
|
+
normalized = "tcp_udp"
|
|
131
|
+
if normalized not in VALID_PROTOCOLS:
|
|
132
|
+
valid = ", ".join(sorted(VALID_PROTOCOLS))
|
|
133
|
+
raise ValueError(f"Unsupported protocol '{protocol}'. Valid protocols: {valid}")
|
|
134
|
+
return normalized
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def normalize_address(value: str | None, label: str) -> str | None:
|
|
138
|
+
"""Normalize an IPv4 host or CIDR address for UniFi firewall payloads."""
|
|
139
|
+
if value is None or value.lower() in ("", "any", "*"):
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
network = ipaddress.ip_network(value, strict=False)
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise ValueError(f"Invalid {label} address '{value}'") from exc
|
|
146
|
+
|
|
147
|
+
if network.version != 4:
|
|
148
|
+
raise ValueError(f"Invalid {label} address '{value}': only IPv4 is supported")
|
|
149
|
+
return str(network)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def normalize_port(value: str | None, label: str) -> str | None:
|
|
153
|
+
"""Normalize a port list/range string."""
|
|
154
|
+
if value is None or value.lower() in ("", "any", "*"):
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
parts = [part.strip() for part in value.split(",") if part.strip()]
|
|
158
|
+
if not parts:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
for part in parts:
|
|
162
|
+
if "-" in part:
|
|
163
|
+
start, end = part.split("-", 1)
|
|
164
|
+
if not start.isdigit() or not end.isdigit():
|
|
165
|
+
raise ValueError(f"Invalid {label} port range '{part}'")
|
|
166
|
+
start_int = int(start)
|
|
167
|
+
end_int = int(end)
|
|
168
|
+
if start_int < 1 or end_int > 65535 or start_int > end_int:
|
|
169
|
+
raise ValueError(f"Invalid {label} port range '{part}'")
|
|
170
|
+
else:
|
|
171
|
+
if not part.isdigit():
|
|
172
|
+
raise ValueError(f"Invalid {label} port '{part}'")
|
|
173
|
+
port_int = int(part)
|
|
174
|
+
if port_int < 1 or port_int > 65535:
|
|
175
|
+
raise ValueError(f"Invalid {label} port '{part}'")
|
|
176
|
+
|
|
177
|
+
return ",".join(parts)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def build_firewall_rule_payload(
|
|
181
|
+
*,
|
|
182
|
+
name: str,
|
|
183
|
+
ruleset: str,
|
|
184
|
+
action: str,
|
|
185
|
+
protocol: str,
|
|
186
|
+
src: str | None,
|
|
187
|
+
dst: str | None,
|
|
188
|
+
src_port: str | None,
|
|
189
|
+
dst_port: str | None,
|
|
190
|
+
logging: bool,
|
|
191
|
+
enabled: bool,
|
|
192
|
+
rule_index: int | None,
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
"""Build a classic UniFi firewall rule payload."""
|
|
195
|
+
if not name.strip():
|
|
196
|
+
raise ValueError("Rule name cannot be empty")
|
|
197
|
+
|
|
198
|
+
normalized_ruleset = normalize_ruleset(ruleset)
|
|
199
|
+
normalized_action = normalize_action(action)
|
|
200
|
+
normalized_protocol = normalize_protocol(protocol)
|
|
201
|
+
normalized_src = normalize_address(src, "source")
|
|
202
|
+
normalized_dst = normalize_address(dst, "destination")
|
|
203
|
+
normalized_src_port = normalize_port(src_port, "source")
|
|
204
|
+
normalized_dst_port = normalize_port(dst_port, "destination")
|
|
205
|
+
|
|
206
|
+
if normalized_protocol in ("all", "icmp") and (
|
|
207
|
+
normalized_src_port or normalized_dst_port
|
|
208
|
+
):
|
|
209
|
+
raise ValueError("Port filters require protocol tcp, udp, or tcp_udp")
|
|
210
|
+
|
|
211
|
+
payload: dict[str, Any] = {
|
|
212
|
+
"name": name.strip(),
|
|
213
|
+
"enabled": enabled,
|
|
214
|
+
"ruleset": normalized_ruleset,
|
|
215
|
+
"action": normalized_action,
|
|
216
|
+
"protocol": normalized_protocol,
|
|
217
|
+
"logging": logging,
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if normalized_src:
|
|
221
|
+
payload["src_network_type"] = "ADDRv4"
|
|
222
|
+
payload["src_address"] = normalized_src
|
|
223
|
+
if normalized_dst:
|
|
224
|
+
payload["dst_network_type"] = "ADDRv4"
|
|
225
|
+
payload["dst_address"] = normalized_dst
|
|
226
|
+
if normalized_src_port:
|
|
227
|
+
payload["src_port"] = normalized_src_port
|
|
228
|
+
if normalized_dst_port:
|
|
229
|
+
payload["dst_port"] = normalized_dst_port
|
|
230
|
+
if rule_index is not None:
|
|
231
|
+
payload["rule_index"] = rule_index
|
|
232
|
+
|
|
233
|
+
return payload
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def resolve_rule_reference(
|
|
237
|
+
rules: list[dict[str, Any]],
|
|
238
|
+
identifier: str,
|
|
239
|
+
ruleset: str,
|
|
240
|
+
) -> dict[str, Any]:
|
|
241
|
+
"""Resolve a firewall rule by ID, exact name, or unique name substring."""
|
|
242
|
+
ruleset_rules = [
|
|
243
|
+
rule for rule in rules if rule.get("ruleset", "").upper() == ruleset
|
|
244
|
+
]
|
|
245
|
+
identifier_lower = identifier.lower()
|
|
246
|
+
|
|
247
|
+
exact_matches = [
|
|
248
|
+
rule
|
|
249
|
+
for rule in ruleset_rules
|
|
250
|
+
if rule.get("_id") == identifier
|
|
251
|
+
or rule.get("name", "").lower() == identifier_lower
|
|
252
|
+
]
|
|
253
|
+
if len(exact_matches) == 1:
|
|
254
|
+
return exact_matches[0]
|
|
255
|
+
if len(exact_matches) > 1:
|
|
256
|
+
raise ValueError(f"Multiple rules match '{identifier}'")
|
|
257
|
+
|
|
258
|
+
partial_matches = [
|
|
259
|
+
rule
|
|
260
|
+
for rule in ruleset_rules
|
|
261
|
+
if identifier_lower in rule.get("name", "").lower()
|
|
262
|
+
]
|
|
263
|
+
if len(partial_matches) == 1:
|
|
264
|
+
return partial_matches[0]
|
|
265
|
+
if len(partial_matches) > 1:
|
|
266
|
+
raise ValueError(f"Multiple rules match '{identifier}'")
|
|
267
|
+
|
|
268
|
+
raise ValueError(f"No {ruleset} rule matches '{identifier}'")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def compute_relative_rule_index(
|
|
272
|
+
rules: list[dict[str, Any]],
|
|
273
|
+
*,
|
|
274
|
+
ruleset: str,
|
|
275
|
+
before: str | None,
|
|
276
|
+
after: str | None,
|
|
277
|
+
) -> int:
|
|
278
|
+
"""Compute a rule index before or after an existing rule."""
|
|
279
|
+
if before and after:
|
|
280
|
+
raise ValueError("--before and --after cannot be used together")
|
|
281
|
+
if not before and not after:
|
|
282
|
+
raise ValueError("Either --before or --after is required")
|
|
283
|
+
|
|
284
|
+
sorted_rules = sorted(
|
|
285
|
+
[rule for rule in rules if rule.get("ruleset", "").upper() == ruleset],
|
|
286
|
+
key=lambda rule: int(rule.get("rule_index", 0)),
|
|
287
|
+
)
|
|
288
|
+
target = resolve_rule_reference(rules, before or after or "", ruleset)
|
|
289
|
+
position = sorted_rules.index(target)
|
|
290
|
+
target_index = int(target.get("rule_index", 0))
|
|
291
|
+
|
|
292
|
+
if before:
|
|
293
|
+
lower_index = (
|
|
294
|
+
int(sorted_rules[position - 1].get("rule_index", 0))
|
|
295
|
+
if position > 0
|
|
296
|
+
else target_index - 1000
|
|
297
|
+
)
|
|
298
|
+
gap = target_index - lower_index
|
|
299
|
+
if gap > 1:
|
|
300
|
+
return lower_index + gap // 2
|
|
301
|
+
return max(1, target_index - 1)
|
|
302
|
+
|
|
303
|
+
upper_index = (
|
|
304
|
+
int(sorted_rules[position + 1].get("rule_index", target_index + 1000))
|
|
305
|
+
if position < len(sorted_rules) - 1
|
|
306
|
+
else target_index + 1000
|
|
307
|
+
)
|
|
308
|
+
gap = upper_index - target_index
|
|
309
|
+
if gap > 1:
|
|
310
|
+
return target_index + gap // 2
|
|
311
|
+
return target_index + 1
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def print_rule_summary(rule: dict[str, Any], *, created: bool = True) -> None:
|
|
315
|
+
"""Print a concise created-rule summary."""
|
|
316
|
+
from rich.table import Table
|
|
317
|
+
|
|
318
|
+
title = "Firewall Rule" if created else "Firewall Rule Payload"
|
|
319
|
+
table = Table(title=title, show_header=False, box=None, padding=(0, 2))
|
|
320
|
+
table.add_column("Field", style="dim")
|
|
321
|
+
table.add_column("Value")
|
|
322
|
+
if created:
|
|
323
|
+
table.add_row("ID:", rule.get("_id", ""))
|
|
324
|
+
table.add_row("Name:", rule.get("name", ""))
|
|
325
|
+
table.add_row("Ruleset:", rule.get("ruleset", ""))
|
|
326
|
+
table.add_row("Action:", rule.get("action", ""))
|
|
327
|
+
table.add_row("Protocol:", format_protocol(rule))
|
|
328
|
+
table.add_row("Source:", format_address(rule, "src"))
|
|
329
|
+
table.add_row("Destination:", format_address(rule, "dst"))
|
|
330
|
+
table.add_row("Destination Port:", format_port(rule, "dst"))
|
|
331
|
+
if "rule_index" in rule:
|
|
332
|
+
table.add_row("Rule Index:", str(rule.get("rule_index", "")))
|
|
333
|
+
table.add_row("Logging:", "Yes" if rule.get("logging", False) else "No")
|
|
334
|
+
table.add_row("Enabled:", "Yes" if rule.get("enabled", True) else "No")
|
|
335
|
+
|
|
336
|
+
if created:
|
|
337
|
+
print_success(f"Created firewall rule '{rule.get('name', '')}'")
|
|
338
|
+
console.print(table)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command("add")
|
|
342
|
+
def add_rule(
|
|
343
|
+
name: Annotated[str, typer.Argument(help="Name for the new firewall rule")],
|
|
344
|
+
ruleset: Annotated[
|
|
345
|
+
str,
|
|
346
|
+
typer.Option("--ruleset", "-r", help="Ruleset, for example LAN_IN"),
|
|
347
|
+
] = "LAN_IN",
|
|
348
|
+
action: Annotated[
|
|
349
|
+
str,
|
|
350
|
+
typer.Option("--action", "-a", help="Action: accept, drop, reject"),
|
|
351
|
+
] = "accept",
|
|
352
|
+
protocol: Annotated[
|
|
353
|
+
str,
|
|
354
|
+
typer.Option("--protocol", "-p", help="Protocol: all, tcp, udp, tcp_udp, icmp"),
|
|
355
|
+
] = "all",
|
|
356
|
+
src: Annotated[
|
|
357
|
+
str | None,
|
|
358
|
+
typer.Option("--src", help="Source IPv4 address/CIDR, or any"),
|
|
359
|
+
] = None,
|
|
360
|
+
dst: Annotated[
|
|
361
|
+
str | None,
|
|
362
|
+
typer.Option("--dst", help="Destination IPv4 address/CIDR, or any"),
|
|
363
|
+
] = None,
|
|
364
|
+
src_port: Annotated[
|
|
365
|
+
str | None,
|
|
366
|
+
typer.Option("--src-port", help="Source port, list, or range"),
|
|
367
|
+
] = None,
|
|
368
|
+
dst_port: Annotated[
|
|
369
|
+
str | None,
|
|
370
|
+
typer.Option("--dst-port", help="Destination port, list, or range"),
|
|
371
|
+
] = None,
|
|
372
|
+
rule_index: Annotated[
|
|
373
|
+
int | None,
|
|
374
|
+
typer.Option("--rule-index", help="Explicit UniFi rule_index value"),
|
|
375
|
+
] = None,
|
|
376
|
+
before: Annotated[
|
|
377
|
+
str | None,
|
|
378
|
+
typer.Option("--before", help="Place before rule ID/name in the same ruleset"),
|
|
379
|
+
] = None,
|
|
380
|
+
after: Annotated[
|
|
381
|
+
str | None,
|
|
382
|
+
typer.Option("--after", help="Place after rule ID/name in the same ruleset"),
|
|
383
|
+
] = None,
|
|
384
|
+
logging: Annotated[
|
|
385
|
+
bool,
|
|
386
|
+
typer.Option("--logging/--no-logging", help="Enable controller logging"),
|
|
387
|
+
] = False,
|
|
388
|
+
enabled: Annotated[
|
|
389
|
+
bool,
|
|
390
|
+
typer.Option("--enabled/--disabled", help="Create rule enabled or disabled"),
|
|
391
|
+
] = True,
|
|
392
|
+
dry_run: Annotated[
|
|
393
|
+
bool,
|
|
394
|
+
typer.Option("--dry-run", help="Print the payload without creating the rule"),
|
|
395
|
+
] = False,
|
|
396
|
+
yes: Annotated[
|
|
397
|
+
bool,
|
|
398
|
+
typer.Option("--yes", "-y", help="Skip confirmation prompt"),
|
|
399
|
+
] = False,
|
|
400
|
+
output: Annotated[
|
|
401
|
+
OutputFormat,
|
|
402
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
403
|
+
] = OutputFormat.TABLE,
|
|
404
|
+
) -> None:
|
|
405
|
+
"""Create a classic UniFi firewall rule."""
|
|
406
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
if rule_index is not None and (before or after):
|
|
410
|
+
raise ValueError("--rule-index cannot be combined with --before or --after")
|
|
411
|
+
|
|
412
|
+
payload = build_firewall_rule_payload(
|
|
413
|
+
name=name,
|
|
414
|
+
ruleset=ruleset,
|
|
415
|
+
action=action,
|
|
416
|
+
protocol=protocol,
|
|
417
|
+
src=src,
|
|
418
|
+
dst=dst,
|
|
419
|
+
src_port=src_port,
|
|
420
|
+
dst_port=dst_port,
|
|
421
|
+
logging=logging,
|
|
422
|
+
enabled=enabled,
|
|
423
|
+
rule_index=rule_index,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
client: UniFiLocalClient | None = None
|
|
427
|
+
if before or after:
|
|
428
|
+
client = UniFiLocalClient()
|
|
429
|
+
rules = run_with_spinner(
|
|
430
|
+
client.get_firewall_rules(),
|
|
431
|
+
"Fetching firewall rules...",
|
|
432
|
+
)
|
|
433
|
+
payload["rule_index"] = compute_relative_rule_index(
|
|
434
|
+
rules,
|
|
435
|
+
ruleset=payload["ruleset"],
|
|
436
|
+
before=before,
|
|
437
|
+
after=after,
|
|
438
|
+
)
|
|
439
|
+
except ValueError as e:
|
|
440
|
+
print_error(str(e))
|
|
441
|
+
raise typer.Exit(1)
|
|
442
|
+
except LocalAPIError as e:
|
|
443
|
+
print_error(str(e))
|
|
444
|
+
raise typer.Exit(1)
|
|
445
|
+
|
|
446
|
+
if dry_run:
|
|
447
|
+
if output == OutputFormat.JSON:
|
|
448
|
+
output_json(payload)
|
|
449
|
+
elif output == OutputFormat.CSV:
|
|
450
|
+
output_csv([payload])
|
|
451
|
+
else:
|
|
452
|
+
print_rule_summary(payload, created=False)
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
if not yes:
|
|
456
|
+
confirmed = typer.confirm(
|
|
457
|
+
f"Create firewall rule '{payload['name']}' in {payload['ruleset']}?"
|
|
458
|
+
)
|
|
459
|
+
if not confirmed:
|
|
460
|
+
raise typer.Exit(0)
|
|
461
|
+
|
|
462
|
+
async def _create():
|
|
463
|
+
active_client = client if client is not None else UniFiLocalClient()
|
|
464
|
+
return await active_client.create_firewall_rule(payload)
|
|
465
|
+
|
|
466
|
+
try:
|
|
467
|
+
created = run_with_spinner(_create(), "Creating firewall rule...")
|
|
468
|
+
except LocalAPIError as e:
|
|
469
|
+
print_error(str(e))
|
|
470
|
+
raise typer.Exit(1)
|
|
471
|
+
|
|
472
|
+
if output == OutputFormat.JSON:
|
|
473
|
+
output_json(created)
|
|
474
|
+
elif output == OutputFormat.CSV:
|
|
475
|
+
output_csv([created])
|
|
476
|
+
else:
|
|
477
|
+
print_rule_summary(created)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
@app.command("list")
|
|
481
|
+
def list_rules(
|
|
482
|
+
ruleset: Annotated[
|
|
483
|
+
str | None,
|
|
484
|
+
typer.Option("--ruleset", "-r", help="Filter by ruleset (e.g., WAN_IN, LAN_IN)"),
|
|
485
|
+
] = None,
|
|
486
|
+
output: Annotated[
|
|
487
|
+
OutputFormat,
|
|
488
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
489
|
+
] = OutputFormat.TABLE,
|
|
490
|
+
verbose: Annotated[
|
|
491
|
+
bool,
|
|
492
|
+
typer.Option("--verbose", "-v", help="Show additional details"),
|
|
493
|
+
] = False,
|
|
494
|
+
) -> None:
|
|
495
|
+
"""List firewall rules."""
|
|
496
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
497
|
+
|
|
498
|
+
async def _list():
|
|
499
|
+
client = UniFiLocalClient()
|
|
500
|
+
return await client.get_firewall_rules()
|
|
501
|
+
|
|
502
|
+
try:
|
|
503
|
+
rules = run_with_spinner(_list(), "Fetching firewall rules...")
|
|
504
|
+
except LocalAPIError as e:
|
|
505
|
+
print_error(str(e))
|
|
506
|
+
raise typer.Exit(1)
|
|
507
|
+
|
|
508
|
+
if not rules:
|
|
509
|
+
console.print("[dim]No firewall rules found[/dim]")
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
# Filter by ruleset if specified
|
|
513
|
+
if ruleset:
|
|
514
|
+
ruleset_upper = ruleset.upper()
|
|
515
|
+
rules = [r for r in rules if r.get("ruleset", "").upper() == ruleset_upper]
|
|
516
|
+
if not rules:
|
|
517
|
+
console.print(f"[dim]No rules found for ruleset '{ruleset}'[/dim]")
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
# Sort by ruleset then by rule index
|
|
521
|
+
rules.sort(key=lambda r: (get_ruleset_order(r.get("ruleset", "")), r.get("rule_index", 0)))
|
|
522
|
+
|
|
523
|
+
if output == OutputFormat.JSON:
|
|
524
|
+
output_json(rules)
|
|
525
|
+
elif output == OutputFormat.CSV:
|
|
526
|
+
columns = [
|
|
527
|
+
("name", "Name"),
|
|
528
|
+
("ruleset", "Ruleset"),
|
|
529
|
+
("action", "Action"),
|
|
530
|
+
("protocol", "Protocol"),
|
|
531
|
+
("src_address", "Source"),
|
|
532
|
+
("dst_address", "Destination"),
|
|
533
|
+
("enabled", "Enabled"),
|
|
534
|
+
]
|
|
535
|
+
csv_data = []
|
|
536
|
+
for r in rules:
|
|
537
|
+
csv_data.append({
|
|
538
|
+
"name": r.get("name", ""),
|
|
539
|
+
"ruleset": r.get("ruleset", ""),
|
|
540
|
+
"action": r.get("action", ""),
|
|
541
|
+
"protocol": format_protocol(r),
|
|
542
|
+
"src_address": format_address(r, "src"),
|
|
543
|
+
"dst_address": format_address(r, "dst"),
|
|
544
|
+
"enabled": "Yes" if r.get("enabled", True) else "No",
|
|
545
|
+
})
|
|
546
|
+
output_csv(csv_data, columns)
|
|
547
|
+
else:
|
|
548
|
+
from rich.table import Table
|
|
549
|
+
|
|
550
|
+
table = Table(title="Firewall Rules", show_header=True, header_style="bold cyan")
|
|
551
|
+
table.add_column("Name")
|
|
552
|
+
table.add_column("Ruleset")
|
|
553
|
+
table.add_column("Action")
|
|
554
|
+
table.add_column("Protocol")
|
|
555
|
+
table.add_column("Source")
|
|
556
|
+
table.add_column("Destination")
|
|
557
|
+
if verbose:
|
|
558
|
+
table.add_column("Src Port")
|
|
559
|
+
table.add_column("Dst Port")
|
|
560
|
+
table.add_column("Enabled")
|
|
561
|
+
|
|
562
|
+
for r in rules:
|
|
563
|
+
name = r.get("name", "(unnamed)")
|
|
564
|
+
ruleset_name = RULESET_NAMES.get(r.get("ruleset", ""), r.get("ruleset", ""))
|
|
565
|
+
action, action_style = format_action(r.get("action", ""))
|
|
566
|
+
protocol = format_protocol(r)
|
|
567
|
+
src = format_address(r, "src")
|
|
568
|
+
dst = format_address(r, "dst")
|
|
569
|
+
enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
|
|
570
|
+
|
|
571
|
+
if verbose:
|
|
572
|
+
src_port = format_port(r, "src")
|
|
573
|
+
dst_port = format_port(r, "dst")
|
|
574
|
+
table.add_row(
|
|
575
|
+
name,
|
|
576
|
+
ruleset_name,
|
|
577
|
+
f"[{action_style}]{action}[/{action_style}]",
|
|
578
|
+
protocol,
|
|
579
|
+
src,
|
|
580
|
+
dst,
|
|
581
|
+
src_port,
|
|
582
|
+
dst_port,
|
|
583
|
+
enabled,
|
|
584
|
+
)
|
|
585
|
+
else:
|
|
586
|
+
table.add_row(
|
|
587
|
+
name,
|
|
588
|
+
ruleset_name,
|
|
589
|
+
f"[{action_style}]{action}[/{action_style}]",
|
|
590
|
+
protocol,
|
|
591
|
+
src,
|
|
592
|
+
dst,
|
|
593
|
+
enabled,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
console.print(table)
|
|
597
|
+
console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@app.command("groups")
|
|
601
|
+
def list_groups(
|
|
602
|
+
output: Annotated[
|
|
603
|
+
OutputFormat,
|
|
604
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
605
|
+
] = OutputFormat.TABLE,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""List firewall groups (address and port groups)."""
|
|
608
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
609
|
+
|
|
610
|
+
async def _list():
|
|
611
|
+
client = UniFiLocalClient()
|
|
612
|
+
return await client.get_firewall_groups()
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
groups = run_with_spinner(_list(), "Fetching firewall groups...")
|
|
616
|
+
except LocalAPIError as e:
|
|
617
|
+
print_error(str(e))
|
|
618
|
+
raise typer.Exit(1)
|
|
619
|
+
|
|
620
|
+
if not groups:
|
|
621
|
+
console.print("[dim]No firewall groups found[/dim]")
|
|
622
|
+
return
|
|
623
|
+
|
|
624
|
+
# Sort by type then name
|
|
625
|
+
groups.sort(key=lambda g: (g.get("group_type", ""), g.get("name", "")))
|
|
626
|
+
|
|
627
|
+
if output == OutputFormat.JSON:
|
|
628
|
+
output_json(groups)
|
|
629
|
+
elif output == OutputFormat.CSV:
|
|
630
|
+
columns = [
|
|
631
|
+
("_id", "ID"),
|
|
632
|
+
("name", "Name"),
|
|
633
|
+
("group_type", "Type"),
|
|
634
|
+
("members", "Members"),
|
|
635
|
+
]
|
|
636
|
+
csv_data = []
|
|
637
|
+
for g in groups:
|
|
638
|
+
members = g.get("group_members", [])
|
|
639
|
+
csv_data.append({
|
|
640
|
+
"_id": g.get("_id", ""),
|
|
641
|
+
"name": g.get("name", ""),
|
|
642
|
+
"group_type": g.get("group_type", ""),
|
|
643
|
+
"members": ", ".join(members) if members else "",
|
|
644
|
+
})
|
|
645
|
+
output_csv(csv_data, columns)
|
|
646
|
+
else:
|
|
647
|
+
from rich.table import Table
|
|
648
|
+
|
|
649
|
+
table = Table(title="Firewall Groups", show_header=True, header_style="bold cyan")
|
|
650
|
+
table.add_column("ID", style="dim")
|
|
651
|
+
table.add_column("Name")
|
|
652
|
+
table.add_column("Type")
|
|
653
|
+
table.add_column("Members")
|
|
654
|
+
|
|
655
|
+
for g in groups:
|
|
656
|
+
group_id = g.get("_id", "")
|
|
657
|
+
name = g.get("name", "")
|
|
658
|
+
group_type = g.get("group_type", "")
|
|
659
|
+
|
|
660
|
+
# Format type for display
|
|
661
|
+
type_display = group_type.replace("-", " ").title()
|
|
662
|
+
|
|
663
|
+
# Format members
|
|
664
|
+
members = g.get("group_members", [])
|
|
665
|
+
if len(members) <= 3:
|
|
666
|
+
members_str = ", ".join(members) if members else "[dim]-[/dim]"
|
|
667
|
+
else:
|
|
668
|
+
members_str = f"{', '.join(members[:3])}... (+{len(members) - 3})"
|
|
669
|
+
|
|
670
|
+
table.add_row(group_id, name, type_display, members_str)
|
|
671
|
+
|
|
672
|
+
console.print(table)
|
|
673
|
+
console.print(f"\n[dim]{len(groups)} group(s)[/dim]")
|
|
@@ -100,7 +100,8 @@ class UniFiLocalClient:
|
|
|
100
100
|
# When API key is set, username/password are not required
|
|
101
101
|
if not self._api_key and (not self.username or not self.password):
|
|
102
102
|
raise LocalAuthenticationError(
|
|
103
|
-
"Controller credentials not configured. Set
|
|
103
|
+
"Controller credentials not configured. Set "
|
|
104
|
+
"UNIFI_CONTROLLER_USERNAME and UNIFI_CONTROLLER_PASSWORD in .env file."
|
|
104
105
|
)
|
|
105
106
|
|
|
106
107
|
@property
|
|
@@ -360,9 +361,11 @@ class UniFiLocalClient:
|
|
|
360
361
|
else:
|
|
361
362
|
raise LocalAuthenticationError(
|
|
362
363
|
f"API key authentication requires UniFi OS (UDM/UDM-Pro/Cloud Gateway, "
|
|
363
|
-
f"firmware >= 5.0.3). This controller returned HTTP
|
|
364
|
+
f"firmware >= 5.0.3). This controller returned HTTP "
|
|
365
|
+
f"{response.status_code}, "
|
|
364
366
|
f"suggesting it does not support API keys. "
|
|
365
|
-
|
|
367
|
+
"Use UNIFI_CONTROLLER_USERNAME/UNIFI_CONTROLLER_PASSWORD "
|
|
368
|
+
"for this controller type."
|
|
366
369
|
)
|
|
367
370
|
|
|
368
371
|
if response.status_code >= 400:
|
|
@@ -644,6 +647,25 @@ class UniFiLocalClient:
|
|
|
644
647
|
response = await self.get("/rest/firewallrule")
|
|
645
648
|
return response.get("data", [])
|
|
646
649
|
|
|
650
|
+
async def create_firewall_rule(self, payload: dict[str, Any]) -> dict[str, Any]:
|
|
651
|
+
"""Create a classic firewall rule."""
|
|
652
|
+
response = await self.post("/rest/firewallrule", data=payload)
|
|
653
|
+
data = response.get("data", [])
|
|
654
|
+
return data[0] if data else {}
|
|
655
|
+
|
|
656
|
+
async def update_firewall_rule(
|
|
657
|
+
self, rule_id: str, payload: dict[str, Any]
|
|
658
|
+
) -> dict[str, Any]:
|
|
659
|
+
"""Update a classic firewall rule."""
|
|
660
|
+
update_payload = {"_id": rule_id, **payload}
|
|
661
|
+
response = await self._request(
|
|
662
|
+
"PUT",
|
|
663
|
+
f"/rest/firewallrule/{rule_id}",
|
|
664
|
+
data=update_payload,
|
|
665
|
+
)
|
|
666
|
+
data = response.get("data", [])
|
|
667
|
+
return data[0] if data else {}
|
|
668
|
+
|
|
647
669
|
async def get_firewall_groups(self) -> list[dict[str, Any]]:
|
|
648
670
|
"""Get all firewall groups."""
|
|
649
671
|
response = await self.get("/rest/firewallgroup")
|
|
@@ -879,7 +901,14 @@ class UniFiLocalClient:
|
|
|
879
901
|
response = await self.post(
|
|
880
902
|
"/stat/report/daily.site",
|
|
881
903
|
data={
|
|
882
|
-
"attrs": [
|
|
904
|
+
"attrs": [
|
|
905
|
+
"time",
|
|
906
|
+
"rx_bytes",
|
|
907
|
+
"tx_bytes",
|
|
908
|
+
"num_sta",
|
|
909
|
+
"wan-rx_bytes",
|
|
910
|
+
"wan-tx_bytes",
|
|
911
|
+
],
|
|
883
912
|
"n": days,
|
|
884
913
|
},
|
|
885
914
|
)
|
|
@@ -890,7 +919,14 @@ class UniFiLocalClient:
|
|
|
890
919
|
response = await self.post(
|
|
891
920
|
"/stat/report/hourly.site",
|
|
892
921
|
data={
|
|
893
|
-
"attrs": [
|
|
922
|
+
"attrs": [
|
|
923
|
+
"time",
|
|
924
|
+
"rx_bytes",
|
|
925
|
+
"tx_bytes",
|
|
926
|
+
"num_sta",
|
|
927
|
+
"wan-rx_bytes",
|
|
928
|
+
"wan-tx_bytes",
|
|
929
|
+
],
|
|
894
930
|
"n": hours,
|
|
895
931
|
},
|
|
896
932
|
)
|
ui_cli-2.0.0/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2.0.0
|
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
"""Firewall commands for local controller."""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
from typing import Annotated, Any
|
|
5
|
-
|
|
6
|
-
import typer
|
|
7
|
-
|
|
8
|
-
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
9
|
-
from ui_cli.output import (
|
|
10
|
-
OutputFormat,
|
|
11
|
-
console,
|
|
12
|
-
output_csv,
|
|
13
|
-
output_json,
|
|
14
|
-
print_error,
|
|
15
|
-
)
|
|
16
|
-
|
|
17
|
-
app = typer.Typer(name="firewall", help="Firewall rules and groups", no_args_is_help=True)
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
# Ruleset display names
|
|
21
|
-
RULESET_NAMES = {
|
|
22
|
-
"WAN_IN": "WAN In",
|
|
23
|
-
"WAN_OUT": "WAN Out",
|
|
24
|
-
"WAN_LOCAL": "WAN Local",
|
|
25
|
-
"LAN_IN": "LAN In",
|
|
26
|
-
"LAN_OUT": "LAN Out",
|
|
27
|
-
"LAN_LOCAL": "LAN Local",
|
|
28
|
-
"GUEST_IN": "Guest In",
|
|
29
|
-
"GUEST_OUT": "Guest Out",
|
|
30
|
-
"GUEST_LOCAL": "Guest Local",
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def format_action(action: str) -> tuple[str, str]:
|
|
35
|
-
"""Format action with color."""
|
|
36
|
-
action_lower = action.lower()
|
|
37
|
-
if action_lower == "accept":
|
|
38
|
-
return "accept", "green"
|
|
39
|
-
elif action_lower == "drop":
|
|
40
|
-
return "drop", "red"
|
|
41
|
-
elif action_lower == "reject":
|
|
42
|
-
return "reject", "yellow"
|
|
43
|
-
return action, "white"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def format_protocol(rule: dict[str, Any]) -> str:
|
|
47
|
-
"""Format protocol for display."""
|
|
48
|
-
protocol = rule.get("protocol", "all")
|
|
49
|
-
if protocol == "all":
|
|
50
|
-
return "any"
|
|
51
|
-
return protocol.upper()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def format_address(rule: dict[str, Any], prefix: str) -> str:
|
|
55
|
-
"""Format source or destination address."""
|
|
56
|
-
# Check for network type
|
|
57
|
-
net_type = rule.get(f"{prefix}_network_type", "")
|
|
58
|
-
if net_type == "ADDRv4":
|
|
59
|
-
addr = rule.get(f"{prefix}_address", "")
|
|
60
|
-
return addr if addr else "any"
|
|
61
|
-
|
|
62
|
-
# Check for firewall group
|
|
63
|
-
group = rule.get(f"{prefix}_firewallgroup_ids", [])
|
|
64
|
-
if group:
|
|
65
|
-
return f"group:{len(group)}"
|
|
66
|
-
|
|
67
|
-
# Check for specific network
|
|
68
|
-
network = rule.get(f"{prefix}_network", "")
|
|
69
|
-
if network:
|
|
70
|
-
return network
|
|
71
|
-
|
|
72
|
-
return "any"
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
def format_port(rule: dict[str, Any], prefix: str) -> str:
|
|
76
|
-
"""Format port information."""
|
|
77
|
-
port = rule.get(f"{prefix}_port", "")
|
|
78
|
-
if port:
|
|
79
|
-
return str(port)
|
|
80
|
-
return "*"
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def get_ruleset_order(ruleset: str) -> int:
|
|
84
|
-
"""Get sort order for rulesets."""
|
|
85
|
-
order = ["WAN_IN", "WAN_OUT", "WAN_LOCAL", "LAN_IN", "LAN_OUT", "LAN_LOCAL", "GUEST_IN", "GUEST_OUT", "GUEST_LOCAL"]
|
|
86
|
-
try:
|
|
87
|
-
return order.index(ruleset)
|
|
88
|
-
except ValueError:
|
|
89
|
-
return 100
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
@app.command("list")
|
|
93
|
-
def list_rules(
|
|
94
|
-
ruleset: Annotated[
|
|
95
|
-
str | None,
|
|
96
|
-
typer.Option("--ruleset", "-r", help="Filter by ruleset (e.g., WAN_IN, LAN_IN)"),
|
|
97
|
-
] = None,
|
|
98
|
-
output: Annotated[
|
|
99
|
-
OutputFormat,
|
|
100
|
-
typer.Option("--output", "-o", help="Output format"),
|
|
101
|
-
] = OutputFormat.TABLE,
|
|
102
|
-
verbose: Annotated[
|
|
103
|
-
bool,
|
|
104
|
-
typer.Option("--verbose", "-v", help="Show additional details"),
|
|
105
|
-
] = False,
|
|
106
|
-
) -> None:
|
|
107
|
-
"""List firewall rules."""
|
|
108
|
-
from ui_cli.commands.local.utils import run_with_spinner
|
|
109
|
-
|
|
110
|
-
async def _list():
|
|
111
|
-
client = UniFiLocalClient()
|
|
112
|
-
return await client.get_firewall_rules()
|
|
113
|
-
|
|
114
|
-
try:
|
|
115
|
-
rules = run_with_spinner(_list(), "Fetching firewall rules...")
|
|
116
|
-
except LocalAPIError as e:
|
|
117
|
-
print_error(str(e))
|
|
118
|
-
raise typer.Exit(1)
|
|
119
|
-
|
|
120
|
-
if not rules:
|
|
121
|
-
console.print("[dim]No firewall rules found[/dim]")
|
|
122
|
-
return
|
|
123
|
-
|
|
124
|
-
# Filter by ruleset if specified
|
|
125
|
-
if ruleset:
|
|
126
|
-
ruleset_upper = ruleset.upper()
|
|
127
|
-
rules = [r for r in rules if r.get("ruleset", "").upper() == ruleset_upper]
|
|
128
|
-
if not rules:
|
|
129
|
-
console.print(f"[dim]No rules found for ruleset '{ruleset}'[/dim]")
|
|
130
|
-
return
|
|
131
|
-
|
|
132
|
-
# Sort by ruleset then by rule index
|
|
133
|
-
rules.sort(key=lambda r: (get_ruleset_order(r.get("ruleset", "")), r.get("rule_index", 0)))
|
|
134
|
-
|
|
135
|
-
if output == OutputFormat.JSON:
|
|
136
|
-
output_json(rules)
|
|
137
|
-
elif output == OutputFormat.CSV:
|
|
138
|
-
columns = [
|
|
139
|
-
("name", "Name"),
|
|
140
|
-
("ruleset", "Ruleset"),
|
|
141
|
-
("action", "Action"),
|
|
142
|
-
("protocol", "Protocol"),
|
|
143
|
-
("src_address", "Source"),
|
|
144
|
-
("dst_address", "Destination"),
|
|
145
|
-
("enabled", "Enabled"),
|
|
146
|
-
]
|
|
147
|
-
csv_data = []
|
|
148
|
-
for r in rules:
|
|
149
|
-
csv_data.append({
|
|
150
|
-
"name": r.get("name", ""),
|
|
151
|
-
"ruleset": r.get("ruleset", ""),
|
|
152
|
-
"action": r.get("action", ""),
|
|
153
|
-
"protocol": format_protocol(r),
|
|
154
|
-
"src_address": format_address(r, "src"),
|
|
155
|
-
"dst_address": format_address(r, "dst"),
|
|
156
|
-
"enabled": "Yes" if r.get("enabled", True) else "No",
|
|
157
|
-
})
|
|
158
|
-
output_csv(csv_data, columns)
|
|
159
|
-
else:
|
|
160
|
-
from rich.table import Table
|
|
161
|
-
|
|
162
|
-
table = Table(title="Firewall Rules", show_header=True, header_style="bold cyan")
|
|
163
|
-
table.add_column("Name")
|
|
164
|
-
table.add_column("Ruleset")
|
|
165
|
-
table.add_column("Action")
|
|
166
|
-
table.add_column("Protocol")
|
|
167
|
-
table.add_column("Source")
|
|
168
|
-
table.add_column("Destination")
|
|
169
|
-
if verbose:
|
|
170
|
-
table.add_column("Src Port")
|
|
171
|
-
table.add_column("Dst Port")
|
|
172
|
-
table.add_column("Enabled")
|
|
173
|
-
|
|
174
|
-
for r in rules:
|
|
175
|
-
name = r.get("name", "(unnamed)")
|
|
176
|
-
ruleset_name = RULESET_NAMES.get(r.get("ruleset", ""), r.get("ruleset", ""))
|
|
177
|
-
action, action_style = format_action(r.get("action", ""))
|
|
178
|
-
protocol = format_protocol(r)
|
|
179
|
-
src = format_address(r, "src")
|
|
180
|
-
dst = format_address(r, "dst")
|
|
181
|
-
enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
|
|
182
|
-
|
|
183
|
-
if verbose:
|
|
184
|
-
src_port = format_port(r, "src")
|
|
185
|
-
dst_port = format_port(r, "dst")
|
|
186
|
-
table.add_row(
|
|
187
|
-
name,
|
|
188
|
-
ruleset_name,
|
|
189
|
-
f"[{action_style}]{action}[/{action_style}]",
|
|
190
|
-
protocol,
|
|
191
|
-
src,
|
|
192
|
-
dst,
|
|
193
|
-
src_port,
|
|
194
|
-
dst_port,
|
|
195
|
-
enabled,
|
|
196
|
-
)
|
|
197
|
-
else:
|
|
198
|
-
table.add_row(
|
|
199
|
-
name,
|
|
200
|
-
ruleset_name,
|
|
201
|
-
f"[{action_style}]{action}[/{action_style}]",
|
|
202
|
-
protocol,
|
|
203
|
-
src,
|
|
204
|
-
dst,
|
|
205
|
-
enabled,
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
console.print(table)
|
|
209
|
-
console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
@app.command("groups")
|
|
213
|
-
def list_groups(
|
|
214
|
-
output: Annotated[
|
|
215
|
-
OutputFormat,
|
|
216
|
-
typer.Option("--output", "-o", help="Output format"),
|
|
217
|
-
] = OutputFormat.TABLE,
|
|
218
|
-
) -> None:
|
|
219
|
-
"""List firewall groups (address and port groups)."""
|
|
220
|
-
from ui_cli.commands.local.utils import run_with_spinner
|
|
221
|
-
|
|
222
|
-
async def _list():
|
|
223
|
-
client = UniFiLocalClient()
|
|
224
|
-
return await client.get_firewall_groups()
|
|
225
|
-
|
|
226
|
-
try:
|
|
227
|
-
groups = run_with_spinner(_list(), "Fetching firewall groups...")
|
|
228
|
-
except LocalAPIError as e:
|
|
229
|
-
print_error(str(e))
|
|
230
|
-
raise typer.Exit(1)
|
|
231
|
-
|
|
232
|
-
if not groups:
|
|
233
|
-
console.print("[dim]No firewall groups found[/dim]")
|
|
234
|
-
return
|
|
235
|
-
|
|
236
|
-
# Sort by type then name
|
|
237
|
-
groups.sort(key=lambda g: (g.get("group_type", ""), g.get("name", "")))
|
|
238
|
-
|
|
239
|
-
if output == OutputFormat.JSON:
|
|
240
|
-
output_json(groups)
|
|
241
|
-
elif output == OutputFormat.CSV:
|
|
242
|
-
columns = [
|
|
243
|
-
("_id", "ID"),
|
|
244
|
-
("name", "Name"),
|
|
245
|
-
("group_type", "Type"),
|
|
246
|
-
("members", "Members"),
|
|
247
|
-
]
|
|
248
|
-
csv_data = []
|
|
249
|
-
for g in groups:
|
|
250
|
-
members = g.get("group_members", [])
|
|
251
|
-
csv_data.append({
|
|
252
|
-
"_id": g.get("_id", ""),
|
|
253
|
-
"name": g.get("name", ""),
|
|
254
|
-
"group_type": g.get("group_type", ""),
|
|
255
|
-
"members": ", ".join(members) if members else "",
|
|
256
|
-
})
|
|
257
|
-
output_csv(csv_data, columns)
|
|
258
|
-
else:
|
|
259
|
-
from rich.table import Table
|
|
260
|
-
|
|
261
|
-
table = Table(title="Firewall Groups", show_header=True, header_style="bold cyan")
|
|
262
|
-
table.add_column("ID", style="dim")
|
|
263
|
-
table.add_column("Name")
|
|
264
|
-
table.add_column("Type")
|
|
265
|
-
table.add_column("Members")
|
|
266
|
-
|
|
267
|
-
for g in groups:
|
|
268
|
-
group_id = g.get("_id", "")
|
|
269
|
-
name = g.get("name", "")
|
|
270
|
-
group_type = g.get("group_type", "")
|
|
271
|
-
|
|
272
|
-
# Format type for display
|
|
273
|
-
type_display = group_type.replace("-", " ").title()
|
|
274
|
-
|
|
275
|
-
# Format members
|
|
276
|
-
members = g.get("group_members", [])
|
|
277
|
-
if len(members) <= 3:
|
|
278
|
-
members_str = ", ".join(members) if members else "[dim]-[/dim]"
|
|
279
|
-
else:
|
|
280
|
-
members_str = f"{', '.join(members[:3])}... (+{len(members) - 3})"
|
|
281
|
-
|
|
282
|
-
table.add_row(group_id, name, type_display, members_str)
|
|
283
|
-
|
|
284
|
-
console.print(table)
|
|
285
|
-
console.print(f"\n[dim]{len(groups)} group(s)[/dim]")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|