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.
Files changed (56) hide show
  1. {fh_tool_cli-0.2.1/src/fh_tool_cli.egg-info → fh_tool_cli-0.2.2}/PKG-INFO +20 -5
  2. fh_tool_cli-0.2.1/PKG-INFO → fh_tool_cli-0.2.2/README.md +17 -18
  3. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/pyproject.toml +3 -1
  4. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/cli.py +45 -39
  5. fh_tool_cli-0.2.2/src/fh_tool_cli/click_cli.py +297 -0
  6. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/commands/web.py +24 -7
  7. fh_tool_cli-0.2.2/src/fh_tool_cli/credential_sources.py +47 -0
  8. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/credentials.py +1 -1
  9. fh_tool_cli-0.2.2/src/fh_tool_cli/output.py +48 -0
  10. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/parser.py +6 -1
  11. fh_tool_cli-0.2.1/README.md → fh_tool_cli-0.2.2/src/fh_tool_cli.egg-info/PKG-INFO +33 -4
  12. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/SOURCES.txt +4 -0
  13. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/requires.txt +2 -0
  14. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_account.py +47 -0
  15. fh_tool_cli-0.2.2/tests/test_click_cli.py +72 -0
  16. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_credentials.py +42 -3
  17. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/setup.cfg +0 -0
  18. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/__init__.py +0 -0
  19. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/account.py +0 -0
  20. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/argparse_utils.py +0 -0
  21. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/__init__.py +0 -0
  22. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
  23. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/fh_tool.py +0 -0
  24. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/local_vm.py +0 -0
  25. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/telnet.py +0 -0
  26. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backends/web_ajax.py +0 -0
  27. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/backup.py +0 -0
  28. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/client.py +0 -0
  29. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/commands/__init__.py +0 -0
  30. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/config_decrypt.py +0 -0
  31. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/config_store.py +0 -0
  32. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/crypto.py +0 -0
  33. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/diagnostics.py +0 -0
  34. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/errors.py +0 -0
  35. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/remote.py +0 -0
  36. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/risk.py +0 -0
  37. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/upload.py +0 -0
  38. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/web_discovery.py +0 -0
  39. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli/web_writes.py +0 -0
  40. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
  41. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
  42. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
  43. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_backup.py +0 -0
  44. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_cfg_cmd.py +0 -0
  45. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_cli_risk.py +0 -0
  46. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_config_decrypt.py +0 -0
  47. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_diagnostics.py +0 -0
  48. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_extracted_helpers.py +0 -0
  49. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_local_vm_integration.py +0 -0
  50. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_parser_registration.py +0 -0
  51. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_remote.py +0 -0
  52. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_upload.py +0 -0
  53. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_ajax.py +0 -0
  54. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_ajax_post.py +0 -0
  55. {fh_tool_cli-0.2.1 → fh_tool_cli-0.2.2}/tests/test_web_discovery.py +0 -0
  56. {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.1
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 密码;失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
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/诊断命令默认不会自动套用派生凭据。如果设备仍是 HG5143F 默认 Telnet 规则,并且已提供或保存 MAC,可以显式加 `--use-derived-credentials` 作为 fallback:
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 --use-derived-credentials
167
- fh-tool wan list --use-derived-credentials
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 密码;失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
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/诊断命令默认不会自动套用派生凭据。如果设备仍是 HG5143F 默认 Telnet 规则,并且已提供或保存 MAC,可以显式加 `--use-derived-credentials` 作为 fallback:
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 --use-derived-credentials
167
- fh-tool wan list --use-derived-credentials
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.1"
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
- if getattr(args, "use_derived_credentials", False):
360
- mac, _mac_source = resolve_mac(args, ip, required=True)
361
- assert mac is not None
362
- derived = derive_hg5143f_telnet(mac)
363
- if username and username != derived.username and password is None:
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={"password_input": _password_input_mode(args)},
515
+ target={"input_mode": _su_runtime_password_input_mode(args)},
529
516
  side_effects={"runtime_password_write": False},
530
517
  )
531
- secret = read_secret_from_args(args)
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
- args = parse_args(argv)
1021
- try:
1022
- result = args.handler(args)
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=getattr(args, "telnet_username", None),
197
- password=_web_telnet_password_from_args(args),
198
+ username=username,
199
+ password=password,
198
200
  timeout=args.timeout,
199
201
  )
200
202
  ).run
201
203
  )
202
204
 
203
205
 
204
- def _web_telnet_password_from_args(args: argparse.Namespace) -> str | None:
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
- return sys.stdin.readline().rstrip("\n")
211
- if has_password:
212
- return str(args.telnet_password)
213
- return None
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="explicit-opt-in-telnet-fallback",
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="显式使用已验证的 HG5143F 派生 Telnet 凭据作为 fallback",
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 密码;失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
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/诊断命令默认不会自动套用派生凭据。如果设备仍是 HG5143F 默认 Telnet 规则,并且已提供或保存 MAC,可以显式加 `--use-derived-credentials` 作为 fallback:
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 --use-derived-credentials
153
- fh-tool wan list --use-derived-credentials
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
@@ -1,2 +1,4 @@
1
+ click>=8.1.0
1
2
  cryptography>=42.0.0
2
3
  requests>=2.31.0
4
+ rich>=13.7.0
@@ -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, "explicit-opt-in-telnet-fallback")
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 test_telnet_derived_credentials_are_explicit_fallback(self) -> None:
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