fh_tool-cli 0.2.4__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.4 → fh_tool_cli-0.2.5}/PKG-INFO +26 -6
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/README.md +25 -5
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/pyproject.toml +1 -1
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/__init__.py +1 -1
- {fh_tool_cli-0.2.4 → 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.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/cli.py +23 -3
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/client.py +12 -4
- {fh_tool_cli-0.2.4 → 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.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/parser.py +7 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/upload.py +3 -1
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/PKG-INFO +26 -6
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/SOURCES.txt +2 -0
- fh_tool_cli-0.2.5/tests/test_fh_port.py +273 -0
- fh_tool_cli-0.2.4/src/fh_tool_cli/backends/fh_tool.py +0 -98
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/setup.cfg +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/account.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/__init__.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/local_vm.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/telnet.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/web_ajax.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backup.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/click_cli.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/__init__.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_decrypt.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_store.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credential_sources.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credentials.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/crypto.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/diagnostics.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/errors.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/output.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/remote.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/risk.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_build.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_collect.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_common.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_verify.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_discovery.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_writes.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/requires.txt +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_account.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_backup.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_cfg_cmd.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_cli_risk.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_click_cli.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_config_decrypt.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_credentials.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_diagnostics.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_extracted_helpers.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_local_vm_integration.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_parser_registration.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_remote.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_telnet_backend.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_upload.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_build.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_collect.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_verify.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_ajax.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_ajax_post.py +0 -0
- {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_discovery.py +0 -0
- {fh_tool_cli-0.2.4 → 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,不需要碰真实网关:
|
|
@@ -358,7 +382,3 @@ fh-tool telnet disable --confirm
|
|
|
358
382
|
## License
|
|
359
383
|
|
|
360
384
|
AGPL-3.0-or-later
|
|
361
|
-
|
|
362
|
-
## 其它
|
|
363
|
-
|
|
364
|
-
[认可 LINUX DO](https://linux.do)
|
|
@@ -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,不需要碰真实网关:
|
|
@@ -342,7 +366,3 @@ fh-tool telnet disable --confirm
|
|
|
342
366
|
## License
|
|
343
367
|
|
|
344
368
|
AGPL-3.0-or-later
|
|
345
|
-
|
|
346
|
-
## 其它
|
|
347
|
-
|
|
348
|
-
[认可 LINUX DO](https://linux.do)
|
|
@@ -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 (
|
|
@@ -184,6 +188,7 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
184
188
|
ip, ip_source = resolve_ip(args)
|
|
185
189
|
mac, mac_source = resolve_mac(args, ip, required=False, allow_prompt=False)
|
|
186
190
|
ports = parse_ports(args.ports)
|
|
191
|
+
fh_port, fh_port_source = resolve_fh_port(args, ip, mac=mac, timeout=args.timeout)
|
|
187
192
|
|
|
188
193
|
result: dict[str, Any] = {
|
|
189
194
|
"mode": "probe",
|
|
@@ -191,13 +196,16 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
191
196
|
"ip_source": ip_source,
|
|
192
197
|
"mac": format_mac(mac) if mac else None,
|
|
193
198
|
"mac_source": mac_source,
|
|
199
|
+
"fh_port": fh_port,
|
|
200
|
+
"fh_port_source": fh_port_source,
|
|
194
201
|
"tcp": {str(port): tcp_open(ip, port, args.timeout) for port in ports},
|
|
195
202
|
"surface": {},
|
|
196
203
|
"fh_tool_probe": None,
|
|
204
|
+
"fh_ports": probe_fh_port_candidates(ip, mac, args.timeout),
|
|
197
205
|
}
|
|
198
206
|
|
|
199
|
-
for path in [FH_TOOL_API_PATH, FH_TOOL_UPLOAD_PATH,
|
|
200
|
-
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)
|
|
201
209
|
try:
|
|
202
210
|
response = requests.get(url, timeout=args.timeout, allow_redirects=False)
|
|
203
211
|
result["surface"][path] = {
|
|
@@ -214,6 +222,7 @@ def command_probe(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
214
222
|
mac,
|
|
215
223
|
api_payload("GetDevInfo"),
|
|
216
224
|
args.timeout,
|
|
225
|
+
port=fh_port,
|
|
217
226
|
)
|
|
218
227
|
result["fh_tool_probe"] = {
|
|
219
228
|
"func": "GetDevInfo",
|
|
@@ -810,6 +819,7 @@ def command_log_download(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
810
819
|
url_value,
|
|
811
820
|
Path(args.output).expanduser(),
|
|
812
821
|
args.timeout,
|
|
822
|
+
port=result["fh_port"],
|
|
813
823
|
)
|
|
814
824
|
return result
|
|
815
825
|
|
|
@@ -851,7 +861,7 @@ def command_download_file(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
851
861
|
output = Path(args.output).expanduser()
|
|
852
862
|
else:
|
|
853
863
|
output = Path.cwd() / Path(url_value.split("?", 1)[0]).name
|
|
854
|
-
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"])
|
|
855
865
|
return result
|
|
856
866
|
|
|
857
867
|
|
|
@@ -983,14 +993,19 @@ def command_raw_call(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
983
993
|
|
|
984
994
|
def command_download_url(args: argparse.Namespace) -> dict[str, Any]:
|
|
985
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)
|
|
986
998
|
return {
|
|
987
999
|
"ip": ip,
|
|
988
1000
|
"ip_source": ip_source,
|
|
1001
|
+
"fh_port": fh_port,
|
|
1002
|
+
"fh_port_source": fh_port_source,
|
|
989
1003
|
"download": download_to_file(
|
|
990
1004
|
ip,
|
|
991
1005
|
args.url,
|
|
992
1006
|
Path(args.output).expanduser(),
|
|
993
1007
|
args.timeout,
|
|
1008
|
+
port=fh_port,
|
|
994
1009
|
),
|
|
995
1010
|
}
|
|
996
1011
|
|
|
@@ -998,6 +1013,8 @@ def command_download_url(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
998
1013
|
def command_upload(args: argparse.Namespace) -> dict[str, Any]:
|
|
999
1014
|
dry_run = not is_confirmed(args)
|
|
1000
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)
|
|
1001
1018
|
file_path = Path(args.file).expanduser()
|
|
1002
1019
|
result = upload_file(
|
|
1003
1020
|
ip=ip,
|
|
@@ -1005,9 +1022,12 @@ def command_upload(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1005
1022
|
file_path=file_path,
|
|
1006
1023
|
sessionid=args.sessionid,
|
|
1007
1024
|
timeout=args.timeout,
|
|
1025
|
+
port=fh_port,
|
|
1008
1026
|
dry_run=dry_run,
|
|
1009
1027
|
)
|
|
1010
1028
|
result["ip_source"] = ip_source
|
|
1029
|
+
result["fh_port"] = fh_port
|
|
1030
|
+
result["fh_port_source"] = fh_port_source
|
|
1011
1031
|
if dry_run:
|
|
1012
1032
|
_attach_dry_run_notice(result)
|
|
1013
1033
|
return result
|
|
@@ -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}"
|
|
@@ -6,6 +6,7 @@ from collections.abc import Mapping
|
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
9
|
+
from .argparse_utils import normalize_port
|
|
9
10
|
from .backends.local_vm import DEFAULT_VM_ROOT
|
|
10
11
|
from .backup import DEFAULT_DEVICE_RESTORE_CHUNK_SIZE, DEFAULT_DEVICE_RESTORE_TMPDIR
|
|
11
12
|
from .commands.web import register_web_commands
|
|
@@ -34,6 +35,12 @@ def add_common_options(parser: argparse.ArgumentParser) -> None:
|
|
|
34
35
|
default=5.0,
|
|
35
36
|
help="HTTP/TCP timeout 秒数,默认 5",
|
|
36
37
|
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--fh-port",
|
|
40
|
+
type=normalize_port,
|
|
41
|
+
default=None,
|
|
42
|
+
help="fh_tool API 端口;默认先 8080,TCP 不可达时自动探测候选端口(如 80)。显式指定后跳过探测",
|
|
43
|
+
)
|
|
37
44
|
parser.add_argument(
|
|
38
45
|
"--config",
|
|
39
46
|
default=str(DEFAULT_CONFIG_PATH),
|
|
@@ -10,6 +10,7 @@ import requests
|
|
|
10
10
|
|
|
11
11
|
from .backends.fh_tool import FH_TOOL_UPLOAD_PATH
|
|
12
12
|
from .errors import CliError
|
|
13
|
+
from .fh_endpoints import DEFAULT_FH_TOOL_PORT
|
|
13
14
|
|
|
14
15
|
UPLOAD_ACTIONS = {"upgradeimage", "preconfig"}
|
|
15
16
|
REDACTED = "[REDACTED]"
|
|
@@ -37,11 +38,12 @@ def upload_file(
|
|
|
37
38
|
file_path: Path,
|
|
38
39
|
sessionid: str,
|
|
39
40
|
timeout: float,
|
|
41
|
+
port: int = DEFAULT_FH_TOOL_PORT,
|
|
40
42
|
dry_run: bool = False,
|
|
41
43
|
post: Callable[..., Any] = requests.post,
|
|
42
44
|
) -> dict[str, Any]:
|
|
43
45
|
file_info = _validate_upload_inputs(action, file_path, sessionid)
|
|
44
|
-
url = f"http://{ip}:
|
|
46
|
+
url = f"http://{ip}:{port}{FH_TOOL_UPLOAD_PATH}?action={action}"
|
|
45
47
|
base = _upload_result_base(
|
|
46
48
|
ip=ip,
|
|
47
49
|
url=url,
|
|
@@ -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,不需要碰真实网关:
|
|
@@ -358,7 +382,3 @@ fh-tool telnet disable --confirm
|
|
|
358
382
|
## License
|
|
359
383
|
|
|
360
384
|
AGPL-3.0-or-later
|
|
361
|
-
|
|
362
|
-
## 其它
|
|
363
|
-
|
|
364
|
-
[认可 LINUX DO](https://linux.do)
|
|
@@ -14,6 +14,7 @@ src/fh_tool_cli/credentials.py
|
|
|
14
14
|
src/fh_tool_cli/crypto.py
|
|
15
15
|
src/fh_tool_cli/diagnostics.py
|
|
16
16
|
src/fh_tool_cli/errors.py
|
|
17
|
+
src/fh_tool_cli/fh_endpoints.py
|
|
17
18
|
src/fh_tool_cli/output.py
|
|
18
19
|
src/fh_tool_cli/parser.py
|
|
19
20
|
src/fh_tool_cli/remote.py
|
|
@@ -48,6 +49,7 @@ tests/test_config_decrypt.py
|
|
|
48
49
|
tests/test_credentials.py
|
|
49
50
|
tests/test_diagnostics.py
|
|
50
51
|
tests/test_extracted_helpers.py
|
|
52
|
+
tests/test_fh_port.py
|
|
51
53
|
tests/test_local_vm_integration.py
|
|
52
54
|
tests/test_parser_registration.py
|
|
53
55
|
tests/test_remote.py
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import unittest
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from fh_tool_cli import cli
|
|
13
|
+
from fh_tool_cli.argparse_utils import normalize_port
|
|
14
|
+
from fh_tool_cli.backends.fh_tool import (
|
|
15
|
+
api_payload,
|
|
16
|
+
call_method,
|
|
17
|
+
fh_tool_call,
|
|
18
|
+
probe_fh_port_candidates,
|
|
19
|
+
resolve_fh_port,
|
|
20
|
+
)
|
|
21
|
+
from fh_tool_cli.client import make_download_url
|
|
22
|
+
from fh_tool_cli.crypto import derive_crypto, encrypt_payload
|
|
23
|
+
from fh_tool_cli.errors import FHToolError
|
|
24
|
+
|
|
25
|
+
TEST_IP = "192.168.1.1"
|
|
26
|
+
TEST_MAC = "AABBCCDDEEFF"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _FakeResponse:
|
|
30
|
+
def __init__(self, status_code: int = 200, text: str = "", headers: dict[str, str] | None = None):
|
|
31
|
+
self.status_code = status_code
|
|
32
|
+
self.text = text
|
|
33
|
+
self.headers = headers or {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _encrypted_ok_response(result: int = 0) -> _FakeResponse:
|
|
37
|
+
crypto = derive_crypto(TEST_MAC)
|
|
38
|
+
return _FakeResponse(status_code=200, text=encrypt_payload({"result": result}, crypto))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _args(**overrides) -> argparse.Namespace:
|
|
42
|
+
values: dict = {
|
|
43
|
+
"ip": TEST_IP,
|
|
44
|
+
"mac": TEST_MAC,
|
|
45
|
+
"timeout": 1.0,
|
|
46
|
+
"fh_port": None,
|
|
47
|
+
"config": "/nonexistent/fh-tool-test.json",
|
|
48
|
+
"ask_mac": False,
|
|
49
|
+
"json": True,
|
|
50
|
+
}
|
|
51
|
+
values.update(overrides)
|
|
52
|
+
return argparse.Namespace(**values)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NormalizePortTests(unittest.TestCase):
|
|
56
|
+
def test_accepts_valid_port(self) -> None:
|
|
57
|
+
self.assertEqual(normalize_port("80"), 80)
|
|
58
|
+
self.assertEqual(normalize_port("65535"), 65535)
|
|
59
|
+
|
|
60
|
+
def test_rejects_zero(self) -> None:
|
|
61
|
+
with self.assertRaises(argparse.ArgumentTypeError):
|
|
62
|
+
normalize_port("0")
|
|
63
|
+
|
|
64
|
+
def test_rejects_out_of_range(self) -> None:
|
|
65
|
+
with self.assertRaises(argparse.ArgumentTypeError):
|
|
66
|
+
normalize_port("70000")
|
|
67
|
+
|
|
68
|
+
def test_rejects_non_numeric(self) -> None:
|
|
69
|
+
with self.assertRaises(argparse.ArgumentTypeError):
|
|
70
|
+
normalize_port("abc")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ResolveFhPortTests(unittest.TestCase):
|
|
74
|
+
def test_explicit_argument_skips_probing(self) -> None:
|
|
75
|
+
with patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=AssertionError("should not probe")):
|
|
76
|
+
self.assertEqual(
|
|
77
|
+
resolve_fh_port(_args(fh_port=80), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
78
|
+
(80, "argument"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def test_default_8080_when_tcp_open(self) -> None:
|
|
82
|
+
with patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=True) as tcp_mock:
|
|
83
|
+
self.assertEqual(
|
|
84
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
85
|
+
(8080, "default"),
|
|
86
|
+
)
|
|
87
|
+
tcp_mock.assert_called_once_with(TEST_IP, 8080, 1.0)
|
|
88
|
+
|
|
89
|
+
def test_fallback_detected_by_getdevinfo(self) -> None:
|
|
90
|
+
with (
|
|
91
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=[False, True]),
|
|
92
|
+
patch("fh_tool_cli.backends.fh_tool.verify_fh_tool_port", return_value=True) as verify_mock,
|
|
93
|
+
):
|
|
94
|
+
self.assertEqual(
|
|
95
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
96
|
+
(80, "auto_detected"),
|
|
97
|
+
)
|
|
98
|
+
verify_mock.assert_called_once_with(TEST_IP, 80, TEST_MAC, 1.0)
|
|
99
|
+
|
|
100
|
+
def test_fallback_detected_by_surface_when_getdevinfo_fails(self) -> None:
|
|
101
|
+
with (
|
|
102
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=[False, True]),
|
|
103
|
+
patch("fh_tool_cli.backends.fh_tool.verify_fh_tool_port", return_value=False),
|
|
104
|
+
patch("fh_tool_cli.backends.fh_tool.requests.get", return_value=_FakeResponse(404)),
|
|
105
|
+
):
|
|
106
|
+
self.assertEqual(
|
|
107
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
108
|
+
(80, "auto_detected"),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def test_fallback_skips_candidate_when_surface_unreachable(self) -> None:
|
|
112
|
+
with (
|
|
113
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=[False, True]),
|
|
114
|
+
patch("fh_tool_cli.backends.fh_tool.verify_fh_tool_port", return_value=False),
|
|
115
|
+
patch(
|
|
116
|
+
"fh_tool_cli.backends.fh_tool.requests.get",
|
|
117
|
+
side_effect=requests.ConnectionError("refused"),
|
|
118
|
+
),
|
|
119
|
+
):
|
|
120
|
+
self.assertEqual(
|
|
121
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
122
|
+
(8080, "default_unverified"),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def test_all_closed_falls_back_to_default_unverified(self) -> None:
|
|
126
|
+
with patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=False):
|
|
127
|
+
self.assertEqual(
|
|
128
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0),
|
|
129
|
+
(8080, "default_unverified"),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def test_default_port_candidates_are_skipped(self) -> None:
|
|
133
|
+
with patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=False) as tcp_mock:
|
|
134
|
+
self.assertEqual(
|
|
135
|
+
resolve_fh_port(_args(), TEST_IP, mac=TEST_MAC, timeout=1.0, candidates=(8080,)),
|
|
136
|
+
(8080, "default_unverified"),
|
|
137
|
+
)
|
|
138
|
+
tcp_mock.assert_called_once_with(TEST_IP, 8080, 1.0)
|
|
139
|
+
|
|
140
|
+
def test_missing_fh_port_attr_is_tolerated(self) -> None:
|
|
141
|
+
with patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=True):
|
|
142
|
+
self.assertEqual(
|
|
143
|
+
resolve_fh_port(argparse.Namespace(ip=TEST_IP), TEST_IP, timeout=1.0),
|
|
144
|
+
(8080, "default"),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class FhToolCallUrlTests(unittest.TestCase):
|
|
149
|
+
def test_uses_explicit_port_in_url(self) -> None:
|
|
150
|
+
with patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_encrypted_ok_response()) as post_mock:
|
|
151
|
+
result = fh_tool_call(TEST_IP, TEST_MAC, api_payload("GetDevInfo"), 1.0, port=80)
|
|
152
|
+
self.assertEqual(result, {"result": 0})
|
|
153
|
+
self.assertEqual(post_mock.call_args.args[0], "http://192.168.1.1:80/fh_tool/api")
|
|
154
|
+
|
|
155
|
+
def test_default_port_is_8080(self) -> None:
|
|
156
|
+
with patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_encrypted_ok_response()) as post_mock:
|
|
157
|
+
fh_tool_call(TEST_IP, TEST_MAC, api_payload("GetDevInfo"), 1.0)
|
|
158
|
+
self.assertEqual(post_mock.call_args.args[0], "http://192.168.1.1:8080/fh_tool/api")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MakeDownloadUrlTests(unittest.TestCase):
|
|
162
|
+
def test_default_port(self) -> None:
|
|
163
|
+
self.assertEqual(make_download_url(TEST_IP, "/fh_tool/tool_download?a=b"), "http://192.168.1.1:8080/fh_tool/tool_download?a=b")
|
|
164
|
+
|
|
165
|
+
def test_explicit_port(self) -> None:
|
|
166
|
+
self.assertEqual(make_download_url(TEST_IP, "/fh_tool/tool_download", port=80), "http://192.168.1.1:80/fh_tool/tool_download")
|
|
167
|
+
|
|
168
|
+
def test_absolute_url_is_untouched(self) -> None:
|
|
169
|
+
self.assertEqual(make_download_url(TEST_IP, "http://example.com/file.bin", port=80), "http://example.com/file.bin")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class CallMethodTests(unittest.TestCase):
|
|
173
|
+
def test_result_contains_default_port_fields(self) -> None:
|
|
174
|
+
with (
|
|
175
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=True),
|
|
176
|
+
patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_encrypted_ok_response()),
|
|
177
|
+
):
|
|
178
|
+
result = call_method(_args(), "GetDevInfo")
|
|
179
|
+
self.assertEqual(result["fh_port"], 8080)
|
|
180
|
+
self.assertEqual(result["fh_port_source"], "default")
|
|
181
|
+
|
|
182
|
+
def test_result_uses_explicit_port_end_to_end(self) -> None:
|
|
183
|
+
with (
|
|
184
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=AssertionError("should not probe")),
|
|
185
|
+
patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_encrypted_ok_response()) as post_mock,
|
|
186
|
+
):
|
|
187
|
+
result = call_method(_args(fh_port=80), "GetDevInfo")
|
|
188
|
+
self.assertEqual(result["fh_port"], 80)
|
|
189
|
+
self.assertEqual(result["fh_port_source"], "argument")
|
|
190
|
+
self.assertEqual(post_mock.call_args.args[0], "http://192.168.1.1:80/fh_tool/api")
|
|
191
|
+
|
|
192
|
+
def test_unverified_port_appends_hint(self) -> None:
|
|
193
|
+
with (
|
|
194
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=False),
|
|
195
|
+
patch(
|
|
196
|
+
"fh_tool_cli.backends.fh_tool.requests.post",
|
|
197
|
+
side_effect=requests.ConnectionError("connection refused"),
|
|
198
|
+
),
|
|
199
|
+
):
|
|
200
|
+
with self.assertRaises(FHToolError) as ctx:
|
|
201
|
+
call_method(_args(), "GetDevInfo")
|
|
202
|
+
message = str(ctx.exception)
|
|
203
|
+
self.assertIn("--fh-port", message)
|
|
204
|
+
self.assertIn("fh-tool probe", message)
|
|
205
|
+
self.assertIn("HG6142A3", message)
|
|
206
|
+
|
|
207
|
+
def test_http_error_on_default_port_does_not_append_hint(self) -> None:
|
|
208
|
+
with (
|
|
209
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", return_value=True),
|
|
210
|
+
patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_FakeResponse(500)),
|
|
211
|
+
):
|
|
212
|
+
with self.assertRaises(FHToolError) as ctx:
|
|
213
|
+
call_method(_args(), "GetDevInfo")
|
|
214
|
+
self.assertNotIn("--fh-port", str(ctx.exception))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ProbeFhPortCandidatesTests(unittest.TestCase):
|
|
218
|
+
def test_reports_per_port_surface_without_mac(self) -> None:
|
|
219
|
+
with (
|
|
220
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=[False, True]),
|
|
221
|
+
patch("fh_tool_cli.backends.fh_tool.requests.get", return_value=_FakeResponse(404, headers={"Content-Type": "text/html"})),
|
|
222
|
+
):
|
|
223
|
+
report = probe_fh_port_candidates(TEST_IP, None, 1.0)
|
|
224
|
+
self.assertEqual(set(report), {"8080", "80"})
|
|
225
|
+
self.assertFalse(report["8080"]["tcp"])
|
|
226
|
+
self.assertTrue(report["80"]["tcp"])
|
|
227
|
+
self.assertEqual(report["80"]["surface"]["/fh_tool/api"]["status_code"], 404)
|
|
228
|
+
self.assertIsNone(report["80"]["getdevinfo"])
|
|
229
|
+
|
|
230
|
+
def test_getdevinfo_verified_with_mac(self) -> None:
|
|
231
|
+
with (
|
|
232
|
+
patch("fh_tool_cli.backends.fh_tool.tcp_open", side_effect=[False, True]),
|
|
233
|
+
patch("fh_tool_cli.backends.fh_tool.fh_tool_call", return_value={"result": 0}),
|
|
234
|
+
):
|
|
235
|
+
report = probe_fh_port_candidates(TEST_IP, TEST_MAC, 1.0)
|
|
236
|
+
self.assertTrue(report["80"]["getdevinfo"]["ok"])
|
|
237
|
+
self.assertTrue(report["8080"]["getdevinfo"] is None)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class CliEndToEndTests(unittest.TestCase):
|
|
241
|
+
def test_call_with_fh_port_80_sends_to_port_80(self) -> None:
|
|
242
|
+
stdout = io.StringIO()
|
|
243
|
+
stderr = io.StringIO()
|
|
244
|
+
with (
|
|
245
|
+
patch("fh_tool_cli.backends.fh_tool.requests.post", return_value=_encrypted_ok_response()) as post_mock,
|
|
246
|
+
contextlib.redirect_stdout(stdout),
|
|
247
|
+
contextlib.redirect_stderr(stderr),
|
|
248
|
+
):
|
|
249
|
+
exit_code = cli.main(
|
|
250
|
+
[
|
|
251
|
+
"call",
|
|
252
|
+
"--func",
|
|
253
|
+
"GetDevInfo",
|
|
254
|
+
"--fh-port",
|
|
255
|
+
"80",
|
|
256
|
+
"--ip",
|
|
257
|
+
TEST_IP,
|
|
258
|
+
"--mac",
|
|
259
|
+
TEST_MAC,
|
|
260
|
+
"--timeout",
|
|
261
|
+
"1",
|
|
262
|
+
"--json",
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
self.assertEqual(exit_code, 0)
|
|
266
|
+
payload = json.loads(stdout.getvalue())
|
|
267
|
+
self.assertEqual(payload["fh_port"], 80)
|
|
268
|
+
self.assertEqual(payload["fh_port_source"], "argument")
|
|
269
|
+
self.assertTrue(post_mock.call_args.args[0].startswith("http://192.168.1.1:80/"))
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
if __name__ == "__main__":
|
|
273
|
+
unittest.main()
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import argparse
|
|
4
|
-
import logging
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
import requests
|
|
8
|
-
|
|
9
|
-
from ..config_store import format_mac, resolve_ip, resolve_mac
|
|
10
|
-
from ..crypto import decrypt_payload, derive_crypto, encrypt_payload
|
|
11
|
-
from ..errors import FHToolError
|
|
12
|
-
from ..output import log_event
|
|
13
|
-
|
|
14
|
-
FH_TOOL_API_PATH = "/fh_tool/api"
|
|
15
|
-
FH_TOOL_UPLOAD_PATH = "/fh_tool/upload"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def fh_tool_call(
|
|
19
|
-
ip: str,
|
|
20
|
-
mac: str,
|
|
21
|
-
payload: dict[str, Any],
|
|
22
|
-
timeout: float,
|
|
23
|
-
) -> dict[str, Any]:
|
|
24
|
-
crypto = derive_crypto(mac)
|
|
25
|
-
encrypted = encrypt_payload(payload, crypto)
|
|
26
|
-
url = f"http://{ip}:8080{FH_TOOL_API_PATH}"
|
|
27
|
-
func = str(payload.get("func", "unknown"))
|
|
28
|
-
log_event(logging.INFO, "fh_tool.api.request", ip=ip, path=FH_TOOL_API_PATH, func=func)
|
|
29
|
-
try:
|
|
30
|
-
response = requests.post(
|
|
31
|
-
url,
|
|
32
|
-
data=encrypted,
|
|
33
|
-
headers={
|
|
34
|
-
"Content-Type": "text/plain",
|
|
35
|
-
"Connection": "close",
|
|
36
|
-
},
|
|
37
|
-
timeout=timeout,
|
|
38
|
-
allow_redirects=False,
|
|
39
|
-
)
|
|
40
|
-
except requests.RequestException as exc:
|
|
41
|
-
log_event(logging.INFO, "fh_tool.api.error", ip=ip, path=FH_TOOL_API_PATH, func=func, error=str(exc))
|
|
42
|
-
raise FHToolError(f"无法连接 {url}: {exc}") from exc
|
|
43
|
-
|
|
44
|
-
if response.status_code != 200:
|
|
45
|
-
log_event(
|
|
46
|
-
logging.INFO,
|
|
47
|
-
"fh_tool.api.http_error",
|
|
48
|
-
ip=ip,
|
|
49
|
-
path=FH_TOOL_API_PATH,
|
|
50
|
-
func=func,
|
|
51
|
-
status_code=response.status_code,
|
|
52
|
-
)
|
|
53
|
-
raise FHToolError(f"{FH_TOOL_API_PATH} 返回 HTTP {response.status_code}")
|
|
54
|
-
|
|
55
|
-
try:
|
|
56
|
-
result = decrypt_payload(response.text, crypto)
|
|
57
|
-
except Exception as exc:
|
|
58
|
-
log_event(logging.INFO, "fh_tool.api.decrypt_error", ip=ip, path=FH_TOOL_API_PATH, func=func)
|
|
59
|
-
raise FHToolError("响应解密失败,MAC 可能不匹配或固件协议不同") from exc
|
|
60
|
-
log_event(
|
|
61
|
-
logging.INFO,
|
|
62
|
-
"fh_tool.api.response",
|
|
63
|
-
ip=ip,
|
|
64
|
-
path=FH_TOOL_API_PATH,
|
|
65
|
-
func=func,
|
|
66
|
-
status_code=response.status_code,
|
|
67
|
-
result=result.get("result") if isinstance(result, dict) else None,
|
|
68
|
-
)
|
|
69
|
-
return result
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
def api_payload(func: str, params: dict[str, Any] | None = None, index: str = "1") -> dict[str, Any]:
|
|
73
|
-
payload: dict[str, Any] = {"index": str(index), "func": func}
|
|
74
|
-
if params:
|
|
75
|
-
payload.update(params)
|
|
76
|
-
return payload
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def call_method(args: argparse.Namespace, func: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
80
|
-
ip, ip_source = resolve_ip(args)
|
|
81
|
-
mac, mac_source = resolve_mac(args, ip, required=True)
|
|
82
|
-
assert mac is not None
|
|
83
|
-
response = fh_tool_call(ip, mac, api_payload(func, params), args.timeout)
|
|
84
|
-
return {
|
|
85
|
-
"ip": ip,
|
|
86
|
-
"ip_source": ip_source,
|
|
87
|
-
"mac": format_mac(mac),
|
|
88
|
-
"mac_source": mac_source,
|
|
89
|
-
"request": api_payload(func, params),
|
|
90
|
-
"response": response,
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def response_download_url(response: dict[str, Any]) -> str:
|
|
95
|
-
url = response.get("Dowloadurl") or response.get("Downloadurl") or response.get("downloadurl")
|
|
96
|
-
if not isinstance(url, str) or not url:
|
|
97
|
-
raise FHToolError(f"响应中没有 Dowloadurl: {response}")
|
|
98
|
-
return url
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|