py-web-ssh 0.1.2__tar.gz → 0.1.4__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.2 → py_web_ssh-0.1.4}/PKG-INFO +3 -1
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/README.md +3 -1
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/pyproject.toml +1 -1
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/__init__.py +1 -1
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/app.py +13 -3
- py_web_ssh-0.1.4/webssh/client_session.py +64 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/static/app.js +64 -3
- py_web_ssh-0.1.4/webssh/static/index.html +156 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/static/styles.css +45 -1
- py_web_ssh-0.1.2/webssh/static/index.html +0 -131
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/LICENSE +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/auth.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/files.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/history.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/models.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/session.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/ssh_client.py +0 -0
- {py_web_ssh-0.1.2 → py_web_ssh-0.1.4}/webssh/static/logs.html +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.4
|
|
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
|
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
13
13
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
14
14
|
- 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
|
|
15
|
-
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
15
|
+
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
16
|
+
- 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
|
|
17
|
+
- 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
|
|
16
18
|
|
|
17
19
|
## 安装
|
|
18
20
|
|
|
@@ -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
|
|
@@ -32,9 +33,18 @@ sessions = SessionManager()
|
|
|
32
33
|
@app.middleware("http")
|
|
33
34
|
async def require_pin_cookie(request: Request, call_next):
|
|
34
35
|
path = request.url.path
|
|
35
|
-
if
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
38
48
|
|
|
39
49
|
|
|
40
50
|
@app.get("/", response_class=HTMLResponse)
|
|
@@ -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();
|
|
@@ -162,15 +164,31 @@ document.querySelector("#upload-form").addEventListener("submit", async (event)
|
|
|
162
164
|
setStatus(response.ok ? "上传完成" : "上传失败");
|
|
163
165
|
});
|
|
164
166
|
|
|
165
|
-
document.querySelector("#download-form").addEventListener("submit", (event) => {
|
|
167
|
+
document.querySelector("#download-form").addEventListener("submit", async (event) => {
|
|
166
168
|
event.preventDefault();
|
|
167
169
|
const remotePath = valueOf("#download-path");
|
|
168
170
|
if (!activeSessionId || !remotePath) {
|
|
169
171
|
appendLogLine("下载需要会话 UUID 和远端路径。");
|
|
170
172
|
return;
|
|
171
173
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
setStatus("下载中...");
|
|
175
|
+
const response = await fetch(
|
|
176
|
+
`/api/sessions/${activeSessionId}/files/download?remote_path=${encodeURIComponent(remotePath)}`,
|
|
177
|
+
);
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
appendLogLine(`下载失败: ${await response.text()}`);
|
|
180
|
+
setStatus("下载失败");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const blob = await response.blob();
|
|
184
|
+
const link = document.createElement("a");
|
|
185
|
+
link.href = URL.createObjectURL(blob);
|
|
186
|
+
link.download = filenameFromResponse(response, remotePath);
|
|
187
|
+
document.body.appendChild(link);
|
|
188
|
+
link.click();
|
|
189
|
+
link.remove();
|
|
190
|
+
URL.revokeObjectURL(link.href);
|
|
191
|
+
setStatus("下载完成");
|
|
174
192
|
});
|
|
175
193
|
|
|
176
194
|
document.querySelectorAll(".tab").forEach((button) => {
|
|
@@ -185,6 +203,42 @@ document.querySelectorAll(".tab").forEach((button) => {
|
|
|
185
203
|
});
|
|
186
204
|
});
|
|
187
205
|
|
|
206
|
+
function bindControlPanels() {
|
|
207
|
+
panelToggles.forEach((button) => {
|
|
208
|
+
button.addEventListener("click", () => {
|
|
209
|
+
const panelId = button.getAttribute("aria-controls");
|
|
210
|
+
const isOpen = button.getAttribute("aria-expanded") === "true";
|
|
211
|
+
closeControlPanels();
|
|
212
|
+
if (!isOpen) {
|
|
213
|
+
openControlPanel(panelId);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
document.querySelectorAll("[data-open-panel]").forEach((button) => {
|
|
219
|
+
button.addEventListener("click", () => {
|
|
220
|
+
openControlPanel(button.dataset.openPanel);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function openControlPanel(panelId) {
|
|
226
|
+
closeControlPanels();
|
|
227
|
+
const panel = document.querySelector(`#${panelId}`);
|
|
228
|
+
const toggle = document.querySelector(`[aria-controls="${panelId}"]`);
|
|
229
|
+
if (!panel || !toggle) return;
|
|
230
|
+
panel.hidden = false;
|
|
231
|
+
toggle.setAttribute("aria-expanded", "true");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function closeControlPanels() {
|
|
235
|
+
panelToggles.forEach((button) => {
|
|
236
|
+
button.setAttribute("aria-expanded", "false");
|
|
237
|
+
const panel = document.querySelector(`#${button.getAttribute("aria-controls")}`);
|
|
238
|
+
if (panel) panel.hidden = true;
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
188
242
|
function connectWebSocket(sessionId) {
|
|
189
243
|
if (ws) {
|
|
190
244
|
sendSnapshot();
|
|
@@ -329,6 +383,13 @@ function checked(selector) {
|
|
|
329
383
|
return document.querySelector(selector).checked;
|
|
330
384
|
}
|
|
331
385
|
|
|
386
|
+
function filenameFromResponse(response, fallbackPath) {
|
|
387
|
+
const disposition = response.headers.get("Content-Disposition") || "";
|
|
388
|
+
const match = disposition.match(/filename="([^"]+)"/);
|
|
389
|
+
if (match) return match[1];
|
|
390
|
+
return fallbackPath.split("/").filter(Boolean).pop() || "download.bin";
|
|
391
|
+
}
|
|
392
|
+
|
|
332
393
|
function bytesToBase64(bytes) {
|
|
333
394
|
let binary = "";
|
|
334
395
|
const size = 0x8000;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>py-web-ssh</title>
|
|
7
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
|
8
|
+
<link rel="stylesheet" href="/static/styles.css" />
|
|
9
|
+
</head>
|
|
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>
|
|
22
|
+
<main class="app-shell">
|
|
23
|
+
<aside class="sidebar">
|
|
24
|
+
<header class="brand">
|
|
25
|
+
<h1>py-web-ssh</h1>
|
|
26
|
+
<p>Web SSH client</p>
|
|
27
|
+
</header>
|
|
28
|
+
|
|
29
|
+
<section class="panel collapsible-panel">
|
|
30
|
+
<button class="panel-toggle" type="button" aria-expanded="false" aria-controls="connect-panel">
|
|
31
|
+
连接
|
|
32
|
+
</button>
|
|
33
|
+
<div 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>
|
|
39
|
+
<form id="connect-form" class="stack">
|
|
40
|
+
<label>
|
|
41
|
+
目标服务器
|
|
42
|
+
<input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
|
|
43
|
+
</label>
|
|
44
|
+
<div class="grid-2">
|
|
45
|
+
<label>
|
|
46
|
+
端口
|
|
47
|
+
<input id="port" name="port" type="number" min="1" max="65535" value="22" required />
|
|
48
|
+
</label>
|
|
49
|
+
<label>
|
|
50
|
+
用户名
|
|
51
|
+
<input id="username" name="username" required autocomplete="username" />
|
|
52
|
+
</label>
|
|
53
|
+
</div>
|
|
54
|
+
<label>
|
|
55
|
+
口令
|
|
56
|
+
<input id="password" name="password" type="password" autocomplete="current-password" />
|
|
57
|
+
</label>
|
|
58
|
+
<label>
|
|
59
|
+
私钥文件
|
|
60
|
+
<input id="private-key-file" name="private-key-file" type="file" />
|
|
61
|
+
</label>
|
|
62
|
+
<label>
|
|
63
|
+
私钥口令
|
|
64
|
+
<input id="private-key-passphrase" name="private-key-passphrase" type="password" />
|
|
65
|
+
</label>
|
|
66
|
+
<div class="checks">
|
|
67
|
+
<label><input id="legacy-algorithms" type="checkbox" checked /> legacy 算法</label>
|
|
68
|
+
<label><input id="allow-agent" type="checkbox" /> SSH agent</label>
|
|
69
|
+
<label><input id="look-for-keys" type="checkbox" /> 服务端本机密钥</label>
|
|
70
|
+
<label><input id="strict-host-key" type="checkbox" /> known_hosts 校验</label>
|
|
71
|
+
</div>
|
|
72
|
+
<button class="primary" type="submit">连接</button>
|
|
73
|
+
</form>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<section class="panel collapsible-panel">
|
|
78
|
+
<button class="panel-toggle" type="button" aria-expanded="false" aria-controls="session-panel">
|
|
79
|
+
会话
|
|
80
|
+
</button>
|
|
81
|
+
<div id="session-panel" class="stack panel-body" hidden>
|
|
82
|
+
<div class="section-tabs" aria-label="会话栏目">
|
|
83
|
+
<button class="section-tab" type="button" data-open-panel="connect-panel">连接</button>
|
|
84
|
+
<button class="section-tab active" type="button">会话</button>
|
|
85
|
+
<button class="section-tab" type="button" data-open-panel="files-panel">文件</button>
|
|
86
|
+
</div>
|
|
87
|
+
<label>
|
|
88
|
+
UUID
|
|
89
|
+
<input id="session-id" autocomplete="off" spellcheck="false" />
|
|
90
|
+
</label>
|
|
91
|
+
<div class="button-row">
|
|
92
|
+
<button id="reconnect" type="button">重连</button>
|
|
93
|
+
<button id="disconnect" type="button">断开 SSH</button>
|
|
94
|
+
</div>
|
|
95
|
+
<a id="logs-link" class="quiet-link" href="#" target="_blank" rel="noreferrer">打开完整日志</a>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section class="panel collapsible-panel">
|
|
100
|
+
<button class="panel-toggle" type="button" aria-expanded="false" aria-controls="files-panel">
|
|
101
|
+
文件
|
|
102
|
+
</button>
|
|
103
|
+
<div id="files-panel" class="panel-body" hidden>
|
|
104
|
+
<div class="section-tabs" aria-label="文件栏目">
|
|
105
|
+
<button class="section-tab" type="button" data-open-panel="connect-panel">连接</button>
|
|
106
|
+
<button class="section-tab" type="button" data-open-panel="session-panel">会话</button>
|
|
107
|
+
<button class="section-tab active" type="button">文件</button>
|
|
108
|
+
</div>
|
|
109
|
+
<form id="upload-form" class="stack">
|
|
110
|
+
<label>
|
|
111
|
+
上传到远端路径
|
|
112
|
+
<input id="upload-path" placeholder="/tmp/file.txt" />
|
|
113
|
+
</label>
|
|
114
|
+
<label>
|
|
115
|
+
本地文件
|
|
116
|
+
<input id="upload-file" type="file" />
|
|
117
|
+
</label>
|
|
118
|
+
<button type="submit">上传</button>
|
|
119
|
+
</form>
|
|
120
|
+
<form id="download-form" class="stack compact">
|
|
121
|
+
<label>
|
|
122
|
+
下载远端路径
|
|
123
|
+
<input id="download-path" placeholder="/tmp/file.txt" />
|
|
124
|
+
</label>
|
|
125
|
+
<button type="submit">下载</button>
|
|
126
|
+
</form>
|
|
127
|
+
</div>
|
|
128
|
+
</section>
|
|
129
|
+
</aside>
|
|
130
|
+
|
|
131
|
+
<section class="workspace">
|
|
132
|
+
<div class="toolbar">
|
|
133
|
+
<div>
|
|
134
|
+
<strong id="status">未连接</strong>
|
|
135
|
+
<span id="session-label"></span>
|
|
136
|
+
</div>
|
|
137
|
+
<div class="tabs" role="tablist">
|
|
138
|
+
<button class="tab active" data-tab="terminal" type="button">终端</button>
|
|
139
|
+
<button class="tab" data-tab="logs" type="button">日志</button>
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div id="terminal-pane" class="pane active">
|
|
143
|
+
<div id="terminal"></div>
|
|
144
|
+
</div>
|
|
145
|
+
<div id="logs-pane" class="pane">
|
|
146
|
+
<pre id="logs"></pre>
|
|
147
|
+
</div>
|
|
148
|
+
</section>
|
|
149
|
+
</main>
|
|
150
|
+
|
|
151
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
152
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
153
|
+
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-serialize@0.13.0/lib/addon-serialize.min.js"></script>
|
|
154
|
+
<script src="/static/app.js"></script>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|
|
@@ -136,7 +136,51 @@ label {
|
|
|
136
136
|
|
|
137
137
|
.panel {
|
|
138
138
|
display: grid;
|
|
139
|
-
gap:
|
|
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 {
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="zh-CN">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
-
<title>py-web-ssh</title>
|
|
7
|
-
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css" />
|
|
8
|
-
<link rel="stylesheet" href="/static/styles.css" />
|
|
9
|
-
</head>
|
|
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>
|
|
22
|
-
<main class="app-shell">
|
|
23
|
-
<aside class="sidebar">
|
|
24
|
-
<header class="brand">
|
|
25
|
-
<h1>py-web-ssh</h1>
|
|
26
|
-
<p>Web SSH client</p>
|
|
27
|
-
</header>
|
|
28
|
-
|
|
29
|
-
<section class="panel">
|
|
30
|
-
<h2>连接</h2>
|
|
31
|
-
<form id="connect-form" class="stack">
|
|
32
|
-
<label>
|
|
33
|
-
目标服务器
|
|
34
|
-
<input id="host" name="host" required autocomplete="off" placeholder="192.168.1.10" />
|
|
35
|
-
</label>
|
|
36
|
-
<div class="grid-2">
|
|
37
|
-
<label>
|
|
38
|
-
端口
|
|
39
|
-
<input id="port" name="port" type="number" min="1" max="65535" value="22" required />
|
|
40
|
-
</label>
|
|
41
|
-
<label>
|
|
42
|
-
用户名
|
|
43
|
-
<input id="username" name="username" required autocomplete="username" />
|
|
44
|
-
</label>
|
|
45
|
-
</div>
|
|
46
|
-
<label>
|
|
47
|
-
口令
|
|
48
|
-
<input id="password" name="password" type="password" autocomplete="current-password" />
|
|
49
|
-
</label>
|
|
50
|
-
<label>
|
|
51
|
-
私钥文件
|
|
52
|
-
<input id="private-key-file" name="private-key-file" type="file" />
|
|
53
|
-
</label>
|
|
54
|
-
<label>
|
|
55
|
-
私钥口令
|
|
56
|
-
<input id="private-key-passphrase" name="private-key-passphrase" type="password" />
|
|
57
|
-
</label>
|
|
58
|
-
<div class="checks">
|
|
59
|
-
<label><input id="legacy-algorithms" type="checkbox" checked /> legacy 算法</label>
|
|
60
|
-
<label><input id="allow-agent" type="checkbox" /> SSH agent</label>
|
|
61
|
-
<label><input id="look-for-keys" type="checkbox" /> 服务端本机密钥</label>
|
|
62
|
-
<label><input id="strict-host-key" type="checkbox" /> known_hosts 校验</label>
|
|
63
|
-
</div>
|
|
64
|
-
<button class="primary" type="submit">连接</button>
|
|
65
|
-
</form>
|
|
66
|
-
</section>
|
|
67
|
-
|
|
68
|
-
<section class="panel">
|
|
69
|
-
<h2>会话</h2>
|
|
70
|
-
<div class="stack">
|
|
71
|
-
<label>
|
|
72
|
-
UUID
|
|
73
|
-
<input id="session-id" autocomplete="off" spellcheck="false" />
|
|
74
|
-
</label>
|
|
75
|
-
<div class="button-row">
|
|
76
|
-
<button id="reconnect" type="button">重连</button>
|
|
77
|
-
<button id="disconnect" type="button">断开 SSH</button>
|
|
78
|
-
</div>
|
|
79
|
-
<a id="logs-link" class="quiet-link" href="#" target="_blank" rel="noreferrer">打开完整日志</a>
|
|
80
|
-
</div>
|
|
81
|
-
</section>
|
|
82
|
-
|
|
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>
|
|
103
|
-
</section>
|
|
104
|
-
</aside>
|
|
105
|
-
|
|
106
|
-
<section class="workspace">
|
|
107
|
-
<div class="toolbar">
|
|
108
|
-
<div>
|
|
109
|
-
<strong id="status">未连接</strong>
|
|
110
|
-
<span id="session-label"></span>
|
|
111
|
-
</div>
|
|
112
|
-
<div class="tabs" role="tablist">
|
|
113
|
-
<button class="tab active" data-tab="terminal" type="button">终端</button>
|
|
114
|
-
<button class="tab" data-tab="logs" type="button">日志</button>
|
|
115
|
-
</div>
|
|
116
|
-
</div>
|
|
117
|
-
<div id="terminal-pane" class="pane active">
|
|
118
|
-
<div id="terminal"></div>
|
|
119
|
-
</div>
|
|
120
|
-
<div id="logs-pane" class="pane">
|
|
121
|
-
<pre id="logs"></pre>
|
|
122
|
-
</div>
|
|
123
|
-
</section>
|
|
124
|
-
</main>
|
|
125
|
-
|
|
126
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
127
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
128
|
-
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-serialize@0.13.0/lib/addon-serialize.min.js"></script>
|
|
129
|
-
<script src="/static/app.js"></script>
|
|
130
|
-
</body>
|
|
131
|
-
</html>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|