py-web-ssh 0.1.0__tar.gz → 0.1.2__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.0
3
+ Version: 0.1.2
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,6 +34,7 @@ Description-Content-Type: text/markdown
34
34
  - 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
35
35
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
36
36
  - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
37
+ - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
37
38
 
38
39
  ## 安装
39
40
 
@@ -52,10 +53,16 @@ py-web-ssh
52
53
  或者:
53
54
 
54
55
  ```bash
55
- uvicorn webssh.app:app --host 127.0.0.1 --port 8000
56
+ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
56
57
  ```
57
58
 
58
- 打开 <http://127.0.0.1:8000>。
59
+ 打开 <http://127.0.0.1:8022>。
60
+
61
+ 启用 PIN:
62
+
63
+ ```bash
64
+ py-web-ssh --pin 123456
65
+ ```
59
66
 
60
67
  前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
61
68
 
@@ -1,52 +1,59 @@
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/来源限制、审计和会话回收策略。
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
+ - 可选 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 0.0.0.0 --port 8022
35
+ ```
36
+
37
+ 打开 <http://127.0.0.1:8022>。
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/来源限制、审计和会话回收策略。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-web-ssh"
3
- version = "0.1.0"
3
+ version = "0.1.2"
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.0"
5
+ __version__ = "0.1.2"
@@ -2,13 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import base64
5
+ import argparse
5
6
  from pathlib import Path
6
7
  from typing import Annotated
7
8
 
8
- from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
9
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect
9
10
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
10
11
  from fastapi.staticfiles import StaticFiles
11
12
 
13
+ from . import __version__
14
+ from . import auth
15
+ from .auth import add_pin_argument, configure_pin
12
16
  from .files import download_file, filename_for_download, upload_file
13
17
  from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
14
18
  from .session import SessionManager
@@ -16,17 +20,44 @@ from .session import SessionManager
16
20
 
17
21
  BASE_DIR = Path(__file__).resolve().parent
18
22
  STATIC_DIR = BASE_DIR / "static"
23
+ PUBLIC_PATHS = {"/", "/api/auth/status", "/api/auth/login", "/favicon.ico"}
24
+ DEFAULT_HOST = "0.0.0.0"
25
+ DEFAULT_PORT = 8022
19
26
 
20
- app = FastAPI(title="py-web-ssh", version="0.1.0")
27
+ app = FastAPI(title="py-web-ssh", version=__version__)
21
28
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
22
29
  sessions = SessionManager()
23
30
 
24
31
 
32
+ @app.middleware("http")
33
+ async def require_pin_cookie(request: Request, call_next):
34
+ path = request.url.path
35
+ if path.startswith("/static/") or path in PUBLIC_PATHS or auth.pin_auth.is_request_authorized(request):
36
+ return await call_next(request)
37
+ return JSONResponse({"detail": "PIN authentication required."}, status_code=401)
38
+
39
+
25
40
  @app.get("/", response_class=HTMLResponse)
26
41
  def index() -> FileResponse:
27
42
  return FileResponse(STATIC_DIR / "index.html")
28
43
 
29
44
 
45
+ @app.get("/api/auth/status")
46
+ def auth_status() -> JSONResponse:
47
+ return JSONResponse(auth.pin_auth.status_payload())
48
+
49
+
50
+ @app.post("/api/auth/login")
51
+ async def auth_login(request: Request) -> JSONResponse:
52
+ payload = await request.json()
53
+ pin = str(payload.get("pin", ""))
54
+ if not auth.pin_auth.verify_pin(pin):
55
+ raise HTTPException(status_code=401, detail="Invalid PIN.")
56
+ response = JSONResponse({"ok": True, "enabled": auth.pin_auth.enabled})
57
+ auth.pin_auth.set_cookie(response, pin)
58
+ return response
59
+
60
+
30
61
  @app.get("/sessions/{session_id}/logs", response_class=HTMLResponse)
31
62
  def logs_page(session_id: str) -> HTMLResponse:
32
63
  html = (STATIC_DIR / "logs.html").read_text(encoding="utf-8")
@@ -121,6 +152,10 @@ def download(session_id: str, remote_path: str) -> StreamingResponse:
121
152
 
122
153
  @app.websocket("/ws/sessions/{session_id}")
123
154
  async def websocket_session(websocket: WebSocket, session_id: str) -> None:
155
+ if not auth.pin_auth.is_websocket_authorized(websocket):
156
+ await websocket.close(code=4401, reason="PIN authentication required")
157
+ return
158
+
124
159
  session = sessions.get(session_id)
125
160
  if session is None:
126
161
  await websocket.close(code=4404, reason="Session not found")
@@ -190,10 +225,21 @@ def _content_length(upload: UploadFile) -> int | None:
190
225
  return None
191
226
 
192
227
 
228
+ def build_arg_parser() -> argparse.ArgumentParser:
229
+ parser = argparse.ArgumentParser(prog="py-web-ssh")
230
+ parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.")
231
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to listen on.")
232
+ add_pin_argument(parser)
233
+ return parser
234
+
235
+
193
236
  def main() -> None:
194
237
  import uvicorn
195
238
 
196
- uvicorn.run("webssh.app:app", host="127.0.0.1", port=8000, reload=False)
239
+ args = build_arg_parser().parse_args()
240
+
241
+ configure_pin(args.pin)
242
+ uvicorn.run("webssh.app:app", host=args.host, port=args.port, reload=False)
197
243
 
198
244
 
199
245
  if __name__ == "__main__":
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import hashlib
5
+ import hmac
6
+ import secrets
7
+ from dataclasses import dataclass
8
+
9
+ from fastapi import HTTPException, Request, Response, WebSocket
10
+
11
+
12
+ COOKIE_NAME = "py_web_ssh_pin"
13
+
14
+
15
+ @dataclass
16
+ class PinAuth:
17
+ pin_hash: str | None = None
18
+ secret: str = ""
19
+
20
+ @classmethod
21
+ def disabled(cls) -> "PinAuth":
22
+ return cls(pin_hash=None, secret=secrets.token_hex(32))
23
+
24
+ @classmethod
25
+ def from_pin(cls, pin: str | None) -> "PinAuth":
26
+ if not pin:
27
+ return cls.disabled()
28
+ return cls(pin_hash=_hash_pin(pin), secret=secrets.token_hex(32))
29
+
30
+ @property
31
+ def enabled(self) -> bool:
32
+ return self.pin_hash is not None
33
+
34
+ def status_payload(self) -> dict[str, bool]:
35
+ return {"enabled": self.enabled}
36
+
37
+ def verify_pin(self, pin: str) -> bool:
38
+ if not self.enabled:
39
+ return True
40
+ return hmac.compare_digest(_hash_pin(pin), self.pin_hash or "")
41
+
42
+ def set_cookie(self, response: Response, pin: str) -> None:
43
+ if not self.enabled:
44
+ return
45
+ salt = secrets.token_hex(16)
46
+ digest = _hash_with_salt(pin, salt)
47
+ signature = self._signature(salt, digest)
48
+ response.set_cookie(
49
+ COOKIE_NAME,
50
+ f"{salt}:{digest}:{signature}",
51
+ httponly=True,
52
+ samesite="lax",
53
+ secure=False,
54
+ path="/",
55
+ max_age=7 * 24 * 60 * 60,
56
+ )
57
+
58
+ def clear_cookie(self, response: Response) -> None:
59
+ response.delete_cookie(COOKIE_NAME, path="/")
60
+
61
+ def is_request_authorized(self, request: Request) -> bool:
62
+ if not self.enabled:
63
+ return True
64
+ return self._valid_cookie(request.cookies.get(COOKIE_NAME))
65
+
66
+ def is_websocket_authorized(self, websocket: WebSocket) -> bool:
67
+ if not self.enabled:
68
+ return True
69
+ return self._valid_cookie(websocket.cookies.get(COOKIE_NAME))
70
+
71
+ def require_request(self, request: Request) -> None:
72
+ if not self.is_request_authorized(request):
73
+ raise HTTPException(status_code=401, detail="PIN authentication required.")
74
+
75
+ def _valid_cookie(self, value: str | None) -> bool:
76
+ if not value:
77
+ return False
78
+ try:
79
+ salt, digest, signature = value.split(":", 2)
80
+ except ValueError:
81
+ return False
82
+ if not hmac.compare_digest(signature, self._signature(salt, digest)):
83
+ return False
84
+ for candidate_pin_hash in [self.pin_hash]:
85
+ if candidate_pin_hash and hmac.compare_digest(digest, _rehash_pin_hash(candidate_pin_hash, salt)):
86
+ return True
87
+ return False
88
+
89
+ def _signature(self, salt: str, digest: str) -> str:
90
+ return hmac.new(
91
+ self.secret.encode("utf-8"),
92
+ f"{salt}:{digest}".encode("utf-8"),
93
+ hashlib.sha256,
94
+ ).hexdigest()
95
+
96
+
97
+ pin_auth = PinAuth.disabled()
98
+
99
+
100
+ def configure_pin(pin: str | None) -> None:
101
+ global pin_auth
102
+ pin_auth = PinAuth.from_pin(pin)
103
+
104
+
105
+ def add_pin_argument(parser: argparse.ArgumentParser) -> None:
106
+ parser.add_argument(
107
+ "--pin",
108
+ default=None,
109
+ help="Require this PIN before the web client can access SSH APIs.",
110
+ )
111
+
112
+
113
+ def _hash_pin(pin: str) -> str:
114
+ return hashlib.sha256(pin.encode("utf-8")).hexdigest()
115
+
116
+
117
+ def _hash_with_salt(pin: str, salt: str) -> str:
118
+ return _rehash_pin_hash(_hash_pin(pin), salt)
119
+
120
+
121
+ def _rehash_pin_hash(pin_hash: str, salt: str) -> str:
122
+ return hashlib.sha256(f"{salt}:{pin_hash}".encode("utf-8")).hexdigest()
@@ -4,6 +4,10 @@ const statusElement = document.querySelector("#status");
4
4
  const sessionInput = document.querySelector("#session-id");
5
5
  const sessionLabel = document.querySelector("#session-label");
6
6
  const logsLink = document.querySelector("#logs-link");
7
+ const pinGate = document.querySelector("#pin-gate");
8
+ const pinForm = document.querySelector("#pin-form");
9
+ const pinInput = document.querySelector("#pin-input");
10
+ const pinError = document.querySelector("#pin-error");
7
11
 
8
12
  const term = new Terminal({
9
13
  cursorBlink: true,
@@ -51,6 +55,8 @@ if (activeSessionId) {
51
55
  updateSessionUi(activeSessionId);
52
56
  }
53
57
 
58
+ checkPinGate();
59
+
54
60
  window.addEventListener("resize", () => {
55
61
  fitAddon.fit();
56
62
  sendResize();
@@ -100,6 +106,24 @@ document.querySelector("#connect-form").addEventListener("submit", async (event)
100
106
  connectWebSocket(activeSessionId);
101
107
  });
102
108
 
109
+ pinForm.addEventListener("submit", async (event) => {
110
+ event.preventDefault();
111
+ pinError.textContent = "";
112
+ const response = await fetch("/api/auth/login", {
113
+ method: "POST",
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify({ pin: pinInput.value }),
116
+ });
117
+ if (!response.ok) {
118
+ pinError.textContent = "Invalid PIN.";
119
+ pinInput.select();
120
+ return;
121
+ }
122
+ pinInput.value = "";
123
+ pinGate.classList.add("hidden");
124
+ setStatus("Ready");
125
+ });
126
+
103
127
  document.querySelector("#reconnect").addEventListener("click", () => {
104
128
  const id = sessionInput.value.trim();
105
129
  if (id) {
@@ -183,13 +207,29 @@ function connectWebSocket(sessionId) {
183
207
  });
184
208
  socket.addEventListener("close", () => {
185
209
  if (ws !== socket) return;
186
- setStatus("WebSocket 已断开,可重连");
210
+ setStatus("WebSocket closed; reconnect is available");
187
211
  });
188
212
  socket.addEventListener("error", () => {
189
213
  if (ws === socket) setStatus("WebSocket 错误");
190
214
  });
191
215
  }
192
216
 
217
+ async function checkPinGate() {
218
+ const response = await fetch("/api/auth/status");
219
+ if (!response.ok) {
220
+ pinGate.classList.remove("hidden");
221
+ pinInput.focus();
222
+ return;
223
+ }
224
+ const status = await response.json();
225
+ if (status.enabled) {
226
+ pinGate.classList.remove("hidden");
227
+ pinInput.focus();
228
+ } else {
229
+ pinGate.classList.add("hidden");
230
+ }
231
+ }
232
+
193
233
  async function handleMessage(message) {
194
234
  if (message.type === "session") {
195
235
  appendLogLine(`绑定会话 ${message.session_id}`);
@@ -8,6 +8,17 @@
8
8
  <link rel="stylesheet" href="/static/styles.css" />
9
9
  </head>
10
10
  <body>
11
+ <div id="pin-gate" class="pin-gate" aria-live="polite">
12
+ <form id="pin-form" class="pin-box">
13
+ <h2>PIN required</h2>
14
+ <label>
15
+ PIN
16
+ <input id="pin-input" type="password" autocomplete="current-password" />
17
+ </label>
18
+ <button class="primary" type="submit">Unlock</button>
19
+ <p id="pin-error" class="pin-error"></p>
20
+ </form>
21
+ </div>
11
22
  <main class="app-shell">
12
23
  <aside class="sidebar">
13
24
  <header class="brand">
@@ -24,6 +24,42 @@ body {
24
24
  Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
25
25
  }
26
26
 
27
+ .pin-gate {
28
+ position: fixed;
29
+ inset: 0;
30
+ z-index: 10;
31
+ display: grid;
32
+ place-items: center;
33
+ background: rgba(10, 14, 18, 0.92);
34
+ padding: 20px;
35
+ }
36
+
37
+ .pin-gate.hidden {
38
+ display: none;
39
+ }
40
+
41
+ .pin-box {
42
+ display: grid;
43
+ width: min(360px, 100%);
44
+ gap: 14px;
45
+ border: 1px solid var(--line);
46
+ border-radius: 8px;
47
+ background: var(--surface);
48
+ padding: 18px;
49
+ }
50
+
51
+ .pin-box h2 {
52
+ margin: 0;
53
+ font-size: 18px;
54
+ }
55
+
56
+ .pin-error {
57
+ min-height: 18px;
58
+ margin: 0;
59
+ color: var(--danger);
60
+ font-size: 13px;
61
+ }
62
+
27
63
  button,
28
64
  input {
29
65
  font: inherit;
File without changes
File without changes
File without changes
File without changes
File without changes