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.
Files changed (43) hide show
  1. {ui_cli-2.0.0 → ui_cli-2.1.0}/CHANGELOG.md +8 -0
  2. {ui_cli-2.0.0 → ui_cli-2.1.0}/PKG-INFO +20 -1
  3. {ui_cli-2.0.0 → ui_cli-2.1.0}/README.md +19 -0
  4. ui_cli-2.1.0/VERSION +1 -0
  5. ui_cli-2.1.0/src/ui_cli/commands/local/firewall.py +673 -0
  6. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/local_client.py +41 -5
  7. ui_cli-2.0.0/VERSION +0 -1
  8. ui_cli-2.0.0/src/ui_cli/commands/local/firewall.py +0 -285
  9. {ui_cli-2.0.0 → ui_cli-2.1.0}/.gitignore +0 -0
  10. {ui_cli-2.0.0 → ui_cli-2.1.0}/LICENSE +0 -0
  11. {ui_cli-2.0.0 → ui_cli-2.1.0}/pyproject.toml +0 -0
  12. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/__init__.py +0 -0
  13. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/client.py +0 -0
  14. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/__init__.py +0 -0
  15. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/devices.py +0 -0
  16. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/groups.py +0 -0
  17. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/hosts.py +0 -0
  18. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/isp.py +0 -0
  19. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/__init__.py +0 -0
  20. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/apgroups.py +0 -0
  21. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/clients.py +0 -0
  22. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/config.py +0 -0
  23. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/devices.py +0 -0
  24. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/dpi.py +0 -0
  25. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/events.py +0 -0
  26. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/health.py +0 -0
  27. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/networks.py +0 -0
  28. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/portfwd.py +0 -0
  29. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/stats.py +0 -0
  30. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/utils.py +0 -0
  31. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/vouchers.py +0 -0
  32. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/wan.py +0 -0
  33. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/local/wlans.py +0 -0
  34. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/sdwan.py +0 -0
  35. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/sites.py +0 -0
  36. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/speedtest.py +0 -0
  37. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/status.py +0 -0
  38. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/commands/version.py +0 -0
  39. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/config.py +0 -0
  40. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/groups.py +0 -0
  41. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/main.py +0 -0
  42. {ui_cli-2.0.0 → ui_cli-2.1.0}/src/ui_cli/models.py +0 -0
  43. {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.0.0
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 UNIFI_CONTROLLER_USERNAME and UNIFI_CONTROLLER_PASSWORD in .env file."
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 {response.status_code}, "
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
- f"Use UNIFI_CONTROLLER_USERNAME/UNIFI_CONTROLLER_PASSWORD for this controller type."
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": ["time", "rx_bytes", "tx_bytes", "num_sta", "wan-rx_bytes", "wan-tx_bytes"],
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": ["time", "rx_bytes", "tx_bytes", "num_sta", "wan-rx_bytes", "wan-tx_bytes"],
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