fh_tool-cli 0.1.0__py3-none-any.whl
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/__init__.py
ADDED
fh_tool_cli/cli.py
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
import re
|
|
11
|
+
import socket
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
from urllib.parse import urljoin
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
21
|
+
from cryptography.hazmat.primitives.padding import PKCS7
|
|
22
|
+
|
|
23
|
+
DEFAULT_IP = "192.168.1.1"
|
|
24
|
+
FH_TOOL_API_PATH = "/fh_tool/api"
|
|
25
|
+
FH_TOOL_UPLOAD_PATH = "/fh_tool/upload"
|
|
26
|
+
DEFAULT_PORTS = "23,80,443,8080"
|
|
27
|
+
DEFAULT_CONFIG_PATH = Path(
|
|
28
|
+
os.environ.get("FH_TOOL_CLI_CONFIG", "~/.config/fh_tool-cli/config.json")
|
|
29
|
+
).expanduser()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class FHToolCrypto:
|
|
34
|
+
key: bytes
|
|
35
|
+
iv: bytes
|
|
36
|
+
digest: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CliError(RuntimeError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FHToolError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize_ip(value: str) -> str:
|
|
48
|
+
try:
|
|
49
|
+
ip = ipaddress.ip_address(value.strip())
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise argparse.ArgumentTypeError("不符规范的 IP 地址") from exc
|
|
52
|
+
if ip.version != 4:
|
|
53
|
+
raise argparse.ArgumentTypeError("目前只支持 IPv4 网关地址")
|
|
54
|
+
return str(ip)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def normalize_mac(value: str) -> str:
|
|
58
|
+
mac = value.strip().upper().replace(":", "").replace("-", "")
|
|
59
|
+
if not re.fullmatch(r"[A-F0-9]{12}", mac):
|
|
60
|
+
raise argparse.ArgumentTypeError("不符规范的 MAC 地址,应为 AABBCCDDEEFF 格式")
|
|
61
|
+
return mac
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def format_mac(mac: str) -> str:
|
|
65
|
+
return ":".join(mac[i : i + 2] for i in range(0, 12, 2))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def decode_text(data: bytes) -> str:
|
|
69
|
+
return data.decode("utf-8", errors="ignore")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_output(command: list[str]) -> str | None:
|
|
73
|
+
try:
|
|
74
|
+
return decode_text(subprocess.check_output(command, stderr=subprocess.DEVNULL))
|
|
75
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_default_gateway() -> str | None:
|
|
80
|
+
system = platform.system()
|
|
81
|
+
commands: list[list[str]]
|
|
82
|
+
|
|
83
|
+
if system == "Windows":
|
|
84
|
+
commands = [["route", "print", "-4", "0.0.0.0"]]
|
|
85
|
+
elif system == "Darwin":
|
|
86
|
+
commands = [["route", "-n", "get", "default"]]
|
|
87
|
+
else:
|
|
88
|
+
commands = [["ip", "-4", "route", "show", "default"], ["route", "-n"]]
|
|
89
|
+
|
|
90
|
+
for command in commands:
|
|
91
|
+
output = run_output(command)
|
|
92
|
+
if not output:
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
if system == "Darwin":
|
|
96
|
+
match = re.search(r"gateway:\s*(\d{1,3}(?:\.\d{1,3}){3})", output)
|
|
97
|
+
if match:
|
|
98
|
+
return normalize_ip(match.group(1))
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if command[:2] == ["ip", "-4"]:
|
|
102
|
+
match = re.search(r"\bdefault\s+via\s+(\d{1,3}(?:\.\d{1,3}){3})", output)
|
|
103
|
+
if match:
|
|
104
|
+
return normalize_ip(match.group(1))
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
for line in output.splitlines():
|
|
108
|
+
if "0.0.0.0" not in line:
|
|
109
|
+
continue
|
|
110
|
+
candidates = re.findall(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", line)
|
|
111
|
+
for candidate in candidates:
|
|
112
|
+
if candidate != "0.0.0.0":
|
|
113
|
+
return normalize_ip(candidate)
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_mac_address(ip: str) -> str | None:
|
|
119
|
+
system = platform.system()
|
|
120
|
+
commands: list[list[str]]
|
|
121
|
+
|
|
122
|
+
if system == "Windows":
|
|
123
|
+
commands = [["arp", "-a", ip]]
|
|
124
|
+
elif system == "Darwin":
|
|
125
|
+
commands = [["arp", "-n", ip]]
|
|
126
|
+
else:
|
|
127
|
+
commands = [["ip", "neigh", "show", ip], ["arp", "-n", ip]]
|
|
128
|
+
|
|
129
|
+
for command in commands:
|
|
130
|
+
output = run_output(command)
|
|
131
|
+
if not output:
|
|
132
|
+
continue
|
|
133
|
+
match = re.search(
|
|
134
|
+
r"([A-Fa-f0-9]{2}(?::[A-Fa-f0-9]{2}){5}|[A-Fa-f0-9]{2}(?:-[A-Fa-f0-9]{2}){5})",
|
|
135
|
+
output,
|
|
136
|
+
)
|
|
137
|
+
if match:
|
|
138
|
+
return normalize_mac(match.group(1))
|
|
139
|
+
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def load_config(path: Path) -> dict[str, Any]:
|
|
144
|
+
if not path.exists():
|
|
145
|
+
return {}
|
|
146
|
+
try:
|
|
147
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
148
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
149
|
+
raise CliError(f"配置文件读取失败: {path}: {exc}") from exc
|
|
150
|
+
if not isinstance(data, dict):
|
|
151
|
+
raise CliError(f"配置文件格式错误: {path}")
|
|
152
|
+
return data
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def save_config(path: Path, data: dict[str, Any]) -> None:
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
path.write_text(
|
|
158
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
159
|
+
encoding="utf-8",
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
path.chmod(0o600)
|
|
163
|
+
except OSError:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def config_path_from_args(args: argparse.Namespace) -> Path:
|
|
168
|
+
return Path(args.config).expanduser()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def resolve_ip(args: argparse.Namespace) -> tuple[str, str]:
|
|
172
|
+
config = load_config(config_path_from_args(args))
|
|
173
|
+
if args.ip:
|
|
174
|
+
return args.ip, "argument"
|
|
175
|
+
if config.get("ip"):
|
|
176
|
+
return normalize_ip(str(config["ip"])), "config"
|
|
177
|
+
detected = detect_default_gateway()
|
|
178
|
+
if detected:
|
|
179
|
+
return detected, "default_gateway"
|
|
180
|
+
return DEFAULT_IP, "fallback"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def resolve_mac(
|
|
184
|
+
args: argparse.Namespace,
|
|
185
|
+
ip: str,
|
|
186
|
+
*,
|
|
187
|
+
required: bool,
|
|
188
|
+
allow_prompt: bool = True,
|
|
189
|
+
) -> tuple[str | None, str]:
|
|
190
|
+
config = load_config(config_path_from_args(args))
|
|
191
|
+
if args.mac:
|
|
192
|
+
return normalize_mac(args.mac), "argument"
|
|
193
|
+
if config.get("mac"):
|
|
194
|
+
return normalize_mac(str(config["mac"])), "config"
|
|
195
|
+
|
|
196
|
+
detected = get_mac_address(ip)
|
|
197
|
+
if detected:
|
|
198
|
+
return detected, "arp"
|
|
199
|
+
|
|
200
|
+
if getattr(args, "ask_mac", False) and allow_prompt and not getattr(args, "json", False):
|
|
201
|
+
manual = input("请手动输入网关 MAC(AABBCCDDEEFF): ")
|
|
202
|
+
return normalize_mac(manual), "prompt"
|
|
203
|
+
|
|
204
|
+
if required:
|
|
205
|
+
raise CliError("缺少网关 MAC。请使用 --mac AABBCCDDEEFF,或先运行 config set。")
|
|
206
|
+
return None, "missing"
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def derive_crypto(mac: str) -> FHToolCrypto:
|
|
210
|
+
digest = hashlib.sha256(mac.encode("ascii")).hexdigest()
|
|
211
|
+
key = "".join(digest[2 * i + 2] for i in range(16)).encode("ascii")
|
|
212
|
+
iv = "".join(digest[3 * i + 3] for i in range(16)).encode("ascii")
|
|
213
|
+
return FHToolCrypto(key=key, iv=iv, digest=digest)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def pkcs7_pad(data: bytes) -> bytes:
|
|
217
|
+
padder = PKCS7(128).padder()
|
|
218
|
+
return padder.update(data) + padder.finalize()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def pkcs7_unpad(data: bytes) -> bytes:
|
|
222
|
+
unpadder = PKCS7(128).unpadder()
|
|
223
|
+
return unpadder.update(data) + unpadder.finalize()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def encrypt_payload(payload: dict[str, Any], crypto: FHToolCrypto) -> str:
|
|
227
|
+
plaintext = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode(
|
|
228
|
+
"utf-8"
|
|
229
|
+
)
|
|
230
|
+
encryptor = Cipher(algorithms.AES(crypto.key), modes.CBC(crypto.iv)).encryptor()
|
|
231
|
+
ciphertext = encryptor.update(pkcs7_pad(plaintext)) + encryptor.finalize()
|
|
232
|
+
return base64.b64encode(ciphertext).decode("ascii")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def decrypt_payload(body: str, crypto: FHToolCrypto) -> dict[str, Any]:
|
|
236
|
+
raw = base64.b64decode(body.strip())
|
|
237
|
+
decryptor = Cipher(algorithms.AES(crypto.key), modes.CBC(crypto.iv)).decryptor()
|
|
238
|
+
plaintext = pkcs7_unpad(decryptor.update(raw) + decryptor.finalize())
|
|
239
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def fh_tool_call(
|
|
243
|
+
ip: str,
|
|
244
|
+
mac: str,
|
|
245
|
+
payload: dict[str, Any],
|
|
246
|
+
timeout: float,
|
|
247
|
+
) -> dict[str, Any]:
|
|
248
|
+
crypto = derive_crypto(mac)
|
|
249
|
+
encrypted = encrypt_payload(payload, crypto)
|
|
250
|
+
url = f"http://{ip}:8080{FH_TOOL_API_PATH}"
|
|
251
|
+
try:
|
|
252
|
+
response = requests.post(
|
|
253
|
+
url,
|
|
254
|
+
data=encrypted,
|
|
255
|
+
headers={
|
|
256
|
+
"Content-Type": "text/plain",
|
|
257
|
+
"Connection": "close",
|
|
258
|
+
},
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
allow_redirects=False,
|
|
261
|
+
)
|
|
262
|
+
except requests.RequestException as exc:
|
|
263
|
+
raise FHToolError(f"无法连接 {url}: {exc}") from exc
|
|
264
|
+
|
|
265
|
+
if response.status_code != 200:
|
|
266
|
+
raise FHToolError(f"{FH_TOOL_API_PATH} 返回 HTTP {response.status_code}")
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
return decrypt_payload(response.text, crypto)
|
|
270
|
+
except Exception as exc:
|
|
271
|
+
raise FHToolError("响应解密失败,MAC 可能不匹配或固件协议不同") from exc
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def tcp_open(ip: str, port: int, timeout: float) -> bool:
|
|
275
|
+
try:
|
|
276
|
+
with socket.create_connection((ip, port), timeout=timeout):
|
|
277
|
+
return True
|
|
278
|
+
except OSError:
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def parse_ports(value: str) -> list[int]:
|
|
283
|
+
ports: list[int] = []
|
|
284
|
+
for part in value.split(","):
|
|
285
|
+
part = part.strip()
|
|
286
|
+
if not part:
|
|
287
|
+
continue
|
|
288
|
+
try:
|
|
289
|
+
port = int(part, 10)
|
|
290
|
+
except ValueError as exc:
|
|
291
|
+
raise argparse.ArgumentTypeError(f"不符规范的 port: {part}") from exc
|
|
292
|
+
if not 1 <= port <= 65535:
|
|
293
|
+
raise argparse.ArgumentTypeError(f"port 超出范围: {part}")
|
|
294
|
+
ports.append(port)
|
|
295
|
+
if not ports:
|
|
296
|
+
raise argparse.ArgumentTypeError("至少需要一个 port")
|
|
297
|
+
return ports
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_scalar(value: str) -> Any:
|
|
301
|
+
lowered = value.lower()
|
|
302
|
+
if lowered == "true":
|
|
303
|
+
return True
|
|
304
|
+
if lowered == "false":
|
|
305
|
+
return False
|
|
306
|
+
if lowered == "null":
|
|
307
|
+
return None
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def parse_kv(values: list[str]) -> dict[str, Any]:
|
|
312
|
+
result: dict[str, Any] = {}
|
|
313
|
+
for value in values:
|
|
314
|
+
if "=" not in value:
|
|
315
|
+
raise CliError(f"--param 需要 k=v 格式: {value}")
|
|
316
|
+
key, raw = value.split("=", 1)
|
|
317
|
+
key = key.strip()
|
|
318
|
+
if not key:
|
|
319
|
+
raise CliError(f"--param key 不能为空: {value}")
|
|
320
|
+
result[key] = parse_scalar(raw)
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def parse_json_object(value: str) -> dict[str, Any]:
|
|
325
|
+
try:
|
|
326
|
+
data = json.loads(value)
|
|
327
|
+
except json.JSONDecodeError as exc:
|
|
328
|
+
raise CliError(f"JSON 解析失败: {exc}") from exc
|
|
329
|
+
if not isinstance(data, dict):
|
|
330
|
+
raise CliError("JSON payload 必须是 object")
|
|
331
|
+
return data
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def api_payload(func: str, params: dict[str, Any] | None = None, index: str = "1") -> dict[str, Any]:
|
|
335
|
+
payload: dict[str, Any] = {"index": str(index), "func": func}
|
|
336
|
+
if params:
|
|
337
|
+
payload.update(params)
|
|
338
|
+
return payload
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def call_method(args: argparse.Namespace, func: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
342
|
+
ip, ip_source = resolve_ip(args)
|
|
343
|
+
mac, mac_source = resolve_mac(args, ip, required=True)
|
|
344
|
+
assert mac is not None
|
|
345
|
+
response = fh_tool_call(ip, mac, api_payload(func, params), args.timeout)
|
|
346
|
+
return {
|
|
347
|
+
"ip": ip,
|
|
348
|
+
"ip_source": ip_source,
|
|
349
|
+
"mac": format_mac(mac),
|
|
350
|
+
"mac_source": mac_source,
|
|
351
|
+
"request": api_payload(func, params),
|
|
352
|
+
"response": response,
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def require_yes(args: argparse.Namespace, message: str) -> None:
|
|
357
|
+
if not getattr(args, "yes", False):
|
|
358
|
+
raise CliError(f"{message}。如确认执行,请加 --yes")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def require_danger(args: argparse.Namespace, message: str) -> None:
|
|
362
|
+
require_yes(args, message)
|
|
363
|
+
if not getattr(args, "danger", False):
|
|
364
|
+
raise CliError(f"{message}。如确认危险动作,请加 --danger")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def require_extreme(args: argparse.Namespace, message: str) -> None:
|
|
368
|
+
require_danger(args, message)
|
|
369
|
+
if not getattr(args, "i_know_this_can_break_my_device", False):
|
|
370
|
+
raise CliError(
|
|
371
|
+
f"{message}。最后确认参数是 --i-know-this-can-break-my-device"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def response_download_url(response: dict[str, Any]) -> str:
|
|
376
|
+
url = response.get("Dowloadurl") or response.get("Downloadurl") or response.get("downloadurl")
|
|
377
|
+
if not isinstance(url, str) or not url:
|
|
378
|
+
raise FHToolError(f"响应中没有 Dowloadurl: {response}")
|
|
379
|
+
return url
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def make_download_url(ip: str, value: str) -> str:
|
|
383
|
+
if value.startswith("http://") or value.startswith("https://"):
|
|
384
|
+
return value
|
|
385
|
+
return urljoin(f"http://{ip}:8080", value)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def download_to_file(ip: str, url_value: str, output: Path, timeout: float) -> dict[str, Any]:
|
|
389
|
+
url = make_download_url(ip, url_value)
|
|
390
|
+
try:
|
|
391
|
+
response = requests.get(url, timeout=timeout, stream=True)
|
|
392
|
+
except requests.RequestException as exc:
|
|
393
|
+
raise FHToolError(f"下载失败 {url}: {exc}") from exc
|
|
394
|
+
if response.status_code != 200:
|
|
395
|
+
raise FHToolError(f"下载失败 {url}: HTTP {response.status_code}")
|
|
396
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
397
|
+
with output.open("wb") as file:
|
|
398
|
+
for chunk in response.iter_content(chunk_size=1024 * 128):
|
|
399
|
+
if chunk:
|
|
400
|
+
file.write(chunk)
|
|
401
|
+
return {
|
|
402
|
+
"url": url,
|
|
403
|
+
"output": str(output),
|
|
404
|
+
"bytes": output.stat().st_size,
|
|
405
|
+
"content_type": response.headers.get("Content-Type"),
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def emit(data: Any, json_mode: bool) -> None:
|
|
410
|
+
if json_mode:
|
|
411
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
if isinstance(data, dict):
|
|
415
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
416
|
+
else:
|
|
417
|
+
print(data)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def command_probe(args: argparse.Namespace) -> dict[str, Any]:
|
|
421
|
+
ip, ip_source = resolve_ip(args)
|
|
422
|
+
mac, mac_source = resolve_mac(args, ip, required=False, allow_prompt=False)
|
|
423
|
+
ports = parse_ports(args.ports)
|
|
424
|
+
|
|
425
|
+
result: dict[str, Any] = {
|
|
426
|
+
"mode": "probe",
|
|
427
|
+
"ip": ip,
|
|
428
|
+
"ip_source": ip_source,
|
|
429
|
+
"mac": format_mac(mac) if mac else None,
|
|
430
|
+
"mac_source": mac_source,
|
|
431
|
+
"tcp": {str(port): tcp_open(ip, port, args.timeout) for port in ports},
|
|
432
|
+
"surface": {},
|
|
433
|
+
"fh_tool_probe": None,
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
for path in [FH_TOOL_API_PATH, FH_TOOL_UPLOAD_PATH, "/fh_tool/tool_download"]:
|
|
437
|
+
url = f"http://{ip}:8080{path}"
|
|
438
|
+
try:
|
|
439
|
+
response = requests.get(url, timeout=args.timeout, allow_redirects=False)
|
|
440
|
+
result["surface"][path] = {
|
|
441
|
+
"status_code": response.status_code,
|
|
442
|
+
"content_type": response.headers.get("Content-Type"),
|
|
443
|
+
}
|
|
444
|
+
except requests.RequestException as exc:
|
|
445
|
+
result["surface"][path] = {"error": str(exc)}
|
|
446
|
+
|
|
447
|
+
if mac:
|
|
448
|
+
try:
|
|
449
|
+
dev_info = fh_tool_call(
|
|
450
|
+
ip,
|
|
451
|
+
mac,
|
|
452
|
+
api_payload("GetDevInfo"),
|
|
453
|
+
args.timeout,
|
|
454
|
+
)
|
|
455
|
+
result["fh_tool_probe"] = {
|
|
456
|
+
"func": "GetDevInfo",
|
|
457
|
+
"ok": dev_info.get("result") == 0,
|
|
458
|
+
"response": dev_info,
|
|
459
|
+
}
|
|
460
|
+
except FHToolError as exc:
|
|
461
|
+
result["fh_tool_probe"] = {
|
|
462
|
+
"func": "GetDevInfo",
|
|
463
|
+
"ok": False,
|
|
464
|
+
"error": str(exc),
|
|
465
|
+
}
|
|
466
|
+
else:
|
|
467
|
+
result["fh_tool_probe"] = {
|
|
468
|
+
"func": "GetDevInfo",
|
|
469
|
+
"ok": False,
|
|
470
|
+
"error": "missing mac",
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return result
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def command_ports(args: argparse.Namespace) -> dict[str, Any]:
|
|
477
|
+
ip, ip_source = resolve_ip(args)
|
|
478
|
+
ports = parse_ports(args.ports)
|
|
479
|
+
return {
|
|
480
|
+
"ip": ip,
|
|
481
|
+
"ip_source": ip_source,
|
|
482
|
+
"tcp": {str(port): tcp_open(ip, port, args.timeout) for port in ports},
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def command_config_show(args: argparse.Namespace) -> dict[str, Any]:
|
|
487
|
+
path = config_path_from_args(args)
|
|
488
|
+
return {"path": str(path), "config": load_config(path)}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def command_config_set(args: argparse.Namespace) -> dict[str, Any]:
|
|
492
|
+
path = config_path_from_args(args)
|
|
493
|
+
config = load_config(path)
|
|
494
|
+
if args.ip:
|
|
495
|
+
config["ip"] = normalize_ip(args.ip)
|
|
496
|
+
if args.mac:
|
|
497
|
+
config["mac"] = normalize_mac(args.mac)
|
|
498
|
+
if not args.ip and not args.mac:
|
|
499
|
+
raise CliError("config set 至少需要 --ip 或 --mac")
|
|
500
|
+
save_config(path, config)
|
|
501
|
+
return {"path": str(path), "config": config}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def command_config_clear(args: argparse.Namespace) -> dict[str, Any]:
|
|
505
|
+
path = config_path_from_args(args)
|
|
506
|
+
existed = path.exists()
|
|
507
|
+
if existed:
|
|
508
|
+
path.unlink()
|
|
509
|
+
return {"path": str(path), "removed": existed}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def command_simple(func: str, params: dict[str, Any] | None = None) -> Callable[[argparse.Namespace], dict[str, Any]]:
|
|
513
|
+
def handler(args: argparse.Namespace) -> dict[str, Any]:
|
|
514
|
+
return call_method(args, func, params)
|
|
515
|
+
|
|
516
|
+
return handler
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def command_set_result(args: argparse.Namespace) -> dict[str, Any]:
|
|
520
|
+
require_yes(args, "SetResult 会写入注册结果配置")
|
|
521
|
+
return call_method(args, "SetResult", {"result": args.result})
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def command_set_port_mirror(args: argparse.Namespace) -> dict[str, Any]:
|
|
525
|
+
require_yes(args, "SetPortMirror 会修改 port mirror 配置")
|
|
526
|
+
return call_method(
|
|
527
|
+
args,
|
|
528
|
+
"SetPortMirror",
|
|
529
|
+
{
|
|
530
|
+
"enable": args.enable,
|
|
531
|
+
"direction": args.direction,
|
|
532
|
+
"srcport": args.srcport,
|
|
533
|
+
"dstport": args.dstport,
|
|
534
|
+
},
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def command_log_download(args: argparse.Namespace) -> dict[str, Any]:
|
|
539
|
+
result = call_method(args, "LogDownload")
|
|
540
|
+
if args.output:
|
|
541
|
+
ip = result["ip"]
|
|
542
|
+
url_value = response_download_url(result["response"])
|
|
543
|
+
result["download"] = download_to_file(
|
|
544
|
+
ip,
|
|
545
|
+
url_value,
|
|
546
|
+
Path(args.output).expanduser(),
|
|
547
|
+
args.timeout,
|
|
548
|
+
)
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def command_set_reg_account(args: argparse.Namespace) -> dict[str, Any]:
|
|
553
|
+
require_yes(args, "SetRegAccount 会修改 operator registration account")
|
|
554
|
+
return call_method(
|
|
555
|
+
args,
|
|
556
|
+
"SetRegAccount",
|
|
557
|
+
{"regname": args.regname, "regpwd": args.regpwd},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def command_set_pwd_reg_password(args: argparse.Namespace) -> dict[str, Any]:
|
|
562
|
+
require_yes(args, "SetPwdRegPassword 会修改 CMCC registration password")
|
|
563
|
+
return call_method(args, "SetPwdRegPassword", {"password": args.password})
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def command_download_file(args: argparse.Namespace) -> dict[str, Any]:
|
|
567
|
+
require_yes(args, "DownloadFile 会在设备上打包并导出指定文件")
|
|
568
|
+
result = call_method(args, "DownloadFile", {"fileName": args.file_name})
|
|
569
|
+
url_value = response_download_url(result["response"])
|
|
570
|
+
if args.output:
|
|
571
|
+
output = Path(args.output).expanduser()
|
|
572
|
+
else:
|
|
573
|
+
output = Path.cwd() / Path(url_value.split("?", 1)[0]).name
|
|
574
|
+
result["download"] = download_to_file(result["ip"], url_value, output, args.timeout)
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def command_restore_default_settings(args: argparse.Namespace) -> dict[str, Any]:
|
|
579
|
+
require_extreme(args, "RestoreDefaultSettings 会恢复出厂设置")
|
|
580
|
+
return call_method(args, "RestoreDefaultSettings")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def command_upload_prepare(args: argparse.Namespace) -> dict[str, Any]:
|
|
584
|
+
return call_method(args, "UploadPrepare")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def command_reboot(args: argparse.Namespace) -> dict[str, Any]:
|
|
588
|
+
require_extreme(args, "DeviceReboot 会重启设备")
|
|
589
|
+
return call_method(args, "DeviceReboot")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def command_set_preconfig(args: argparse.Namespace) -> dict[str, Any]:
|
|
593
|
+
require_danger(args, "SetPreconfig 会切换地区/运营商预配置")
|
|
594
|
+
return call_method(args, "SetPreconfig", {"fullname": args.fullname})
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def command_telnet_enable(args: argparse.Namespace) -> dict[str, Any]:
|
|
598
|
+
require_yes(args, "TelnetEnable=1 会启动 runtime Telnet 服务")
|
|
599
|
+
result = call_method(args, "TelnetEnable", {"telnet": "1"})
|
|
600
|
+
result["telnet_port_open"] = tcp_open(result["ip"], 23, args.timeout)
|
|
601
|
+
return result
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def command_telnet_disable(args: argparse.Namespace) -> dict[str, Any]:
|
|
605
|
+
require_danger(args, "TelnetEnable=0 会关闭 runtime Telnet 服务")
|
|
606
|
+
result = call_method(args, "TelnetEnable", {"telnet": "0"})
|
|
607
|
+
result["telnet_port_open"] = tcp_open(result["ip"], 23, args.timeout)
|
|
608
|
+
return result
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def command_set_fh_debug_log(args: argparse.Namespace) -> dict[str, Any]:
|
|
612
|
+
require_danger(args, "SetFHDebugLog 会执行设备上的 debug shell script")
|
|
613
|
+
return call_method(args, "SetFHDebugLog", {"module": args.module, "data": args.data})
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def command_close_fh_debug_log(args: argparse.Namespace) -> dict[str, Any]:
|
|
617
|
+
require_yes(args, "CloseFHDebugLog 会修改 debug log 配置")
|
|
618
|
+
return call_method(args, "CloseFHDebugLog")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def command_open_fh_debug_log(args: argparse.Namespace) -> dict[str, Any]:
|
|
622
|
+
require_yes(args, "OpenFHDebugLog 会修改 debug log 配置")
|
|
623
|
+
return call_method(args, "OpenFHDebugLog")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def command_raw_call(args: argparse.Namespace) -> dict[str, Any]:
|
|
627
|
+
params: dict[str, Any] = {}
|
|
628
|
+
if args.json_payload:
|
|
629
|
+
params.update(parse_json_object(args.json_payload))
|
|
630
|
+
params.update(parse_kv(args.param))
|
|
631
|
+
|
|
632
|
+
risky_prefixes = ("Set", "Close", "Open", "Restore", "Device", "Upload")
|
|
633
|
+
if args.func.startswith(risky_prefixes) or args.func == "TelnetEnable":
|
|
634
|
+
if not args.allow_risky:
|
|
635
|
+
raise CliError("raw call 调用 risky func 需要 --allow-risky")
|
|
636
|
+
|
|
637
|
+
return call_method(args, args.func, params)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def command_download_url(args: argparse.Namespace) -> dict[str, Any]:
|
|
641
|
+
ip, ip_source = resolve_ip(args)
|
|
642
|
+
return {
|
|
643
|
+
"ip": ip,
|
|
644
|
+
"ip_source": ip_source,
|
|
645
|
+
"download": download_to_file(
|
|
646
|
+
ip,
|
|
647
|
+
args.url,
|
|
648
|
+
Path(args.output).expanduser(),
|
|
649
|
+
args.timeout,
|
|
650
|
+
),
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def command_upload(args: argparse.Namespace) -> dict[str, Any]:
|
|
655
|
+
require_danger(args, "upload 会写入 firmware/preconfig staging path")
|
|
656
|
+
ip, ip_source = resolve_ip(args)
|
|
657
|
+
file_path = Path(args.file).expanduser()
|
|
658
|
+
if not file_path.is_file():
|
|
659
|
+
raise CliError(f"上传文件不存在: {file_path}")
|
|
660
|
+
url = f"http://{ip}:8080{FH_TOOL_UPLOAD_PATH}?action={args.action}"
|
|
661
|
+
try:
|
|
662
|
+
with file_path.open("rb") as file:
|
|
663
|
+
response = requests.post(
|
|
664
|
+
url,
|
|
665
|
+
headers={"fh_upgrade_api_token": args.sessionid},
|
|
666
|
+
files={"file": (file_path.name, file)},
|
|
667
|
+
timeout=args.timeout,
|
|
668
|
+
allow_redirects=False,
|
|
669
|
+
)
|
|
670
|
+
except requests.RequestException as exc:
|
|
671
|
+
raise FHToolError(f"上传失败 {url}: {exc}") from exc
|
|
672
|
+
return {
|
|
673
|
+
"ip": ip,
|
|
674
|
+
"ip_source": ip_source,
|
|
675
|
+
"url": url,
|
|
676
|
+
"status_code": response.status_code,
|
|
677
|
+
"text": response.text[:1000],
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def add_common_options(parser: argparse.ArgumentParser) -> None:
|
|
682
|
+
parser.add_argument("--ip", type=normalize_ip, help="网关 IPv4 地址")
|
|
683
|
+
parser.add_argument(
|
|
684
|
+
"--mac",
|
|
685
|
+
help="网关 MAC,支持 AABBCCDDEEFF / AA:BB:CC:DD:EE:FF / AA-BB-CC-DD-EE-FF",
|
|
686
|
+
)
|
|
687
|
+
parser.add_argument(
|
|
688
|
+
"--timeout",
|
|
689
|
+
type=float,
|
|
690
|
+
default=5.0,
|
|
691
|
+
help="HTTP/TCP timeout 秒数,默认 5",
|
|
692
|
+
)
|
|
693
|
+
parser.add_argument(
|
|
694
|
+
"--config",
|
|
695
|
+
default=str(DEFAULT_CONFIG_PATH),
|
|
696
|
+
help=f"配置文件路径,默认 {DEFAULT_CONFIG_PATH}",
|
|
697
|
+
)
|
|
698
|
+
parser.add_argument(
|
|
699
|
+
"--ask-mac",
|
|
700
|
+
action="store_true",
|
|
701
|
+
help="无法自动获取 MAC 时交互式询问;--json 下不会询问",
|
|
702
|
+
)
|
|
703
|
+
parser.add_argument("--json", action="store_true", help="输出 machine-readable JSON")
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def add_yes(parser: argparse.ArgumentParser) -> None:
|
|
707
|
+
parser.add_argument("--yes", action="store_true", help="确认执行会改变设备状态的动作")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def add_danger(parser: argparse.ArgumentParser) -> None:
|
|
711
|
+
add_yes(parser)
|
|
712
|
+
parser.add_argument("--danger", action="store_true", help="确认执行高风险动作")
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def add_extreme(parser: argparse.ArgumentParser) -> None:
|
|
716
|
+
add_danger(parser)
|
|
717
|
+
parser.add_argument(
|
|
718
|
+
"--i-know-this-can-break-my-device",
|
|
719
|
+
dest="i_know_this_can_break_my_device",
|
|
720
|
+
action="store_true",
|
|
721
|
+
help="确认该动作可能导致设备断网、重启或恢复出厂",
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def add_api_command(
|
|
726
|
+
subparsers: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
727
|
+
name: str,
|
|
728
|
+
func: str,
|
|
729
|
+
*,
|
|
730
|
+
aliases: list[str] | None = None,
|
|
731
|
+
help_text: str,
|
|
732
|
+
) -> None:
|
|
733
|
+
parser = subparsers.add_parser(name, aliases=aliases or [], help=help_text)
|
|
734
|
+
add_common_options(parser)
|
|
735
|
+
parser.set_defaults(handler=command_simple(func))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
739
|
+
parser = argparse.ArgumentParser(
|
|
740
|
+
prog=Path(sys.argv[0]).name or "fh-tool",
|
|
741
|
+
description="本地管理 FiberHome /fh_tool 接口的 CLI。",
|
|
742
|
+
)
|
|
743
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
744
|
+
|
|
745
|
+
probe = subparsers.add_parser("probe", help="低风险探测 fh_tool 是否可用")
|
|
746
|
+
add_common_options(probe)
|
|
747
|
+
probe.add_argument("--ports", default=DEFAULT_PORTS, help=f"逗号分隔 port,默认 {DEFAULT_PORTS}")
|
|
748
|
+
probe.set_defaults(handler=command_probe)
|
|
749
|
+
|
|
750
|
+
ports = subparsers.add_parser("ports", help="检查 TCP port 是否打开")
|
|
751
|
+
add_common_options(ports)
|
|
752
|
+
ports.add_argument("--ports", default=DEFAULT_PORTS, help=f"逗号分隔 port,默认 {DEFAULT_PORTS}")
|
|
753
|
+
ports.set_defaults(handler=command_ports)
|
|
754
|
+
|
|
755
|
+
config = subparsers.add_parser("config", help="管理本机 CLI 配置")
|
|
756
|
+
config_subparsers = config.add_subparsers(dest="config_command", required=True)
|
|
757
|
+
config_show = config_subparsers.add_parser("show", help="显示配置")
|
|
758
|
+
config_show.add_argument("--config", default=str(DEFAULT_CONFIG_PATH))
|
|
759
|
+
config_show.add_argument("--json", action="store_true")
|
|
760
|
+
config_show.set_defaults(handler=command_config_show)
|
|
761
|
+
config_set = config_subparsers.add_parser("set", help="保存默认 IP/MAC")
|
|
762
|
+
config_set.add_argument("--config", default=str(DEFAULT_CONFIG_PATH))
|
|
763
|
+
config_set.add_argument("--json", action="store_true")
|
|
764
|
+
config_set.add_argument("--ip", type=normalize_ip)
|
|
765
|
+
config_set.add_argument("--mac")
|
|
766
|
+
config_set.set_defaults(handler=command_config_set)
|
|
767
|
+
config_clear = config_subparsers.add_parser("clear", help="删除配置文件")
|
|
768
|
+
config_clear.add_argument("--config", default=str(DEFAULT_CONFIG_PATH))
|
|
769
|
+
config_clear.add_argument("--json", action="store_true")
|
|
770
|
+
config_clear.set_defaults(handler=command_config_clear)
|
|
771
|
+
|
|
772
|
+
add_api_command(subparsers, "get-result", "GetResult", help_text="读取 operator registration result")
|
|
773
|
+
add_api_command(subparsers, "get-port-mirror", "GetPortMirror", help_text="读取 port mirror 配置")
|
|
774
|
+
|
|
775
|
+
log_download = subparsers.add_parser("log-download", help="调用 LogDownload,可选下载 tar")
|
|
776
|
+
add_common_options(log_download)
|
|
777
|
+
log_download.add_argument("--output", help="保存返回的 tool_download 文件")
|
|
778
|
+
log_download.set_defaults(handler=command_log_download)
|
|
779
|
+
|
|
780
|
+
add_api_command(subparsers, "dev-info", "GetDevInfo", aliases=["info"], help_text="读取设备基础信息")
|
|
781
|
+
add_api_command(subparsers, "admin-account", "GetAdminAccount", help_text="读取 telecomadmin account")
|
|
782
|
+
add_api_command(subparsers, "reg-account", "GetRegAccount", help_text="读取 registration account")
|
|
783
|
+
add_api_command(subparsers, "pwd-reg-password", "GetPwdRegPassword", help_text="读取 CMCC registration password")
|
|
784
|
+
add_api_command(subparsers, "get-preconfig", "GetPreconfig", help_text="读取 preconfig 列表")
|
|
785
|
+
add_api_command(subparsers, "pppoe-account", "GetPppoeAccount", help_text="读取 PPPoE account")
|
|
786
|
+
|
|
787
|
+
set_result = subparsers.add_parser("set-result", help="写入 registration result")
|
|
788
|
+
add_common_options(set_result)
|
|
789
|
+
add_yes(set_result)
|
|
790
|
+
set_result.add_argument("--result", required=True)
|
|
791
|
+
set_result.set_defaults(handler=command_set_result)
|
|
792
|
+
|
|
793
|
+
set_port_mirror = subparsers.add_parser("set-port-mirror", help="写入 port mirror 配置")
|
|
794
|
+
add_common_options(set_port_mirror)
|
|
795
|
+
add_yes(set_port_mirror)
|
|
796
|
+
set_port_mirror.add_argument("--enable", required=True)
|
|
797
|
+
set_port_mirror.add_argument("--direction", required=True)
|
|
798
|
+
set_port_mirror.add_argument("--srcport", required=True)
|
|
799
|
+
set_port_mirror.add_argument("--dstport", required=True)
|
|
800
|
+
set_port_mirror.set_defaults(handler=command_set_port_mirror)
|
|
801
|
+
|
|
802
|
+
set_reg_account = subparsers.add_parser("set-reg-account", help="写入 registration account")
|
|
803
|
+
add_common_options(set_reg_account)
|
|
804
|
+
add_yes(set_reg_account)
|
|
805
|
+
set_reg_account.add_argument("--regname", required=True)
|
|
806
|
+
set_reg_account.add_argument("--regpwd", required=True)
|
|
807
|
+
set_reg_account.set_defaults(handler=command_set_reg_account)
|
|
808
|
+
|
|
809
|
+
set_pwd_reg_password = subparsers.add_parser("set-pwd-reg-password", help="写入 CMCC registration password")
|
|
810
|
+
add_common_options(set_pwd_reg_password)
|
|
811
|
+
add_yes(set_pwd_reg_password)
|
|
812
|
+
set_pwd_reg_password.add_argument("--password", required=True)
|
|
813
|
+
set_pwd_reg_password.set_defaults(handler=command_set_pwd_reg_password)
|
|
814
|
+
|
|
815
|
+
download_file = subparsers.add_parser("download-file", help="调用 DownloadFile 并保存返回文件")
|
|
816
|
+
add_common_options(download_file)
|
|
817
|
+
add_yes(download_file)
|
|
818
|
+
download_file.add_argument("--file-name", required=True)
|
|
819
|
+
download_file.add_argument("--output", help="本地保存路径;默认使用返回文件名")
|
|
820
|
+
download_file.set_defaults(handler=command_download_file)
|
|
821
|
+
|
|
822
|
+
restore = subparsers.add_parser("restore-default-settings", help="恢复出厂设置")
|
|
823
|
+
add_common_options(restore)
|
|
824
|
+
add_extreme(restore)
|
|
825
|
+
restore.set_defaults(handler=command_restore_default_settings)
|
|
826
|
+
|
|
827
|
+
upload_prepare = subparsers.add_parser("upload-prepare", help="调用 UploadPrepare 获取 sessionid")
|
|
828
|
+
add_common_options(upload_prepare)
|
|
829
|
+
upload_prepare.set_defaults(handler=command_upload_prepare)
|
|
830
|
+
|
|
831
|
+
reboot = subparsers.add_parser("reboot", help="重启设备")
|
|
832
|
+
add_common_options(reboot)
|
|
833
|
+
add_extreme(reboot)
|
|
834
|
+
reboot.set_defaults(handler=command_reboot)
|
|
835
|
+
|
|
836
|
+
set_preconfig = subparsers.add_parser("set-preconfig", help="切换 preconfig")
|
|
837
|
+
add_common_options(set_preconfig)
|
|
838
|
+
add_danger(set_preconfig)
|
|
839
|
+
set_preconfig.add_argument("--fullname", required=True)
|
|
840
|
+
set_preconfig.set_defaults(handler=command_set_preconfig)
|
|
841
|
+
|
|
842
|
+
telnet = subparsers.add_parser("telnet", help="管理 runtime Telnet")
|
|
843
|
+
telnet_subparsers = telnet.add_subparsers(dest="telnet_command", required=True)
|
|
844
|
+
telnet_enable = telnet_subparsers.add_parser("enable", help="调用 TelnetEnable=1")
|
|
845
|
+
add_common_options(telnet_enable)
|
|
846
|
+
add_yes(telnet_enable)
|
|
847
|
+
telnet_enable.set_defaults(handler=command_telnet_enable)
|
|
848
|
+
telnet_disable = telnet_subparsers.add_parser("disable", help="调用 TelnetEnable=0")
|
|
849
|
+
add_common_options(telnet_disable)
|
|
850
|
+
add_danger(telnet_disable)
|
|
851
|
+
telnet_disable.set_defaults(handler=command_telnet_disable)
|
|
852
|
+
|
|
853
|
+
set_fh_debug_log = subparsers.add_parser("set-fh-debug-log", help="调用 SetFHDebugLog")
|
|
854
|
+
add_common_options(set_fh_debug_log)
|
|
855
|
+
add_danger(set_fh_debug_log)
|
|
856
|
+
set_fh_debug_log.add_argument("--module", required=True)
|
|
857
|
+
set_fh_debug_log.add_argument("--data", required=True)
|
|
858
|
+
set_fh_debug_log.set_defaults(handler=command_set_fh_debug_log)
|
|
859
|
+
|
|
860
|
+
close_fh_debug_log = subparsers.add_parser("close-fh-debug-log", help="关闭 FH debug log")
|
|
861
|
+
add_common_options(close_fh_debug_log)
|
|
862
|
+
add_yes(close_fh_debug_log)
|
|
863
|
+
close_fh_debug_log.set_defaults(handler=command_close_fh_debug_log)
|
|
864
|
+
|
|
865
|
+
open_fh_debug_log = subparsers.add_parser("open-fh-debug-log", help="开启 FH debug log")
|
|
866
|
+
add_common_options(open_fh_debug_log)
|
|
867
|
+
add_yes(open_fh_debug_log)
|
|
868
|
+
open_fh_debug_log.set_defaults(handler=command_open_fh_debug_log)
|
|
869
|
+
|
|
870
|
+
raw_call = subparsers.add_parser("call", help="raw fh_tool/api call")
|
|
871
|
+
add_common_options(raw_call)
|
|
872
|
+
raw_call.add_argument("--func", required=True)
|
|
873
|
+
raw_call.add_argument("--param", action="append", default=[], help="k=v,可重复")
|
|
874
|
+
raw_call.add_argument("--json-payload", help="额外 JSON object 参数")
|
|
875
|
+
raw_call.add_argument("--allow-risky", action="store_true", help="允许 raw call 调用 risky func")
|
|
876
|
+
raw_call.set_defaults(handler=command_raw_call)
|
|
877
|
+
|
|
878
|
+
download_url = subparsers.add_parser("download-url", help="下载 /fh_tool/tool_download 返回文件")
|
|
879
|
+
add_common_options(download_url)
|
|
880
|
+
download_url.add_argument("--url", required=True)
|
|
881
|
+
download_url.add_argument("--output", required=True)
|
|
882
|
+
download_url.set_defaults(handler=command_download_url)
|
|
883
|
+
|
|
884
|
+
upload = subparsers.add_parser("upload", help="调用 /fh_tool/upload")
|
|
885
|
+
add_common_options(upload)
|
|
886
|
+
add_danger(upload)
|
|
887
|
+
upload.add_argument("--action", choices=["upgradeimage", "preconfig"], required=True)
|
|
888
|
+
upload.add_argument("--file", required=True)
|
|
889
|
+
upload.add_argument("--sessionid", required=True)
|
|
890
|
+
upload.set_defaults(handler=command_upload)
|
|
891
|
+
|
|
892
|
+
return parser.parse_args(argv)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def main(argv: list[str] | None = None) -> int:
|
|
896
|
+
args = parse_args(argv)
|
|
897
|
+
try:
|
|
898
|
+
result = args.handler(args)
|
|
899
|
+
except (argparse.ArgumentTypeError, CliError, FHToolError, ValueError) as exc:
|
|
900
|
+
print(f"错误: {exc}", file=sys.stderr)
|
|
901
|
+
return 2
|
|
902
|
+
except KeyboardInterrupt:
|
|
903
|
+
print("已取消", file=sys.stderr)
|
|
904
|
+
return 130
|
|
905
|
+
|
|
906
|
+
emit(result, getattr(args, "json", False))
|
|
907
|
+
return 0
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
if __name__ == "__main__":
|
|
911
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fh_tool-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
|
|
5
|
+
Author: Gxxk
|
|
6
|
+
License-Expression: AGPL-3.0-or-later
|
|
7
|
+
Keywords: FiberHome,fh_tool,ONU,gateway,telecomadmin
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: System :: Networking
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: cryptography>=42.0.0
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
|
|
15
|
+
# fh_tool-cli
|
|
16
|
+
|
|
17
|
+
本项目是本地管理 FiberHome `/fh_tool` 接口的 Python CLI,目标是快速管理/调整你自己的设备。
|
|
18
|
+
|
|
19
|
+
默认推荐用 `uvx`:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
uvx --from . fh-tool --help
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
如果已经发布到 PyPI,使用方式会变成:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uvx --from fh_tool-cli fh-tool --help
|
|
29
|
+
uvx fh_tool-cli --help
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
也可以在源码目录里运行:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
cd ~/fh_tool-cli
|
|
36
|
+
uv run fh-tool --help
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
下文示例中的 `fh-tool ...` 如果是在源码目录内运行,可以统一写成 `uv run fh-tool ...`。
|
|
40
|
+
|
|
41
|
+
## 快速配置
|
|
42
|
+
|
|
43
|
+
保存默认 IP/MAC:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
fh-tool config set --ip 192.168.1.1 --mac AABBCCDDEEFF
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
查看配置:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
fh-tool config show
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
配置文件默认在:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
~/.config/fh_tool-cli/config.json
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
所有命令也都可以临时传:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
fh-tool dev-info --ip 192.168.1.1 --mac AABBCCDDEEFF
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## 常用命令
|
|
68
|
+
|
|
69
|
+
低风险 probe:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
fh-tool probe
|
|
73
|
+
fh-tool probe --json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
读取设备信息:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
fh-tool dev-info
|
|
80
|
+
fh-tool get-result
|
|
81
|
+
fh-tool get-port-mirror
|
|
82
|
+
fh-tool get-preconfig
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
读取账号类信息:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
fh-tool admin-account
|
|
89
|
+
fh-tool reg-account
|
|
90
|
+
fh-tool pppoe-account
|
|
91
|
+
fh-tool pwd-reg-password
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
打开 runtime Telnet:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
fh-tool telnet enable --yes
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
检查端口:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
fh-tool ports --ports 23,80,443,8080
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 22 个 `/fh_tool/api` method 覆盖
|
|
107
|
+
|
|
108
|
+
已提供 typed command:
|
|
109
|
+
|
|
110
|
+
| func | command |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `GetResult` | `fh-tool get-result` |
|
|
113
|
+
| `SetResult` | `fh-tool set-result --result VALUE --yes` |
|
|
114
|
+
| `GetPortMirror` | `fh-tool get-port-mirror` |
|
|
115
|
+
| `SetPortMirror` | `fh-tool set-port-mirror --enable ... --direction ... --srcport ... --dstport ... --yes` |
|
|
116
|
+
| `LogDownload` | `fh-tool log-download [--output log.tar.gz]` |
|
|
117
|
+
| `GetDevInfo` | `fh-tool dev-info` |
|
|
118
|
+
| `GetAdminAccount` | `fh-tool admin-account` |
|
|
119
|
+
| `GetRegAccount` | `fh-tool reg-account` |
|
|
120
|
+
| `SetRegAccount` | `fh-tool set-reg-account --regname ... --regpwd ... --yes` |
|
|
121
|
+
| `GetPwdRegPassword` | `fh-tool pwd-reg-password` |
|
|
122
|
+
| `SetPwdRegPassword` | `fh-tool set-pwd-reg-password --password ... --yes` |
|
|
123
|
+
| `DownloadFile` | `fh-tool download-file --file-name /var/... --output out.tar.gz --yes` |
|
|
124
|
+
| `RestoreDefaultSettings` | `fh-tool restore-default-settings --yes --danger --i-know-this-can-break-my-device` |
|
|
125
|
+
| `UploadPrepare` | `fh-tool upload-prepare` |
|
|
126
|
+
| `DeviceReboot` | `fh-tool reboot --yes --danger --i-know-this-can-break-my-device` |
|
|
127
|
+
| `GetPreconfig` | `fh-tool get-preconfig` |
|
|
128
|
+
| `SetPreconfig` | `fh-tool set-preconfig --fullname ... --yes --danger` |
|
|
129
|
+
| `TelnetEnable` | `fh-tool telnet enable --yes` / `fh-tool telnet disable --yes --danger` |
|
|
130
|
+
| `GetPppoeAccount` | `fh-tool pppoe-account` |
|
|
131
|
+
| `SetFHDebugLog` | `fh-tool set-fh-debug-log --module tr069 --data ... --yes --danger` |
|
|
132
|
+
| `CloseFHDebugLog` | `fh-tool close-fh-debug-log --yes` |
|
|
133
|
+
| `OpenFHDebugLog` | `fh-tool open-fh-debug-log --yes` |
|
|
134
|
+
|
|
135
|
+
raw call 入口:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
fh-tool call --func GetDevInfo
|
|
139
|
+
fh-tool call --func TelnetEnable --param telnet=1 --allow-risky
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## upload / download endpoint
|
|
143
|
+
|
|
144
|
+
下载 `LogDownload` 或 `DownloadFile` 返回的文件:
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
fh-tool download-url --url '/fh_tool/tool_download?file=xxx.tar.gz' --output xxx.tar.gz
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
上传前先获取 token:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
fh-tool upload-prepare
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
上传 firmware/preconfig:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
fh-tool upload --action preconfig --file sysinfo_conf --sessionid TOKEN --yes --danger
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## 风险边界
|
|
163
|
+
|
|
164
|
+
这些命令默认不会执行,必须显式确认:
|
|
165
|
+
|
|
166
|
+
- `--yes`: 会写设备状态或创建下载文件。
|
|
167
|
+
- `--danger`: 高风险写入、关闭 Telnet、执行 debug script、upload。
|
|
168
|
+
- `--i-know-this-can-break-my-device`: 恢复出厂或重启。
|
|
169
|
+
|
|
170
|
+
当前设备如果需要保持 Telnet 打开,不要运行:
|
|
171
|
+
|
|
172
|
+
```bash
|
|
173
|
+
fh-tool telnet disable --yes --danger
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## License
|
|
177
|
+
|
|
178
|
+
AGPL-3.0-or-later
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
fh_tool_cli/__init__.py,sha256=iIscsZ2qBZZo7S-sFSFxPQrqulMjqD-vpR5sVwCfJ6E,72
|
|
2
|
+
fh_tool_cli/cli.py,sha256=tkeuZHsl3Kq_hwJuN1TBo_VtsVNEHboHtaqNOHUni4M,32610
|
|
3
|
+
fh_tool_cli-0.1.0.dist-info/METADATA,sha256=y4EgML5r33Cj1FtLs4-g-p8-Clw4TMa4-bPjvnXjURM,4287
|
|
4
|
+
fh_tool_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
fh_tool_cli-0.1.0.dist-info/entry_points.txt,sha256=7zkyLkN5pemLvfLk5EucJvB4dGI6pJcn0z32ghziSjQ,84
|
|
6
|
+
fh_tool_cli-0.1.0.dist-info/top_level.txt,sha256=g3brii9lgqfPHXzj88IoqKtAxbtCfFOaYfx1Gk1M1j8,12
|
|
7
|
+
fh_tool_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fh_tool_cli
|