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,426 @@
|
|
|
1
|
+
"""Network configuration 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="networks", help="Network configuration", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_dhcp_range(network: dict[str, Any]) -> str:
|
|
21
|
+
"""Format DHCP range for display."""
|
|
22
|
+
if not network.get("dhcpd_enabled", False):
|
|
23
|
+
return "Disabled"
|
|
24
|
+
|
|
25
|
+
start = network.get("dhcpd_start", "")
|
|
26
|
+
stop = network.get("dhcpd_stop", "")
|
|
27
|
+
|
|
28
|
+
if start and stop:
|
|
29
|
+
# Extract last octet for compact display
|
|
30
|
+
start_last = start.split(".")[-1] if "." in start else start
|
|
31
|
+
stop_last = stop.split(".")[-1] if "." in stop else stop
|
|
32
|
+
return f".{start_last} - .{stop_last}"
|
|
33
|
+
|
|
34
|
+
return "Enabled"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_subnet(network: dict[str, Any]) -> str:
|
|
38
|
+
"""Format subnet for display."""
|
|
39
|
+
subnet = network.get("ip_subnet", "")
|
|
40
|
+
if subnet:
|
|
41
|
+
return subnet
|
|
42
|
+
return "-"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_network_purpose(network: dict[str, Any]) -> str:
|
|
46
|
+
"""Get network purpose/type."""
|
|
47
|
+
purpose = network.get("purpose", "")
|
|
48
|
+
if purpose:
|
|
49
|
+
return purpose
|
|
50
|
+
# Fallback to network type
|
|
51
|
+
return network.get("networkgroup", "LAN")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command("list")
|
|
55
|
+
def list_networks(
|
|
56
|
+
output: Annotated[
|
|
57
|
+
OutputFormat,
|
|
58
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
59
|
+
] = OutputFormat.TABLE,
|
|
60
|
+
verbose: Annotated[
|
|
61
|
+
bool,
|
|
62
|
+
typer.Option("--verbose", "-v", help="Show additional details"),
|
|
63
|
+
] = False,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""List all networks."""
|
|
66
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
67
|
+
|
|
68
|
+
async def _list():
|
|
69
|
+
client = UniFiLocalClient()
|
|
70
|
+
return await client.get_networks()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
networks = run_with_spinner(_list(), "Fetching networks...")
|
|
74
|
+
except LocalAPIError as e:
|
|
75
|
+
print_error(str(e))
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
if not networks:
|
|
79
|
+
console.print("[dim]No networks found[/dim]")
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
if output == OutputFormat.JSON:
|
|
83
|
+
output_json(networks)
|
|
84
|
+
elif output == OutputFormat.CSV:
|
|
85
|
+
columns = [
|
|
86
|
+
("_id", "ID"),
|
|
87
|
+
("name", "Name"),
|
|
88
|
+
("vlan", "VLAN"),
|
|
89
|
+
("ip_subnet", "Subnet"),
|
|
90
|
+
("purpose", "Purpose"),
|
|
91
|
+
("dhcpd_enabled", "DHCP"),
|
|
92
|
+
]
|
|
93
|
+
csv_data = []
|
|
94
|
+
for n in networks:
|
|
95
|
+
csv_data.append({
|
|
96
|
+
"_id": n.get("_id", ""),
|
|
97
|
+
"name": n.get("name", ""),
|
|
98
|
+
"vlan": n.get("vlan", "1"),
|
|
99
|
+
"ip_subnet": n.get("ip_subnet", ""),
|
|
100
|
+
"purpose": get_network_purpose(n),
|
|
101
|
+
"dhcpd_enabled": "Yes" if n.get("dhcpd_enabled") else "No",
|
|
102
|
+
})
|
|
103
|
+
output_csv(csv_data, columns)
|
|
104
|
+
else:
|
|
105
|
+
from rich.table import Table
|
|
106
|
+
|
|
107
|
+
table = Table(title="Networks", show_header=True, header_style="bold cyan")
|
|
108
|
+
table.add_column("ID", style="dim")
|
|
109
|
+
table.add_column("Name")
|
|
110
|
+
table.add_column("VLAN")
|
|
111
|
+
table.add_column("Subnet")
|
|
112
|
+
table.add_column("DHCP")
|
|
113
|
+
table.add_column("Purpose")
|
|
114
|
+
if verbose:
|
|
115
|
+
table.add_column("Gateway")
|
|
116
|
+
table.add_column("Domain")
|
|
117
|
+
|
|
118
|
+
for n in networks:
|
|
119
|
+
network_id = n.get("_id", "")
|
|
120
|
+
name = n.get("name", "")
|
|
121
|
+
vlan = str(n.get("vlan", "1"))
|
|
122
|
+
subnet = format_subnet(n)
|
|
123
|
+
dhcp = format_dhcp_range(n)
|
|
124
|
+
purpose = get_network_purpose(n)
|
|
125
|
+
|
|
126
|
+
if verbose:
|
|
127
|
+
gateway = n.get("dhcpd_gateway", n.get("ip_subnet", "").split("/")[0] if n.get("ip_subnet") else "")
|
|
128
|
+
domain = n.get("domain_name", "-")
|
|
129
|
+
table.add_row(network_id, name, vlan, subnet, dhcp, purpose, gateway, domain)
|
|
130
|
+
else:
|
|
131
|
+
table.add_row(network_id, name, vlan, subnet, dhcp, purpose)
|
|
132
|
+
|
|
133
|
+
console.print(table)
|
|
134
|
+
console.print(f"\n[dim]{len(networks)} network(s)[/dim]")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@app.command("get")
|
|
138
|
+
def get_network(
|
|
139
|
+
network_id: Annotated[str, typer.Argument(help="Network ID or name")],
|
|
140
|
+
output: Annotated[
|
|
141
|
+
OutputFormat,
|
|
142
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
143
|
+
] = OutputFormat.TABLE,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Get network details."""
|
|
146
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
147
|
+
|
|
148
|
+
async def _get():
|
|
149
|
+
client = UniFiLocalClient()
|
|
150
|
+
networks = await client.get_networks()
|
|
151
|
+
|
|
152
|
+
# Find by ID or name
|
|
153
|
+
for n in networks:
|
|
154
|
+
if n.get("_id") == network_id or n.get("name", "").lower() == network_id.lower():
|
|
155
|
+
return n
|
|
156
|
+
|
|
157
|
+
# Partial name match
|
|
158
|
+
for n in networks:
|
|
159
|
+
if network_id.lower() in n.get("name", "").lower():
|
|
160
|
+
return n
|
|
161
|
+
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
network = run_with_spinner(_get(), "Finding network...")
|
|
166
|
+
except LocalAPIError as e:
|
|
167
|
+
print_error(str(e))
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
|
|
170
|
+
if not network:
|
|
171
|
+
print_error(f"Network '{network_id}' not found")
|
|
172
|
+
raise typer.Exit(1)
|
|
173
|
+
|
|
174
|
+
if output == OutputFormat.JSON:
|
|
175
|
+
output_json(network)
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
# Table output
|
|
179
|
+
from rich.table import Table
|
|
180
|
+
|
|
181
|
+
name = network.get("name", "Unknown")
|
|
182
|
+
console.print()
|
|
183
|
+
console.print(f"[bold cyan]Network: {name}[/bold cyan]")
|
|
184
|
+
console.print("─" * 40)
|
|
185
|
+
console.print()
|
|
186
|
+
|
|
187
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
188
|
+
table.add_column("Key", style="dim")
|
|
189
|
+
table.add_column("Value")
|
|
190
|
+
|
|
191
|
+
table.add_row("ID:", network.get("_id", ""))
|
|
192
|
+
table.add_row("VLAN:", str(network.get("vlan", "1")))
|
|
193
|
+
table.add_row("Purpose:", get_network_purpose(network))
|
|
194
|
+
table.add_row("", "")
|
|
195
|
+
|
|
196
|
+
# Subnet info
|
|
197
|
+
subnet = network.get("ip_subnet", "")
|
|
198
|
+
if subnet:
|
|
199
|
+
table.add_row("Subnet:", subnet)
|
|
200
|
+
gateway = subnet.split("/")[0] if "/" in subnet else ""
|
|
201
|
+
if gateway:
|
|
202
|
+
# Replace last octet with .1 for gateway
|
|
203
|
+
parts = gateway.rsplit(".", 1)
|
|
204
|
+
if len(parts) == 2:
|
|
205
|
+
gateway = f"{parts[0]}.1"
|
|
206
|
+
table.add_row("Gateway:", network.get("dhcpd_gateway", gateway))
|
|
207
|
+
|
|
208
|
+
table.add_row("", "")
|
|
209
|
+
|
|
210
|
+
# DHCP info
|
|
211
|
+
if network.get("dhcpd_enabled"):
|
|
212
|
+
table.add_row("DHCP:", "[green]Enabled[/green]")
|
|
213
|
+
start = network.get("dhcpd_start", "")
|
|
214
|
+
stop = network.get("dhcpd_stop", "")
|
|
215
|
+
if start and stop:
|
|
216
|
+
table.add_row("Range:", f"{start} - {stop}")
|
|
217
|
+
lease = network.get("dhcpd_leasetime", 86400)
|
|
218
|
+
table.add_row("Lease:", f"{lease // 3600}h")
|
|
219
|
+
|
|
220
|
+
# DNS (dhcpd_dns_{1..4}, gated by dhcpd_dns_enabled)
|
|
221
|
+
if network.get("dhcpd_dns_enabled"):
|
|
222
|
+
dns_parts = [
|
|
223
|
+
network.get(f"dhcpd_dns_{i}", "") for i in (1, 2, 3, 4)
|
|
224
|
+
]
|
|
225
|
+
dns_parts = [d for d in dns_parts if d]
|
|
226
|
+
if dns_parts:
|
|
227
|
+
table.add_row("DNS:", ", ".join(dns_parts))
|
|
228
|
+
else:
|
|
229
|
+
table.add_row("DNS:", "[dim]custom enabled, no servers set[/dim]")
|
|
230
|
+
else:
|
|
231
|
+
table.add_row("DNS:", "[dim]auto (gateway)[/dim]")
|
|
232
|
+
else:
|
|
233
|
+
table.add_row("DHCP:", "[dim]Disabled[/dim]")
|
|
234
|
+
|
|
235
|
+
table.add_row("", "")
|
|
236
|
+
|
|
237
|
+
# Additional settings
|
|
238
|
+
if network.get("igmp_snooping"):
|
|
239
|
+
table.add_row("IGMP Snooping:", "Yes")
|
|
240
|
+
|
|
241
|
+
if network.get("dhcpguard_enabled"):
|
|
242
|
+
table.add_row("DHCP Guard:", "Yes")
|
|
243
|
+
|
|
244
|
+
domain = network.get("domain_name")
|
|
245
|
+
if domain:
|
|
246
|
+
table.add_row("Domain:", domain)
|
|
247
|
+
|
|
248
|
+
# Network isolation
|
|
249
|
+
if network.get("purpose") == "guest":
|
|
250
|
+
table.add_row("Guest Network:", "Yes")
|
|
251
|
+
if network.get("networkgroup") == "LAN":
|
|
252
|
+
table.add_row("Internet Only:", "Yes")
|
|
253
|
+
|
|
254
|
+
console.print(table)
|
|
255
|
+
console.print()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@app.command("update")
|
|
259
|
+
def update_network(
|
|
260
|
+
network_ids: Annotated[
|
|
261
|
+
list[str],
|
|
262
|
+
typer.Argument(help="Network ID(s) or name(s) — pass multiple to update in one call"),
|
|
263
|
+
],
|
|
264
|
+
dhcp_start: Annotated[
|
|
265
|
+
str | None,
|
|
266
|
+
typer.Option("--dhcp-start", help="DHCP range start (last octet or full IP)"),
|
|
267
|
+
] = None,
|
|
268
|
+
dhcp_stop: Annotated[
|
|
269
|
+
str | None,
|
|
270
|
+
typer.Option("--dhcp-stop", help="DHCP range stop (last octet or full IP)"),
|
|
271
|
+
] = None,
|
|
272
|
+
dns1: Annotated[
|
|
273
|
+
str | None,
|
|
274
|
+
typer.Option("--dns1", help="Primary DHCP DNS server (dhcpd_dns_1)"),
|
|
275
|
+
] = None,
|
|
276
|
+
dns2: Annotated[
|
|
277
|
+
str | None,
|
|
278
|
+
typer.Option("--dns2", help="Secondary DHCP DNS server (dhcpd_dns_2)"),
|
|
279
|
+
] = None,
|
|
280
|
+
dns3: Annotated[
|
|
281
|
+
str | None,
|
|
282
|
+
typer.Option("--dns3", help="Tertiary DHCP DNS server (dhcpd_dns_3)"),
|
|
283
|
+
] = None,
|
|
284
|
+
dns4: Annotated[
|
|
285
|
+
str | None,
|
|
286
|
+
typer.Option("--dns4", help="Quaternary DHCP DNS server (dhcpd_dns_4)"),
|
|
287
|
+
] = None,
|
|
288
|
+
no_dns: Annotated[
|
|
289
|
+
bool,
|
|
290
|
+
typer.Option("--no-dns", help="Disable custom DHCP DNS (fall back to auto/gateway)"),
|
|
291
|
+
] = False,
|
|
292
|
+
output: Annotated[
|
|
293
|
+
OutputFormat,
|
|
294
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
295
|
+
] = OutputFormat.TABLE,
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Update network settings (DHCP range, DHCP DNS servers)."""
|
|
298
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
299
|
+
from ui_cli.output import print_success
|
|
300
|
+
|
|
301
|
+
dns_requested = any(v is not None for v in (dns1, dns2, dns3, dns4)) or no_dns
|
|
302
|
+
dhcp_requested = dhcp_start is not None or dhcp_stop is not None
|
|
303
|
+
|
|
304
|
+
if not dhcp_requested and not dns_requested:
|
|
305
|
+
print_error(
|
|
306
|
+
"At least one option required (--dhcp-start, --dhcp-stop, --dns1..--dns4, --no-dns)"
|
|
307
|
+
)
|
|
308
|
+
raise typer.Exit(1)
|
|
309
|
+
|
|
310
|
+
if no_dns and any(v is not None for v in (dns1, dns2, dns3, dns4)):
|
|
311
|
+
print_error("--no-dns cannot be combined with --dns1/--dns2/--dns3/--dns4")
|
|
312
|
+
raise typer.Exit(1)
|
|
313
|
+
|
|
314
|
+
def _resolve(networks: list[dict[str, Any]], needle: str) -> dict[str, Any] | None:
|
|
315
|
+
for n in networks:
|
|
316
|
+
if n.get("_id") == needle or n.get("name", "").lower() == needle.lower():
|
|
317
|
+
return n
|
|
318
|
+
for n in networks:
|
|
319
|
+
if needle.lower() in n.get("name", "").lower():
|
|
320
|
+
return n
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
async def _update_one(
|
|
324
|
+
client: UniFiLocalClient,
|
|
325
|
+
networks: list[dict[str, Any]],
|
|
326
|
+
needle: str,
|
|
327
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
328
|
+
network = _resolve(networks, needle)
|
|
329
|
+
if not network:
|
|
330
|
+
return None, f"Network '{needle}' not found"
|
|
331
|
+
|
|
332
|
+
net_id = network["_id"]
|
|
333
|
+
subnet = network.get("ip_subnet", "")
|
|
334
|
+
payload: dict[str, Any] = {"_id": net_id}
|
|
335
|
+
|
|
336
|
+
if dhcp_requested:
|
|
337
|
+
if not network.get("dhcpd_enabled"):
|
|
338
|
+
return None, f"Network '{network.get('name')}' has DHCP disabled"
|
|
339
|
+
if not subnet:
|
|
340
|
+
return None, f"Network '{network.get('name')}' has no subnet configured"
|
|
341
|
+
prefix = ".".join(subnet.split("/")[0].split(".")[:3])
|
|
342
|
+
|
|
343
|
+
if dhcp_start is not None:
|
|
344
|
+
payload["dhcpd_start"] = (
|
|
345
|
+
f"{prefix}.{dhcp_start}" if "." not in dhcp_start else dhcp_start
|
|
346
|
+
)
|
|
347
|
+
if dhcp_stop is not None:
|
|
348
|
+
payload["dhcpd_stop"] = (
|
|
349
|
+
f"{prefix}.{dhcp_stop}" if "." not in dhcp_stop else dhcp_stop
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if dns_requested:
|
|
353
|
+
if no_dns:
|
|
354
|
+
payload["dhcpd_dns_enabled"] = False
|
|
355
|
+
payload.update({
|
|
356
|
+
"dhcpd_dns_1": "",
|
|
357
|
+
"dhcpd_dns_2": "",
|
|
358
|
+
"dhcpd_dns_3": "",
|
|
359
|
+
"dhcpd_dns_4": "",
|
|
360
|
+
})
|
|
361
|
+
else:
|
|
362
|
+
payload["dhcpd_dns_enabled"] = True
|
|
363
|
+
if dns1 is not None:
|
|
364
|
+
payload["dhcpd_dns_1"] = dns1
|
|
365
|
+
if dns2 is not None:
|
|
366
|
+
payload["dhcpd_dns_2"] = dns2
|
|
367
|
+
if dns3 is not None:
|
|
368
|
+
payload["dhcpd_dns_3"] = dns3
|
|
369
|
+
if dns4 is not None:
|
|
370
|
+
payload["dhcpd_dns_4"] = dns4
|
|
371
|
+
|
|
372
|
+
updated = await client.update_network(net_id, payload)
|
|
373
|
+
return updated, None
|
|
374
|
+
|
|
375
|
+
async def _update_all():
|
|
376
|
+
client = UniFiLocalClient()
|
|
377
|
+
networks = await client.get_networks()
|
|
378
|
+
results: list[tuple[str, dict[str, Any] | None, str | None]] = []
|
|
379
|
+
for needle in network_ids:
|
|
380
|
+
result, err = await _update_one(client, networks, needle)
|
|
381
|
+
results.append((needle, result, err))
|
|
382
|
+
return results
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
results = run_with_spinner(_update_all(), "Updating networks...")
|
|
386
|
+
except LocalAPIError as e:
|
|
387
|
+
print_error(str(e))
|
|
388
|
+
raise typer.Exit(1)
|
|
389
|
+
|
|
390
|
+
if output == OutputFormat.JSON:
|
|
391
|
+
output_json([
|
|
392
|
+
{"network": needle, "error": err, "result": result}
|
|
393
|
+
for needle, result, err in results
|
|
394
|
+
])
|
|
395
|
+
exit_code = 1 if any(err for _, _, err in results) else 0
|
|
396
|
+
raise typer.Exit(exit_code)
|
|
397
|
+
|
|
398
|
+
exit_code = 0
|
|
399
|
+
for needle, result, err in results:
|
|
400
|
+
if err:
|
|
401
|
+
print_error(f"[{needle}] {err}")
|
|
402
|
+
exit_code = 1
|
|
403
|
+
continue
|
|
404
|
+
if not result:
|
|
405
|
+
print_error(f"[{needle}] Update failed - no response from controller")
|
|
406
|
+
exit_code = 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
name = result.get("name", needle)
|
|
410
|
+
print_success(f"Updated network '{name}'")
|
|
411
|
+
if dhcp_requested:
|
|
412
|
+
console.print(
|
|
413
|
+
f" DHCP Range: {result.get('dhcpd_start', '')} - {result.get('dhcpd_stop', '')}"
|
|
414
|
+
)
|
|
415
|
+
if dns_requested:
|
|
416
|
+
if result.get("dhcpd_dns_enabled"):
|
|
417
|
+
dns_parts = [
|
|
418
|
+
result.get(f"dhcpd_dns_{i}", "") for i in (1, 2, 3, 4)
|
|
419
|
+
]
|
|
420
|
+
dns_parts = [d for d in dns_parts if d]
|
|
421
|
+
console.print(f" DNS: {', '.join(dns_parts) if dns_parts else '(enabled, empty)'}")
|
|
422
|
+
else:
|
|
423
|
+
console.print(" DNS: auto (custom disabled)")
|
|
424
|
+
|
|
425
|
+
if exit_code:
|
|
426
|
+
raise typer.Exit(exit_code)
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Port forwarding 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="portfwd", help="Port forwarding rules", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def format_protocol(rule: dict[str, Any]) -> str:
|
|
21
|
+
"""Format protocol for display."""
|
|
22
|
+
proto = rule.get("proto", "tcp_udp")
|
|
23
|
+
if proto == "tcp_udp":
|
|
24
|
+
return "TCP/UDP"
|
|
25
|
+
return proto.upper()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_source(rule: dict[str, Any]) -> str:
|
|
29
|
+
"""Format source (WAN) side."""
|
|
30
|
+
port = rule.get("dst_port", "")
|
|
31
|
+
src = rule.get("src", "any")
|
|
32
|
+
|
|
33
|
+
if src and src != "any":
|
|
34
|
+
return f"{src}:{port}"
|
|
35
|
+
return f"*:{port}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_destination(rule: dict[str, Any]) -> str:
|
|
39
|
+
"""Format destination (LAN) side."""
|
|
40
|
+
fwd_ip = rule.get("fwd", "")
|
|
41
|
+
fwd_port = rule.get("fwd_port", rule.get("dst_port", ""))
|
|
42
|
+
|
|
43
|
+
if fwd_ip:
|
|
44
|
+
return f"{fwd_ip}:{fwd_port}"
|
|
45
|
+
return f"*:{fwd_port}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_interface(rule: dict[str, Any]) -> str:
|
|
49
|
+
"""Format WAN interface."""
|
|
50
|
+
pfwd_interface = rule.get("pfwd_interface", "")
|
|
51
|
+
if pfwd_interface == "all":
|
|
52
|
+
return "All WANs"
|
|
53
|
+
elif pfwd_interface:
|
|
54
|
+
return pfwd_interface
|
|
55
|
+
return "WAN"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("list")
|
|
59
|
+
def list_port_forwards(
|
|
60
|
+
output: Annotated[
|
|
61
|
+
OutputFormat,
|
|
62
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
63
|
+
] = OutputFormat.TABLE,
|
|
64
|
+
all_rules: Annotated[
|
|
65
|
+
bool,
|
|
66
|
+
typer.Option("--all", "-a", help="Show disabled rules too"),
|
|
67
|
+
] = True,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""List port forwarding rules."""
|
|
70
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
71
|
+
|
|
72
|
+
async def _list():
|
|
73
|
+
client = UniFiLocalClient()
|
|
74
|
+
return await client.get_port_forwards()
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
rules = run_with_spinner(_list(), "Fetching port forwards...")
|
|
78
|
+
except LocalAPIError as e:
|
|
79
|
+
print_error(str(e))
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
|
|
82
|
+
if not rules:
|
|
83
|
+
console.print("[dim]No port forwarding rules found[/dim]")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Filter disabled if not showing all
|
|
87
|
+
if not all_rules:
|
|
88
|
+
rules = [r for r in rules if r.get("enabled", True)]
|
|
89
|
+
if not rules:
|
|
90
|
+
console.print("[dim]No enabled port forwarding rules[/dim]")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# Sort by name
|
|
94
|
+
rules.sort(key=lambda r: r.get("name", "").lower())
|
|
95
|
+
|
|
96
|
+
if output == OutputFormat.JSON:
|
|
97
|
+
output_json(rules)
|
|
98
|
+
elif output == OutputFormat.CSV:
|
|
99
|
+
columns = [
|
|
100
|
+
("_id", "ID"),
|
|
101
|
+
("name", "Name"),
|
|
102
|
+
("enabled", "Enabled"),
|
|
103
|
+
("proto", "Protocol"),
|
|
104
|
+
("dst_port", "WAN Port"),
|
|
105
|
+
("fwd", "LAN IP"),
|
|
106
|
+
("fwd_port", "LAN Port"),
|
|
107
|
+
("pfwd_interface", "Interface"),
|
|
108
|
+
]
|
|
109
|
+
csv_data = []
|
|
110
|
+
for r in rules:
|
|
111
|
+
csv_data.append({
|
|
112
|
+
"_id": r.get("_id", ""),
|
|
113
|
+
"name": r.get("name", ""),
|
|
114
|
+
"enabled": "Yes" if r.get("enabled", True) else "No",
|
|
115
|
+
"proto": format_protocol(r),
|
|
116
|
+
"dst_port": r.get("dst_port", ""),
|
|
117
|
+
"fwd": r.get("fwd", ""),
|
|
118
|
+
"fwd_port": r.get("fwd_port", r.get("dst_port", "")),
|
|
119
|
+
"pfwd_interface": format_interface(r),
|
|
120
|
+
})
|
|
121
|
+
output_csv(csv_data, columns)
|
|
122
|
+
else:
|
|
123
|
+
from rich.table import Table
|
|
124
|
+
|
|
125
|
+
table = Table(title="Port Forwarding Rules", show_header=True, header_style="bold cyan")
|
|
126
|
+
table.add_column("Name")
|
|
127
|
+
table.add_column("Protocol")
|
|
128
|
+
table.add_column("WAN Port")
|
|
129
|
+
table.add_column("→", style="dim")
|
|
130
|
+
table.add_column("LAN Destination")
|
|
131
|
+
table.add_column("Interface")
|
|
132
|
+
table.add_column("Enabled")
|
|
133
|
+
|
|
134
|
+
for r in rules:
|
|
135
|
+
name = r.get("name", "(unnamed)")
|
|
136
|
+
protocol = format_protocol(r)
|
|
137
|
+
wan_port = str(r.get("dst_port", ""))
|
|
138
|
+
destination = format_destination(r)
|
|
139
|
+
interface = format_interface(r)
|
|
140
|
+
enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
|
|
141
|
+
|
|
142
|
+
table.add_row(
|
|
143
|
+
name,
|
|
144
|
+
protocol,
|
|
145
|
+
wan_port,
|
|
146
|
+
"→",
|
|
147
|
+
destination,
|
|
148
|
+
interface,
|
|
149
|
+
enabled,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
console.print(table)
|
|
153
|
+
console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")
|