py-web-ssh 0.1.3__tar.gz → 0.1.5__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.
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/PKG-INFO +2 -2
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/README.md +1 -1
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/pyproject.toml +1 -1
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/__init__.py +1 -1
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/app.py +77 -5
- py_web_ssh-0.1.5/webssh/files.py +163 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/models.py +2 -1
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/static/app.js +145 -8
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/static/index.html +40 -31
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/static/styles.css +27 -0
- py_web_ssh-0.1.5/webssh/transfers.py +100 -0
- py_web_ssh-0.1.3/webssh/files.py +0 -189
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/LICENSE +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/auth.py +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/client_session.py +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/history.py +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/session.py +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/ssh_client.py +0 -0
- {py_web_ssh-0.1.3 → py_web_ssh-0.1.5}/webssh/static/logs.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: py-web-ssh
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -33,7 +33,7 @@ Description-Content-Type: text/markdown
|
|
|
33
33
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
34
34
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
35
35
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
36
|
-
-
|
|
36
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
|
|
37
37
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
38
38
|
- 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
|
|
39
39
|
- 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
12
12
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
13
13
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
14
|
-
-
|
|
14
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
|
|
15
15
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
16
16
|
- 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
|
|
17
17
|
- 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
|
|
@@ -14,9 +14,15 @@ from . import __version__
|
|
|
14
14
|
from . import auth
|
|
15
15
|
from .auth import add_pin_argument, configure_pin
|
|
16
16
|
from .client_session import ensure_client_session_cookie
|
|
17
|
-
from .files import
|
|
17
|
+
from .files import (
|
|
18
|
+
FileTransferCancelled,
|
|
19
|
+
download_file_via_ssh,
|
|
20
|
+
filename_for_download,
|
|
21
|
+
upload_file_via_ssh,
|
|
22
|
+
)
|
|
18
23
|
from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
|
|
19
24
|
from .session import SessionManager
|
|
25
|
+
from .transfers import TransferManager
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
BASE_DIR = Path(__file__).resolve().parent
|
|
@@ -28,6 +34,7 @@ DEFAULT_PORT = 8022
|
|
|
28
34
|
app = FastAPI(title="py-web-ssh", version=__version__)
|
|
29
35
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
30
36
|
sessions = SessionManager()
|
|
37
|
+
transfers = TransferManager()
|
|
31
38
|
|
|
32
39
|
|
|
33
40
|
@app.middleware("http")
|
|
@@ -118,19 +125,35 @@ def upload(
|
|
|
118
125
|
session_id: str,
|
|
119
126
|
remote_path: Annotated[str, Form(min_length=1)],
|
|
120
127
|
file: Annotated[UploadFile, File()],
|
|
128
|
+
transfer_id: Annotated[str | None, Form()] = None,
|
|
129
|
+
total_bytes: Annotated[int | None, Form()] = None,
|
|
121
130
|
) -> FileTransferResponse:
|
|
122
131
|
session = _require_session(session_id)
|
|
132
|
+
tracker = transfers.get(transfer_id) if transfer_id else None
|
|
133
|
+
if transfer_id and tracker is None:
|
|
134
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
135
|
+
if tracker is None:
|
|
136
|
+
tracker = transfers.create_upload(total_bytes or _content_length(file), remote_path)
|
|
123
137
|
try:
|
|
124
|
-
method, transferred =
|
|
125
|
-
session.
|
|
138
|
+
method, transferred = upload_file_via_ssh(
|
|
139
|
+
session.config,
|
|
126
140
|
file.file,
|
|
127
141
|
remote_path,
|
|
128
|
-
_content_length(file),
|
|
142
|
+
total_bytes or _content_length(file),
|
|
143
|
+
cancel_event=tracker.cancel_event,
|
|
144
|
+
progress=tracker.update_progress,
|
|
129
145
|
)
|
|
146
|
+
except FileTransferCancelled as exc:
|
|
147
|
+
message = f"File upload cancelled: {exc}"
|
|
148
|
+
tracker.cancelled(message)
|
|
149
|
+
session.log("warning", message, None)
|
|
150
|
+
raise HTTPException(status_code=499, detail=message) from exc
|
|
130
151
|
except Exception as exc:
|
|
152
|
+
tracker.fail(str(exc))
|
|
131
153
|
session.log("error", f"File upload failed: {exc}", None)
|
|
132
154
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
133
155
|
message = f"Uploaded {transferred} bytes to {remote_path} using {method}."
|
|
156
|
+
tracker.complete(transferred, message)
|
|
134
157
|
session.log("info", message, None)
|
|
135
158
|
return FileTransferResponse(
|
|
136
159
|
ok=True,
|
|
@@ -138,14 +161,63 @@ def upload(
|
|
|
138
161
|
bytes_transferred=transferred,
|
|
139
162
|
remote_path=remote_path,
|
|
140
163
|
message=message,
|
|
164
|
+
transfer_id=tracker.id,
|
|
141
165
|
)
|
|
142
166
|
|
|
143
167
|
|
|
168
|
+
@app.post("/api/sessions/{session_id}/files/uploads")
|
|
169
|
+
async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
|
|
170
|
+
_require_session(session_id)
|
|
171
|
+
payload = await request.json()
|
|
172
|
+
remote_path = str(payload.get("remote_path", "")).strip()
|
|
173
|
+
if not remote_path:
|
|
174
|
+
raise HTTPException(status_code=422, detail="remote_path is required.")
|
|
175
|
+
total_bytes = payload.get("total_bytes")
|
|
176
|
+
if total_bytes is not None:
|
|
177
|
+
total_bytes = int(total_bytes)
|
|
178
|
+
tracker = transfers.create_upload(total_bytes, remote_path)
|
|
179
|
+
return JSONResponse(
|
|
180
|
+
{
|
|
181
|
+
"transfer_id": tracker.id,
|
|
182
|
+
"state": tracker.status().state,
|
|
183
|
+
"remote_path": remote_path,
|
|
184
|
+
"total_bytes": total_bytes,
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.get("/api/transfers/{transfer_id}")
|
|
190
|
+
def get_transfer(transfer_id: str) -> JSONResponse:
|
|
191
|
+
tracker = transfers.get(transfer_id)
|
|
192
|
+
if tracker is None:
|
|
193
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
194
|
+
status = tracker.status()
|
|
195
|
+
return JSONResponse(
|
|
196
|
+
{
|
|
197
|
+
"transfer_id": status.transfer_id,
|
|
198
|
+
"state": status.state,
|
|
199
|
+
"bytes_transferred": status.bytes_transferred,
|
|
200
|
+
"total_bytes": status.total_bytes,
|
|
201
|
+
"remote_path": status.remote_path,
|
|
202
|
+
"message": status.message,
|
|
203
|
+
"created_at": status.created_at.isoformat(),
|
|
204
|
+
"updated_at": status.updated_at.isoformat(),
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.delete("/api/transfers/{transfer_id}")
|
|
210
|
+
def cancel_transfer(transfer_id: str) -> JSONResponse:
|
|
211
|
+
if not transfers.cancel(transfer_id):
|
|
212
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
213
|
+
return JSONResponse({"ok": True})
|
|
214
|
+
|
|
215
|
+
|
|
144
216
|
@app.get("/api/sessions/{session_id}/files/download")
|
|
145
217
|
def download(session_id: str, remote_path: str) -> StreamingResponse:
|
|
146
218
|
session = _require_session(session_id)
|
|
147
219
|
try:
|
|
148
|
-
method, stream =
|
|
220
|
+
method, stream = download_file_via_ssh(session.config, remote_path)
|
|
149
221
|
except Exception as exc:
|
|
150
222
|
session.log("error", f"File download failed: {exc}", None)
|
|
151
223
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import os
|
|
6
|
+
import posixpath
|
|
7
|
+
import shlex
|
|
8
|
+
import threading
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
11
|
+
from typing import BinaryIO
|
|
12
|
+
|
|
13
|
+
from .models import ConnectRequest
|
|
14
|
+
from .ssh_client import connect_ssh
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class FileTransferError(RuntimeError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FileTransferCancelled(FileTransferError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
ProgressCallback = Callable[[int], None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def upload_file_via_ssh(
|
|
29
|
+
config: ConnectRequest,
|
|
30
|
+
source: BinaryIO,
|
|
31
|
+
remote_path: str,
|
|
32
|
+
size: int | None,
|
|
33
|
+
cancel_event: threading.Event | None = None,
|
|
34
|
+
progress: ProgressCallback | None = None,
|
|
35
|
+
) -> tuple[str, int]:
|
|
36
|
+
"""Upload using only an SSH exec channel and POSIX-ish shell commands.
|
|
37
|
+
|
|
38
|
+
This intentionally does not use SFTP or SCP. It follows the simple-ssh-copy
|
|
39
|
+
style: stream base64 into a remote shell, decode into a temporary file, and
|
|
40
|
+
atomically move the temp file into place after the complete upload succeeds.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
connected = connect_ssh(config, lambda _level, _message, _details=None: None)
|
|
44
|
+
client = connected.client
|
|
45
|
+
remote_dir = posixpath.dirname(remote_path) or "."
|
|
46
|
+
temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
|
|
47
|
+
command = _upload_command(remote_dir, temp_path, remote_path)
|
|
48
|
+
stdin = stdout = stderr = None
|
|
49
|
+
transferred = 0
|
|
50
|
+
try:
|
|
51
|
+
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
52
|
+
while True:
|
|
53
|
+
if cancel_event and cancel_event.is_set():
|
|
54
|
+
raise FileTransferCancelled("Upload cancelled by client.")
|
|
55
|
+
chunk = source.read(48 * 1024)
|
|
56
|
+
if not chunk:
|
|
57
|
+
break
|
|
58
|
+
transferred += len(chunk)
|
|
59
|
+
stdin.write(base64.b64encode(chunk).decode("ascii"))
|
|
60
|
+
stdin.write("\n")
|
|
61
|
+
stdin.flush()
|
|
62
|
+
if progress:
|
|
63
|
+
progress(transferred)
|
|
64
|
+
|
|
65
|
+
if cancel_event and cancel_event.is_set():
|
|
66
|
+
raise FileTransferCancelled("Upload cancelled by client.")
|
|
67
|
+
|
|
68
|
+
stdin.channel.shutdown_write()
|
|
69
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
70
|
+
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
71
|
+
except FileTransferCancelled:
|
|
72
|
+
_close_transport(client)
|
|
73
|
+
raise
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
_close_transport(client)
|
|
76
|
+
raise FileTransferError(f"SSH shell upload failed: {exc}") from exc
|
|
77
|
+
finally:
|
|
78
|
+
for stream in (stdin, stdout, stderr):
|
|
79
|
+
if stream is not None:
|
|
80
|
+
try:
|
|
81
|
+
stream.close()
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
client.close()
|
|
85
|
+
|
|
86
|
+
if exit_code != 0:
|
|
87
|
+
raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
|
|
88
|
+
if size is not None and transferred != size:
|
|
89
|
+
raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
|
|
90
|
+
return "shell", transferred
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def download_file_via_ssh(config: ConnectRequest, remote_path: str) -> tuple[str, Iterator[bytes]]:
|
|
94
|
+
connected = connect_ssh(config, lambda _level, _message, _details=None: None)
|
|
95
|
+
client = connected.client
|
|
96
|
+
command = f"base64 < {shlex.quote(remote_path)}"
|
|
97
|
+
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
98
|
+
stdin.close()
|
|
99
|
+
|
|
100
|
+
def iterator() -> Iterator[bytes]:
|
|
101
|
+
buffered = b""
|
|
102
|
+
try:
|
|
103
|
+
while True:
|
|
104
|
+
chunk = stdout.channel.recv(64 * 1024)
|
|
105
|
+
if not chunk:
|
|
106
|
+
break
|
|
107
|
+
buffered += b"".join(chunk.split())
|
|
108
|
+
keep = len(buffered) % 4
|
|
109
|
+
ready = buffered[:-keep] if keep else buffered
|
|
110
|
+
buffered = buffered[-keep:] if keep else b""
|
|
111
|
+
if ready:
|
|
112
|
+
yield base64.b64decode(ready)
|
|
113
|
+
if buffered:
|
|
114
|
+
yield base64.b64decode(buffered)
|
|
115
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
116
|
+
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
117
|
+
if exit_code != 0:
|
|
118
|
+
raise FileTransferError(
|
|
119
|
+
f"Remote download command failed with exit code {exit_code}: {error_text}"
|
|
120
|
+
)
|
|
121
|
+
except binascii.Error as exc:
|
|
122
|
+
raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
|
|
123
|
+
finally:
|
|
124
|
+
stdout.close()
|
|
125
|
+
stderr.close()
|
|
126
|
+
client.close()
|
|
127
|
+
|
|
128
|
+
return "shell", iterator()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _upload_command(remote_dir: str, temp_path: str, remote_path: str) -> str:
|
|
132
|
+
quoted_temp = shlex.quote(temp_path)
|
|
133
|
+
return "\n".join(
|
|
134
|
+
[
|
|
135
|
+
"set -e",
|
|
136
|
+
f"mkdir -p -- {shlex.quote(remote_dir)}",
|
|
137
|
+
f"tmp={quoted_temp}",
|
|
138
|
+
"cleanup() { rm -f -- \"$tmp\"; }",
|
|
139
|
+
"trap cleanup EXIT HUP INT TERM",
|
|
140
|
+
f"if printf '' | base64 -d >/dev/null 2>&1; then base64 -d > {quoted_temp}; "
|
|
141
|
+
f"else base64 -D > {quoted_temp}; fi",
|
|
142
|
+
f"mv -- {quoted_temp} {shlex.quote(remote_path)}",
|
|
143
|
+
"trap - EXIT",
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _close_transport(client) -> None:
|
|
149
|
+
try:
|
|
150
|
+
transport = client.get_transport()
|
|
151
|
+
if transport is not None:
|
|
152
|
+
transport.close()
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
try:
|
|
156
|
+
client.close()
|
|
157
|
+
except Exception:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def filename_for_download(remote_path: str) -> str:
|
|
162
|
+
name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
|
|
163
|
+
return os.path.basename(name)
|
|
@@ -9,6 +9,10 @@ const pinForm = document.querySelector("#pin-form");
|
|
|
9
9
|
const pinInput = document.querySelector("#pin-input");
|
|
10
10
|
const pinError = document.querySelector("#pin-error");
|
|
11
11
|
const panelToggles = document.querySelectorAll(".panel-toggle");
|
|
12
|
+
const uploadProgress = document.querySelector("#upload-progress");
|
|
13
|
+
const uploadProgressText = document.querySelector("#upload-progress-text");
|
|
14
|
+
const uploadProgressBar = document.querySelector("#upload-progress-bar");
|
|
15
|
+
const cancelUploadButton = document.querySelector("#cancel-upload");
|
|
12
16
|
|
|
13
17
|
const term = new Terminal({
|
|
14
18
|
cursorBlink: true,
|
|
@@ -50,6 +54,7 @@ let ws = null;
|
|
|
50
54
|
let activeSessionId = localStorage.getItem("py-web-ssh-session") || "";
|
|
51
55
|
let lastAppliedSeq = 0;
|
|
52
56
|
let snapshotTimer = null;
|
|
57
|
+
let activeUpload = null;
|
|
53
58
|
|
|
54
59
|
if (activeSessionId) {
|
|
55
60
|
sessionInput.value = activeSessionId;
|
|
@@ -153,26 +158,108 @@ document.querySelector("#upload-form").addEventListener("submit", async (event)
|
|
|
153
158
|
}
|
|
154
159
|
const form = new FormData();
|
|
155
160
|
form.append("remote_path", remotePath);
|
|
161
|
+
form.append("total_bytes", String(file.size));
|
|
156
162
|
form.append("file", file);
|
|
163
|
+
let xhr = null;
|
|
164
|
+
const uploadState = {
|
|
165
|
+
xhr: null,
|
|
166
|
+
transferId: null,
|
|
167
|
+
pollTimer: null,
|
|
168
|
+
cancelled: false,
|
|
169
|
+
fileSize: file.size,
|
|
170
|
+
};
|
|
171
|
+
activeUpload = uploadState;
|
|
172
|
+
showUploadProgress(0, file.size, "准备上传...");
|
|
157
173
|
setStatus("上传中...");
|
|
158
|
-
|
|
174
|
+
try {
|
|
175
|
+
const task = await createUploadTask(activeSessionId, remotePath, file.size);
|
|
176
|
+
uploadState.transferId = task.transfer_id;
|
|
177
|
+
form.append("transfer_id", uploadState.transferId);
|
|
178
|
+
startUploadPolling(uploadState);
|
|
179
|
+
xhr = uploadWithXhr(`/api/sessions/${activeSessionId}/files/upload`, form, uploadState);
|
|
180
|
+
uploadState.xhr = xhr;
|
|
181
|
+
const result = await xhr;
|
|
182
|
+
showUploadProgress(result.bytes_transferred, file.size, "上传完成");
|
|
183
|
+
appendLogLine(`上传完成: ${JSON.stringify(result)}`);
|
|
184
|
+
setStatus("上传完成");
|
|
185
|
+
} catch (error) {
|
|
186
|
+
appendLogLine(uploadState.cancelled ? "上传已取消。" : `上传失败: ${error}`);
|
|
187
|
+
setStatus(uploadState.cancelled ? "上传已取消" : "上传失败");
|
|
188
|
+
} finally {
|
|
189
|
+
stopUploadPolling(uploadState);
|
|
190
|
+
if (activeUpload === uploadState) activeUpload = null;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
async function createUploadTask(sessionId, remotePath, totalBytes) {
|
|
195
|
+
const response = await fetch(`/api/sessions/${sessionId}/files/uploads`, {
|
|
159
196
|
method: "POST",
|
|
160
|
-
|
|
197
|
+
headers: { "Content-Type": "application/json" },
|
|
198
|
+
body: JSON.stringify({ remote_path: remotePath, total_bytes: totalBytes }),
|
|
161
199
|
});
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
throw new Error(await response.text());
|
|
202
|
+
}
|
|
203
|
+
return await response.json();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function uploadWithXhr(url, form, uploadState) {
|
|
207
|
+
return new Promise((resolve, reject) => {
|
|
208
|
+
const xhr = new XMLHttpRequest();
|
|
209
|
+
xhr.open("POST", url);
|
|
210
|
+
xhr.upload.addEventListener("progress", (event) => {
|
|
211
|
+
if (event.lengthComputable) {
|
|
212
|
+
showUploadProgress(event.loaded, event.total, "正在发送到服务端...");
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
xhr.addEventListener("load", () => {
|
|
216
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
217
|
+
resolve(JSON.parse(xhr.responseText));
|
|
218
|
+
} else {
|
|
219
|
+
reject(new Error(xhr.responseText || `HTTP ${xhr.status}`));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
xhr.addEventListener("error", () => reject(new Error("Network error during upload.")));
|
|
223
|
+
xhr.addEventListener("abort", () => reject(new Error("Upload request aborted.")));
|
|
224
|
+
xhr.send(form);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
cancelUploadButton.addEventListener("click", async () => {
|
|
229
|
+
if (!activeUpload) return;
|
|
230
|
+
activeUpload.cancelled = true;
|
|
231
|
+
showUploadProgress(0, activeUpload.fileSize, "正在取消上传...");
|
|
232
|
+
if (activeUpload.transferId) {
|
|
233
|
+
await fetch(`/api/transfers/${activeUpload.transferId}`, { method: "DELETE" });
|
|
234
|
+
}
|
|
235
|
+
if (activeUpload.xhr) activeUpload.xhr.abort();
|
|
165
236
|
});
|
|
166
237
|
|
|
167
|
-
document.querySelector("#download-form").addEventListener("submit", (event) => {
|
|
238
|
+
document.querySelector("#download-form").addEventListener("submit", async (event) => {
|
|
168
239
|
event.preventDefault();
|
|
169
240
|
const remotePath = valueOf("#download-path");
|
|
170
241
|
if (!activeSessionId || !remotePath) {
|
|
171
242
|
appendLogLine("下载需要会话 UUID 和远端路径。");
|
|
172
243
|
return;
|
|
173
244
|
}
|
|
174
|
-
|
|
175
|
-
|
|
245
|
+
setStatus("下载中...");
|
|
246
|
+
const response = await fetch(
|
|
247
|
+
`/api/sessions/${activeSessionId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
|
|
248
|
+
);
|
|
249
|
+
if (!response.ok) {
|
|
250
|
+
appendLogLine(`下载失败: ${await response.text()}`);
|
|
251
|
+
setStatus("下载失败");
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const blob = await response.blob();
|
|
255
|
+
const link = document.createElement("a");
|
|
256
|
+
link.href = URL.createObjectURL(blob);
|
|
257
|
+
link.download = filenameFromResponse(response, remotePath);
|
|
258
|
+
document.body.appendChild(link);
|
|
259
|
+
link.click();
|
|
260
|
+
link.remove();
|
|
261
|
+
URL.revokeObjectURL(link.href);
|
|
262
|
+
setStatus("下载完成");
|
|
176
263
|
});
|
|
177
264
|
|
|
178
265
|
document.querySelectorAll(".tab").forEach((button) => {
|
|
@@ -367,6 +454,56 @@ function checked(selector) {
|
|
|
367
454
|
return document.querySelector(selector).checked;
|
|
368
455
|
}
|
|
369
456
|
|
|
457
|
+
function filenameFromResponse(response, fallbackPath) {
|
|
458
|
+
const disposition = response.headers.get("Content-Disposition") || "";
|
|
459
|
+
const match = disposition.match(/filename="([^"]+)"/);
|
|
460
|
+
if (match) return match[1];
|
|
461
|
+
return fallbackPath.split("/").filter(Boolean).pop() || "download.bin";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function showUploadProgress(done, total, message) {
|
|
465
|
+
uploadProgress.classList.remove("hidden");
|
|
466
|
+
const percent = total ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
|
467
|
+
uploadProgressBar.value = percent;
|
|
468
|
+
uploadProgressText.textContent = `${message} ${formatBytes(done)}${total ? ` / ${formatBytes(total)}` : ""}`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function startUploadPolling(uploadState) {
|
|
472
|
+
stopUploadPolling(uploadState);
|
|
473
|
+
uploadState.pollTimer = window.setInterval(async () => {
|
|
474
|
+
if (!uploadState.transferId) return;
|
|
475
|
+
try {
|
|
476
|
+
const response = await fetch(`/api/transfers/${uploadState.transferId}`);
|
|
477
|
+
if (!response.ok) return;
|
|
478
|
+
const status = await response.json();
|
|
479
|
+
showUploadProgress(
|
|
480
|
+
status.bytes_transferred || 0,
|
|
481
|
+
status.total_bytes || uploadState.fileSize,
|
|
482
|
+
status.message || status.state,
|
|
483
|
+
);
|
|
484
|
+
if (["completed", "cancelled", "failed"].includes(status.state)) {
|
|
485
|
+
stopUploadPolling(uploadState);
|
|
486
|
+
}
|
|
487
|
+
} catch (_error) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
}, 500);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function stopUploadPolling(uploadState) {
|
|
494
|
+
if (uploadState.pollTimer) {
|
|
495
|
+
window.clearInterval(uploadState.pollTimer);
|
|
496
|
+
uploadState.pollTimer = null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function formatBytes(value) {
|
|
501
|
+
if (!Number.isFinite(value)) return "0 B";
|
|
502
|
+
if (value < 1024) return `${value} B`;
|
|
503
|
+
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
|
|
504
|
+
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
|
|
505
|
+
}
|
|
506
|
+
|
|
370
507
|
function bytesToBase64(bytes) {
|
|
371
508
|
let binary = "";
|
|
372
509
|
const size = 0x8000;
|
|
@@ -30,46 +30,48 @@
|
|
|
30
30
|
<button class="panel-toggle" type="button" aria-expanded="false" aria-controls="connect-panel">
|
|
31
31
|
连接
|
|
32
32
|
</button>
|
|
33
|
-
<
|
|
33
|
+
<div id="connect-panel" class="stack panel-body" hidden>
|
|
34
34
|
<div class="section-tabs" aria-label="连接栏目">
|
|
35
35
|
<button class="section-tab active" type="button">连接</button>
|
|
36
36
|
<button class="section-tab" type="button" data-open-panel="session-panel">会话</button>
|
|
37
37
|
<button class="section-tab" type="button" data-open-panel="files-panel">文件</button>
|
|
38
38
|
</div>
|
|
39
|
-
<
|
|
40
|
-
目标服务器
|
|
41
|
-
<input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
|
|
42
|
-
</label>
|
|
43
|
-
<div class="grid-2">
|
|
39
|
+
<form id="connect-form" class="stack">
|
|
44
40
|
<label>
|
|
45
|
-
|
|
46
|
-
<input id="
|
|
41
|
+
目标服务器
|
|
42
|
+
<input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
|
|
47
43
|
</label>
|
|
44
|
+
<div class="grid-2">
|
|
45
|
+
<label>
|
|
46
|
+
端口
|
|
47
|
+
<input id="port" name="port" type="number" min="1" max="65535" value="22" required />
|
|
48
|
+
</label>
|
|
49
|
+
<label>
|
|
50
|
+
用户名
|
|
51
|
+
<input id="username" name="username" required autocomplete="username" />
|
|
52
|
+
</label>
|
|
53
|
+
</div>
|
|
48
54
|
<label>
|
|
49
|
-
|
|
50
|
-
<input id="
|
|
55
|
+
口令
|
|
56
|
+
<input id="password" name="password" type="password" autocomplete="current-password" />
|
|
51
57
|
</label>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
<
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
<label><input id="strict-host-key" type="checkbox" /> known_hosts 校验</label>
|
|
70
|
-
</div>
|
|
71
|
-
<button class="primary" type="submit">连接</button>
|
|
72
|
-
</form>
|
|
58
|
+
<label>
|
|
59
|
+
私钥文件
|
|
60
|
+
<input id="private-key-file" name="private-key-file" type="file" />
|
|
61
|
+
</label>
|
|
62
|
+
<label>
|
|
63
|
+
私钥口令
|
|
64
|
+
<input id="private-key-passphrase" name="private-key-passphrase" type="password" />
|
|
65
|
+
</label>
|
|
66
|
+
<div class="checks">
|
|
67
|
+
<label><input id="legacy-algorithms" type="checkbox" checked /> legacy 算法</label>
|
|
68
|
+
<label><input id="allow-agent" type="checkbox" /> SSH agent</label>
|
|
69
|
+
<label><input id="look-for-keys" type="checkbox" /> 服务端本机密钥</label>
|
|
70
|
+
<label><input id="strict-host-key" type="checkbox" /> known_hosts 校验</label>
|
|
71
|
+
</div>
|
|
72
|
+
<button class="primary" type="submit">连接</button>
|
|
73
|
+
</form>
|
|
74
|
+
</div>
|
|
73
75
|
</section>
|
|
74
76
|
|
|
75
77
|
<section class="panel collapsible-panel">
|
|
@@ -115,6 +117,13 @@
|
|
|
115
117
|
</label>
|
|
116
118
|
<button type="submit">上传</button>
|
|
117
119
|
</form>
|
|
120
|
+
<div id="upload-progress" class="transfer-progress hidden">
|
|
121
|
+
<div class="progress-row">
|
|
122
|
+
<span id="upload-progress-text">等待上传</span>
|
|
123
|
+
<button id="cancel-upload" type="button">取消上传</button>
|
|
124
|
+
</div>
|
|
125
|
+
<progress id="upload-progress-bar" max="100" value="0"></progress>
|
|
126
|
+
</div>
|
|
118
127
|
<form id="download-form" class="stack compact">
|
|
119
128
|
<label>
|
|
120
129
|
下载远端路径
|
|
@@ -233,6 +233,33 @@ label {
|
|
|
233
233
|
text-decoration: none;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
.transfer-progress {
|
|
237
|
+
display: grid;
|
|
238
|
+
gap: 8px;
|
|
239
|
+
border: 1px solid var(--line);
|
|
240
|
+
border-radius: 6px;
|
|
241
|
+
padding: 10px;
|
|
242
|
+
background: #0c1116;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.transfer-progress.hidden {
|
|
246
|
+
display: none;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.progress-row {
|
|
250
|
+
display: grid;
|
|
251
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
252
|
+
gap: 8px;
|
|
253
|
+
align-items: center;
|
|
254
|
+
color: var(--muted);
|
|
255
|
+
font-size: 12px;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
progress {
|
|
259
|
+
width: 100%;
|
|
260
|
+
height: 10px;
|
|
261
|
+
}
|
|
262
|
+
|
|
236
263
|
.workspace {
|
|
237
264
|
display: grid;
|
|
238
265
|
grid-template-rows: 52px minmax(0, 1fr);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
TransferState = Literal["running", "completed", "cancelled", "failed"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TransferStatus:
|
|
15
|
+
transfer_id: str
|
|
16
|
+
state: TransferState
|
|
17
|
+
bytes_transferred: int
|
|
18
|
+
total_bytes: int | None
|
|
19
|
+
remote_path: str
|
|
20
|
+
message: str
|
|
21
|
+
created_at: datetime
|
|
22
|
+
updated_at: datetime
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TransferTracker:
|
|
26
|
+
def __init__(self, total_bytes: int | None, remote_path: str) -> None:
|
|
27
|
+
now = datetime.now(timezone.utc)
|
|
28
|
+
self.id = str(uuid.uuid4())
|
|
29
|
+
self.cancel_event = threading.Event()
|
|
30
|
+
self._lock = threading.RLock()
|
|
31
|
+
self._status = TransferStatus(
|
|
32
|
+
transfer_id=self.id,
|
|
33
|
+
state="running",
|
|
34
|
+
bytes_transferred=0,
|
|
35
|
+
total_bytes=total_bytes,
|
|
36
|
+
remote_path=remote_path,
|
|
37
|
+
message="Upload started.",
|
|
38
|
+
created_at=now,
|
|
39
|
+
updated_at=now,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def update_progress(self, bytes_transferred: int) -> None:
|
|
43
|
+
with self._lock:
|
|
44
|
+
self._status.bytes_transferred = bytes_transferred
|
|
45
|
+
self._status.message = "Uploading."
|
|
46
|
+
self._status.updated_at = datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
def complete(self, bytes_transferred: int, message: str) -> None:
|
|
49
|
+
with self._lock:
|
|
50
|
+
self._status.state = "completed"
|
|
51
|
+
self._status.bytes_transferred = bytes_transferred
|
|
52
|
+
self._status.message = message
|
|
53
|
+
self._status.updated_at = datetime.now(timezone.utc)
|
|
54
|
+
|
|
55
|
+
def fail(self, message: str) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
self._status.state = "failed"
|
|
58
|
+
self._status.message = message
|
|
59
|
+
self._status.updated_at = datetime.now(timezone.utc)
|
|
60
|
+
|
|
61
|
+
def cancel(self) -> None:
|
|
62
|
+
self.cancel_event.set()
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._status.state = "cancelled"
|
|
65
|
+
self._status.message = "Upload cancellation requested."
|
|
66
|
+
self._status.updated_at = datetime.now(timezone.utc)
|
|
67
|
+
|
|
68
|
+
def cancelled(self, message: str) -> None:
|
|
69
|
+
self.cancel_event.set()
|
|
70
|
+
with self._lock:
|
|
71
|
+
self._status.state = "cancelled"
|
|
72
|
+
self._status.message = message
|
|
73
|
+
self._status.updated_at = datetime.now(timezone.utc)
|
|
74
|
+
|
|
75
|
+
def status(self) -> TransferStatus:
|
|
76
|
+
with self._lock:
|
|
77
|
+
return TransferStatus(**self._status.__dict__)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TransferManager:
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._lock = threading.RLock()
|
|
83
|
+
self._transfers: dict[str, TransferTracker] = {}
|
|
84
|
+
|
|
85
|
+
def create_upload(self, total_bytes: int | None, remote_path: str) -> TransferTracker:
|
|
86
|
+
tracker = TransferTracker(total_bytes=total_bytes, remote_path=remote_path)
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._transfers[tracker.id] = tracker
|
|
89
|
+
return tracker
|
|
90
|
+
|
|
91
|
+
def get(self, transfer_id: str) -> TransferTracker | None:
|
|
92
|
+
with self._lock:
|
|
93
|
+
return self._transfers.get(transfer_id)
|
|
94
|
+
|
|
95
|
+
def cancel(self, transfer_id: str) -> bool:
|
|
96
|
+
tracker = self.get(transfer_id)
|
|
97
|
+
if tracker is None:
|
|
98
|
+
return False
|
|
99
|
+
tracker.cancel()
|
|
100
|
+
return True
|
py_web_ssh-0.1.3/webssh/files.py
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import base64
|
|
4
|
-
import binascii
|
|
5
|
-
import os
|
|
6
|
-
import posixpath
|
|
7
|
-
import shlex
|
|
8
|
-
import uuid
|
|
9
|
-
from collections.abc import Iterator
|
|
10
|
-
from typing import BinaryIO
|
|
11
|
-
|
|
12
|
-
import paramiko
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class FileTransferError(RuntimeError):
|
|
16
|
-
pass
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def upload_file(
|
|
20
|
-
client: paramiko.SSHClient,
|
|
21
|
-
source: BinaryIO,
|
|
22
|
-
remote_path: str,
|
|
23
|
-
size: int | None,
|
|
24
|
-
) -> tuple[str, int]:
|
|
25
|
-
try:
|
|
26
|
-
return _upload_sftp(client, source, remote_path, size)
|
|
27
|
-
except Exception as sftp_exc:
|
|
28
|
-
source.seek(0)
|
|
29
|
-
try:
|
|
30
|
-
return _upload_shell(client, source, remote_path, size)
|
|
31
|
-
except Exception as shell_exc:
|
|
32
|
-
raise FileTransferError(
|
|
33
|
-
f"SFTP upload failed: {sftp_exc}\nShell fallback upload failed: {shell_exc}"
|
|
34
|
-
) from shell_exc
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def download_file(client: paramiko.SSHClient, remote_path: str) -> tuple[str, Iterator[bytes]]:
|
|
38
|
-
try:
|
|
39
|
-
return "sftp", _download_sftp(client, remote_path)
|
|
40
|
-
except Exception as sftp_exc:
|
|
41
|
-
try:
|
|
42
|
-
return "shell", _download_shell(client, remote_path)
|
|
43
|
-
except Exception as shell_exc:
|
|
44
|
-
raise FileTransferError(
|
|
45
|
-
f"SFTP download failed: {sftp_exc}\nShell fallback download failed: {shell_exc}"
|
|
46
|
-
) from shell_exc
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _upload_sftp(
|
|
50
|
-
client: paramiko.SSHClient,
|
|
51
|
-
source: BinaryIO,
|
|
52
|
-
remote_path: str,
|
|
53
|
-
size: int | None,
|
|
54
|
-
) -> tuple[str, int]:
|
|
55
|
-
transferred = 0
|
|
56
|
-
|
|
57
|
-
def callback(sent: int, _total: int) -> None:
|
|
58
|
-
nonlocal transferred
|
|
59
|
-
transferred = max(transferred, sent)
|
|
60
|
-
|
|
61
|
-
with client.open_sftp() as sftp:
|
|
62
|
-
parent = posixpath.dirname(remote_path)
|
|
63
|
-
if parent and parent != ".":
|
|
64
|
-
_sftp_mkdirs(sftp, parent)
|
|
65
|
-
sftp.putfo(source, remote_path, file_size=size or 0, callback=callback)
|
|
66
|
-
|
|
67
|
-
if transferred == 0 and size is not None:
|
|
68
|
-
transferred = size
|
|
69
|
-
elif transferred == 0:
|
|
70
|
-
transferred = _remote_size(client, remote_path)
|
|
71
|
-
return "sftp", transferred
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _sftp_mkdirs(sftp: paramiko.SFTPClient, path: str) -> None:
|
|
75
|
-
if path in ("", "/"):
|
|
76
|
-
return
|
|
77
|
-
parts = [part for part in path.split("/") if part]
|
|
78
|
-
current = "/" if path.startswith("/") else ""
|
|
79
|
-
for part in parts:
|
|
80
|
-
current = posixpath.join(current, part) if current else part
|
|
81
|
-
try:
|
|
82
|
-
sftp.stat(current)
|
|
83
|
-
except IOError:
|
|
84
|
-
sftp.mkdir(current)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _upload_shell(
|
|
88
|
-
client: paramiko.SSHClient,
|
|
89
|
-
source: BinaryIO,
|
|
90
|
-
remote_path: str,
|
|
91
|
-
size: int | None,
|
|
92
|
-
) -> tuple[str, int]:
|
|
93
|
-
remote_dir = posixpath.dirname(remote_path) or "."
|
|
94
|
-
temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
|
|
95
|
-
command = (
|
|
96
|
-
"set -e; "
|
|
97
|
-
f"mkdir -p -- {shlex.quote(remote_dir)}; "
|
|
98
|
-
f"({_base64_decode_command()}) > {shlex.quote(temp_path)}; "
|
|
99
|
-
f"mv -- {shlex.quote(temp_path)} {shlex.quote(remote_path)}"
|
|
100
|
-
)
|
|
101
|
-
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
102
|
-
transferred = 0
|
|
103
|
-
try:
|
|
104
|
-
while True:
|
|
105
|
-
chunk = source.read(48 * 1024)
|
|
106
|
-
if not chunk:
|
|
107
|
-
break
|
|
108
|
-
transferred += len(chunk)
|
|
109
|
-
stdin.write(base64.b64encode(chunk).decode("ascii"))
|
|
110
|
-
stdin.write("\n")
|
|
111
|
-
stdin.channel.shutdown_write()
|
|
112
|
-
exit_code = stdout.channel.recv_exit_status()
|
|
113
|
-
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
114
|
-
finally:
|
|
115
|
-
stdin.close()
|
|
116
|
-
stdout.close()
|
|
117
|
-
stderr.close()
|
|
118
|
-
if exit_code != 0:
|
|
119
|
-
raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
|
|
120
|
-
if size is not None and transferred != size:
|
|
121
|
-
raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
|
|
122
|
-
return "shell", transferred
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
def _download_sftp(client: paramiko.SSHClient, remote_path: str) -> Iterator[bytes]:
|
|
126
|
-
sftp = client.open_sftp()
|
|
127
|
-
remote_file = sftp.open(remote_path, "rb")
|
|
128
|
-
|
|
129
|
-
def iterator() -> Iterator[bytes]:
|
|
130
|
-
try:
|
|
131
|
-
while True:
|
|
132
|
-
chunk = remote_file.read(64 * 1024)
|
|
133
|
-
if not chunk:
|
|
134
|
-
break
|
|
135
|
-
yield chunk
|
|
136
|
-
finally:
|
|
137
|
-
remote_file.close()
|
|
138
|
-
sftp.close()
|
|
139
|
-
|
|
140
|
-
return iterator()
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
def _download_shell(client: paramiko.SSHClient, remote_path: str) -> Iterator[bytes]:
|
|
144
|
-
command = f"base64 < {shlex.quote(remote_path)}"
|
|
145
|
-
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
146
|
-
stdin.close()
|
|
147
|
-
|
|
148
|
-
def iterator() -> Iterator[bytes]:
|
|
149
|
-
buffered = b""
|
|
150
|
-
try:
|
|
151
|
-
while True:
|
|
152
|
-
chunk = stdout.channel.recv(64 * 1024)
|
|
153
|
-
if not chunk:
|
|
154
|
-
break
|
|
155
|
-
buffered += b"".join(chunk.split())
|
|
156
|
-
keep = len(buffered) % 4
|
|
157
|
-
ready = buffered[:-keep] if keep else buffered
|
|
158
|
-
buffered = buffered[-keep:] if keep else b""
|
|
159
|
-
if ready:
|
|
160
|
-
yield base64.b64decode(ready)
|
|
161
|
-
if buffered:
|
|
162
|
-
yield base64.b64decode(buffered)
|
|
163
|
-
exit_code = stdout.channel.recv_exit_status()
|
|
164
|
-
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
165
|
-
if exit_code != 0:
|
|
166
|
-
raise FileTransferError(
|
|
167
|
-
f"Remote download command failed with exit code {exit_code}: {error_text}"
|
|
168
|
-
)
|
|
169
|
-
except binascii.Error as exc:
|
|
170
|
-
raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
|
|
171
|
-
finally:
|
|
172
|
-
stdout.close()
|
|
173
|
-
stderr.close()
|
|
174
|
-
|
|
175
|
-
return iterator()
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _base64_decode_command() -> str:
|
|
179
|
-
return "base64 -d 2>/dev/null || base64 -D"
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _remote_size(client: paramiko.SSHClient, remote_path: str) -> int:
|
|
183
|
-
with client.open_sftp() as sftp:
|
|
184
|
-
return int(sftp.stat(remote_path).st_size or 0)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def filename_for_download(remote_path: str) -> str:
|
|
188
|
-
name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
|
|
189
|
-
return os.path.basename(name)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|