fh_tool-cli 0.2.3__tar.gz → 0.2.5__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 (67) hide show
  1. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/PKG-INFO +38 -2
  2. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/README.md +37 -1
  3. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/pyproject.toml +1 -1
  4. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/__init__.py +1 -1
  5. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/argparse_utils.py +10 -0
  6. fh_tool_cli-0.2.5/src/fh_tool_cli/backends/fh_tool.py +240 -0
  7. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/cli.py +65 -3
  8. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/click_cli.py +9 -1
  9. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/client.py +12 -4
  10. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/web.py +3 -2
  11. fh_tool_cli-0.2.5/src/fh_tool_cli/fh_endpoints.py +19 -0
  12. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/parser.py +43 -0
  13. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/upload.py +3 -1
  14. fh_tool_cli-0.2.5/src/fh_tool_cli/vm_build.py +458 -0
  15. fh_tool_cli-0.2.5/src/fh_tool_cli/vm_collect.py +330 -0
  16. fh_tool_cli-0.2.5/src/fh_tool_cli/vm_common.py +143 -0
  17. fh_tool_cli-0.2.5/src/fh_tool_cli/vm_verify.py +273 -0
  18. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/PKG-INFO +38 -2
  19. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/SOURCES.txt +9 -0
  20. fh_tool_cli-0.2.5/tests/test_fh_port.py +273 -0
  21. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_parser_registration.py +3 -0
  22. fh_tool_cli-0.2.5/tests/test_vm_build.py +126 -0
  23. fh_tool_cli-0.2.5/tests/test_vm_collect.py +115 -0
  24. fh_tool_cli-0.2.5/tests/test_vm_verify.py +73 -0
  25. fh_tool_cli-0.2.3/src/fh_tool_cli/backends/fh_tool.py +0 -98
  26. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/setup.cfg +0 -0
  27. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/account.py +0 -0
  28. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/__init__.py +0 -0
  29. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
  30. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/local_vm.py +0 -0
  31. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/telnet.py +0 -0
  32. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/web_ajax.py +0 -0
  33. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backup.py +0 -0
  34. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/__init__.py +0 -0
  35. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_decrypt.py +0 -0
  36. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_store.py +0 -0
  37. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credential_sources.py +0 -0
  38. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credentials.py +0 -0
  39. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/crypto.py +0 -0
  40. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/diagnostics.py +0 -0
  41. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/errors.py +0 -0
  42. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/output.py +0 -0
  43. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/remote.py +0 -0
  44. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/risk.py +0 -0
  45. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_discovery.py +0 -0
  46. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_writes.py +0 -0
  47. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
  48. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
  49. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/requires.txt +0 -0
  50. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
  51. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_account.py +0 -0
  52. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_backup.py +0 -0
  53. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_cfg_cmd.py +0 -0
  54. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_cli_risk.py +0 -0
  55. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_click_cli.py +0 -0
  56. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_config_decrypt.py +0 -0
  57. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_credentials.py +0 -0
  58. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_diagnostics.py +0 -0
  59. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_extracted_helpers.py +0 -0
  60. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_local_vm_integration.py +0 -0
  61. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_remote.py +0 -0
  62. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_telnet_backend.py +0 -0
  63. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_upload.py +0 -0
  64. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_ajax.py +0 -0
  65. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_ajax_post.py +0 -0
  66. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_discovery.py +0 -0
  67. {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/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
3
+ Version: 0.2.5
4
4
  Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
5
5
  Author: Gxxk
6
6
  License-Expression: AGPL-3.0-or-later
@@ -82,11 +82,12 @@ fh-tool dev-info --ip 192.168.1.1 --mac AABBCCDDEEFF
82
82
 
83
83
  ## 常用命令
84
84
 
85
- 低风险 probe
85
+ 低风险 probe(输出 `fh_port`/`fh_port_source` 与各候选端口 `fh_ports` 明细,可用于诊断 fh_tool API 所在端口):
86
86
 
87
87
  ```bash
88
88
  fh-tool probe
89
89
  fh-tool probe --json
90
+ fh-tool probe --fh-port 80
90
91
  ```
91
92
 
92
93
  读取设备信息:
@@ -129,6 +130,29 @@ fh-tool telnet enable --confirm
129
130
  fh-tool ports --ports 23,80,443,8080
130
131
  ```
131
132
 
133
+ ## fh_tool 端口与型号/固件兼容性
134
+
135
+ 不同型号/固件的 fh_tool 后端监听端口可能不同。大多数固件在 `8080`,但例如 **HG6142A3(固件 V03.00.M0000)** 的 fh_tool API 位于 `http://192.168.1.1:80/fh_tool/api`(80 端口)。
136
+
137
+ 工具默认行为:
138
+
139
+ 1. 先尝试默认端口 `8080`;
140
+ 2. 仅当 `8080` TCP 不可达时,自动探测候选端口(当前为 `80`,用 `GetDevInfo` / HTTP surface 验证),并在结果 JSON 的 `fh_port`/`fh_port_source` 字段中说明最终使用的端口和来源(`argument`/`default`/`auto_detected`/`default_unverified`);
141
+ 3. 探测失败时沿用 `8080` 并在错误信息中给出引导。
142
+
143
+ 也可以显式指定端口(跳过探测):
144
+
145
+ ```bash
146
+ fh-tool dev-info --fh-port 80
147
+ fh-tool call --func GetDevInfo --fh-port 80
148
+ ```
149
+
150
+ 已知局限:
151
+
152
+ - 如果 `8080` 与 `80` 同时 TCP 开放、但 fh_tool API 只在 `80`,TCP 快筛会停留在 `8080` 并以 HTTP/解密错误失败,此时需要 `--fh-port 80` 显式指定;`fh-tool probe --json` 的 `fh_ports` 字段可以诊断这种场景。
153
+ - 设备返回的绝对下载 URL(含端口)按原样使用,不会重写端口。
154
+ - `HTTP 4xx/5xx`、解密失败不会触发端口切换——那说明 8080 上有 HTTP 服务,问题在路径/协议/MAC 而非端口。
155
+
132
156
  ## 本地 VM 测试环境
133
157
 
134
158
  如果已经在 `/mnt/dev-cold/HG5143F-ONU-vm` 启动本地 userspace VM,可直接用它测试 `cfg_cmd` 和 Web AJAX,不需要碰真实网关:
@@ -151,6 +175,18 @@ fh-tool wan list --backend local-vm
151
175
 
152
176
  `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/`。
153
177
 
178
+ 也可以从 HG5143F 原始 MTD dump 构建同类 userspace VM。`collect` 默认只输出 dry-run 计划,不读 flash;真正采集必须加 `--confirm`,自动开启 Telnet 也必须显式加 `--auto-enable-telnet --confirm`:
179
+
180
+ ```bash
181
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps
182
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps --auto-enable-telnet --confirm
183
+ fh-tool vm build --dump-dir ./hg5143f-dumps --output ./HG5143F-ONU-vm
184
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm
185
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm --with-fhapi --with-http
186
+ ```
187
+
188
+ `vm build` 需要 `ubireader_extract_images`、`ubireader_extract_files`、`unsquashfs`、`jefferson`、`qemu-arm-static` 和 `proot`。输出目录会包含 `rootfs-vm/`、`source/`、`logs/`、`bin/`、`build-manifest.json`、`verify-manifest.json` 和 `events.ndjson`。生成的 VM 是 32-bit ARM userspace under `qemu-arm-static`/`proot -0`,不是完整板级 QEMU 启动;默认不会运行完整 `/etc/rc.d/rcS`。
189
+
154
190
  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` 关闭。失败不会阻塞只读抓取,并会在结果里返回脱敏 login summary。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
155
191
 
156
192
  后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
@@ -66,11 +66,12 @@ fh-tool dev-info --ip 192.168.1.1 --mac AABBCCDDEEFF
66
66
 
67
67
  ## 常用命令
68
68
 
69
- 低风险 probe
69
+ 低风险 probe(输出 `fh_port`/`fh_port_source` 与各候选端口 `fh_ports` 明细,可用于诊断 fh_tool API 所在端口):
70
70
 
71
71
  ```bash
72
72
  fh-tool probe
73
73
  fh-tool probe --json
74
+ fh-tool probe --fh-port 80
74
75
  ```
75
76
 
76
77
  读取设备信息:
@@ -113,6 +114,29 @@ fh-tool telnet enable --confirm
113
114
  fh-tool ports --ports 23,80,443,8080
114
115
  ```
115
116
 
117
+ ## fh_tool 端口与型号/固件兼容性
118
+
119
+ 不同型号/固件的 fh_tool 后端监听端口可能不同。大多数固件在 `8080`,但例如 **HG6142A3(固件 V03.00.M0000)** 的 fh_tool API 位于 `http://192.168.1.1:80/fh_tool/api`(80 端口)。
120
+
121
+ 工具默认行为:
122
+
123
+ 1. 先尝试默认端口 `8080`;
124
+ 2. 仅当 `8080` TCP 不可达时,自动探测候选端口(当前为 `80`,用 `GetDevInfo` / HTTP surface 验证),并在结果 JSON 的 `fh_port`/`fh_port_source` 字段中说明最终使用的端口和来源(`argument`/`default`/`auto_detected`/`default_unverified`);
125
+ 3. 探测失败时沿用 `8080` 并在错误信息中给出引导。
126
+
127
+ 也可以显式指定端口(跳过探测):
128
+
129
+ ```bash
130
+ fh-tool dev-info --fh-port 80
131
+ fh-tool call --func GetDevInfo --fh-port 80
132
+ ```
133
+
134
+ 已知局限:
135
+
136
+ - 如果 `8080` 与 `80` 同时 TCP 开放、但 fh_tool API 只在 `80`,TCP 快筛会停留在 `8080` 并以 HTTP/解密错误失败,此时需要 `--fh-port 80` 显式指定;`fh-tool probe --json` 的 `fh_ports` 字段可以诊断这种场景。
137
+ - 设备返回的绝对下载 URL(含端口)按原样使用,不会重写端口。
138
+ - `HTTP 4xx/5xx`、解密失败不会触发端口切换——那说明 8080 上有 HTTP 服务,问题在路径/协议/MAC 而非端口。
139
+
116
140
  ## 本地 VM 测试环境
117
141
 
118
142
  如果已经在 `/mnt/dev-cold/HG5143F-ONU-vm` 启动本地 userspace VM,可直接用它测试 `cfg_cmd` 和 Web AJAX,不需要碰真实网关:
@@ -135,6 +159,18 @@ fh-tool wan list --backend local-vm
135
159
 
136
160
  `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
161
 
162
+ 也可以从 HG5143F 原始 MTD dump 构建同类 userspace VM。`collect` 默认只输出 dry-run 计划,不读 flash;真正采集必须加 `--confirm`,自动开启 Telnet 也必须显式加 `--auto-enable-telnet --confirm`:
163
+
164
+ ```bash
165
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps
166
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps --auto-enable-telnet --confirm
167
+ fh-tool vm build --dump-dir ./hg5143f-dumps --output ./HG5143F-ONU-vm
168
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm
169
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm --with-fhapi --with-http
170
+ ```
171
+
172
+ `vm build` 需要 `ubireader_extract_images`、`ubireader_extract_files`、`unsquashfs`、`jefferson`、`qemu-arm-static` 和 `proot`。输出目录会包含 `rootfs-vm/`、`source/`、`logs/`、`bin/`、`build-manifest.json`、`verify-manifest.json` 和 `events.ndjson`。生成的 VM 是 32-bit ARM userspace under `qemu-arm-static`/`proot -0`,不是完整板级 QEMU 启动;默认不会运行完整 `/etc/rc.d/rcS`。
173
+
138
174
  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` 关闭。失败不会阻塞只读抓取,并会在结果里返回脱敏 login summary。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
139
175
 
140
176
  后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fh_tool-cli"
7
- version = "0.2.3"
7
+ version = "0.2.5"
8
8
  description = "用于本地管理 FiberHome fh_tool 接口的 Python CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Local CLI for FiberHome fh_tool endpoints."""
2
2
 
3
- __version__ = "0.2.1"
3
+ __version__ = "0.2.5"
@@ -25,6 +25,16 @@ def parse_ports(value: str) -> list[int]:
25
25
  return ports
26
26
 
27
27
 
28
+ def normalize_port(value: str) -> int:
29
+ try:
30
+ port = int(value, 10)
31
+ except ValueError as exc:
32
+ raise argparse.ArgumentTypeError(f"不符规范的 port: {value}") from exc
33
+ if not 1 <= port <= 65535:
34
+ raise argparse.ArgumentTypeError(f"port 超出范围: {value}")
35
+ return port
36
+
37
+
28
38
  def parse_scalar(value: str) -> Any:
29
39
  lowered = value.lower()
30
40
  if lowered == "true":
@@ -0,0 +1,240 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from ..client import tcp_open
10
+ from ..config_store import format_mac, resolve_ip, resolve_mac
11
+ from ..crypto import decrypt_payload, derive_crypto, encrypt_payload
12
+ from ..errors import FHToolError
13
+ from ..fh_endpoints import (
14
+ DEFAULT_FH_TOOL_PORT,
15
+ FALLBACK_FH_TOOL_PORTS,
16
+ PORT_PROBE_TCP_TIMEOUT,
17
+ fh_tool_url,
18
+ )
19
+ from ..output import log_event
20
+
21
+ FH_TOOL_API_PATH = "/fh_tool/api"
22
+ FH_TOOL_UPLOAD_PATH = "/fh_tool/upload"
23
+ TOOL_DOWNLOAD_PATH = "/fh_tool/tool_download"
24
+ SURFACE_PROBE_PATHS = (FH_TOOL_API_PATH, FH_TOOL_UPLOAD_PATH, TOOL_DOWNLOAD_PATH)
25
+
26
+
27
+ def fh_tool_call(
28
+ ip: str,
29
+ mac: str,
30
+ payload: dict[str, Any],
31
+ timeout: float,
32
+ *,
33
+ port: int = DEFAULT_FH_TOOL_PORT,
34
+ ) -> dict[str, Any]:
35
+ crypto = derive_crypto(mac)
36
+ encrypted = encrypt_payload(payload, crypto)
37
+ url = fh_tool_url(ip, port, FH_TOOL_API_PATH)
38
+ func = str(payload.get("func", "unknown"))
39
+ log_event(logging.INFO, "fh_tool.api.request", ip=ip, port=port, path=FH_TOOL_API_PATH, func=func)
40
+ try:
41
+ response = requests.post(
42
+ url,
43
+ data=encrypted,
44
+ headers={
45
+ "Content-Type": "text/plain",
46
+ "Connection": "close",
47
+ },
48
+ timeout=timeout,
49
+ allow_redirects=False,
50
+ )
51
+ except requests.RequestException as exc:
52
+ log_event(logging.INFO, "fh_tool.api.error", ip=ip, path=FH_TOOL_API_PATH, func=func, error=str(exc))
53
+ raise FHToolError(f"无法连接 {url}: {exc}") from exc
54
+
55
+ if response.status_code != 200:
56
+ log_event(
57
+ logging.INFO,
58
+ "fh_tool.api.http_error",
59
+ ip=ip,
60
+ path=FH_TOOL_API_PATH,
61
+ func=func,
62
+ status_code=response.status_code,
63
+ )
64
+ raise FHToolError(f"{FH_TOOL_API_PATH} 返回 HTTP {response.status_code}")
65
+
66
+ try:
67
+ result = decrypt_payload(response.text, crypto)
68
+ except Exception as exc:
69
+ log_event(logging.INFO, "fh_tool.api.decrypt_error", ip=ip, path=FH_TOOL_API_PATH, func=func)
70
+ raise FHToolError("响应解密失败,MAC 可能不匹配或固件协议不同") from exc
71
+ log_event(
72
+ logging.INFO,
73
+ "fh_tool.api.response",
74
+ ip=ip,
75
+ path=FH_TOOL_API_PATH,
76
+ func=func,
77
+ status_code=response.status_code,
78
+ result=result.get("result") if isinstance(result, dict) else None,
79
+ )
80
+ return result
81
+
82
+
83
+ def api_payload(func: str, params: dict[str, Any] | None = None, index: str = "1") -> dict[str, Any]:
84
+ payload: dict[str, Any] = {"index": str(index), "func": func}
85
+ if params:
86
+ payload.update(params)
87
+ return payload
88
+
89
+
90
+ def verify_fh_tool_port(ip: str, port: int, mac: str, timeout: float) -> bool:
91
+ try:
92
+ result = fh_tool_call(ip, mac, api_payload("GetDevInfo"), timeout, port=port)
93
+ except FHToolError:
94
+ return False
95
+ return result.get("result") == 0
96
+
97
+
98
+ def _surface_reachable(ip: str, port: int, timeout: float) -> bool:
99
+ url = fh_tool_url(ip, port, FH_TOOL_API_PATH)
100
+ try:
101
+ response = requests.get(url, timeout=timeout, allow_redirects=False)
102
+ except requests.RequestException:
103
+ return False
104
+ log_event(logging.DEBUG, "fh_tool.port.surface", ip=ip, port=port, status_code=response.status_code)
105
+ return True
106
+
107
+
108
+ def resolve_fh_port(
109
+ args: argparse.Namespace,
110
+ ip: str,
111
+ *,
112
+ mac: str | None = None,
113
+ timeout: float,
114
+ candidates: tuple[int, ...] = FALLBACK_FH_TOOL_PORTS,
115
+ ) -> tuple[int, str]:
116
+ """解析 fh_tool 后端端口:显式 --fh-port > 8080 TCP 快筛 > 候选端口探测 > 回退默认。
117
+
118
+ 仅当 8080 TCP 不可达时才触发候选端口探测;HTTP 4xx/5xx、解密失败不触发切换。
119
+ """
120
+ explicit = getattr(args, "fh_port", None)
121
+ if explicit is not None:
122
+ log_event(logging.DEBUG, "fh_tool.port.explicit", ip=ip, port=explicit)
123
+ return explicit, "argument"
124
+
125
+ tcp_timeout = min(timeout, PORT_PROBE_TCP_TIMEOUT)
126
+ if tcp_open(ip, DEFAULT_FH_TOOL_PORT, tcp_timeout):
127
+ log_event(logging.DEBUG, "fh_tool.port.default_ok", ip=ip, port=DEFAULT_FH_TOOL_PORT)
128
+ return DEFAULT_FH_TOOL_PORT, "default"
129
+
130
+ tried: list[int] = []
131
+ for port in candidates:
132
+ if port == DEFAULT_FH_TOOL_PORT:
133
+ continue
134
+ tried.append(port)
135
+ if not tcp_open(ip, port, tcp_timeout):
136
+ continue
137
+ verified_by = "surface"
138
+ if mac:
139
+ if verify_fh_tool_port(ip, port, mac, timeout):
140
+ verified_by = "getdevinfo"
141
+ elif not _surface_reachable(ip, port, timeout):
142
+ continue
143
+ log_event(
144
+ logging.INFO,
145
+ "fh_tool.port.fallback_selected",
146
+ ip=ip,
147
+ port=port,
148
+ default_port=DEFAULT_FH_TOOL_PORT,
149
+ verified_by=verified_by,
150
+ )
151
+ return port, "auto_detected"
152
+
153
+ log_event(
154
+ logging.INFO,
155
+ "fh_tool.port.fallback_failed",
156
+ ip=ip,
157
+ tried=tried,
158
+ default_port=DEFAULT_FH_TOOL_PORT,
159
+ )
160
+ return DEFAULT_FH_TOOL_PORT, "default_unverified"
161
+
162
+
163
+ def _unverified_port_hint(exc: FHToolError) -> FHToolError:
164
+ candidates = ", ".join(str(port) for port in FALLBACK_FH_TOOL_PORTS)
165
+ return FHToolError(
166
+ f"{exc}\n"
167
+ f"(提示:已自动探测候选端口 {candidates},未发现 fh_tool 服务。"
168
+ "可运行 fh-tool probe 查看各端口/路径状态,或用 --fh-port 显式指定端口;"
169
+ "部分型号/固件(如 HG6142A3 V03.00.M0000)的 fh_tool API 在 80 端口)"
170
+ )
171
+
172
+
173
+ def probe_fh_port_candidates(
174
+ ip: str,
175
+ mac: str | None,
176
+ timeout: float,
177
+ ports: tuple[int, ...] | None = None,
178
+ ) -> dict[str, dict[str, Any]]:
179
+ """对候选端口逐个报告 tcp/surface/getdevinfo 状态,供 probe 命令诊断端点。"""
180
+ if ports is None:
181
+ ports = tuple(dict.fromkeys([DEFAULT_FH_TOOL_PORT, *FALLBACK_FH_TOOL_PORTS]))
182
+ tcp_timeout = min(timeout, PORT_PROBE_TCP_TIMEOUT)
183
+ report: dict[str, dict[str, Any]] = {}
184
+ for port in ports:
185
+ entry: dict[str, Any] = {
186
+ "tcp": tcp_open(ip, port, tcp_timeout),
187
+ "surface": {},
188
+ "getdevinfo": None,
189
+ }
190
+ for path in SURFACE_PROBE_PATHS:
191
+ url = fh_tool_url(ip, port, path)
192
+ try:
193
+ response = requests.get(url, timeout=timeout, allow_redirects=False)
194
+ entry["surface"][path] = {
195
+ "status_code": response.status_code,
196
+ "content_type": response.headers.get("Content-Type"),
197
+ }
198
+ except requests.RequestException as exc:
199
+ entry["surface"][path] = {"error": str(exc)}
200
+ if mac and entry["tcp"]:
201
+ try:
202
+ dev_info = fh_tool_call(ip, mac, api_payload("GetDevInfo"), timeout, port=port)
203
+ entry["getdevinfo"] = {
204
+ "ok": dev_info.get("result") == 0,
205
+ "response": dev_info,
206
+ }
207
+ except FHToolError as exc:
208
+ entry["getdevinfo"] = {"ok": False, "error": str(exc)}
209
+ report[str(port)] = entry
210
+ return report
211
+
212
+
213
+ def call_method(args: argparse.Namespace, func: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
214
+ ip, ip_source = resolve_ip(args)
215
+ mac, mac_source = resolve_mac(args, ip, required=True)
216
+ assert mac is not None
217
+ port, port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
218
+ try:
219
+ response = fh_tool_call(ip, mac, api_payload(func, params), args.timeout, port=port)
220
+ except FHToolError as exc:
221
+ if port_source == "default_unverified":
222
+ raise _unverified_port_hint(exc) from exc
223
+ raise
224
+ return {
225
+ "ip": ip,
226
+ "ip_source": ip_source,
227
+ "mac": format_mac(mac),
228
+ "mac_source": mac_source,
229
+ "fh_port": port,
230
+ "fh_port_source": port_source,
231
+ "request": api_payload(func, params),
232
+ "response": response,
233
+ }
234
+
235
+
236
+ def response_download_url(response: dict[str, Any]) -> str:
237
+ url = response.get("Dowloadurl") or response.get("Downloadurl") or response.get("downloadurl")
238
+ if not isinstance(url, str) or not url:
239
+ raise FHToolError(f"响应中没有 Dowloadurl: {response}")
240
+ return url
@@ -21,9 +21,13 @@ from .argparse_utils import parse_json_object, parse_kv, parse_ports
21
21
  from .backends.fh_tool import (
22
22
  FH_TOOL_API_PATH,
23
23
  FH_TOOL_UPLOAD_PATH,
24
+ TOOL_DOWNLOAD_PATH,
24
25
  api_payload,
25
26
  call_method,
26
27
  fh_tool_call,
28
+ fh_tool_url,
29
+ probe_fh_port_candidates,
30
+ resolve_fh_port,
27
31
  response_download_url,
28
32
  )
29
33
  from .backup import (
@@ -101,6 +105,9 @@ from .upload import (
101
105
  upload_workflow_plan,
102
106
  )
103
107
  from .output import emit
108
+ from .vm_build import build_vm
109
+ from .vm_collect import collect_vm_dumps
110
+ from .vm_verify import verify_vm
104
111
 
105
112
 
106
113
  SENSITIVE_PLAN_KEY_RE = re.compile(
@@ -181,6 +188,7 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
181
188
  ip, ip_source = resolve_ip(args)
182
189
  mac, mac_source = resolve_mac(args, ip, required=False, allow_prompt=False)
183
190
  ports = parse_ports(args.ports)
191
+ fh_port, fh_port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
184
192
 
185
193
  result: dict[str, Any] = {
186
194
  "mode": "probe",
@@ -188,13 +196,16 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
188
196
  "ip_source": ip_source,
189
197
  "mac": format_mac(mac) if mac else None,
190
198
  "mac_source": mac_source,
199
+ "fh_port": fh_port,
200
+ "fh_port_source": fh_port_source,
191
201
  "tcp": {str(port): tcp_open(ip, port, args.timeout) for port in ports},
192
202
  "surface": {},
193
203
  "fh_tool_probe": None,
204
+ "fh_ports": probe_fh_port_candidates(ip, mac, args.timeout),
194
205
  }
195
206
 
196
- for path in [FH_TOOL_API_PATH, FH_TOOL_UPLOAD_PATH, "/fh_tool/tool_download"]:
197
- url = f"http://{ip}:8080{path}"
207
+ for path in [FH_TOOL_API_PATH, FH_TOOL_UPLOAD_PATH, TOOL_DOWNLOAD_PATH]:
208
+ url = fh_tool_url(ip, fh_port, path)
198
209
  try:
199
210
  response = requests.get(url, timeout=args.timeout, allow_redirects=False)
200
211
  result["surface"][path] = {
@@ -211,6 +222,7 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
211
222
  mac,
212
223
  api_payload("GetDevInfo"),
213
224
  args.timeout,
225
+ port=fh_port,
214
226
  )
215
227
  result["fh_tool_probe"] = {
216
228
  "func": "GetDevInfo",
@@ -294,6 +306,42 @@ def command_backup_verify(args: argparse.Namespace) -> dict[str, Any]:
294
306
  return verify_backup(Path(args.path).expanduser())
295
307
 
296
308
 
309
+ def command_vm_collect(args: argparse.Namespace) -> dict[str, Any]:
310
+ confirmed = is_confirmed(args)
311
+ result_prefix: dict[str, Any] = {}
312
+ if confirmed and args.auto_enable_telnet:
313
+ result_prefix["auto_enable_telnet"] = call_method(args, "TelnetEnable", {"telnet": "1"})
314
+ result = collect_vm_dumps(
315
+ shell_runner=_telnet_root_shell_runner_from_args(args) if confirmed else (lambda _command: ""),
316
+ output_dir=Path(args.output).expanduser(),
317
+ confirmed=confirmed,
318
+ all_mtd=args.all_mtd,
319
+ requested_partitions=args.partition,
320
+ chunk_size=args.chunk_size,
321
+ retries=args.retries,
322
+ )
323
+ if result_prefix:
324
+ result.update(result_prefix)
325
+ return result
326
+
327
+
328
+ def command_vm_build(args: argparse.Namespace) -> dict[str, Any]:
329
+ return build_vm(
330
+ dump_dir=Path(args.dump_dir).expanduser(),
331
+ output_dir=Path(args.output).expanduser(),
332
+ rootfs_slot=args.rootfs_slot,
333
+ force=args.force,
334
+ )
335
+
336
+
337
+ def command_vm_verify(args: argparse.Namespace) -> dict[str, Any]:
338
+ return verify_vm(
339
+ vm_root=Path(args.vm_root).expanduser(),
340
+ with_fhapi=args.with_fhapi,
341
+ with_http=args.with_http,
342
+ )
343
+
344
+
297
345
  def command_restore_backup(args: argparse.Namespace) -> dict[str, Any]:
298
346
  dry_run = not is_confirmed(args)
299
347
  if args.target == "device":
@@ -771,6 +819,7 @@ def command_log_download(args: argparse.Namespace) -> dict[str, Any]:
771
819
  url_value,
772
820
  Path(args.output).expanduser(),
773
821
  args.timeout,
822
+ port=result["fh_port"],
774
823
  )
775
824
  return result
776
825
 
@@ -812,7 +861,7 @@ def command_download_file(args: argparse.Namespace) -> dict[str, Any]:
812
861
  output = Path(args.output).expanduser()
813
862
  else:
814
863
  output = Path.cwd() / Path(url_value.split("?", 1)[0]).name
815
- result["download"] = download_to_file(result["ip"], url_value, output, args.timeout)
864
+ result["download"] = download_to_file(result["ip"], url_value, output, args.timeout, port=result["fh_port"])
816
865
  return result
817
866
 
818
867
 
@@ -944,14 +993,19 @@ def command_raw_call(args: argparse.Namespace) -> dict[str, Any]:
944
993
 
945
994
  def command_download_url(args: argparse.Namespace) -> dict[str, Any]:
946
995
  ip, ip_source = resolve_ip(args)
996
+ mac, _mac_source = resolve_mac(args, ip, required=False, allow_prompt=False)
997
+ fh_port, fh_port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
947
998
  return {
948
999
  "ip": ip,
949
1000
  "ip_source": ip_source,
1001
+ "fh_port": fh_port,
1002
+ "fh_port_source": fh_port_source,
950
1003
  "download": download_to_file(
951
1004
  ip,
952
1005
  args.url,
953
1006
  Path(args.output).expanduser(),
954
1007
  args.timeout,
1008
+ port=fh_port,
955
1009
  ),
956
1010
  }
957
1011
 
@@ -959,6 +1013,8 @@ def command_download_url(args: argparse.Namespace) -> dict[str, Any]:
959
1013
  def command_upload(args: argparse.Namespace) -> dict[str, Any]:
960
1014
  dry_run = not is_confirmed(args)
961
1015
  ip, ip_source = resolve_ip(args)
1016
+ mac, _mac_source = resolve_mac(args, ip, required=False, allow_prompt=False)
1017
+ fh_port, fh_port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
962
1018
  file_path = Path(args.file).expanduser()
963
1019
  result = upload_file(
964
1020
  ip=ip,
@@ -966,9 +1022,12 @@ def command_upload(args: argparse.Namespace) -> dict[str, Any]:
966
1022
  file_path=file_path,
967
1023
  sessionid=args.sessionid,
968
1024
  timeout=args.timeout,
1025
+ port=fh_port,
969
1026
  dry_run=dry_run,
970
1027
  )
971
1028
  result["ip_source"] = ip_source
1029
+ result["fh_port"] = fh_port
1030
+ result["fh_port_source"] = fh_port_source
972
1031
  if dry_run:
973
1032
  _attach_dry_run_notice(result)
974
1033
  return result
@@ -981,6 +1040,9 @@ def _command_handlers() -> dict[str, Any]:
981
1040
  "command_config_decrypt": command_config_decrypt,
982
1041
  "command_backup_create": command_backup_create,
983
1042
  "command_backup_verify": command_backup_verify,
1043
+ "command_vm_collect": command_vm_collect,
1044
+ "command_vm_build": command_vm_build,
1045
+ "command_vm_verify": command_vm_verify,
984
1046
  "command_restore_backup": command_restore_backup,
985
1047
  "command_credentials_derive": command_credentials_derive,
986
1048
  "command_cfg_get": command_cfg_get,
@@ -257,6 +257,14 @@ def _parameter_from_action(action: argparse.Action) -> click.Parameter | None:
257
257
 
258
258
 
259
259
  def _option_from_action(action: argparse.Action) -> click.Option:
260
+ option_strings = list(action.option_strings)
261
+ inferred_names = {
262
+ option.lstrip("-").replace("-", "_")
263
+ for option in option_strings
264
+ if option.startswith("--")
265
+ }
266
+ if action.dest not in inferred_names:
267
+ option_strings.append(action.dest)
260
268
  kwargs: dict[str, Any] = {
261
269
  "help": None if action.help is argparse.SUPPRESS else action.help,
262
270
  "hidden": action.help is argparse.SUPPRESS,
@@ -279,7 +287,7 @@ def _option_from_action(action: argparse.Action) -> click.Option:
279
287
  type=_type_from_action(action),
280
288
  )
281
289
 
282
- return click.Option(list(action.option_strings), **kwargs)
290
+ return click.Option(option_strings, **kwargs)
283
291
 
284
292
 
285
293
  def _argument_from_action(action: argparse.Action) -> click.Argument:
@@ -9,6 +9,7 @@ from urllib.parse import urljoin
9
9
  import requests
10
10
 
11
11
  from .errors import FHToolError
12
+ from .fh_endpoints import DEFAULT_FH_TOOL_PORT
12
13
  from .output import log_event
13
14
 
14
15
 
@@ -23,14 +24,21 @@ def tcp_open(ip: str, port: int, timeout: float) -> bool:
23
24
  return False
24
25
 
25
26
 
26
- def make_download_url(ip: str, value: str) -> str:
27
+ def make_download_url(ip: str, value: str, port: int = DEFAULT_FH_TOOL_PORT) -> str:
27
28
  if value.startswith("http://") or value.startswith("https://"):
28
29
  return value
29
- return urljoin(f"http://{ip}:8080", value)
30
+ return urljoin(f"http://{ip}:{port}", value)
30
31
 
31
32
 
32
- def download_to_file(ip: str, url_value: str, output: Path, timeout: float) -> dict[str, Any]:
33
- url = make_download_url(ip, url_value)
33
+ def download_to_file(
34
+ ip: str,
35
+ url_value: str,
36
+ output: Path,
37
+ timeout: float,
38
+ *,
39
+ port: int = DEFAULT_FH_TOOL_PORT,
40
+ ) -> dict[str, Any]:
41
+ url = make_download_url(ip, url_value, port)
34
42
  log_event(logging.INFO, "download.start", url=url, output=str(output))
35
43
  try:
36
44
  response = requests.get(url, timeout=timeout, stream=True)
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from ..account import WEB_ADMIN_PASSWORD_PATH
11
11
  from ..argparse_utils import parse_json_object, parse_kv
12
12
  from ..backends.cfg_cmd import CfgCmdBackend
13
- from ..backends.fh_tool import api_payload, fh_tool_call
13
+ from ..backends.fh_tool import api_payload, fh_tool_call, resolve_fh_port
14
14
  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
@@ -182,7 +182,8 @@ def _web_password_from_admin_account(args: argparse.Namespace) -> str:
182
182
  ip, _ip_source = resolve_ip(args)
183
183
  mac, _mac_source = resolve_mac(args, ip, required=True, allow_prompt=True)
184
184
  assert mac is not None
185
- result = fh_tool_call(ip, mac, api_payload("GetAdminAccount"), args.timeout)
185
+ fh_port, _fh_port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
186
+ result = fh_tool_call(ip, mac, api_payload("GetAdminAccount"), args.timeout, port=fh_port)
186
187
  password = _extract_password_from_mapping(result)
187
188
  if not password:
188
189
  raise FHToolError("GetAdminAccount 响应中没有可用 password 字段")
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ # fh_tool 后端端点常量与 URL 构造。
4
+ # 独立成模块以避免 client.py 与 backends.fh_tool 之间的循环导入:
5
+ # backends.fh_tool 导入 client.tcp_open,而 client.py 需要这里的默认端口。
6
+
7
+ DEFAULT_FH_TOOL_PORT = 8080
8
+ # 8080 TCP 不可达时依次尝试的候选端口;HG6142A3 V03.00.M0000 实测 fh_tool API 在 80 端口。
9
+ FALLBACK_FH_TOOL_PORTS = (80,)
10
+ # TCP 探测超时上限(秒),控制候选端口全部不可达时的最坏延迟。
11
+ PORT_PROBE_TCP_TIMEOUT = 2.0
12
+
13
+
14
+ def fh_tool_base_url(ip: str, port: int) -> str:
15
+ return f"http://{ip}:{port}"
16
+
17
+
18
+ def fh_tool_url(ip: str, port: int, path: str) -> str:
19
+ return f"{fh_tool_base_url(ip, port)}{path}"