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.
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/PKG-INFO +38 -2
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/README.md +37 -1
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/pyproject.toml +1 -1
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/__init__.py +1 -1
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/argparse_utils.py +10 -0
- fh_tool_cli-0.2.5/src/fh_tool_cli/backends/fh_tool.py +240 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/cli.py +65 -3
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/click_cli.py +9 -1
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/client.py +12 -4
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/web.py +3 -2
- fh_tool_cli-0.2.5/src/fh_tool_cli/fh_endpoints.py +19 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/parser.py +43 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/upload.py +3 -1
- fh_tool_cli-0.2.5/src/fh_tool_cli/vm_build.py +458 -0
- fh_tool_cli-0.2.5/src/fh_tool_cli/vm_collect.py +330 -0
- fh_tool_cli-0.2.5/src/fh_tool_cli/vm_common.py +143 -0
- fh_tool_cli-0.2.5/src/fh_tool_cli/vm_verify.py +273 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/PKG-INFO +38 -2
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/SOURCES.txt +9 -0
- fh_tool_cli-0.2.5/tests/test_fh_port.py +273 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_parser_registration.py +3 -0
- fh_tool_cli-0.2.5/tests/test_vm_build.py +126 -0
- fh_tool_cli-0.2.5/tests/test_vm_collect.py +115 -0
- fh_tool_cli-0.2.5/tests/test_vm_verify.py +73 -0
- fh_tool_cli-0.2.3/src/fh_tool_cli/backends/fh_tool.py +0 -98
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/setup.cfg +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/account.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/__init__.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/local_vm.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/telnet.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/web_ajax.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backup.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/__init__.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_decrypt.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_store.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credential_sources.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credentials.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/crypto.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/diagnostics.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/errors.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/output.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/remote.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/risk.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_discovery.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_writes.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/requires.txt +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_account.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_backup.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_cli_risk.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_click_cli.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_config_decrypt.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_credentials.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_diagnostics.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_extracted_helpers.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_local_vm_integration.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_remote.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_telnet_backend.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_upload.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_ajax.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_ajax_post.py +0 -0
- {fh_tool_cli-0.2.3 → fh_tool_cli-0.2.5}/tests/test_web_discovery.py +0 -0
- {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
|
+
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:
|
|
@@ -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,
|
|
197
|
-
url =
|
|
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(
|
|
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}:
|
|
30
|
+
return urljoin(f"http://{ip}:{port}", value)
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def download_to_file(
|
|
33
|
-
|
|
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
|
-
|
|
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}"
|