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
+42 -53
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,52 +17,47 @@ 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:
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: try:
while True: conn = await asyncssh.connect(
msg = await websocket.receive() config.SSH_HOST, username=config.SSH_USER,
if "bytes" in msg and msg["bytes"]: client_keys=[config.SSH_KEY_PATH], known_hosts=None,
data = msg["bytes"] )
if data[0:1] == b"\x01" and len(data) == 5: except Exception:
cols = struct.unpack("!H", data[1:3])[0] await websocket.close(code=1011, reason="SSH connection failed")
rows = struct.unpack("!H", data[3:5])[0] return
fcntl.ioctl(master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
else: try:
os.write(master_fd, data) chan, session = await conn.create_session(
elif "text" in msg and msg["text"]: asyncssh.SSHClientProcess, term_type="xterm-256color",
os.write(master_fd, msg["text"].encode()) term_size=(80, 24),
except WebSocketDisconnect: )
pass
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: finally:
read_task.cancel() conn.close()
os.close(master_fd)
os.kill(pid, signal.SIGTERM)
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