py-web-ssh 0.1.7__tar.gz → 0.1.9__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.7 → py_web_ssh-0.1.9}/PKG-INFO +2 -2
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/README.md +1 -1
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/pyproject.toml +1 -1
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/__init__.py +1 -1
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/app.py +6 -1
- py_web_ssh-0.1.9/webssh/files.py +311 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/session.py +2 -0
- py_web_ssh-0.1.7/webssh/files.py +0 -176
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/LICENSE +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/auth.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/client_session.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/history.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/models.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/runtime_config.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/ssh_client.py +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/static/app.js +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/static/index.html +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/static/logs.html +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/static/styles.css +0 -0
- {py_web_ssh-0.1.7 → py_web_ssh-0.1.9}/webssh/transfers.py +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.9
|
|
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
|
|
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
|
|
|
34
34
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
35
35
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
36
36
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
37
|
-
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH
|
|
37
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,通过短小的远端 shell 命令分块追加 base64 临时文件,再在远端解码到临时数据文件并 `mv` 到最终路径,支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
|
|
38
38
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
39
39
|
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
40
40
|
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
13
13
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
14
14
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
15
|
-
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH
|
|
15
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,通过短小的远端 shell 命令分块追加 base64 临时文件,再在远端解码到临时数据文件并 `mv` 到最终路径,支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
|
|
16
16
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
17
17
|
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
18
18
|
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
@@ -187,7 +187,12 @@ def upload(
|
|
|
187
187
|
|
|
188
188
|
@app.post("/api/sessions/{session_id}/files/uploads")
|
|
189
189
|
async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
|
|
190
|
-
_require_session(session_id)
|
|
190
|
+
session = _require_session(session_id)
|
|
191
|
+
if session.confirmed_host_key is None:
|
|
192
|
+
raise HTTPException(
|
|
193
|
+
status_code=409,
|
|
194
|
+
detail="SSH server host key has not been confirmed in the terminal yet.",
|
|
195
|
+
)
|
|
191
196
|
payload = await request.json()
|
|
192
197
|
remote_path = str(payload.get("remote_path", "")).strip()
|
|
193
198
|
if not remote_path:
|
|
@@ -0,0 +1,311 @@
|
|
|
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, Protocol
|
|
12
|
+
|
|
13
|
+
from .models import ConnectRequest
|
|
14
|
+
from .ssh_client import HostKeyInfo, 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
|
+
UPLOAD_BLOCK_SIZE = 4096
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ShellClient(Protocol):
|
|
30
|
+
def exec_command(self, command: str): ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def upload_file_via_ssh(
|
|
34
|
+
config: ConnectRequest,
|
|
35
|
+
source: BinaryIO,
|
|
36
|
+
remote_path: str,
|
|
37
|
+
size: int | None,
|
|
38
|
+
expected_host_key: HostKeyInfo,
|
|
39
|
+
cancel_event: threading.Event | None = None,
|
|
40
|
+
progress: ProgressCallback | None = None,
|
|
41
|
+
) -> tuple[str, int]:
|
|
42
|
+
"""Upload using only bounded SSH exec commands and POSIX-ish shell commands.
|
|
43
|
+
|
|
44
|
+
This intentionally does not use SFTP or SCP. It follows the simple-ssh-copy
|
|
45
|
+
style: append base64 fragments with short remote commands, decode into a
|
|
46
|
+
temporary file, and move that temp file into place after the complete upload
|
|
47
|
+
succeeds.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
connected = connect_ssh(
|
|
51
|
+
config,
|
|
52
|
+
lambda _level, _message, _details=None: None,
|
|
53
|
+
expected_host_key=expected_host_key,
|
|
54
|
+
)
|
|
55
|
+
client = connected.client
|
|
56
|
+
remote_dir = posixpath.dirname(remote_path) or "."
|
|
57
|
+
token = uuid.uuid4().hex
|
|
58
|
+
base64_temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{token}.b64")
|
|
59
|
+
data_temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{token}.tmp")
|
|
60
|
+
transferred = 0
|
|
61
|
+
completed = False
|
|
62
|
+
try:
|
|
63
|
+
_run_remote_command(client, f"mkdir -p {shlex.quote(remote_dir)}")
|
|
64
|
+
_run_remote_command(client, f": > {shlex.quote(base64_temp_path)}")
|
|
65
|
+
transferred = _write_base64_temp_file(
|
|
66
|
+
client,
|
|
67
|
+
source,
|
|
68
|
+
base64_temp_path,
|
|
69
|
+
cancel_event=cancel_event,
|
|
70
|
+
progress=progress,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if cancel_event and cancel_event.is_set():
|
|
74
|
+
raise FileTransferCancelled("Upload cancelled by client.")
|
|
75
|
+
|
|
76
|
+
_run_remote_command(client, _decode_and_move_command(base64_temp_path, data_temp_path, remote_path))
|
|
77
|
+
completed = True
|
|
78
|
+
except FileTransferCancelled:
|
|
79
|
+
_cleanup_remote_upload(client, base64_temp_path, data_temp_path)
|
|
80
|
+
_close_transport(client)
|
|
81
|
+
raise
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
_cleanup_remote_upload(client, base64_temp_path, data_temp_path)
|
|
84
|
+
_close_transport(client)
|
|
85
|
+
raise FileTransferError(f"SSH shell upload failed: {exc}") from exc
|
|
86
|
+
finally:
|
|
87
|
+
if completed:
|
|
88
|
+
_cleanup_remote_upload(client, base64_temp_path, data_temp_path, raise_on_failure=False)
|
|
89
|
+
client.close()
|
|
90
|
+
|
|
91
|
+
if size is not None and transferred != size:
|
|
92
|
+
raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
|
|
93
|
+
return "shell", transferred
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def download_file_via_ssh(
|
|
97
|
+
config: ConnectRequest,
|
|
98
|
+
remote_path: str,
|
|
99
|
+
expected_host_key: HostKeyInfo,
|
|
100
|
+
) -> tuple[str, Iterator[bytes]]:
|
|
101
|
+
connected = connect_ssh(
|
|
102
|
+
config,
|
|
103
|
+
lambda _level, _message, _details=None: None,
|
|
104
|
+
expected_host_key=expected_host_key,
|
|
105
|
+
)
|
|
106
|
+
client = connected.client
|
|
107
|
+
command = f"base64 < {shlex.quote(remote_path)}"
|
|
108
|
+
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
109
|
+
stdin.close()
|
|
110
|
+
|
|
111
|
+
def iterator() -> Iterator[bytes]:
|
|
112
|
+
buffered = b""
|
|
113
|
+
try:
|
|
114
|
+
while True:
|
|
115
|
+
chunk = stdout.channel.recv(64 * 1024)
|
|
116
|
+
if not chunk:
|
|
117
|
+
break
|
|
118
|
+
buffered += b"".join(chunk.split())
|
|
119
|
+
keep = len(buffered) % 4
|
|
120
|
+
ready = buffered[:-keep] if keep else buffered
|
|
121
|
+
buffered = buffered[-keep:] if keep else b""
|
|
122
|
+
if ready:
|
|
123
|
+
yield base64.b64decode(ready)
|
|
124
|
+
if buffered:
|
|
125
|
+
yield base64.b64decode(buffered)
|
|
126
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
127
|
+
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
128
|
+
if exit_code != 0:
|
|
129
|
+
raise FileTransferError(
|
|
130
|
+
f"Remote download command failed with exit code {exit_code}: {error_text}"
|
|
131
|
+
)
|
|
132
|
+
except binascii.Error as exc:
|
|
133
|
+
raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
|
|
134
|
+
finally:
|
|
135
|
+
stdout.close()
|
|
136
|
+
stderr.close()
|
|
137
|
+
client.close()
|
|
138
|
+
|
|
139
|
+
return "shell", iterator()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _write_base64_temp_file(
|
|
143
|
+
client: ShellClient,
|
|
144
|
+
source: BinaryIO,
|
|
145
|
+
remote_base64_path: str,
|
|
146
|
+
cancel_event: threading.Event | None = None,
|
|
147
|
+
progress: ProgressCallback | None = None,
|
|
148
|
+
block_size: int = UPLOAD_BLOCK_SIZE,
|
|
149
|
+
) -> int:
|
|
150
|
+
transferred = 0
|
|
151
|
+
pending = b""
|
|
152
|
+
|
|
153
|
+
while True:
|
|
154
|
+
if cancel_event and cancel_event.is_set():
|
|
155
|
+
raise FileTransferCancelled("Upload cancelled by client.")
|
|
156
|
+
data = source.read(block_size)
|
|
157
|
+
if not data:
|
|
158
|
+
break
|
|
159
|
+
|
|
160
|
+
data = pending + data
|
|
161
|
+
encodable_len = len(data) - (len(data) % 3)
|
|
162
|
+
if encodable_len:
|
|
163
|
+
raw_chunk = data[:encodable_len]
|
|
164
|
+
_append_base64_to_remote_file(client, remote_base64_path, base64.b64encode(raw_chunk), block_size)
|
|
165
|
+
transferred += len(raw_chunk)
|
|
166
|
+
if progress:
|
|
167
|
+
progress(transferred)
|
|
168
|
+
pending = data[encodable_len:]
|
|
169
|
+
|
|
170
|
+
if pending:
|
|
171
|
+
_append_base64_to_remote_file(client, remote_base64_path, base64.b64encode(pending), block_size)
|
|
172
|
+
transferred += len(pending)
|
|
173
|
+
if progress:
|
|
174
|
+
progress(transferred)
|
|
175
|
+
|
|
176
|
+
return transferred
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _append_base64_to_remote_file(
|
|
180
|
+
client: ShellClient,
|
|
181
|
+
remote_base64_path: str,
|
|
182
|
+
encoded_data: bytes,
|
|
183
|
+
block_size: int = UPLOAD_BLOCK_SIZE,
|
|
184
|
+
) -> None:
|
|
185
|
+
quoted_path = shlex.quote(remote_base64_path)
|
|
186
|
+
command_prefix = "printf %s "
|
|
187
|
+
command_suffix = f" >> {quoted_path}"
|
|
188
|
+
max_payload_len = _max_base64_payload_length(command_prefix, command_suffix, block_size)
|
|
189
|
+
encoded_text = encoded_data.decode("ascii")
|
|
190
|
+
|
|
191
|
+
for offset in range(0, len(encoded_text), max_payload_len):
|
|
192
|
+
payload = encoded_text[offset : offset + max_payload_len]
|
|
193
|
+
_run_bounded_remote_command(
|
|
194
|
+
client,
|
|
195
|
+
f"{command_prefix}{shlex.quote(payload)}{command_suffix}",
|
|
196
|
+
block_size,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _max_base64_payload_length(command_prefix: str, command_suffix: str, block_size: int) -> int:
|
|
201
|
+
overhead = len(command_prefix.encode("utf-8")) + len(command_suffix.encode("utf-8"))
|
|
202
|
+
available = block_size - overhead
|
|
203
|
+
if available < 4:
|
|
204
|
+
raise ValueError("Upload block size is too small for remote command overhead.")
|
|
205
|
+
return available - (available % 4)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _decode_and_move_command(base64_temp_path: str, data_temp_path: str, remote_path: str) -> str:
|
|
209
|
+
quoted_base64 = shlex.quote(base64_temp_path)
|
|
210
|
+
quoted_temp = shlex.quote(data_temp_path)
|
|
211
|
+
quoted_remote = shlex.quote(remote_path)
|
|
212
|
+
quoted_error = shlex.quote(f"{data_temp_path}.err")
|
|
213
|
+
return "\n".join(
|
|
214
|
+
[
|
|
215
|
+
"set -e",
|
|
216
|
+
f"rm -f {quoted_temp} {quoted_error}",
|
|
217
|
+
f"if command base64 -d < {quoted_base64} > {quoted_temp} 2> {quoted_error}; then",
|
|
218
|
+
" :",
|
|
219
|
+
f"elif command base64 -D < {quoted_base64} > {quoted_temp} 2> {quoted_error}; then",
|
|
220
|
+
" :",
|
|
221
|
+
"else",
|
|
222
|
+
f" cat {quoted_error} >&2",
|
|
223
|
+
" exit 1",
|
|
224
|
+
"fi",
|
|
225
|
+
f"mv -f {quoted_temp} {quoted_remote}",
|
|
226
|
+
f"rm -f {quoted_base64} {quoted_error}",
|
|
227
|
+
]
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _run_bounded_remote_command(client: ShellClient, command: str, block_size: int) -> tuple[bytes, bytes]:
|
|
232
|
+
command_len = len(command.encode("utf-8"))
|
|
233
|
+
if command_len > block_size:
|
|
234
|
+
raise FileTransferError(f"Upload command exceeded block_size={block_size}: {command_len}")
|
|
235
|
+
return _run_remote_command(client, command)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _run_remote_command(client: ShellClient, command: str) -> tuple[bytes, bytes]:
|
|
239
|
+
stdin = stdout = stderr = None
|
|
240
|
+
try:
|
|
241
|
+
stdin, stdout, stderr = client.exec_command(command)
|
|
242
|
+
if stdin is not None:
|
|
243
|
+
stdin.close()
|
|
244
|
+
output = stdout.read()
|
|
245
|
+
error = stderr.read()
|
|
246
|
+
exit_code = stdout.channel.recv_exit_status()
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
raise FileTransferError(f"{exc}; command={_short_command(command)}") from exc
|
|
249
|
+
finally:
|
|
250
|
+
for stream in (stdin, stdout, stderr):
|
|
251
|
+
if stream is not None:
|
|
252
|
+
try:
|
|
253
|
+
stream.close()
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
if exit_code != 0:
|
|
258
|
+
message = error.decode("utf-8", errors="replace").strip()
|
|
259
|
+
output_text = output.decode("utf-8", errors="replace").strip()
|
|
260
|
+
if output_text:
|
|
261
|
+
message = f"{message}\n{output_text}".strip()
|
|
262
|
+
raise FileTransferError(
|
|
263
|
+
message or f"Remote command failed with exit code {exit_code}: {_short_command(command)}"
|
|
264
|
+
)
|
|
265
|
+
return output, error
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _cleanup_remote_upload(
|
|
269
|
+
client: ShellClient,
|
|
270
|
+
base64_temp_path: str,
|
|
271
|
+
data_temp_path: str,
|
|
272
|
+
raise_on_failure: bool = False,
|
|
273
|
+
) -> None:
|
|
274
|
+
command = "rm -f " + " ".join(
|
|
275
|
+
shlex.quote(path)
|
|
276
|
+
for path in (
|
|
277
|
+
base64_temp_path,
|
|
278
|
+
data_temp_path,
|
|
279
|
+
f"{data_temp_path}.err",
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
try:
|
|
283
|
+
_run_remote_command(client, command)
|
|
284
|
+
except Exception:
|
|
285
|
+
if raise_on_failure:
|
|
286
|
+
raise
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _short_command(command: str, limit: int = 300) -> str:
|
|
290
|
+
compact = " ".join(command.split())
|
|
291
|
+
if len(compact) <= limit:
|
|
292
|
+
return compact
|
|
293
|
+
return compact[: limit - 3] + "..."
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _close_transport(client) -> None:
|
|
297
|
+
try:
|
|
298
|
+
transport = client.get_transport()
|
|
299
|
+
if transport is not None:
|
|
300
|
+
transport.close()
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
try:
|
|
304
|
+
client.close()
|
|
305
|
+
except Exception:
|
|
306
|
+
pass
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def filename_for_download(remote_path: str) -> str:
|
|
310
|
+
name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
|
|
311
|
+
return os.path.basename(name)
|
|
@@ -272,6 +272,8 @@ class TerminalSession:
|
|
|
272
272
|
self._host_key_lock.wait(timeout=0.2)
|
|
273
273
|
else:
|
|
274
274
|
accepted = False
|
|
275
|
+
with self._host_key_lock:
|
|
276
|
+
self._awaiting_host_key_confirmation = False
|
|
275
277
|
|
|
276
278
|
if accepted:
|
|
277
279
|
self._append_output(b"Y\r\nContinuing SSH authentication...\r\n")
|
py_web_ssh-0.1.7/webssh/files.py
DELETED
|
@@ -1,176 +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 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 HostKeyInfo, 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
|
-
expected_host_key: HostKeyInfo,
|
|
34
|
-
cancel_event: threading.Event | None = None,
|
|
35
|
-
progress: ProgressCallback | None = None,
|
|
36
|
-
) -> tuple[str, int]:
|
|
37
|
-
"""Upload using only an SSH exec channel and POSIX-ish shell commands.
|
|
38
|
-
|
|
39
|
-
This intentionally does not use SFTP or SCP. It follows the simple-ssh-copy
|
|
40
|
-
style: stream base64 into a remote shell, decode into a temporary file, and
|
|
41
|
-
atomically move the temp file into place after the complete upload succeeds.
|
|
42
|
-
"""
|
|
43
|
-
|
|
44
|
-
connected = connect_ssh(
|
|
45
|
-
config,
|
|
46
|
-
lambda _level, _message, _details=None: None,
|
|
47
|
-
expected_host_key=expected_host_key,
|
|
48
|
-
)
|
|
49
|
-
client = connected.client
|
|
50
|
-
remote_dir = posixpath.dirname(remote_path) or "."
|
|
51
|
-
temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
|
|
52
|
-
command = _upload_command(remote_dir, temp_path, remote_path)
|
|
53
|
-
stdin = stdout = stderr = None
|
|
54
|
-
transferred = 0
|
|
55
|
-
try:
|
|
56
|
-
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
57
|
-
while True:
|
|
58
|
-
if cancel_event and cancel_event.is_set():
|
|
59
|
-
raise FileTransferCancelled("Upload cancelled by client.")
|
|
60
|
-
chunk = source.read(48 * 1024)
|
|
61
|
-
if not chunk:
|
|
62
|
-
break
|
|
63
|
-
transferred += len(chunk)
|
|
64
|
-
stdin.write(base64.b64encode(chunk).decode("ascii"))
|
|
65
|
-
stdin.write("\n")
|
|
66
|
-
stdin.flush()
|
|
67
|
-
if progress:
|
|
68
|
-
progress(transferred)
|
|
69
|
-
|
|
70
|
-
if cancel_event and cancel_event.is_set():
|
|
71
|
-
raise FileTransferCancelled("Upload cancelled by client.")
|
|
72
|
-
|
|
73
|
-
stdin.channel.shutdown_write()
|
|
74
|
-
exit_code = stdout.channel.recv_exit_status()
|
|
75
|
-
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
76
|
-
except FileTransferCancelled:
|
|
77
|
-
_close_transport(client)
|
|
78
|
-
raise
|
|
79
|
-
except Exception as exc:
|
|
80
|
-
_close_transport(client)
|
|
81
|
-
raise FileTransferError(f"SSH shell upload failed: {exc}") from exc
|
|
82
|
-
finally:
|
|
83
|
-
for stream in (stdin, stdout, stderr):
|
|
84
|
-
if stream is not None:
|
|
85
|
-
try:
|
|
86
|
-
stream.close()
|
|
87
|
-
except Exception:
|
|
88
|
-
pass
|
|
89
|
-
client.close()
|
|
90
|
-
|
|
91
|
-
if exit_code != 0:
|
|
92
|
-
raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
|
|
93
|
-
if size is not None and transferred != size:
|
|
94
|
-
raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
|
|
95
|
-
return "shell", transferred
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def download_file_via_ssh(
|
|
99
|
-
config: ConnectRequest,
|
|
100
|
-
remote_path: str,
|
|
101
|
-
expected_host_key: HostKeyInfo,
|
|
102
|
-
) -> tuple[str, Iterator[bytes]]:
|
|
103
|
-
connected = connect_ssh(
|
|
104
|
-
config,
|
|
105
|
-
lambda _level, _message, _details=None: None,
|
|
106
|
-
expected_host_key=expected_host_key,
|
|
107
|
-
)
|
|
108
|
-
client = connected.client
|
|
109
|
-
command = f"base64 < {shlex.quote(remote_path)}"
|
|
110
|
-
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
111
|
-
stdin.close()
|
|
112
|
-
|
|
113
|
-
def iterator() -> Iterator[bytes]:
|
|
114
|
-
buffered = b""
|
|
115
|
-
try:
|
|
116
|
-
while True:
|
|
117
|
-
chunk = stdout.channel.recv(64 * 1024)
|
|
118
|
-
if not chunk:
|
|
119
|
-
break
|
|
120
|
-
buffered += b"".join(chunk.split())
|
|
121
|
-
keep = len(buffered) % 4
|
|
122
|
-
ready = buffered[:-keep] if keep else buffered
|
|
123
|
-
buffered = buffered[-keep:] if keep else b""
|
|
124
|
-
if ready:
|
|
125
|
-
yield base64.b64decode(ready)
|
|
126
|
-
if buffered:
|
|
127
|
-
yield base64.b64decode(buffered)
|
|
128
|
-
exit_code = stdout.channel.recv_exit_status()
|
|
129
|
-
error_text = stderr.read().decode("utf-8", errors="replace")
|
|
130
|
-
if exit_code != 0:
|
|
131
|
-
raise FileTransferError(
|
|
132
|
-
f"Remote download command failed with exit code {exit_code}: {error_text}"
|
|
133
|
-
)
|
|
134
|
-
except binascii.Error as exc:
|
|
135
|
-
raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
|
|
136
|
-
finally:
|
|
137
|
-
stdout.close()
|
|
138
|
-
stderr.close()
|
|
139
|
-
client.close()
|
|
140
|
-
|
|
141
|
-
return "shell", iterator()
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
def _upload_command(remote_dir: str, temp_path: str, remote_path: str) -> str:
|
|
145
|
-
quoted_temp = shlex.quote(temp_path)
|
|
146
|
-
return "\n".join(
|
|
147
|
-
[
|
|
148
|
-
"set -e",
|
|
149
|
-
f"mkdir -p -- {shlex.quote(remote_dir)}",
|
|
150
|
-
f"tmp={quoted_temp}",
|
|
151
|
-
"cleanup() { rm -f -- \"$tmp\"; }",
|
|
152
|
-
"trap cleanup EXIT HUP INT TERM",
|
|
153
|
-
f"if printf '' | base64 -d >/dev/null 2>&1; then base64 -d > {quoted_temp}; "
|
|
154
|
-
f"else base64 -D > {quoted_temp}; fi",
|
|
155
|
-
f"mv -- {quoted_temp} {shlex.quote(remote_path)}",
|
|
156
|
-
"trap - EXIT",
|
|
157
|
-
]
|
|
158
|
-
)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def _close_transport(client) -> None:
|
|
162
|
-
try:
|
|
163
|
-
transport = client.get_transport()
|
|
164
|
-
if transport is not None:
|
|
165
|
-
transport.close()
|
|
166
|
-
except Exception:
|
|
167
|
-
pass
|
|
168
|
-
try:
|
|
169
|
-
client.close()
|
|
170
|
-
except Exception:
|
|
171
|
-
pass
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def filename_for_download(remote_path: str) -> str:
|
|
175
|
-
name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
|
|
176
|
-
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|