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

264 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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"}]}`