fh_tool-cli 0.2.1__tar.gz → 0.2.2__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.
- {fh_tool_cli-0.2.1/src/fh_tool_cli.egg-info → fh_tool_cli-0.2.2}/PKG-INFO +20 -5
- fh_tool_cli-0.2.1/PKG-INFO → fh_tool_cli-0.2.2/README.md +17 -18
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/pyproject.toml +3 -1
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/cli.py +45 -39
- fh_tool_cli-0.2.2/src/fh_tool_cli/click_cli.py +297 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/commands/web.py +24 -7
- fh_tool_cli-0.2.2/src/fh_tool_cli/credential_sources.py +47 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/credentials.py +1 -1
- fh_tool_cli-0.2.2/src/fh_tool_cli/output.py +48 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/parser.py +6 -1
- fh_tool_cli-0.2.1/README.md → fh_tool_cli-0.2.2/src/fh_tool_cli.egg-info/PKG-INFO +33 -4
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/SOURCES.txt +4 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/requires.txt +2 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_account.py +47 -0
- fh_tool_cli-0.2.2/tests/test_click_cli.py +72 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_credentials.py +42 -3
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/setup.cfg +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/__init__.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/account.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/argparse_utils.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/__init__.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/fh_tool.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/local_vm.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/telnet.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/web_ajax.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backup.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/client.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/commands/__init__.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/config_decrypt.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/config_store.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/crypto.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/diagnostics.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/errors.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/remote.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/risk.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/upload.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/web_discovery.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/web_writes.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_backup.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_cli_risk.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_config_decrypt.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_diagnostics.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_extracted_helpers.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_local_vm_integration.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_parser_registration.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_remote.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_upload.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_ajax.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_ajax_post.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_discovery.py +0 -0
- {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_write_payloads.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fh_tool-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
|
|
5
5
|
Author: Gxxk
|
|
6
6
|
License-Expression: AGPL-3.0-or-later
|
|
@@ -9,8 +9,10 @@ Classifier: Programming Language :: Python :: 3
|
|
|
9
9
|
Classifier: Topic :: System :: Networking
|
|
10
10
|
Requires-Python: >=3.11
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: click>=8.1.0
|
|
12
13
|
Requires-Dist: cryptography>=42.0.0
|
|
13
14
|
Requires-Dist: requests>=2.31.0
|
|
15
|
+
Requires-Dist: rich>=13.7.0
|
|
14
16
|
|
|
15
17
|
# fh_tool-cli
|
|
16
18
|
|
|
@@ -38,6 +40,18 @@ uv run fh-tool --help
|
|
|
38
40
|
|
|
39
41
|
下文示例中的 `fh-tool ...` 如果是在源码目录内运行,可以统一写成 `uv run fh-tool ...`。
|
|
40
42
|
|
|
43
|
+
## CLI 输出和日志
|
|
44
|
+
|
|
45
|
+
CLI 入口使用 Click,错误和日志输出使用 Rich。命令结果只写 stdout;错误、警告和日志写 stderr,便于脚本把 stdout 当作数据流处理。
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
fh-tool --verbose probe --json
|
|
49
|
+
fh-tool --quiet config show
|
|
50
|
+
fh-tool --log-file fh-tool.log dev-info
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--json` 输出稳定的 machine-readable JSON,不混入日志或样式。当前默认人类输出仍保持 pretty JSON,以兼容已有脚本;后续可以在不影响 `--json` 的前提下逐步增加表格化输出。
|
|
54
|
+
|
|
41
55
|
## 快速配置
|
|
42
56
|
|
|
43
57
|
保存默认 IP/MAC:
|
|
@@ -135,7 +149,7 @@ fh-tool wan list --backend local-vm
|
|
|
135
149
|
|
|
136
150
|
`local-vm` 只是在本机 proot VM 内执行厂商 `cfg_cmd`。`--vm-root` 必须指向 VM 工作区目录,也就是包含 `bin/proot-shell` 和 `rootfs-vm/fhrom/bin/cfg_cmd` 的目录;默认是 `/mnt/dev-cold/HG5143F-ONU-vm`。不要把它指到里面的 `rootfs-vm/`。
|
|
137
151
|
|
|
138
|
-
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin
|
|
152
|
+
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin 密码;cfg 来源需要 Telnet 时会默认使用 HG5143F 派生 Telnet 凭据 fallback,可用 `--no-derived-credentials` 关闭。失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
|
|
139
153
|
|
|
140
154
|
后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
|
|
141
155
|
|
|
@@ -160,11 +174,12 @@ fh-tool web services set --service telnet --enabled 0 --confirm
|
|
|
160
174
|
|
|
161
175
|
常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
|
|
162
176
|
|
|
163
|
-
Telnet/cfg
|
|
177
|
+
Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
|
|
164
178
|
|
|
165
179
|
```bash
|
|
166
|
-
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
167
|
-
fh-tool wan list
|
|
180
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
181
|
+
fh-tool wan list
|
|
182
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
|
|
168
183
|
```
|
|
169
184
|
|
|
170
185
|
诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: fh_tool-cli
|
|
3
|
-
Version: 0.2.1
|
|
4
|
-
Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
|
|
5
|
-
Author: Gxxk
|
|
6
|
-
License-Expression: AGPL-3.0-or-later
|
|
7
|
-
Keywords: FiberHome,fh_tool,ONU,gateway,telecomadmin
|
|
8
|
-
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Topic :: System :: Networking
|
|
10
|
-
Requires-Python: >=3.11
|
|
11
|
-
Description-Content-Type: text/markdown
|
|
12
|
-
Requires-Dist: cryptography>=42.0.0
|
|
13
|
-
Requires-Dist: requests>=2.31.0
|
|
14
|
-
|
|
15
1
|
# fh_tool-cli
|
|
16
2
|
|
|
17
3
|
本项目是本地管理 FiberHome `/fh_tool` 接口的 Python CLI,目标是快速管理/调整你自己的设备。
|
|
@@ -38,6 +24,18 @@ uv run fh-tool --help
|
|
|
38
24
|
|
|
39
25
|
下文示例中的 `fh-tool ...` 如果是在源码目录内运行,可以统一写成 `uv run fh-tool ...`。
|
|
40
26
|
|
|
27
|
+
## CLI 输出和日志
|
|
28
|
+
|
|
29
|
+
CLI 入口使用 Click,错误和日志输出使用 Rich。命令结果只写 stdout;错误、警告和日志写 stderr,便于脚本把 stdout 当作数据流处理。
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
fh-tool --verbose probe --json
|
|
33
|
+
fh-tool --quiet config show
|
|
34
|
+
fh-tool --log-file fh-tool.log dev-info
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`--json` 输出稳定的 machine-readable JSON,不混入日志或样式。当前默认人类输出仍保持 pretty JSON,以兼容已有脚本;后续可以在不影响 `--json` 的前提下逐步增加表格化输出。
|
|
38
|
+
|
|
41
39
|
## 快速配置
|
|
42
40
|
|
|
43
41
|
保存默认 IP/MAC:
|
|
@@ -135,7 +133,7 @@ fh-tool wan list --backend local-vm
|
|
|
135
133
|
|
|
136
134
|
`local-vm` 只是在本机 proot VM 内执行厂商 `cfg_cmd`。`--vm-root` 必须指向 VM 工作区目录,也就是包含 `bin/proot-shell` 和 `rootfs-vm/fhrom/bin/cfg_cmd` 的目录;默认是 `/mnt/dev-cold/HG5143F-ONU-vm`。不要把它指到里面的 `rootfs-vm/`。
|
|
137
135
|
|
|
138
|
-
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin
|
|
136
|
+
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin 密码;cfg 来源需要 Telnet 时会默认使用 HG5143F 派生 Telnet 凭据 fallback,可用 `--no-derived-credentials` 关闭。失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
|
|
139
137
|
|
|
140
138
|
后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
|
|
141
139
|
|
|
@@ -160,11 +158,12 @@ fh-tool web services set --service telnet --enabled 0 --confirm
|
|
|
160
158
|
|
|
161
159
|
常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
|
|
162
160
|
|
|
163
|
-
Telnet/cfg
|
|
161
|
+
Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
|
|
164
162
|
|
|
165
163
|
```bash
|
|
166
|
-
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
167
|
-
fh-tool wan list
|
|
164
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
165
|
+
fh-tool wan list
|
|
166
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
|
|
168
167
|
```
|
|
169
168
|
|
|
170
169
|
诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fh_tool-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "用于本地管理 FiberHome fh_tool 接口的 Python CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -18,8 +18,10 @@ classifiers = [
|
|
|
18
18
|
"Topic :: System :: Networking",
|
|
19
19
|
]
|
|
20
20
|
dependencies = [
|
|
21
|
+
"click>=8.1.0",
|
|
21
22
|
"cryptography>=42.0.0",
|
|
22
23
|
"requests>=2.31.0",
|
|
24
|
+
"rich>=13.7.0",
|
|
23
25
|
]
|
|
24
26
|
|
|
25
27
|
[project.scripts]
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import json
|
|
5
4
|
import re
|
|
6
5
|
import sys
|
|
7
6
|
from pathlib import Path
|
|
@@ -10,6 +9,7 @@ from typing import Any, Callable
|
|
|
10
9
|
import requests
|
|
11
10
|
|
|
12
11
|
from .account import (
|
|
12
|
+
PasswordInput,
|
|
13
13
|
account_show,
|
|
14
14
|
read_secret_from_args,
|
|
15
15
|
set_su_runtime_password,
|
|
@@ -63,9 +63,12 @@ from .config_store import (
|
|
|
63
63
|
resolve_mac,
|
|
64
64
|
save_config,
|
|
65
65
|
)
|
|
66
|
+
from .credential_sources import (
|
|
67
|
+
complete_hg5143f_telnet_login,
|
|
68
|
+
derive_hg5143f_su_password_from_args,
|
|
69
|
+
)
|
|
66
70
|
from .credentials import (
|
|
67
71
|
derive_credentials,
|
|
68
|
-
derive_hg5143f_telnet,
|
|
69
72
|
)
|
|
70
73
|
from .diagnostics import (
|
|
71
74
|
firewall_status,
|
|
@@ -97,17 +100,7 @@ from .upload import (
|
|
|
97
100
|
upload_file_info,
|
|
98
101
|
upload_workflow_plan,
|
|
99
102
|
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def emit(data: Any, json_mode: bool) -> None:
|
|
103
|
-
if json_mode:
|
|
104
|
-
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
105
|
-
return
|
|
106
|
-
|
|
107
|
-
if isinstance(data, dict):
|
|
108
|
-
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
109
|
-
else:
|
|
110
|
-
print(data)
|
|
103
|
+
from .output import emit
|
|
111
104
|
|
|
112
105
|
|
|
113
106
|
SENSITIVE_PLAN_KEY_RE = re.compile(
|
|
@@ -355,18 +348,12 @@ def _telnet_credentials_from_args(args: argparse.Namespace) -> TelnetCredentials
|
|
|
355
348
|
ip, _ip_source = resolve_ip(args)
|
|
356
349
|
username = getattr(args, "username", None)
|
|
357
350
|
password = _password_from_args(args)
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
raise CliError(
|
|
365
|
-
"--use-derived-credentials 只能为默认 HG5143F Telnet 账号补齐密码;"
|
|
366
|
-
"自定义 --username 需要同时提供密码"
|
|
367
|
-
)
|
|
368
|
-
username = username or derived.username
|
|
369
|
-
password = password or derived.password
|
|
351
|
+
username, password = complete_hg5143f_telnet_login(
|
|
352
|
+
args,
|
|
353
|
+
ip=ip,
|
|
354
|
+
username=username,
|
|
355
|
+
password=password,
|
|
356
|
+
)
|
|
370
357
|
|
|
371
358
|
return TelnetCredentials(
|
|
372
359
|
host=ip,
|
|
@@ -525,15 +512,43 @@ def command_account_set_su_runtime_password(args: argparse.Namespace) -> dict[st
|
|
|
525
512
|
return _write_dry_run_plan(
|
|
526
513
|
"account set-su-runtime-password",
|
|
527
514
|
"set-su-runtime-password 会覆写 runtime su password",
|
|
528
|
-
target={"
|
|
515
|
+
target={"input_mode": _su_runtime_password_input_mode(args)},
|
|
529
516
|
side_effects={"runtime_password_write": False},
|
|
530
517
|
)
|
|
531
|
-
secret =
|
|
518
|
+
secret, password_source = _su_runtime_password_from_args(args)
|
|
532
519
|
result = set_su_runtime_password(_telnet_shell_from_args(args).run, secret.password)
|
|
533
520
|
result["generated"] = secret.generated
|
|
521
|
+
result["password_source"] = password_source
|
|
534
522
|
return result
|
|
535
523
|
|
|
536
524
|
|
|
525
|
+
def _su_runtime_password_from_args(args: argparse.Namespace) -> tuple[PasswordInput, str]:
|
|
526
|
+
selected = [
|
|
527
|
+
getattr(args, "password", None) is not None,
|
|
528
|
+
getattr(args, "password_stdin", False),
|
|
529
|
+
getattr(args, "generate", False),
|
|
530
|
+
]
|
|
531
|
+
if any(selected):
|
|
532
|
+
secret = read_secret_from_args(args)
|
|
533
|
+
if getattr(args, "password", None) is not None:
|
|
534
|
+
return secret, "argument"
|
|
535
|
+
if getattr(args, "password_stdin", False):
|
|
536
|
+
return secret, "stdin"
|
|
537
|
+
return secret, "generate"
|
|
538
|
+
ip, _ip_source = resolve_ip(args)
|
|
539
|
+
return PasswordInput(
|
|
540
|
+
derive_hg5143f_su_password_from_args(args, ip=ip),
|
|
541
|
+
generated=False,
|
|
542
|
+
), "derived-hg5143f-su"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _su_runtime_password_input_mode(args: argparse.Namespace) -> str:
|
|
546
|
+
mode = _password_input_mode(args)
|
|
547
|
+
if mode == "required_on_confirm":
|
|
548
|
+
return "derived-hg5143f-su-on-confirm"
|
|
549
|
+
return mode
|
|
550
|
+
|
|
551
|
+
|
|
537
552
|
def command_autoupdate_status(args: argparse.Namespace) -> dict[str, Any]:
|
|
538
553
|
return autoupdate_status(
|
|
539
554
|
_cfg_backend_from_args(args),
|
|
@@ -1017,18 +1032,9 @@ def add_api_command(
|
|
|
1017
1032
|
|
|
1018
1033
|
|
|
1019
1034
|
def main(argv: list[str] | None = None) -> int:
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
except (argparse.ArgumentTypeError, CliError, FHToolError, ValueError) as exc:
|
|
1024
|
-
print(f"错误: {exc}", file=sys.stderr)
|
|
1025
|
-
return 2
|
|
1026
|
-
except KeyboardInterrupt:
|
|
1027
|
-
print("已取消", file=sys.stderr)
|
|
1028
|
-
return 130
|
|
1029
|
-
|
|
1030
|
-
emit(result, getattr(args, "json", False))
|
|
1031
|
-
return 0
|
|
1035
|
+
from .click_cli import run_click_cli
|
|
1036
|
+
|
|
1037
|
+
return run_click_cli(argv, _command_handlers())
|
|
1032
1038
|
|
|
1033
1039
|
|
|
1034
1040
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .errors import CliError, FHToolError
|
|
11
|
+
from .output import configure_logging, emit, emit_cancelled, emit_error
|
|
12
|
+
from .parser import HandlerMap, build_parser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CONTEXT_SETTINGS = {
|
|
16
|
+
"help_option_names": ["-h", "--help"],
|
|
17
|
+
"ignore_unknown_options": False,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FHToolClickError(click.ClickException):
|
|
22
|
+
exit_code = 2
|
|
23
|
+
|
|
24
|
+
def show(self, file: Any | None = None) -> None:
|
|
25
|
+
emit_error(self.format_message())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArgparseParamType(click.ParamType):
|
|
29
|
+
name = "value"
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
converter: Callable[[str], Any] | None = None,
|
|
34
|
+
*,
|
|
35
|
+
choices: list[Any] | tuple[Any, ...] | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.converter = converter
|
|
38
|
+
self.choices = tuple(choices or ())
|
|
39
|
+
|
|
40
|
+
def convert(self, value: Any, param: click.Parameter | None, ctx: click.Context | None) -> Any:
|
|
41
|
+
if value is None:
|
|
42
|
+
return None
|
|
43
|
+
try:
|
|
44
|
+
converted = self.converter(value) if self.converter else value
|
|
45
|
+
except argparse.ArgumentTypeError as exc:
|
|
46
|
+
self.fail(str(exc), param, ctx)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
self.fail(str(exc), param, ctx)
|
|
49
|
+
|
|
50
|
+
if self.choices and converted not in self.choices:
|
|
51
|
+
choices = ", ".join(str(item) for item in self.choices)
|
|
52
|
+
self.fail(f"must be one of: {choices}", param, ctx)
|
|
53
|
+
return converted
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_click_cli(handlers: HandlerMap) -> click.Group:
|
|
57
|
+
parser = build_parser(handlers)
|
|
58
|
+
command = _command_from_parser(parser, name="fh-tool", fixed={}, help_text=parser.description)
|
|
59
|
+
if not isinstance(command, click.Group):
|
|
60
|
+
raise TypeError("root parser must produce a Click group")
|
|
61
|
+
|
|
62
|
+
command.params.insert(
|
|
63
|
+
0,
|
|
64
|
+
click.Option(
|
|
65
|
+
["--log-file"],
|
|
66
|
+
help="写入日志文件",
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
command.params.insert(
|
|
70
|
+
0,
|
|
71
|
+
click.Option(
|
|
72
|
+
["-q", "--quiet"],
|
|
73
|
+
count=True,
|
|
74
|
+
help="减少非结果输出",
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
command.params.insert(
|
|
78
|
+
0,
|
|
79
|
+
click.Option(
|
|
80
|
+
["-v", "--verbose"],
|
|
81
|
+
count=True,
|
|
82
|
+
help="增加日志详细度,可重复",
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
return command
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def run_click_cli(argv: list[str] | None, handlers: HandlerMap) -> int:
|
|
89
|
+
command = build_click_cli(handlers)
|
|
90
|
+
effective_args = list(sys.argv[1:] if argv is None else argv)
|
|
91
|
+
if not effective_args:
|
|
92
|
+
effective_args = ["--help"]
|
|
93
|
+
try:
|
|
94
|
+
result = command.main(args=effective_args, prog_name="fh-tool", standalone_mode=False)
|
|
95
|
+
except click.ClickException as exc:
|
|
96
|
+
if isinstance(exc, FHToolClickError):
|
|
97
|
+
exc.show()
|
|
98
|
+
else:
|
|
99
|
+
emit_error(exc.format_message())
|
|
100
|
+
return exc.exit_code
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
emit_cancelled()
|
|
103
|
+
return 130
|
|
104
|
+
return result if isinstance(result, int) else 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _command_from_parser(
|
|
108
|
+
parser: argparse.ArgumentParser,
|
|
109
|
+
*,
|
|
110
|
+
name: str,
|
|
111
|
+
fixed: dict[str, Any],
|
|
112
|
+
help_text: str | None,
|
|
113
|
+
) -> click.Command:
|
|
114
|
+
subparsers = _subparsers_action(parser)
|
|
115
|
+
params = [_parameter_from_action(action) for action in _parser_parameter_actions(parser)]
|
|
116
|
+
params = [param for param in params if param is not None]
|
|
117
|
+
|
|
118
|
+
if subparsers is None:
|
|
119
|
+
return click.Command(
|
|
120
|
+
name=name,
|
|
121
|
+
params=params,
|
|
122
|
+
callback=_leaf_callback(parser, fixed),
|
|
123
|
+
help=help_text,
|
|
124
|
+
context_settings=CONTEXT_SETTINGS,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
handler = parser._defaults.get("handler")
|
|
128
|
+
group = click.Group(
|
|
129
|
+
name=name,
|
|
130
|
+
params=params,
|
|
131
|
+
callback=_group_callback(parser, fixed) if handler is not None else _parent_callback(fixed),
|
|
132
|
+
invoke_without_command=handler is not None,
|
|
133
|
+
no_args_is_help=handler is None,
|
|
134
|
+
help=help_text,
|
|
135
|
+
context_settings=CONTEXT_SETTINGS,
|
|
136
|
+
)
|
|
137
|
+
for child_name, child_parser in subparsers.choices.items():
|
|
138
|
+
child_fixed = {**fixed, subparsers.dest: child_name}
|
|
139
|
+
child_help = _subparser_help(subparsers, child_name)
|
|
140
|
+
child = _command_from_parser(
|
|
141
|
+
child_parser,
|
|
142
|
+
name=child_name,
|
|
143
|
+
fixed=child_fixed,
|
|
144
|
+
help_text=child_help,
|
|
145
|
+
)
|
|
146
|
+
group.add_command(child, name=child_name)
|
|
147
|
+
return group
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _parent_callback(fixed: dict[str, Any]) -> Callable[..., None]:
|
|
151
|
+
@click.pass_context
|
|
152
|
+
def callback(ctx: click.Context, **kwargs: Any) -> None:
|
|
153
|
+
configure_logging(
|
|
154
|
+
verbose=int(kwargs.pop("verbose", 0) or 0),
|
|
155
|
+
quiet=int(kwargs.pop("quiet", 0) or 0),
|
|
156
|
+
log_file=kwargs.pop("log_file", None),
|
|
157
|
+
)
|
|
158
|
+
state = ctx.ensure_object(dict)
|
|
159
|
+
state.update(fixed)
|
|
160
|
+
state.update(_normalize_click_values(kwargs))
|
|
161
|
+
|
|
162
|
+
return callback
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _group_callback(parser: argparse.ArgumentParser, fixed: dict[str, Any]) -> Callable[..., int | None]:
|
|
166
|
+
@click.pass_context
|
|
167
|
+
def callback(ctx: click.Context, **kwargs: Any) -> int | None:
|
|
168
|
+
configure_logging(
|
|
169
|
+
verbose=int(kwargs.pop("verbose", 0) or 0),
|
|
170
|
+
quiet=int(kwargs.pop("quiet", 0) or 0),
|
|
171
|
+
log_file=kwargs.pop("log_file", None),
|
|
172
|
+
)
|
|
173
|
+
state = ctx.ensure_object(dict)
|
|
174
|
+
state.update(fixed)
|
|
175
|
+
state.update(_normalize_click_values(kwargs))
|
|
176
|
+
if ctx.invoked_subcommand is not None:
|
|
177
|
+
return None
|
|
178
|
+
return _run_parser_handler(parser, state)
|
|
179
|
+
|
|
180
|
+
return callback
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _leaf_callback(parser: argparse.ArgumentParser, fixed: dict[str, Any]) -> Callable[..., int]:
|
|
184
|
+
@click.pass_context
|
|
185
|
+
def callback(ctx: click.Context, **kwargs: Any) -> int:
|
|
186
|
+
state = ctx.ensure_object(dict)
|
|
187
|
+
state.update(fixed)
|
|
188
|
+
state.update(_normalize_click_values(kwargs))
|
|
189
|
+
return _run_parser_handler(parser, state)
|
|
190
|
+
|
|
191
|
+
return callback
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _run_parser_handler(parser: argparse.ArgumentParser, values: dict[str, Any]) -> int:
|
|
195
|
+
args_values = dict(parser._defaults)
|
|
196
|
+
args_values.update(values)
|
|
197
|
+
handler = args_values.get("handler")
|
|
198
|
+
if handler is None:
|
|
199
|
+
raise FHToolClickError("missing command handler")
|
|
200
|
+
args = SimpleNamespace(**args_values)
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
result = handler(args)
|
|
204
|
+
except (argparse.ArgumentTypeError, CliError, FHToolError, ValueError) as exc:
|
|
205
|
+
raise FHToolClickError(str(exc)) from exc
|
|
206
|
+
|
|
207
|
+
emit(result, bool(getattr(args, "json", False)))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalize_click_values(values: dict[str, Any]) -> dict[str, Any]:
|
|
212
|
+
normalized: dict[str, Any] = {}
|
|
213
|
+
for key, value in values.items():
|
|
214
|
+
if isinstance(value, tuple):
|
|
215
|
+
normalized[key] = list(value)
|
|
216
|
+
else:
|
|
217
|
+
normalized[key] = value
|
|
218
|
+
return normalized
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _subparsers_action(parser: argparse.ArgumentParser) -> argparse._SubParsersAction[argparse.ArgumentParser] | None:
|
|
222
|
+
for action in parser._actions:
|
|
223
|
+
if isinstance(action, argparse._SubParsersAction):
|
|
224
|
+
return action
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _parser_parameter_actions(parser: argparse.ArgumentParser) -> list[argparse.Action]:
|
|
229
|
+
return [
|
|
230
|
+
action
|
|
231
|
+
for action in parser._actions
|
|
232
|
+
if not isinstance(action, (argparse._HelpAction, argparse._SubParsersAction))
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _parameter_from_action(action: argparse.Action) -> click.Parameter | None:
|
|
237
|
+
if action.option_strings:
|
|
238
|
+
return _option_from_action(action)
|
|
239
|
+
return _argument_from_action(action)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _option_from_action(action: argparse.Action) -> click.Option:
|
|
243
|
+
kwargs: dict[str, Any] = {
|
|
244
|
+
"help": None if action.help is argparse.SUPPRESS else action.help,
|
|
245
|
+
"hidden": action.help is argparse.SUPPRESS,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if isinstance(action, argparse._StoreTrueAction):
|
|
249
|
+
kwargs.update(is_flag=True, default=bool(action.default), flag_value=True)
|
|
250
|
+
elif isinstance(action, argparse._StoreFalseAction):
|
|
251
|
+
kwargs.update(is_flag=True, default=bool(action.default), flag_value=False)
|
|
252
|
+
elif isinstance(action, argparse._AppendAction):
|
|
253
|
+
kwargs.update(
|
|
254
|
+
multiple=True,
|
|
255
|
+
default=tuple(action.default or ()),
|
|
256
|
+
type=_type_from_action(action),
|
|
257
|
+
)
|
|
258
|
+
else:
|
|
259
|
+
kwargs.update(
|
|
260
|
+
required=bool(getattr(action, "required", False)),
|
|
261
|
+
default=None if action.default is argparse.SUPPRESS else action.default,
|
|
262
|
+
type=_type_from_action(action),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
return click.Option(list(action.option_strings), **kwargs)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _argument_from_action(action: argparse.Action) -> click.Argument:
|
|
269
|
+
nargs = action.nargs if action.nargs is not None else 1
|
|
270
|
+
return click.Argument(
|
|
271
|
+
[action.dest],
|
|
272
|
+
required=nargs not in ("?", "*"),
|
|
273
|
+
nargs=nargs,
|
|
274
|
+
type=_type_from_action(action),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _type_from_action(action: argparse.Action) -> click.ParamType:
|
|
279
|
+
choices = list(action.choices) if action.choices is not None else None
|
|
280
|
+
converter = action.type
|
|
281
|
+
if converter is None and not choices:
|
|
282
|
+
return click.STRING
|
|
283
|
+
return ArgparseParamType(converter, choices=choices)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _subparser_help(
|
|
287
|
+
action: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
288
|
+
name: str,
|
|
289
|
+
) -> str | None:
|
|
290
|
+
for choice_action in action._choices_actions:
|
|
291
|
+
if choice_action.dest == name:
|
|
292
|
+
return choice_action.help
|
|
293
|
+
parser = action.choices.get(name)
|
|
294
|
+
for choice_action in action._choices_actions:
|
|
295
|
+
if action.choices.get(choice_action.dest) is parser:
|
|
296
|
+
return choice_action.help
|
|
297
|
+
return None
|
|
@@ -15,6 +15,7 @@ from ..backends.local_vm import DEFAULT_VM_ROOT, LocalVmShell
|
|
|
15
15
|
from ..backends.telnet import TelnetCredentials, TelnetShell
|
|
16
16
|
from ..backends.web_ajax import DEFAULT_AJAX_PATH, DEFAULT_WEB_LOGIN_PORT, WebAjaxClient
|
|
17
17
|
from ..config_store import DEFAULT_CONFIG_PATH, normalize_ip, resolve_ip, resolve_mac
|
|
18
|
+
from ..credential_sources import complete_hg5143f_telnet_login
|
|
18
19
|
from ..errors import CliError, FHToolError
|
|
19
20
|
from ..risk import dry_run_notice, is_confirmed
|
|
20
21
|
from ..web_discovery import (
|
|
@@ -188,29 +189,40 @@ def _cfg_backend_from_web_args(args: argparse.Namespace) -> CfgCmdBackend:
|
|
|
188
189
|
expensive_missing_paths=True,
|
|
189
190
|
)
|
|
190
191
|
ip, _ip_source = resolve_ip(args)
|
|
192
|
+
username, password = _web_telnet_login_from_args(args, ip)
|
|
191
193
|
return CfgCmdBackend(
|
|
192
194
|
TelnetShell(
|
|
193
195
|
TelnetCredentials(
|
|
194
196
|
host=ip,
|
|
195
197
|
port=getattr(args, "telnet_port", 23),
|
|
196
|
-
username=
|
|
197
|
-
password=
|
|
198
|
+
username=username,
|
|
199
|
+
password=password,
|
|
198
200
|
timeout=args.timeout,
|
|
199
201
|
)
|
|
200
202
|
).run
|
|
201
203
|
)
|
|
202
204
|
|
|
203
205
|
|
|
204
|
-
def
|
|
206
|
+
def _web_telnet_login_from_args(
|
|
207
|
+
args: argparse.Namespace,
|
|
208
|
+
ip: str,
|
|
209
|
+
) -> tuple[str | None, str | None]:
|
|
205
210
|
has_password = getattr(args, "telnet_password", None) is not None
|
|
206
211
|
has_stdin = getattr(args, "telnet_password_stdin", False)
|
|
207
212
|
if has_password and has_stdin:
|
|
208
213
|
raise CliError("cfg password source 只能选择 --telnet-password 或 --telnet-password-stdin")
|
|
214
|
+
username = getattr(args, "telnet_username", None)
|
|
215
|
+
password: str | None = None
|
|
209
216
|
if has_stdin:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
return
|
|
217
|
+
password = sys.stdin.readline().rstrip("\n")
|
|
218
|
+
elif has_password:
|
|
219
|
+
password = str(args.telnet_password)
|
|
220
|
+
return complete_hg5143f_telnet_login(
|
|
221
|
+
args,
|
|
222
|
+
ip=ip,
|
|
223
|
+
username=username,
|
|
224
|
+
password=password,
|
|
225
|
+
)
|
|
214
226
|
|
|
215
227
|
|
|
216
228
|
def _extract_password_from_mapping(value: Any) -> str | None:
|
|
@@ -557,6 +569,11 @@ def add_web_options(parser: argparse.ArgumentParser) -> None:
|
|
|
557
569
|
parser.add_argument("--telnet-username", help="cfg 密码来源 Telnet 用户名")
|
|
558
570
|
parser.add_argument("--telnet-password", help="cfg 密码来源 Telnet 密码")
|
|
559
571
|
parser.add_argument("--telnet-password-stdin", action="store_true", help="从 stdin 读取 cfg 密码来源 Telnet 密码")
|
|
572
|
+
parser.add_argument(
|
|
573
|
+
"--no-derived-credentials",
|
|
574
|
+
action="store_true",
|
|
575
|
+
help="关闭 cfg 密码来源的默认 HG5143F 派生 Telnet 凭据 fallback",
|
|
576
|
+
)
|
|
560
577
|
parser.add_argument("--reveal-secrets", action="store_true", help="输出 Web AJAX sensitive 明文")
|
|
561
578
|
parser.add_argument("--json", action="store_true", help="输出 machine-readable JSON")
|
|
562
579
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from .config_store import resolve_mac
|
|
6
|
+
from .credentials import HG5143F_TELNET_USERNAME, derive_hg5143f_su, derive_hg5143f_telnet
|
|
7
|
+
from .errors import CliError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def derived_credentials_enabled(args: argparse.Namespace) -> bool:
|
|
11
|
+
return not getattr(args, "no_derived_credentials", False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def complete_hg5143f_telnet_login(
|
|
15
|
+
args: argparse.Namespace,
|
|
16
|
+
*,
|
|
17
|
+
ip: str,
|
|
18
|
+
username: str | None,
|
|
19
|
+
password: str | None,
|
|
20
|
+
explicit_flag_name: str = "--use-derived-credentials",
|
|
21
|
+
) -> tuple[str | None, str | None]:
|
|
22
|
+
if password is not None or not derived_credentials_enabled(args):
|
|
23
|
+
return username, password
|
|
24
|
+
|
|
25
|
+
explicit_derived = bool(getattr(args, "use_derived_credentials", False))
|
|
26
|
+
if username and username != HG5143F_TELNET_USERNAME:
|
|
27
|
+
if explicit_derived:
|
|
28
|
+
raise CliError(
|
|
29
|
+
f"{explicit_flag_name} 只能为默认 HG5143F Telnet 账号补齐密码;"
|
|
30
|
+
"自定义用户名需要同时提供密码"
|
|
31
|
+
)
|
|
32
|
+
return username, password
|
|
33
|
+
|
|
34
|
+
mac, _mac_source = resolve_mac(args, ip, required=True)
|
|
35
|
+
assert mac is not None
|
|
36
|
+
derived = derive_hg5143f_telnet(mac)
|
|
37
|
+
return username or derived.username, derived.password
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def derive_hg5143f_su_password_from_args(
|
|
41
|
+
args: argparse.Namespace,
|
|
42
|
+
*,
|
|
43
|
+
ip: str,
|
|
44
|
+
) -> str:
|
|
45
|
+
mac, _mac_source = resolve_mac(args, ip, required=True)
|
|
46
|
+
assert mac is not None
|
|
47
|
+
return derive_hg5143f_su(mac).password
|
|
@@ -59,7 +59,7 @@ def derive_hg5143f_telnet(mac: str) -> DerivedCredential:
|
|
|
59
59
|
persistent=True,
|
|
60
60
|
target="telnet-login",
|
|
61
61
|
confidence="high-hg5143f-local-live-verified",
|
|
62
|
-
integration_level="
|
|
62
|
+
integration_level="automatic-telnet-fallback",
|
|
63
63
|
note=(
|
|
64
64
|
"Default Telnet login credential observed on HG5143F V4 firmware. "
|
|
65
65
|
"Explicit CLI username/password still override it."
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def configure_logging(*, verbose: int = 0, quiet: int = 0, log_file: str | None = None) -> None:
|
|
13
|
+
level = logging.WARNING
|
|
14
|
+
if quiet:
|
|
15
|
+
level = logging.ERROR
|
|
16
|
+
elif verbose >= 2:
|
|
17
|
+
level = logging.DEBUG
|
|
18
|
+
elif verbose == 1:
|
|
19
|
+
level = logging.INFO
|
|
20
|
+
|
|
21
|
+
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
|
|
22
|
+
if log_file:
|
|
23
|
+
path = Path(log_file).expanduser()
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
handlers.append(logging.FileHandler(path, encoding="utf-8"))
|
|
26
|
+
|
|
27
|
+
logging.basicConfig(
|
|
28
|
+
level=level,
|
|
29
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
30
|
+
handlers=handlers,
|
|
31
|
+
force=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def emit(data: Any, json_mode: bool) -> None:
|
|
36
|
+
if json_mode or isinstance(data, dict):
|
|
37
|
+
sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
sys.stdout.write(str(data) + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def emit_error(message: str) -> None:
|
|
44
|
+
Console(file=sys.stderr).print(f"[bold red]错误:[/bold red] {message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def emit_cancelled() -> None:
|
|
48
|
+
Console(file=sys.stderr).print("[yellow]已取消[/yellow]")
|
|
@@ -118,7 +118,12 @@ def add_telnet_options(
|
|
|
118
118
|
parser.add_argument(
|
|
119
119
|
"--use-derived-credentials",
|
|
120
120
|
action="store_true",
|
|
121
|
-
help="
|
|
121
|
+
help="兼容参数;HG5143F 派生 Telnet 凭据现在默认作为 fallback",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--no-derived-credentials",
|
|
125
|
+
action="store_true",
|
|
126
|
+
help="关闭默认 HG5143F 派生 Telnet 凭据 fallback",
|
|
122
127
|
)
|
|
123
128
|
|
|
124
129
|
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fh_tool-cli
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
|
|
5
|
+
Author: Gxxk
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Keywords: FiberHome,fh_tool,ONU,gateway,telecomadmin
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: System :: Networking
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: click>=8.1.0
|
|
13
|
+
Requires-Dist: cryptography>=42.0.0
|
|
14
|
+
Requires-Dist: requests>=2.31.0
|
|
15
|
+
Requires-Dist: rich>=13.7.0
|
|
16
|
+
|
|
1
17
|
# fh_tool-cli
|
|
2
18
|
|
|
3
19
|
本项目是本地管理 FiberHome `/fh_tool` 接口的 Python CLI,目标是快速管理/调整你自己的设备。
|
|
@@ -24,6 +40,18 @@ uv run fh-tool --help
|
|
|
24
40
|
|
|
25
41
|
下文示例中的 `fh-tool ...` 如果是在源码目录内运行,可以统一写成 `uv run fh-tool ...`。
|
|
26
42
|
|
|
43
|
+
## CLI 输出和日志
|
|
44
|
+
|
|
45
|
+
CLI 入口使用 Click,错误和日志输出使用 Rich。命令结果只写 stdout;错误、警告和日志写 stderr,便于脚本把 stdout 当作数据流处理。
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
fh-tool --verbose probe --json
|
|
49
|
+
fh-tool --quiet config show
|
|
50
|
+
fh-tool --log-file fh-tool.log dev-info
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`--json` 输出稳定的 machine-readable JSON,不混入日志或样式。当前默认人类输出仍保持 pretty JSON,以兼容已有脚本;后续可以在不影响 `--json` 的前提下逐步增加表格化输出。
|
|
54
|
+
|
|
27
55
|
## 快速配置
|
|
28
56
|
|
|
29
57
|
保存默认 IP/MAC:
|
|
@@ -121,7 +149,7 @@ fh-tool wan list --backend local-vm
|
|
|
121
149
|
|
|
122
150
|
`local-vm` 只是在本机 proot VM 内执行厂商 `cfg_cmd`。`--vm-root` 必须指向 VM 工作区目录,也就是包含 `bin/proot-shell` 和 `rootfs-vm/fhrom/bin/cfg_cmd` 的目录;默认是 `/mnt/dev-cold/HG5143F-ONU-vm`。不要把它指到里面的 `rootfs-vm/`。
|
|
123
151
|
|
|
124
|
-
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin
|
|
152
|
+
Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin 密码;cfg 来源需要 Telnet 时会默认使用 HG5143F 派生 Telnet 凭据 fallback,可用 `--no-derived-credentials` 关闭。失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
|
|
125
153
|
|
|
126
154
|
后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
|
|
127
155
|
|
|
@@ -146,11 +174,12 @@ fh-tool web services set --service telnet --enabled 0 --confirm
|
|
|
146
174
|
|
|
147
175
|
常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
|
|
148
176
|
|
|
149
|
-
Telnet/cfg
|
|
177
|
+
Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
|
|
150
178
|
|
|
151
179
|
```bash
|
|
152
|
-
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
153
|
-
fh-tool wan list
|
|
180
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer
|
|
181
|
+
fh-tool wan list
|
|
182
|
+
fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
|
|
154
183
|
```
|
|
155
184
|
|
|
156
185
|
诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
|
|
@@ -5,13 +5,16 @@ src/fh_tool_cli/account.py
|
|
|
5
5
|
src/fh_tool_cli/argparse_utils.py
|
|
6
6
|
src/fh_tool_cli/backup.py
|
|
7
7
|
src/fh_tool_cli/cli.py
|
|
8
|
+
src/fh_tool_cli/click_cli.py
|
|
8
9
|
src/fh_tool_cli/client.py
|
|
9
10
|
src/fh_tool_cli/config_decrypt.py
|
|
10
11
|
src/fh_tool_cli/config_store.py
|
|
12
|
+
src/fh_tool_cli/credential_sources.py
|
|
11
13
|
src/fh_tool_cli/credentials.py
|
|
12
14
|
src/fh_tool_cli/crypto.py
|
|
13
15
|
src/fh_tool_cli/diagnostics.py
|
|
14
16
|
src/fh_tool_cli/errors.py
|
|
17
|
+
src/fh_tool_cli/output.py
|
|
15
18
|
src/fh_tool_cli/parser.py
|
|
16
19
|
src/fh_tool_cli/remote.py
|
|
17
20
|
src/fh_tool_cli/risk.py
|
|
@@ -36,6 +39,7 @@ tests/test_account.py
|
|
|
36
39
|
tests/test_backup.py
|
|
37
40
|
tests/test_cfg_cmd.py
|
|
38
41
|
tests/test_cli_risk.py
|
|
42
|
+
tests/test_click_cli.py
|
|
39
43
|
tests/test_config_decrypt.py
|
|
40
44
|
tests/test_credentials.py
|
|
41
45
|
tests/test_diagnostics.py
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import unittest
|
|
5
|
+
from unittest.mock import patch
|
|
5
6
|
|
|
6
7
|
from fh_tool_cli.account import (
|
|
7
8
|
TELNET_PASSWORD_PATH,
|
|
@@ -12,6 +13,8 @@ from fh_tool_cli.account import (
|
|
|
12
13
|
set_telnet_password,
|
|
13
14
|
)
|
|
14
15
|
from fh_tool_cli.backends.cfg_cmd import CfgCmdBackend
|
|
16
|
+
from fh_tool_cli.cli import parse_args
|
|
17
|
+
from fh_tool_cli.credentials import HG5143F_SU_PASSWORD_PREFIX
|
|
15
18
|
from fh_tool_cli.errors import CliError
|
|
16
19
|
|
|
17
20
|
|
|
@@ -71,6 +74,50 @@ class AccountTests(unittest.TestCase):
|
|
|
71
74
|
self.assertFalse(result["persistent"])
|
|
72
75
|
self.assertEqual(len(calls), 1)
|
|
73
76
|
|
|
77
|
+
def test_set_su_runtime_password_uses_derived_default(self) -> None:
|
|
78
|
+
calls: list[str] = []
|
|
79
|
+
args = parse_args(
|
|
80
|
+
[
|
|
81
|
+
"account",
|
|
82
|
+
"set-su-runtime-password",
|
|
83
|
+
"--ip",
|
|
84
|
+
"192.168.1.1",
|
|
85
|
+
"--mac",
|
|
86
|
+
"D8F50736BC10",
|
|
87
|
+
"--confirm",
|
|
88
|
+
]
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
class FakeShell:
|
|
92
|
+
def run(self, command: str) -> str:
|
|
93
|
+
calls.append(command)
|
|
94
|
+
return "ok"
|
|
95
|
+
|
|
96
|
+
with patch("fh_tool_cli.cli._telnet_shell_from_args", return_value=FakeShell()):
|
|
97
|
+
result = args.handler(args)
|
|
98
|
+
|
|
99
|
+
self.assertEqual(result["password_source"], "derived-hg5143f-su")
|
|
100
|
+
self.assertEqual(result["value"], "[REDACTED]")
|
|
101
|
+
self.assertIn(HG5143F_SU_PASSWORD_PREFIX, calls[0])
|
|
102
|
+
self.assertIn("36BC10", calls[0])
|
|
103
|
+
|
|
104
|
+
def test_set_su_runtime_password_dry_run_mentions_derived_default(self) -> None:
|
|
105
|
+
args = parse_args(
|
|
106
|
+
[
|
|
107
|
+
"account",
|
|
108
|
+
"set-su-runtime-password",
|
|
109
|
+
"--ip",
|
|
110
|
+
"192.168.1.1",
|
|
111
|
+
"--mac",
|
|
112
|
+
"D8F50736BC10",
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
result = args.handler(args)
|
|
117
|
+
|
|
118
|
+
self.assertEqual(result["target"]["input_mode"], "derived-hg5143f-su-on-confirm")
|
|
119
|
+
self.assertFalse(result["executed"])
|
|
120
|
+
|
|
74
121
|
|
|
75
122
|
if __name__ == "__main__":
|
|
76
123
|
unittest.main()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from fh_tool_cli import cli
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClickCliTests(unittest.TestCase):
|
|
12
|
+
def test_main_uses_click_entrypoint_and_emits_json(self) -> None:
|
|
13
|
+
stdout = io.StringIO()
|
|
14
|
+
stderr = io.StringIO()
|
|
15
|
+
|
|
16
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
17
|
+
exit_code = cli.main(["--verbose", "config", "show", "--json"])
|
|
18
|
+
|
|
19
|
+
self.assertEqual(exit_code, 0)
|
|
20
|
+
self.assertEqual(stderr.getvalue(), "")
|
|
21
|
+
payload = json.loads(stdout.getvalue())
|
|
22
|
+
self.assertIn("path", payload)
|
|
23
|
+
self.assertIsInstance(payload["config"], dict)
|
|
24
|
+
|
|
25
|
+
def test_click_parameter_errors_use_cli_error_channel(self) -> None:
|
|
26
|
+
stdout = io.StringIO()
|
|
27
|
+
stderr = io.StringIO()
|
|
28
|
+
|
|
29
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
30
|
+
exit_code = cli.main(
|
|
31
|
+
[
|
|
32
|
+
"web",
|
|
33
|
+
"port-mapping",
|
|
34
|
+
"set",
|
|
35
|
+
"--external-port",
|
|
36
|
+
"not-a-port",
|
|
37
|
+
"--ip",
|
|
38
|
+
"127.0.0.1",
|
|
39
|
+
]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
self.assertEqual(exit_code, 2)
|
|
43
|
+
self.assertEqual(stdout.getvalue(), "")
|
|
44
|
+
self.assertIn("错误:", stderr.getvalue())
|
|
45
|
+
self.assertIn("port 必须是整数", stderr.getvalue())
|
|
46
|
+
|
|
47
|
+
def test_click_help_is_available(self) -> None:
|
|
48
|
+
stdout = io.StringIO()
|
|
49
|
+
stderr = io.StringIO()
|
|
50
|
+
|
|
51
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
52
|
+
exit_code = cli.main(["--help"])
|
|
53
|
+
|
|
54
|
+
self.assertEqual(exit_code, 0)
|
|
55
|
+
self.assertEqual(stderr.getvalue(), "")
|
|
56
|
+
self.assertIn("Usage: fh-tool", stdout.getvalue())
|
|
57
|
+
self.assertIn("--log-file", stdout.getvalue())
|
|
58
|
+
|
|
59
|
+
def test_no_args_shows_help_without_error(self) -> None:
|
|
60
|
+
stdout = io.StringIO()
|
|
61
|
+
stderr = io.StringIO()
|
|
62
|
+
|
|
63
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
64
|
+
exit_code = cli.main([])
|
|
65
|
+
|
|
66
|
+
self.assertEqual(exit_code, 0)
|
|
67
|
+
self.assertEqual(stderr.getvalue(), "")
|
|
68
|
+
self.assertIn("Usage: fh-tool", stdout.getvalue())
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
unittest.main()
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import unittest
|
|
4
4
|
|
|
5
5
|
from fh_tool_cli.cli import _telnet_credentials_from_args, parse_args
|
|
6
|
+
from fh_tool_cli.commands.web import _web_telnet_login_from_args
|
|
6
7
|
from fh_tool_cli.credentials import (
|
|
7
8
|
HG5143F_SU_PASSWORD_PREFIX,
|
|
8
9
|
HG5143F_TELNET_PASSWORD_PREFIX,
|
|
@@ -21,7 +22,7 @@ class CredentialsTests(unittest.TestCase):
|
|
|
21
22
|
self.assertEqual(credential.username, "telnetadmin")
|
|
22
23
|
self.assertTrue(credential.password.startswith(HG5143F_TELNET_PASSWORD_PREFIX))
|
|
23
24
|
self.assertTrue(credential.password.endswith("36BC10"))
|
|
24
|
-
self.assertEqual(credential.integration_level, "
|
|
25
|
+
self.assertEqual(credential.integration_level, "automatic-telnet-fallback")
|
|
25
26
|
|
|
26
27
|
def test_hg5143f_su_derivation_is_display_only(self) -> None:
|
|
27
28
|
credential = derive_hg5143f_su("D8F50736BC10")
|
|
@@ -70,7 +71,7 @@ class CredentialsTests(unittest.TestCase):
|
|
|
70
71
|
self.assertEqual(result["credentials"][0]["password"], "[REDACTED]")
|
|
71
72
|
self.assertEqual(result["credentials"][0]["target"], "telnet-login")
|
|
72
73
|
|
|
73
|
-
def
|
|
74
|
+
def test_telnet_derived_credentials_are_default_fallback(self) -> None:
|
|
74
75
|
args = parse_args(
|
|
75
76
|
[
|
|
76
77
|
"cfg",
|
|
@@ -80,7 +81,6 @@ class CredentialsTests(unittest.TestCase):
|
|
|
80
81
|
"192.168.1.1",
|
|
81
82
|
"--mac",
|
|
82
83
|
"D8F50736BC10",
|
|
83
|
-
"--use-derived-credentials",
|
|
84
84
|
]
|
|
85
85
|
)
|
|
86
86
|
|
|
@@ -90,6 +90,25 @@ class CredentialsTests(unittest.TestCase):
|
|
|
90
90
|
self.assertIsNotNone(credentials.password)
|
|
91
91
|
self.assertTrue(credentials.password.endswith("36BC10"))
|
|
92
92
|
|
|
93
|
+
def test_telnet_derived_credentials_can_be_disabled(self) -> None:
|
|
94
|
+
args = parse_args(
|
|
95
|
+
[
|
|
96
|
+
"cfg",
|
|
97
|
+
"get",
|
|
98
|
+
"InternetGatewayDevice.DeviceInfo.Manufacturer",
|
|
99
|
+
"--ip",
|
|
100
|
+
"192.168.1.1",
|
|
101
|
+
"--mac",
|
|
102
|
+
"D8F50736BC10",
|
|
103
|
+
"--no-derived-credentials",
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
credentials = _telnet_credentials_from_args(args)
|
|
108
|
+
|
|
109
|
+
self.assertIsNone(credentials.username)
|
|
110
|
+
self.assertIsNone(credentials.password)
|
|
111
|
+
|
|
93
112
|
def test_explicit_telnet_password_overrides_derived_password(self) -> None:
|
|
94
113
|
args = parse_args(
|
|
95
114
|
[
|
|
@@ -132,6 +151,26 @@ class CredentialsTests(unittest.TestCase):
|
|
|
132
151
|
with self.assertRaises(CliError):
|
|
133
152
|
_telnet_credentials_from_args(args)
|
|
134
153
|
|
|
154
|
+
def test_web_cfg_telnet_source_uses_derived_credentials_by_default(self) -> None:
|
|
155
|
+
args = parse_args(
|
|
156
|
+
[
|
|
157
|
+
"web",
|
|
158
|
+
"ajax",
|
|
159
|
+
"get",
|
|
160
|
+
"get_base_info",
|
|
161
|
+
"--ip",
|
|
162
|
+
"192.168.1.1",
|
|
163
|
+
"--mac",
|
|
164
|
+
"D8F50736BC10",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
username, password = _web_telnet_login_from_args(args, "192.168.1.1")
|
|
169
|
+
|
|
170
|
+
self.assertEqual(username, "telnetadmin")
|
|
171
|
+
self.assertIsNotNone(password)
|
|
172
|
+
self.assertTrue(password.endswith("36BC10"))
|
|
173
|
+
|
|
135
174
|
|
|
136
175
|
if __name__ == "__main__":
|
|
137
176
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|