fh_tool-cli 0.2.2__py3-none-any.whl → 0.2.3__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/account.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import shlex
4
4
  import sys
5
5
  from dataclasses import dataclass
6
+ from hashlib import md5
6
7
  from secrets import token_urlsafe
7
8
  from typing import Any
8
9
 
@@ -13,6 +14,8 @@ WEB_ADMIN_PASSWORD_PATH = "InternetGatewayDevice.DeviceInfo.X_CT-COM_TeleComAcco
13
14
  TELNET_PASSWORD_PATH = "InternetGatewayDevice.DeviceInfo.X_CT-COM_ServiceManage.TelnetPassword"
14
15
  TELNET_USERNAME_PATH = "InternetGatewayDevice.DeviceInfo.X_CT-COM_ServiceManage.TelnetUserName"
15
16
  SU_RUNTIME_PASSWORD_FILE = "/var/telsu"
17
+ _MD5_CRYPT_MAGIC = b"$1$"
18
+ _MD5_CRYPT_ALPHABET = "./0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
16
19
 
17
20
 
18
21
  @dataclass(frozen=True)
@@ -69,7 +72,9 @@ def set_telnet_username(backend: CfgCmdBackend, name: str) -> dict[str, Any]:
69
72
 
70
73
 
71
74
  def set_su_runtime_password(shell_runner: Any, password: str) -> dict[str, Any]:
72
- command = f"printf %s {shlex.quote(password)} > {shlex.quote(SU_RUNTIME_PASSWORD_FILE)}"
75
+ hashed_password = _md5_crypt_empty_salt(password)
76
+ passwd_entry = f"root:{hashed_password}:0:0:Telnet user:/:/bin/ash"
77
+ command = f"printf '%s\\n' {shlex.quote(passwd_entry)} > {shlex.quote(SU_RUNTIME_PASSWORD_FILE)}"
73
78
  shell_runner(command)
74
79
  return {
75
80
  "path": SU_RUNTIME_PASSWORD_FILE,
@@ -80,6 +85,62 @@ def set_su_runtime_password(shell_runner: Any, password: str) -> dict[str, Any]:
80
85
  }
81
86
 
82
87
 
88
+ def _md5_crypt_empty_salt(password: str) -> str:
89
+ return _md5_crypt(password.encode(), b"")
90
+
91
+
92
+ def _md5_crypt(password: bytes, salt: bytes) -> str:
93
+ if len(salt) > 8:
94
+ salt = salt[:8]
95
+
96
+ digest = md5(password + _MD5_CRYPT_MAGIC + salt)
97
+ alternate = md5(password + salt + password).digest()
98
+ password_len = len(password)
99
+
100
+ remaining = password_len
101
+ while remaining > 0:
102
+ digest.update(alternate[: min(16, remaining)])
103
+ remaining -= 16
104
+
105
+ remaining = password_len
106
+ while remaining > 0:
107
+ if remaining & 1:
108
+ digest.update(b"\x00")
109
+ else:
110
+ digest.update(password[:1])
111
+ remaining >>= 1
112
+
113
+ final = digest.digest()
114
+ for index in range(1000):
115
+ digest = md5()
116
+ digest.update(password if index & 1 else final)
117
+ if index % 3:
118
+ digest.update(salt)
119
+ if index % 7:
120
+ digest.update(password)
121
+ digest.update(final if index & 1 else password)
122
+ final = digest.digest()
123
+
124
+ encoded = (
125
+ _md5_crypt_base64(final[0], final[6], final[12], 4)
126
+ + _md5_crypt_base64(final[1], final[7], final[13], 4)
127
+ + _md5_crypt_base64(final[2], final[8], final[14], 4)
128
+ + _md5_crypt_base64(final[3], final[9], final[15], 4)
129
+ + _md5_crypt_base64(final[4], final[10], final[5], 4)
130
+ + _md5_crypt_base64(0, 0, final[11], 2)
131
+ )
132
+ return f"$1${salt.decode()}${encoded}"
133
+
134
+
135
+ def _md5_crypt_base64(byte2: int, byte1: int, byte0: int, length: int) -> str:
136
+ value = (byte2 << 16) | (byte1 << 8) | byte0
137
+ encoded = []
138
+ for _ in range(length):
139
+ encoded.append(_MD5_CRYPT_ALPHABET[value & 0x3F])
140
+ value >>= 6
141
+ return "".join(encoded)
142
+
143
+
83
144
  def _redact_write_result(result: dict[str, Any]) -> dict[str, Any]:
84
145
  redacted = dict(result)
85
146
  redacted["value"] = "[REDACTED]"
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import logging
4
5
  import re
5
6
  import shlex
6
7
  from collections.abc import Callable
@@ -8,6 +9,7 @@ from pathlib import Path
8
9
  from typing import Any
9
10
 
10
11
  from ..errors import CliError
12
+ from ..output import log_event
11
13
 
12
14
  DANGER_CFG_PATH_RE = re.compile(
13
15
  r"(tr-?069|wan|pon|loid|vlan|preconfig|servicelist|smartswitch|cloudplat)",
@@ -57,13 +59,23 @@ class CfgCmdBackend:
57
59
  self.expensive_missing_paths = expensive_missing_paths
58
60
 
59
61
  def get(self, path: str) -> str:
60
- return parse_cfg_get_output(path, self._runner(_cfg_command("get", path)))
62
+ log_event(logging.DEBUG, "cfg.get.start", path=path, risk=cfg_read_risk(path))
63
+ output = self._runner(_cfg_command("get", path))
64
+ value = parse_cfg_get_output(path, output)
65
+ log_event(logging.DEBUG, "cfg.get.success", path=path, risk=cfg_read_risk(path), empty=not bool(value))
66
+ return value
61
67
 
62
68
  def set(self, path: str, value: str) -> str:
63
- return self._runner(_cfg_command("set", path, value))
69
+ log_event(logging.INFO, "cfg.set.start", path=path, risk=cfg_path_risk(path))
70
+ result = self._runner(_cfg_command("set", path, value))
71
+ log_event(logging.INFO, "cfg.set.success", path=path, risk=cfg_path_risk(path))
72
+ return result
64
73
 
65
74
  def attr(self, path: str) -> str:
66
- return self._runner(_cfg_command("attr", path)).strip()
75
+ log_event(logging.DEBUG, "cfg.attr.start", path=path, risk=cfg_read_risk(path))
76
+ result = self._runner(_cfg_command("attr", path)).strip()
77
+ log_event(logging.DEBUG, "cfg.attr.success", path=path, risk=cfg_read_risk(path), empty=not bool(result))
78
+ return result
67
79
 
68
80
 
69
81
  def cfg_set_with_verify(backend: CfgCmdBackend, path: str, value: str) -> dict[str, Any]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import logging
4
5
  from typing import Any
5
6
 
6
7
  import requests
@@ -8,6 +9,7 @@ import requests
8
9
  from ..config_store import format_mac, resolve_ip, resolve_mac
9
10
  from ..crypto import decrypt_payload, derive_crypto, encrypt_payload
10
11
  from ..errors import FHToolError
12
+ from ..output import log_event
11
13
 
12
14
  FH_TOOL_API_PATH = "/fh_tool/api"
13
15
  FH_TOOL_UPLOAD_PATH = "/fh_tool/upload"
@@ -22,6 +24,8 @@ def fh_tool_call(
22
24
  crypto = derive_crypto(mac)
23
25
  encrypted = encrypt_payload(payload, crypto)
24
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)
25
29
  try:
26
30
  response = requests.post(
27
31
  url,
@@ -34,15 +38,35 @@ def fh_tool_call(
34
38
  allow_redirects=False,
35
39
  )
36
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))
37
42
  raise FHToolError(f"无法连接 {url}: {exc}") from exc
38
43
 
39
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
+ )
40
53
  raise FHToolError(f"{FH_TOOL_API_PATH} 返回 HTTP {response.status_code}")
41
54
 
42
55
  try:
43
- return decrypt_payload(response.text, crypto)
56
+ result = decrypt_payload(response.text, crypto)
44
57
  except Exception as exc:
58
+ log_event(logging.INFO, "fh_tool.api.decrypt_error", ip=ip, path=FH_TOOL_API_PATH, func=func)
45
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
46
70
 
47
71
 
48
72
  def api_payload(func: str, params: dict[str, Any] | None = None, index: str = "1") -> dict[str, Any]:
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import socket
4
+ import logging
4
5
  from dataclasses import dataclass
5
6
 
6
7
  from ..config_store import decode_text
7
8
  from ..errors import FHToolError
9
+ from ..output import log_event
8
10
 
9
11
 
10
12
  @dataclass(frozen=True)
@@ -21,6 +23,14 @@ class TelnetShell:
21
23
  self.credentials = credentials
22
24
 
23
25
  def run(self, command: str) -> str:
26
+ log_event(
27
+ logging.INFO,
28
+ "telnet.command.start",
29
+ host=self.credentials.host,
30
+ port=self.credentials.port,
31
+ username_present=bool(self.credentials.username),
32
+ password_present=bool(self.credentials.password),
33
+ )
24
34
  try:
25
35
  with socket.create_connection(
26
36
  (self.credentials.host, self.credentials.port),
@@ -35,10 +45,72 @@ class TelnetShell:
35
45
  sock.sendall(self.credentials.password.encode("utf-8") + b"\n")
36
46
  sock.sendall(command.encode("utf-8") + b"\n")
37
47
  sock.sendall(b"exit\n")
38
- return decode_text(_read_all(sock))
48
+ output = decode_text(_read_all(sock))
49
+ log_event(
50
+ logging.INFO,
51
+ "telnet.command.success",
52
+ host=self.credentials.host,
53
+ port=self.credentials.port,
54
+ output_bytes=len(output.encode("utf-8")),
55
+ )
56
+ return output
39
57
  except OSError as exc:
58
+ log_event(
59
+ logging.INFO,
60
+ "telnet.command.error",
61
+ host=self.credentials.host,
62
+ port=self.credentials.port,
63
+ error=str(exc),
64
+ )
40
65
  raise FHToolError(f"Telnet command failed: {exc}") from exc
41
66
 
67
+ def run_as_root(self, command: str, *, su_password: str) -> str:
68
+ log_event(
69
+ logging.INFO,
70
+ "telnet.root_command.start",
71
+ host=self.credentials.host,
72
+ port=self.credentials.port,
73
+ username_present=bool(self.credentials.username),
74
+ telnet_password_present=bool(self.credentials.password),
75
+ su_password_present=bool(su_password),
76
+ )
77
+ try:
78
+ with socket.create_connection(
79
+ (self.credentials.host, self.credentials.port),
80
+ timeout=self.credentials.timeout,
81
+ ) as sock:
82
+ sock.settimeout(self.credentials.timeout)
83
+ if self.credentials.username:
84
+ _read_until(sock, b"login:")
85
+ sock.sendall(self.credentials.username.encode("utf-8") + b"\n")
86
+ if self.credentials.password:
87
+ _read_until(sock, b"Password:")
88
+ sock.sendall(self.credentials.password.encode("utf-8") + b"\n")
89
+ sock.sendall(b"su root\n")
90
+ _read_until(sock, b"Password:")
91
+ sock.sendall(su_password.encode("utf-8") + b"\n")
92
+ sock.sendall(command.encode("utf-8") + b"\n")
93
+ sock.sendall(b"exit\n")
94
+ sock.sendall(b"exit\n")
95
+ output = decode_text(_read_all(sock))
96
+ log_event(
97
+ logging.INFO,
98
+ "telnet.root_command.success",
99
+ host=self.credentials.host,
100
+ port=self.credentials.port,
101
+ output_bytes=len(output.encode("utf-8")),
102
+ )
103
+ return output
104
+ except OSError as exc:
105
+ log_event(
106
+ logging.INFO,
107
+ "telnet.root_command.error",
108
+ host=self.credentials.host,
109
+ port=self.credentials.port,
110
+ error=str(exc),
111
+ )
112
+ raise FHToolError(f"Telnet root command failed: {exc}") from exc
113
+
42
114
 
43
115
  def _read_until(sock: socket.socket, marker: bytes) -> bytes:
44
116
  data = bytearray()
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import base64
4
4
  import hashlib
5
+ import logging
5
6
  import re
6
7
  from typing import Any
7
8
  from urllib.parse import urljoin
@@ -11,6 +12,7 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
11
12
  from cryptography.hazmat.primitives.padding import PKCS7
12
13
 
13
14
  from ..errors import FHToolError
15
+ from ..output import log_event
14
16
 
15
17
  DEFAULT_AJAX_PATH = "/cgi-bin/ajax"
16
18
  DEFAULT_WEB_LOGIN_PORT = "0"
@@ -65,10 +67,13 @@ class WebAjaxClient:
65
67
 
66
68
  def login_check(self) -> dict[str, Any]:
67
69
  url = self.base_url
70
+ log_event(logging.INFO, "web.login_check.start", base_url=self.base_url)
68
71
  try:
69
72
  response = self.session.get(url, timeout=self.timeout, allow_redirects=False)
70
73
  except requests.RequestException as exc:
74
+ log_event(logging.INFO, "web.login_check.error", base_url=self.base_url, error=str(exc))
71
75
  raise FHToolError(f"Web login-check failed {url}: {exc}") from exc
76
+ log_event(logging.INFO, "web.login_check.response", base_url=self.base_url, status_code=response.status_code)
72
77
  result = {
73
78
  "url": url,
74
79
  "status_code": response.status_code,
@@ -112,6 +117,7 @@ class WebAjaxClient:
112
117
  *,
113
118
  port: str = DEFAULT_WEB_LOGIN_PORT,
114
119
  ) -> dict[str, Any]:
120
+ log_event(logging.INFO, "web.login.start", base_url=self.base_url, username=username)
115
121
  brmad = self._fetch_brmad()
116
122
  self.ajax_get("get_operator", use_session=False)
117
123
  loginpd = fiberhome_web_encrypt(
@@ -128,10 +134,21 @@ class WebAjaxClient:
128
134
  )
129
135
  response = result.get("response")
130
136
  login_result = response.get("login_result") if isinstance(response, dict) else None
137
+ ok = result["ok"] and str(login_result) == "0"
138
+ log_event(
139
+ logging.INFO,
140
+ "web.login.response",
141
+ base_url=self.base_url,
142
+ username=username,
143
+ status_code=result["status_code"],
144
+ ok=ok,
145
+ login_result=login_result,
146
+ sessionid_present=bool(self.sessionid),
147
+ )
131
148
  return {
132
149
  "method": "do_login",
133
150
  "status_code": result["status_code"],
134
- "ok": result["ok"] and str(login_result) == "0",
151
+ "ok": ok,
135
152
  "login_result": login_result,
136
153
  "username": username,
137
154
  "sessionid_present": bool(self.sessionid),
@@ -151,6 +168,14 @@ class WebAjaxClient:
151
168
  request_params.update(params)
152
169
  if use_session and self.sessionid:
153
170
  request_params.setdefault("sessionid", self.sessionid)
171
+ log_event(
172
+ logging.DEBUG,
173
+ "web.ajax.request",
174
+ base_url=self.base_url,
175
+ http_method="GET",
176
+ method=method,
177
+ sessionid_present=bool(request_params.get("sessionid")),
178
+ )
154
179
  try:
155
180
  response = self.session.get(
156
181
  url,
@@ -159,6 +184,14 @@ class WebAjaxClient:
159
184
  allow_redirects=False,
160
185
  )
161
186
  except requests.RequestException as exc:
187
+ log_event(
188
+ logging.INFO,
189
+ "web.ajax.error",
190
+ base_url=self.base_url,
191
+ http_method="GET",
192
+ method=method,
193
+ error=str(exc),
194
+ )
162
195
  raise FHToolError(f"Web AJAX GET failed {url}: {exc}") from exc
163
196
  return self._response_result("GET", url, method, response)
164
197
 
@@ -168,6 +201,14 @@ class WebAjaxClient:
168
201
  request_data["ajaxmethod"] = method
169
202
  if self.sessionid:
170
203
  request_data.setdefault("sessionid", self.sessionid)
204
+ log_event(
205
+ logging.DEBUG,
206
+ "web.ajax.request",
207
+ base_url=self.base_url,
208
+ http_method="POST",
209
+ method=method,
210
+ sessionid_present=bool(request_data.get("sessionid")),
211
+ )
171
212
  try:
172
213
  response = self.session.post(
173
214
  url,
@@ -176,6 +217,14 @@ class WebAjaxClient:
176
217
  allow_redirects=False,
177
218
  )
178
219
  except requests.RequestException as exc:
220
+ log_event(
221
+ logging.INFO,
222
+ "web.ajax.error",
223
+ base_url=self.base_url,
224
+ http_method="POST",
225
+ method=method,
226
+ error=str(exc),
227
+ )
179
228
  raise FHToolError(f"Web AJAX POST failed {url}: {exc}") from exc
180
229
  return self._response_result("POST", url, method, response)
181
230
 
@@ -319,6 +368,16 @@ class WebAjaxClient:
319
368
  "content_type": response.headers.get("Content-Type"),
320
369
  "sessionid_present": bool(self.sessionid),
321
370
  }
371
+ log_event(
372
+ logging.DEBUG,
373
+ "web.ajax.response",
374
+ base_url=self.base_url,
375
+ http_method=http_method,
376
+ method=method,
377
+ status_code=response.status_code,
378
+ ok=result["ok"],
379
+ sessionid_present=bool(self.sessionid),
380
+ )
322
381
  try:
323
382
  payload = response.json()
324
383
  except ValueError:
fh_tool_cli/backup.py CHANGED
@@ -23,13 +23,30 @@ BACKUP_PATHS = [
23
23
  "/fhconf/status_version_flag",
24
24
  "/fhconf/cfgmgr_restore_flag_conf",
25
25
  "/fhconf/factory_reset_conf",
26
+ "/fhconf/fhcfgmonitor.conf",
27
+ "/fhconf/fac_process_start_list",
28
+ "/fhconf/u_config_conf",
26
29
  "/fhconf/process_start_list",
30
+ "/fhconf/process_monitor_list",
31
+ "/fhconf/normal_reboot_flag",
32
+ "/fhconf/crc_file",
33
+ "/fhconf/usrconfig_dl_conf",
34
+ "/fhconf/usrconfig_dl_flag",
35
+ "/fhconf/gdecms/gdecms_conf",
27
36
  "/fhconf/tr069_control_conf",
28
37
  "/fhconf/xpon_tr069cfg_conf",
29
38
  "/fhdata/factory_conf",
30
39
  "/fhdata/sysinfo_conf",
31
40
  "/fhdata/pre_usrconfig_conf",
32
41
  "/fhdata/pre_attrconfig_conf",
42
+ "/fhdata/tr069_control_conf",
43
+ "/fhdata/voice_digitmap_conf",
44
+ "/fhdata/factorymodeflag",
45
+ "/opt/upt/apps/info/reboot_info",
46
+ "/opt/upt/apps/info/crash_info",
47
+ "/opt/upt/apps/mtd.booting",
48
+ "/opt/upt/apps/upt.mtd",
49
+ "/opt/upt/apps/saf-upgradeinfo",
33
50
  "/var/tel_passwd",
34
51
  "/var/telWan_passwd",
35
52
  "/var/telsu",
fh_tool_cli/cli.py CHANGED
@@ -299,7 +299,7 @@ def command_restore_backup(args: argparse.Namespace) -> dict[str, Any]:
299
299
  if args.target == "device":
300
300
  result = restore_backup_to_device(
301
301
  Path(args.backup).expanduser(),
302
- shell_runner=_telnet_shell_from_args(args).run,
302
+ shell_runner=_telnet_root_shell_runner_from_args(args),
303
303
  dry_run=dry_run,
304
304
  paths=args.path,
305
305
  remote_tmpdir=args.remote_tmpdir,
@@ -380,6 +380,36 @@ def _telnet_shell_from_args(args: argparse.Namespace) -> TelnetShell:
380
380
  return TelnetShell(_telnet_credentials_from_args(args))
381
381
 
382
382
 
383
+ def _telnet_root_shell_runner_from_args(args: argparse.Namespace) -> Callable[[str], str]:
384
+ if getattr(args, "backend", "telnet") == "local-vm":
385
+ return LocalVmShell(
386
+ Path(args.vm_root).expanduser(),
387
+ timeout=args.timeout,
388
+ ).run
389
+ ip, _ip_source = resolve_ip(args)
390
+ su_password = _current_su_password_from_args(args, ip=ip)
391
+ shell = _telnet_shell_from_args(args)
392
+
393
+ def run(command: str) -> str:
394
+ return shell.run_as_root(command, su_password=su_password)
395
+
396
+ return run
397
+
398
+
399
+ def _current_su_password_from_args(args: argparse.Namespace, *, ip: str) -> str:
400
+ has_password = getattr(args, "su_password", None) is not None
401
+ has_stdin = getattr(args, "su_password_stdin", False)
402
+ if has_password and has_stdin:
403
+ raise CliError("当前 su root 密码只能选择 --su-password 或 --su-password-stdin")
404
+ if has_stdin:
405
+ if getattr(args, "password_stdin", False) or getattr(args, "telnet_password_stdin", False):
406
+ raise CliError("--su-password-stdin 不能和其它 stdin 密码选项同时使用")
407
+ return sys.stdin.readline().rstrip("\n")
408
+ if has_password:
409
+ return str(args.su_password)
410
+ return derive_hg5143f_su_password_from_args(args, ip=ip)
411
+
412
+
383
413
  def _shell_runner_from_args(args: argparse.Namespace) -> Callable[[str], str]:
384
414
  if getattr(args, "backend", "telnet") == "local-vm":
385
415
  return LocalVmShell(
@@ -516,7 +546,7 @@ def command_account_set_su_runtime_password(args: argparse.Namespace) -> dict[st
516
546
  side_effects={"runtime_password_write": False},
517
547
  )
518
548
  secret, password_source = _su_runtime_password_from_args(args)
519
- result = set_su_runtime_password(_telnet_shell_from_args(args).run, secret.password)
549
+ result = set_su_runtime_password(_telnet_root_shell_runner_from_args(args), secret.password)
520
550
  result["generated"] = secret.generated
521
551
  result["password_source"] = password_source
522
552
  return result
@@ -570,10 +600,10 @@ def command_autoupdate_plan(args: argparse.Namespace) -> dict[str, Any]:
570
600
 
571
601
 
572
602
  def command_tr069_status(args: argparse.Namespace) -> dict[str, Any]:
573
- shell = _telnet_shell_from_args(args)
603
+ shell_runner = _telnet_root_shell_runner_from_args(args)
574
604
  return tr069_status(
575
605
  _cfg_backend_from_args(args),
576
- shell.run,
606
+ shell_runner,
577
607
  reveal_secrets=args.reveal_secrets,
578
608
  )
579
609
 
@@ -624,10 +654,10 @@ def command_tr069_randomize_connection_request(args: argparse.Namespace) -> dict
624
654
 
625
655
 
626
656
  def command_cloud_status(args: argparse.Namespace) -> dict[str, Any]:
627
- shell = _telnet_shell_from_args(args)
657
+ shell_runner = _telnet_root_shell_runner_from_args(args)
628
658
  return cloud_status(
629
659
  _cfg_backend_from_args(args),
630
- shell.run,
660
+ shell_runner,
631
661
  reveal_secrets=args.reveal_secrets,
632
662
  )
633
663
 
@@ -659,7 +689,7 @@ def command_cloud_disable_cloudclt(args: argparse.Namespace) -> dict[str, Any]:
659
689
  "cloud disable-cloudclt 会停止/禁用 cloud client",
660
690
  side_effects={"shell_write": False, "service_stop": False},
661
691
  )
662
- return cloud_disable_cloudclt(_telnet_shell_from_args(args).run)
692
+ return cloud_disable_cloudclt(_telnet_root_shell_runner_from_args(args))
663
693
 
664
694
 
665
695
  def command_cloud_disable_smartswitch(args: argparse.Namespace) -> dict[str, Any]:
@@ -686,7 +716,7 @@ def command_vlan_list(args: argparse.Namespace) -> dict[str, Any]:
686
716
 
687
717
 
688
718
  def command_ip_status(args: argparse.Namespace) -> dict[str, Any]:
689
- return ip_status(_shell_runner_from_args(args))
719
+ return ip_status(_telnet_root_shell_runner_from_args(args))
690
720
 
691
721
 
692
722
  def command_firewall_status(args: argparse.Namespace) -> dict[str, Any]:
fh_tool_cli/click_cli.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import logging
4
5
  import sys
5
6
  from types import SimpleNamespace
6
7
  from typing import Any, Callable
@@ -8,7 +9,7 @@ from typing import Any, Callable
8
9
  import click
9
10
 
10
11
  from .errors import CliError, FHToolError
11
- from .output import configure_logging, emit, emit_cancelled, emit_error
12
+ from .output import configure_logging, emit, emit_cancelled, emit_error, log_event
12
13
  from .parser import HandlerMap, build_parser
13
14
 
14
15
 
@@ -150,11 +151,7 @@ def _command_from_parser(
150
151
  def _parent_callback(fixed: dict[str, Any]) -> Callable[..., None]:
151
152
  @click.pass_context
152
153
  def callback(ctx: click.Context, **kwargs: Any) -> None:
153
- configure_logging(
154
- verbose=int(kwargs.pop("verbose", 0) or 0),
155
- quiet=int(kwargs.pop("quiet", 0) or 0),
156
- log_file=kwargs.pop("log_file", None),
157
- )
154
+ _configure_logging_from_kwargs(kwargs)
158
155
  state = ctx.ensure_object(dict)
159
156
  state.update(fixed)
160
157
  state.update(_normalize_click_values(kwargs))
@@ -165,11 +162,7 @@ def _parent_callback(fixed: dict[str, Any]) -> Callable[..., None]:
165
162
  def _group_callback(parser: argparse.ArgumentParser, fixed: dict[str, Any]) -> Callable[..., int | None]:
166
163
  @click.pass_context
167
164
  def callback(ctx: click.Context, **kwargs: Any) -> int | None:
168
- configure_logging(
169
- verbose=int(kwargs.pop("verbose", 0) or 0),
170
- quiet=int(kwargs.pop("quiet", 0) or 0),
171
- log_file=kwargs.pop("log_file", None),
172
- )
165
+ _configure_logging_from_kwargs(kwargs)
173
166
  state = ctx.ensure_object(dict)
174
167
  state.update(fixed)
175
168
  state.update(_normalize_click_values(kwargs))
@@ -191,6 +184,16 @@ def _leaf_callback(parser: argparse.ArgumentParser, fixed: dict[str, Any]) -> Ca
191
184
  return callback
192
185
 
193
186
 
187
+ def _configure_logging_from_kwargs(kwargs: dict[str, Any]) -> None:
188
+ if not {"verbose", "quiet", "log_file"} & set(kwargs):
189
+ return
190
+ configure_logging(
191
+ verbose=int(kwargs.pop("verbose", 0) or 0),
192
+ quiet=int(kwargs.pop("quiet", 0) or 0),
193
+ log_file=kwargs.pop("log_file", None),
194
+ )
195
+
196
+
194
197
  def _run_parser_handler(parser: argparse.ArgumentParser, values: dict[str, Any]) -> int:
195
198
  args_values = dict(parser._defaults)
196
199
  args_values.update(values)
@@ -198,16 +201,30 @@ def _run_parser_handler(parser: argparse.ArgumentParser, values: dict[str, Any])
198
201
  if handler is None:
199
202
  raise FHToolClickError("missing command handler")
200
203
  args = SimpleNamespace(**args_values)
204
+ handler_name = getattr(handler, "__name__", handler.__class__.__name__)
205
+ command = _command_name(args_values)
201
206
 
202
207
  try:
208
+ log_event(logging.DEBUG, "cli.command.start", command=command, handler=handler_name)
203
209
  result = handler(args)
204
210
  except (argparse.ArgumentTypeError, CliError, FHToolError, ValueError) as exc:
211
+ log_event(logging.INFO, "cli.command.error", command=command, handler=handler_name, error=str(exc))
205
212
  raise FHToolClickError(str(exc)) from exc
206
213
 
207
214
  emit(result, bool(getattr(args, "json", False)))
215
+ log_event(logging.DEBUG, "cli.command.success", command=command, handler=handler_name)
208
216
  return 0
209
217
 
210
218
 
219
+ def _command_name(values: dict[str, Any]) -> str:
220
+ parts = [
221
+ value
222
+ for key, value in values.items()
223
+ if key == "command" or key.endswith("_command")
224
+ ]
225
+ return " ".join(str(part) for part in parts if part) or "root"
226
+
227
+
211
228
  def _normalize_click_values(values: dict[str, Any]) -> dict[str, Any]:
212
229
  normalized: dict[str, Any] = {}
213
230
  for key, value in values.items():
fh_tool_cli/client.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import logging
3
4
  import socket
4
5
  from pathlib import Path
5
6
  from typing import Any
@@ -8,13 +9,17 @@ from urllib.parse import urljoin
8
9
  import requests
9
10
 
10
11
  from .errors import FHToolError
12
+ from .output import log_event
11
13
 
12
14
 
13
15
  def tcp_open(ip: str, port: int, timeout: float) -> bool:
16
+ log_event(logging.DEBUG, "tcp.probe.start", ip=ip, port=port)
14
17
  try:
15
18
  with socket.create_connection((ip, port), timeout=timeout):
19
+ log_event(logging.DEBUG, "tcp.probe.result", ip=ip, port=port, open=True)
16
20
  return True
17
21
  except OSError:
22
+ log_event(logging.DEBUG, "tcp.probe.result", ip=ip, port=port, open=False)
18
23
  return False
19
24
 
20
25
 
@@ -26,20 +31,32 @@ def make_download_url(ip: str, value: str) -> str:
26
31
 
27
32
  def download_to_file(ip: str, url_value: str, output: Path, timeout: float) -> dict[str, Any]:
28
33
  url = make_download_url(ip, url_value)
34
+ log_event(logging.INFO, "download.start", url=url, output=str(output))
29
35
  try:
30
36
  response = requests.get(url, timeout=timeout, stream=True)
31
37
  except requests.RequestException as exc:
38
+ log_event(logging.INFO, "download.error", url=url, output=str(output), error=str(exc))
32
39
  raise FHToolError(f"下载失败 {url}: {exc}") from exc
33
40
  if response.status_code != 200:
41
+ log_event(logging.INFO, "download.http_error", url=url, output=str(output), status_code=response.status_code)
34
42
  raise FHToolError(f"下载失败 {url}: HTTP {response.status_code}")
35
43
  output.parent.mkdir(parents=True, exist_ok=True)
36
44
  with output.open("wb") as file:
37
45
  for chunk in response.iter_content(chunk_size=1024 * 128):
38
46
  if chunk:
39
47
  file.write(chunk)
48
+ bytes_written = output.stat().st_size
49
+ log_event(
50
+ logging.INFO,
51
+ "download.success",
52
+ url=url,
53
+ output=str(output),
54
+ bytes=bytes_written,
55
+ content_type=response.headers.get("Content-Type"),
56
+ )
40
57
  return {
41
58
  "url": url,
42
59
  "output": str(output),
43
- "bytes": output.stat().st_size,
60
+ "bytes": bytes_written,
44
61
  "content_type": response.headers.get("Content-Type"),
45
62
  }
@@ -81,10 +81,10 @@ def _web_login_if_requested(
81
81
  explicit_secret = getattr(args, "password", None) is not None or getattr(args, "password_stdin", False)
82
82
  explicit_username = getattr(args, "username", None) is not None
83
83
  explicit_source = password_source != "auto"
84
- if client.sessionid and force_auto and not (explicit_secret or explicit_username or explicit_source):
84
+ if _has_explicit_web_sessionid(args) and force_auto and not (explicit_secret or explicit_source):
85
85
  return {
86
86
  "attempted": False,
87
- "username": DEFAULT_WEB_USERNAME,
87
+ "username": getattr(args, "username", None) or DEFAULT_WEB_USERNAME,
88
88
  "password_source": "sessionid",
89
89
  "sessionid_present": True,
90
90
  }
@@ -95,6 +95,8 @@ def _web_login_if_requested(
95
95
  password, source, errors = _web_password_from_args(args)
96
96
  if password is None:
97
97
  if password_source == "none":
98
+ if require_success and not client.sessionid:
99
+ raise CliError("Web login 需要密码或显式 --sessionid")
98
100
  return {
99
101
  "attempted": False,
100
102
  "username": username,
@@ -112,11 +114,23 @@ def _web_login_if_requested(
112
114
  "error": error,
113
115
  }
114
116
 
115
- result = client.login(
116
- username,
117
- password,
118
- port=getattr(args, "web_login_port", DEFAULT_WEB_LOGIN_PORT),
119
- )
117
+ try:
118
+ result = client.login(
119
+ username,
120
+ password,
121
+ port=getattr(args, "web_login_port", DEFAULT_WEB_LOGIN_PORT),
122
+ )
123
+ except FHToolError as exc:
124
+ if require_success:
125
+ raise CliError(f"Web login failed: {exc}") from exc
126
+ return {
127
+ "attempted": True,
128
+ "username": username,
129
+ "password_source": source,
130
+ "sessionid_present": bool(client.sessionid),
131
+ "ok": False,
132
+ "error": str(exc),
133
+ }
120
134
  result["attempted"] = True
121
135
  result["password_source"] = source
122
136
  if require_success and not result["ok"]:
@@ -126,6 +140,10 @@ def _web_login_if_requested(
126
140
  return result
127
141
 
128
142
 
143
+ def _has_explicit_web_sessionid(args: argparse.Namespace) -> bool:
144
+ return getattr(args, "sessionid", None) is not None
145
+
146
+
129
147
  def _web_password_from_args(args: argparse.Namespace) -> tuple[str | None, str, list[str]]:
130
148
  has_password = getattr(args, "password", None) is not None
131
149
  has_stdin = getattr(args, "password_stdin", False)
@@ -263,8 +281,10 @@ def command_web_login_check(args: argparse.Namespace) -> dict[str, Any]:
263
281
 
264
282
  def command_web_ajax_get(args: argparse.Namespace) -> dict[str, Any]:
265
283
  client = _web_client_from_args(args)
266
- _web_login_if_requested(client, args)
267
- return client.ajax_get(args.method)
284
+ login_result = _web_read_login(client, args)
285
+ result = client.ajax_get(args.method)
286
+ result["login"] = _auth_summary(login_result)
287
+ return result
268
288
 
269
289
 
270
290
  def command_web_ajax_post(args: argparse.Namespace) -> dict[str, Any]:
@@ -453,6 +473,10 @@ def _auth_summary(auth: dict[str, Any] | None) -> dict[str, Any]:
453
473
  return summary
454
474
 
455
475
 
476
+ def _web_read_login(client: WebAjaxClient, args: argparse.Namespace) -> dict[str, Any] | None:
477
+ return _web_login_if_requested(client, args, require_success=False, force_auto=True)
478
+
479
+
456
480
  def _require_lan_target(args: argparse.Namespace) -> None:
457
481
  ip, _ip_source = resolve_ip(args)
458
482
  address = ipaddress.ip_address(ip)
@@ -464,15 +488,17 @@ def _require_lan_target(args: argparse.Namespace) -> None:
464
488
  def command_web_typed(group: str, action: str) -> Callable[[argparse.Namespace], dict[str, Any]]:
465
489
  def handler(args: argparse.Namespace) -> dict[str, Any]:
466
490
  client = _web_client_from_args(args)
467
- _web_login_if_requested(client, args)
468
- return client.typed(group, action)
491
+ login_result = _web_read_login(client, args)
492
+ result = client.typed(group, action)
493
+ result["login"] = _auth_summary(login_result)
494
+ return result
469
495
 
470
496
  return handler
471
497
 
472
498
 
473
499
  def command_web_diagnostics_show(args: argparse.Namespace) -> dict[str, Any]:
474
500
  client = _web_client_from_args(args)
475
- _web_login_if_requested(client, args)
501
+ login_result = _web_read_login(client, args)
476
502
  requested = args.view or [
477
503
  "wan",
478
504
  "port-mapping",
@@ -501,6 +527,7 @@ def command_web_diagnostics_show(args: argparse.Namespace) -> dict[str, Any]:
501
527
  "errors": errors,
502
528
  "partial_failure": bool(errors),
503
529
  "sessionid_present": bool(client.sessionid),
530
+ "login": _auth_summary(login_result),
504
531
  }
505
532
  }
506
533
 
@@ -517,8 +544,12 @@ def command_web_typed_write(group: str, action: str) -> Callable[[argparse.Names
517
544
  if not payload:
518
545
  raise CliError("Web AJAX write 需要 typed 参数、--param 或 --json-payload")
519
546
  client = _web_client_from_args(args)
520
- _web_login_if_requested(client, args)
547
+ login_result = None
548
+ if not dry_run:
549
+ login_result = _web_login_if_requested(client, args, require_success=True, force_auto=True)
521
550
  result = client.typed_write(group, action, payload, dry_run=dry_run)
551
+ if login_result is not None:
552
+ result["login"] = _auth_summary(login_result)
522
553
  if dry_run:
523
554
  result["plan"] = {"execute_requires": ["--confirm"]}
524
555
  result.update(dry_run_notice())
fh_tool_cli/output.py CHANGED
@@ -2,12 +2,38 @@ from __future__ import annotations
2
2
 
3
3
  import json
4
4
  import logging
5
+ import re
5
6
  import sys
7
+ from datetime import datetime, timezone
6
8
  from pathlib import Path
7
9
  from typing import Any
8
10
 
9
11
  from rich.console import Console
10
12
 
13
+ SENSITIVE_LOG_KEY_RE = re.compile(
14
+ r"(sessionid|token|password|passwd|pwd|loginpd|loid|pppoe|acs|secret|psk|wepkey|wpakey|regpwd)",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+
19
+ class StructuredLogFormatter(logging.Formatter):
20
+ def format(self, record: logging.LogRecord) -> str:
21
+ event = getattr(record, "event", None)
22
+ fields = getattr(record, "structured_fields", None)
23
+ payload: dict[str, Any] = {
24
+ "timestamp": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
25
+ "level": record.levelname.lower(),
26
+ "logger": record.name,
27
+ "message": record.getMessage(),
28
+ }
29
+ if event:
30
+ payload["event"] = event
31
+ if isinstance(fields, dict):
32
+ payload.update(fields)
33
+ if record.exc_info:
34
+ payload["exception"] = self.formatException(record.exc_info)
35
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
36
+
11
37
 
12
38
  def configure_logging(*, verbose: int = 0, quiet: int = 0, log_file: str | None = None) -> None:
13
39
  level = logging.WARNING
@@ -24,14 +50,40 @@ def configure_logging(*, verbose: int = 0, quiet: int = 0, log_file: str | None
24
50
  path.parent.mkdir(parents=True, exist_ok=True)
25
51
  handlers.append(logging.FileHandler(path, encoding="utf-8"))
26
52
 
53
+ formatter = StructuredLogFormatter()
54
+ for handler in handlers:
55
+ handler.setFormatter(formatter)
56
+
27
57
  logging.basicConfig(
28
58
  level=level,
29
- format="%(levelname)s %(name)s: %(message)s",
30
59
  handlers=handlers,
31
60
  force=True,
32
61
  )
33
62
 
34
63
 
64
+ def log_event(level: int, event: str, **fields: Any) -> None:
65
+ logging.getLogger("fh_tool_cli").log(
66
+ level,
67
+ event,
68
+ extra={
69
+ "event": event,
70
+ "structured_fields": redact_log_fields(fields),
71
+ },
72
+ )
73
+
74
+
75
+ def redact_log_fields(value: Any, *, key: str | None = None) -> Any:
76
+ if key is not None and SENSITIVE_LOG_KEY_RE.search(key):
77
+ return "[REDACTED]"
78
+ if isinstance(value, dict):
79
+ return {str(item_key): redact_log_fields(item, key=str(item_key)) for item_key, item in value.items()}
80
+ if isinstance(value, list):
81
+ return [redact_log_fields(item) for item in value]
82
+ if isinstance(value, tuple):
83
+ return [redact_log_fields(item) for item in value]
84
+ return value
85
+
86
+
35
87
  def emit(data: Any, json_mode: bool) -> None:
36
88
  if json_mode or isinstance(data, dict):
37
89
  sys.stdout.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
fh_tool_cli/parser.py CHANGED
@@ -125,6 +125,8 @@ def add_telnet_options(
125
125
  action="store_true",
126
126
  help="关闭默认 HG5143F 派生 Telnet 凭据 fallback",
127
127
  )
128
+ parser.add_argument("--su-password", help="当前 su root 密码;未提供时默认使用 HG5143F 派生候选")
129
+ parser.add_argument("--su-password-stdin", action="store_true", help="从 stdin 读取当前 su root 密码")
128
130
 
129
131
 
130
132
  def add_cfg_backend_options(parser: argparse.ArgumentParser) -> None:
fh_tool_cli/remote.py CHANGED
@@ -36,6 +36,7 @@ CLOUD_ENDPOINT_PATHS = {
36
36
  CLOUD_PROCESSES = ("gdecms", "saf", "appmgr", "cloudclient", "cloudclocal", "cloudclt")
37
37
  SMARTSWITCH_PATH = CLOUD_ENDPOINT_PATHS["smart_switch"]
38
38
  SMARTSWITCH_DISABLED_VALUE = "0"
39
+ SMARTSWITCH_CONFIRMED_STORAGE_PATH = "/fhdata/sysinfo_conf"
39
40
  SMARTSWITCH_IMPACT = [
40
41
  "Blocks SAF/appframework cloud integration paths controlled by SmartSwitch.",
41
42
  "May affect operator cloud management features exposed through CloudPlat.",
@@ -159,6 +160,8 @@ def remote_plan(kind: str) -> dict[str, Any]:
159
160
  "description": "Set SmartSwitch=0 to block SAF/appframework.",
160
161
  "path": SMARTSWITCH_PATH,
161
162
  "target_value": SMARTSWITCH_DISABLED_VALUE,
163
+ "confirmed_storage_path": SMARTSWITCH_CONFIRMED_STORAGE_PATH,
164
+ "storage_path_basis": "research_confirmed",
162
165
  "impact": SMARTSWITCH_IMPACT,
163
166
  "forbidden_changes": SMARTSWITCH_FORBIDDEN_CHANGES,
164
167
  "device_write": True,
@@ -205,13 +208,27 @@ def tr069_randomize_connection_request(backend: CfgCmdBackend) -> dict[str, Any]
205
208
 
206
209
 
207
210
  def cloud_disable_cloudclt(shell_runner: Any) -> dict[str, Any]:
208
- command = "cloudclt stop 2>/dev/null || /etc/init.d/cloudclt stop 2>/dev/null || true"
209
- output = shell_runner(command)
211
+ commands = [
212
+ "lxc-attach -n saf -- /etc/init.d/cloudclt stop 2>&1 || true",
213
+ "lxc-attach -n saf -- /etc/init.d/cloudclt disable 2>&1 || true",
214
+ ]
215
+ command_results = [
216
+ {
217
+ "command": command,
218
+ "output": str(shell_runner(command)),
219
+ }
220
+ for command in commands
221
+ ]
210
222
  return {
211
223
  "action": "disable-cloudclt",
212
224
  "risk": "danger",
213
- "command": command,
214
- "output": str(output),
225
+ "target": {
226
+ "container": "saf",
227
+ "service": "cloudclt",
228
+ },
229
+ "command": " && ".join(commands),
230
+ "output": "\n".join(result["output"] for result in command_results),
231
+ "commands": command_results,
215
232
  }
216
233
 
217
234
 
@@ -226,6 +243,8 @@ def cloud_disable_smartswitch(backend: CfgCmdBackend) -> dict[str, Any]:
226
243
  "risk": "danger",
227
244
  "path": SMARTSWITCH_PATH,
228
245
  "target_value": SMARTSWITCH_DISABLED_VALUE,
246
+ "confirmed_storage_path": SMARTSWITCH_CONFIRMED_STORAGE_PATH,
247
+ "storage_path_basis": "research_confirmed",
229
248
  "impact": SMARTSWITCH_IMPACT,
230
249
  "forbidden_changes": SMARTSWITCH_FORBIDDEN_CHANGES,
231
250
  "write": {
@@ -258,6 +277,8 @@ def _smart_switch_status(status: dict[str, Any]) -> dict[str, Any]:
258
277
  result: dict[str, Any] = {
259
278
  "path": SMARTSWITCH_PATH,
260
279
  "disable_value": SMARTSWITCH_DISABLED_VALUE,
280
+ "confirmed_storage_path": SMARTSWITCH_CONFIRMED_STORAGE_PATH,
281
+ "storage_path_basis": "research_confirmed",
261
282
  "risk": "danger",
262
283
  "impact": SMARTSWITCH_IMPACT,
263
284
  "forbidden_changes": SMARTSWITCH_FORBIDDEN_CHANGES,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fh_tool-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: 用于本地管理 FiberHome fh_tool 接口的 Python CLI
5
5
  Author: Gxxk
6
6
  License-Expression: AGPL-3.0-or-later
@@ -52,6 +52,8 @@ fh-tool --log-file fh-tool.log dev-info
52
52
 
53
53
  `--json` 输出稳定的 machine-readable JSON,不混入日志或样式。当前默认人类输出仍保持 pretty JSON,以兼容已有脚本;后续可以在不影响 `--json` 的前提下逐步增加表格化输出。
54
54
 
55
+ `--log-file` 写入 JSON lines 结构化日志;`-v/-vv` 控制 info/debug 事件,`-q` 只保留 error 级别。日志只记录 command、HTTP method、状态码、cfg path、风险等级等元数据,密码、session、token、LOID、PPPoE 等字段会脱敏。
56
+
55
57
  ## 快速配置
56
58
 
57
59
  保存默认 IP/MAC:
@@ -149,7 +151,7 @@ fh-tool wan list --backend local-vm
149
151
 
150
152
  `local-vm` 只是在本机 proot VM 内执行厂商 `cfg_cmd`。`--vm-root` 必须指向 VM 工作区目录,也就是包含 `bin/proot-shell` 和 `rootfs-vm/fhrom/bin/cfg_cmd` 的目录;默认是 `/mnt/dev-cold/HG5143F-ONU-vm`。不要把它指到里面的 `rootfs-vm/`。
151
153
 
152
- Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin 密码;cfg 来源需要 Telnet 时会默认使用 HG5143F 派生 Telnet 凭据 fallback,可用 `--no-derived-credentials` 关闭。失败不会阻塞只读抓取。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
154
+ Web AJAX 读取命令只读。需要复用已有 Web session 时加 `--sessionid`;需要登录时可显式传 `--password-stdin` 或 `--password`。未显式提供密码时默认 `--username useradmin --password-source auto`,会依次尝试 `GetAdminAccount` 和 cfg 路径读取 Web superadmin 密码;cfg 来源需要 Telnet 时会默认使用 HG5143F 派生 Telnet 凭据 fallback,可用 `--no-derived-credentials` 关闭。失败不会阻塞只读抓取,并会在结果里返回脱敏 login summary。sessionid、密码、LOID、PPPoE 等敏感字段默认会脱敏;只有显式加 `--reveal-secrets` 才输出明文。
153
155
 
154
156
  后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
155
157
 
@@ -174,6 +176,8 @@ fh-tool web services set --service telnet --enabled 0 --confirm
174
176
 
175
177
  常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
176
178
 
179
+ Web AJAX 写命令 dry-run 不会登录;加 `--confirm` 后,如果没有显式 `--sessionid`,会要求自动登录成功后才发 POST。
180
+
177
181
  Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
178
182
 
179
183
  ```bash
@@ -182,6 +186,8 @@ fh-tool wan list
182
186
  fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
183
187
  ```
184
188
 
189
+ 需要 root shell 的 Telnet 操作会自动执行 `su root`,当前 su 密码默认按 HG5143F 规则从 MAC 派生;如果 runtime su 密码已经被你改过,可显式传 `--su-password` 或 `--su-password-stdin`。例如 device restore、`cloud disable-cloudclt --confirm`、`account set-su-runtime-password --confirm` 会走 root runner。`account set-su-runtime-password` 写入的是 `/var/telsu` 的 passwd 格式 md5-crypt 行,不会把明文密码写入远端命令。
190
+
185
191
  诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
186
192
 
187
193
  本地 VM 集成测试默认会跳过;需要显式指定 VM 目录才会运行:
@@ -226,6 +232,8 @@ fh-tool restore backup.tgz \
226
232
 
227
233
  当前 restore 只恢复 allowlist 内的配置文件,且会拒绝路径穿越、绝对路径逃逸和未知文件写入。`/proc/mtd`、runtime password 文件、restore/factory reset flag 等备份内容不会被恢复。恢复后会 read-back/hash verify;不会自动 reboot,也不会自动 factory reset。
228
234
 
235
+ 默认备份清单包含调研确认的 `/fhconf`、`/fhdata`、runtime password、CloudPlat/appframework 状态和 boot/app 信息等只读路径;这些新增调研路径不会自动加入 restore allowlist。
236
+
229
237
  ## CloudPlat / SmartSwitch
230
238
 
231
239
  CloudPlat 默认先做只读审计和计划:
@@ -244,6 +252,14 @@ fh-tool cloud disable-smartswitch --confirm
244
252
 
245
253
  该命令不会修改 LOID、PON、WAN VLAN、ServiceList 或 TR-069 VLAN。
246
254
 
255
+ 只停 CloudPlat 连接优先使用 SAF container 内的 `cloudclt` init 脚本:
256
+
257
+ ```bash
258
+ fh-tool cloud disable-cloudclt --confirm
259
+ ```
260
+
261
+ 该命令通过 `lxc-attach -n saf -- /etc/init.d/cloudclt stop/disable` 执行,需要 root runner;仍然不会 patch 启动脚本或删除 package。
262
+
247
263
  ## 22 个 `/fh_tool/api` method 覆盖
248
264
 
249
265
  已提供 typed command:
@@ -1,10 +1,10 @@
1
1
  fh_tool_cli/__init__.py,sha256=QWxyrUwVq3pHukey6kpMjqRKbzi5ksTU-gvnTqEoAkE,72
2
- fh_tool_cli/account.py,sha256=-m3yctePqJ3JXeWcw-zcw_CJSymccF2mmH469IToqpw,3165
2
+ fh_tool_cli/account.py,sha256=l-qNkDao3FdSN2VASqBHsHYLLVl4L9Hx4JfE1m8s8tQ,5150
3
3
  fh_tool_cli/argparse_utils.py,sha256=ntjr5O8H-GbFEUvZKHf-0NbRy0N4-lS6YRXbMQ6fbA4,1640
4
- fh_tool_cli/backup.py,sha256=eR4_3NcfizZwVznF8IBh8lGotkKX50edLCMxvx6XWYg,24024
5
- fh_tool_cli/cli.py,sha256=Hy0OYr3pjEHnIUwzW-oauPyXkYUKNHKoz1jqrWtynC4,34332
6
- fh_tool_cli/click_cli.py,sha256=AYQggSPJHXz4owmW1Rt34e3qKjdlawYSkMMO8qrwYRw,9579
7
- fh_tool_cli/client.py,sha256=rahXuj5pkmkJO2R-cTp9D8qt9bFexwfqJSn7HpjO0_I,1396
4
+ fh_tool_cli/backup.py,sha256=ywoim97pa11DO7KuqcTptMEQha9ENsmTyiQD9_Lgdkc,24592
5
+ fh_tool_cli/cli.py,sha256=o9WGZPKfM4siIjzjMXPvsfUhUz1sKY9uuL5sZpdiNYA,35667
6
+ fh_tool_cli/click_cli.py,sha256=voxEuVPUth88wcDd-XqJPMHYcuTi6bZCqqWFR-3q1ZQ,10286
7
+ fh_tool_cli/client.py,sha256=-59Zvn3S5DREtxOuu2iXCbnpmCEpbi2csKA2X3XUwFY,2202
8
8
  fh_tool_cli/config_decrypt.py,sha256=mB0OXF4b9GgSKkHKwu_U1POih2Z1IUfOwBxLXjstEaE,9519
9
9
  fh_tool_cli/config_store.py,sha256=BK0H_lGWHZp6P8gH9Uc70emHNnjC6yQ7Lile8Zy5WGo,5257
10
10
  fh_tool_cli/credential_sources.py,sha256=kZ94LTylk4i3hQRvQiG7eAe9N8u6oiWenxRFBJA_f2U,1495
@@ -12,23 +12,23 @@ fh_tool_cli/credentials.py,sha256=xVSbFxX9xD5X5BPCNiVJEDvwk_iVf3q0hnLDcBR02mw,31
12
12
  fh_tool_cli/crypto.py,sha256=8hbR5q451_sX4OoQ59c0pKs33H8CKezpE40ZYGbgNc8,2151
13
13
  fh_tool_cli/diagnostics.py,sha256=f5snLEn7SugQrzOZIyWM8SwceCCiUSfhLHeiUdgfTs4,19669
14
14
  fh_tool_cli/errors.py,sha256=_JI5_p1or7PnMw1CV6mVpmDbAgTWy06ysmdoAEu54l0,120
15
- fh_tool_cli/output.py,sha256=hi3-rVcKwEPxSuUjaoUZsZeDbh3VreRQJOL5tV3v8a8,1287
16
- fh_tool_cli/parser.py,sha256=5L_S8ZCGlokNQnoAcMoS_riPKmw6YAVoL3Vt6670SpI,34227
17
- fh_tool_cli/remote.py,sha256=z_YqNLvIoha3iimf1Ce1DZ4y-6xGqXJql7Xv8uDUvOA,9704
15
+ fh_tool_cli/output.py,sha256=3XD1j7KGGHjtxfuFr6ptkT9k4cZe7uY3WvgRgBxKT-c,3108
16
+ fh_tool_cli/parser.py,sha256=eytzdJWl5VpH1--scUf8HSVsmCi7B35axaziq3XuK1k,34460
17
+ fh_tool_cli/remote.py,sha256=N6c1LcJJ9itjufCdULdzgFeMxCH8e_o7uNPQ-QnQngM,10547
18
18
  fh_tool_cli/risk.py,sha256=9sKCGE3uiNR-1ylzJbm0ryICVju12VQxia2gp-FRxFA,1270
19
19
  fh_tool_cli/upload.py,sha256=13L2Dgn3_1l5rkTGzCuxk4Jr0R15vdSVF9A40A44WHk,7442
20
20
  fh_tool_cli/web_discovery.py,sha256=Ud8_p78-Fx1fVa3u4w3apYkKh-0yg8YNx745iGiBDm4,23393
21
21
  fh_tool_cli/web_writes.py,sha256=H7zRMYJggl8qFu9e4KQW5k-KOZke4qVh2sHbwxqaABY,12917
22
22
  fh_tool_cli/backends/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
23
- fh_tool_cli/backends/cfg_cmd.py,sha256=zvK5O-utBBplvqOZyJhuqiMvu_qZ8Pgn6aDvNXbLty8,3992
24
- fh_tool_cli/backends/fh_tool.py,sha256=bwn21rXyU4Qs4_KBHPaZKP8f1CEdogE21manisJ1nOw,2335
23
+ fh_tool_cli/backends/cfg_cmd.py,sha256=WFdO0M9Qu7OndkgfAAYp5Z9EgL2IOUBGTK-_hxDqFPM,4707
24
+ fh_tool_cli/backends/fh_tool.py,sha256=i5VPAwe7ksjSu0QZbgjItzya0x1BR108y-UuIO48bv4,3225
25
25
  fh_tool_cli/backends/local_vm.py,sha256=kSgHVplCViK8L6mOOttj85BjGPHFUFMzwmXjlIPIdO0,2433
26
- fh_tool_cli/backends/telnet.py,sha256=1_xmP3LeWlHPCq-zL6s8F-25r4CJaLyr1GsM1_Y2ZbI,1931
27
- fh_tool_cli/backends/web_ajax.py,sha256=UDn6HT6N9KuiMMn_asBTEYw23ZR8jvReKPeVUSTcKPY,12685
26
+ fh_tool_cli/backends/telnet.py,sha256=ud5q71oXbZjTS3wt1_qXvp5bRbyZBm4GD66Zo31RVTI,4865
27
+ fh_tool_cli/backends/web_ajax.py,sha256=E4lbga-zuv_Z9TRWxylbIMpeECVimy8cGgWyAl04Geo,14750
28
28
  fh_tool_cli/commands/__init__.py,sha256=xXoXJsFkq0kRgoh3GOTDMkDQzocHfSAqEejPHDA9Jb8,54
29
- fh_tool_cli/commands/web.py,sha256=WsB5pb8nOkeDmkPfJjOXN9ieDUbgsPiHc5a4q93WeDw,36116
30
- fh_tool_cli-0.2.2.dist-info/METADATA,sha256=OxyucnsJRjaoyKSFzn2pRW9F0w-mVlJ-1BhwEw7H6Io,12395
31
- fh_tool_cli-0.2.2.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
- fh_tool_cli-0.2.2.dist-info/entry_points.txt,sha256=7zkyLkN5pemLvfLk5EucJvB4dGI6pJcn0z32ghziSjQ,84
33
- fh_tool_cli-0.2.2.dist-info/top_level.txt,sha256=g3brii9lgqfPHXzj88IoqKtAxbtCfFOaYfx1Gk1M1j8,12
34
- fh_tool_cli-0.2.2.dist-info/RECORD,,
29
+ fh_tool_cli/commands/web.py,sha256=ta1F9csqY4a7Ja3QR5oLtlPTSpWoyEc3SGyCFymzDO8,37413
30
+ fh_tool_cli-0.2.3.dist-info/METADATA,sha256=iYMGjyOVNpzDwmvDsvnaKTp50r7O0BvqIUrE-YNQUlI,13849
31
+ fh_tool_cli-0.2.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
+ fh_tool_cli-0.2.3.dist-info/entry_points.txt,sha256=7zkyLkN5pemLvfLk5EucJvB4dGI6pJcn0z32ghziSjQ,84
33
+ fh_tool_cli-0.2.3.dist-info/top_level.txt,sha256=g3brii9lgqfPHXzj88IoqKtAxbtCfFOaYfx1Gk1M1j8,12
34
+ fh_tool_cli-0.2.3.dist-info/RECORD,,