py-web-ssh 0.1.6__tar.gz → 0.1.8__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.6 → py_web_ssh-0.1.8}/PKG-INFO +5 -4
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/README.md +4 -3
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/pyproject.toml +1 -1
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/__init__.py +1 -1
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/app.py +20 -2
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/client_session.py +0 -1
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/files.py +17 -4
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/models.py +0 -2
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/session.py +88 -3
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/ssh_client.py +58 -42
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/static/app.js +0 -6
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/static/index.html +0 -2
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/LICENSE +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/auth.py +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/history.py +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/runtime_config.py +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/static/logs.html +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/static/styles.css +0 -0
- {py_web_ssh-0.1.6 → py_web_ssh-0.1.8}/webssh/transfers.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.8
|
|
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
|
|
@@ -27,13 +27,14 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
## 功能
|
|
28
28
|
|
|
29
29
|
- SSH 交互终端:Paramiko 后端 `invoke_shell`,xterm.js 前端实时交互。
|
|
30
|
-
-
|
|
30
|
+
- 登录方式:密码、浏览器上传私钥、私钥口令、服务端本机默认 `~/.ssh` 密钥、免口令/none auth。
|
|
31
|
+
- Host key 确认:不使用 `known_hosts` 校验;每次交互 SSH 连接都会在 xterm.js 终端里显示服务器 host key 指纹,并要求用户输入 `Y` 或 `N` 后才继续认证。
|
|
31
32
|
- Legacy 兼容:启动时按当前 Paramiko 运行时能力尽量启用旧 KEX/Cipher/MAC/HostKey/Pubkey 算法,并在日志中列出不可用算法。
|
|
32
33
|
- UUID 会话:创建会话后返回 UUID,WebSocket 断开后可用同 UUID 重连。
|
|
33
34
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
34
35
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
35
36
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
36
|
-
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv`
|
|
37
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
|
|
37
38
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
38
39
|
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
39
40
|
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
@@ -93,5 +94,5 @@ py-web-ssh --lock-private-key C:\secrets\id_ed25519
|
|
|
93
94
|
|
|
94
95
|
## 安全提示
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;SSH agent 已禁用,`known_hosts` 校验已移除,用户必须在 xterm.js 终端里确认服务器 host key 指纹后才会继续认证。如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
|
|
97
98
|
|
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
7
|
- SSH 交互终端:Paramiko 后端 `invoke_shell`,xterm.js 前端实时交互。
|
|
8
|
-
-
|
|
8
|
+
- 登录方式:密码、浏览器上传私钥、私钥口令、服务端本机默认 `~/.ssh` 密钥、免口令/none auth。
|
|
9
|
+
- Host key 确认:不使用 `known_hosts` 校验;每次交互 SSH 连接都会在 xterm.js 终端里显示服务器 host key 指纹,并要求用户输入 `Y` 或 `N` 后才继续认证。
|
|
9
10
|
- Legacy 兼容:启动时按当前 Paramiko 运行时能力尽量启用旧 KEX/Cipher/MAC/HostKey/Pubkey 算法,并在日志中列出不可用算法。
|
|
10
11
|
- UUID 会话:创建会话后返回 UUID,WebSocket 断开后可用同 UUID 重连。
|
|
11
12
|
- 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
|
|
12
13
|
- 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
|
|
13
14
|
- 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
|
|
14
|
-
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv`
|
|
15
|
+
- 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。文件传输连接会校验服务器 host key 必须等于用户在交互终端里确认过的 host key。
|
|
15
16
|
- 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
|
|
16
17
|
- 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
|
|
17
18
|
- 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
|
|
@@ -71,4 +72,4 @@ py-web-ssh --lock-private-key C:\secrets\id_ed25519
|
|
|
71
72
|
|
|
72
73
|
## 安全提示
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
这个项目默认面向可信内网或本机使用。私钥和口令只保存在进程内存中,不写入日志;SSH agent 已禁用,`known_hosts` 校验已移除,用户必须在 xterm.js 终端里确认服务器 host key 指纹后才会继续认证。如果要暴露到公网,请务必加 HTTPS、登录认证、CSRF/来源限制、审计和会话回收策略。
|
|
@@ -142,6 +142,12 @@ def upload(
|
|
|
142
142
|
total_bytes: Annotated[int | None, Form()] = None,
|
|
143
143
|
) -> FileTransferResponse:
|
|
144
144
|
session = _require_session(session_id)
|
|
145
|
+
expected_host_key = session.confirmed_host_key
|
|
146
|
+
if expected_host_key is None:
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=409,
|
|
149
|
+
detail="SSH server host key has not been confirmed in the terminal yet.",
|
|
150
|
+
)
|
|
145
151
|
tracker = transfers.get(transfer_id) if transfer_id else None
|
|
146
152
|
if transfer_id and tracker is None:
|
|
147
153
|
raise HTTPException(status_code=404, detail="Transfer not found.")
|
|
@@ -153,6 +159,7 @@ def upload(
|
|
|
153
159
|
file.file,
|
|
154
160
|
remote_path,
|
|
155
161
|
total_bytes or _content_length(file),
|
|
162
|
+
expected_host_key,
|
|
156
163
|
cancel_event=tracker.cancel_event,
|
|
157
164
|
progress=tracker.update_progress,
|
|
158
165
|
)
|
|
@@ -180,7 +187,12 @@ def upload(
|
|
|
180
187
|
|
|
181
188
|
@app.post("/api/sessions/{session_id}/files/uploads")
|
|
182
189
|
async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
|
|
183
|
-
_require_session(session_id)
|
|
190
|
+
session = _require_session(session_id)
|
|
191
|
+
if session.confirmed_host_key is None:
|
|
192
|
+
raise HTTPException(
|
|
193
|
+
status_code=409,
|
|
194
|
+
detail="SSH server host key has not been confirmed in the terminal yet.",
|
|
195
|
+
)
|
|
184
196
|
payload = await request.json()
|
|
185
197
|
remote_path = str(payload.get("remote_path", "")).strip()
|
|
186
198
|
if not remote_path:
|
|
@@ -229,8 +241,14 @@ def cancel_transfer(transfer_id: str) -> JSONResponse:
|
|
|
229
241
|
@app.get("/api/sessions/{session_id}/files/download")
|
|
230
242
|
def download(session_id: str, remote_path: str) -> StreamingResponse:
|
|
231
243
|
session = _require_session(session_id)
|
|
244
|
+
expected_host_key = session.confirmed_host_key
|
|
245
|
+
if expected_host_key is None:
|
|
246
|
+
raise HTTPException(
|
|
247
|
+
status_code=409,
|
|
248
|
+
detail="SSH server host key has not been confirmed in the terminal yet.",
|
|
249
|
+
)
|
|
232
250
|
try:
|
|
233
|
-
method, stream = download_file_via_ssh(session.config, remote_path)
|
|
251
|
+
method, stream = download_file_via_ssh(session.config, remote_path, expected_host_key)
|
|
234
252
|
except Exception as exc:
|
|
235
253
|
session.log("error", f"File download failed: {exc}", None)
|
|
236
254
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
@@ -11,7 +11,7 @@ from collections.abc import Callable, Iterator
|
|
|
11
11
|
from typing import BinaryIO
|
|
12
12
|
|
|
13
13
|
from .models import ConnectRequest
|
|
14
|
-
from .ssh_client import connect_ssh
|
|
14
|
+
from .ssh_client import HostKeyInfo, connect_ssh
|
|
15
15
|
|
|
16
16
|
|
|
17
17
|
class FileTransferError(RuntimeError):
|
|
@@ -30,6 +30,7 @@ def upload_file_via_ssh(
|
|
|
30
30
|
source: BinaryIO,
|
|
31
31
|
remote_path: str,
|
|
32
32
|
size: int | None,
|
|
33
|
+
expected_host_key: HostKeyInfo,
|
|
33
34
|
cancel_event: threading.Event | None = None,
|
|
34
35
|
progress: ProgressCallback | None = None,
|
|
35
36
|
) -> tuple[str, int]:
|
|
@@ -40,7 +41,11 @@ def upload_file_via_ssh(
|
|
|
40
41
|
atomically move the temp file into place after the complete upload succeeds.
|
|
41
42
|
"""
|
|
42
43
|
|
|
43
|
-
connected = connect_ssh(
|
|
44
|
+
connected = connect_ssh(
|
|
45
|
+
config,
|
|
46
|
+
lambda _level, _message, _details=None: None,
|
|
47
|
+
expected_host_key=expected_host_key,
|
|
48
|
+
)
|
|
44
49
|
client = connected.client
|
|
45
50
|
remote_dir = posixpath.dirname(remote_path) or "."
|
|
46
51
|
temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
|
|
@@ -90,8 +95,16 @@ def upload_file_via_ssh(
|
|
|
90
95
|
return "shell", transferred
|
|
91
96
|
|
|
92
97
|
|
|
93
|
-
def download_file_via_ssh(
|
|
94
|
-
|
|
98
|
+
def download_file_via_ssh(
|
|
99
|
+
config: ConnectRequest,
|
|
100
|
+
remote_path: str,
|
|
101
|
+
expected_host_key: HostKeyInfo,
|
|
102
|
+
) -> tuple[str, Iterator[bytes]]:
|
|
103
|
+
connected = connect_ssh(
|
|
104
|
+
config,
|
|
105
|
+
lambda _level, _message, _details=None: None,
|
|
106
|
+
expected_host_key=expected_host_key,
|
|
107
|
+
)
|
|
95
108
|
client = connected.client
|
|
96
109
|
command = f"base64 < {shlex.quote(remote_path)}"
|
|
97
110
|
stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
|
|
@@ -18,10 +18,8 @@ class ConnectRequest(BaseModel):
|
|
|
18
18
|
password: str | None = None
|
|
19
19
|
private_key: str | None = None
|
|
20
20
|
private_key_passphrase: str | None = None
|
|
21
|
-
allow_agent: bool = False
|
|
22
21
|
look_for_keys: bool = False
|
|
23
22
|
legacy_algorithms: bool = True
|
|
24
|
-
strict_host_key: bool = False
|
|
25
23
|
term: str = Field(default="xterm-256color", min_length=1, max_length=64)
|
|
26
24
|
size: TerminalSize = Field(default_factory=TerminalSize)
|
|
27
25
|
timeout_seconds: float = Field(default=20.0, ge=3.0, le=120.0)
|
|
@@ -16,10 +16,10 @@ from starlette.websockets import WebSocket
|
|
|
16
16
|
|
|
17
17
|
from .history import OutputChunk, OutputHistory
|
|
18
18
|
from .models import ConnectRequest, LogEntry, SessionSummary
|
|
19
|
-
from .ssh_client import ConnectedClient, connect_ssh
|
|
19
|
+
from .ssh_client import ConnectedClient, HostKeyInfo, connect_ssh
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
SessionState = Literal["connecting", "connected", "closing", "closed", "error"]
|
|
22
|
+
SessionState = Literal["connecting", "waiting_host_key", "connected", "closing", "closed", "error"]
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
@dataclass(eq=False)
|
|
@@ -41,6 +41,10 @@ class TerminalSession:
|
|
|
41
41
|
self._clients: set[BrowserConnection] = set()
|
|
42
42
|
self._lock = threading.RLock()
|
|
43
43
|
self._channel_lock = threading.RLock()
|
|
44
|
+
self._host_key_lock = threading.Condition(threading.RLock())
|
|
45
|
+
self._awaiting_host_key_confirmation = False
|
|
46
|
+
self._host_key_decision: bool | None = None
|
|
47
|
+
self._confirmed_host_key: HostKeyInfo | None = None
|
|
44
48
|
self._stop = threading.Event()
|
|
45
49
|
self._thread = threading.Thread(target=self._run, name=f"ssh-session-{self.id}", daemon=True)
|
|
46
50
|
self._connected: ConnectedClient | None = None
|
|
@@ -134,6 +138,8 @@ class TerminalSession:
|
|
|
134
138
|
}
|
|
135
139
|
|
|
136
140
|
def send_input(self, data: bytes) -> None:
|
|
141
|
+
if self._handle_host_key_confirmation_input(data):
|
|
142
|
+
return
|
|
137
143
|
with self._channel_lock:
|
|
138
144
|
if self._channel is None or self.state != "connected":
|
|
139
145
|
raise RuntimeError("SSH channel is not connected.")
|
|
@@ -174,10 +180,19 @@ class TerminalSession:
|
|
|
174
180
|
raise RuntimeError("SSH connection is not ready.")
|
|
175
181
|
return self._connected.client
|
|
176
182
|
|
|
183
|
+
@property
|
|
184
|
+
def confirmed_host_key(self) -> HostKeyInfo | None:
|
|
185
|
+
with self._host_key_lock:
|
|
186
|
+
return self._confirmed_host_key
|
|
187
|
+
|
|
177
188
|
def _run(self) -> None:
|
|
178
189
|
try:
|
|
179
190
|
self.log("info", "Creating SSH connection.", None)
|
|
180
|
-
self._connected = connect_ssh(
|
|
191
|
+
self._connected = connect_ssh(
|
|
192
|
+
self.config,
|
|
193
|
+
self.log,
|
|
194
|
+
confirm_host_key=self._confirm_host_key,
|
|
195
|
+
)
|
|
181
196
|
channel = self._connected.transport.open_session()
|
|
182
197
|
channel.get_pty(
|
|
183
198
|
term=self.config.term,
|
|
@@ -238,6 +253,76 @@ class TerminalSession:
|
|
|
238
253
|
self.updated_at = datetime.now(timezone.utc)
|
|
239
254
|
self._broadcast(self._chunk_payload(chunk) | {"type": "output"})
|
|
240
255
|
|
|
256
|
+
def _confirm_host_key(self, host_key: HostKeyInfo) -> bool:
|
|
257
|
+
with self._host_key_lock:
|
|
258
|
+
self._awaiting_host_key_confirmation = True
|
|
259
|
+
self._host_key_decision = None
|
|
260
|
+
self._set_state("waiting_host_key")
|
|
261
|
+
self.log("warning", "Waiting for browser user to confirm SSH server host key.", host_key.details())
|
|
262
|
+
self._append_output(self._host_key_prompt(host_key).encode("utf-8"))
|
|
263
|
+
|
|
264
|
+
while not self._stop.is_set():
|
|
265
|
+
with self._host_key_lock:
|
|
266
|
+
if self._host_key_decision is not None:
|
|
267
|
+
accepted = self._host_key_decision
|
|
268
|
+
self._awaiting_host_key_confirmation = False
|
|
269
|
+
if accepted:
|
|
270
|
+
self._confirmed_host_key = host_key
|
|
271
|
+
break
|
|
272
|
+
self._host_key_lock.wait(timeout=0.2)
|
|
273
|
+
else:
|
|
274
|
+
accepted = False
|
|
275
|
+
with self._host_key_lock:
|
|
276
|
+
self._awaiting_host_key_confirmation = False
|
|
277
|
+
|
|
278
|
+
if accepted:
|
|
279
|
+
self._append_output(b"Y\r\nContinuing SSH authentication...\r\n")
|
|
280
|
+
self._set_state("connecting")
|
|
281
|
+
return True
|
|
282
|
+
|
|
283
|
+
self._append_output(b"N\r\nSSH connection cancelled before authentication.\r\n")
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
def _handle_host_key_confirmation_input(self, data: bytes) -> bool:
|
|
287
|
+
with self._host_key_lock:
|
|
288
|
+
if not self._awaiting_host_key_confirmation:
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
text = data.decode("utf-8", errors="ignore")
|
|
292
|
+
decision: bool | None = None
|
|
293
|
+
invalid = False
|
|
294
|
+
for char in text:
|
|
295
|
+
if char in "\r\n\t ":
|
|
296
|
+
continue
|
|
297
|
+
if char in ("y", "Y"):
|
|
298
|
+
decision = True
|
|
299
|
+
break
|
|
300
|
+
if char in ("n", "N"):
|
|
301
|
+
decision = False
|
|
302
|
+
break
|
|
303
|
+
invalid = True
|
|
304
|
+
break
|
|
305
|
+
|
|
306
|
+
if decision is None:
|
|
307
|
+
if invalid:
|
|
308
|
+
self._append_output(b"\r\nPlease type Y or N: ")
|
|
309
|
+
return True
|
|
310
|
+
|
|
311
|
+
with self._host_key_lock:
|
|
312
|
+
self._host_key_decision = decision
|
|
313
|
+
self._host_key_lock.notify_all()
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
def _host_key_prompt(self, host_key: HostKeyInfo) -> str:
|
|
317
|
+
return (
|
|
318
|
+
"\r\n[py-web-ssh] SSH server host key fingerprint\r\n"
|
|
319
|
+
f"Host: {self.config.host}:{self.config.port}\r\n"
|
|
320
|
+
f"Key type: {host_key.key_type}\r\n"
|
|
321
|
+
f"SHA256 fingerprint: {host_key.sha256_fingerprint}\r\n"
|
|
322
|
+
f"MD5 fingerprint: {host_key.md5_fingerprint}\r\n"
|
|
323
|
+
"Continue connecting? Type Y or N: "
|
|
324
|
+
)
|
|
325
|
+
|
|
241
326
|
def _chunk_payload(self, chunk: OutputChunk) -> dict[str, Any]:
|
|
242
327
|
return {
|
|
243
328
|
"seq": chunk.seq,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
3
5
|
import io
|
|
4
6
|
import socket
|
|
5
7
|
import traceback
|
|
@@ -21,7 +23,48 @@ class ConnectedClient:
|
|
|
21
23
|
transport: paramiko.Transport
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class HostKeyInfo:
|
|
28
|
+
key_type: str
|
|
29
|
+
sha256_fingerprint: str
|
|
30
|
+
md5_fingerprint: str
|
|
31
|
+
key_base64: str
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def from_key(cls, key: paramiko.PKey) -> "HostKeyInfo":
|
|
35
|
+
key_bytes = key.asbytes()
|
|
36
|
+
sha256_digest = hashlib.sha256(key_bytes).digest()
|
|
37
|
+
sha256_value = base64.b64encode(sha256_digest).decode("ascii").rstrip("=")
|
|
38
|
+
md5_value = ":".join(f"{byte:02x}" for byte in key.get_fingerprint())
|
|
39
|
+
return cls(
|
|
40
|
+
key_type=key.get_name(),
|
|
41
|
+
sha256_fingerprint=f"SHA256:{sha256_value}",
|
|
42
|
+
md5_fingerprint=f"MD5:{md5_value}",
|
|
43
|
+
key_base64=base64.b64encode(key_bytes).decode("ascii"),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def matches(self, key: paramiko.PKey) -> bool:
|
|
47
|
+
return self.key_base64 == base64.b64encode(key.asbytes()).decode("ascii")
|
|
48
|
+
|
|
49
|
+
def details(self) -> str:
|
|
50
|
+
return "\n".join(
|
|
51
|
+
[
|
|
52
|
+
f"type={self.key_type}",
|
|
53
|
+
f"sha256={self.sha256_fingerprint}",
|
|
54
|
+
f"md5={self.md5_fingerprint}",
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
HostKeyConfirmation = Callable[[HostKeyInfo], bool]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def connect_ssh(
|
|
63
|
+
config: ConnectRequest,
|
|
64
|
+
log: LogCallback,
|
|
65
|
+
confirm_host_key: HostKeyConfirmation | None = None,
|
|
66
|
+
expected_host_key: HostKeyInfo | None = None,
|
|
67
|
+
) -> ConnectedClient:
|
|
25
68
|
"""Open an SSH connection, including legacy algorithms Paramiko still supports."""
|
|
26
69
|
|
|
27
70
|
sock = socket.create_connection((config.host, config.port), timeout=config.timeout_seconds)
|
|
@@ -35,10 +78,21 @@ def connect_ssh(config: ConnectRequest, log: LogCallback) -> ConnectedClient:
|
|
|
35
78
|
if config.keepalive_seconds:
|
|
36
79
|
transport.set_keepalive(config.keepalive_seconds)
|
|
37
80
|
|
|
38
|
-
|
|
39
|
-
|
|
81
|
+
host_key = transport.get_remote_server_key()
|
|
82
|
+
host_key_info = HostKeyInfo.from_key(host_key)
|
|
83
|
+
log("info", "SSH server host key received.", host_key_info.details())
|
|
84
|
+
if expected_host_key is not None:
|
|
85
|
+
if not expected_host_key.matches(host_key):
|
|
86
|
+
raise paramiko.SSHException(
|
|
87
|
+
"SSH server host key changed since the browser user confirmed it."
|
|
88
|
+
)
|
|
89
|
+
log("info", "SSH server host key matches the browser-confirmed key.", None)
|
|
90
|
+
elif confirm_host_key is not None:
|
|
91
|
+
if not confirm_host_key(host_key_info):
|
|
92
|
+
raise paramiko.SSHException("SSH server host key was rejected by the browser user.")
|
|
93
|
+
log("info", "SSH server host key accepted by the browser user.", None)
|
|
40
94
|
else:
|
|
41
|
-
|
|
95
|
+
raise paramiko.SSHException("SSH server host key confirmation is required.")
|
|
42
96
|
|
|
43
97
|
_authenticate(transport, config, log)
|
|
44
98
|
|
|
@@ -158,29 +212,6 @@ def _supported_algorithms(options: paramiko.transport.SecurityOptions, attr: str
|
|
|
158
212
|
return set(getattr(transport, info_attr).keys())
|
|
159
213
|
|
|
160
214
|
|
|
161
|
-
def _check_known_host(config: ConnectRequest, transport: paramiko.Transport, log: LogCallback) -> None:
|
|
162
|
-
host_key = transport.get_remote_server_key()
|
|
163
|
-
known_hosts = paramiko.HostKeys()
|
|
164
|
-
paths = [
|
|
165
|
-
Path.home() / ".ssh" / "known_hosts",
|
|
166
|
-
Path.home() / ".ssh" / "known_hosts2",
|
|
167
|
-
]
|
|
168
|
-
for path in paths:
|
|
169
|
-
if path.exists():
|
|
170
|
-
known_hosts.load(str(path))
|
|
171
|
-
|
|
172
|
-
candidates = [config.host, f"[{config.host}]:{config.port}"]
|
|
173
|
-
for candidate in candidates:
|
|
174
|
-
if known_hosts.check(candidate, host_key):
|
|
175
|
-
log("info", f"Host key verified for {candidate}.", None)
|
|
176
|
-
return
|
|
177
|
-
|
|
178
|
-
raise paramiko.SSHException(
|
|
179
|
-
"Strict host key checking failed. The remote host key is not present in "
|
|
180
|
-
"~/.ssh/known_hosts for this server and port."
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
|
|
184
215
|
def _authenticate(transport: paramiko.Transport, config: ConnectRequest, log: LogCallback) -> None:
|
|
185
216
|
username = config.username
|
|
186
217
|
errors: list[str] = []
|
|
@@ -196,21 +227,6 @@ def _authenticate(transport: paramiko.Transport, config: ConnectRequest, log: Lo
|
|
|
196
227
|
if transport.is_authenticated():
|
|
197
228
|
return
|
|
198
229
|
|
|
199
|
-
if config.allow_agent:
|
|
200
|
-
try:
|
|
201
|
-
for key in paramiko.Agent().get_keys():
|
|
202
|
-
try:
|
|
203
|
-
log("info", f"Trying SSH agent key {key.get_name()}.", None)
|
|
204
|
-
transport.auth_publickey(username, key)
|
|
205
|
-
except Exception as exc:
|
|
206
|
-
errors.append(f"agent key {key.get_name()}: {exc}")
|
|
207
|
-
log("debug", f"SSH agent key failed: {exc}", None)
|
|
208
|
-
if transport.is_authenticated():
|
|
209
|
-
return
|
|
210
|
-
except Exception as exc:
|
|
211
|
-
errors.append(f"agent: {exc}")
|
|
212
|
-
log("warning", f"SSH agent authentication failed: {exc}", traceback.format_exc())
|
|
213
|
-
|
|
214
230
|
if config.look_for_keys:
|
|
215
231
|
for key in _load_default_private_keys(config.private_key_passphrase, log):
|
|
216
232
|
try:
|
|
@@ -34,9 +34,7 @@ const translations = {
|
|
|
34
34
|
privateKeyLocked: "Private key file is forced by the server.",
|
|
35
35
|
privateKeyPassphrase: "Private key passphrase",
|
|
36
36
|
legacyAlgorithms: "Legacy algorithms",
|
|
37
|
-
sshAgent: "SSH agent",
|
|
38
37
|
serverKeys: "Server local keys",
|
|
39
|
-
knownHosts: "known_hosts check",
|
|
40
38
|
reconnect: "Reconnect",
|
|
41
39
|
disconnect: "Disconnect SSH",
|
|
42
40
|
openLogs: "Open full logs",
|
|
@@ -95,9 +93,7 @@ const translations = {
|
|
|
95
93
|
privateKeyLocked: "私钥文件已强制绑定",
|
|
96
94
|
privateKeyPassphrase: "私钥口令",
|
|
97
95
|
legacyAlgorithms: "legacy 算法",
|
|
98
|
-
sshAgent: "SSH agent",
|
|
99
96
|
serverKeys: "服务端本机密钥",
|
|
100
|
-
knownHosts: "known_hosts 校验",
|
|
101
97
|
reconnect: "重连",
|
|
102
98
|
disconnect: "断开 SSH",
|
|
103
99
|
openLogs: "打开完整日志",
|
|
@@ -222,10 +218,8 @@ document.querySelector("#connect-form").addEventListener("submit", async (event)
|
|
|
222
218
|
password: valueOf("#password"),
|
|
223
219
|
private_key: privateKey,
|
|
224
220
|
private_key_passphrase: valueOf("#private-key-passphrase"),
|
|
225
|
-
allow_agent: checked("#allow-agent"),
|
|
226
221
|
look_for_keys: checked("#look-for-keys"),
|
|
227
222
|
legacy_algorithms: checked("#legacy-algorithms"),
|
|
228
|
-
strict_host_key: checked("#strict-host-key"),
|
|
229
223
|
term: "xterm-256color",
|
|
230
224
|
size: { cols: term.cols, rows: term.rows },
|
|
231
225
|
};
|
|
@@ -70,9 +70,7 @@
|
|
|
70
70
|
</label>
|
|
71
71
|
<div class="checks">
|
|
72
72
|
<label><input id="legacy-algorithms" type="checkbox" checked /> <span data-i18n="legacyAlgorithms">Legacy algorithms</span></label>
|
|
73
|
-
<label><input id="allow-agent" type="checkbox" /> <span data-i18n="sshAgent">SSH agent</span></label>
|
|
74
73
|
<label><input id="look-for-keys" type="checkbox" /> <span data-i18n="serverKeys">Server local keys</span></label>
|
|
75
|
-
<label><input id="strict-host-key" type="checkbox" /> <span data-i18n="knownHosts">known_hosts check</span></label>
|
|
76
74
|
</div>
|
|
77
75
|
<button class="primary" type="submit" data-i18n="connect">Connect</button>
|
|
78
76
|
</form>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|