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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: py-web-ssh
3
- Version: 0.1.7
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 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
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 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
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。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-web-ssh"
3
- version = "0.1.7"
3
+ version = "0.1.9"
4
4
  description = "A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.7"
5
+ __version__ = "0.1.9"
@@ -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")
@@ -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