264 lines
7.5 KiB
Markdown
264 lines
7.5 KiB
Markdown
|
|
# 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"}]}`
|