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.
Files changed (67) hide show
  1. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/PKG-INFO +26 -6
  2. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/README.md +25 -5
  3. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/pyproject.toml +1 -1
  4. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/__init__.py +1 -1
  5. {fh_tool_cli-0.2.4 → 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.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/cli.py +23 -3
  8. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/client.py +12 -4
  9. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/web.py +3 -2
  10. fh_tool_cli-0.2.5/src/fh_tool_cli/fh_endpoints.py +19 -0
  11. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/parser.py +7 -0
  12. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/upload.py +3 -1
  13. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/PKG-INFO +26 -6
  14. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/SOURCES.txt +2 -0
  15. fh_tool_cli-0.2.5/tests/test_fh_port.py +273 -0
  16. fh_tool_cli-0.2.4/src/fh_tool_cli/backends/fh_tool.py +0 -98
  17. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/setup.cfg +0 -0
  18. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/account.py +0 -0
  19. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/__init__.py +0 -0
  20. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/cfg_cmd.py +0 -0
  21. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/local_vm.py +0 -0
  22. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/telnet.py +0 -0
  23. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backends/web_ajax.py +0 -0
  24. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/backup.py +0 -0
  25. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/click_cli.py +0 -0
  26. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/commands/__init__.py +0 -0
  27. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_decrypt.py +0 -0
  28. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/config_store.py +0 -0
  29. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credential_sources.py +0 -0
  30. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/credentials.py +0 -0
  31. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/crypto.py +0 -0
  32. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/diagnostics.py +0 -0
  33. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/errors.py +0 -0
  34. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/output.py +0 -0
  35. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/remote.py +0 -0
  36. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/risk.py +0 -0
  37. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_build.py +0 -0
  38. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_collect.py +0 -0
  39. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_common.py +0 -0
  40. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/vm_verify.py +0 -0
  41. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_discovery.py +0 -0
  42. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli/web_writes.py +0 -0
  43. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
  44. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
  45. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/requires.txt +0 -0
  46. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
  47. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_account.py +0 -0
  48. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_backup.py +0 -0
  49. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_cfg_cmd.py +0 -0
  50. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_cli_risk.py +0 -0
  51. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_click_cli.py +0 -0
  52. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_config_decrypt.py +0 -0
  53. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_credentials.py +0 -0
  54. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_diagnostics.py +0 -0
  55. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_extracted_helpers.py +0 -0
  56. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_local_vm_integration.py +0 -0
  57. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_parser_registration.py +0 -0
  58. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_remote.py +0 -0
  59. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_telnet_backend.py +0 -0
  60. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_upload.py +0 -0
  61. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_build.py +0 -0
  62. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_collect.py +0 -0
  63. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_vm_verify.py +0 -0
  64. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_ajax.py +0 -0
  65. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_ajax_post.py +0 -0
  66. {fh_tool_cli-0.2.4 → fh_tool_cli-0.2.5}/tests/test_web_discovery.py +0 -0
  67. {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.4
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)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fh_tool-cli"
7
- version = "0.2.4"
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 (
@@ -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, "/fh_tool/tool_download"]:
200
- 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)
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}: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}"
@@ -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}:8080{FH_TOOL_UPLOAD_PATH}?action={action}"
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.4
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