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.
- ui_cli/__init__.py +31 -0
- ui_cli/client.py +269 -0
- ui_cli/commands/__init__.py +1 -0
- ui_cli/commands/devices.py +187 -0
- ui_cli/commands/groups.py +503 -0
- ui_cli/commands/hosts.py +114 -0
- ui_cli/commands/isp.py +100 -0
- ui_cli/commands/local/__init__.py +63 -0
- ui_cli/commands/local/apgroups.py +445 -0
- ui_cli/commands/local/clients.py +1537 -0
- ui_cli/commands/local/config.py +758 -0
- ui_cli/commands/local/devices.py +570 -0
- ui_cli/commands/local/dpi.py +369 -0
- ui_cli/commands/local/events.py +289 -0
- ui_cli/commands/local/firewall.py +285 -0
- ui_cli/commands/local/health.py +195 -0
- ui_cli/commands/local/networks.py +426 -0
- ui_cli/commands/local/portfwd.py +153 -0
- ui_cli/commands/local/stats.py +234 -0
- ui_cli/commands/local/utils.py +85 -0
- ui_cli/commands/local/vouchers.py +410 -0
- ui_cli/commands/local/wan.py +302 -0
- ui_cli/commands/local/wlans.py +257 -0
- ui_cli/commands/mcp.py +416 -0
- ui_cli/commands/sdwan.py +168 -0
- ui_cli/commands/sites.py +65 -0
- ui_cli/commands/speedtest.py +192 -0
- ui_cli/commands/status.py +410 -0
- ui_cli/commands/version.py +13 -0
- ui_cli/config.py +106 -0
- ui_cli/groups.py +567 -0
- ui_cli/local_client.py +897 -0
- ui_cli/main.py +61 -0
- ui_cli/models.py +188 -0
- ui_cli/output.py +251 -0
- ui_cli-1.2.1.dist-info/METADATA +1315 -0
- ui_cli-1.2.1.dist-info/RECORD +46 -0
- ui_cli-1.2.1.dist-info/WHEEL +4 -0
- ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
- ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
- ui_mcp/ARCHITECTURE.md +243 -0
- ui_mcp/README.md +235 -0
- ui_mcp/__init__.py +7 -0
- ui_mcp/__main__.py +10 -0
- ui_mcp/cli_runner.py +112 -0
- 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()
|