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.
@@ -0,0 +1,3 @@
1
+ """Local CLI for FiberHome fh_tool endpoints."""
2
+
3
+ __version__ = "0.1.0"
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ fh-tool = fh_tool_cli.cli:main
3
+ fh_tool-cli = fh_tool_cli.cli:main
@@ -0,0 +1 @@
1
+ fh_tool_cli