py-web-ssh 0.1.4__tar.gz → 0.1.6__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.4
3
+ Version: 0.1.6
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
@@ -33,8 +33,10 @@ Description-Content-Type: text/markdown
33
33
  - 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
34
34
  - 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
35
35
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
36
- - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
36
+ - 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
37
37
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
38
+ - 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
39
+ - 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
38
40
  - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
39
41
  - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
40
42
 
@@ -66,10 +68,21 @@ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
66
68
  py-web-ssh --pin 123456
67
69
  ```
68
70
 
71
+ 锁定连接目标或凭据:
72
+
73
+ ```bash
74
+ py-web-ssh --lock-host server.example.com --lock-username deploy
75
+ py-web-ssh --lock-pwd 'ssh-password'
76
+ py-web-ssh --lock-private-key C:\secrets\id_ed25519
77
+ ```
78
+
79
+ `--lock-pwd` 和 `--lock-private-key` 的值只在服务端使用,不会通过配置接口发送给浏览器。`--lock-private-key` 指向的是服务端本机文件路径,不是浏览器上传的文件。
80
+
69
81
  前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
70
82
 
71
83
  ## API 概览
72
84
 
85
+ - `GET /api/config` 查看非敏感的服务端公开配置和锁定状态。
73
86
  - `POST /api/sessions` 创建 SSH 会话。
74
87
  - `GET /api/sessions/{uuid}` 查看会话状态。
75
88
  - `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
@@ -11,8 +11,10 @@
11
11
  - 会话回收:同一个 UUID 如果所有浏览器连接都断开并且 5 分钟内无人重连,服务端会主动断开 SSH 并清理内存缓存。
12
12
  - 终端恢复:服务端保存 SSH 输出流,浏览器定期回传 xterm serialize 快照;重连时先恢复快照,再补放快照之后的输出。
13
13
  - 日志页面:`/sessions/{uuid}/logs` 展示完整连接、认证、错误和文件传输日志。
14
- - 文件传输:优先 SFTP;SFTP 不可用时参考 `simple-ssh-copy` 思路,使用远端 `base64` shell 命令 fallback 上传/下载。
14
+ - 文件传输:参考 `simple-ssh-copy` 思路,不使用 SFTP/SCP;每次传输都按连接配置新建独立 SSH 连接,使用远端 `base64` shell 命令上传/下载。上传先写远端临时文件,完成后再 `mv` 到最终路径,并支持进度显示和取消。
15
15
  - 可选 PIN 门禁:服务端传入 `--pin` 后,网页启动时必须先输入正确 PIN;验证成功后浏览器会保存加盐哈希 cookie,后端会保护 HTTP API、日志页面、文件接口和 WebSocket。
16
+ - 启动锁定策略:支持 `--lock-host`、`--lock-username`、`--lock-pwd`、`--lock-private-key`,可从服务端强制绑定目标主机、用户名、密码和服务端侧私钥文件。前端会锁定或隐藏对应控件,后端仍会校验并覆盖敏感字段。
17
+ - 中英双语:默认英文,网页和日志页都支持中英切换;语言选择会长期保存到 `py_web_ssh_lang` cookie。
16
18
  - 浏览器客户端 session:首次访问时服务端会分配独立的浏览器 session UUID,并写入 HttpOnly cookie;它与 SSH 会话 UUID 分离。
17
19
  - 左侧控制面板:连接、会话、文件三个栏目改为互斥折叠面板,一次最多展开一个,也可以全部折叠。
18
20
 
@@ -38,17 +40,28 @@ uvicorn webssh.app:app --host 0.0.0.0 --port 8022
38
40
 
39
41
  打开 <http://127.0.0.1:8022>。
40
42
 
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 会话。
43
+ 启用 PIN:
44
+
45
+ ```bash
46
+ py-web-ssh --pin 123456
47
+ ```
48
+
49
+ 锁定连接目标或凭据:
50
+
51
+ ```bash
52
+ py-web-ssh --lock-host server.example.com --lock-username deploy
53
+ py-web-ssh --lock-pwd 'ssh-password'
54
+ py-web-ssh --lock-private-key C:\secrets\id_ed25519
55
+ ```
56
+
57
+ `--lock-pwd` 和 `--lock-private-key` 的值只在服务端使用,不会通过配置接口发送给浏览器。`--lock-private-key` 指向的是服务端本机文件路径,不是浏览器上传的文件。
58
+
59
+ 前端默认从 jsDelivr 加载 xterm.js、fit addon 和 serialize addon。离线内网部署时,请把这些静态资源 vendoring 到 `webssh/static/` 并替换 `index.html` 里的 CDN 地址。
60
+
61
+ ## API 概览
62
+
63
+ - `GET /api/config` 查看非敏感的服务端公开配置和锁定状态。
64
+ - `POST /api/sessions` 创建 SSH 会话。
52
65
  - `GET /api/sessions/{uuid}` 查看会话状态。
53
66
  - `GET /api/sessions/{uuid}/logs` 获取完整日志 JSON。
54
67
  - `DELETE /api/sessions/{uuid}` 主动断开 SSH。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "py-web-ssh"
3
- version = "0.1.4"
3
+ version = "0.1.6"
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.4"
5
+ __version__ = "0.1.6"
@@ -1,8 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
- import base64
5
4
  import argparse
5
+ import base64
6
6
  from pathlib import Path
7
7
  from typing import Annotated
8
8
 
@@ -14,9 +14,17 @@ from . import __version__
14
14
  from . import auth
15
15
  from .auth import add_pin_argument, configure_pin
16
16
  from .client_session import ensure_client_session_cookie
17
- from .files import download_file, filename_for_download, upload_file
17
+ from .files import (
18
+ FileTransferCancelled,
19
+ download_file_via_ssh,
20
+ filename_for_download,
21
+ upload_file_via_ssh,
22
+ )
18
23
  from .models import ConnectRequest, CreateSessionResponse, FileTransferResponse
24
+ from .runtime_config import add_runtime_lock_arguments, configure_runtime_locks
25
+ from . import runtime_config as runtime_config_module
19
26
  from .session import SessionManager
27
+ from .transfers import TransferManager
20
28
 
21
29
 
22
30
  BASE_DIR = Path(__file__).resolve().parent
@@ -28,6 +36,7 @@ DEFAULT_PORT = 8022
28
36
  app = FastAPI(title="py-web-ssh", version=__version__)
29
37
  app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
30
38
  sessions = SessionManager()
39
+ transfers = TransferManager()
31
40
 
32
41
 
33
42
  @app.middleware("http")
@@ -53,8 +62,18 @@ def index() -> FileResponse:
53
62
 
54
63
 
55
64
  @app.get("/api/auth/status")
56
- def auth_status() -> JSONResponse:
57
- return JSONResponse(auth.pin_auth.status_payload())
65
+ def auth_status(request: Request) -> JSONResponse:
66
+ return JSONResponse(
67
+ {
68
+ **auth.pin_auth.status_payload(),
69
+ "authorized": auth.pin_auth.is_request_authorized(request),
70
+ }
71
+ )
72
+
73
+
74
+ @app.get("/api/config")
75
+ def public_config() -> JSONResponse:
76
+ return JSONResponse(runtime_config_module.runtime_config.public_payload())
58
77
 
59
78
 
60
79
  @app.post("/api/auth/login")
@@ -76,7 +95,8 @@ def logs_page(session_id: str) -> HTMLResponse:
76
95
 
77
96
  @app.post("/api/sessions", response_model=CreateSessionResponse)
78
97
  def create_session(config: ConnectRequest) -> CreateSessionResponse:
79
- session = sessions.create(config)
98
+ locked_config = runtime_config_module.runtime_config.apply_to_connect_request(config)
99
+ session = sessions.create(locked_config)
80
100
  return CreateSessionResponse(
81
101
  session_id=session.id,
82
102
  logs_url=f"/sessions/{session.id}/logs",
@@ -118,19 +138,35 @@ def upload(
118
138
  session_id: str,
119
139
  remote_path: Annotated[str, Form(min_length=1)],
120
140
  file: Annotated[UploadFile, File()],
141
+ transfer_id: Annotated[str | None, Form()] = None,
142
+ total_bytes: Annotated[int | None, Form()] = None,
121
143
  ) -> FileTransferResponse:
122
144
  session = _require_session(session_id)
145
+ tracker = transfers.get(transfer_id) if transfer_id else None
146
+ if transfer_id and tracker is None:
147
+ raise HTTPException(status_code=404, detail="Transfer not found.")
148
+ if tracker is None:
149
+ tracker = transfers.create_upload(total_bytes or _content_length(file), remote_path)
123
150
  try:
124
- method, transferred = upload_file(
125
- session.ssh_client,
151
+ method, transferred = upload_file_via_ssh(
152
+ session.config,
126
153
  file.file,
127
154
  remote_path,
128
- _content_length(file),
155
+ total_bytes or _content_length(file),
156
+ cancel_event=tracker.cancel_event,
157
+ progress=tracker.update_progress,
129
158
  )
159
+ except FileTransferCancelled as exc:
160
+ message = f"File upload cancelled: {exc}"
161
+ tracker.cancelled(message)
162
+ session.log("warning", message, None)
163
+ raise HTTPException(status_code=499, detail=message) from exc
130
164
  except Exception as exc:
165
+ tracker.fail(str(exc))
131
166
  session.log("error", f"File upload failed: {exc}", None)
132
167
  raise HTTPException(status_code=500, detail=str(exc)) from exc
133
168
  message = f"Uploaded {transferred} bytes to {remote_path} using {method}."
169
+ tracker.complete(transferred, message)
134
170
  session.log("info", message, None)
135
171
  return FileTransferResponse(
136
172
  ok=True,
@@ -138,14 +174,63 @@ def upload(
138
174
  bytes_transferred=transferred,
139
175
  remote_path=remote_path,
140
176
  message=message,
177
+ transfer_id=tracker.id,
141
178
  )
142
179
 
143
180
 
181
+ @app.post("/api/sessions/{session_id}/files/uploads")
182
+ async def create_upload_task(session_id: str, request: Request) -> JSONResponse:
183
+ _require_session(session_id)
184
+ payload = await request.json()
185
+ remote_path = str(payload.get("remote_path", "")).strip()
186
+ if not remote_path:
187
+ raise HTTPException(status_code=422, detail="remote_path is required.")
188
+ total_bytes = payload.get("total_bytes")
189
+ if total_bytes is not None:
190
+ total_bytes = int(total_bytes)
191
+ tracker = transfers.create_upload(total_bytes, remote_path)
192
+ return JSONResponse(
193
+ {
194
+ "transfer_id": tracker.id,
195
+ "state": tracker.status().state,
196
+ "remote_path": remote_path,
197
+ "total_bytes": total_bytes,
198
+ }
199
+ )
200
+
201
+
202
+ @app.get("/api/transfers/{transfer_id}")
203
+ def get_transfer(transfer_id: str) -> JSONResponse:
204
+ tracker = transfers.get(transfer_id)
205
+ if tracker is None:
206
+ raise HTTPException(status_code=404, detail="Transfer not found.")
207
+ status = tracker.status()
208
+ return JSONResponse(
209
+ {
210
+ "transfer_id": status.transfer_id,
211
+ "state": status.state,
212
+ "bytes_transferred": status.bytes_transferred,
213
+ "total_bytes": status.total_bytes,
214
+ "remote_path": status.remote_path,
215
+ "message": status.message,
216
+ "created_at": status.created_at.isoformat(),
217
+ "updated_at": status.updated_at.isoformat(),
218
+ }
219
+ )
220
+
221
+
222
+ @app.delete("/api/transfers/{transfer_id}")
223
+ def cancel_transfer(transfer_id: str) -> JSONResponse:
224
+ if not transfers.cancel(transfer_id):
225
+ raise HTTPException(status_code=404, detail="Transfer not found.")
226
+ return JSONResponse({"ok": True})
227
+
228
+
144
229
  @app.get("/api/sessions/{session_id}/files/download")
145
230
  def download(session_id: str, remote_path: str) -> StreamingResponse:
146
231
  session = _require_session(session_id)
147
232
  try:
148
- method, stream = download_file(session.ssh_client, remote_path)
233
+ method, stream = download_file_via_ssh(session.config, remote_path)
149
234
  except Exception as exc:
150
235
  session.log("error", f"File download failed: {exc}", None)
151
236
  raise HTTPException(status_code=500, detail=str(exc)) from exc
@@ -240,6 +325,7 @@ def build_arg_parser() -> argparse.ArgumentParser:
240
325
  parser.add_argument("--host", default=DEFAULT_HOST, help="Host interface to bind.")
241
326
  parser.add_argument("--port", type=int, default=DEFAULT_PORT, help="Port to listen on.")
242
327
  add_pin_argument(parser)
328
+ add_runtime_lock_arguments(parser)
243
329
  return parser
244
330
 
245
331
 
@@ -249,6 +335,12 @@ def main() -> None:
249
335
  args = build_arg_parser().parse_args()
250
336
 
251
337
  configure_pin(args.pin)
338
+ configure_runtime_locks(
339
+ lock_host=args.lock_host,
340
+ lock_username=args.lock_username,
341
+ lock_password=args.lock_pwd,
342
+ lock_private_key=args.lock_private_key,
343
+ )
252
344
  uvicorn.run("webssh.app:app", host=args.host, port=args.port, reload=False)
253
345
 
254
346
 
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import binascii
5
+ import os
6
+ import posixpath
7
+ import shlex
8
+ import threading
9
+ import uuid
10
+ from collections.abc import Callable, Iterator
11
+ from typing import BinaryIO
12
+
13
+ from .models import ConnectRequest
14
+ from .ssh_client import connect_ssh
15
+
16
+
17
+ class FileTransferError(RuntimeError):
18
+ pass
19
+
20
+
21
+ class FileTransferCancelled(FileTransferError):
22
+ pass
23
+
24
+
25
+ ProgressCallback = Callable[[int], None]
26
+
27
+
28
+ def upload_file_via_ssh(
29
+ config: ConnectRequest,
30
+ source: BinaryIO,
31
+ remote_path: str,
32
+ size: int | None,
33
+ cancel_event: threading.Event | None = None,
34
+ progress: ProgressCallback | None = None,
35
+ ) -> tuple[str, int]:
36
+ """Upload using only an SSH exec channel and POSIX-ish shell commands.
37
+
38
+ This intentionally does not use SFTP or SCP. It follows the simple-ssh-copy
39
+ style: stream base64 into a remote shell, decode into a temporary file, and
40
+ atomically move the temp file into place after the complete upload succeeds.
41
+ """
42
+
43
+ connected = connect_ssh(config, lambda _level, _message, _details=None: None)
44
+ client = connected.client
45
+ remote_dir = posixpath.dirname(remote_path) or "."
46
+ temp_path = posixpath.join(remote_dir, f".py-web-ssh-upload-{uuid.uuid4().hex}.tmp")
47
+ command = _upload_command(remote_dir, temp_path, remote_path)
48
+ stdin = stdout = stderr = None
49
+ transferred = 0
50
+ try:
51
+ stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
52
+ while True:
53
+ if cancel_event and cancel_event.is_set():
54
+ raise FileTransferCancelled("Upload cancelled by client.")
55
+ chunk = source.read(48 * 1024)
56
+ if not chunk:
57
+ break
58
+ transferred += len(chunk)
59
+ stdin.write(base64.b64encode(chunk).decode("ascii"))
60
+ stdin.write("\n")
61
+ stdin.flush()
62
+ if progress:
63
+ progress(transferred)
64
+
65
+ if cancel_event and cancel_event.is_set():
66
+ raise FileTransferCancelled("Upload cancelled by client.")
67
+
68
+ stdin.channel.shutdown_write()
69
+ exit_code = stdout.channel.recv_exit_status()
70
+ error_text = stderr.read().decode("utf-8", errors="replace")
71
+ except FileTransferCancelled:
72
+ _close_transport(client)
73
+ raise
74
+ except Exception as exc:
75
+ _close_transport(client)
76
+ raise FileTransferError(f"SSH shell upload failed: {exc}") from exc
77
+ finally:
78
+ for stream in (stdin, stdout, stderr):
79
+ if stream is not None:
80
+ try:
81
+ stream.close()
82
+ except Exception:
83
+ pass
84
+ client.close()
85
+
86
+ if exit_code != 0:
87
+ raise FileTransferError(f"Remote upload command failed with exit code {exit_code}: {error_text}")
88
+ if size is not None and transferred != size:
89
+ raise FileTransferError(f"Uploaded {transferred} bytes, expected {size} bytes.")
90
+ return "shell", transferred
91
+
92
+
93
+ def download_file_via_ssh(config: ConnectRequest, remote_path: str) -> tuple[str, Iterator[bytes]]:
94
+ connected = connect_ssh(config, lambda _level, _message, _details=None: None)
95
+ client = connected.client
96
+ command = f"base64 < {shlex.quote(remote_path)}"
97
+ stdin, stdout, stderr = client.exec_command(f"sh -c {shlex.quote(command)}")
98
+ stdin.close()
99
+
100
+ def iterator() -> Iterator[bytes]:
101
+ buffered = b""
102
+ try:
103
+ while True:
104
+ chunk = stdout.channel.recv(64 * 1024)
105
+ if not chunk:
106
+ break
107
+ buffered += b"".join(chunk.split())
108
+ keep = len(buffered) % 4
109
+ ready = buffered[:-keep] if keep else buffered
110
+ buffered = buffered[-keep:] if keep else b""
111
+ if ready:
112
+ yield base64.b64decode(ready)
113
+ if buffered:
114
+ yield base64.b64decode(buffered)
115
+ exit_code = stdout.channel.recv_exit_status()
116
+ error_text = stderr.read().decode("utf-8", errors="replace")
117
+ if exit_code != 0:
118
+ raise FileTransferError(
119
+ f"Remote download command failed with exit code {exit_code}: {error_text}"
120
+ )
121
+ except binascii.Error as exc:
122
+ raise FileTransferError(f"Remote command returned invalid base64: {exc}") from exc
123
+ finally:
124
+ stdout.close()
125
+ stderr.close()
126
+ client.close()
127
+
128
+ return "shell", iterator()
129
+
130
+
131
+ def _upload_command(remote_dir: str, temp_path: str, remote_path: str) -> str:
132
+ quoted_temp = shlex.quote(temp_path)
133
+ return "\n".join(
134
+ [
135
+ "set -e",
136
+ f"mkdir -p -- {shlex.quote(remote_dir)}",
137
+ f"tmp={quoted_temp}",
138
+ "cleanup() { rm -f -- \"$tmp\"; }",
139
+ "trap cleanup EXIT HUP INT TERM",
140
+ f"if printf '' | base64 -d >/dev/null 2>&1; then base64 -d > {quoted_temp}; "
141
+ f"else base64 -D > {quoted_temp}; fi",
142
+ f"mv -- {quoted_temp} {shlex.quote(remote_path)}",
143
+ "trap - EXIT",
144
+ ]
145
+ )
146
+
147
+
148
+ def _close_transport(client) -> None:
149
+ try:
150
+ transport = client.get_transport()
151
+ if transport is not None:
152
+ transport.close()
153
+ except Exception:
154
+ pass
155
+ try:
156
+ client.close()
157
+ except Exception:
158
+ pass
159
+
160
+
161
+ def filename_for_download(remote_path: str) -> str:
162
+ name = posixpath.basename(remote_path.rstrip("/")) or "download.bin"
163
+ return os.path.basename(name)
@@ -70,7 +70,8 @@ class SessionSummary(BaseModel):
70
70
 
71
71
  class FileTransferResponse(BaseModel):
72
72
  ok: bool
73
- method: Literal["sftp", "shell"]
73
+ method: Literal["shell"]
74
74
  bytes_transferred: int
75
75
  remote_path: str
76
76
  message: str
77
+ transfer_id: str | None = None
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from fastapi import HTTPException
8
+
9
+ from .models import ConnectRequest
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RuntimeConfig:
14
+ lock_host: str | None = None
15
+ lock_username: str | None = None
16
+ lock_password: str | None = None
17
+ lock_private_key_path: Path | None = None
18
+
19
+ @property
20
+ def lock_password_enabled(self) -> bool:
21
+ return self.lock_password is not None
22
+
23
+ @property
24
+ def lock_private_key_enabled(self) -> bool:
25
+ return self.lock_private_key_path is not None
26
+
27
+ def public_payload(self) -> dict[str, object]:
28
+ return {
29
+ "locks": {
30
+ "host": {"enabled": self.lock_host is not None, "value": self.lock_host},
31
+ "username": {"enabled": self.lock_username is not None, "value": self.lock_username},
32
+ "password": {"enabled": self.lock_password_enabled},
33
+ "private_key": {"enabled": self.lock_private_key_enabled},
34
+ }
35
+ }
36
+
37
+ def apply_to_connect_request(self, incoming: ConnectRequest) -> ConnectRequest:
38
+ data = incoming.model_dump()
39
+
40
+ if self.lock_host is not None:
41
+ requested = str(data.get("host") or "").strip()
42
+ if requested and requested != self.lock_host:
43
+ raise HTTPException(status_code=403, detail="Host is locked by the server.")
44
+ data["host"] = self.lock_host
45
+
46
+ if self.lock_username is not None:
47
+ requested = str(data.get("username") or "").strip()
48
+ if requested and requested != self.lock_username:
49
+ raise HTTPException(status_code=403, detail="Username is locked by the server.")
50
+ data["username"] = self.lock_username
51
+
52
+ if self.lock_password is not None:
53
+ data["password"] = self.lock_password
54
+
55
+ if self.lock_private_key_path is not None:
56
+ data["private_key"] = _read_locked_private_key(self.lock_private_key_path)
57
+
58
+ return ConnectRequest.model_validate(data)
59
+
60
+
61
+ runtime_config = RuntimeConfig()
62
+
63
+
64
+ def configure_runtime_locks(
65
+ *,
66
+ lock_host: str | None = None,
67
+ lock_username: str | None = None,
68
+ lock_password: str | None = None,
69
+ lock_private_key: str | None = None,
70
+ ) -> None:
71
+ global runtime_config
72
+ key_path = Path(lock_private_key).expanduser().resolve() if lock_private_key else None
73
+ if key_path is not None:
74
+ _read_locked_private_key(key_path)
75
+ runtime_config = RuntimeConfig(
76
+ lock_host=_blank_to_none(lock_host),
77
+ lock_username=_blank_to_none(lock_username),
78
+ lock_password=lock_password if lock_password is not None else None,
79
+ lock_private_key_path=key_path,
80
+ )
81
+
82
+
83
+ def add_runtime_lock_arguments(parser: argparse.ArgumentParser) -> None:
84
+ parser.add_argument(
85
+ "--lock-host",
86
+ default=None,
87
+ metavar="HOST",
88
+ help="Only allow SSH connections to this host or domain.",
89
+ )
90
+ parser.add_argument(
91
+ "--lock-username",
92
+ default=None,
93
+ metavar="USERNAME",
94
+ help="Only allow SSH connections with this username.",
95
+ )
96
+ parser.add_argument(
97
+ "--lock-pwd",
98
+ default=None,
99
+ metavar="PASSWORD",
100
+ help="Bind this SSH password server-side. It is never sent to the browser.",
101
+ )
102
+ parser.add_argument(
103
+ "--lock-private-key",
104
+ default=None,
105
+ metavar="KEY_FILE",
106
+ help="Bind this server-side SSH private key file. It is never sent to the browser.",
107
+ )
108
+
109
+
110
+ def _read_locked_private_key(path: Path) -> str:
111
+ try:
112
+ return path.read_text(encoding="utf-8")
113
+ except OSError as exc:
114
+ raise ValueError(f"Could not read locked private key file {path}: {exc}") from exc
115
+
116
+
117
+ def _blank_to_none(value: str | None) -> str | None:
118
+ if value is None:
119
+ return None
120
+ value = value.strip()
121
+ return value or None