Guanghu Domestic Migration a27e87cb99 chore: import sanitized domestic snapshot for REPO-007
Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc

[SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
2026-07-17 15:59:55 +08:00

7.5 KiB
Raw Blame History

GHCS v0.2 · terminal_router.py · SSH Web终端后端 · WebSocket

部署路径:~/guanghu-dev/ghcs-mono/backend/terminal_router.py

"""
GHCS v0.2 - SSH Web终端
WebSocket + paramiko · 浏览器内操作服务器
"""
import os
import json
import asyncio
import logging
from typing import Optional

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException
from auth import verify_token

logger = logging.getLogger("ghcs.terminal")
router = APIRouter(prefix="/ws", tags=["terminal"])

SSH_KEY_PATH = os.getenv("GHCS_SSH_KEY", os.path.expanduser("~/.ssh/id_rsa"))

# 可用服务器白名单
SERVERS = {
    "ghcs": {
        "name": "GHCS部署台",
        "host": "43.139.207.172",
        "port": 22,
        "username": "root",
        "key_filename": SSH_KEY_PATH,
    },
}

# 命令白名单前缀(安全:只允许白名单内的命令执行)
# 空列表 = 不做命令过滤(开发阶段可用,正式上线前必须设置)
COMMAND_WHITELIST_PREFIXES: list[str] = []

# 危险命令黑名单(即使白名单为空,这些命令也绝对不允许执行)
DANGEROUS_COMMANDS = [
    "rm -rf /", "mkfs", "dd if=", "> /dev/sda", ":(){ :|:& };:",
]

def _validate_command(cmd: str) -> bool:
    """验证命令是否安全"""
    cmd_stripped = cmd.strip()
    if not cmd_stripped:
        return True
    
    # 黑名单检查
    for dangerous in DANGEROUS_COMMANDS:
        if dangerous in cmd_stripped:
            return False
    
    # 如果配置了白名单,检查前缀
    if COMMAND_WHITELIST_PREFIXES:
        return any(
            cmd_stripped.startswith(prefix)
            for prefix in COMMAND_WHITELIST_PREFIXES
        )
    
    return True

@router.get("/terminal/servers")
async def list_terminal_servers():
    """列出可连接的服务器无需鉴权仅返回服务器名称和IP"""
    return {
        "servers": [
            {"key": k, "name": v["name"], "host": v["host"]}
            for k, v in SERVERS.items()
        ]
    }

@router.websocket("/terminal/{server_key}")
async def terminal_websocket(websocket: WebSocket, server_key: str):
    """WebSocket终端连接"""
    if server_key not in SERVERS:
        await websocket.close(code=4004, reason=f"未知服务器: {server_key}")
        return
    
    await websocket.accept()
    
    import paramiko
    import threading
    
    server = SERVERS[server_key]
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    
    channel = None
    running = True
    
    try:
        # 连接SSH
        client.connect(
            hostname=server["host"],
            port=server["port"],
            username=server["username"],
            key_filename=server["key_filename"],
            timeout=10,
        )
        
        # 打开交互式shell
        channel = client.invoke_shell(
            term="xterm-256color",
            width=120,
            height=40,
        )
        channel.settimeout(0.0)
        
        logger.info(f"[TERMINAL] connected to {server_key} ({server['host']})")
        
        def read_ssh():
            """从SSH读取输出 → 推送到WebSocket"""
            nonlocal running
            while running:
                try:
                    if channel.recv_ready():
                        data = channel.recv(4096)
                        if data:
                            asyncio.run_coroutine_threadsafe(
                                websocket.send_bytes(data),
                                asyncio.get_event_loop(),
                            )
                    else:
                        import time
                        time.sleep(0.01)
                except Exception:
                    if running:
                        import time
                        time.sleep(0.05)
        
        # 启动读取线程
        reader_thread = threading.Thread(target=read_ssh, daemon=True)
        reader_thread.start()
        
        # 主循环接收WebSocket输入 → 发送到SSH
        while True:
            try:
                data = await websocket.receive_text()
                
                # 解析JSON消息
                try:
                    msg = json.loads(data)
                except json.JSONDecodeError:
                    msg = {"type": "stdin", "data": data}
                
                if msg.get("type") == "resize":
                    # 终端窗口大小调整
                    cols = msg.get("cols", 120)
                    rows = msg.get("rows", 40)
                    channel.resize_pty(width=cols, height=rows)
                elif msg.get("type") == "stdin":
                    # 普通输入
                    stdin_data = msg.get("data", "")
                    if _validate_command(stdin_data):
                        channel.send(stdin_data)
                    else:
                        channel.send(b"\r\n[GHCS] 命令被安全策略阻止\r\n")
                else:
                    # 兼容纯文本输入
                    if _validate_command(data):
                        channel.send(data)
                    else:
                        channel.send(b"\r\n[GHCS] 命令被安全策略阻止\r\n")
                        
            except WebSocketDisconnect:
                break
            except Exception as e:
                logger.error(f"[TERMINAL] error: {e}")
                break
                
    except Exception as e:
        logger.error(f"[TERMINAL] SSH连接失败: {e}")
        await websocket.send_text(json.dumps({
            "type": "error",
            "message": f"SSH连接失败: {str(e)}"
        }))
    finally:
        running = False
        if channel:
            try:
                channel.close()
            except Exception:
                pass
        try:
            client.close()
        except Exception:
            pass
        logger.info(f"[TERMINAL] disconnected from {server_key}")

接口说明

接口 方法 作用
GET /ws/terminal/servers GET 列出可连接的服务器
WS /ws/terminal/{server_key} WebSocket 建立终端连接

WebSocket 消息格式

发送(前端 → 后端)

{"type": "stdin", "data": "ls -la\n"}
{"type": "resize", "cols": 120, "rows": 40}

接收(后端 → 前端)

  • SSH stdout/stderr → 二进制字节流bytes
  • 错误消息 → {"type": "error", "message": "..."}

安全设计

  • DANGEROUS_COMMANDS 黑名单:绝对不允许执行的命令
  • COMMAND_WHITELIST_PREFIXES:正式上线后可启用白名单模式
  • 开发阶段白名单为空 = 不做命令过滤 · 方便调试

🔧 部署步骤

Step 1 · 保存文件

复制代码 → VS Code 新建 ~/guanghu-dev/ghcs-mono/backend/terminal_router.py → ⌘S

Step 2 · 安装依赖

cd ~/guanghu-dev/ghcs-mono/backend
source venv/bin/activate
pip install websockets paramiko

Step 3 · 改 main.py

from api_keys import router as api_keys_router 下方加:

from terminal_router import router as terminal_router

app.include_router(api_keys_router) 下方加:

app.include_router(terminal_router)

Step 4 · 重启后端

pkill -f "uvicorn main:app"
uvicorn main:app --host 127.0.0.1 --port 5002 &

Step 5 · 验证

# 测试服务器列表接口
curl http://127.0.0.1:5002/ws/terminal/servers

预期返回:{"servers":[{"key":"ghcs","name":"GHCS部署台","host":"43.139.207.172"}]}