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 +62 -1
- fh_tool_cli/backends/cfg_cmd.py +15 -3
- fh_tool_cli/backends/fh_tool.py +25 -1
- fh_tool_cli/backends/telnet.py +73 -1
- fh_tool_cli/backends/web_ajax.py +60 -1
- fh_tool_cli/backup.py +17 -0
- fh_tool_cli/cli.py +38 -8
- fh_tool_cli/click_cli.py +28 -11
- fh_tool_cli/client.py +18 -1
- fh_tool_cli/commands/web.py +44 -13
- fh_tool_cli/output.py +53 -1
- fh_tool_cli/parser.py +2 -0
- fh_tool_cli/remote.py +25 -4
- {fh_tool_cli-0.2.2.dist-info → fh_tool_cli-0.2.3.dist-info}/METADATA +18 -2
- {fh_tool_cli-0.2.2.dist-info → fh_tool_cli-0.2.3.dist-info}/RECORD +18 -18
- {fh_tool_cli-0.2.2.dist-info → fh_tool_cli-0.2.3.dist-info}/WHEEL +0 -0
- {fh_tool_cli-0.2.2.dist-info → fh_tool_cli-0.2.3.dist-info}/entry_points.txt +0 -0
- {fh_tool_cli-0.2.2.dist-info → fh_tool_cli-0.2.3.dist-info}/top_level.txt +0 -0
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
|
-
|
|
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]"
|
fh_tool_cli/backends/cfg_cmd.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]:
|
fh_tool_cli/backends/fh_tool.py
CHANGED
|
@@ -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
|
-
|
|
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]:
|
fh_tool_cli/backends/telnet.py
CHANGED
|
@@ -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
|
-
|
|
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()
|
fh_tool_cli/backends/web_ajax.py
CHANGED
|
@@ -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":
|
|
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=
|
|
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(
|
|
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
|
-
|
|
603
|
+
shell_runner = _telnet_root_shell_runner_from_args(args)
|
|
574
604
|
return tr069_status(
|
|
575
605
|
_cfg_backend_from_args(args),
|
|
576
|
-
|
|
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
|
-
|
|
657
|
+
shell_runner = _telnet_root_shell_runner_from_args(args)
|
|
628
658
|
return cloud_status(
|
|
629
659
|
_cfg_backend_from_args(args),
|
|
630
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
60
|
+
"bytes": bytes_written,
|
|
44
61
|
"content_type": response.headers.get("Content-Type"),
|
|
45
62
|
}
|
fh_tool_cli/commands/web.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
"
|
|
214
|
-
|
|
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.
|
|
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`
|
|
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
|
|
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=
|
|
5
|
-
fh_tool_cli/cli.py,sha256=
|
|
6
|
-
fh_tool_cli/click_cli.py,sha256=
|
|
7
|
-
fh_tool_cli/client.py,sha256
|
|
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=
|
|
16
|
-
fh_tool_cli/parser.py,sha256=
|
|
17
|
-
fh_tool_cli/remote.py,sha256=
|
|
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=
|
|
24
|
-
fh_tool_cli/backends/fh_tool.py,sha256=
|
|
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=
|
|
27
|
-
fh_tool_cli/backends/web_ajax.py,sha256=
|
|
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=
|
|
30
|
-
fh_tool_cli-0.2.
|
|
31
|
-
fh_tool_cli-0.2.
|
|
32
|
-
fh_tool_cli-0.2.
|
|
33
|
-
fh_tool_cli-0.2.
|
|
34
|
-
fh_tool_cli-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|