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
+1
View File
@@ -8,6 +8,7 @@ RUN npm run build
# Stage 2: Python runtime # Stage 2: Python runtime
FROM python:3.12-slim 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 WORKDIR /app
COPY backend/requirements.txt . COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r 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 # 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") 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 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 pyjwt==2.8.0
passlib==1.7.4 passlib==1.7.4
pyotp==2.9.0 pyotp==2.9.0
asyncssh==2.17.0
+26 -37
View File
@@ -1,18 +1,12 @@
import asyncio import asyncio
import os
import pty
import signal
import struct import struct
import fcntl
import termios
import select
from fastapi import WebSocket, WebSocketDisconnect, Query from fastapi import WebSocket, WebSocketDisconnect, Query
import asyncssh
import jwt import jwt
import config import config
async def ws_endpoint(websocket: WebSocket, token: str = Query("")): async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
# Verify auth token
try: try:
payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM]) payload = jwt.decode(token, config.SECRET_KEY, algorithms=[config.ALGORITHM])
if payload.get("sub") != config.ADMIN_USER: if payload.get("sub") != config.ADMIN_USER:
@@ -23,34 +17,30 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
return return
await websocket.accept() 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: try:
while True: conn = await asyncssh.connect(
await asyncio.sleep(0.01) config.SSH_HOST, username=config.SSH_USER,
r, _, _ = select.select([master_fd], [], [], 0) client_keys=[config.SSH_KEY_PATH], known_hosts=None,
if r: )
data = os.read(master_fd, 4096) except Exception:
if not data: await websocket.close(code=1011, reason="SSH connection failed")
break return
await websocket.send_bytes(data)
except (OSError, WebSocketDisconnect): 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 pass
read_task = asyncio.create_task(read_pty()) read_task = asyncio.create_task(read_ssh())
try: try:
while True: while True:
@@ -60,15 +50,14 @@ async def ws_endpoint(websocket: WebSocket, token: str = Query("")):
if data[0:1] == b"\x01" and len(data) == 5: if data[0:1] == b"\x01" and len(data) == 5:
cols = struct.unpack("!H", data[1:3])[0] cols = struct.unpack("!H", data[1:3])[0]
rows = struct.unpack("!H", data[3:5])[0] rows = struct.unpack("!H", data[3:5])[0]
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) chan.change_terminal_size(cols, rows)
else: else:
os.write(master_fd, data) chan.write(data.decode("utf-8", errors="replace"))
elif "text" in msg and msg["text"]: elif "text" in msg and msg["text"]:
os.write(master_fd, msg["text"].encode()) chan.write(msg["text"])
except WebSocketDisconnect: except WebSocketDisconnect:
pass pass
finally: finally:
read_task.cancel() read_task.cancel()
os.close(master_fd) finally:
os.kill(pid, signal.SIGTERM) conn.close()
os.waitpid(pid, 0)
+5
View File
@@ -30,8 +30,13 @@ services:
- GITEA_URL=http://gitea:3000 - GITEA_URL=http://gitea:3000
- GITEA_TOKEN=${GITEA_TOKEN} - GITEA_TOKEN=${GITEA_TOKEN}
- VOLUME_ROOT=/volume1 - VOLUME_ROOT=/volume1
- SSH_HOST=host.docker.internal
- SSH_USER=zjgump
volumes: volumes:
- /volume1:/volume1 - /volume1:/volume1
- ./ssh/dashboard_terminal:/app/ssh/id_ed25519:ro
extra_hosts:
- "host.docker.internal:host-gateway"
networks: networks:
- internal - internal
- gitea_gitea - gitea_gitea