qnap-nas-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qnap_nas_cli/__init__.py +3 -0
- qnap_nas_cli/__main__.py +3 -0
- qnap_nas_cli/cli.py +327 -0
- qnap_nas_cli/client.py +201 -0
- qnap_nas_cli/commands.py +229 -0
- qnap_nas_cli/config.py +69 -0
- qnap_nas_cli/errors.py +14 -0
- qnap_nas_cli/incident.py +302 -0
- qnap_nas_cli/mcp.py +126 -0
- qnap_nas_cli/parsers.py +191 -0
- qnap_nas_cli/redaction.py +48 -0
- qnap_nas_cli/remediation.py +186 -0
- qnap_nas_cli/ssh_backend.py +141 -0
- qnap_nas_cli-0.2.0.dist-info/METADATA +259 -0
- qnap_nas_cli-0.2.0.dist-info/RECORD +19 -0
- qnap_nas_cli-0.2.0.dist-info/WHEEL +5 -0
- qnap_nas_cli-0.2.0.dist-info/entry_points.txt +2 -0
- qnap_nas_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- qnap_nas_cli-0.2.0.dist-info/top_level.txt +1 -0
qnap_nas_cli/__init__.py
ADDED
qnap_nas_cli/__main__.py
ADDED
qnap_nas_cli/cli.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .client import QtsClient
|
|
11
|
+
from .commands import disks_ssh, dump, health, incident_report, network, raid, services, smart_details, storage, sysinfo, tailscale, validate_dump
|
|
12
|
+
from .config import Config, default_config_path, load_config, password_from_environment, save_config
|
|
13
|
+
from .errors import QtsError, TwoFactorRequired
|
|
14
|
+
from .mcp import serve
|
|
15
|
+
from .incident import DEFAULT_MAX_ITEMS, MAX_MAX_ITEMS, response_plan, write_evidence_bundle
|
|
16
|
+
from .remediation import apply_plan, unavailable_plan
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parser() -> argparse.ArgumentParser:
|
|
20
|
+
root = argparse.ArgumentParser(prog="qts", description="QNAP QTS safety-first diagnostics CLI")
|
|
21
|
+
root.add_argument("--version", action="version", version="%(prog)s {}".format(__version__))
|
|
22
|
+
root.add_argument("--config", type=Path, help="設定檔路徑(也可使用 QTS_CONFIG)")
|
|
23
|
+
root.add_argument("--json", action="store_true", help="輸出 JSON")
|
|
24
|
+
root.add_argument("--insecure", action="store_true", help="停用 TLS 憑證驗證(僅限受信任區網暫時使用)")
|
|
25
|
+
subs = root.add_subparsers(dest="command", required=True)
|
|
26
|
+
|
|
27
|
+
setup = subs.add_parser("setup", help="寫入連線設定;不儲存密碼")
|
|
28
|
+
setup.add_argument("--origin", required=True)
|
|
29
|
+
setup.add_argument("--user", required=True)
|
|
30
|
+
setup.add_argument("--ssh-host")
|
|
31
|
+
setup.add_argument("--ssh-user")
|
|
32
|
+
setup.add_argument("--ssh-identity-file")
|
|
33
|
+
setup.add_argument("--timeout", type=int, default=15)
|
|
34
|
+
|
|
35
|
+
subs.add_parser("doctor", help="檢查本機設定與連線前置條件")
|
|
36
|
+
subs.add_parser("whoami", help="登入並顯示目前帳號與 NAS")
|
|
37
|
+
subs.add_parser("status", help="系統型號、韌體、負載、溫度與 uptime")
|
|
38
|
+
health_parser = subs.add_parser("health", help="唯讀磁碟與儲存健康稽核")
|
|
39
|
+
health_parser.add_argument("--volume-index", type=int, action="append", help="額外查詢指定 volume index,可重複指定;預設 1")
|
|
40
|
+
storage_parser = subs.add_parser("storage", aliases=["disks", "volumes"], help="磁碟與儲存空間")
|
|
41
|
+
storage_parser.add_argument("--volume-index", type=int, action="append", help="額外查詢指定 volume index,可重複指定;預設 1")
|
|
42
|
+
subs.add_parser("network", help="網路介面與 DNS")
|
|
43
|
+
subs.add_parser("tailscale", help="透過設定的免密 SSH 讀取 Tailscale")
|
|
44
|
+
subs.add_parser("raid", help="透過免密 SSH 唯讀解析 /proc/mdstat")
|
|
45
|
+
smart_parser = subs.add_parser("smart-details", help="透過免密 SSH 讀取指定 bay 的 SMART attributes")
|
|
46
|
+
smart_parser.add_argument("disk", type=int)
|
|
47
|
+
subs.add_parser("disk-topology", help="透過唯讀 SSH 讀取實體磁碟 bay、角色與型號")
|
|
48
|
+
subs.add_parser("services", help="唯讀檢查 Container Station 與 Tailscale QPKG 狀態")
|
|
49
|
+
dump_parser = subs.add_parser("dump", help="匯出已遮蔽敏感資料的相容性樣本")
|
|
50
|
+
dump_parser.add_argument("directory", nargs="?", type=Path, default=Path("qts-dump"))
|
|
51
|
+
fixture_parser = subs.add_parser("fixture", help="驗證 dump/fixture 是否能安全解析")
|
|
52
|
+
fixture_subs = fixture_parser.add_subparsers(dest="fixture_command", required=True)
|
|
53
|
+
fixture_validate = fixture_subs.add_parser("validate", help="離線驗證已遮蔽的 dump")
|
|
54
|
+
fixture_validate.add_argument("directory", type=Path)
|
|
55
|
+
fixture_validate.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
56
|
+
subs.add_parser("mcp", help="啟動唯讀 stdio MCP server")
|
|
57
|
+
incident_parser = subs.add_parser("incident", help="勒索事件唯讀盤點、人工 containment/evidence/rescue 工作流")
|
|
58
|
+
incident_subs = incident_parser.add_subparsers(dest="incident_command", required=True)
|
|
59
|
+
incident_scan = incident_subs.add_parser("scan", help="掃描有限、已知勒索指標並彙整健康/RAID evidence")
|
|
60
|
+
incident_scan.add_argument("--max-items", type=int, default=DEFAULT_MAX_ITEMS, help="最多掃描的檔案/資料夾項目數,1..{}".format(MAX_MAX_ITEMS))
|
|
61
|
+
incident_scan.add_argument("--evidence-dir", type=Path, help="可選:建立新的本機 evidence bundle 目錄")
|
|
62
|
+
incident_contain = incident_subs.add_parser("contain", help="從 incident report 產生人工 containment 清單;不修改 NAS")
|
|
63
|
+
incident_contain.add_argument("report", type=Path)
|
|
64
|
+
incident_evidence = incident_subs.add_parser("evidence", help="將既有 incident report 封存並建立 SHA-256 manifest")
|
|
65
|
+
incident_evidence.add_argument("report", type=Path)
|
|
66
|
+
incident_evidence.add_argument("directory", type=Path)
|
|
67
|
+
incident_rescue = incident_subs.add_parser("rescue", help="從 incident report 產生離線備份復原計畫;不覆寫 NAS")
|
|
68
|
+
incident_rescue.add_argument("report", type=Path)
|
|
69
|
+
incident_rescue.add_argument("--candidate-path", help="僅可指定本次 report 已列出的 indicator 範例,匯出一份唯讀本機樣本")
|
|
70
|
+
incident_rescue.add_argument("--directory", type=Path, help="建立新的本機隔離 evidence 目錄;搭配 --candidate-path 使用")
|
|
71
|
+
incident_rescue.add_argument("--max-bytes", type=int, default=100 * 1024 * 1024)
|
|
72
|
+
repair = subs.add_parser("repair", help="一般修復的 plan/apply 閘門;尚無已驗證的 NAS 寫入 adapter")
|
|
73
|
+
repair_subs = repair.add_subparsers(dest="repair_command", required=True)
|
|
74
|
+
repair_plan = repair_subs.add_parser("plan", help="讀取設備身分並輸出明確拒絕執行的 dry-run plan")
|
|
75
|
+
repair_plan.add_argument("--action", required=True, choices=["service-start", "service-stop", "notification-change", "firmware-update", "raid-rebuild", "smart-repair"])
|
|
76
|
+
repair_plan.add_argument("--output", type=Path, default=Path("qts-repair-plan.json"))
|
|
77
|
+
repair_apply = repair_subs.add_parser("apply", help="驗證 plan 與確認碼;未註冊的 mutation adapter 一律拒絕")
|
|
78
|
+
repair_apply.add_argument("plan_file", type=Path)
|
|
79
|
+
repair_apply.add_argument("--confirm", required=True, help="逐字輸入 plan_id;拒絕未驗證操作")
|
|
80
|
+
nested_parsers = (incident_scan, incident_contain, incident_evidence, incident_rescue, repair_plan, repair_apply)
|
|
81
|
+
for command_parser in nested_parsers:
|
|
82
|
+
command_parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
83
|
+
command_parser.add_argument("--insecure", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
84
|
+
command_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
85
|
+
seen = set()
|
|
86
|
+
for command_parser in subs.choices.values():
|
|
87
|
+
if id(command_parser) in seen:
|
|
88
|
+
continue
|
|
89
|
+
seen.add(id(command_parser))
|
|
90
|
+
command_parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
91
|
+
command_parser.add_argument("--insecure", action="store_true", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
92
|
+
command_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
93
|
+
return root
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _config(args: argparse.Namespace) -> Config:
|
|
97
|
+
config = load_config(args.config)
|
|
98
|
+
if args.insecure:
|
|
99
|
+
config.insecure = True
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _password() -> str:
|
|
104
|
+
password = password_from_environment()
|
|
105
|
+
if password is not None:
|
|
106
|
+
return password
|
|
107
|
+
if not sys.stdin.isatty():
|
|
108
|
+
value = sys.stdin.readline().rstrip("\r\n")
|
|
109
|
+
if value:
|
|
110
|
+
return value
|
|
111
|
+
return getpass.getpass("QTS password: ")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _security_code() -> Optional[str]:
|
|
115
|
+
value = os.environ.get("QTS_SECURITY_CODE")
|
|
116
|
+
if value is None:
|
|
117
|
+
return None
|
|
118
|
+
value = value.replace(" ", "").strip()
|
|
119
|
+
return value or None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _prompt_security_code() -> str:
|
|
123
|
+
if not sys.stdin.isatty():
|
|
124
|
+
raise TwoFactorRequired("QTS 帳號需要 2FA;非互動模式請設定當下的 QTS_SECURITY_CODE")
|
|
125
|
+
value = getpass.getpass("QTS security code: ").replace(" ", "").strip()
|
|
126
|
+
if not value:
|
|
127
|
+
raise TwoFactorRequired("QTS 2FA 驗證碼不可為空")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _login(client: QtsClient) -> None:
|
|
132
|
+
password = _password()
|
|
133
|
+
try:
|
|
134
|
+
client.login(password, _security_code())
|
|
135
|
+
except TwoFactorRequired:
|
|
136
|
+
client.login(password, _prompt_security_code())
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_json(path: Path) -> Dict[str, Any]:
|
|
140
|
+
try:
|
|
141
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
142
|
+
except (OSError, ValueError) as exc:
|
|
143
|
+
raise QtsError("無法讀取 JSON 檔案:{}".format(exc)) from exc
|
|
144
|
+
if not isinstance(value, dict):
|
|
145
|
+
raise QtsError("JSON 根節點必須是 object")
|
|
146
|
+
return value
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _save_new_json(path: Path, value: Dict[str, Any]) -> None:
|
|
150
|
+
try:
|
|
151
|
+
with path.open("x", encoding="utf-8") as stream:
|
|
152
|
+
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
153
|
+
stream.write("\n")
|
|
154
|
+
except FileExistsError as exc:
|
|
155
|
+
raise QtsError("輸出檔已存在,為避免覆寫 evidence/plan 已拒絕:{}".format(path)) from exc
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _print(value: Any, json_output: bool) -> None:
|
|
159
|
+
if json_output:
|
|
160
|
+
print(json.dumps(value, ensure_ascii=False, indent=2))
|
|
161
|
+
return
|
|
162
|
+
if isinstance(value, dict):
|
|
163
|
+
for key, item in value.items():
|
|
164
|
+
if isinstance(item, (dict, list)):
|
|
165
|
+
print("{}:".format(key))
|
|
166
|
+
rendered = json.dumps(item, ensure_ascii=False, indent=2)
|
|
167
|
+
print("\n".join(" " + line for line in rendered.splitlines()))
|
|
168
|
+
elif item is not None:
|
|
169
|
+
print("{:<20} {}".format(key, item))
|
|
170
|
+
else:
|
|
171
|
+
print(value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _doctor(config: Config, config_path: Optional[Path] = None) -> Dict[str, Any]:
|
|
175
|
+
import shutil
|
|
176
|
+
from urllib.parse import urlparse
|
|
177
|
+
parsed = urlparse(config.origin)
|
|
178
|
+
local_http = parsed.scheme == "http" and parsed.hostname in ("127.0.0.1", "localhost")
|
|
179
|
+
return {
|
|
180
|
+
"config": str(config_path or default_config_path()),
|
|
181
|
+
"origin": config.origin,
|
|
182
|
+
"username": config.username,
|
|
183
|
+
"tls_verification": not config.insecure,
|
|
184
|
+
"password_available": bool(password_from_environment()),
|
|
185
|
+
"ssh_configured": bool(config.ssh_host and config.ssh_user),
|
|
186
|
+
"ssh_executable": shutil.which("ssh") is not None,
|
|
187
|
+
"safe": (config.origin.startswith("https://") and not config.insecure) or local_http,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: Optional[list] = None) -> int:
|
|
192
|
+
args = parser().parse_args(argv)
|
|
193
|
+
try:
|
|
194
|
+
if args.command == "setup":
|
|
195
|
+
target = save_config(Config(args.origin, args.user, args.insecure, args.ssh_host, args.ssh_user, args.timeout, args.ssh_identity_file), args.config)
|
|
196
|
+
_print({"saved": str(target), "password_saved": False}, args.json)
|
|
197
|
+
return 0
|
|
198
|
+
if args.command == "fixture":
|
|
199
|
+
result = validate_dump(args.directory)
|
|
200
|
+
_print(result, args.json)
|
|
201
|
+
return 0 if result["valid"] else 2
|
|
202
|
+
offline_incident = args.command == "incident" and args.incident_command in ("contain", "evidence", "rescue")
|
|
203
|
+
if offline_incident and not (args.incident_command == "rescue" and args.candidate_path):
|
|
204
|
+
report = _read_json(args.report)
|
|
205
|
+
if args.incident_command == "evidence":
|
|
206
|
+
value = write_evidence_bundle(args.directory, report)
|
|
207
|
+
else:
|
|
208
|
+
scan = report.get("ransomware_scan")
|
|
209
|
+
if not isinstance(scan, dict):
|
|
210
|
+
raise QtsError("report 缺少 ransomware_scan")
|
|
211
|
+
if args.incident_command == "contain":
|
|
212
|
+
value = response_plan(scan)
|
|
213
|
+
else:
|
|
214
|
+
value = {
|
|
215
|
+
"status": "manual_rescue_plan_only",
|
|
216
|
+
"actions_applied": [],
|
|
217
|
+
"steps": [
|
|
218
|
+
"在隔離的復原環境使用已驗證的離線/不可變備份;不要把備份直接覆寫到疑似受感染的 NAS。",
|
|
219
|
+
"先完成遏止、可信任重建與漏洞修補;再抽樣驗證檔案雜湊、權限、時間戳及應用可讀性。",
|
|
220
|
+
"分批恢復至新位置並保留原始加密資料與證據;由資料擁有者簽核後才切換服務。",
|
|
221
|
+
],
|
|
222
|
+
"why": "本工具不會對原 NAS 寫檔、解密、刪除或覆寫;恢復來源與完整性需由管理者確認。",
|
|
223
|
+
}
|
|
224
|
+
_print(value, args.json)
|
|
225
|
+
return 0
|
|
226
|
+
if args.command == "repair" and args.repair_command == "apply":
|
|
227
|
+
plan = _read_json(args.plan_file)
|
|
228
|
+
result = apply_plan(plan, args.confirm, handlers={})
|
|
229
|
+
_print(result, args.json)
|
|
230
|
+
return 2 if result["status"] == "refused" else 0
|
|
231
|
+
config = _config(args)
|
|
232
|
+
if args.command == "doctor":
|
|
233
|
+
_print(_doctor(config, args.config), args.json)
|
|
234
|
+
return 0
|
|
235
|
+
if args.command == "mcp":
|
|
236
|
+
return serve(config)
|
|
237
|
+
if args.command == "incident":
|
|
238
|
+
if args.incident_command == "rescue":
|
|
239
|
+
report = _read_json(args.report)
|
|
240
|
+
scan = report.get("ransomware_scan")
|
|
241
|
+
if not isinstance(scan, dict):
|
|
242
|
+
raise QtsError("report 缺少 ransomware_scan")
|
|
243
|
+
if not args.candidate_path:
|
|
244
|
+
raise QtsError("請提供 --candidate-path,或只產生離線 rescue plan")
|
|
245
|
+
candidates = [example for indicator in scan.get("indicators", []) for example in indicator.get("examples", [])]
|
|
246
|
+
if args.candidate_path not in candidates:
|
|
247
|
+
raise QtsError("只能匯出本 report 列出的 indicator 範例路徑")
|
|
248
|
+
if not args.directory:
|
|
249
|
+
raise QtsError("匯出本機樣本時必須指定新的 --directory")
|
|
250
|
+
if not 1 <= args.max_bytes <= 1024 * 1024 * 1024:
|
|
251
|
+
raise QtsError("max-bytes 必須介於 1 byte 與 1 GiB")
|
|
252
|
+
source_path, _, filename = args.candidate_path.rpartition("/")
|
|
253
|
+
if not source_path or not filename:
|
|
254
|
+
raise QtsError("candidate-path 格式無效")
|
|
255
|
+
report_manifest = write_evidence_bundle(args.directory, report)
|
|
256
|
+
with QtsClient(config) as client:
|
|
257
|
+
_login(client)
|
|
258
|
+
sample = client.download_file(source_path, filename, args.directory / filename, args.max_bytes)
|
|
259
|
+
value = {
|
|
260
|
+
"status": "sample_exported_not_restored",
|
|
261
|
+
"actions_applied": [],
|
|
262
|
+
"source": args.candidate_path,
|
|
263
|
+
"sample": sample,
|
|
264
|
+
"report_manifest": report_manifest,
|
|
265
|
+
"note": "已唯讀匯出到新本機目錄;這不是 NAS 復原,也不會執行或解密樣本。",
|
|
266
|
+
}
|
|
267
|
+
_save_new_json(args.directory / "rescue-manifest.json", value)
|
|
268
|
+
_print(value, args.json)
|
|
269
|
+
return 0
|
|
270
|
+
if not 1 <= args.max_items <= MAX_MAX_ITEMS:
|
|
271
|
+
raise QtsError("max-items 必須介於 1 到 {}".format(MAX_MAX_ITEMS))
|
|
272
|
+
with QtsClient(config) as client:
|
|
273
|
+
_login(client)
|
|
274
|
+
value = incident_report(client, config, args.max_items)
|
|
275
|
+
if args.evidence_dir:
|
|
276
|
+
value["evidence_bundle"] = write_evidence_bundle(args.evidence_dir, value)
|
|
277
|
+
_print(value, args.json)
|
|
278
|
+
return 0
|
|
279
|
+
if args.command == "repair":
|
|
280
|
+
with QtsClient(config) as client:
|
|
281
|
+
_login(client)
|
|
282
|
+
identity = sysinfo(client)
|
|
283
|
+
device = {"origin": config.origin, "model": identity.get("model"), "firmware": identity.get("firmware"), "build": identity.get("build")}
|
|
284
|
+
plan = unavailable_plan(device, args.action)
|
|
285
|
+
_save_new_json(args.output, plan)
|
|
286
|
+
_print({"plan": str(args.output), "plan_id": plan["plan_id"], "status": plan["status"], "refusal_reason": plan["refusal_reason"]}, args.json)
|
|
287
|
+
return 2
|
|
288
|
+
if args.command in ("tailscale", "raid", "disk-topology", "smart-details", "services"):
|
|
289
|
+
ssh_operations = {
|
|
290
|
+
"tailscale": lambda: tailscale(config),
|
|
291
|
+
"raid": lambda: raid(config),
|
|
292
|
+
"disk-topology": lambda: disks_ssh(config),
|
|
293
|
+
"smart-details": lambda: smart_details(config, args.disk),
|
|
294
|
+
"services": lambda: services(config),
|
|
295
|
+
}
|
|
296
|
+
_print(ssh_operations[args.command](), args.json)
|
|
297
|
+
return 0
|
|
298
|
+
operations = {
|
|
299
|
+
"status": sysinfo,
|
|
300
|
+
"health": health,
|
|
301
|
+
"storage": storage,
|
|
302
|
+
"disks": storage,
|
|
303
|
+
"volumes": storage,
|
|
304
|
+
"network": network,
|
|
305
|
+
}
|
|
306
|
+
with QtsClient(config) as client:
|
|
307
|
+
_login(client)
|
|
308
|
+
if args.command == "whoami":
|
|
309
|
+
data = sysinfo(client)
|
|
310
|
+
value = {"username": config.username, "hostname": data.get("hostname"), "model": data.get("model"), "origin": config.origin}
|
|
311
|
+
elif args.command == "dump":
|
|
312
|
+
value = dump(client, args.directory)
|
|
313
|
+
else:
|
|
314
|
+
if args.command in ("storage", "disks", "volumes", "health"):
|
|
315
|
+
indices = getattr(args, "volume_index", None)
|
|
316
|
+
value = health(client, indices) if args.command == "health" else storage(client, indices)
|
|
317
|
+
else:
|
|
318
|
+
value = operations[args.command](client)
|
|
319
|
+
_print(value, args.json)
|
|
320
|
+
return 0
|
|
321
|
+
except (QtsError, OSError) as exc:
|
|
322
|
+
print("error: {}".format(exc), file=sys.stderr)
|
|
323
|
+
return 2
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
raise SystemExit(main())
|
qnap_nas_cli/client.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import posixpath
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from .config import Config
|
|
14
|
+
from .errors import AuthenticationError, CompatibilityError, QtsError, TwoFactorRequired
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
AUTH_PATH = "/cgi-bin/authLogin.cgi"
|
|
18
|
+
LOGOUT_PATH = "/cgi-bin/authLogout.cgi"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _xml_text(root: ET.Element, tag: str) -> Optional[str]:
|
|
22
|
+
node = root.find(".//{}".format(tag))
|
|
23
|
+
return None if node is None else (node.text or "").strip()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class QtsClient:
|
|
27
|
+
def __init__(self, config: Config):
|
|
28
|
+
self.config = config.validate()
|
|
29
|
+
self.sid = None # type: Optional[str]
|
|
30
|
+
self._ssl_context = ssl.create_default_context()
|
|
31
|
+
if config.insecure:
|
|
32
|
+
self._ssl_context.check_hostname = False
|
|
33
|
+
self._ssl_context.verify_mode = ssl.CERT_NONE
|
|
34
|
+
|
|
35
|
+
def _open(self, request: urllib.request.Request) -> bytes:
|
|
36
|
+
try:
|
|
37
|
+
with urllib.request.urlopen(request, timeout=self.config.timeout, context=self._ssl_context) as response:
|
|
38
|
+
return response.read()
|
|
39
|
+
except urllib.error.HTTPError as exc:
|
|
40
|
+
body = exc.read(512).decode("utf-8", "replace").replace("\n", " ")
|
|
41
|
+
raise QtsError("QTS 回傳 HTTP {}:{}".format(exc.code, body or "空白回應")) from exc
|
|
42
|
+
except urllib.error.URLError as exc:
|
|
43
|
+
reason = str(exc.reason)
|
|
44
|
+
if "CERTIFICATE_VERIFY_FAILED" in reason:
|
|
45
|
+
reason += "(請安裝可信憑證;僅在受信任區網暫時使用 --insecure)"
|
|
46
|
+
raise QtsError("無法連線至 NAS:{}".format(reason)) from exc
|
|
47
|
+
|
|
48
|
+
def login(self, password: str, security_code: Optional[str] = None) -> str:
|
|
49
|
+
if not password:
|
|
50
|
+
raise AuthenticationError("密碼不可為空")
|
|
51
|
+
fields = {
|
|
52
|
+
"user": self.config.username,
|
|
53
|
+
"pwd": base64.b64encode(password.encode("utf-8")).decode("ascii"),
|
|
54
|
+
"serviceKey": "1",
|
|
55
|
+
"client_app": "qnap-nas-cli",
|
|
56
|
+
}
|
|
57
|
+
if security_code:
|
|
58
|
+
fields["security_code"] = security_code
|
|
59
|
+
fields["dont_verify_2sv_again"] = "0"
|
|
60
|
+
body = urllib.parse.urlencode(fields).encode("ascii")
|
|
61
|
+
request = urllib.request.Request(
|
|
62
|
+
self.config.origin + AUTH_PATH,
|
|
63
|
+
data=body,
|
|
64
|
+
headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "qnap-nas-cli/0.1"},
|
|
65
|
+
)
|
|
66
|
+
raw = self._open(request)
|
|
67
|
+
try:
|
|
68
|
+
root = ET.fromstring(raw)
|
|
69
|
+
except ET.ParseError as exc:
|
|
70
|
+
raise CompatibilityError("QTS 登入回應不是預期的 XML") from exc
|
|
71
|
+
if _xml_text(root, "authPassed") != "1":
|
|
72
|
+
needs_2fa = _xml_text(root, "need_2_step_verification") == "1" or _xml_text(root, "need_2sv") == "1"
|
|
73
|
+
if needs_2fa and not security_code:
|
|
74
|
+
raise TwoFactorRequired("QTS 帳號需要 2FA")
|
|
75
|
+
if needs_2fa and security_code:
|
|
76
|
+
raise AuthenticationError("QTS 2FA 驗證失敗;請確認驗證碼與 NAS 時間")
|
|
77
|
+
code = _xml_text(root, "errorValue") or "unknown"
|
|
78
|
+
raise AuthenticationError("QTS 登入失敗(errorValue={})".format(code))
|
|
79
|
+
sid = _xml_text(root, "authSid")
|
|
80
|
+
if not sid:
|
|
81
|
+
raise CompatibilityError("QTS 表示登入成功,但沒有回傳 authSid")
|
|
82
|
+
self.sid = sid
|
|
83
|
+
return sid
|
|
84
|
+
|
|
85
|
+
def logout(self) -> None:
|
|
86
|
+
if not self.sid:
|
|
87
|
+
return
|
|
88
|
+
try:
|
|
89
|
+
self.get(LOGOUT_PATH, {}, include_sid=True)
|
|
90
|
+
except QtsError:
|
|
91
|
+
pass
|
|
92
|
+
finally:
|
|
93
|
+
self.sid = None
|
|
94
|
+
|
|
95
|
+
def get(self, path: str, params: Dict[str, str], include_sid: bool = True) -> bytes:
|
|
96
|
+
request = self._get_request(path, params, include_sid)
|
|
97
|
+
return self._open(request)
|
|
98
|
+
|
|
99
|
+
def _get_request(self, path: str, params: Dict[str, str], include_sid: bool = True) -> urllib.request.Request:
|
|
100
|
+
query = dict(params)
|
|
101
|
+
if include_sid:
|
|
102
|
+
if not self.sid:
|
|
103
|
+
raise AuthenticationError("尚未登入 QTS")
|
|
104
|
+
query["sid"] = self.sid
|
|
105
|
+
url = self.config.origin + path + "?" + urllib.parse.urlencode(query)
|
|
106
|
+
return urllib.request.Request(url, headers={"User-Agent": "qnap-nas-cli/0.1"})
|
|
107
|
+
|
|
108
|
+
def download_file(self, source_path: str, filename: str, target: Path,
|
|
109
|
+
max_bytes: int = 100 * 1024 * 1024) -> Dict[str, object]:
|
|
110
|
+
"""Stream one selected File Station file to a new local file, bounded by max_bytes."""
|
|
111
|
+
if not self.sid:
|
|
112
|
+
raise AuthenticationError("尚未登入 QTS")
|
|
113
|
+
if not isinstance(source_path, str) or not source_path.startswith("/") or "\x00" in source_path:
|
|
114
|
+
raise QtsError("NAS 檔案路徑格式不安全")
|
|
115
|
+
if any(part in (".", "..") for part in source_path.replace("\\", "/").split("/")):
|
|
116
|
+
raise QtsError("NAS 檔案路徑不得包含 dot segment")
|
|
117
|
+
if not isinstance(filename, str) or filename in ("", ".", "..") or "/" in filename or "\\" in filename or "\x00" in filename:
|
|
118
|
+
raise QtsError("檔名必須是單一安全路徑元件")
|
|
119
|
+
if type(max_bytes) is not int or not 1 <= max_bytes <= 1024 * 1024 * 1024:
|
|
120
|
+
raise QtsError("max_bytes 必須介於 1 byte 與 1 GiB")
|
|
121
|
+
|
|
122
|
+
target = Path(target)
|
|
123
|
+
request = self._get_request("/cgi-bin/filemanager/utilRequest.cgi", {
|
|
124
|
+
"func": "download", "isfolder": "0", "compress": "0",
|
|
125
|
+
"source_path": posixpath.normpath(source_path), "source_file": filename,
|
|
126
|
+
"source_total": "1",
|
|
127
|
+
})
|
|
128
|
+
digest = hashlib.sha256()
|
|
129
|
+
byte_count = 0
|
|
130
|
+
created = False
|
|
131
|
+
try:
|
|
132
|
+
with urllib.request.urlopen(request, timeout=self.config.timeout, context=self._ssl_context) as response:
|
|
133
|
+
content_length = response.headers.get("Content-Length")
|
|
134
|
+
if content_length and int(content_length) > max_bytes:
|
|
135
|
+
raise QtsError("指定檔案超過 rescue 匯出上限 {} bytes".format(max_bytes))
|
|
136
|
+
content_type = (response.headers.get("Content-Type") or "").lower()
|
|
137
|
+
if "text/html" in content_type or "application/json" in content_type:
|
|
138
|
+
raise QtsError("File Station download 回傳登入頁或 JSON 錯誤,不保存為檔案")
|
|
139
|
+
with target.open("xb") as output:
|
|
140
|
+
created = True
|
|
141
|
+
while True:
|
|
142
|
+
chunk = response.read(min(1024 * 1024, max_bytes - byte_count + 1))
|
|
143
|
+
if not chunk:
|
|
144
|
+
break
|
|
145
|
+
byte_count += len(chunk)
|
|
146
|
+
if byte_count > max_bytes:
|
|
147
|
+
raise QtsError("指定檔案超過 rescue 匯出上限 {} bytes".format(max_bytes))
|
|
148
|
+
output.write(chunk)
|
|
149
|
+
digest.update(chunk)
|
|
150
|
+
except FileExistsError as exc:
|
|
151
|
+
raise QtsError("本機 rescue 目標已存在;拒絕覆寫:{}".format(target)) from exc
|
|
152
|
+
except urllib.error.HTTPError as exc:
|
|
153
|
+
if created:
|
|
154
|
+
target.unlink(missing_ok=True)
|
|
155
|
+
raise QtsError("File Station download 回傳 HTTP {}".format(exc.code)) from exc
|
|
156
|
+
except urllib.error.URLError as exc:
|
|
157
|
+
if created:
|
|
158
|
+
target.unlink(missing_ok=True)
|
|
159
|
+
raise QtsError("File Station download 連線失敗:{}".format(exc.reason)) from exc
|
|
160
|
+
except (OSError, ValueError) as exc:
|
|
161
|
+
if created:
|
|
162
|
+
try:
|
|
163
|
+
target.unlink()
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
raise QtsError("File Station download 寫入失敗:{}".format(exc)) from exc
|
|
167
|
+
except QtsError:
|
|
168
|
+
if created:
|
|
169
|
+
try:
|
|
170
|
+
target.unlink()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
raise
|
|
174
|
+
except Exception:
|
|
175
|
+
if created:
|
|
176
|
+
try:
|
|
177
|
+
target.unlink()
|
|
178
|
+
except OSError:
|
|
179
|
+
pass
|
|
180
|
+
raise
|
|
181
|
+
return {"path": str(target), "size_bytes": byte_count, "sha256": digest.hexdigest(), "max_bytes": max_bytes}
|
|
182
|
+
|
|
183
|
+
def get_xml(self, path: str, params: Dict[str, str]) -> ET.Element:
|
|
184
|
+
raw = self.get(path, params)
|
|
185
|
+
try:
|
|
186
|
+
return ET.fromstring(raw)
|
|
187
|
+
except ET.ParseError as exc:
|
|
188
|
+
raise CompatibilityError("{} 回應不是預期的 XML".format(path)) from exc
|
|
189
|
+
|
|
190
|
+
def get_json(self, path: str, params: Dict[str, str]):
|
|
191
|
+
raw = self.get(path, params)
|
|
192
|
+
try:
|
|
193
|
+
return json.loads(raw.decode("utf-8"))
|
|
194
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
195
|
+
raise CompatibilityError("{} 回應不是預期的 JSON".format(path)) from exc
|
|
196
|
+
|
|
197
|
+
def __enter__(self) -> "QtsClient":
|
|
198
|
+
return self
|
|
199
|
+
|
|
200
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
201
|
+
self.logout()
|