ui-cli 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. ui_mcp/server.py +468 -0
@@ -0,0 +1,758 @@
1
+ """Running configuration commands for local controller."""
2
+
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+ from enum import Enum
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from ui_cli.local_client import (
11
+ LocalAPIError,
12
+ LocalAuthenticationError,
13
+ LocalConnectionError,
14
+ UniFiLocalClient,
15
+ )
16
+ from ui_cli.output import OutputFormat, console, output_json
17
+
18
+ app = typer.Typer(help="View running configuration")
19
+
20
+
21
+ class ConfigSection(str, Enum):
22
+ """Configuration sections."""
23
+ ALL = "all"
24
+ NETWORKS = "networks"
25
+ WIRELESS = "wireless"
26
+ FIREWALL = "firewall"
27
+ DEVICES = "devices"
28
+ PORTFWD = "portfwd"
29
+ DHCP = "dhcp"
30
+ ROUTING = "routing"
31
+
32
+
33
+ def handle_error(e: Exception) -> None:
34
+ """Handle and display API errors."""
35
+ if isinstance(e, LocalAuthenticationError):
36
+ console.print(f"[red]Authentication error:[/red] {e.message}")
37
+ elif isinstance(e, LocalConnectionError):
38
+ console.print(f"[red]Connection error:[/red] {e.message}")
39
+ elif isinstance(e, LocalAPIError):
40
+ console.print(f"[red]API error:[/red] {e.message}")
41
+ else:
42
+ console.print(f"[red]Error:[/red] {e}")
43
+ raise typer.Exit(1)
44
+
45
+
46
+ def format_uptime(seconds: int) -> str:
47
+ """Format uptime seconds to human-readable string."""
48
+ if seconds < 60:
49
+ return f"{seconds}s"
50
+ elif seconds < 3600:
51
+ minutes = seconds // 60
52
+ return f"{minutes}m"
53
+ elif seconds < 86400:
54
+ hours = seconds // 3600
55
+ minutes = (seconds % 3600) // 60
56
+ return f"{hours}h {minutes}m"
57
+ else:
58
+ days = seconds // 86400
59
+ hours = (seconds % 86400) // 3600
60
+ return f"{days}d {hours}h"
61
+
62
+
63
+ # ============================================================
64
+ # Formatting Functions for Each Section
65
+ # ============================================================
66
+
67
+ def format_networks_section(networks: list[dict], verbose: bool = False) -> None:
68
+ """Format and print networks section."""
69
+ if not networks:
70
+ console.print(" [dim](no networks configured)[/dim]")
71
+ return
72
+
73
+ for net in sorted(networks, key=lambda x: x.get("vlan_enabled", False) and x.get("vlan", 0) or 0):
74
+ name = net.get("name", "Unnamed")
75
+ purpose = net.get("purpose", "unknown")
76
+
77
+ # VLAN info
78
+ vlan = net.get("vlan", "")
79
+ vlan_str = f" (VLAN {vlan})" if net.get("vlan_enabled") and vlan else ""
80
+
81
+ console.print(f" [bold]{name}[/bold]{vlan_str}")
82
+ console.print(f" [dim]Purpose:[/dim] {purpose}")
83
+
84
+ # Subnet info
85
+ subnet = net.get("ip_subnet", "")
86
+ if subnet:
87
+ console.print(f" [dim]Subnet:[/dim] {subnet}")
88
+
89
+ # Gateway
90
+ gateway = net.get("ipv4_gateway", "") or net.get("gateway", "")
91
+ if not gateway and subnet:
92
+ # Derive gateway from subnet (usually .1)
93
+ parts = subnet.split("/")
94
+ if parts:
95
+ ip_parts = parts[0].split(".")
96
+ if len(ip_parts) == 4:
97
+ gateway = f"{ip_parts[0]}.{ip_parts[1]}.{ip_parts[2]}.1"
98
+ if gateway:
99
+ console.print(f" [dim]Gateway:[/dim] {gateway}")
100
+
101
+ # DHCP
102
+ dhcp_enabled = net.get("dhcpd_enabled", False)
103
+ if dhcp_enabled:
104
+ dhcp_start = net.get("dhcpd_start", "")
105
+ dhcp_stop = net.get("dhcpd_stop", "")
106
+ if dhcp_start and dhcp_stop:
107
+ console.print(f" [dim]DHCP:[/dim] Enabled ({dhcp_start} - {dhcp_stop})")
108
+ else:
109
+ console.print(f" [dim]DHCP:[/dim] Enabled")
110
+ else:
111
+ console.print(f" [dim]DHCP:[/dim] Disabled")
112
+
113
+ # DNS
114
+ dns1 = net.get("dhcpd_dns_1", "")
115
+ dns2 = net.get("dhcpd_dns_2", "")
116
+ if dns1 or dns2:
117
+ dns_list = [d for d in [dns1, dns2] if d]
118
+ console.print(f" [dim]DNS:[/dim] {', '.join(dns_list)}")
119
+
120
+ # Domain
121
+ domain = net.get("domain_name", "")
122
+ if domain:
123
+ console.print(f" [dim]Domain:[/dim] {domain}")
124
+
125
+ # Isolation
126
+ if net.get("network_isolation", False):
127
+ console.print(f" [dim]Isolation:[/dim] [yellow]Yes[/yellow]")
128
+
129
+ # Internet access
130
+ if net.get("internet_access_enabled") is False:
131
+ console.print(f" [dim]Internet:[/dim] [red]Blocked[/red]")
132
+
133
+ if verbose:
134
+ net_id = net.get("_id", "")
135
+ if net_id:
136
+ console.print(f" [dim]ID:[/dim] {net_id}")
137
+
138
+ console.print()
139
+
140
+
141
+ def format_wireless_section(wlans: list[dict], networks: list[dict], verbose: bool = False) -> None:
142
+ """Format and print wireless section."""
143
+ if not wlans:
144
+ console.print(" [dim](no wireless networks configured)[/dim]")
145
+ return
146
+
147
+ # Build network ID to name mapping
148
+ net_map = {n.get("_id"): n.get("name", "Unknown") for n in networks}
149
+
150
+ for wlan in sorted(wlans, key=lambda x: x.get("name", "")):
151
+ name = wlan.get("name", "Unnamed")
152
+ enabled = wlan.get("enabled", True)
153
+
154
+ status = "" if enabled else " [red](disabled)[/red]"
155
+ console.print(f" [bold]{name}[/bold]{status}")
156
+
157
+ # Network mapping
158
+ network_id = wlan.get("networkconf_id", "")
159
+ network_name = net_map.get(network_id, "Default")
160
+ console.print(f" [dim]Network:[/dim] {network_name}")
161
+
162
+ # Security
163
+ security = wlan.get("security", "open")
164
+ wpa_mode = wlan.get("wpa_mode", "")
165
+ wpa3 = wlan.get("wpa3_support", False)
166
+
167
+ if security == "wpapsk":
168
+ if wpa3:
169
+ sec_str = "WPA2/WPA3 Personal"
170
+ elif wpa_mode == "wpa2":
171
+ sec_str = "WPA2 Personal"
172
+ else:
173
+ sec_str = "WPA Personal"
174
+ elif security == "wpaeap":
175
+ sec_str = "WPA Enterprise"
176
+ elif security == "open":
177
+ sec_str = "Open"
178
+ else:
179
+ sec_str = security
180
+ console.print(f" [dim]Security:[/dim] {sec_str}")
181
+
182
+ # Bands
183
+ wlan_band = wlan.get("wlan_band", "both")
184
+ if wlan_band == "2g":
185
+ band_str = "2.4 GHz only"
186
+ elif wlan_band == "5g":
187
+ band_str = "5 GHz only"
188
+ else:
189
+ band_str = "2.4 GHz + 5 GHz"
190
+ console.print(f" [dim]Band:[/dim] {band_str}")
191
+
192
+ # Hidden SSID
193
+ if wlan.get("hide_ssid", False):
194
+ console.print(f" [dim]Hidden:[/dim] Yes")
195
+
196
+ # Guest network
197
+ if wlan.get("is_guest", False):
198
+ console.print(f" [dim]Guest:[/dim] Yes")
199
+
200
+ # Client isolation
201
+ if wlan.get("ap_group_isolation", False) or wlan.get("l2_isolation", False):
202
+ console.print(f" [dim]Isolation:[/dim] Yes")
203
+
204
+ # Fast roaming
205
+ if wlan.get("fast_roaming_enabled", False):
206
+ console.print(f" [dim]Fast Roaming:[/dim] Yes")
207
+
208
+ # PMF
209
+ pmf = wlan.get("pmf_mode", "")
210
+ if pmf:
211
+ console.print(f" [dim]PMF:[/dim] {pmf}")
212
+
213
+ if verbose:
214
+ wlan_id = wlan.get("_id", "")
215
+ if wlan_id:
216
+ console.print(f" [dim]ID:[/dim] {wlan_id}")
217
+
218
+ console.print()
219
+
220
+
221
+ def format_firewall_section(rules: list[dict], groups: list[dict], verbose: bool = False) -> None:
222
+ """Format and print firewall section."""
223
+ # Build group ID to name mapping
224
+ group_map = {g.get("_id"): g.get("name", "Unknown") for g in groups}
225
+
226
+ # Group rules by ruleset
227
+ rulesets: dict[str, list] = {}
228
+ for rule in rules:
229
+ ruleset = rule.get("ruleset", "unknown")
230
+ if ruleset not in rulesets:
231
+ rulesets[ruleset] = []
232
+ rulesets[ruleset].append(rule)
233
+
234
+ # Sort rulesets in logical order
235
+ ruleset_order = ["WAN_IN", "WAN_OUT", "WAN_LOCAL", "LAN_IN", "LAN_OUT", "LAN_LOCAL", "GUEST_IN", "GUEST_OUT"]
236
+ sorted_rulesets = sorted(rulesets.keys(), key=lambda x: ruleset_order.index(x) if x in ruleset_order else 99)
237
+
238
+ if not rules:
239
+ console.print(" [dim](no custom firewall rules)[/dim]")
240
+ else:
241
+ for ruleset in sorted_rulesets:
242
+ ruleset_rules = sorted(rulesets[ruleset], key=lambda x: x.get("rule_index", 0))
243
+ console.print(f" [bold]{ruleset}[/bold] ({len(ruleset_rules)} rules)")
244
+
245
+ for rule in ruleset_rules:
246
+ idx = rule.get("rule_index", "")
247
+ name = rule.get("name", "Unnamed")
248
+ action = rule.get("action", "").upper()
249
+ enabled = rule.get("enabled", True)
250
+
251
+ # Color code action
252
+ if action == "DROP" or action == "REJECT":
253
+ action_str = f"[red]{action}[/red]"
254
+ elif action == "ACCEPT":
255
+ action_str = f"[green]{action}[/green]"
256
+ else:
257
+ action_str = action
258
+
259
+ status = "" if enabled else " [dim](disabled)[/dim]"
260
+
261
+ # Source/destination
262
+ src = rule.get("src_firewallgroup_ids", [])
263
+ dst = rule.get("dst_firewallgroup_ids", [])
264
+ src_str = ", ".join([group_map.get(s, s) for s in src]) if src else "Any"
265
+ dst_str = ", ".join([group_map.get(d, d) for d in dst]) if dst else "Any"
266
+
267
+ # Protocol/port
268
+ protocol = rule.get("protocol", "all")
269
+ dst_port = rule.get("dst_port", "")
270
+ proto_str = protocol.upper()
271
+ if dst_port:
272
+ proto_str += f" {dst_port}"
273
+
274
+ console.print(f" {idx:4} {name[:25]:<25} {action_str:<8} {src_str[:12]:<12} → {dst_str[:12]:<12} {proto_str}{status}")
275
+
276
+ console.print()
277
+
278
+ # Firewall groups
279
+ if groups:
280
+ console.print(f" [bold]Firewall Groups[/bold] ({len(groups)} groups)")
281
+ for group in sorted(groups, key=lambda x: x.get("name", "")):
282
+ name = group.get("name", "Unnamed")
283
+ group_type = group.get("group_type", "unknown")
284
+ members = group.get("group_members", [])
285
+
286
+ type_str = {"address-group": "Address", "port-group": "Port", "network-group": "Network"}.get(group_type, group_type)
287
+ members_str = ", ".join(members[:5])
288
+ if len(members) > 5:
289
+ members_str += f" (+{len(members) - 5} more)"
290
+
291
+ console.print(f" {name:<20} [{type_str}] {members_str}")
292
+ console.print()
293
+
294
+
295
+ def format_port_forwards_section(forwards: list[dict], verbose: bool = False) -> None:
296
+ """Format and print port forwarding section."""
297
+ if not forwards:
298
+ console.print(" [dim](no port forwards configured)[/dim]")
299
+ return
300
+
301
+ console.print(f" {'Name':<20} {'Protocol':<10} {'WAN Port':<12} {'LAN IP':<16} {'LAN Port':<10} {'Enabled'}")
302
+ console.print(f" {'-' * 20} {'-' * 10} {'-' * 12} {'-' * 16} {'-' * 10} {'-' * 7}")
303
+
304
+ for fwd in sorted(forwards, key=lambda x: x.get("name", "")):
305
+ name = fwd.get("name", "Unnamed")[:20]
306
+ proto = fwd.get("proto", "tcp_udp").upper()
307
+ dst_port = fwd.get("dst_port", "")
308
+ fwd_ip = fwd.get("fwd", "")
309
+ fwd_port = fwd.get("fwd_port", dst_port)
310
+ enabled = fwd.get("enabled", True)
311
+
312
+ enabled_str = "[green]Yes[/green]" if enabled else "[red]No[/red]"
313
+
314
+ console.print(f" {name:<20} {proto:<10} {dst_port:<12} {fwd_ip:<16} {fwd_port:<10} {enabled_str}")
315
+
316
+ console.print()
317
+
318
+
319
+ def format_devices_section(devices: list[dict], verbose: bool = False) -> None:
320
+ """Format and print devices section."""
321
+ if not devices:
322
+ console.print(" [dim](no devices)[/dim]")
323
+ return
324
+
325
+ # Sort by type then name
326
+ type_order = {"ugw": 0, "udm": 0, "usw": 1, "uap": 2, "uph": 3}
327
+ sorted_devices = sorted(devices, key=lambda x: (type_order.get(x.get("type", ""), 99), x.get("name", "")))
328
+
329
+ for dev in sorted_devices:
330
+ name = dev.get("name", "Unnamed")
331
+ model = dev.get("model", "Unknown")
332
+ dev_type = dev.get("type", "")
333
+ ip = dev.get("ip", "")
334
+ mac = dev.get("mac", "").upper()
335
+ version = dev.get("version", "")
336
+ state = dev.get("state", 0)
337
+ uptime = dev.get("uptime", 0)
338
+
339
+ # Device type label
340
+ type_labels = {"ugw": "Gateway", "udm": "Gateway", "usw": "Switch", "uap": "AP", "uph": "Phone"}
341
+ type_label = type_labels.get(dev_type, dev_type.upper())
342
+
343
+ # State
344
+ state_str = ""
345
+ if state == 1:
346
+ state_str = "[green]online[/green]"
347
+ elif state == 0:
348
+ state_str = "[red]offline[/red]"
349
+ else:
350
+ state_str = f"[yellow]state:{state}[/yellow]"
351
+
352
+ console.print(f" [bold]{name}[/bold] ({model}) {state_str}")
353
+ console.print(f" [dim]Type:[/dim] {type_label}")
354
+ console.print(f" [dim]IP:[/dim] {ip}")
355
+ console.print(f" [dim]MAC:[/dim] {mac}")
356
+ if version:
357
+ console.print(f" [dim]Firmware:[/dim] {version}")
358
+ if uptime:
359
+ console.print(f" [dim]Uptime:[/dim] {format_uptime(uptime)}")
360
+
361
+ # AP-specific: radio info
362
+ if dev_type == "uap":
363
+ radio_table = dev.get("radio_table", [])
364
+ for radio in radio_table:
365
+ radio_type = radio.get("radio", "")
366
+ channel = radio.get("channel", "")
367
+ ht = radio.get("ht", "")
368
+ tx_power = radio.get("tx_power", "")
369
+
370
+ if radio_type == "ng":
371
+ band = "2.4G"
372
+ elif radio_type == "na":
373
+ band = "5G"
374
+ else:
375
+ band = radio_type
376
+
377
+ ht_str = f" ({ht})" if ht else ""
378
+ power_str = f", {tx_power}dBm" if tx_power else ""
379
+ console.print(f" [dim]Channel {band}:[/dim] {channel}{ht_str}{power_str}")
380
+
381
+ # Switch-specific: port info
382
+ if dev_type == "usw" and verbose:
383
+ port_table = dev.get("port_table", [])
384
+ if port_table:
385
+ up_ports = [p for p in port_table if p.get("up", False)]
386
+ console.print(f" [dim]Ports:[/dim] {len(up_ports)}/{len(port_table)} up")
387
+
388
+ if verbose:
389
+ dev_id = dev.get("_id", "")
390
+ if dev_id:
391
+ console.print(f" [dim]ID:[/dim] {dev_id}")
392
+
393
+ console.print()
394
+
395
+
396
+ def format_dhcp_reservations_section(reservations: list[dict], networks: list[dict], verbose: bool = False) -> None:
397
+ """Format and print DHCP reservations section."""
398
+ if not reservations:
399
+ console.print(" [dim](no DHCP reservations)[/dim]")
400
+ return
401
+
402
+ # Build network ID to name mapping
403
+ net_map = {n.get("_id"): n.get("name", "Unknown") for n in networks}
404
+
405
+ console.print(f" {'Name':<20} {'MAC':<18} {'IP':<16} {'Network'}")
406
+ console.print(f" {'-' * 20} {'-' * 18} {'-' * 16} {'-' * 15}")
407
+
408
+ for res in sorted(reservations, key=lambda x: x.get("name", "") or x.get("hostname", "")):
409
+ name = (res.get("name") or res.get("hostname") or "Unknown")[:20]
410
+ mac = res.get("mac", "").upper()
411
+ ip = res.get("fixed_ip", "")
412
+ network_id = res.get("network_id", "")
413
+ network_name = net_map.get(network_id, "Default")
414
+
415
+ console.print(f" {name:<20} {mac:<18} {ip:<16} {network_name}")
416
+
417
+ console.print()
418
+
419
+
420
+ def format_routing_section(routes: list[dict], verbose: bool = False) -> None:
421
+ """Format and print static routing section."""
422
+ if not routes:
423
+ console.print(" [dim](no static routes configured)[/dim]")
424
+ return
425
+
426
+ console.print(f" {'Name':<20} {'Destination':<20} {'Gateway/Interface':<20} {'Enabled'}")
427
+ console.print(f" {'-' * 20} {'-' * 20} {'-' * 20} {'-' * 7}")
428
+
429
+ for route in sorted(routes, key=lambda x: x.get("name", "")):
430
+ name = route.get("name", "Unnamed")[:20]
431
+ dest = route.get("static_route_network", "")
432
+ gateway = route.get("static_route_nexthop", "") or route.get("static_route_interface", "")
433
+ enabled = route.get("enabled", True)
434
+
435
+ enabled_str = "[green]Yes[/green]" if enabled else "[red]No[/red]"
436
+
437
+ console.print(f" {name:<20} {dest:<20} {gateway:<20} {enabled_str}")
438
+
439
+ console.print()
440
+
441
+
442
+ def to_yaml(config: dict, hide_secrets: bool = True) -> str:
443
+ """Convert config to YAML format."""
444
+ lines = []
445
+ lines.append("# UniFi Running Configuration")
446
+ lines.append(f"# Exported: {datetime.now(timezone.utc).isoformat()}")
447
+ lines.append("")
448
+
449
+ def yaml_value(v, indent=0):
450
+ """Convert a value to YAML string."""
451
+ prefix = " " * indent
452
+ if v is None:
453
+ return "null"
454
+ elif isinstance(v, bool):
455
+ return "true" if v else "false"
456
+ elif isinstance(v, (int, float)):
457
+ return str(v)
458
+ elif isinstance(v, str):
459
+ # Hide passwords/secrets
460
+ if hide_secrets and any(s in v.lower() for s in ["password", "secret", "key", "x_passphrase"]):
461
+ return '"********"'
462
+ if "\n" in v or ":" in v or '"' in v:
463
+ return f'"{v}"'
464
+ return v if v else '""'
465
+ elif isinstance(v, list):
466
+ if not v:
467
+ return "[]"
468
+ if all(isinstance(i, (str, int, float, bool)) for i in v):
469
+ return "[" + ", ".join(yaml_value(i) for i in v) + "]"
470
+ return v # Complex list, handle separately
471
+ elif isinstance(v, dict):
472
+ return v # Handle separately
473
+ return str(v)
474
+
475
+ def write_dict(d, indent=0):
476
+ """Write a dict as YAML."""
477
+ prefix = " " * indent
478
+ for k, v in d.items():
479
+ # Skip internal fields
480
+ if k.startswith("_") and k != "_id":
481
+ continue
482
+ # Hide secret fields
483
+ if hide_secrets and any(s in k.lower() for s in ["password", "secret", "x_passphrase", "wpa_psk"]):
484
+ lines.append(f"{prefix}{k}: \"********\"")
485
+ continue
486
+
487
+ val = yaml_value(v, indent)
488
+ if isinstance(val, dict):
489
+ lines.append(f"{prefix}{k}:")
490
+ write_dict(val, indent + 1)
491
+ elif isinstance(val, list) and val and isinstance(val[0], dict):
492
+ lines.append(f"{prefix}{k}:")
493
+ for item in val:
494
+ lines.append(f"{prefix} -")
495
+ write_dict(item, indent + 2)
496
+ else:
497
+ lines.append(f"{prefix}{k}: {val}")
498
+
499
+ # Networks
500
+ if config.get("networks"):
501
+ lines.append("networks:")
502
+ for net in config["networks"]:
503
+ lines.append(f" - name: {net.get('name', 'Unknown')}")
504
+ for k, v in net.items():
505
+ if k == "name" or k.startswith("_"):
506
+ continue
507
+ val = yaml_value(v)
508
+ if not isinstance(val, (dict, list)) or (isinstance(val, list) and isinstance(val, str)):
509
+ lines.append(f" {k}: {val}")
510
+ lines.append("")
511
+
512
+ # Wireless
513
+ if config.get("wireless"):
514
+ lines.append("wireless:")
515
+ for wlan in config["wireless"]:
516
+ lines.append(f" - name: {wlan.get('name', 'Unknown')}")
517
+ for k, v in wlan.items():
518
+ if k == "name" or k.startswith("_"):
519
+ continue
520
+ if hide_secrets and any(s in k.lower() for s in ["password", "x_passphrase", "wpa_psk"]):
521
+ lines.append(f" {k}: \"********\"")
522
+ continue
523
+ val = yaml_value(v)
524
+ if not isinstance(val, (dict, list)) or (isinstance(val, list) and isinstance(val, str)):
525
+ lines.append(f" {k}: {val}")
526
+ lines.append("")
527
+
528
+ # Firewall rules
529
+ if config.get("firewall_rules"):
530
+ lines.append("firewall_rules:")
531
+ for rule in config["firewall_rules"]:
532
+ lines.append(f" - name: {rule.get('name', 'Unknown')}")
533
+ lines.append(f" ruleset: {rule.get('ruleset', '')}")
534
+ lines.append(f" action: {rule.get('action', '')}")
535
+ lines.append(f" enabled: {yaml_value(rule.get('enabled', True))}")
536
+ lines.append("")
537
+
538
+ # Port forwards
539
+ if config.get("port_forwards"):
540
+ lines.append("port_forwards:")
541
+ for fwd in config["port_forwards"]:
542
+ lines.append(f" - name: {fwd.get('name', 'Unknown')}")
543
+ lines.append(f" dst_port: {fwd.get('dst_port', '')}")
544
+ lines.append(f" fwd: {fwd.get('fwd', '')}")
545
+ lines.append(f" fwd_port: {fwd.get('fwd_port', '')}")
546
+ lines.append(f" proto: {fwd.get('proto', 'tcp_udp')}")
547
+ lines.append(f" enabled: {yaml_value(fwd.get('enabled', True))}")
548
+ lines.append("")
549
+
550
+ # DHCP reservations
551
+ if config.get("dhcp_reservations"):
552
+ lines.append("dhcp_reservations:")
553
+ for res in config["dhcp_reservations"]:
554
+ name = res.get("name") or res.get("hostname") or "Unknown"
555
+ lines.append(f" - name: {name}")
556
+ lines.append(f" mac: {res.get('mac', '')}")
557
+ lines.append(f" fixed_ip: {res.get('fixed_ip', '')}")
558
+ lines.append("")
559
+
560
+ # Devices
561
+ if config.get("devices"):
562
+ lines.append("devices:")
563
+ for dev in config["devices"]:
564
+ lines.append(f" - name: {dev.get('name', 'Unknown')}")
565
+ lines.append(f" model: {dev.get('model', '')}")
566
+ lines.append(f" mac: {dev.get('mac', '').upper()}")
567
+ lines.append(f" ip: {dev.get('ip', '')}")
568
+ lines.append(f" type: {dev.get('type', '')}")
569
+ lines.append("")
570
+
571
+ return "\n".join(lines)
572
+
573
+
574
+ # ============================================================
575
+ # Commands
576
+ # ============================================================
577
+
578
+ @app.command("show")
579
+ def show_config(
580
+ section: Annotated[
581
+ ConfigSection,
582
+ typer.Option("--section", "-s", help="Section to show"),
583
+ ] = ConfigSection.ALL,
584
+ output: Annotated[
585
+ OutputFormat,
586
+ typer.Option("--output", "-o", help="Output format (table, json, yaml)"),
587
+ ] = OutputFormat.TABLE,
588
+ verbose: Annotated[
589
+ bool,
590
+ typer.Option("--verbose", "-v", help="Show additional details (IDs, etc)"),
591
+ ] = False,
592
+ hide_secrets: Annotated[
593
+ bool,
594
+ typer.Option("--hide-secrets/--show-secrets", help="Hide passwords and keys"),
595
+ ] = True,
596
+ ) -> None:
597
+ """Show running configuration.
598
+
599
+ Displays the current active configuration of your UniFi network
600
+ including networks, wireless, firewall, devices, and more.
601
+
602
+ Examples:
603
+ ./ui lo config show # Full config
604
+ ./ui lo config show -s networks # Just networks
605
+ ./ui lo config show -s firewall # Just firewall
606
+ ./ui lo config show -o yaml # Export as YAML
607
+ ./ui lo config show -o json # Export as JSON
608
+ ./ui lo config show --show-secrets # Include passwords
609
+ """
610
+ async def _fetch_config():
611
+ client = UniFiLocalClient()
612
+ # Fetch only what we need based on section
613
+ if section == ConfigSection.ALL:
614
+ return await client.get_running_config()
615
+ elif section == ConfigSection.NETWORKS:
616
+ return {"networks": await client.get_networks()}
617
+ elif section == ConfigSection.WIRELESS:
618
+ return {
619
+ "wireless": await client.get_wlans(),
620
+ "networks": await client.get_networks(), # For network name mapping
621
+ }
622
+ elif section == ConfigSection.FIREWALL:
623
+ return {
624
+ "firewall_rules": await client.get_firewall_rules(),
625
+ "firewall_groups": await client.get_firewall_groups(),
626
+ }
627
+ elif section == ConfigSection.DEVICES:
628
+ return {"devices": await client.get_devices()}
629
+ elif section == ConfigSection.PORTFWD:
630
+ return {"port_forwards": await client.get_port_forwards()}
631
+ elif section == ConfigSection.DHCP:
632
+ return {
633
+ "dhcp_reservations": await client.get_dhcp_reservations(),
634
+ "networks": await client.get_networks(),
635
+ }
636
+ elif section == ConfigSection.ROUTING:
637
+ return {"routing": await client.get_routing()}
638
+ return {}
639
+
640
+ try:
641
+ from ui_cli.commands.local.utils import run_with_spinner
642
+ config = run_with_spinner(_fetch_config(), "Fetching configuration...")
643
+ except Exception as e:
644
+ handle_error(e)
645
+ return
646
+
647
+ # Handle different output formats
648
+ if output == OutputFormat.JSON:
649
+ # For JSON, optionally hide secrets
650
+ if hide_secrets:
651
+ def redact_secrets(obj):
652
+ if isinstance(obj, dict):
653
+ return {
654
+ k: "********" if any(s in k.lower() for s in ["password", "secret", "x_passphrase", "wpa_psk"]) else redact_secrets(v)
655
+ for k, v in obj.items()
656
+ }
657
+ elif isinstance(obj, list):
658
+ return [redact_secrets(i) for i in obj]
659
+ return obj
660
+ config = redact_secrets(config)
661
+ output_json(config)
662
+ return
663
+
664
+ if output.value == "yaml" or str(output) == "yaml":
665
+ console.print(to_yaml(config, hide_secrets=hide_secrets))
666
+ return
667
+
668
+ # Table output
669
+ controller_url = client.controller_url
670
+ site = client.site
671
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
672
+
673
+ console.print()
674
+ console.print("[bold]UniFi Running Configuration[/bold]")
675
+ console.print("═" * 70)
676
+ console.print(f"Controller: {controller_url}")
677
+ console.print(f"Site: {site}")
678
+ console.print(f"Exported: {timestamp}")
679
+ console.print("═" * 70)
680
+ console.print()
681
+
682
+ # Networks section
683
+ if section in (ConfigSection.ALL, ConfigSection.NETWORKS):
684
+ networks = config.get("networks", [])
685
+ console.print("┌─ [bold]NETWORKS[/bold] " + "─" * 58 + "┐")
686
+ console.print()
687
+ format_networks_section(networks, verbose)
688
+ console.print("└" + "─" * 70 + "┘")
689
+ console.print()
690
+
691
+ # Wireless section
692
+ if section in (ConfigSection.ALL, ConfigSection.WIRELESS):
693
+ wlans = config.get("wireless", [])
694
+ networks = config.get("networks", [])
695
+ console.print("┌─ [bold]WIRELESS[/bold] " + "─" * 58 + "┐")
696
+ console.print()
697
+ format_wireless_section(wlans, networks, verbose)
698
+ console.print("└" + "─" * 70 + "┘")
699
+ console.print()
700
+
701
+ # Firewall section
702
+ if section in (ConfigSection.ALL, ConfigSection.FIREWALL):
703
+ rules = config.get("firewall_rules", [])
704
+ groups = config.get("firewall_groups", [])
705
+ console.print("┌─ [bold]FIREWALL[/bold] " + "─" * 58 + "┐")
706
+ console.print()
707
+ format_firewall_section(rules, groups, verbose)
708
+ console.print("└" + "─" * 70 + "┘")
709
+ console.print()
710
+
711
+ # Port forwarding section
712
+ if section in (ConfigSection.ALL, ConfigSection.PORTFWD):
713
+ forwards = config.get("port_forwards", [])
714
+ console.print("┌─ [bold]PORT FORWARDING[/bold] " + "─" * 51 + "┐")
715
+ console.print()
716
+ format_port_forwards_section(forwards, verbose)
717
+ console.print("└" + "─" * 70 + "┘")
718
+ console.print()
719
+
720
+ # DHCP reservations section
721
+ if section in (ConfigSection.ALL, ConfigSection.DHCP):
722
+ reservations = config.get("dhcp_reservations", [])
723
+ networks = config.get("networks", [])
724
+ console.print("┌─ [bold]DHCP RESERVATIONS[/bold] " + "─" * 49 + "┐")
725
+ console.print()
726
+ format_dhcp_reservations_section(reservations, networks, verbose)
727
+ console.print("└" + "─" * 70 + "┘")
728
+ console.print()
729
+
730
+ # Routing section
731
+ if section in (ConfigSection.ALL, ConfigSection.ROUTING):
732
+ routes = config.get("routing", [])
733
+ console.print("┌─ [bold]STATIC ROUTES[/bold] " + "─" * 53 + "┐")
734
+ console.print()
735
+ format_routing_section(routes, verbose)
736
+ console.print("└" + "─" * 70 + "┘")
737
+ console.print()
738
+
739
+ # Devices section
740
+ if section in (ConfigSection.ALL, ConfigSection.DEVICES):
741
+ devices = config.get("devices", [])
742
+ console.print("┌─ [bold]DEVICES[/bold] " + "─" * 59 + "┐")
743
+ console.print()
744
+ format_devices_section(devices, verbose)
745
+ console.print("└" + "─" * 70 + "┘")
746
+ console.print()
747
+
748
+ # Summary
749
+ if section == ConfigSection.ALL:
750
+ networks_count = len(config.get("networks", []))
751
+ wlans_count = len(config.get("wireless", []))
752
+ rules_count = len(config.get("firewall_rules", []))
753
+ forwards_count = len(config.get("port_forwards", []))
754
+ devices_count = len(config.get("devices", []))
755
+ dhcp_count = len(config.get("dhcp_reservations", []))
756
+
757
+ console.print(f"[dim]Summary: {networks_count} networks, {wlans_count} SSIDs, {rules_count} firewall rules, {forwards_count} port forwards, {devices_count} devices, {dhcp_count} DHCP reservations[/dim]")
758
+ console.print()