Terminal: SSH to NAS host instead of container shell
Deploy Dashboard / deploy (push) Has been cancelled

- 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 36f1fc0962
7 changed files with 62 additions and 53 deletions
+1
View File
@@ -8,6 +8,7 @@ RUN npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+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()
+5
View File
@@ -30,8 +30,13 @@ services:
- GITEA_URL=http://gitea:3000
- GITEA_TOKEN=${GITEA_TOKEN}
- VOLUME_ROOT=/volume1
- SSH_HOST=host.docker.internal
- SSH_USER=zjgump
volumes:
- /volume1:/volume1
- ./ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
extra_hosts:
- "host.docker.internal:host-gateway"
networks:
- internal
- gitea_gitea
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/MwAAAJi8dYFQvHWB
UAAAAAtzc2gtZWQyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/Mw
AAAEANwPXx/fEISyOhps72XCb5Okusk9wHUTW6TjHuqfKAEQDUHzaA25sc31hJui7NkHHl
lwEB9JetMR/qPb6T2n8zAAAAEmRhc2hib2FyZC10ZXJtaW5hbAECAw==
-----END OPENSSH PRIVATE KEY-----
+1
View File
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIADUHzaA25sc31hJui7NkHHllwEB9JetMR/qPb6T2n8z dashboard-terminal