py-web-ssh 0.1.0__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.0/LICENSE +21 -0
- py_web_ssh-0.1.0/PKG-INFO +75 -0
- py_web_ssh-0.1.0/README.md +52 -0
- py_web_ssh-0.1.0/pyproject.toml +37 -0
- py_web_ssh-0.1.0/webssh/__init__.py +5 -0
- py_web_ssh-0.1.0/webssh/app.py +200 -0
- py_web_ssh-0.1.0/webssh/files.py +189 -0
- py_web_ssh-0.1.0/webssh/history.py +66 -0
- py_web_ssh-0.1.0/webssh/models.py +76 -0
- py_web_ssh-0.1.0/webssh/session.py +345 -0
- py_web_ssh-0.1.0/webssh/ssh_client.py +288 -0
- py_web_ssh-0.1.0/webssh/static/app.js +316 -0
- py_web_ssh-0.1.0/webssh/static/index.html +120 -0
- py_web_ssh-0.1.0/webssh/static/logs.html +44 -0
- py_web_ssh-0.1.0/webssh/static/styles.css +267 -0
py_web_ssh-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GGN_2015
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-web-ssh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: fastapi (>=0.111.0)
|
|
16
|
+
Requires-Dist: paramiko (>=3.4.0)
|
|
17
|
+
Requires-Dist: pytest (>=8.2.0) ; extra == "dev"
|
|
18
|
+
Requires-Dist: python-multipart (>=0.0.9)
|
|
19
|
+
Requires-Dist: ruff (>=0.5.0) ; extra == "dev"
|
|
20
|
+
Requires-Dist: uvicorn[standard] (>=0.30.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# py-web-ssh
|
|
24
|
+
|
|
25
|
+
一个基于 Python/FastAPI/Paramiko 的 Web SSH 客户端。前端使用 xterm.js 渲染真实终端控制序列,后端通过 WebSocket 转发 SSH 交互数据,并为每个网页客户端分配随机 UUID,支持同 UUID 断线重连、终端快照恢复、日志查看和文件上传下载。
|
|
26
|
+
|
|
27
|
+
## 功能
|
|
28
|
+
|
|
29
|
+
- SSH 交互终端:Paramiko 后端 `invoke_shell`,xterm.js 前端实时交互。
|
|
30
|
+
- 登录方式:密码、浏览器上传私钥、私钥口令、SSH agent、服务端本机 `~/.ssh` 密钥、免口令/none auth。
|
|
31
|
+
- Legacy 兼容:启动时按当前 Paramiko 运行时能力尽量启用旧 KEX/Cipher/MAC/HostKey/Pubkey 算法,并在日志中列出不可用算法。
|
|
32
|
+
- UUID 会话:创建会话后返回 UUID,WebSocket 断开后可用同 UUID 重连。
|
|
33
|
+
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
34
|
+
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
35
|
+
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
36
|
+
- 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
|
|
37
|
+
|
|
38
|
+
## 安装
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
python -m venv .venv
|
|
42
|
+
.venv\Scripts\activate
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 启动
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
py-web-ssh
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
或者:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uvicorn webssh.app:app --host 127.0.0.1 --port 8000
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
打开 <http://127.0.0.1:8000>。
|
|
59
|
+
|
|
60
|
+
前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
|
|
61
|
+
|
|
62
|
+
## API 概览
|
|
63
|
+
|
|
64
|
+
- `POST /api/sessions` 创建 SSH 会话。
|
|
65
|
+
- `GET /api/sessions/{uuid}` 查看会话状态。
|
|
66
|
+
- `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
|
|
67
|
+
- `DELETE /api/sessions/{uuid}` 主动断开 SSH。
|
|
68
|
+
- `WS /ws/sessions/{uuid}` 终端输入、输出、resize、快照和断开控制。
|
|
69
|
+
- `POST /api/sessions/{uuid}/files/upload` 上传文件,multipart 字段:`remote_path`、`file`。
|
|
70
|
+
- `GET /api/sessions/{uuid}/files/download?remote_path=/path/file` 下载远端文件。
|
|
71
|
+
|
|
72
|
+
## 安全提示
|
|
73
|
+
|
|
74
|
+
这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
|
|
75
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# py-web-ssh
|
|
2
|
+
|
|
3
|
+
一个基于 Python/FastAPI/Paramiko 的 Web SSH 客户端。前端使用 xterm.js 渲染真实终端控制序列,后端通过 WebSocket 转发 SSH 交互数据,并为每个网页客户端分配随机 UUID,支持同 UUID 断线重连、终端快照恢复、日志查看和文件上传下载。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- SSH 交互终端:Paramiko 后端 `invoke_shell`,xterm.js 前端实时交互。
|
|
8
|
+
- 登录方式:密码、浏览器上传私钥、私钥口令、SSH agent、服务端本机 `~/.ssh` 密钥、免口令/none auth。
|
|
9
|
+
- Legacy 兼容:启动时按当前 Paramiko 运行时能力尽量启用旧 KEX/Cipher/MAC/HostKey/Pubkey 算法,并在日志中列出不可用算法。
|
|
10
|
+
- UUID 会话:创建会话后返回 UUID,WebSocket 断开后可用同 UUID 重连。
|
|
11
|
+
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
12
|
+
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
13
|
+
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
14
|
+
- 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m venv .venv
|
|
20
|
+
.venv\Scripts\activate
|
|
21
|
+
pip install -e .
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 启动
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
py-web-ssh
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
或者:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvicorn webssh.app:app --host 127.0.0.1 --port 8000
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
打开 <http://127.0.0.1:8000>。
|
|
37
|
+
|
|
38
|
+
前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
|
|
39
|
+
|
|
40
|
+
## API 概览
|
|
41
|
+
|
|
42
|
+
- `POST /api/sessions` 创建 SSH 会话。
|
|
43
|
+
- `GET /api/sessions/{uuid}` 查看会话状态。
|
|
44
|
+
- `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
|
|
45
|
+
- `DELETE /api/sessions/{uuid}` 主动断开 SSH。
|
|
46
|
+
- `WS /ws/sessions/{uuid}` 终端输入、输出、resize、快照和断开控制。
|
|
47
|
+
- `POST /api/sessions/{uuid}/files/upload` 上传文件,multipart 字段:`remote_path`、`file`。
|
|
48
|
+
- `GET /api/sessions/{uuid}/files/download?remote_path=/path/file` 下载远端文件。
|
|
49
|
+
|
|
50
|
+
## 安全提示
|
|
51
|
+
|
|
52
|
+
这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "py-web-ssh"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python web SSH client with xterm.js, reconnectable sessions, logs, and file transfer."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"fastapi>=0.111.0",
|
|
10
|
+
"uvicorn[standard]>=0.30.0",
|
|
11
|
+
"paramiko>=3.4.0",
|
|
12
|
+
"python-multipart>=0.0.9",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[tool.poetry]
|
|
16
|
+
packages = [{ include = "webssh" }]
|
|
17
|
+
include = ["webssh/static/*"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=8.2.0",
|
|
26
|
+
"ruff>=0.5.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
py-web-ssh = "webssh.app:main"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py310"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
|
|
9
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
|
|
12
|
+
from .files import download_file, filename_for_download, upload_file
|
|
13
|
+
from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
|
|
14
|
+
from .session import SessionManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
18
|
+
STATIC_DIR = BASE_DIR / "static"
|
|
19
|
+
|
|
20
|
+
app = FastAPI(title="py-web-ssh", version="0.1.0")
|
|
21
|
+
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|
22
|
+
sessions = SessionManager()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.get("/", response_class=HTMLResponse)
|
|
26
|
+
def index() -> FileResponse:
|
|
27
|
+
return FileResponse(STATIC_DIR / "index.html")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.get("/sessions/{session_id}/logs", response_class=HTMLResponse)
|
|
31
|
+
def logs_page(session_id: str) -> HTMLResponse:
|
|
32
|
+
html = (STATIC_DIR / "logs.html").read_text(encoding="utf-8")
|
|
33
|
+
return HTMLResponse(html.replace("__SESSION_ID__", session_id))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.post("/api/sessions", response_model=CreateSessionResponse)
|
|
37
|
+
def create_session(config: ConnectRequest) -> CreateSessionResponse:
|
|
38
|
+
session = sessions.create(config)
|
|
39
|
+
return CreateSessionResponse(
|
|
40
|
+
session_id=session.id,
|
|
41
|
+
logs_url=f"/sessions/{session.id}/logs",
|
|
42
|
+
websocket_url=f"/ws/sessions/{session.id}",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.get("/api/sessions")
|
|
47
|
+
def list_sessions() -> JSONResponse:
|
|
48
|
+
return JSONResponse([session.summary().model_dump(mode="json") for session in sessions.list()])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.get("/api/sessions/{session_id}")
|
|
52
|
+
def get_session(session_id: str) -> JSONResponse:
|
|
53
|
+
session = _require_session(session_id)
|
|
54
|
+
return JSONResponse(session.summary().model_dump(mode="json"))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.get("/api/sessions/{session_id}/logs")
|
|
58
|
+
def get_logs(session_id: str) -> JSONResponse:
|
|
59
|
+
session = _require_session(session_id)
|
|
60
|
+
return JSONResponse(
|
|
61
|
+
{
|
|
62
|
+
"session": session.summary().model_dump(mode="json"),
|
|
63
|
+
"logs": [entry.model_dump(mode="json") for entry in session.logs()],
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.delete("/api/sessions/{session_id}")
|
|
69
|
+
def close_session(session_id: str) -> JSONResponse:
|
|
70
|
+
if not sessions.close(session_id):
|
|
71
|
+
raise HTTPException(status_code=404, detail="Session not found.")
|
|
72
|
+
return JSONResponse({"ok": True})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@app.post("/api/sessions/{session_id}/files/upload", response_model=FileTransferResponse)
|
|
76
|
+
def upload(
|
|
77
|
+
session_id: str,
|
|
78
|
+
remote_path: Annotated[str, Form(min_length=1)],
|
|
79
|
+
file: Annotated[UploadFile, File()],
|
|
80
|
+
) -> FileTransferResponse:
|
|
81
|
+
session = _require_session(session_id)
|
|
82
|
+
try:
|
|
83
|
+
method, transferred = upload_file(
|
|
84
|
+
session.ssh_client,
|
|
85
|
+
file.file,
|
|
86
|
+
remote_path,
|
|
87
|
+
_content_length(file),
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
session.log("error", f"File upload failed: {exc}", None)
|
|
91
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
92
|
+
message = f"Uploaded {transferred} bytes to {remote_path} using {method}."
|
|
93
|
+
session.log("info", message, None)
|
|
94
|
+
return FileTransferResponse(
|
|
95
|
+
ok=True,
|
|
96
|
+
method=method,
|
|
97
|
+
bytes_transferred=transferred,
|
|
98
|
+
remote_path=remote_path,
|
|
99
|
+
message=message,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.get("/api/sessions/{session_id}/files/download")
|
|
104
|
+
def download(session_id: str, remote_path: str) -> StreamingResponse:
|
|
105
|
+
session = _require_session(session_id)
|
|
106
|
+
try:
|
|
107
|
+
method, stream = download_file(session.ssh_client, remote_path)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
session.log("error", f"File download failed: {exc}", None)
|
|
110
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
111
|
+
session.log("info", f"Downloading {remote_path} using {method}.", None)
|
|
112
|
+
return StreamingResponse(
|
|
113
|
+
stream,
|
|
114
|
+
media_type="application/octet-stream",
|
|
115
|
+
headers={
|
|
116
|
+
"Content-Disposition": f'attachment; filename="{filename_for_download(remote_path)}"',
|
|
117
|
+
"X-Transfer-Method": method,
|
|
118
|
+
},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.websocket("/ws/sessions/{session_id}")
|
|
123
|
+
async def websocket_session(websocket: WebSocket, session_id: str) -> None:
|
|
124
|
+
session = sessions.get(session_id)
|
|
125
|
+
if session is None:
|
|
126
|
+
await websocket.close(code=4404, reason="Session not found")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
await websocket.accept()
|
|
130
|
+
connection = session.attach(websocket)
|
|
131
|
+
sender: asyncio.Task | None = None
|
|
132
|
+
try:
|
|
133
|
+
await websocket.send_json({"type": "session", "session_id": session.id})
|
|
134
|
+
await websocket.send_json(session.replay_payload())
|
|
135
|
+
sender = asyncio.create_task(_websocket_sender(websocket, connection.queue))
|
|
136
|
+
while True:
|
|
137
|
+
message = await websocket.receive_json()
|
|
138
|
+
await _handle_ws_message(session, message)
|
|
139
|
+
except WebSocketDisconnect:
|
|
140
|
+
pass
|
|
141
|
+
finally:
|
|
142
|
+
session.detach(connection)
|
|
143
|
+
if sender is not None:
|
|
144
|
+
sender.cancel()
|
|
145
|
+
try:
|
|
146
|
+
await sender
|
|
147
|
+
except asyncio.CancelledError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def _websocket_sender(websocket: WebSocket, queue: asyncio.Queue[dict]) -> None:
|
|
152
|
+
while True:
|
|
153
|
+
message = await queue.get()
|
|
154
|
+
await websocket.send_json(message)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _handle_ws_message(session, message: dict) -> None:
|
|
158
|
+
message_type = message.get("type")
|
|
159
|
+
try:
|
|
160
|
+
if message_type == "input":
|
|
161
|
+
session.send_input(base64.b64decode(message.get("data", "")))
|
|
162
|
+
elif message_type == "resize":
|
|
163
|
+
session.resize(int(message.get("cols", 100)), int(message.get("rows", 30)))
|
|
164
|
+
elif message_type == "snapshot":
|
|
165
|
+
session.save_snapshot(int(message.get("seq", 0)), base64.b64decode(message.get("data", "")))
|
|
166
|
+
elif message_type == "disconnect":
|
|
167
|
+
session.close("Browser requested SSH disconnect.")
|
|
168
|
+
elif message_type == "ping":
|
|
169
|
+
return
|
|
170
|
+
else:
|
|
171
|
+
session.log("warning", f"Unknown WebSocket message type: {message_type}", None)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
session.log("warning", f"Could not process browser message {message_type}: {exc}", None)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _require_session(session_id: str):
|
|
177
|
+
session = sessions.get(session_id)
|
|
178
|
+
if session is None:
|
|
179
|
+
raise HTTPException(status_code=404, detail="Session not found.")
|
|
180
|
+
return session
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _content_length(upload: UploadFile) -> int | None:
|
|
184
|
+
size_header = upload.headers.get("content-length")
|
|
185
|
+
if size_header:
|
|
186
|
+
try:
|
|
187
|
+
return int(size_header)
|
|
188
|
+
except ValueError:
|
|
189
|
+
return None
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main() -> None:
|
|
194
|
+
import uvicorn
|
|
195
|
+
|
|
196
|
+
uvicorn.run("webssh.app:app", host="127.0.0.1", port=8000, reload=False)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
main()
|
|
@@ -0,0 +1,189 @@
|
|
|
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)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import deque
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class OutputChunk:
|
|
9
|
+
seq: int
|
|
10
|
+
data: bytes
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def end_seq(self) -> int:
|
|
14
|
+
return self.seq + len(self.data)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OutputHistory:
|
|
18
|
+
"""Bounded byte history for terminal replay.
|
|
19
|
+
|
|
20
|
+
The sequence number is an absolute byte offset in the SSH output stream.
|
|
21
|
+
Keeping absolute offsets lets a reconnecting browser resume from the last
|
|
22
|
+
xterm snapshot even when old chunks have been trimmed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, max_bytes: int) -> None:
|
|
26
|
+
self.max_bytes = max(1, max_bytes)
|
|
27
|
+
self._chunks: deque[OutputChunk] = deque()
|
|
28
|
+
self._bytes = 0
|
|
29
|
+
self._next_seq = 0
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def next_seq(self) -> int:
|
|
33
|
+
return self._next_seq
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def earliest_seq(self) -> int:
|
|
37
|
+
if not self._chunks:
|
|
38
|
+
return self._next_seq
|
|
39
|
+
return self._chunks[0].seq
|
|
40
|
+
|
|
41
|
+
def append(self, data: bytes) -> OutputChunk:
|
|
42
|
+
chunk = OutputChunk(seq=self._next_seq, data=data)
|
|
43
|
+
self._chunks.append(chunk)
|
|
44
|
+
self._next_seq = chunk.end_seq
|
|
45
|
+
self._bytes += len(data)
|
|
46
|
+
self._trim()
|
|
47
|
+
return chunk
|
|
48
|
+
|
|
49
|
+
def since(self, seq: int | None = None) -> list[OutputChunk]:
|
|
50
|
+
if seq is None:
|
|
51
|
+
return list(self._chunks)
|
|
52
|
+
chunks: list[OutputChunk] = []
|
|
53
|
+
for chunk in self._chunks:
|
|
54
|
+
if chunk.end_seq <= seq:
|
|
55
|
+
continue
|
|
56
|
+
if chunk.seq < seq:
|
|
57
|
+
offset = seq - chunk.seq
|
|
58
|
+
chunks.append(OutputChunk(seq=seq, data=chunk.data[offset:]))
|
|
59
|
+
else:
|
|
60
|
+
chunks.append(chunk)
|
|
61
|
+
return chunks
|
|
62
|
+
|
|
63
|
+
def _trim(self) -> None:
|
|
64
|
+
while self._bytes > self.max_bytes and self._chunks:
|
|
65
|
+
dropped = self._chunks.popleft()
|
|
66
|
+
self._bytes -= len(dropped.data)
|