diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile index bfa2c38..d208d4a 100644 --- a/dashboard/Dockerfile +++ b/dashboard/Dockerfile @@ -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 diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index 5a218f0..abd57c7 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -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") diff --git a/dashboard/backend/requirements.txt b/dashboard/backend/requirements.txt index acb5ef8..4a09f49 100644 --- a/dashboard/backend/requirements.txt +++ b/dashboard/backend/requirements.txt @@ -7,3 +7,4 @@ psutil==6.1.0 pyjwt==2.8.0 passlib==1.7.4 pyotp==2.9.0 +asyncssh==2.17.0 diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index 8d01e32..97430e6 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -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() diff --git a/dashboard/docker-compose.yml b/dashboard/docker-compose.yml index 7aa3590..5ecb4d6 100644 --- a/dashboard/docker-compose.yml +++ b/dashboard/docker-compose.yml @@ -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 diff --git a/dashboard/ssh/dashboard_terminal b/dashboard/ssh/dashboard_terminal new file mode 100644 index 0000000..b0df4fe --- /dev/null +++ b/dashboard/ssh/dashboard_terminal @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/MwAAAJi8dYFQvHWB +UAAAAAtzc2gtZWQyNTUxOQAAACAA1B82gNubHN9YSbouzZBx5ZcBAfSXrTEf6j2+k9p/Mw +AAAEANwPXx/fEISyOhps72XCb5Okusk9wHUTW6TjHuqfKAEQDUHzaA25sc31hJui7NkHHl +lwEB9JetMR/qPb6T2n8zAAAAEmRhc2hib2FyZC10ZXJtaW5hbAECAw== +-----END OPENSSH PRIVATE KEY----- diff --git a/dashboard/ssh/dashboard_terminal.pub b/dashboard/ssh/dashboard_terminal.pub new file mode 100644 index 0000000..750bc26 --- /dev/null +++ b/dashboard/ssh/dashboard_terminal.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIADUHzaA25sc31hJui7NkHHllwEB9JetMR/qPb6T2n8z dashboard-terminal