py-web-ssh 0.1.1__tar.gz → 0.1.3__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.1
3
+ Version: 0.1.3
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
@@ -35,6 +35,8 @@ Description-Content-Type: text/markdown
35
35
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
36
36
  - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
37
37
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
38
+ - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
39
+ - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
38
40
 
39
41
  ## 安装
40
42
 
@@ -53,10 +55,10 @@ py-web-ssh
53
55
  或者:
54
56
 
55
57
  ```bash
56
- uvicorn webssh.app:app --host 127.0.0.1 --port 8000
58
+ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
57
59
  ```
58
60
 
59
- 打开 <http://127.0.0.1:8000>。
61
+ 打开 <http://127.0.0.1:8022>。
60
62
 
61
63
  启用 PIN:
62
64
 
@@ -1,59 +1,61 @@
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 上传/下载。
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
15
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
16
-
17
- ## 安装
18
-
19
- ```bash
20
- python -m venv .venv
21
- .venv\Scripts\activate
22
- pip install -e .
23
- ```
24
-
25
- ## 启动
26
-
27
- ```bash
28
- py-web-ssh
29
- ```
30
-
31
- 或者:
32
-
33
- ```bash
34
- uvicorn webssh.app:app --host 127.0.0.1 --port 8000
35
- ```
36
-
37
- 打开 <http://127.0.0.1:8000>。
38
-
39
- 启用 PIN:
40
-
41
- ```bash
42
- py-web-ssh --pin 123456
43
- ```
44
-
45
- 前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
46
-
47
- ## API 概览
48
-
49
- - `POST /api/sessions` 创建 SSH 会话。
50
- - `GET /api/sessions/{uuid}` 查看会话状态。
51
- - `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
52
- - `DELETE /api/sessions/{uuid}` 主动断开 SSH。
53
- - `WS /ws/sessions/{uuid}` 终端输入、输出、resize、快照和断开控制。
54
- - `POST /api/sessions/{uuid}/files/upload` 上传文件,multipart 字段:`remote_path`、`file`。
55
- - `GET /api/sessions/{uuid}/files/download?remote_path=/path/file` 下载远端文件。
56
-
57
- ## 安全提示
58
-
59
- 这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
16
+ - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
17
+ - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
18
+
19
+ ## 安装
20
+
21
+ ```bash
22
+ python -m venv .venv
23
+ .venv\Scripts\activate
24
+ pip install -e .
25
+ ```
26
+
27
+ ## 启动
28
+
29
+ ```bash
30
+ py-web-ssh
31
+ ```
32
+
33
+ 或者:
34
+
35
+ ```bash
36
+ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
37
+ ```
38
+
39
+ 打开 <http://127.0.0.1:8022>。
40
+
41
+ 启用 PIN:
42
+
43
+ ```bash
44
+ py-web-ssh --pin 123456
45
+ ```
46
+
47
+ 前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
48
+
49
+ ## API 概览
50
+
51
+ - `POST /api/sessions` 创建 SSH 会话。
52
+ - `GET /api/sessions/{uuid}` 查看会话状态。
53
+ - `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
54
+ - `DELETE /api/sessions/{uuid}` 主动断开 SSH。
55
+ - `WS /ws/sessions/{uuid}` 终端输入、输出、resize、快照和断开控制。
56
+ - `POST /api/sessions/{uuid}/files/upload` 上传文件,multipart 字段:`remote_path`、`file`。
57
+ - `GET /api/sessions/{uuid}/files/download?remote_path=/path/file` 下载远端文件。
58
+
59
+ ## 安全提示
60
+
61
+ 这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-web-ssh"
3
- version = "0.1.1"
3
+ version = "0.1.3"
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.1"
5
+ __version__ = "0.1.3"
@@ -13,6 +13,7 @@ from fastapi.staticfiles import StaticFiles
13
13
  from . import __version__
14
14
  from . import auth
15
15
  from .auth import add_pin_argument, configure_pin
16
+ from .client_session import ensure_client_session_cookie
16
17
  from .files import download_file, filename_for_download, upload_file
17
18
  from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
18
19
  from .session import SessionManager
@@ -21,6 +22,8 @@ from .session import SessionManager
21
22
  BASE_DIR = Path(__file__).resolve().parent
22
23
  STATIC_DIR = BASE_DIR / "static"
23
24
  PUBLIC_PATHS = {"/", "/api/auth/status", "/api/auth/login", "/favicon.ico"}
25
+ DEFAULT_HOST = "0.0.0.0"
26
+ DEFAULT_PORT = 8022
24
27
 
25
28
  app = FastAPI(title="py-web-ssh", version=__version__)
26
29
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -30,9 +33,18 @@ sessions = SessionManager()
30
33
  @app.middleware("http")
31
34
  async def require_pin_cookie(request: Request, call_next):
32
35
  path = request.url.path
33
- if path.startswith("/static/") or path in PUBLIC_PATHS or auth.pin_auth.is_request_authorized(request):
34
- return await call_next(request)
35
- return JSONResponse({"detail": "PIN authentication required."}, status_code=401)
36
+ if not (
37
+ path.startswith("/static/")
38
+ or path in PUBLIC_PATHS
39
+ or auth.pin_auth.is_request_authorized(request)
40
+ ):
41
+ response = JSONResponse({"detail": "PIN authentication required."}, status_code=401)
42
+ ensure_client_session_cookie(request, response)
43
+ return response
44
+
45
+ response = await call_next(request)
46
+ ensure_client_session_cookie(request, response)
47
+ return response
36
48
 
37
49
 
38
50
  @app.get("/", response_class=HTMLResponse)
@@ -223,14 +235,18 @@ def _content_length(upload: UploadFile) -> int | None:
223
235
  return None
224
236
 
225
237
 
238
+ def build_arg_parser() -> argparse.ArgumentParser:
239
+ parser = argparse.ArgumentParser(prog="py-web-ssh")
240
+ parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.")
241
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to listen on.")
242
+ add_pin_argument(parser)
243
+ return parser
244
+
245
+
226
246
  def main() -> None:
227
247
  import uvicorn
228
248
 
229
- parser = argparse.ArgumentParser(prog="py-web-ssh")
230
- parser.add_argument("--host", default="127.0.0.1", help="Host interface to bind.")
231
- parser.add_argument("--port", type=int, default=8000, help="Port to listen on.")
232
- add_pin_argument(parser)
233
- args = parser.parse_args()
249
+ args = build_arg_parser().parse_args()
234
250
 
235
251
  configure_pin(args.pin)
236
252
  uvicorn.run("webssh.app:app", host=args.host, port=args.port, reload=False)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import secrets
4
+ import threading
5
+ import uuid
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timezone
8
+
9
+ from fastapi import Request
10
+ from fastapi.responses import Response
11
+
12
+
13
+ COOKIE_NAME = "py_web_ssh_client_session"
14
+
15
+
16
+ @dataclass
17
+ class ClientSession:
18
+ session_id: str
19
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
20
+ updated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
21
+
22
+
23
+ class ClientSessionStore:
24
+ def __init__(self) -> None:
25
+ self._lock = threading.RLock()
26
+ self._sessions: dict[str, ClientSession] = {}
27
+
28
+ def get_or_create(self, session_id: str | None) -> tuple[ClientSession, bool]:
29
+ with self._lock:
30
+ if session_id and session_id in self._sessions:
31
+ session = self._sessions[session_id]
32
+ session.updated_at = datetime.now(timezone.utc)
33
+ return session, False
34
+
35
+ session = ClientSession(session_id=str(uuid.uuid4()))
36
+ self._sessions[session.session_id] = session
37
+ return session, True
38
+
39
+ def get(self, session_id: str) -> ClientSession | None:
40
+ with self._lock:
41
+ return self._sessions.get(session_id)
42
+
43
+ def count(self) -> int:
44
+ with self._lock:
45
+ return len(self._sessions)
46
+
47
+
48
+ client_sessions = ClientSessionStore()
49
+
50
+
51
+ def ensure_client_session_cookie(request: Request, response: Response) -> ClientSession:
52
+ session, created = client_sessions.get_or_create(request.cookies.get(COOKIE_NAME))
53
+ request.state.client_session_id = session.session_id
54
+ if created:
55
+ response.set_cookie(
56
+ COOKIE_NAME,
57
+ session.session_id,
58
+ httponly=True,
59
+ samesite="lax",
60
+ secure=False,
61
+ path="/",
62
+ max_age=30 * 24 * 60 * 60,
63
+ )
64
+ return session
@@ -8,6 +8,7 @@ const pinGate = document.querySelector("#pin-gate");
8
8
  const pinForm = document.querySelector("#pin-form");
9
9
  const pinInput = document.querySelector("#pin-input");
10
10
  const pinError = document.querySelector("#pin-error");
11
+ const panelToggles = document.querySelectorAll(".panel-toggle");
11
12
 
12
13
  const term = new Terminal({
13
14
  cursorBlink: true,
@@ -56,6 +57,7 @@ if (activeSessionId) {
56
57
  }
57
58
 
58
59
  checkPinGate();
60
+ bindControlPanels();
59
61
 
60
62
  window.addEventListener("resize", () => {
61
63
  fitAddon.fit();
@@ -185,6 +187,42 @@ document.querySelectorAll(".tab").forEach((button) => {
185
187
  });
186
188
  });
187
189
 
190
+ function bindControlPanels() {
191
+ panelToggles.forEach((button) => {
192
+ button.addEventListener("click", () => {
193
+ const panelId = button.getAttribute("aria-controls");
194
+ const isOpen = button.getAttribute("aria-expanded") === "true";
195
+ closeControlPanels();
196
+ if (!isOpen) {
197
+ openControlPanel(panelId);
198
+ }
199
+ });
200
+ });
201
+
202
+ document.querySelectorAll("[data-open-panel]").forEach((button) => {
203
+ button.addEventListener("click", () => {
204
+ openControlPanel(button.dataset.openPanel);
205
+ });
206
+ });
207
+ }
208
+
209
+ function openControlPanel(panelId) {
210
+ closeControlPanels();
211
+ const panel = document.querySelector(`#${panelId}`);
212
+ const toggle = document.querySelector(`[aria-controls="${panelId}"]`);
213
+ if (!panel || !toggle) return;
214
+ panel.hidden = false;
215
+ toggle.setAttribute("aria-expanded", "true");
216
+ }
217
+
218
+ function closeControlPanels() {
219
+ panelToggles.forEach((button) => {
220
+ button.setAttribute("aria-expanded", "false");
221
+ const panel = document.querySelector(`#${button.getAttribute("aria-controls")}`);
222
+ if (panel) panel.hidden = true;
223
+ });
224
+ }
225
+
188
226
  function connectWebSocket(sessionId) {
189
227
  if (ws) {
190
228
  sendSnapshot();
@@ -26,9 +26,16 @@
26
26
  <p>Web SSH client</p>
27
27
  </header>
28
28
 
29
- <section class="panel">
30
- <h2>连接</h2>
31
- <form id="connect-form" class="stack">
29
+ <section class="panel collapsible-panel">
30
+ <button class="panel-toggle" type="button" aria-expanded="false" aria-controls="connect-panel">
31
+ 连接
32
+ </button>
33
+ <form id="connect-panel" class="stack panel-body" hidden>
34
+ <div class="section-tabs" aria-label="连接栏目">
35
+ <button class="section-tab active" type="button">连接</button>
36
+ <button class="section-tab" type="button" data-open-panel="session-panel">会话</button>
37
+ <button class="section-tab" type="button" data-open-panel="files-panel">文件</button>
38
+ </div>
32
39
  <label>
33
40
  目标服务器
34
41
  <input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
@@ -65,9 +72,16 @@
65
72
  </form>
66
73
  </section>
67
74
 
68
- <section class="panel">
69
- <h2>会话</h2>
70
- <div class="stack">
75
+ <section class="panel collapsible-panel">
76
+ <button class="panel-toggle" type="button" aria-expanded="false" aria-controls="session-panel">
77
+ 会话
78
+ </button>
79
+ <div id="session-panel" class="stack panel-body" hidden>
80
+ <div class="section-tabs" aria-label="会话栏目">
81
+ <button class="section-tab" type="button" data-open-panel="connect-panel">连接</button>
82
+ <button class="section-tab active" type="button">会话</button>
83
+ <button class="section-tab" type="button" data-open-panel="files-panel">文件</button>
84
+ </div>
71
85
  <label>
72
86
  UUID
73
87
  <input id="session-id" autocomplete="off" spellcheck="false" />
@@ -80,26 +94,35 @@
80
94
  </div>
81
95
  </section>
82
96
 
83
- <section class="panel">
84
- <h2>文件</h2>
85
- <form id="upload-form" class="stack">
86
- <label>
87
- 上传到远端路径
88
- <input id="upload-path" placeholder="/tmp/file.txt" />
89
- </label>
90
- <label>
91
- 本地文件
92
- <input id="upload-file" type="file" />
93
- </label>
94
- <button type="submit">上传</button>
95
- </form>
96
- <form id="download-form" class="stack compact">
97
- <label>
98
- 下载远端路径
99
- <input id="download-path" placeholder="/tmp/file.txt" />
100
- </label>
101
- <button type="submit">下载</button>
102
- </form>
97
+ <section class="panel collapsible-panel">
98
+ <button class="panel-toggle" type="button" aria-expanded="false" aria-controls="files-panel">
99
+ 文件
100
+ </button>
101
+ <div id="files-panel" class="panel-body" hidden>
102
+ <div class="section-tabs" aria-label="文件栏目">
103
+ <button class="section-tab" type="button" data-open-panel="connect-panel">连接</button>
104
+ <button class="section-tab" type="button" data-open-panel="session-panel">会话</button>
105
+ <button class="section-tab active" type="button">文件</button>
106
+ </div>
107
+ <form id="upload-form" class="stack">
108
+ <label>
109
+ 上传到远端路径
110
+ <input id="upload-path" placeholder="/tmp/file.txt" />
111
+ </label>
112
+ <label>
113
+ 本地文件
114
+ <input id="upload-file" type="file" />
115
+ </label>
116
+ <button type="submit">上传</button>
117
+ </form>
118
+ <form id="download-form" class="stack compact">
119
+ <label>
120
+ 下载远端路径
121
+ <input id="download-path" placeholder="/tmp/file.txt" />
122
+ </label>
123
+ <button type="submit">下载</button>
124
+ </form>
125
+ </div>
103
126
  </section>
104
127
  </aside>
105
128
 
@@ -136,7 +136,51 @@ label {
136
136
 
137
137
  .panel {
138
138
  display: grid;
139
- gap: 12px;
139
+ gap: 8px;
140
+ }
141
+
142
+ .panel-toggle {
143
+ display: flex;
144
+ align-items: center;
145
+ justify-content: space-between;
146
+ width: 100%;
147
+ min-height: 38px;
148
+ font-weight: 700;
149
+ }
150
+
151
+ .panel-toggle::after {
152
+ content: "+";
153
+ color: var(--muted);
154
+ }
155
+
156
+ .panel-toggle[aria-expanded="true"]::after {
157
+ content: "-";
158
+ }
159
+
160
+ .panel-body {
161
+ display: grid;
162
+ gap: 10px;
163
+ }
164
+
165
+ .panel-body[hidden] {
166
+ display: none;
167
+ }
168
+
169
+ .section-tabs {
170
+ display: grid;
171
+ grid-template-columns: repeat(3, minmax(0, 1fr));
172
+ gap: 6px;
173
+ }
174
+
175
+ .section-tab {
176
+ min-height: 30px;
177
+ padding: 0 6px;
178
+ font-size: 12px;
179
+ }
180
+
181
+ .section-tab.active {
182
+ border-color: var(--accent);
183
+ color: var(--accent);
140
184
  }
141
185
 
142
186
  .panel h2 {
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes