py-web-ssh 0.1.4__tar.gz → 0.1.6__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.4 → py_web_ssh-0.1.6}/PKG-INFO +15 -2
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/README.md +25 -12
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/pyproject.toml +1 -1
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/__init__.py +1 -1
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/app.py +101 -9
- py_web_ssh-0.1.6/webssh/files.py +163 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/models.py +2 -1
- py_web_ssh-0.1.6/webssh/runtime_config.py +121 -0
- py_web_ssh-0.1.6/webssh/static/app.js +747 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/static/index.html +60 -48
- py_web_ssh-0.1.6/webssh/static/logs.html +86 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/static/styles.css +63 -0
- py_web_ssh-0.1.6/webssh/transfers.py +100 -0
- py_web_ssh-0.1.4/webssh/files.py +0 -189
- py_web_ssh-0.1.4/webssh/static/app.js +0 -417
- py_web_ssh-0.1.4/webssh/static/logs.html +0 -44
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/LICENSE +0 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/auth.py +0 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/client_session.py +0 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/history.py +0 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/session.py +0 -0
- {py_web_ssh-0.1.4 → py_web_ssh-0.1.6}/webssh/ssh_client.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.6
|
|
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,8 +33,10 @@ 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
|
+
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
39
|
+
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
38
40
|
- 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
|
|
39
41
|
- 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
|
|
40
42
|
|
|
@@ -66,10 +68,21 @@ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
|
|
|
66
68
|
py-web-ssh --pin 123456
|
|
67
69
|
```
|
|
68
70
|
|
|
71
|
+
锁定连接目标或凭据:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
py-web-ssh --lock-host server.example.com --lock-username deploy
|
|
75
|
+
py-web-ssh --lock-pwd 'ssh-password'
|
|
76
|
+
py-web-ssh --lock-private-key C:\secrets\id_ed25519
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`--lock-pwd` 和 `--lock-private-key` 的值只在服务端使用,不会通过配置接口发送给浏览器。`--lock-private-key` 指向的是服务端本机文件路径,不是浏览器上传的文件。
|
|
80
|
+
|
|
69
81
|
前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
|
|
70
82
|
|
|
71
83
|
## API 概览
|
|
72
84
|
|
|
85
|
+
- `GET /api/config` 查看非敏感的服务端公开配置和锁定状态。
|
|
73
86
|
- `POST /api/sessions` 创建 SSH 会话。
|
|
74
87
|
- `GET /api/sessions/{uuid}` 查看会话状态。
|
|
75
88
|
- `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
|
|
@@ -11,8 +11,10 @@
|
|
|
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
|
+
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
17
|
+
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
16
18
|
- 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
|
|
17
19
|
- 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
|
|
18
20
|
|
|
@@ -38,17 +40,28 @@ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
|
|
|
38
40
|
|
|
39
41
|
打开 <http://127.0.0.1:8022>。
|
|
40
42
|
|
|
41
|
-
启用 PIN:
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
py-web-ssh --pin 123456
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
-
|
|
43
|
+
启用 PIN:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
py-web-ssh --pin 123456
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
锁定连接目标或凭据:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
py-web-ssh --lock-host server.example.com --lock-username deploy
|
|
53
|
+
py-web-ssh --lock-pwd 'ssh-password'
|
|
54
|
+
py-web-ssh --lock-private-key C:\secrets\id_ed25519
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`--lock-pwd` 和 `--lock-private-key` 的值只在服务端使用,不会通过配置接口发送给浏览器。`--lock-private-key` 指向的是服务端本机文件路径,不是浏览器上传的文件。
|
|
58
|
+
|
|
59
|
+
前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
|
|
60
|
+
|
|
61
|
+
## API 概览
|
|
62
|
+
|
|
63
|
+
- `GET /api/config` 查看非敏感的服务端公开配置和锁定状态。
|
|
64
|
+
- `POST /api/sessions` 创建 SSH 会话。
|
|
52
65
|
- `GET /api/sessions/{uuid}` 查看会话状态。
|
|
53
66
|
- `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
|
|
54
67
|
- `DELETE /api/sessions/{uuid}` 主动断开 SSH。
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
-
import base64
|
|
5
4
|
import argparse
|
|
5
|
+
import base64
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
from typing import Annotated
|
|
8
8
|
|
|
@@ -14,9 +14,17 @@ 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
|
|
24
|
+
from .runtime_config import add_runtime_lock_arguments, configure_runtime_locks
|
|
25
|
+
from . import runtime_config as runtime_config_module
|
|
19
26
|
from .session import SessionManager
|
|
27
|
+
from .transfers import TransferManager
|
|
20
28
|
|
|
21
29
|
|
|
22
30
|
BASE_DIR = Path(__file__).resolve().parent
|
|
@@ -28,6 +36,7 @@ DEFAULT_PORT = 8022
|
|
|
28
36
|
app = FastAPI(title="py-web-ssh", version=__version__)
|
|
29
37
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
30
38
|
sessions = SessionManager()
|
|
39
|
+
transfers = TransferManager()
|
|
31
40
|
|
|
32
41
|
|
|
33
42
|
@app.middleware("http")
|
|
@@ -53,8 +62,18 @@ def index() -> FileResponse:
|
|
|
53
62
|
|
|
54
63
|
|
|
55
64
|
@app.get("/api/auth/status")
|
|
56
|
-
def auth_status() -> JSONResponse:
|
|
57
|
-
return JSONResponse(
|
|
65
|
+
def auth_status(request: Request) -> JSONResponse:
|
|
66
|
+
return JSONResponse(
|
|
67
|
+
{
|
|
68
|
+
**auth.pin_auth.status_payload(),
|
|
69
|
+
"authorized": auth.pin_auth.is_request_authorized(request),
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.get("/api/config")
|
|
75
|
+
def public_config() -> JSONResponse:
|
|
76
|
+
return JSONResponse(runtime_config_module.runtime_config.public_payload())
|
|
58
77
|
|
|
59
78
|
|
|
60
79
|
@app.post("/api/auth/login")
|
|
@@ -76,7 +95,8 @@ def logs_page(session_id: str) -> HTMLResponse:
|
|
|
76
95
|
|
|
77
96
|
@app.post("/api/sessions", response_model=CreateSessionResponse)
|
|
78
97
|
def create_session(config: ConnectRequest) -> CreateSessionResponse:
|
|
79
|
-
|
|
98
|
+
locked_config = runtime_config_module.runtime_config.apply_to_connect_request(config)
|
|
99
|
+
session = sessions.create(locked_config)
|
|
80
100
|
return CreateSessionResponse(
|
|
81
101
|
session_id=session.id,
|
|
82
102
|
logs_url=f"/sessions/{session.id}/logs",
|
|
@@ -118,19 +138,35 @@ def upload(
|
|
|
118
138
|
session_id: str,
|
|
119
139
|
remote_path: Annotated[str, Form(min_length=1)],
|
|
120
140
|
file: Annotated[UploadFile, File()],
|
|
141
|
+
transfer_id: Annotated[str | None, Form()] = None,
|
|
142
|
+
total_bytes: Annotated[int | None, Form()] = None,
|
|
121
143
|
) -> FileTransferResponse:
|
|
122
144
|
session = _require_session(session_id)
|
|
145
|
+
tracker = transfers.get(transfer_id) if transfer_id else None
|
|
146
|
+
if transfer_id and tracker is None:
|
|
147
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
148
|
+
if tracker is None:
|
|
149
|
+
tracker = transfers.create_upload(total_bytes or _content_length(file), remote_path)
|
|
123
150
|
try:
|
|
124
|
-
method, transferred =
|
|
125
|
-
session.
|
|
151
|
+
method, transferred = upload_file_via_ssh(
|
|
152
|
+
session.config,
|
|
126
153
|
file.file,
|
|
127
154
|
remote_path,
|
|
128
|
-
_content_length(file),
|
|
155
|
+
total_bytes or _content_length(file),
|
|
156
|
+
cancel_event=tracker.cancel_event,
|
|
157
|
+
progress=tracker.update_progress,
|
|
129
158
|
)
|
|
159
|
+
except FileTransferCancelled as exc:
|
|
160
|
+
message = f"File upload cancelled: {exc}"
|
|
161
|
+
tracker.cancelled(message)
|
|
162
|
+
session.log("warning", message, None)
|
|
163
|
+
raise HTTPException(status_code=499, detail=message) from exc
|
|
130
164
|
except Exception as exc:
|
|
165
|
+
tracker.fail(str(exc))
|
|
131
166
|
session.log("error", f"File upload failed: {exc}", None)
|
|
132
167
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
133
168
|
message = f"Uploaded {transferred} bytes to {remote_path} using {method}."
|
|
169
|
+
tracker.complete(transferred, message)
|
|
134
170
|
session.log("info", message, None)
|
|
135
171
|
return FileTransferResponse(
|
|
136
172
|
ok=True,
|
|
@@ -138,14 +174,63 @@ def upload(
|
|
|
138
174
|
bytes_transferred=transferred,
|
|
139
175
|
remote_path=remote_path,
|
|
140
176
|
message=message,
|
|
177
|
+
transfer_id=tracker.id,
|
|
141
178
|
)
|
|
142
179
|
|
|
143
180
|
|
|
181
|
+
@app.post("/api/sessions/{session_id}/files/uploads")
|
|
182
|
+
async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
|
|
183
|
+
_require_session(session_id)
|
|
184
|
+
payload = await request.json()
|
|
185
|
+
remote_path = str(payload.get("remote_path", "")).strip()
|
|
186
|
+
if not remote_path:
|
|
187
|
+
raise HTTPException(status_code=422, detail="remote_path is required.")
|
|
188
|
+
total_bytes = payload.get("total_bytes")
|
|
189
|
+
if total_bytes is not None:
|
|
190
|
+
total_bytes = int(total_bytes)
|
|
191
|
+
tracker = transfers.create_upload(total_bytes, remote_path)
|
|
192
|
+
return JSONResponse(
|
|
193
|
+
{
|
|
194
|
+
"transfer_id": tracker.id,
|
|
195
|
+
"state": tracker.status().state,
|
|
196
|
+
"remote_path": remote_path,
|
|
197
|
+
"total_bytes": total_bytes,
|
|
198
|
+
}
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.get("/api/transfers/{transfer_id}")
|
|
203
|
+
def get_transfer(transfer_id: str) -> JSONResponse:
|
|
204
|
+
tracker = transfers.get(transfer_id)
|
|
205
|
+
if tracker is None:
|
|
206
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
207
|
+
status = tracker.status()
|
|
208
|
+
return JSONResponse(
|
|
209
|
+
{
|
|
210
|
+
"transfer_id": status.transfer_id,
|
|
211
|
+
"state": status.state,
|
|
212
|
+
"bytes_transferred": status.bytes_transferred,
|
|
213
|
+
"total_bytes": status.total_bytes,
|
|
214
|
+
"remote_path": status.remote_path,
|
|
215
|
+
"message": status.message,
|
|
216
|
+
"created_at": status.created_at.isoformat(),
|
|
217
|
+
"updated_at": status.updated_at.isoformat(),
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@app.delete("/api/transfers/{transfer_id}")
|
|
223
|
+
def cancel_transfer(transfer_id: str) -> JSONResponse:
|
|
224
|
+
if not transfers.cancel(transfer_id):
|
|
225
|
+
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
226
|
+
return JSONResponse({"ok": True})
|
|
227
|
+
|
|
228
|
+
|
|
144
229
|
@app.get("/api/sessions/{session_id}/files/download")
|
|
145
230
|
def download(session_id: str, remote_path: str) -> StreamingResponse:
|
|
146
231
|
session = _require_session(session_id)
|
|
147
232
|
try:
|
|
148
|
-
method, stream =
|
|
233
|
+
method, stream = download_file_via_ssh(session.config, remote_path)
|
|
149
234
|
except Exception as exc:
|
|
150
235
|
session.log("error", f"File download failed: {exc}", None)
|
|
151
236
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
@@ -240,6 +325,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
|
|
|
240
325
|
parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.")
|
|
241
326
|
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to listen on.")
|
|
242
327
|
add_pin_argument(parser)
|
|
328
|
+
add_runtime_lock_arguments(parser)
|
|
243
329
|
return parser
|
|
244
330
|
|
|
245
331
|
|
|
@@ -249,6 +335,12 @@ def main() -> None:
|
|
|
249
335
|
args = build_arg_parser().parse_args()
|
|
250
336
|
|
|
251
337
|
configure_pin(args.pin)
|
|
338
|
+
configure_runtime_locks(
|
|
339
|
+
lock_host=args.lock_host,
|
|
340
|
+
lock_username=args.lock_username,
|
|
341
|
+
lock_password=args.lock_pwd,
|
|
342
|
+
lock_private_key=args.lock_private_key,
|
|
343
|
+
)
|
|
252
344
|
uvicorn.run("webssh.app:app", host=args.host, port=args.port, reload=False)
|
|
253
345
|
|
|
254
346
|
|
|
@@ -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)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
|
|
9
|
+
from .models import ConnectRequest
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class RuntimeConfig:
|
|
14
|
+
lock_host: str | None = None
|
|
15
|
+
lock_username: str | None = None
|
|
16
|
+
lock_password: str | None = None
|
|
17
|
+
lock_private_key_path: Path | None = None
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def lock_password_enabled(self) -> bool:
|
|
21
|
+
return self.lock_password is not None
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def lock_private_key_enabled(self) -> bool:
|
|
25
|
+
return self.lock_private_key_path is not None
|
|
26
|
+
|
|
27
|
+
def public_payload(self) -> dict[str, object]:
|
|
28
|
+
return {
|
|
29
|
+
"locks": {
|
|
30
|
+
"host": {"enabled": self.lock_host is not None, "value": self.lock_host},
|
|
31
|
+
"username": {"enabled": self.lock_username is not None, "value": self.lock_username},
|
|
32
|
+
"password": {"enabled": self.lock_password_enabled},
|
|
33
|
+
"private_key": {"enabled": self.lock_private_key_enabled},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def apply_to_connect_request(self, incoming: ConnectRequest) -> ConnectRequest:
|
|
38
|
+
data = incoming.model_dump()
|
|
39
|
+
|
|
40
|
+
if self.lock_host is not None:
|
|
41
|
+
requested = str(data.get("host") or "").strip()
|
|
42
|
+
if requested and requested != self.lock_host:
|
|
43
|
+
raise HTTPException(status_code=403, detail="Host is locked by the server.")
|
|
44
|
+
data["host"] = self.lock_host
|
|
45
|
+
|
|
46
|
+
if self.lock_username is not None:
|
|
47
|
+
requested = str(data.get("username") or "").strip()
|
|
48
|
+
if requested and requested != self.lock_username:
|
|
49
|
+
raise HTTPException(status_code=403, detail="Username is locked by the server.")
|
|
50
|
+
data["username"] = self.lock_username
|
|
51
|
+
|
|
52
|
+
if self.lock_password is not None:
|
|
53
|
+
data["password"] = self.lock_password
|
|
54
|
+
|
|
55
|
+
if self.lock_private_key_path is not None:
|
|
56
|
+
data["private_key"] = _read_locked_private_key(self.lock_private_key_path)
|
|
57
|
+
|
|
58
|
+
return ConnectRequest.model_validate(data)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
runtime_config = RuntimeConfig()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def configure_runtime_locks(
|
|
65
|
+
*,
|
|
66
|
+
lock_host: str | None = None,
|
|
67
|
+
lock_username: str | None = None,
|
|
68
|
+
lock_password: str | None = None,
|
|
69
|
+
lock_private_key: str | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
global runtime_config
|
|
72
|
+
key_path = Path(lock_private_key).expanduser().resolve() if lock_private_key else None
|
|
73
|
+
if key_path is not None:
|
|
74
|
+
_read_locked_private_key(key_path)
|
|
75
|
+
runtime_config = RuntimeConfig(
|
|
76
|
+
lock_host=_blank_to_none(lock_host),
|
|
77
|
+
lock_username=_blank_to_none(lock_username),
|
|
78
|
+
lock_password=lock_password if lock_password is not None else None,
|
|
79
|
+
lock_private_key_path=key_path,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def add_runtime_lock_arguments(parser: argparse.ArgumentParser) -> None:
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--lock-host",
|
|
86
|
+
default=None,
|
|
87
|
+
metavar="HOST",
|
|
88
|
+
help="Only allow SSH connections to this host or domain.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--lock-username",
|
|
92
|
+
default=None,
|
|
93
|
+
metavar="USERNAME",
|
|
94
|
+
help="Only allow SSH connections with this username.",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--lock-pwd",
|
|
98
|
+
default=None,
|
|
99
|
+
metavar="PASSWORD",
|
|
100
|
+
help="Bind this SSH password server-side. It is never sent to the browser.",
|
|
101
|
+
)
|
|
102
|
+
parser.add_argument(
|
|
103
|
+
"--lock-private-key",
|
|
104
|
+
default=None,
|
|
105
|
+
metavar="KEY_FILE",
|
|
106
|
+
help="Bind this server-side SSH private key file. It is never sent to the browser.",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_locked_private_key(path: Path) -> str:
|
|
111
|
+
try:
|
|
112
|
+
return path.read_text(encoding="utf-8")
|
|
113
|
+
except OSError as exc:
|
|
114
|
+
raise ValueError(f"Could not read locked private key file {path}: {exc}") from exc
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _blank_to_none(value: str | None) -> str | None:
|
|
118
|
+
if value is None:
|
|
119
|
+
return None
|
|
120
|
+
value = value.strip()
|
|
121
|
+
return value or None
|