fh_tool-cli 0.2.2__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/PKG-INFO +34 -2
  2. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/README.md +33 -1
  3. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/pyproject.toml +1 -1
  4. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/account.py +62 -1
  5. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backends/cfg_cmd.py +15 -3
  6. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backends/fh_tool.py +25 -1
  7. fh_tool_cli-0.2.4/src/fh_tool_cli/backends/telnet.py +138 -0
  8. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backends/web_ajax.py +60 -1
  9. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backup.py +17 -0
  10. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/cli.py +80 -8
  11. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/click_cli.py +37 -12
  12. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/client.py +18 -1
  13. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/commands/web.py +44 -13
  14. fh_tool_cli-0.2.4/src/fh_tool_cli/output.py +100 -0
  15. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/parser.py +38 -0
  16. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/remote.py +25 -4
  17. fh_tool_cli-0.2.4/src/fh_tool_cli/vm_build.py +458 -0
  18. fh_tool_cli-0.2.4/src/fh_tool_cli/vm_collect.py +330 -0
  19. fh_tool_cli-0.2.4/src/fh_tool_cli/vm_common.py +143 -0
  20. fh_tool_cli-0.2.4/src/fh_tool_cli/vm_verify.py +273 -0
  21. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/PKG-INFO +34 -2
  22. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/SOURCES.txt +8 -0
  23. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_account.py +19 -12
  24. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_backup.py +26 -0
  25. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_click_cli.py +21 -0
  26. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_parser_registration.py +3 -0
  27. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_remote.py +25 -3
  28. fh_tool_cli-0.2.4/tests/test_telnet_backend.py +73 -0
  29. fh_tool_cli-0.2.4/tests/test_vm_build.py +126 -0
  30. fh_tool_cli-0.2.4/tests/test_vm_collect.py +115 -0
  31. fh_tool_cli-0.2.4/tests/test_vm_verify.py +73 -0
  32. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_web_ajax.py +172 -10
  33. fh_tool_cli-0.2.4/tests/test_web_ajax_post.py +234 -0
  34. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_web_write_payloads.py +75 -1
  35. fh_tool_cli-0.2.2/src/fh_tool_cli/backends/telnet.py +0 -66
  36. fh_tool_cli-0.2.2/src/fh_tool_cli/output.py +0 -48
  37. fh_tool_cli-0.2.2/tests/test_web_ajax_post.py +0 -128
  38. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/setup.cfg +0 -0
  39. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/__init__.py +0 -0
  40. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/argparse_utils.py +0 -0
  41. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backends/__init__.py +0 -0
  42. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/backends/local_vm.py +0 -0
  43. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/commands/__init__.py +0 -0
  44. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/config_decrypt.py +0 -0
  45. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/config_store.py +0 -0
  46. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/credential_sources.py +0 -0
  47. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/credentials.py +0 -0
  48. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/crypto.py +0 -0
  49. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/diagnostics.py +0 -0
  50. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/errors.py +0 -0
  51. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/risk.py +0 -0
  52. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/upload.py +0 -0
  53. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/web_discovery.py +0 -0
  54. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli/web_writes.py +0 -0
  55. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/dependency_links.txt +0 -0
  56. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/entry_points.txt +0 -0
  57. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/requires.txt +0 -0
  58. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/src/fh_tool_cli.egg-info/top_level.txt +0 -0
  59. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_cfg_cmd.py +0 -0
  60. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_cli_risk.py +0 -0
  61. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_config_decrypt.py +0 -0
  62. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_credentials.py +0 -0
  63. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_diagnostics.py +0 -0
  64. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_extracted_helpers.py +0 -0
  65. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_local_vm_integration.py +0 -0
  66. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_upload.py +0 -0
  67. {fh_tool_cli-0.2.2 → fh_tool_cli-0.2.4}/tests/test_web_discovery.py +0 -0
@@ -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.4
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,19 @@ 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
+ 也可以从 HG5143F 原始 MTD dump 构建同类 userspace VM。`collect` 默认只输出 dry-run 计划,不读 flash;真正采集必须加 `--confirm`,自动开启 Telnet 也必须显式加 `--auto-enable-telnet --confirm`:
155
+
156
+ ```bash
157
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps
158
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps --auto-enable-telnet --confirm
159
+ fh-tool vm build --dump-dir ./hg5143f-dumps --output ./HG5143F-ONU-vm
160
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm
161
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm --with-fhapi --with-http
162
+ ```
163
+
164
+ `vm build` 需要 `ubireader_extract_images`、`ubireader_extract_files`、`unsquashfs`、`jefferson`、`qemu-arm-static` 和 `proot`。输出目录会包含 `rootfs-vm/`、`source/`、`logs/`、`bin/`、`build-manifest.json`、`verify-manifest.json` 和 `events.ndjson`。生成的 VM 是 32-bit ARM userspace under `qemu-arm-static`/`proot -0`,不是完整板级 QEMU 启动;默认不会运行完整 `/etc/rc.d/rcS`。
165
+
166
+ 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
167
 
154
168
  后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
155
169
 
@@ -174,6 +188,8 @@ fh-tool web services set --service telnet --enabled 0 --confirm
174
188
 
175
189
  常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
176
190
 
191
+ Web AJAX 写命令 dry-run 不会登录;加 `--confirm` 后,如果没有显式 `--sessionid`,会要求自动登录成功后才发 POST。
192
+
177
193
  Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
178
194
 
179
195
  ```bash
@@ -182,6 +198,8 @@ fh-tool wan list
182
198
  fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
183
199
  ```
184
200
 
201
+ 需要 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 行,不会把明文密码写入远端命令。
202
+
185
203
  诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
186
204
 
187
205
  本地 VM 集成测试默认会跳过;需要显式指定 VM 目录才会运行:
@@ -226,6 +244,8 @@ fh-tool restore backup.tgz \
226
244
 
227
245
  当前 restore 只恢复 allowlist 内的配置文件,且会拒绝路径穿越、绝对路径逃逸和未知文件写入。`/proc/mtd`、runtime password 文件、restore/factory reset flag 等备份内容不会被恢复。恢复后会 read-back/hash verify;不会自动 reboot,也不会自动 factory reset。
228
246
 
247
+ 默认备份清单包含调研确认的 `/fhconf`、`/fhdata`、runtime password、CloudPlat/appframework 状态和 boot/app 信息等只读路径;这些新增调研路径不会自动加入 restore allowlist。
248
+
229
249
  ## CloudPlat / SmartSwitch
230
250
 
231
251
  CloudPlat 默认先做只读审计和计划:
@@ -244,6 +264,14 @@ fh-tool cloud disable-smartswitch --confirm
244
264
 
245
265
  该命令不会修改 LOID、PON、WAN VLAN、ServiceList 或 TR-069 VLAN。
246
266
 
267
+ 只停 CloudPlat 连接优先使用 SAF container 内的 `cloudclt` init 脚本:
268
+
269
+ ```bash
270
+ fh-tool cloud disable-cloudclt --confirm
271
+ ```
272
+
273
+ 该命令通过 `lxc-attach -n saf -- /etc/init.d/cloudclt stop/disable` 执行,需要 root runner;仍然不会 patch 启动脚本或删除 package。
274
+
247
275
  ## 22 个 `/fh_tool/api` method 覆盖
248
276
 
249
277
  已提供 typed command:
@@ -330,3 +358,7 @@ fh-tool telnet disable --confirm
330
358
  ## License
331
359
 
332
360
  AGPL-3.0-or-later
361
+
362
+ ## 其它
363
+
364
+ [认可 LINUX DO](https://linux.do)
@@ -36,6 +36,8 @@ fh-tool --log-file fh-tool.log dev-info
36
36
 
37
37
  `--json` 输出稳定的 machine-readable JSON,不混入日志或样式。当前默认人类输出仍保持 pretty JSON,以兼容已有脚本;后续可以在不影响 `--json` 的前提下逐步增加表格化输出。
38
38
 
39
+ `--log-file` 写入 JSON lines 结构化日志;`-v/-vv` 控制 info/debug 事件,`-q` 只保留 error 级别。日志只记录 command、HTTP method、状态码、cfg path、风险等级等元数据,密码、session、token、LOID、PPPoE 等字段会脱敏。
40
+
39
41
  ## 快速配置
40
42
 
41
43
  保存默认 IP/MAC:
@@ -133,7 +135,19 @@ fh-tool wan list --backend local-vm
133
135
 
134
136
  `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/`。
135
137
 
136
- 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` 才输出明文。
138
+ 也可以从 HG5143F 原始 MTD dump 构建同类 userspace VM。`collect` 默认只输出 dry-run 计划,不读 flash;真正采集必须加 `--confirm`,自动开启 Telnet 也必须显式加 `--auto-enable-telnet --confirm`:
139
+
140
+ ```bash
141
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps
142
+ fh-tool vm collect --ip 192.168.1.1 --output ./hg5143f-dumps --auto-enable-telnet --confirm
143
+ fh-tool vm build --dump-dir ./hg5143f-dumps --output ./HG5143F-ONU-vm
144
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm
145
+ fh-tool vm verify --vm-root ./HG5143F-ONU-vm --with-fhapi --with-http
146
+ ```
147
+
148
+ `vm build` 需要 `ubireader_extract_images`、`ubireader_extract_files`、`unsquashfs`、`jefferson`、`qemu-arm-static` 和 `proot`。输出目录会包含 `rootfs-vm/`、`source/`、`logs/`、`bin/`、`build-manifest.json`、`verify-manifest.json` 和 `events.ndjson`。生成的 VM 是 32-bit ARM userspace under `qemu-arm-static`/`proot -0`,不是完整板级 QEMU 启动;默认不会运行完整 `/etc/rc.d/rcS`。
149
+
150
+ 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` 才输出明文。
137
151
 
138
152
  后台 AJAX 接口发现以 live discovery 为主路径,不需要 HAR,也不需要 rootfs:
139
153
 
@@ -158,6 +172,8 @@ fh-tool web services set --service telnet --enabled 0 --confirm
158
172
 
159
173
  常规 Web 写接口优先使用 typed 参数。`web ajax post` 和 `web ajax replay` 支持任意已知或未知 AJAX method,默认只输出计划,不发 POST;执行必须加 `--confirm`,且默认拒绝空 payload。`--json-payload` 和可重复的 `--param k=v` 仍保留为固件差异逃生口,合并顺序是 typed 参数、JSON、最后 `--param` 覆盖。旧的确认参数会直接报弃用错误。
160
174
 
175
+ Web AJAX 写命令 dry-run 不会登录;加 `--confirm` 后,如果没有显式 `--sessionid`,会要求自动登录成功后才发 POST。
176
+
161
177
  Telnet/cfg/诊断命令在未显式传 Telnet 密码时,会默认使用 HG5143F 派生 Telnet 凭据 fallback;显式传入的用户名/密码始终优先。如果设备不是该规则,或需要保留空凭据/自定义认证,可加 `--no-derived-credentials` 关闭。`--use-derived-credentials` 仍作为兼容参数接受:
162
178
 
163
179
  ```bash
@@ -166,6 +182,8 @@ fh-tool wan list
166
182
  fh-tool cfg get InternetGatewayDevice.DeviceInfo.Manufacturer --no-derived-credentials
167
183
  ```
168
184
 
185
+ 需要 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 行,不会把明文密码写入远端命令。
186
+
169
187
  诊断命令保持只读;`ip status` 这类依赖 VM/userspace 工具的命令会分别标记每个 probe 的 `ok/output/error`,工具缺失时输出 `partial_failure=true`,不会吞掉其它已成功字段。
170
188
 
171
189
  本地 VM 集成测试默认会跳过;需要显式指定 VM 目录才会运行:
@@ -210,6 +228,8 @@ fh-tool restore backup.tgz \
210
228
 
211
229
  当前 restore 只恢复 allowlist 内的配置文件,且会拒绝路径穿越、绝对路径逃逸和未知文件写入。`/proc/mtd`、runtime password 文件、restore/factory reset flag 等备份内容不会被恢复。恢复后会 read-back/hash verify;不会自动 reboot,也不会自动 factory reset。
212
230
 
231
+ 默认备份清单包含调研确认的 `/fhconf`、`/fhdata`、runtime password、CloudPlat/appframework 状态和 boot/app 信息等只读路径;这些新增调研路径不会自动加入 restore allowlist。
232
+
213
233
  ## CloudPlat / SmartSwitch
214
234
 
215
235
  CloudPlat 默认先做只读审计和计划:
@@ -228,6 +248,14 @@ fh-tool cloud disable-smartswitch --confirm
228
248
 
229
249
  该命令不会修改 LOID、PON、WAN VLAN、ServiceList 或 TR-069 VLAN。
230
250
 
251
+ 只停 CloudPlat 连接优先使用 SAF container 内的 `cloudclt` init 脚本:
252
+
253
+ ```bash
254
+ fh-tool cloud disable-cloudclt --confirm
255
+ ```
256
+
257
+ 该命令通过 `lxc-attach -n saf -- /etc/init.d/cloudclt stop/disable` 执行,需要 root runner;仍然不会 patch 启动脚本或删除 package。
258
+
231
259
  ## 22 个 `/fh_tool/api` method 覆盖
232
260
 
233
261
  已提供 typed command:
@@ -314,3 +342,7 @@ fh-tool telnet disable --confirm
314
342
  ## License
315
343
 
316
344
  AGPL-3.0-or-later
345
+
346
+ ## 其它
347
+
348
+ [认可 LINUX DO](https://linux.do)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "fh_tool-cli"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "用于本地管理 FiberHome fh_tool 接口的 Python CLI"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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]:
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import socket
4
+ import logging
5
+ from dataclasses import dataclass
6
+
7
+ from ..config_store import decode_text
8
+ from ..errors import FHToolError
9
+ from ..output import log_event
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TelnetCredentials:
14
+ host: str
15
+ port: int = 23
16
+ username: str | None = None
17
+ password: str | None = None
18
+ timeout: float = 5.0
19
+
20
+
21
+ class TelnetShell:
22
+ def __init__(self, credentials: TelnetCredentials):
23
+ self.credentials = credentials
24
+
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
+ )
34
+ try:
35
+ with socket.create_connection(
36
+ (self.credentials.host, self.credentials.port),
37
+ timeout=self.credentials.timeout,
38
+ ) as sock:
39
+ sock.settimeout(self.credentials.timeout)
40
+ if self.credentials.username:
41
+ _read_until(sock, b"login:")
42
+ sock.sendall(self.credentials.username.encode("utf-8") + b"\n")
43
+ if self.credentials.password:
44
+ _read_until(sock, b"Password:")
45
+ sock.sendall(self.credentials.password.encode("utf-8") + b"\n")
46
+ sock.sendall(command.encode("utf-8") + b"\n")
47
+ sock.sendall(b"exit\n")
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
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
+ )
65
+ raise FHToolError(f"Telnet command failed: {exc}") from exc
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
+
114
+
115
+ def _read_until(sock: socket.socket, marker: bytes) -> bytes:
116
+ data = bytearray()
117
+ while marker not in data:
118
+ try:
119
+ chunk = sock.recv(4096)
120
+ except TimeoutError:
121
+ break
122
+ if not chunk:
123
+ break
124
+ data.extend(chunk)
125
+ return bytes(data)
126
+
127
+
128
+ def _read_all(sock: socket.socket) -> bytes:
129
+ data = bytearray()
130
+ while True:
131
+ try:
132
+ chunk = sock.recv(4096)
133
+ except TimeoutError:
134
+ break
135
+ if not chunk:
136
+ break
137
+ data.extend(chunk)
138
+ return bytes(data)
@@ -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: