Terminal: SSH to NAS host instead of container shell

- Rewrite terminal.py to use asyncssh instead of pty/fork
- Add SSH key pair for dashboard container auth
- Mount SSH key and add SSH config vars in docker-compose
- Install openssh-client in Dockerfile
- Add asyncssh to requirements.txt
This commit is contained in:
Gan, Jimmy
2026-02-19 19:24:38 +08:00
parent 69bd41b5a1
commit ee2e5dcb3c
5 changed files with 54 additions and 53 deletions
+5
View File
@@ -14,3 +14,8 @@ ADMIN_USER = os.environ.get("ADMIN_USER", "admin")
# Default hash for "admin" using pbkdf2_sha256 scheme to avoid bcrypt compat issues
ADMIN_PASSWORD_HASH = os.environ.get("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$25tTKoUQIgQghLC2ds4ZYw$H/fCb3H5wmOkwj6l6KrVlmns0TWfzKPOrG3sgVDR.eg")
TOTP_SECRET = os.environ.get("TOTP_SECRET", "JBSWY3DPEHPK3PXP") # Base32 encoded secretans 2FA disabled by default
# SSH terminal
SSH_HOST = os.environ.get("SSH_HOST", "host.docker.internal")
SSH_USER = os.environ.get("SSH_USER", "zjgump")
SSH_KEY_PATH = os.environ.get("SSH_KEY_PATH", "/app/ssh/id_ed25519")
+1
View File
@@ -7,3 +7,4 @@ psutil==6.1.0
pyjwt==2.8.0
passlib==1.7.4
pyotp==2.9.0
asyncssh==2.17.0
+42 -53
View File
@@ -1,18 +1,12 @@
import asyncio
import os
import pty
import signal
import struct
import fcntl
import termios
import select
from fastapi import WebSocket, WebSocketDisconnect, Query
import asyncssh
import jwt
import config
async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
# Verify auth token
try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("sub") != config.ADMIN_USER:
@@ -23,52 +17,47 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
return
await websocket.accept()
master_fd, slave_fd = pty.openpty()
pid = os.fork()
if pid == 0:
os.close(master_fd)
os.setsid()
os.dup2(slave_fd, 0)
os.dup2(slave_fd, 1)
os.dup2(slave_fd, 2)
os.close(slave_fd)
os.execvp("/bin/bash", ["/bin/bash"])
os.close(slave_fd)
async def read_pty():
try:
while True:
await asyncio.sleep(0.01)
r, _, _ = select.select([master_fd], [], [], 0)
if r:
data = os.read(master_fd, 4096)
if not data:
break
await websocket.send_bytes(data)
except (OSError, WebSocketDisconnect):
pass
read_task = asyncio.create_task(read_pty())
try:
while True:
msg = await websocket.receive()
if "bytes" in msg and msg["bytes"]:
data = msg["bytes"]
if data[0:1] == b"\x01" and len(data) == 5:
cols = struct.unpack("!H", data[1:3])[0]
rows = struct.unpack("!H", data[3:5])[0]
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
else:
os.write(master_fd, data)
elif "text" in msg and msg["text"]:
os.write(master_fd, msg["text"].encode())
except WebSocketDisconnect:
pass
conn = await asyncssh.connect(
config.SSH_HOST, username=config.SSH_USER,
client_keys=[config.SSH_KEY_PATH], known_hosts=None,
)
except Exception:
await websocket.close(code=1011, reason="SSH connection failed")
return
try:
chan, session = await conn.create_session(
asyncssh.SSHClientProcess, term_type="xterm-256color",
term_size=(80, 24),
)
async def read_ssh():
try:
async for data in chan.stdout:
await websocket.send_bytes(data.encode() if isinstance(data, str) else data)
except (asyncssh.Error, WebSocketDisconnect):
pass
read_task = asyncio.create_task(read_ssh())
try:
while True:
msg = await websocket.receive()
if "bytes" in msg and msg["bytes"]:
data = msg["bytes"]
if data[0:1] == b"\x01" and len(data) == 5:
cols = struct.unpack("!H", data[1:3])[0]
rows = struct.unpack("!H", data[3:5])[0]
chan.change_terminal_size(cols, rows)
else:
chan.write(data.decode("utf-8", errors="replace"))
elif "text" in msg and msg["text"]:
chan.write(msg["text"])
except WebSocketDisconnect:
pass
finally:
read_task.cancel()
finally:
read_task.cancel()
os.close(master_fd)
os.kill(pid, signal.SIGTERM)
os.waitpid(pid, 0)
conn.close()