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

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

```python
"""
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 消息格式

### 发送（前端 → 后端）

```json
{"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 · 安装依赖

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

### Step 3 · 改 [main.py](http://main.py)

在 `from api_keys import router as api_keys_router` 下方加：

```python
from terminal_router import router as terminal_router
```

在 `app.include_router(api_keys_router)` 下方加：

```python
app.include_router(terminal_router)
```

### Step 4 · 重启后端

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

### Step 5 · 验证

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

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