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:
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user