Source snapshot: 97d7f0fae96dc04b7ddad56fc1db6a108ed662cc [SEC-CLEAN] · pre-push-clean v1.0 · 109处敏感信息已自动转乱码
7.5 KiB
7.5 KiB
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"}]}