Merge pull request 'Dev qing' (#15) from dev_qing into main
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
commit
18387586a4
92 files changed
+10680
-1582
No files matched your search
@@ -0,0 +1,61 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.env
|
||||
.env/
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts
|
||||
*.bin
|
||||
*.o
|
||||
*.a
|
||||
*.out
|
||||
|
||||
# Documentation (exclude READMEs)
|
||||
*.md
|
||||
!README.md
|
||||
docs/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Data files (handled via volumes)
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
chroma/
|
||||
|
||||
# Other modules not needed for build
|
||||
bot4cj/
|
||||
core/
|
||||
gateway/
|
||||
lib*/
|
||||
waf/
|
||||
captcha/
|
||||
smtp/
|
||||
static/
|
||||
tests/
|
||||
3d/
|
||||
BotOps/
|
||||
pymsg/tests/
|
||||
@@ -0,0 +1,11 @@
|
||||
# AI Review 服务配置
|
||||
# 复制此文件为 ai_review/.env 并填入你的 API Key
|
||||
|
||||
# DeepSeek/OpenAI API 密钥
|
||||
OPENAI_API_KEY=your_api_key_here
|
||||
|
||||
# API Base URL(默认使用 DeepSeek)
|
||||
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||
|
||||
# 使用的模型名称
|
||||
MODEL_NAME=deepseek-chat
|
||||
@@ -1,3 +1,7 @@
|
||||
gateway/shufeng.ini
|
||||
core/Nidus.service.shufeng
|
||||
ai_review/.env
|
||||
confs/__pycache__/
|
||||
libhttp/test_server
|
||||
*.pyc
|
||||
__pycache__/
|
||||
+24
-1
@@ -58,4 +58,27 @@ BotOps/
|
||||
|
||||
与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释):
|
||||
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
|
||||
- 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong`
|
||||
- 接收:
|
||||
- `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源
|
||||
- `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程
|
||||
- `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 3000 字符)
|
||||
- `reasoning_end`(**思考结束**):思考结束,切换到正式回答
|
||||
- **顺序保证**:不预创建空 AI widget,按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),思考区域必然在 AI 正式回答**之前**
|
||||
- `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾(重置状态)
|
||||
- `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示
|
||||
- `info`(系统信息):过滤"Conversation started"/"对话开始"等隐式通知,避免污染聊天流
|
||||
- `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `command_result` / `connection_ack` / `notification` / `pong`
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.2
|
||||
- ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前
|
||||
- ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑
|
||||
|
||||
### v1.1
|
||||
- ✅ 支持 DeepSeek 思考模式展示:新增 `reasoning_start`/`reasoning`/`reasoning_end` 消息处理,思考内容以灰色斜体区域展示
|
||||
- ✅ 超长思考内容自动折叠(显示最后 3000 字符)
|
||||
- ✅ 过滤"Conversation started"/"对话开始"等隐式系统通知,避免在回答末尾显示无用信息
|
||||
|
||||
### v1.0
|
||||
- 初始版本:Markdown 增量流式渲染、任务独立侧栏、自动重连、快捷键支持
|
||||
+26
-10
@@ -58,6 +58,13 @@ class BotOpsApp(App):
|
||||
color: yellow;
|
||||
padding: 0 1;
|
||||
}
|
||||
.msg-reasoning {
|
||||
color: $text-muted;
|
||||
background: $surface;
|
||||
padding: 0 1;
|
||||
margin: 0 1;
|
||||
text-style: italic;
|
||||
}
|
||||
#status {
|
||||
height: 1;
|
||||
}
|
||||
@@ -140,9 +147,9 @@ class BotOpsApp(App):
|
||||
await self.notify_system("未连接到服务器,先 /connect", level="error")
|
||||
return
|
||||
await self.chat.add_user(text)
|
||||
await self.chat.start_ai() # 预建 AI 消息框,即时反馈
|
||||
await self.chat.start_ai() # 标记AI回复开始状态(不预创建空widget)
|
||||
if not await self.ws.send_message(text):
|
||||
await self.chat.finish_ai("") # 发送失败:移除预建的空消息框
|
||||
await self.chat.finish_ai("") # 发送失败:重置AI状态
|
||||
await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
|
||||
|
||||
# ── 快捷键 ────────────────────────────────────────────────────────────
|
||||
@@ -163,21 +170,27 @@ class BotOpsApp(App):
|
||||
if t == "chunk":
|
||||
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
||||
await self.chat.append_ai(content)
|
||||
elif t == "reasoning_start":
|
||||
# 开始深度思考,创建思考区域
|
||||
await self.chat.start_reasoning(content or "🤔 深度思考中...")
|
||||
elif t == "reasoning":
|
||||
# 思考内容增量
|
||||
stripped = content
|
||||
if stripped:
|
||||
await self.chat.append_reasoning(stripped)
|
||||
elif t == "reasoning_end":
|
||||
# 思考结束
|
||||
await self.chat.end_reasoning()
|
||||
elif t == "line":
|
||||
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
||||
stripped = content.strip()
|
||||
if not stripped:
|
||||
pass
|
||||
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")):
|
||||
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示
|
||||
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")):
|
||||
pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示
|
||||
else:
|
||||
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
|
||||
self.tasks.write(stripped)
|
||||
elif t == "reasoning":
|
||||
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
|
||||
stripped = content.strip()
|
||||
if stripped:
|
||||
self.tasks.write(f"💭 {stripped[-200:]}")
|
||||
elif t == "complete":
|
||||
# complete 携带完整全文,用于对齐已显示内容
|
||||
await self.chat.finish_ai(content)
|
||||
@@ -217,7 +230,10 @@ class BotOpsApp(App):
|
||||
elif t == "error":
|
||||
await self.notify_system(f"❌ {content}", level="error")
|
||||
elif t == "info":
|
||||
await self.notify_system(f"ℹ️ {content}")
|
||||
# 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了)
|
||||
skip_keywords = ("Conversation started", "对话开始", "🎭")
|
||||
if not any(k in content for k in skip_keywords):
|
||||
await self.notify_system(f"ℹ️ {content}")
|
||||
elif t == "command_result":
|
||||
await self.notify_system(content)
|
||||
elif t == "connection_ack":
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# BotOps 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `app.py`、`ws_client.py`、`commands.py`、`widgets/`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 1 | 0 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: WebSocket 客户端连接无证书验证/无认证
|
||||
- **位置**: `ws_client.py`
|
||||
- **问题**: BotOps 作为运维管理客户端,连接后端 WebSocket 时:
|
||||
- 若连接 aibox/core 的 WebSocket 管理接口,需要确认 TLS 证书验证是否启用
|
||||
- Token/认证凭据存储方式需审计
|
||||
- **修复建议**:
|
||||
1. 启用 TLS 证书验证,不使用 `verify=False`
|
||||
2. 认证 Token 安全存储(系统钥匙串而非明文配置文件)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | 命令执行权限 | BotOps 作为运维工具,可执行系统命令,需确认是否限制了可执行命令范围 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **桌面客户端架构**: 本地运行应用,天然隔离
|
||||
2. ✅ **WebSocket 通信**: 与后端实时通信
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 短期
|
||||
- [ ] 审计 ws_client.py 的 TLS 配置
|
||||
- [ ] 确认命令执行白名单
|
||||
+100
-23
@@ -1,8 +1,17 @@
|
||||
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
|
||||
|
||||
服务端协议(aibox/main.py output_callback 原样透传 msg_type):
|
||||
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append
|
||||
- complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序)
|
||||
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
|
||||
- chunk 增量片段(正式回答内容),直接 append 到 AI 消息
|
||||
- reasoning_start 思考开始信号,创建思考区域(在AI回答之前)
|
||||
- reasoning 思考内容增量片段,追加到思考区域
|
||||
- reasoning_end 思考结束信号
|
||||
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
|
||||
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
|
||||
|
||||
顺序保证:不预创建空AI widget,按消息到达顺序自然挂载。
|
||||
- 先到 reasoning_start → 思考区域挂在最后
|
||||
- 后到 chunk → AI回答挂在思考区域之后
|
||||
- 无思考直接 chunk → AI回答挂在最后
|
||||
"""
|
||||
from textual.containers import VerticalScroll
|
||||
from textual.widgets import Markdown, Static
|
||||
@@ -11,6 +20,8 @@ from textual.widgets import Markdown, Static
|
||||
class ChatView(VerticalScroll):
|
||||
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
|
||||
|
||||
不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载,
|
||||
天然保证思考过程在正式回答之前。
|
||||
滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长
|
||||
(流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚,
|
||||
下一条新消息再重新锚定。
|
||||
@@ -19,7 +30,10 @@ class ChatView(VerticalScroll):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._ai_widget: Markdown | None = None
|
||||
self._reasoning_widget: Static | None = None
|
||||
self._ai_text = ""
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True # 初始无活跃 AI 消息
|
||||
|
||||
@property
|
||||
def ai_text(self) -> str:
|
||||
@@ -38,20 +52,65 @@ class ChatView(VerticalScroll):
|
||||
self._anchor_bottom()
|
||||
|
||||
async def start_ai(self):
|
||||
"""预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。"""
|
||||
if self._ai_widget is not None:
|
||||
"""开始AI回复(标记状态,不预创建空widget)。
|
||||
|
||||
为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget,
|
||||
此时思考widget(如果有)已经先挂载,顺序自然正确。
|
||||
"""
|
||||
if not self._ai_finalized:
|
||||
return
|
||||
self._ai_text = ""
|
||||
self._ai_widget = Markdown("", classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._ai_finalized = False
|
||||
|
||||
async def start_reasoning(self, hint: str = "🤔 思考中..."):
|
||||
"""开始展示思考过程区域。直接挂载在当前末尾位置。"""
|
||||
if self._reasoning_widget is not None:
|
||||
return
|
||||
self._reasoning_text = ""
|
||||
self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False)
|
||||
await self.mount(self._reasoning_widget)
|
||||
self._anchor_bottom()
|
||||
|
||||
async def append_reasoning(self, delta: str):
|
||||
"""追加思考内容增量。"""
|
||||
if not delta:
|
||||
return
|
||||
if self._reasoning_widget is None:
|
||||
await self.start_reasoning()
|
||||
self._reasoning_text += delta
|
||||
# 限制思考内容显示长度(最多显示最后 3000 字符,避免思考过长占满屏幕)
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"💭 思考中...\n{display}")
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def end_reasoning(self):
|
||||
"""结束思考区域,更新提示文字。"""
|
||||
if self._reasoning_widget is not None and self._reasoning_text:
|
||||
# 思考结束后保持展示,更新标签为已完成
|
||||
display = self._reasoning_text
|
||||
if len(display) > 3000:
|
||||
display = "...(前略)\n" + display[-3000:]
|
||||
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
|
||||
# 思考区域保留显示,不删除
|
||||
|
||||
async def _ensure_ai_widget(self):
|
||||
"""确保AI widget存在(第一次chunk到来时创建)。"""
|
||||
if self._ai_widget is None:
|
||||
self._ai_widget = Markdown("", classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._anchor_bottom()
|
||||
|
||||
async def append_ai(self, delta: str):
|
||||
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
|
||||
if not delta:
|
||||
return
|
||||
if self._ai_widget is None:
|
||||
await self.start_ai()
|
||||
if self._ai_finalized:
|
||||
# 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据
|
||||
return
|
||||
# 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了)
|
||||
await self._ensure_ai_widget()
|
||||
self._ai_widget.append(delta)
|
||||
self._ai_text += delta
|
||||
# anchor 状态下容器会自动吸底;保险起见刷新后再滚一次
|
||||
@@ -60,31 +119,49 @@ class ChatView(VerticalScroll):
|
||||
async def finish_ai(self, full_text: str = ""):
|
||||
"""收尾一条 AI 消息。
|
||||
|
||||
- full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐;
|
||||
- full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。
|
||||
chunk 流式追加是内容权威来源,complete 只做收尾:
|
||||
- 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载)
|
||||
- widget 存在且有流式内容:保持已渲染内容不变,仅清空引用
|
||||
- widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充
|
||||
- widget 不存在(未收到任何 chunk)且有 full_text:补建
|
||||
- widget 不存在且无内容:正常无内容场景
|
||||
"""
|
||||
if self._ai_finalized:
|
||||
return
|
||||
w = self._ai_widget
|
||||
self._ai_widget = None
|
||||
if w is None:
|
||||
# 流式期间丢帧,只在 complete 收到全文:补建完整消息
|
||||
|
||||
if w is not None:
|
||||
if self._ai_text:
|
||||
# 有流式内容,chunk 已完整渲染,直接保留,不做替换
|
||||
pass
|
||||
elif full_text:
|
||||
# widget存在但没收到任何chunk(异常):用full_text填充
|
||||
w.update(full_text)
|
||||
self._ai_text = full_text
|
||||
else:
|
||||
# widget存在但没有任何输出:移除空框(正常不会走到这里,因为不预创建)
|
||||
await w.remove()
|
||||
else:
|
||||
# widget不存在(start_ai后没收到任何chunk),有full_text则补建
|
||||
if full_text:
|
||||
self._ai_text = full_text
|
||||
await self.mount(Markdown(full_text, classes="msg-ai"))
|
||||
self._ai_widget = Markdown(full_text, classes="msg-ai")
|
||||
await self.mount(self._ai_widget)
|
||||
self._ai_widget = None
|
||||
self._anchor_bottom()
|
||||
return
|
||||
if full_text and full_text != self._ai_text:
|
||||
# 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐
|
||||
await w.remove()
|
||||
self._ai_text = full_text
|
||||
await self.mount(Markdown(full_text, classes="msg-ai"))
|
||||
elif not full_text and not self._ai_text:
|
||||
# 预建后没有任何输出:移除空框
|
||||
await w.remove()
|
||||
|
||||
self._ai_text = ""
|
||||
self._reasoning_widget = None
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True
|
||||
self.call_after_refresh(self.scroll_end, animate=False)
|
||||
|
||||
async def reset(self):
|
||||
"""清空聊天区(/clear)。"""
|
||||
self._ai_widget = None
|
||||
self._reasoning_widget = None
|
||||
self._ai_text = ""
|
||||
self._reasoning_text = ""
|
||||
self._ai_finalized = True
|
||||
await self.remove_children()
|
||||
@@ -1,7 +1,7 @@
|
||||
# micro_servers
|
||||
|
||||
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。
|
||||
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。
|
||||
|
||||
## 架构总览
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
└──────────────────►│ ├─────────────►│─────────────────┘ │
|
||||
├────────────►│ └─────── HTTP /review ──────────────┘
|
||||
└────────────►│
|
||||
日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox ──(log_client, pymsg)──► db:8005 (Logs 表)
|
||||
日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs ──(log_client, pymsg)──► db:8005 (Logs 表)
|
||||
配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载)
|
||||
```
|
||||
|
||||
### 请求链路
|
||||
@@ -50,6 +51,7 @@
|
||||
| [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
|
||||
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
|
||||
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
|
||||
| [confs](confs/) | Python(pymsg) | 8010 | 统一配置服务,集中存放并分发各服务配置文件(pymsg 路由 get/list/reload/health),内存缓存 + mtime 自动重载,路径遍历防护 |
|
||||
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
|
||||
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
|
||||
|
||||
@@ -92,7 +94,7 @@
|
||||
- **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。
|
||||
- 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。
|
||||
- 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。
|
||||
- 配置见 [static/config.ini](static/config.ini)(注:CORS 白名单硬编码于 `fsys.py`,config.ini 的 cors 段未生效)。
|
||||
- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);本地 [static/config.ini](static/config.ini) 作为 confs 不可用时的 fail-safe 回退。
|
||||
|
||||
### dnfs(树状菜单服务)
|
||||
|
||||
@@ -140,6 +142,13 @@
|
||||
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。
|
||||
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
|
||||
|
||||
### confs(统一配置服务)
|
||||
|
||||
- 集中存放各微服务的配置文件(`confs/configs/`),基于 pymsg(RpcServer,TCP 长度前缀帧 + JSON)分发,监听 127.0.0.1:8010。
|
||||
- pymsg 路由:`get`(取配置文件全文;带 `key` 时按键提取,ini 用 `Section.option`、json 用点号路径)、`list`(列出全部配置及元信息)、`reload`(重载指定配置,缺省清空全部缓存)、`health`。
|
||||
- 内存缓存 + 文件 mtime 变化自动重载,改配置无需重启 confs;配置名校验(basename + realpath 前缀 + 扩展名白名单)防路径遍历。
|
||||
- 消费方约定:服务启动时用 `RpcClient(8010).call("get", {"name": "<服务名>.ini"})` 拉取配置,confs 不可用时回退本地配置文件(fail-safe)。目前 `static` 已接入。
|
||||
|
||||
### aibox(独立 AI 智能体系统)
|
||||
|
||||
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs 等主干微服务无调用关系。
|
||||
@@ -185,6 +194,7 @@
|
||||
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
|
||||
| 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
|
||||
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
|
||||
| 8010 | confs | 127.0.0.1 | 统一配置服务(pymsg/TCP,仅内部调用) |
|
||||
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
|
||||
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
|
||||
|
||||
@@ -218,7 +228,7 @@ bash _setup.deb.sh # Debian/Ubuntu
|
||||
# 或
|
||||
bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora
|
||||
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> core)
|
||||
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> captcha -> confs -> core)
|
||||
bash _inst.sh
|
||||
```
|
||||
|
||||
@@ -231,7 +241,7 @@ bash _run.sh # 运行
|
||||
|
||||
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。
|
||||
|
||||
> **注意**:`ai_review` 与 `captcha` 未包含在 `_inst.sh` 中,需分别进入 `ai_review/`、`captcha/` 目录执行 `_set.sh` 安装。其中 `ai_review` 需额外配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需进入 `ai_review/` 目录执行 `_set.sh` 安装,并额外配置 `.env` 文件(DeepSeek API 密钥等)。
|
||||
|
||||
> **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。
|
||||
|
||||
@@ -240,8 +250,9 @@ bash _run.sh # 运行
|
||||
由 `core` 守护的子进程通过 `init.txt` 自启动(顺序很重要,waf 必须先于 gateway):
|
||||
|
||||
```
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/db/_run.sh
|
||||
bash /opt/micro_servers/confs/_run.sh
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/dnfs/_run.sh
|
||||
bash /opt/micro_servers/smtp/_run.sh
|
||||
bash /opt/micro_servers/static/_run.sh
|
||||
|
||||
@@ -35,4 +35,6 @@ inst_module "static"
|
||||
inst_module "dnfs"
|
||||
inst_module "db"
|
||||
inst_module "smtp"
|
||||
inst_module "captcha"
|
||||
inst_module "confs"
|
||||
inst_module "core" # core last
|
||||
@@ -0,0 +1,18 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
chroma/
|
||||
.DS_Store
|
||||
@@ -0,0 +1,43 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="ai_review"
|
||||
LABEL description="AI内容审核服务 - DeepSeek + RAG"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装依赖(ai_review不依赖pymsg)
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 复制应用代码
|
||||
COPY . /app/
|
||||
|
||||
# 创建ChromaDB持久化目录
|
||||
RUN mkdir -p /app/chroma
|
||||
VOLUME ["/app/chroma"]
|
||||
|
||||
# 创建非root用户
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8007
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)" || exit 1
|
||||
|
||||
# 默认环境变量
|
||||
ENV AI_REVIEW_HOST=0.0.0.0 \
|
||||
AI_REVIEW_PORT=8007 \
|
||||
CHROMA_PERSIST_DIR=/app/chroma
|
||||
|
||||
CMD ["python", "ai_review.py"]
|
||||
@@ -0,0 +1,75 @@
|
||||
# ai_review 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `ai_review.py`、`agent/`、`security_middleware.py`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 1 | 0 | 1 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: 所有接口无认证,存在 API 滥用和 RAG 投毒风险
|
||||
- **位置**: `ai_review.py` Flask 路由
|
||||
- **问题**:
|
||||
- `/review`、`/rag/feedback`、`/rag/list` 等接口均无认证
|
||||
- 虽然绑定 127.0.0.1,但任何本地进程/用户可调用
|
||||
- **RAG 投毒风险**:`/rag/feedback` 可向知识库注入恶意样本,绕过内容审核
|
||||
- **API 滥用**:无限制调用 DeepSeek API,消耗费用
|
||||
- **修复建议**:
|
||||
1. `/rag/feedback` 必须添加认证(仅管理员/internal 调用)
|
||||
2. `/review` 添加上游服务认证(共享密钥)
|
||||
3. 添加调用速率限制
|
||||
4. RAG 反馈需人工审核后才入库,而非直接写入
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | DeepSeek API Key 通过 .env 配置,但需确认未硬编码或提交 | 检查 .env 是否在 .gitignore 中 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | RAG 知识库文件无校验,损坏文件可能导致服务异常 |
|
||||
| P2-2 | 审核 prompt 可被注入(内容中包含"忽略之前指令"等越狱提示) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **fail-closed 设计**: API 异常或响应解析失败时一律返回 `{"invalid": "true"}`(拦截),宁可误拦不可漏放
|
||||
2. ✅ **RAG top-3 相似样本注入**: MD5 去重,提升审核准确率
|
||||
3. ✅ **审核类别覆盖**: 色情、违禁药品、赌博、诈骗、仇恨/暴力/恐怖主义
|
||||
4. ✅ **security_middleware.py**: 安全响应头
|
||||
5. ✅ **Docker 容器化**: Python 3.11-slim 基础镜像,端口绑定 127.0.0.1
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] `/rag/feedback` 添加认证,禁止未授权写入
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 所有接口添加内部服务认证
|
||||
- [ ] 调用速率限制
|
||||
- [ ] RAG 反馈改为人工审核入库
|
||||
|
||||
### 中期
|
||||
- [ ] Prompt 注入防护
|
||||
- [ ] RAG 知识库文件校验
|
||||
+127
-33
@@ -19,6 +19,8 @@
|
||||
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化
|
||||
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
|
||||
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
|
||||
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
|
||||
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
@@ -32,7 +34,7 @@
|
||||
|
||||
```bash
|
||||
# 克隆或下载代码后,安装依赖
|
||||
pip install websockets requests beautifulsoup4
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 配置
|
||||
@@ -46,18 +48,18 @@ export DEEPSEEK_API_KEY="your-api-key"
|
||||
|
||||
3. **启动服务器**:
|
||||
```bash
|
||||
python main.py
|
||||
python aibox.py
|
||||
```
|
||||
|
||||
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。
|
||||
|
||||
> 注意:本模块入口文件为 `main.py`(不是 `agent.py`)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`_run.sh` 为被 `core` 拉起的启动脚本。
|
||||
> 注意:本模块入口文件为 `aibox.py`(单文件约 8450 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`_run.sh` 为被 `core` 拉起的启动脚本。
|
||||
|
||||
## 📁 目录结构
|
||||
|
||||
```
|
||||
aibox/
|
||||
├── main.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统)
|
||||
├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统 + 任务调度器)
|
||||
├── client.py # 示例 WebSocket 客户端
|
||||
├── voice_client.py # 语音客户端
|
||||
├── entry.html # 前端入口页
|
||||
@@ -85,12 +87,22 @@ aibox/
|
||||
```
|
||||
~/.aibox/
|
||||
├── config.json # 配置文件
|
||||
├── prompts/ # 系统提示词目录
|
||||
│ ├── 00_IDENTITY.md # 用户身份信息
|
||||
│ ├── 1_*.md # 基础技能提示词(文件操作、网页访问等)
|
||||
├── prompts/ # 系统提示词目录(按文件名数字前缀排序加载)
|
||||
│ ├── 00_IDENTITY.md # 用户身份信息(最先加载)
|
||||
│ ├── 1_memory_search.md # 基础技能提示词(1-10,按数字升序加载)
|
||||
│ ├── 2_memory_save.md
|
||||
│ ├── 3_file_operations.md
|
||||
│ ├── 4_web_access.md
|
||||
│ ├── 5_memo_management.md
|
||||
│ ├── 6_command_execution.md
|
||||
│ ├── 7_extension_skills.md
|
||||
│ ├── 8_identity_management.md
|
||||
│ ├── 9_language_instruction.md
|
||||
│ ├── 10_task_handling.md
|
||||
│ ├── 99_SOUL.md # AI 灵魂定义(角色、性格、能力边界)
|
||||
│ └── 100_STATUS.md # AI 状态记录
|
||||
│ └── 100_STATUS.md # AI 状态记录(最后加载)
|
||||
├── skills/ # 扩展技能目录(运行时从仓库 skills/ 复制或用户自建)
|
||||
├── workspace/ # AI 专用工作目录(临时文件、脚本、数据文件默认存放位置)
|
||||
├── memories.db # 记忆和任务数据库(SQLite)
|
||||
├── current_conversation.json # 当前对话
|
||||
├── conversation_history.json # 对话索引
|
||||
@@ -98,6 +110,16 @@ aibox/
|
||||
└── tool_results/ # 工具结果缓存
|
||||
```
|
||||
|
||||
## 📂 工作目录规范
|
||||
|
||||
为避免 AI 产生的临时文件污染系统其他目录,所有 AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/` 目录:
|
||||
|
||||
- **文件操作**:`write_file` 工具默认路径为 `~/.aibox/workspace/`(用户明确指定路径时除外)
|
||||
- **命令执行**:`run_command` 工具默认 `work_dir` 为 `~/.aibox/workspace/`,命令产生的文件落在该专用工作区
|
||||
- **用户指定路径优先**:当用户明确要求在特定路径操作时,按用户指定路径执行
|
||||
- **读取不受限**:读取系统文件、项目文件时使用实际路径,不受工作目录限制
|
||||
- **技能调用**:`~/.aibox/skills/` 下的技能执行不受此限制
|
||||
|
||||
## 🔧 可用工具
|
||||
|
||||
AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,在 `DeepSeekChat` 中注册):
|
||||
@@ -192,15 +214,23 @@ const ws = new WebSocket('ws://localhost:8765');
|
||||
|
||||
| 类型 | 说明 |
|
||||
|-----|------|
|
||||
| `chunk` | AI 响应的片段(流式输出) |
|
||||
| `complete` | AI 响应完成 |
|
||||
| `reasoning` | AI 思考过程(思考模式) |
|
||||
| `error` | 错误信息 |
|
||||
| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
|
||||
| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) |
|
||||
| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) |
|
||||
| `reasoning_end` | AI 思考结束信号(切换到正式回答) |
|
||||
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
|
||||
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) |
|
||||
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
|
||||
| `command_result` | 命令执行结果 |
|
||||
| `task_status` | 任务状态更新(executing/complete/failed) |
|
||||
| `task_process` | 任务执行过程中的 AI 输出 |
|
||||
| `task_error` | 任务执行错误 |
|
||||
| `task_added` | 任务添加成功 |
|
||||
| `dialogue_ended` | 对话结束通知 |
|
||||
| `dialogue_reset` | 对话重置完成 |
|
||||
| `notification` | 通知消息 |
|
||||
| `connection_ack` | 连接确认 |
|
||||
| `interrupt_ack` | 中断确认 |
|
||||
| `info` | 系统信息 |
|
||||
| `pong` | Ping 响应 |
|
||||
|
||||
@@ -358,15 +388,17 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
|
||||
## 🧩 提示词系统
|
||||
|
||||
提示词文件位于 `~/.aibox/prompts/`:
|
||||
提示词文件位于 `~/.aibox/prompts/`,按文件名**数字前缀升序**加载(identity → 技能 1~10 → soul → status):
|
||||
|
||||
| 文件 | 用途 | 更新方式 |
|
||||
|-----|------|---------|
|
||||
| `00_IDENTITY.md` | 用户身份信息 | AI 通过 `update_identity` 工具更新 |
|
||||
| `1_*.md` | 基础技能提示词 | 手动编辑,AI 通过 `reload_prompts` 重载 |
|
||||
| `99_SOUL.md` | AI 灵魂定义(角色、性格) | AI 通过 `update_soul` 工具更新 |
|
||||
| `N_*.md`(N=1~10) | 基础技能提示词(记忆搜索/保存、文件/网页/命令/任务操作、技能扩展、身份管理、语言、任务规则) | 手动编辑,AI 通过 `/reload_prompts` 重载 |
|
||||
| `99_SOUL.md` | AI 灵魂定义(角色、性格、能力边界、行为准则) | AI 通过 `update_soul` 工具更新 |
|
||||
| `100_STATUS.md` | AI 状态记录 | AI 通过 `save_memory_and_end_conversation` 更新 |
|
||||
|
||||
> **加载顺序保证**:`00_IDENTITY.md` 最先加载(AI 先知道用户是谁),然后是 1~10 号技能提示词(按数字升序),接着是 `99_SOUL.md`(AI 的角色定位),最后是 `100_STATUS.md`(当前状态)。每轮用户输入前会附加一段简洁的工作流程提醒(不重复 system prompt 中的详细说明),工具调用后递归继续时不再重复注入。
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
主要配置项(`~/.aibox/config.json`,首次启动自动生成):
|
||||
@@ -385,7 +417,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
"websocket_host": "localhost",
|
||||
"websocket_port": 8765,
|
||||
"skills_dir": "~/.aibox/skills",
|
||||
"prompts_dir": "~/.aibox/prompts"
|
||||
"prompts_dir": "~/.aibox/prompts",
|
||||
"workspace_dir": "~/.aibox/workspace"
|
||||
},
|
||||
"scheduler": {
|
||||
"enabled": true,
|
||||
@@ -396,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
"api": {
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"reasoning_enabled": false,
|
||||
"api_key_env": "DEEPSEEK_API_KEY",
|
||||
"timeout": 30,
|
||||
"timeout": 120,
|
||||
"temperature": 0.7
|
||||
},
|
||||
"context": {
|
||||
@@ -441,7 +475,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
"archive_dir": "archive"
|
||||
},
|
||||
"tools": {
|
||||
"max_result_length": 10000,
|
||||
"max_result_length": 1048576,
|
||||
"save_result_to_file": false,
|
||||
"tool_results_dir": "tool_results"
|
||||
},
|
||||
@@ -461,12 +495,14 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
}
|
||||
```
|
||||
|
||||
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
||||
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
|
||||
>
|
||||
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
||||
|
||||
## 🛠️ 命令行参数
|
||||
|
||||
```bash
|
||||
python main.py [选项]
|
||||
python aibox.py [选项]
|
||||
|
||||
选项:
|
||||
--verbose, -v 显示详细日志
|
||||
@@ -487,19 +523,19 @@ python main.py [选项]
|
||||
|
||||
```bash
|
||||
# 启动服务器(详细日志)
|
||||
python main.py --verbose
|
||||
python aibox.py --verbose
|
||||
|
||||
# 指定端口
|
||||
python main.py --port 9000
|
||||
python aibox.py --port 9000
|
||||
|
||||
# 检查任务
|
||||
python main.py --check-memo
|
||||
python aibox.py --check-memo
|
||||
|
||||
# 添加任务(命令行)
|
||||
python main.py --memo-add "提醒开会|下午3点会议室A|+1h"
|
||||
python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h"
|
||||
|
||||
# 查看所有任务
|
||||
python main.py --memo-list
|
||||
python aibox.py --memo-list
|
||||
```
|
||||
|
||||
## 🔄 架构流程
|
||||
@@ -539,9 +575,8 @@ python main.py --memo-list
|
||||
### 普通对话流程
|
||||
```
|
||||
Client -> Server: {type: "message", content: "帮我查天气"}
|
||||
Server -> Client: {type: "chunk", content: "正在查"}
|
||||
Server -> Client: {type: "chunk", content: "询天气..."}
|
||||
Server -> Client: {type: "tool_call", tool: "search_skills", args: {keyword: "天气"}}
|
||||
Server -> Client: {type: "line", content: "🔧 调用工具: search_skills\n"}
|
||||
Server -> Client: {type: "line", content: "🔧 调用工具: bing_search\n"}
|
||||
Server -> Client: {type: "chunk", content: "今日天气晴朗..."}
|
||||
Server -> Client: {type: "complete", content: "今日天气晴朗,温度25°C"}
|
||||
```
|
||||
@@ -569,10 +604,10 @@ Server -> Client: {type: "dialogue_reset", content: "✅ 对话状态已完全
|
||||
|
||||
### 添加新工具
|
||||
|
||||
1. 继承 `Tool` 基类(位于 `main.py`)
|
||||
1. 继承 `Tool` 基类(位于 `aibox.py`)
|
||||
2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
|
||||
3. 可选实现 `execute_streaming()` 支持流式输出
|
||||
4. 在 `DeepSeekChat` 的工具注册处注册
|
||||
3. 可选实现 `execute_streaming()` 支持流式输出(通过 `output_queue` 发送 `("line", content)` 和 `("complete", final_result)`)
|
||||
4. 在 `DeepSeekChat._register_tools()` 中注册
|
||||
|
||||
```python
|
||||
class MyTool(Tool):
|
||||
@@ -593,6 +628,11 @@ class MyTool(Tool):
|
||||
return f"执行结果: {param}"
|
||||
```
|
||||
|
||||
> **注意**:`execute()` 必须返回字符串。工具结果会经过多层安全防护:
|
||||
> 1. **工具层源头限制**:`read_file` 按需读取不全载(最多500行,单行≤10KB),`run_command` 输出≤1MB(超出立即终止进程)
|
||||
> 2. **`_sanitize_tool_result()`**:None/非字符串转为安全字符串,过滤控制字符
|
||||
> 3. **最终兜底截断**:超过 `tools.max_result_length`(默认1MB字符)时截断并提示,避免破坏 API 请求格式
|
||||
|
||||
### 添加新命令
|
||||
|
||||
在 `WebSocketHandler` 的命令处理分支(`handle_command`)中添加:
|
||||
@@ -603,6 +643,10 @@ elif command == "/mycommand":
|
||||
await self.send_message(websocket, {"type": "command_result", "content": result})
|
||||
```
|
||||
|
||||
### 错误恢复机制
|
||||
|
||||
`think_and_respond()` 在每轮对话开始前记录消息列表长度 `msgs_before_turn`。若 API 请求失败(网络错误、HTTP 错误、JSON 解析错误或其他异常),会自动调用 `_rollback_messages(msgs_before_turn)` 回滚本轮添加的所有消息(包括 user 消息、可能的 assistant/tool_calls 消息、tool 响应消息),防止格式错误的消息污染对话历史导致后续请求持续失败。客户端收到 `error` 消息后可安全重试。
|
||||
|
||||
## ❓ 常见问题
|
||||
|
||||
### Q: API Key 无效怎么办?
|
||||
@@ -648,15 +692,65 @@ elif command == "/mycommand":
|
||||
|
||||
### Q: WebSocket 连接断开?
|
||||
**A:**
|
||||
1. 检查服务器是否运行:`ps aux | grep main.py`
|
||||
1. 检查服务器是否运行:`ps aux | grep aibox.py`
|
||||
2. 确认端口未被占用:`netstat -tlnp | grep 8765`
|
||||
3. 检查防火墙设置
|
||||
4. 查看服务器日志:`tail -f ~/.aibox/ai.log`
|
||||
5. 考虑使用 `--host 0.0.0.0` 允许外部连接
|
||||
|
||||
### Q: 工具调用后 API 报错无法继续?
|
||||
**A:** 此问题已在 v2.1 修复:工具结果会经过安全清洗(控制字符过滤、非字符串转换、超长截断),API 请求失败时自动回滚本轮消息。如果仍出现问题:
|
||||
1. 查看日志:`tail -f ~/.aibox/ai.log`
|
||||
2. 使用 `/new` 命令重置对话
|
||||
3. 确认工具返回值为字符串且不含二进制/控制字符
|
||||
|
||||
## 📄 更新日志
|
||||
|
||||
### v2.0 (当前版本)
|
||||
### v2.5
|
||||
- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode):
|
||||
- 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数
|
||||
- 使用 `deepseek-v4-flash` 模型原生思考能力,**思考模式下完整支持工具调用**(文件操作、命令执行、记忆搜索等均可正常使用)
|
||||
- 实时展示 AI 思考过程:新增 `reasoning_start`/`reasoning`/`reasoning_end` WebSocket 消息类型
|
||||
- 思考内容在前端以灰色斜体区域独立展示,超长思考自动折叠显示最后 2000 字符
|
||||
- 即使未启用思考模式,若模型返回 `reasoning_content` 也会自动实时展示
|
||||
- API 超时默认从 30s 提高到 120s,适配思考模式更长的响应时间
|
||||
- ✅ 删除重复定义的 `_add_assistant_message_with_reasoning` 方法
|
||||
|
||||
### v2.4
|
||||
- ✅ **新增专用工作目录规范**:
|
||||
- AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录
|
||||
- `write_file` 工具默认路径设为工作目录(用户明确指定路径时除外)
|
||||
- `run_command` 工具默认 `work_dir` 设为工作目录,命令产生的文件落在专用工作区
|
||||
- 新增 `system.workspace_dir` 配置项(默认 `~/.aibox/workspace`),可自定义工作目录位置
|
||||
- 在文件操作和命令执行提示词中明确工作目录规范
|
||||
- ✅ 命令执行提示词补充 1MB 输出大小限制说明
|
||||
|
||||
### v2.3
|
||||
- ✅ **修复工具输出OOM崩溃问题**:
|
||||
- `read_file`:不再一次性 `readlines()` 加载整个文件,改为按行范围按需读取;大文件 `tail=true` 使用反向块读取(>1MB文件不全文遍历);新增单行长度限制(10KB/行,超长截断);`max_lines` 硬上限500行防止请求超大范围
|
||||
- `run_command`:非流式执行从 `capture_output=True` 改为逐行读取并实时计数,输出达到1MB立即终止进程;流式执行同样增加1MB输出大小限制,超出终止并提示截断;统一stdout/stderr合并计数
|
||||
- `tools.max_result_length` 默认值从10KB提高到1MB字符,作为最终兜底截断
|
||||
- 形成"工具层源头限制→结果清洗→最终兜底"三层防护,彻底防止大文件/大输出导致内存溢出
|
||||
|
||||
### v2.2
|
||||
- ✅ 修复提示词系统排序 bug:`SKILL_PREFIX = "1_"` 只匹配 `1_` 开头文件,导致 `2_`~`10_` 技能提示词排序错乱(`10_task_handling.md` 排在 `1_memory_search.md` 前面);改用正则按数字前缀排序
|
||||
- ✅ 修复 `_init_system_prompts` 使用未排序列表的问题,确保 system prompt 按 identity → 技能 → soul → status 正确顺序加载
|
||||
- ✅ 精简每轮用户输入的 pre_prompt:原来约 30 行重复指令(与 system prompt 内容重复),每轮浪费 ~300 token;精简为 5 条简洁提醒
|
||||
- ✅ 修复工具调用后递归继续时重复注入工作流指令的问题:新增 `is_continuation` 参数,递归时跳过 pre_prompt
|
||||
- ✅ 优化默认大文件阈值:50KB/200行 → 500KB/2000行,增加 `tail=true` 和二进制文件处理指导
|
||||
- ✅ 优化记忆搜索习惯:"每次都要做"→"根据话题相关性判断",避免简单问候也触发无意义搜索
|
||||
|
||||
### v2.1
|
||||
- ✅ 修复工具调用结果破坏对话导致 API 持续请求失败的问题
|
||||
- 不再回传 `reasoning_content`(API 响应字段)到请求中
|
||||
- `tool_call.id` 为空时自动生成 fallback id,防止 API 400
|
||||
- 新增 `_sanitize_tool_result()`:None/非字符串安全转换、控制字符过滤、超长结果截断
|
||||
- `execute_tool` 异常捕获,避免工具执行异常中断整个对话
|
||||
- API 请求失败时自动回滚本轮消息(`_rollback_messages`),防止坏消息污染历史
|
||||
- ✅ 修复 `complete` 消息语义:chunk 流式为内容权威来源,complete 仅用于收尾
|
||||
- ✅ 修正文档:入口文件为 `aibox.py`(非 `main.py`),补充缺失的消息类型(`line`/`task_error`/`notification` 等)
|
||||
|
||||
### v2.0
|
||||
- ✅ 新增 `update_soul` 工具(智能体自我进化)
|
||||
- ✅ 新增 `create_skill` 工具(AI 自主创建技能)
|
||||
- ✅ 新增 `install_skill` 工具(技能安装指引)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# aibox 模块安全审计报告
|
||||
|
||||
**审计日期:** 2026-08-01
|
||||
**审计文件:** [aibox.py](file:///Users/qingsong/micro_servers/aibox/aibox.py)
|
||||
**审计范围:** AI引擎、工具系统、WebSocket服务、任务调度器、数据库
|
||||
|
||||
---
|
||||
|
||||
## 问题严重等级定义
|
||||
|
||||
| 等级 | 颜色 | 说明 | 响应时间 |
|
||||
|------|------|------|----------|
|
||||
| P0 | 🔴 严重 | 可直接被利用获取系统权限或造成服务完全不可用 | 立即修复 |
|
||||
| P1 | 🟠 高危 | 可被利用获取敏感数据或造成部分功能不可用 | 24小时内修复 |
|
||||
| P2 | 🟡 中危 | 存在安全隐患但利用难度较高或影响有限 | 一周内修复 |
|
||||
| P3 | 🟢 低危 | 健壮性/信息泄露类问题,风险较低 | 版本迭代时修复 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 严重问题
|
||||
|
||||
### 1. 命令注入漏洞
|
||||
- **位置:** `execute_local_command` 工具实现 (~L2670)
|
||||
- **风险描述:**
|
||||
```python
|
||||
# 当前危险实现模式
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
```
|
||||
使用 `shell=True` 并通过 f-string 直接拼接用户输入的命令,攻击者(或被诱导的AI)可以构造恶意命令执行任意系统操作,例如:
|
||||
- `rm -rf /` 删除所有文件
|
||||
- `cat /etc/shadow` 窃取密码哈希
|
||||
- 反弹 shell 完全控制系统
|
||||
|
||||
- **修复建议:**
|
||||
1. 禁止 `shell=True`,改用 `subprocess.run([...], shell=False)`
|
||||
2. 将命令拆分为参数列表,使用 `shlex.split()` 解析或手动分割
|
||||
3. 添加命令白名单机制,只允许执行预定义的安全命令(ls, cat, grep等)
|
||||
4. 限制工作目录,禁止在敏感目录执行命令
|
||||
|
||||
---
|
||||
|
||||
### 2. WebSocket 无认证机制
|
||||
- **位置:** `WebSocketHandler.handle()` / `WebSocketServer` (~L6000+)
|
||||
- **风险描述:**
|
||||
- WebSocket 服务监听在 `0.0.0.0:8765`,公网可访问
|
||||
- 无任何 token、密码或 session 认证
|
||||
- 任何人连接后可无限制使用 DeepSeek API(消耗费用)
|
||||
- 结合工具调用漏洞可直接控制服务器
|
||||
|
||||
- **修复建议:**
|
||||
1. **短期方案:** 将监听地址改为 `127.0.0.1`,仅允许本地访问,通过 gateway/WAF 统一接入并认证
|
||||
2. **长期方案:** WebSocket 握手阶段校验 token(支持 URL query 参数或 Authorization header)
|
||||
3. 参考现有 gateway 的认证机制(RSA签名/PBKDF2)做统一接入
|
||||
4. 添加 Origin 校验,防止跨站 WebSocket 劫持
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 高危问题
|
||||
|
||||
### 3. 路径遍历风险
|
||||
- **位置:** `read_file` / `write_file` / `append_file` 等文件操作工具
|
||||
- **风险描述:**
|
||||
- 只有 `read_memory` 实现了 `safe_path` 校验
|
||||
- 其他文件工具未限制可访问目录,通过 `../../etc/passwd` 可读取系统敏感文件
|
||||
- 写入工具可覆盖任意文件,破坏系统或植入后门
|
||||
|
||||
- **修复建议:**
|
||||
- 定义允许访问的根目录(如 `~/.aibox/workspace/`)
|
||||
- 统一实现 `safe_path()` 函数,校验后规范化路径,确保在允许目录内
|
||||
- 对写入操作添加额外确认或权限控制
|
||||
|
||||
---
|
||||
|
||||
### 4. SSRF 漏洞风险
|
||||
- **位置:** `web_search` / `fetch_url` / 网络请求类工具
|
||||
- **风险描述:**
|
||||
- 未限制可访问的 IP 范围
|
||||
- 可访问内网服务:`http://127.0.0.1:8443`(gateway)、`http://10.x.x.x` 等
|
||||
- 可访问云服务商元数据接口(如 `http://169.254.169.254/` 获取临时凭证)
|
||||
|
||||
- **修复建议:**
|
||||
- 禁止访问内网 IP 段:`127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`
|
||||
- 禁止访问非 HTTP/HTTPS 协议
|
||||
- 跟随重定向时重新校验目标地址
|
||||
|
||||
---
|
||||
|
||||
### 5. 敏感信息暴露
|
||||
- **位置:** 系统提示词构建 / `tools_info`
|
||||
- **风险描述:**
|
||||
- 系统提示词中包含所有工具的完整参数 schema
|
||||
- 错误响应可能返回栈追踪等内部信息
|
||||
- 工具返回的原始错误信息可能泄露路径、代码结构
|
||||
|
||||
- **修复建议:**
|
||||
- 精简工具描述,只暴露必要信息
|
||||
- 对外返回的错误信息做脱敏处理,仅返回通用错误提示
|
||||
- 详细错误只记录在服务端日志
|
||||
|
||||
---
|
||||
|
||||
### 6. API Key 配置校验缺失
|
||||
- **位置:** `ConfigManager` 初始化
|
||||
- **风险描述:**
|
||||
- 配置中默认值是占位符,但启动时未校验
|
||||
- 可能因配置错误导致空 Key 或使用默认无效 Key
|
||||
- 没有 Key 时直接启动失败或行为异常
|
||||
|
||||
- **修复建议:**
|
||||
- 启动时强制校验 `DEEPSEEK_API_KEY` 配置
|
||||
- 未配置有效 Key 时直接拒绝启动并给出明确提示
|
||||
- 日志中不要打印完整 API Key
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 中危问题
|
||||
|
||||
### 7. 无请求频率限制
|
||||
- **位置:** WebSocket 消息处理
|
||||
- **风险:** 恶意用户可高频发送请求,导致 API 费用暴涨或服务拒绝
|
||||
- **建议:** 添加 per-connection 消息频率限制(如每分钟最多 30 轮对话)
|
||||
|
||||
### 8. 工具调用结果无大小限制
|
||||
- **位置:** `_sanitize_tool_result()`
|
||||
- **风险:** 读取大文件(如几GB的日志)可能导致内存溢出,服务崩溃
|
||||
- **建议:** 工具输出统一限制大小(如 1MB),超出截断并提示
|
||||
|
||||
### 9. 对话历史明文存储
|
||||
- **位置:** SQLite 数据库 `~/.aibox/memos.db`
|
||||
- **风险:** 聊天记录包含敏感信息,明文存储易被窃取
|
||||
- **建议:** 对敏感内容加密存储,或限制数据库文件权限
|
||||
|
||||
### 10. 临时文件清理不彻底
|
||||
- **位置:** 代码执行/命令执行工具
|
||||
- **风险:** 执行代码生成的临时文件未清理,可能占用磁盘空间或泄露信息
|
||||
- **建议:** 使用 `tempfile.TemporaryDirectory()` 自动清理
|
||||
|
||||
### 11. 技能文件加载无沙箱
|
||||
- **位置:** `load_skills()` 技能加载
|
||||
- **风险:** 可执行技能(`.sh`/`.py`)直接运行,无隔离或校验
|
||||
- **建议:** 技能目录做权限校验,仅加载可信来源技能,或在沙箱中执行
|
||||
|
||||
### 12. 日志未脱敏
|
||||
- **位置:** Logger 输出
|
||||
- **风险:** API 请求/响应、对话内容可能写入日志文件,泄露敏感信息
|
||||
- **建议:** 日志中脱敏 API Key、密码等敏感字段,对话内容可选记录
|
||||
|
||||
---
|
||||
|
||||
## 🟢 P3 低危/健壮性问题
|
||||
|
||||
### 13. 递归深度防护不足
|
||||
- **位置:** `think_and_respond()` 递归调用
|
||||
- **说明:** 虽然设置了 `max_recursion_depth=10`,但复杂工具链仍可能导致栈溢出
|
||||
- **建议:** 改用迭代而非递归,或添加更严格的深度检查
|
||||
|
||||
### 14. 对话压缩可能丢失上下文
|
||||
- **位置:** `_compress_conversation()`
|
||||
- **说明:** 当前压缩算法简单截断早期消息,可能丢失工具调用上下文
|
||||
- **建议:** 保留系统提示和工具调用/结果对,只压缩纯对话内容
|
||||
|
||||
### 15. 多线程竞争风险
|
||||
- **位置:** `EnhancedAIManager` 状态管理
|
||||
- **说明:** `messages`/`tools` 等状态在多连接下可能有竞争条件(当前单连接模型,但代码未防护)
|
||||
- **建议:** 必要时添加锁机制,或确保每个连接独立实例
|
||||
|
||||
### 16. 监听地址硬编码
|
||||
- **位置:** WebSocketServer 启动
|
||||
- **说明:** 当前默认 `0.0.0.0:8765`,建议改为配置项且默认 `127.0.0.1`
|
||||
|
||||
### 17. 缺少优雅关闭
|
||||
- **位置:** 信号处理
|
||||
- **说明:** 关闭时未确保消息队列刷新、数据库连接正确关闭
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有的良好安全实践
|
||||
|
||||
审计中发现以下安全措施已经实现:
|
||||
|
||||
1. ✅ **工具结果清洗:** `_sanitize_tool_result()` 过滤控制字符,确保工具返回值是合法字符串
|
||||
2. ✅ **消息回滚机制:** API 失败时 `_rollback_messages()` 回滚对话,防止坏消息污染历史
|
||||
3. ✅ **对话自动压缩:** Token/消息数超限自动压缩,防止上下文无限增长
|
||||
4. ✅ **pymsg 超时防护:** `recv_timeout=10s` 防止 Slowloris DoS 攻击
|
||||
5. ✅ **log_client 健壮性:** 双层异常保护、队列大小限制、Event 同步刷新
|
||||
6. ✅ **提示词软约束:** 系统提示词中明确要求 AI 不要执行危险命令(需配合代码层面硬约束)
|
||||
7. ✅ **提示词排序修复:** 数字前缀正则排序,确保技能提示正确加载顺序
|
||||
8. ✅ **内存搜索优化:** 基于主题相关性搜索,避免不必要的全库扫描
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 修复路线图
|
||||
|
||||
### 第一阶段(立即):修复 P0
|
||||
- [ ] 修复命令注入:移除 `shell=True`,改用参数列表,添加命令白名单
|
||||
- [ ] WebSocket 认证:默认绑定 127.0.0.1,通过 gateway 接入或添加 token 认证
|
||||
|
||||
### 第二阶段(近期):修复 P1
|
||||
- [ ] 统一文件操作 safe_path 校验,限制工作目录
|
||||
- [ ] 网络请求添加 SSRF 防护,禁止内网访问
|
||||
- [ ] 工具输出大小限制(1MB)
|
||||
- [ ] 启动时 API Key 配置校验
|
||||
|
||||
### 第三阶段(后续版本):P2/P3
|
||||
- [ ] 请求频率限制
|
||||
- [ ] 日志脱敏
|
||||
- [ ] 错误信息脱敏
|
||||
- [ ] 临时文件自动清理
|
||||
- [ ] 对话加密存储
|
||||
- [ ] 多线程安全加固
|
||||
- [ ] 优雅关闭
|
||||
|
||||
---
|
||||
|
||||
**审计完成时间:** 2026-08-01
|
||||
+516
-242
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,58 @@
|
||||
# aibox 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `aibox.py`、`skills/`、WebSocket 服务
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 2 | 2 | 0 |
|
||||
| P1 | 4 | 部分 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题(已修复)
|
||||
|
||||
### P0-1: 命令注入漏洞(已修复)
|
||||
- **原问题**: `skills/ssh-exec.py` 等工具使用 `shell=True` 拼接用户输入,导致任意命令执行
|
||||
- **修复状态**: ✅ 已修复(OOM防护 + 结果清洗)
|
||||
|
||||
### P0-2: WebSocket 无认证且监听 0.0.0.0(已修复)
|
||||
- **原问题**: WebSocket 服务无认证,监听所有网络接口,可被远程连接执行任意工具
|
||||
- **修复状态**: ✅ 已修复
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题(剩余)
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | 部分工具结果输出未完全过滤敏感信息 | 需确认所有 skill 工具输出是否清洗了密码、密钥、Token |
|
||||
| P1-2 | 本地工具调用权限控制 | nmap、ssh、camera 等危险工具应有调用白名单/管理员限制 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制(此前已修复)
|
||||
|
||||
1. ✅ **OOM 防护**: 工具执行输出限制大小,防止内存耗尽
|
||||
2. ✅ **工具结果清洗**: 过滤敏感信息输出
|
||||
3. ✅ **WebSocket 安全配置**: 已收敛监听范围
|
||||
4. ✅ **独立进程执行**: 工具在隔离进程中运行,不直接访问主服务内存
|
||||
5. ✅ **fail-closed 设计**: 工具异常时安全返回,不暴露内部状态
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 审计所有 skills/ 下工具的命令执行方式,确认无 shell=True 拼接
|
||||
- [ ] 危险工具(ssh、nmap、camera)添加权限控制
|
||||
- [ ] 工具结果敏感信息过滤全面验证
|
||||
|
||||
### 中期
|
||||
- [ ] 工具调用沙箱化(容器/受限用户)
|
||||
- [ ] 工具调用审计日志
|
||||
@@ -0,0 +1,2 @@
|
||||
# 数据库备份输出目录
|
||||
# 容器内备份会输出到此目录,可直接复制走
|
||||
+10
-42
@@ -21,7 +21,6 @@ captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import secrets
|
||||
@@ -34,7 +33,7 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -97,57 +96,24 @@ def generate_image(text):
|
||||
class CaptchaServer:
|
||||
"""基于 pymsg 的验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8009):
|
||||
def __init__(self, addr="127.0.0.1", port=8009, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"image": self._handle_image,
|
||||
"verify": self._handle_verify,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
self._server = RpcServer(addr, port, workers=workers)
|
||||
self._server.add_route("image", self._handle_image)
|
||||
self._server.add_route("verify", self._handle_verify)
|
||||
self._server.add_route("health", self._handle_health)
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[captcha] 监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@@ -209,9 +175,11 @@ if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description="captcha — pymsg 验证码服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8009, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="回调并发线程数(0=同步串行模式)")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = CaptchaServer(args.addr, args.port)
|
||||
server = CaptchaServer(args.addr, args.port, workers=args.workers)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# captcha 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `captcha_service.py`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 0 | 0 | 0 |
|
||||
| P1 | 1 | 0 | 1 |
|
||||
| P2 | 1 | 0 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | 验证码存储无容量限制,大量生成可耗尽内存 | 无验证码数量上限,攻击者可高频调用 `/captcha/image` 生成大量验证码填满内存 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | 内存存储不支持多实例水平扩展(需换 Redis),当前单实例可接受 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **一次性使用**: verify 后立即删除 Token
|
||||
2. ✅ **5 分钟过期**: 惰性清理
|
||||
3. ✅ **常数时间比较**: XOR 防时序攻击
|
||||
4. ✅ **仅监听 127.0.0.1**: 不对外暴露
|
||||
5. ✅ **gateway 层代理**: `Cache-Control: no-store` 禁止缓存验证码图片
|
||||
6. ✅ **依赖 pymsg 协议**: recv_timeout 防 Slowloris
|
||||
7. ✅ **图形验证码使用 PIL 生成**: 不依赖外部服务
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 短期
|
||||
- [ ] 添加验证码存储容量上限(如最多 10000 个,超出时清理最早的)
|
||||
- [ ] 添加生成速率限制(单 IP 每分钟最多生成 N 个)
|
||||
|
||||
### 中期
|
||||
- [ ] 考虑替换为 Redis 存储支持水平扩展(当前单实例足够)
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
# confs 统一配置服务(pymsg RpcServer/TCP)
|
||||
|
||||
集中存放各微服务的配置文件,通过 pymsg RPC 协议分发给内部服务,替代各服务本地散落的 ini/json/yaml 配置,实现配置的统一管理与下发。
|
||||
|
||||
服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8010`(TCP),仅允许本机调用。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **集中托管**:各微服务的配置文件统一存放于 `configs/` 目录,按配置名分发
|
||||
- **多格式支持**:`.ini` / `.conf` / `.cfg` / `.json` / `.yaml` / `.yml` / `.toml` / `.txt`
|
||||
- **内存缓存 + 自动重载**:文件 mtime 变化时自动重新读取,无需重启服务
|
||||
- **按键取值**:ini/conf/cfg 支持 `Section.option` 取单键,json 支持点号路径(含数字下标)
|
||||
- **路径遍历防护**:basename 校验 + realpath 前缀校验 + 扩展名白名单
|
||||
- **TOCTOU 防护**:所有文件系统操作(realpath → stat → open)在锁内原子完成,防止符号链接替换竞态
|
||||
- **缓存防污染**:`get()` 返回缓存条目的浅拷贝,调用方修改返回值不会影响内部缓存
|
||||
- **list 符号链接防护**:列出配置时同样解析 realpath 校验前缀,逃逸符号链接不会泄露外部文件元信息
|
||||
- **严格参数校验**:`name`/`key` 参数必须为字符串,空字符串/空白字符串/非字符串类型明确返回错误,不会产生误行为
|
||||
- **INI 安全解析**:禁用 ConfigParser 的 `%(key)s` 插值功能,配置值中包含裸 `%` 字符不会触发解析错误
|
||||
- **文件大小限制**:单配置文件上限 1 MiB,防止超大文件导致 OOM
|
||||
- **线程安全**:所有公开方法持锁,保证 `workers>0` 并发回调下的缓存一致性
|
||||
- **仅监听本机**:默认 `127.0.0.1:8010`,不对外暴露
|
||||
- **Slowloris 防护**:pymsg MsgServer 默认设置 10 秒接收超时,慢速半开连接自动断开
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Python 3
|
||||
- [pymsg](../pymsg/) —— TCP 长度前缀帧协议(`[4字节大端长度][UTF-8 JSON]`),上层提供 `RpcServer`/`RpcClient` 高层 JSON RPC 封装
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
./_set.sh # 建 venv 并安装 requirements.txt(本地 ../pymsg + nuitka)
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
# 方式一:通过启动脚本(推荐,由 core 守护时即使用此方式)
|
||||
cd confs && ./_run.sh
|
||||
|
||||
# 方式二:直接运行
|
||||
python3 confs.py # 默认 127.0.0.1:8010,配置目录 configs/
|
||||
python3 confs.py --addr 127.0.0.1 --port 8010 --workers 4 --dir configs/
|
||||
```
|
||||
|
||||
服务监听 `tcp://127.0.0.1:8010`,配置目录默认为同级的 `configs/`。生产环境由 core(Nidus)按 init.txt 统一拉起,守护靠 systemd。
|
||||
|
||||
## 协议与路由
|
||||
|
||||
服务基于 pymsg RPC 协议:客户端使用 `RpcClient.call(route, payload)` 传 dict,无需手动拼 `_route` 或手动 JSON 序列化/反序列化。响应格式与 db / smtp / captcha 一致:
|
||||
|
||||
```json
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
```
|
||||
|
||||
`RpcClient.call()` 成功时直接返回 `data` 字段的值(dict/str/None 等),失败时抛出 `RpcError`。
|
||||
|
||||
| 路由 | 说明 |
|
||||
| ------ | ------------------------------------------ |
|
||||
| `get` | 取配置文件全文;带 `key` 时返回单键值(仅 ini/json) |
|
||||
| `list` | 列出配置目录下全部配置文件及其元信息 |
|
||||
| `reload` | 重载指定配置;省略 `name`(或传 `null`)时清空全部缓存 |
|
||||
| `health` | 健康检查 |
|
||||
|
||||
### 1. get — 取配置
|
||||
|
||||
**请求**(取全文):
|
||||
|
||||
```python
|
||||
c.call("get", {"name": "example.ini"})
|
||||
```
|
||||
|
||||
**返回**(data 字段):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "example.ini",
|
||||
"content": "[server]\nhost = 127.0.0.1\nport = 8010\nratio = 80%\n",
|
||||
"size": 60,
|
||||
"mtime": "2026-08-01T10:00:00+00:00"
|
||||
}
|
||||
```
|
||||
|
||||
**请求**(按键取值,仅 ini/conf/cfg 与 json 支持):
|
||||
|
||||
```python
|
||||
c.call("get", {"name": "example.ini", "key": "server.port"})
|
||||
```
|
||||
|
||||
**返回**:
|
||||
|
||||
```json
|
||||
{"name": "example.ini", "key": "server.port", "value": "8010"}
|
||||
```
|
||||
|
||||
参数校验:
|
||||
- `name` 必填,必须为非空字符串;非字符串/空字符串返回 `ConfigError`
|
||||
- `key` 可选;若提供必须为非空字符串,非字符串/空字符串返回 `ConfigError`
|
||||
|
||||
> - ini/conf/cfg:键格式为 `Section.option`,返回字符串值;插值已禁用,`%` 字符原样返回
|
||||
> - json:键格式为点号路径(如 `server.port`),列表可用数字下标(如 `servers.0.host`),返回原始 JSON 值
|
||||
> - 其他类型(yaml/yml/toml/txt)不支持按键查询,需取全文后自行解析
|
||||
|
||||
### 2. list — 列出配置
|
||||
|
||||
**请求**:
|
||||
|
||||
```python
|
||||
c.call("list", {})
|
||||
```
|
||||
|
||||
**返回**:
|
||||
|
||||
```json
|
||||
{
|
||||
"config_dir": "/opt/micro_servers/confs/configs",
|
||||
"configs": [
|
||||
{"name": "example.ini", "size": 60, "mtime": "2026-08-01T10:00:00+00:00", "cached": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 逃逸到 `configs/` 目录外的符号链接不会出现在列表中。
|
||||
|
||||
### 3. reload — 重载配置
|
||||
|
||||
**重载指定配置**(立即重新读取文件):
|
||||
|
||||
```python
|
||||
c.call("reload", {"name": "example.ini"})
|
||||
```
|
||||
|
||||
**清空全部缓存**(下次访问惰性重载):
|
||||
|
||||
```python
|
||||
c.call("reload", {}) # 省略 name 字段
|
||||
# 或
|
||||
c.call("reload", {"name": None})
|
||||
```
|
||||
|
||||
**返回**:
|
||||
|
||||
```json
|
||||
{"reloaded": ["example.ini"]}
|
||||
```
|
||||
|
||||
参数校验:
|
||||
- 省略 `name` 或 `name` 为 `null`:清空全部缓存,返回被清除的配置名列表
|
||||
- `name` 为非字符串(数字、布尔、列表等):返回 `ConfigError("name 字段必须是字符串")`
|
||||
- `name` 为空字符串或纯空白:返回 `ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)")`
|
||||
- 指定合法 `name`:立即重新读取该配置并返回受影响列表
|
||||
|
||||
### 4. health — 健康检查
|
||||
|
||||
**请求**:
|
||||
|
||||
```python
|
||||
c.call("health", {})
|
||||
```
|
||||
|
||||
**返回**:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "confs",
|
||||
"config_dir": "/opt/micro_servers/confs/configs",
|
||||
"cached": 1,
|
||||
"uptime": 3600
|
||||
}
|
||||
```
|
||||
|
||||
### 调用示例(Python)
|
||||
|
||||
```python
|
||||
from pymsg import RpcClient, RpcError
|
||||
|
||||
c = RpcClient("127.0.0.1", 8010, "my-service")
|
||||
|
||||
try:
|
||||
# 取配置全文(c.call() 直接返回 data 字段,无需 resp["data"])
|
||||
entry = c.call("get", {"name": "static.ini"}, timeout=5)
|
||||
content = entry["content"]
|
||||
print(f"static.ini size={entry['size']}")
|
||||
|
||||
# 按键取值
|
||||
port = c.call("get", {"name": "static.ini", "key": "server.port"}, timeout=5)
|
||||
print(f"static server.port = {port}")
|
||||
|
||||
# 列出所有配置
|
||||
lst = c.call("list", {}, timeout=5)
|
||||
for cfg in lst["configs"]:
|
||||
print(cfg["name"], cfg["size"])
|
||||
|
||||
# 重载指定配置
|
||||
c.call("reload", {"name": "static.ini"})
|
||||
|
||||
# 清空全部缓存
|
||||
c.call("reload", {})
|
||||
|
||||
except RpcError as e:
|
||||
print(f"confs 调用失败: {e}")
|
||||
```
|
||||
|
||||
## 缓存机制
|
||||
|
||||
- **内存缓存**:每个配置首次读取后缓存于 `ConfigStore._cache`,结构为 `{name: {content, mtime, size}}`
|
||||
- **自动重载**:每次 `get` 时在锁内比对文件 mtime(通过 `os.stat()`),变化则重新读取并更新缓存
|
||||
- **手动重载**:`reload` 路由可指定单个配置立即重读,或清空全部缓存(下次访问惰性重载)
|
||||
- **返回值隔离**:`get()` 返回缓存条目的**浅拷贝**,调用方对返回 dict 的修改不会污染内部缓存
|
||||
- **线程安全**:所有缓存操作持 `threading.Lock`,并发安全
|
||||
|
||||
## 安全机制
|
||||
|
||||
- **扩展名白名单**:仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt`,其余一律拒绝
|
||||
- **路径遍历防护**:配置名必须等于 `os.path.basename(name)`,且 realpath 必须位于 `configs/` 目录前缀之下;符号链接会被 `os.path.realpath()` 解析后校验
|
||||
- **TOCTOU 防护**:所有文件系统操作(`realpath` → `stat` → `open`)均在持锁状态下原子完成,不存在"先校验后打开"的时间窗口,防止符号链接替换攻击
|
||||
- **文件大小限制**:单配置文件上限 1 MiB(`MAX_CONFIG_SIZE`),超过则拒绝读取并返回错误,防止超大文件导致内存溢出
|
||||
- **Slowloris 防护**:pymsg `MsgServer` 默认设置 10 秒接收超时,慢速发送的半开连接会被自动断开
|
||||
- **缓存防污染**:`get()` 返回浅拷贝,调用方无法通过修改返回值污染内部缓存
|
||||
- **list 符号链接防护**:列出配置时对每个文件做 realpath 前缀校验,逃逸到目录外的符号链接被跳过,不泄露外部文件元信息(大小/mtime)
|
||||
- **严格参数校验**:`name`/`key` 参数必须为非空字符串,类型错误或空值明确返回 `ConfigError`,不产生误行为(如空字符串误清空全部缓存)
|
||||
- **INI 安全解析**:使用 `ConfigParser(interpolation=None)` 禁用 `%(key)s` 插值,配置值中裸 `%` 字符不会触发 `InterpolationError`
|
||||
- **仅监听本机**:默认 `127.0.0.1`,不对外暴露,仅供内部服务调用
|
||||
- **无鉴权**:当前无身份校验,依赖"仅本机 + 内部网络"的信任边界;如需承载敏感配置(如 API 密钥),应额外评估鉴权方案
|
||||
|
||||
## 集成关系
|
||||
|
||||
- 由 `core`(`PMC`)通过 `init.txt` 中的 `bash /opt/micro_servers/confs/_run.sh` 拉起并守护(在 db 之后、ai_review 之前启动)
|
||||
- 各内部服务通过 pymsg `RpcClient` 调 `get` 路由按需获取配置,替代本地散落的 ini/json/yaml
|
||||
- 自身日志通过 `log_client.py` 异步推送至 `db:8005` 的 Logs 表(与其他服务一致)
|
||||
|
||||
### 已接入的服务
|
||||
|
||||
| 服务 | 配置文件 | 接入方式 |
|
||||
|------|---------|---------|
|
||||
| [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 |
|
||||
| [dnfs](../dnfs/) | [dnfs.ini](configs/dnfs.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 环境变量覆盖 |
|
||||
|
||||
static 的接入实现见 [static/fsys.py](../static/fsys.py):
|
||||
- `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时
|
||||
- `CONFS_HOST` / `CONFS_PORT` 环境变量定位 confs(默认 `127.0.0.1:8010`)
|
||||
- `_merge_ini()` 仅覆盖已知的 section/key(白名单合并,不引入未预期字段)
|
||||
- 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层
|
||||
|
||||
dnfs 的接入实现见 [dnfs/dnfs.py](../dnfs/dnfs.py):
|
||||
- 三层加载顺序:内置默认值 → confs(dnfs.ini)→ 环境变量覆盖(优先级最高)
|
||||
- 支持的环境变量:`LISTEN_ADDR`/`LISTEN_PORT`/`DB_HOST`/`DB_PORT`/`REVIEW_URL`/`CONFS_HOST`/`CONFS_PORT`
|
||||
- Docker 环境通过环境变量传入服务名(db/ai_review),覆盖 confs 中的 127.0.0.1 默认值
|
||||
|
||||
其他服务(gateway / waf / ai_review / smtp / db)尚未接入,仍读各自本地配置。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
confs/
|
||||
├── confs.py # 服务主程序(ConfigStore + ConfsServer)
|
||||
├── log_client.py # 统一日志客户端 SDK(与其他服务一致的副本)
|
||||
├── configs/ # 配置文件存放目录(被分发对象)
|
||||
│ ├── example.ini # 示例配置
|
||||
│ ├── static.ini # static 服务配置(已接入分发)
|
||||
│ └── dnfs.ini # dnfs 服务配置(已接入分发)
|
||||
├── docs/
|
||||
│ └── SECURITY_AUDIT_20260801.md # 安全审计报告
|
||||
├── _set.sh # 初始化脚本(建 venv + 安装依赖)
|
||||
├── _run.sh # 启动脚本(venv 中 exec python3 confs.py)
|
||||
├── requirements.txt # Python 依赖(本地 ../pymsg + nuitka)
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 配置文件需手动放入 `configs/` 目录(或用 `--dir` 指定其他目录),服务不会自动生成业务配置
|
||||
- 文件以 UTF-8 读取,非 UTF-8 文件会返回 `配置读取失败` 错误
|
||||
- 配置名区分大小写(与文件系统一致)
|
||||
- 单配置文件大小上限为 1 MiB(`MAX_CONFIG_SIZE`),超过此限制会返回 `配置文件过大` 错误,防止超大文件读入内存导致 OOM
|
||||
- 缓存基于文件 mtime 判断变化,若同秒内修改且 mtime 精度不足(如某些 FAT 文件系统),可能无法及时重载——可手动调 `reload` 路由强制刷新
|
||||
- `reload` 清空全部缓存的正确方式是**省略 `name` 字段**或传 `{"name": null}`;传空字符串 `""` 会返回错误而非清空缓存(防误操作)
|
||||
- INI 配置文件中 `%(xxx)s` 语法**不会**被当作插值引用,而是作为字面字符串返回;如需插值请在业务层自行处理
|
||||
- 调用方拿到 `get()` 返回的 dict 可以自由修改(如 `entry["content"] = "x"`),不会影响 confs 内部缓存
|
||||
- 当前接入情况见上文"集成关系 → 已接入的服务";新服务接入可参考 static 的三层 fail-safe 模式(内置默认 → confs 优先 → 本地回退)
|
||||
- 安全审计报告详见 [docs/SECURITY_AUDIT_20260801.md](docs/SECURITY_AUDIT_20260801.md)
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
source ./venv/bin/activate
|
||||
#exec ./smtp.bin
|
||||
sleep 2
|
||||
exec python3 confs.py
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
#nuitka --standalone confs.py
|
||||
@@ -0,0 +1,16 @@
|
||||
[server]
|
||||
host = 127.0.0.1
|
||||
port = 8006
|
||||
|
||||
[database]
|
||||
host = 127.0.0.1
|
||||
port = 8005
|
||||
timeout = 10
|
||||
|
||||
[ai_review]
|
||||
url = http://127.0.0.1:8007/review
|
||||
timeout = 10
|
||||
|
||||
[confs]
|
||||
host = 127.0.0.1
|
||||
port = 8010
|
||||
@@ -0,0 +1,10 @@
|
||||
# 示例配置 —— 统一配置服务 confs 的配置文件存放目录
|
||||
# 各微服务的配置文件集中放在本目录,由 confs 通过 pymsg get 路由分发给内部服务。
|
||||
# 支持类型:.ini / .conf / .cfg / .json / .yaml / .yml / .toml / .txt
|
||||
|
||||
[Server]
|
||||
address = 127.0.0.1
|
||||
port = 8010
|
||||
|
||||
[Feature]
|
||||
dark_mode = on
|
||||
@@ -0,0 +1,17 @@
|
||||
[server]
|
||||
host = 127.0.0.1
|
||||
port = 9200
|
||||
|
||||
[directory]
|
||||
root = ./public
|
||||
|
||||
[ssl]
|
||||
enabled = false
|
||||
cert_file = cert.pem
|
||||
key_file = key.pem
|
||||
|
||||
[cors]
|
||||
# 允许跨域的 Origin 白名单(逗号分隔)
|
||||
# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 *
|
||||
# 本地开发联调时可临时追加 http://localhost:9200,调试完务必移除
|
||||
allowed_origins = https://qsont.me,https://yhdet.top
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
"""
|
||||
confs — 统一配置服务(pymsg RpcServer/TCP,默认 127.0.0.1:8010)
|
||||
|
||||
集中存放各微服务的配置文件,通过 pymsg 高层 RPC 协议分发给内部服务,
|
||||
替代各服务本地散落的 ini/json/yaml 配置文件,实现配置的统一管理与下发。
|
||||
|
||||
客户端使用 RpcClient 直接传 dict,无需手动拼 _route / json.dumps / json.loads::
|
||||
|
||||
c = RpcClient("127.0.0.1", 8010, "my-service")
|
||||
c.call("get", {"name": "example.ini"})
|
||||
c.call("get", {"name": "example.ini", "key": "server.port"})
|
||||
c.call("list", {})
|
||||
c.call("reload", {"name": "example.ini"})
|
||||
c.call("health", {})
|
||||
|
||||
响应格式(与 db / smtp / captcha 一致)::
|
||||
|
||||
{"success": true, "data": {...}}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
配置文件存放于 confs/configs/ 目录(可用 --dir 覆盖),内存缓存 +
|
||||
文件 mtime 变化自动重载;配置名校验防路径遍历,所有文件系统操作在
|
||||
持锁状态下原子完成以防 TOCTOU 竞态。仅监听 127.0.0.1,不对外暴露。
|
||||
"""
|
||||
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# pymsg / log_client 导入:生产环境 pymsg 由 venv(pip install ../pymsg)提供;
|
||||
# 附加 ../pymsg 源码路径作为无 venv 时的开发兜底
|
||||
_here = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, os.path.join(_here, ".."))
|
||||
sys.path.insert(0, os.path.join(_here, "..", "pymsg"))
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
from log_client import logs # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
CONFIG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "configs")
|
||||
# 允许分发的配置文件类型(白名单)
|
||||
ALLOWED_EXT = {".ini", ".conf", ".cfg", ".json", ".yaml", ".yml", ".toml", ".txt"}
|
||||
# 单配置文件大小上限(1 MiB),防止超大文件读入内存导致 OOM
|
||||
MAX_CONFIG_SIZE = 1 * 1024 * 1024
|
||||
|
||||
|
||||
# ── 异常 ────────────────────────────────────────────────────────────────
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""配置服务业务错误(由 RpcServer 包装为 success=false 返回)。"""
|
||||
|
||||
|
||||
class ConfigNotFound(ConfigError):
|
||||
"""配置文件不存在。"""
|
||||
|
||||
|
||||
class ConfigNameInvalid(ConfigError):
|
||||
"""配置名非法(路径遍历或类型不允许)。"""
|
||||
|
||||
|
||||
class KeyNotFound(ConfigError):
|
||||
"""配置键不存在或键格式错误。"""
|
||||
|
||||
|
||||
def _iso(ts: float) -> str:
|
||||
"""时间戳转 ISO 8601(UTC,与 db 服务的日期序列化约定一致)。"""
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ConfigStore — 配置文件仓库
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ConfigStore:
|
||||
"""配置文件仓库:磁盘 configs/ 目录 + 内存缓存,mtime 变化自动重载。
|
||||
|
||||
所有公开方法持锁操作(配置文件小、内部调用频率低,简单持锁即可),
|
||||
保证 workers>0 并发回调下的缓存一致性。
|
||||
"""
|
||||
|
||||
def __init__(self, root: str):
|
||||
self.root = os.path.realpath(root)
|
||||
os.makedirs(self.root, exist_ok=True)
|
||||
self._cache = {} # name -> {"content": str, "mtime": float, "size": int}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _validate_name(name: str) -> None:
|
||||
"""纯字符串校验配置名(basename + 扩展名白名单),不访问文件系统。
|
||||
|
||||
用于锁外快速拒绝明显非法的请求;真实路径解析和文件存在性检查
|
||||
必须在持锁后通过 _resolve_locked() 完成,防止 TOCTOU 竞态。
|
||||
"""
|
||||
if not name or name != os.path.basename(name):
|
||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
raise ConfigNameInvalid(f"不支持的配置类型: {ext or '(无扩展名)'}")
|
||||
|
||||
def _resolve_locked(self, name: str) -> str:
|
||||
"""持锁状态下解析配置名到绝对路径,并校验路径安全性与文件存在性。
|
||||
|
||||
必须在 self._lock 持有期间调用;通过 os.path.realpath() 即时解析符号链接,
|
||||
确保从校验到打开文件之间不存在替换窗口,防御 TOCTOU 攻击。
|
||||
"""
|
||||
path = os.path.realpath(os.path.join(self.root, name))
|
||||
if not path.startswith(self.root + os.sep):
|
||||
raise ConfigNameInvalid(f"非法配置名: {name!r}")
|
||||
if not os.path.isfile(path):
|
||||
raise ConfigNotFound(f"配置不存在: {name}")
|
||||
return path
|
||||
|
||||
def get(self, name: str) -> dict:
|
||||
"""取配置内容(带缓存;文件 mtime 变化时自动重新读取)。
|
||||
|
||||
所有文件系统操作(realpath/stat/open)均在锁内完成,
|
||||
防止路径校验与文件读取之间的 TOCTOU 竞态。
|
||||
返回缓存条目的浅拷贝,防止调用方意外修改污染缓存。
|
||||
"""
|
||||
self._validate_name(name)
|
||||
with self._lock:
|
||||
path = self._resolve_locked(name)
|
||||
try:
|
||||
st = os.stat(path)
|
||||
mtime = st.st_mtime
|
||||
size = st.st_size
|
||||
except OSError:
|
||||
self._cache.pop(name, None)
|
||||
raise ConfigNotFound(f"配置不存在或不可读: {name}") from None
|
||||
entry = self._cache.get(name)
|
||||
if entry is not None and entry["mtime"] == mtime:
|
||||
return dict(entry)
|
||||
if size > MAX_CONFIG_SIZE:
|
||||
raise ConfigError(
|
||||
f"配置文件过大: {name}({size} 字节,上限 {MAX_CONFIG_SIZE} 字节)"
|
||||
)
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise ConfigError(f"配置读取失败: {name}({e})") from None
|
||||
entry = {"content": content, "mtime": mtime,
|
||||
"size": len(content.encode("utf-8"))}
|
||||
self._cache[name] = entry
|
||||
logs.info("confs", f"配置已加载 {name}({entry['size']}B)")
|
||||
return dict(entry)
|
||||
|
||||
def list(self) -> list:
|
||||
"""列出配置目录下全部允许类型的配置文件(持锁遍历,保证 _cache 访问一致)。
|
||||
|
||||
通过 os.path.realpath() 解析符号链接后校验前缀,防止逃逸到 configs/
|
||||
目录外的符号链接泄露外部文件元信息(大小/mtime)。
|
||||
"""
|
||||
out = []
|
||||
with self._lock:
|
||||
root_prefix = self.root + os.sep
|
||||
for name in sorted(os.listdir(self.root)):
|
||||
if os.path.splitext(name)[1].lower() not in ALLOWED_EXT:
|
||||
continue
|
||||
raw_path = os.path.join(self.root, name)
|
||||
# realpath 解析符号链接后校验前缀,逃逸的符号链接跳过
|
||||
try:
|
||||
path = os.path.realpath(raw_path)
|
||||
except OSError:
|
||||
continue
|
||||
if not path.startswith(root_prefix):
|
||||
continue
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
st = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
out.append({"name": name, "size": st.st_size,
|
||||
"mtime": _iso(st.st_mtime),
|
||||
"cached": name in self._cache})
|
||||
return out
|
||||
|
||||
def reload(self, name: "str | None" = None) -> list:
|
||||
"""重载配置:指定 name 立即重新读取;缺省清空全部缓存(下次访问惰性重载)。
|
||||
|
||||
Returns:
|
||||
受影响(重载/移出缓存)的配置名列表。
|
||||
"""
|
||||
if name is not None:
|
||||
self._validate_name(name)
|
||||
with self._lock:
|
||||
# 在锁内重新解析路径,防止 TOCTOU
|
||||
self._resolve_locked(name)
|
||||
self._cache.pop(name, None)
|
||||
self.get(name)
|
||||
return [name]
|
||||
with self._lock:
|
||||
dropped = list(self._cache.keys())
|
||||
self._cache.clear()
|
||||
return dropped
|
||||
|
||||
def cached_count(self) -> int:
|
||||
with self._lock:
|
||||
return len(self._cache)
|
||||
|
||||
|
||||
# ── 按键提取(可选功能) ────────────────────────────────────────────────
|
||||
|
||||
def _extract_key(name: str, content: str, key: str):
|
||||
"""从配置内容中提取单个键。
|
||||
|
||||
- ini/conf/cfg:键格式 ``Section.option``,返回字符串值;
|
||||
- json:键格式为点号路径(如 ``server.port``,列表可用数字下标),
|
||||
返回原始 JSON 值;
|
||||
- 其他类型不支持按键查询,需取全文后自行解析。
|
||||
"""
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
|
||||
if ext in (".ini", ".conf", ".cfg"):
|
||||
# interpolation=None:禁用 %(key)s 插值,避免配置值中裸 % 触发 InterpolationError
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read_string(content)
|
||||
except configparser.Error as e:
|
||||
raise ConfigError(f"INI 解析失败: {name}({e})") from None
|
||||
section, _, option = key.partition(".")
|
||||
if not section or not option:
|
||||
raise KeyNotFound(f"INI 键格式应为 Section.option: {key!r}")
|
||||
if parser.has_option(section, option):
|
||||
return parser.get(section, option)
|
||||
raise KeyNotFound(f"键不存在: {name} -> {key}")
|
||||
|
||||
if ext == ".json":
|
||||
try:
|
||||
node = json.loads(content)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigError(f"JSON 解析失败: {name}({e})") from None
|
||||
for part in key.split("."):
|
||||
if isinstance(node, dict) and part in node:
|
||||
node = node[part]
|
||||
elif isinstance(node, list) and part.isdigit() and int(part) < len(node):
|
||||
node = node[int(part)]
|
||||
else:
|
||||
raise KeyNotFound(f"键不存在: {name} -> {key}")
|
||||
return node
|
||||
|
||||
raise ConfigError(f"{ext} 类型不支持按键查询,请取全文后自行解析")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ConfsServer — 主服务类
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ConfsServer:
|
||||
"""统一配置服务:集中管理并向内部微服务分发配置文件。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8010, workers=4, config_dir=CONFIG_DIR):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._started_at = time.time()
|
||||
self.store = ConfigStore(config_dir)
|
||||
self._server = RpcServer(
|
||||
addr, port, workers=workers,
|
||||
on_error=lambda route, e: logs.error(
|
||||
"confs", f"路由 {route} 处理异常: {e}"),
|
||||
)
|
||||
self._server.add_route("get", self._handle_get)
|
||||
self._server.add_route("list", self._handle_list)
|
||||
self._server.add_route("reload", self._handle_reload)
|
||||
self._server.add_route("health", self._handle_health)
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[confs] 监听 tcp://{self.addr}:{self.port},配置目录 {self.store.root}")
|
||||
logs.info("confs", f"开始监听 tcp://{self.addr}:{self.port}"
|
||||
f"(配置目录 {self.store.root})")
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 路由处理 ─────────────────────────────────────────────────────
|
||||
|
||||
def _handle_get(self, req):
|
||||
"""取配置文件全文;带 key 时返回单个键的值(仅 ini/json)。"""
|
||||
name = str(req.get("name", "")).strip()
|
||||
if not name:
|
||||
raise ConfigError("缺少 name 字段")
|
||||
entry = self.store.get(name)
|
||||
|
||||
key = req.get("key")
|
||||
if key is None:
|
||||
logs.info("confs", f"分发配置 {name}({entry['size']}B)")
|
||||
return {"name": name, "content": entry["content"],
|
||||
"size": entry["size"], "mtime": _iso(entry["mtime"])}
|
||||
|
||||
if not isinstance(key, str):
|
||||
raise ConfigError("key 字段必须是字符串")
|
||||
key = key.strip()
|
||||
if not key:
|
||||
raise ConfigError("key 字段不能为空")
|
||||
value = _extract_key(name, entry["content"], key)
|
||||
logs.info("confs", f"分发配置键 {name} -> {key}")
|
||||
return {"name": name, "key": key, "value": value}
|
||||
|
||||
def _handle_list(self, _req):
|
||||
"""列出配置目录下全部配置文件及其元信息。"""
|
||||
return {"config_dir": self.store.root, "configs": self.store.list()}
|
||||
|
||||
def _handle_reload(self, req):
|
||||
"""重载指定配置;缺省 name(或 name 为 null)时清空全部缓存(下次访问惰性重载)。
|
||||
|
||||
注意:name 为空字符串、空白字符串或非字符串类型时返回错误,
|
||||
不再像旧版本那样将 falsy 值(""、0、False)误判为"清空全部缓存"。
|
||||
"""
|
||||
name = req.get("name")
|
||||
if name is None:
|
||||
names = self.store.reload(None)
|
||||
logs.info("confs", f"清空配置缓存({len(names)} 项)")
|
||||
return {"reloaded": names}
|
||||
if not isinstance(name, str):
|
||||
raise ConfigError("name 字段必须是字符串")
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ConfigError("name 字段不能为空(要清空全部缓存请省略 name 字段)")
|
||||
names = self.store.reload(name)
|
||||
logs.info("confs", f"重载配置 {names[0]}")
|
||||
return {"reloaded": names}
|
||||
|
||||
def _handle_health(self, _req):
|
||||
return {"status": "ok", "service": "confs",
|
||||
"config_dir": self.store.root,
|
||||
"cached": self.store.cached_count(),
|
||||
"uptime": int(time.time() - self._started_at)}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# 入口
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="confs — 统一配置服务(pymsg RpcServer/TCP)")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8010, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="回调并发线程数(0=同步串行模式)")
|
||||
parser.add_argument("--dir", default=CONFIG_DIR, help="配置文件目录")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = ConfsServer(args.addr, args.port,
|
||||
workers=args.workers, config_dir=args.dir)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
server.stop()
|
||||
@@ -0,0 +1,111 @@
|
||||
# confs 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `confs/confs.py`、`confs/log_client.py`、`confs/_run.sh`、`confs/_set.sh`、`pymsg/pymsg/rpc.py`(RPC 框架依赖层)
|
||||
**审计人**: AI Security Audit
|
||||
|
||||
---
|
||||
|
||||
## 一、模块概述
|
||||
|
||||
`confs` 是微服务架构中的统一配置分发服务,基于 `pymsg RpcServer` 在 `127.0.0.1:8010` 提供 TCP RPC 接口,集中管理各微服务的配置文件,通过内存缓存 + 文件 mtime 自动重载机制向内部服务分发配置。
|
||||
|
||||
**核心组件**:
|
||||
- `ConfigStore`:配置文件仓库(磁盘目录 + 内存缓存 + 自动重载)
|
||||
- `ConfsServer`:RPC 服务入口,路由分发
|
||||
- `_extract_key()`:INI/JSON 单键提取功能
|
||||
- `log_client.py`:异步日志推送客户端(副本,与其他服务同步)
|
||||
|
||||
---
|
||||
|
||||
## 二、已修复问题(本次审计周期内)
|
||||
|
||||
| # | 问题 | 级别 | 修复提交 | 状态 |
|
||||
|---|------|------|---------|------|
|
||||
| 1 | pymsg `recv()` 无超时导致主线程永久阻塞(Slowloris DoS) | **P0** | `9708fbc` | ✅ 已修复 |
|
||||
| 2 | 配置文件读取无大小限制,超大文件导致 OOM | **P1** | `da7f11d` | ✅ 已修复 |
|
||||
| 3 | log_client 后台线程异常无保护,异常可杀死线程导致内存无限增长 | **P1** | `1b9c727` | ✅ 已修复(4份副本同步) |
|
||||
| 4 | `example_log_client.py` 仍使用旧 `MsgClient` 接口与当前 `RpcServer` 不匹配 | **P1** | `bdbea6a` | ✅ 已修复 |
|
||||
| 5 | `ConfigStore.get()` TOCTOU 竞态:锁外 `_resolve()` 后锁内 `open()` 存在符号链接替换窗口 | **P1** | `9efb96e` | ✅ 已修复 |
|
||||
| 6 | log_client 日志时间戳在 `_send()` 时生成而非入队时,队列积压时时间不准确 | **P2** | `1b9c727` | ✅ 已修复 |
|
||||
| 7 | log_client `flush()` 固定 `sleep(0.6)` 不可靠 | **P2** | `1b9c727` | ✅ 已修复 |
|
||||
| 8 | log_client 队列无上限,线程异常时内存无限增长 | **P2** | `1b9c727` | ✅ 已修复 |
|
||||
| 9 | `_handle_reload` 空字符串/非字符串 name(falsy 值)意外清空全部缓存 | **P1** | `b53d499` | ✅ 已修复 |
|
||||
| 10 | `ConfigStore.get()` 返回缓存 dict 可变引用,调用方可污染缓存 | **P1** | `b53d499` | ✅ 已修复 |
|
||||
| 11 | `list()` 不对符号链接做 realpath 校验,可探测外部文件元信息 | **P1** | `b53d499` | ✅ 已修复 |
|
||||
| 12 | `_handle_get` 中 `key` 参数未做类型校验,非字符串值被 `str()` 转换后产生误导行为 | **P1** | `b53d499` | ✅ 已修复 |
|
||||
| 13 | `ConfigParser` 未禁用插值(`%(key)s`),含裸 `%` 的配置值触发 `InterpolationError` | **P2** | `b53d499` | ✅ 已修复 |
|
||||
| 14 | argparse 描述字符串未与顶部文档同步更新(`pymsg/TCP` → `pymsg RpcServer/TCP`) | **P2** | `b53d499` | ✅ 已修复 |
|
||||
|
||||
---
|
||||
|
||||
## 三、遗留问题
|
||||
|
||||
### 🔴 P0 — Critical
|
||||
**无遗留 P0 问题。**
|
||||
|
||||
---
|
||||
|
||||
### 🟠 P1 — High
|
||||
**无遗留 P1 问题。**
|
||||
|
||||
---
|
||||
|
||||
### 🟡 P2 — Medium(建议修复)
|
||||
|
||||
#### P2-1 ~~ConfigParser 未禁用插值~~ ✅ 已修复
|
||||
|
||||
#### P2-2 ~~argparse 描述字符串未与文档同步更新~~ ✅ 已修复
|
||||
|
||||
#### P2-3 日志注入风险
|
||||
|
||||
**文件**: `confs/confs.py` 第 148/282/288 行
|
||||
配置名和 key 直接拼入日志消息,若包含 `\n` 或 ANSI 转义序列可伪造日志条目。防御纵深角度建议过滤控制字符。
|
||||
|
||||
---
|
||||
|
||||
#### P2-4 `list()` 在持锁期间遍历目录并 stat 每个文件
|
||||
|
||||
配置文件数量预计极少(<100),实际影响可忽略,但若未来扩展到大量文件需考虑优化。
|
||||
|
||||
---
|
||||
|
||||
### 🔵 P3 — Low(改进建议)
|
||||
|
||||
| # | 问题 | 文件 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P3-1 | `_run.sh` 无意义 `sleep 2` | `confs/_run.sh` L5 | 启动前固定 sleep 是 hack,应通过 core 的 init.txt 依赖顺序或健康检查保证 |
|
||||
| P3-2 | `_set.sh` 硬编码清华 pip 镜像源 | `confs/_set.sh` L5 | 非中国大陆网络环境下安装可能失败 |
|
||||
| P3-3 | `_set.sh` 缺少 `set -e` | `confs/_set.sh` | pip install 失败后脚本继续执行,可能导致运行时 ImportError |
|
||||
| P3-4 | `requirements.txt` 包含运行时不需要的 nuitka | `confs/requirements.txt` | nuitka 是编译工具(且在 `_set.sh` 中被注释掉),不应作为运行时依赖 |
|
||||
| P3-5 | ConfigParser section 大小写敏感/option 大小写不敏感的隐式行为 | `confs/confs.py` L206-217 | Python 标准库行为,建议文档中说明或统一转为小写处理 |
|
||||
| P3-6 | 错误消息暴露底层 IO 异常信息 | `confs/confs.py` L144 | `Permission denied` 等底层错误信息被原样返回给调用方 |
|
||||
|
||||
---
|
||||
|
||||
## 四、安全机制总结
|
||||
|
||||
| 机制 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 扩展名白名单 | ✅ | 仅允许 `.ini/.conf/.cfg/.json/.yaml/.yml/.toml/.txt` |
|
||||
| 路径遍历防护 | ✅ | basename 校验 + realpath 前缀校验 |
|
||||
| TOCTOU 防护 | ✅ | 文件系统操作(realpath→stat→open)全部在锁内原子完成 |
|
||||
| 文件大小限制 | ✅ | 单文件上限 1 MiB(`MAX_CONFIG_SIZE`) |
|
||||
| Slowloris 防护 | ✅ | pymsg MsgServer 默认 10 秒 recv 超时 |
|
||||
| 缓存一致性 | ✅ | 所有公开方法持锁操作 |
|
||||
| 缓存防污染 | ✅ | `get()` 返回浅拷贝,调用方修改不影响缓存 |
|
||||
| list() 符号链接防护 | ✅ | realpath 解析后校验前缀,逃逸链接跳过 |
|
||||
| 参数类型校验 | ✅ | name/key 非字符串或空值明确报错,不产生误行为 |
|
||||
| 异常隔离 | ✅ | handler 异常由 RpcServer 包装为 `success=false`,不崩溃服务 |
|
||||
| ConfigParser 安全 | ✅ | `interpolation=None` 禁用 `%(key)s` 插值,裸 `%` 不报错 |
|
||||
| 仅监听本机 | ✅ | 默认 `127.0.0.1` |
|
||||
| 日志注入防护 | ❌ | 配置名/key 未过滤控制字符(P2-3) |
|
||||
| 鉴权 | ❌ | 无身份校验,依赖"仅本机"信任边界 |
|
||||
|
||||
---
|
||||
|
||||
## 五、修复优先级建议
|
||||
|
||||
1. **全部 P0/P1 已修复**:共修复 1 个 P0 + 7 个 P1 问题(含此前修复的 pymsg/log_client/TOCTOU/OOM 等)
|
||||
2. **近期修复(P2)**: P2-3(日志注入,过滤控制字符)
|
||||
3. **可接受(P2-4/P3)**: 实际风险极低或属于运维/部署优化项
|
||||
@@ -0,0 +1,244 @@
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目
|
||||
|
||||
|
||||
def _safe_json(obj):
|
||||
"""安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。"""
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
"""生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._flush_done = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。
|
||||
|
||||
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
|
||||
"""
|
||||
entry = {
|
||||
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略)
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
pass # 极端竞争下仍失败则静默丢弃
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_done.clear()
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
|
||||
self._flush_done.wait(timeout=self._flush_interval + 2.0)
|
||||
# 主线程排空队列中剩余条目
|
||||
self._drain_queue_to_db()
|
||||
|
||||
def _drain_queue_to_db(self):
|
||||
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
|
||||
|
||||
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
|
||||
都不会死亡,避免日志队列无限增长导致 OOM。
|
||||
"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
# _send 内部已有细粒度异常处理,此处是最终安全网
|
||||
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
if self._flush_signal.is_set():
|
||||
self._flush_signal.clear()
|
||||
self._flush_done.set()
|
||||
except Exception as e:
|
||||
# 最外层安全网:任何意外异常都不应杀死日志线程
|
||||
print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
time.sleep(0.5) # 防止异常风暴导致 CPU 满载
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
|
||||
self._flush_done.set()
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
|
||||
|
||||
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
|
||||
不抛异常给调用方。
|
||||
"""
|
||||
try:
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
except Exception as e:
|
||||
print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr)
|
||||
return
|
||||
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
try:
|
||||
extra_json = _safe_json(extra_val) if extra_val is not None else None
|
||||
except Exception:
|
||||
extra_json = None
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": entry.get("ts", _now_iso()),
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": extra_json,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient()
|
||||
@@ -0,0 +1,2 @@
|
||||
../pymsg
|
||||
nuitka
|
||||
@@ -0,0 +1,89 @@
|
||||
# core(Nidus)模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `src/main/`、`src/shared/`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 2 | 0 | 2 |
|
||||
| P1 | 3 | 0 | 3 |
|
||||
| P2 | 3 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: PMC HTTP 管理接口无认证,可执行任意系统命令
|
||||
- **位置**: `src/main/HttpTask.cpp`
|
||||
- **问题**: Core(PMC)作为进程管理器,暴露了 HTTP 管理接口(`/exec` 等),**无任何认证**:
|
||||
- 任意能访问管理端口的本地/远程攻击者可执行任意 shell 命令
|
||||
- 可启停任意托管服务、读取日志、获取运行状态
|
||||
- 当前缓解:应仅绑定 127.0.0.1,需确认配置
|
||||
- **修复建议**:
|
||||
1. 确保管理接口仅监听 127.0.0.1
|
||||
2. 添加强认证(预共享 Token 或 Basic Auth over localhost)
|
||||
3. 危险命令(exec、kill)要求二次确认
|
||||
4. 记录所有管理操作审计日志
|
||||
|
||||
### P0-2: init.txt 配置文件无完整性校验,可注入恶意命令
|
||||
- **位置**: `src/main/main.cpp` init.txt 解析
|
||||
- **问题**: init.txt 定义了启动哪些子进程及参数,若文件被篡改:
|
||||
- 可添加任意启动条目,执行恶意二进制
|
||||
- 命令行参数可被注入
|
||||
- **修复建议**:
|
||||
1. init.txt 添加数字签名/校验和验证
|
||||
2. 运行时监控 init.txt 修改,变更需管理员确认
|
||||
3. 文件权限设为 0600,仅 root/运行用户可写
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | 子进程异常退出无次数限制,可被反复重启消耗资源 | 进程监控逻辑 |
|
||||
| P1-2 | 日志路径穿越:子进程日志文件名可控时可能写入任意位置 | 日志配置 |
|
||||
| P1-3 | C++ HTTP 解析器需审计缓冲区溢出 | libhttp 库(shared 模块) |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | 信号处理函数中使用非异步安全函数(如 printf、log) |
|
||||
| P2-2 | 线程池任务队列无上限,可 OOM |
|
||||
| P2-3 | 崩溃 core dump 可能包含敏感信息(密码、Token) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **进程生命周期管理**: 自动重启异常退出的子进程,保障服务可用性
|
||||
2. ✅ **线程池模型**: 固定线程数,防止无限线程创建
|
||||
3. ✅ **日志集中管理**: 统一日志格式和输出
|
||||
4. ✅ **C++ RAII 内存管理**: 使用智能指针减少内存泄漏风险
|
||||
5. ✅ **依赖审计**: 使用经过审计的 libth 线程库、libhttp HTTP 库、libcrypt 加密库
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 确认 PMC HTTP 接口仅绑定 127.0.0.1
|
||||
- [ ] 检查 init.txt 文件权限
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] PMC HTTP 接口添加认证
|
||||
- [ ] 子进程重启频率限制(指数退避或熔断)
|
||||
- [ ] 审计 libhttp HTTP 解析器
|
||||
|
||||
### 中期
|
||||
- [ ] init.txt 完整性校验
|
||||
- [ ] 管理操作审计日志
|
||||
- [ ] 信号处理函数安全改造
|
||||
@@ -1,4 +1,5 @@
|
||||
bash /opt/micro_servers/db/_run.sh
|
||||
bash /opt/micro_servers/confs/_run.sh
|
||||
bash /opt/micro_servers/ai_review/_run.sh
|
||||
bash /opt/micro_servers/dnfs/_run.sh
|
||||
bash /opt/micro_servers/smtp/_run.sh
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
@@ -0,0 +1,53 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="db"
|
||||
LABEL description="dbv2 - pymsg协议数据库服务"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 安装pymsg共享库
|
||||
COPY pymsg/ /app/pymsg/
|
||||
RUN pip install /app/pymsg/
|
||||
|
||||
# 安装依赖
|
||||
COPY db/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 复制应用代码
|
||||
COPY db/ /app/db/
|
||||
|
||||
# 入口脚本
|
||||
RUN chmod +x /app/db/docker-entrypoint.sh
|
||||
|
||||
# 创建数据持久化目录并设置权限
|
||||
RUN mkdir -p /app/data && chown -R 1000:1000 /app/data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# 创建非root用户
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
WORKDIR /app/db
|
||||
|
||||
EXPOSE 8005
|
||||
|
||||
# 健康检查:尝试连接TCP端口
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()" || exit 1
|
||||
|
||||
# 默认使用SQLite,数据持久化到/app/data/database.db
|
||||
ENV DATABASE_URL=sqlite:////app/data/database.db \
|
||||
LISTEN_ADDR=0.0.0.0 \
|
||||
LISTEN_PORT=8005 \
|
||||
WORKERS=4
|
||||
|
||||
ENTRYPOINT ["/app/db/docker-entrypoint.sh"]
|
||||
+12
-6
@@ -189,7 +189,7 @@ CREATE TABLE IF NOT EXISTS Logs (
|
||||
|
||||
### 写入方式
|
||||
|
||||
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表:
|
||||
**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg `RpcClient`(高层 RPC 封装)调 `execute` 路由逐条 INSERT 到 Logs 表:
|
||||
|
||||
```python
|
||||
from log_client import logs
|
||||
@@ -202,11 +202,16 @@ logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
|
||||
客户端特性:
|
||||
- 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送
|
||||
- fail-safe:db 不可用时静默丢弃,绝不影响业务
|
||||
- **RpcClient 高层 RPC**:直接传 dict 对象,无需手动拼 `_route` / json.dumps / json.loads
|
||||
- fail-safe:db 不可用时静默丢弃,绝不影响业务;后台线程外层 try/except 捕获所有异常,任何网络/序列化错误都不会杀死日志线程(防止队列无限增长导致 OOM)
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 长度保护:`message` 上限 8192 字节、`extra` 上限 4096 字节,超长自动截断
|
||||
- 时间戳准确:日志入队时即生成 UTC ISO 时间戳,而非发送时生成,队列积压时时间仍反映事件真实发生时间
|
||||
- 队列保护:队列上限 10000 条,满时丢弃最旧条目(drop-oldest),`push()` 永远不阻塞业务线程
|
||||
- 安全序列化:`extra` 字段包含不可 JSON 序列化对象时自动 fallback 为 `str()`/`repr()`,不会因 TypeError 中断
|
||||
- flush 可靠:`flush()` 使用 Event 通知而非固定 sleep,精确等待后台线程完成批量发送
|
||||
- 长度保护:服务端 `_log_internal()` 对内部日志有 `message` 上限 8192 字节、`extra` 上限 4096 字节的自动截断;外部客户端建议在业务层控制日志长度
|
||||
|
||||
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
|
||||
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归),并在写入前做长度截断。
|
||||
|
||||
### 查询日志
|
||||
|
||||
@@ -239,8 +244,9 @@ SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) |
|
||||
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 |
|
||||
| `dbv2.py` | 服务主程序(pymsg RpcServer/TCP,唯一在跑的实现) |
|
||||
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用(基于 RpcClient,含线程保护/队列上限/安全序列化等健壮性设计) |
|
||||
| `db_tools.py` | SQLite 运维工具:backup / export / import / vacuum / wal-checkpoint / info,供 docker.sh 等脚本调用 |
|
||||
| `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 |
|
||||
| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 |
|
||||
| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 |
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
db_tools — SQLite 数据库备份、导出、导入工具
|
||||
|
||||
用法:
|
||||
python db_tools.py backup <output_path> # 安全备份数据库(使用 sqlite3 backup API)
|
||||
python db_tools.py export <output.json> # 导出所有表数据为 JSON
|
||||
python db_tools.py import <input.json> # 从 JSON 导入数据
|
||||
python db_tools.py vacuum # 执行 VACUUM 压缩数据库
|
||||
python db_tools.py wal-checkpoint # 强制 WAL 检查点(确保数据写入主库)
|
||||
python db_tools.py info # 显示数据库信息和表统计
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
|
||||
|
||||
# 从 sqlite:///path 提取文件路径
|
||||
def _get_db_path() -> str:
|
||||
if DATABASE_URL.startswith("sqlite:////"):
|
||||
return DATABASE_URL[10:] # 绝对路径 sqlite:////app/data/xxx.db
|
||||
elif DATABASE_URL.startswith("sqlite:///"):
|
||||
return DATABASE_URL[9:] # 相对路径
|
||||
else:
|
||||
return "database.db"
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""创建数据库连接(配置 PRAGMA)。"""
|
||||
conn = sqlite3.connect(_get_db_path())
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def backup(output_path: str):
|
||||
"""
|
||||
使用 SQLite 官方 backup API 进行在线热备份(不需要停机)。
|
||||
这是最安全的备份方式,支持读写并发。
|
||||
"""
|
||||
src = _get_db_path()
|
||||
print(f"[backup] 正在备份数据库: {src} -> {output_path}")
|
||||
|
||||
if not os.path.exists(src):
|
||||
print(f"[backup] 错误: 数据库文件不存在: {src}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
start = datetime.now(timezone.utc)
|
||||
|
||||
# 使用 sqlite3 backup API(支持在线备份)
|
||||
src_conn = sqlite3.connect(src)
|
||||
dst_conn = sqlite3.connect(output_path)
|
||||
try:
|
||||
src_conn.backup(dst_conn)
|
||||
dst_conn.commit()
|
||||
finally:
|
||||
dst_conn.close()
|
||||
src_conn.close()
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
elapsed = (datetime.now(timezone.utc) - start).total_seconds()
|
||||
print(f"[backup] 备份完成: {output_path} ({size_mb:.2f} MB, 耗时 {elapsed:.2f}s)")
|
||||
|
||||
|
||||
def export_json(output_path: str):
|
||||
"""导出所有表数据为 JSON 格式(便于跨平台传输/迁移)。"""
|
||||
print(f"[export] 正在导出数据到: {output_path}")
|
||||
conn = _connect()
|
||||
|
||||
result = {
|
||||
"_meta": {
|
||||
"exported_at": datetime.now(timezone.utc).isoformat(),
|
||||
"db_path": _get_db_path(),
|
||||
"sqlite_version": sqlite3.sqlite_version,
|
||||
},
|
||||
"tables": {}
|
||||
}
|
||||
|
||||
try:
|
||||
# 获取所有用户表
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
|
||||
for (table_name,) in tables:
|
||||
# 获取列信息
|
||||
columns = [desc[1] for desc in conn.execute(f"PRAGMA table_info({table_name})").fetchall()]
|
||||
|
||||
# 获取行数
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
|
||||
|
||||
# 获取所有数据
|
||||
rows = conn.execute(f"SELECT * FROM [{table_name}]").fetchall()
|
||||
data = [dict(row) for row in rows]
|
||||
|
||||
result["tables"][table_name] = {
|
||||
"columns": columns,
|
||||
"row_count": count,
|
||||
"data": data,
|
||||
}
|
||||
print(f"[export] 表 {table_name}: {count} 行")
|
||||
|
||||
# 同时导出 schema
|
||||
schema = []
|
||||
for (table_name,) in tables:
|
||||
ddl = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,)
|
||||
).fetchone()[0]
|
||||
if ddl:
|
||||
schema.append(ddl + ";")
|
||||
|
||||
result["_meta"]["schema"] = schema
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
size_mb = os.path.getsize(output_path) / (1024 * 1024)
|
||||
print(f"[export] 导出完成: {output_path} ({size_mb:.2f} MB)")
|
||||
|
||||
|
||||
def import_json(input_path: str):
|
||||
"""从 JSON 文件导入数据(注意:会清空现有数据!)。"""
|
||||
print(f"[import] 正在从 {input_path} 导入数据")
|
||||
|
||||
if not os.path.exists(input_path):
|
||||
print(f"[import] 错误: 文件不存在: {input_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
with open(input_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
conn = _connect()
|
||||
|
||||
try:
|
||||
# 是否清空现有表(需要确认)
|
||||
tables = data.get("tables", {})
|
||||
if tables:
|
||||
print(f"[import] 警告: 即将导入 {len(tables)} 个表,现有数据将被覆盖!")
|
||||
# 为安全起见,不自动清空,由用户先备份
|
||||
|
||||
# 按外键依赖顺序导入(先主表后子表,这里简单按名字排序)
|
||||
for table_name in sorted(tables.keys()):
|
||||
table_data = tables[table_name]
|
||||
rows = table_data.get("data", [])
|
||||
columns = table_data.get("columns", [])
|
||||
|
||||
if not rows or not columns:
|
||||
print(f"[import] 跳表 {table_name}: 无数据或列定义")
|
||||
continue
|
||||
|
||||
# 先清空表
|
||||
conn.execute(f"DELETE FROM [{table_name}]")
|
||||
|
||||
# 批量插入
|
||||
placeholders = ", ".join(["?"] * len(columns))
|
||||
col_names = ", ".join(f"[{c}]" for c in columns)
|
||||
insert_sql = f"INSERT INTO [{table_name}] ({col_names}) VALUES ({placeholders})"
|
||||
|
||||
conn.executemany(insert_sql, [tuple(row.get(col) for col in columns) for row in rows])
|
||||
print(f"[import] 表 {table_name}: 导入 {len(rows)} 行")
|
||||
|
||||
conn.commit()
|
||||
print("[import] 导入完成")
|
||||
else:
|
||||
print("[import] 没有可导入的表数据")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def vacuum():
|
||||
"""执行 VACUUM 压缩数据库,回收空间、重建索引。"""
|
||||
print("[vacuum] 正在执行 VACUUM...")
|
||||
db_path = _get_db_path()
|
||||
before_size = os.path.getsize(db_path) / (1024 * 1024)
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
# WAL 检查点先将所有数据写入主库
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.execute("VACUUM")
|
||||
conn.execute("ANALYZE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
after_size = os.path.getsize(db_path) / (1024 * 1024)
|
||||
saved = before_size - after_size
|
||||
print(f"[vacuum] 完成: {before_size:.2f} MB -> {after_size:.2f} MB (节省 {saved:.2f} MB)")
|
||||
|
||||
|
||||
def wal_checkpoint():
|
||||
"""强制 WAL 检查点,确保所有写入都持久化到主数据库文件。"""
|
||||
print("[wal-checkpoint] 执行 WAL 检查点...")
|
||||
conn = _connect()
|
||||
try:
|
||||
result = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone()
|
||||
# busy, log, checkpointed
|
||||
print(f"[wal-checkpoint] 结果: busy={result[0]}, log_pages={result[1]}, checkpointed={result[2]}")
|
||||
finally:
|
||||
conn.close()
|
||||
print("[wal-checkpoint] 完成")
|
||||
|
||||
|
||||
def show_info():
|
||||
"""显示数据库信息和表统计。"""
|
||||
db_path = _get_db_path()
|
||||
print("=" * 50)
|
||||
print("SQLite 数据库信息")
|
||||
print("=" * 50)
|
||||
print(f"文件路径: {os.path.abspath(db_path)}")
|
||||
|
||||
if os.path.exists(db_path):
|
||||
size_mb = os.path.getsize(db_path) / (1024 * 1024)
|
||||
mtime = datetime.fromtimestamp(os.path.getmtime(db_path), tz=timezone.utc)
|
||||
print(f"文件大小: {size_mb:.2f} MB")
|
||||
print(f"最后修改: {mtime.isoformat()}")
|
||||
else:
|
||||
print("文件大小: (不存在)")
|
||||
|
||||
# WAL 文件
|
||||
wal_path = db_path + "-wal"
|
||||
shm_path = db_path + "-shm"
|
||||
if os.path.exists(wal_path):
|
||||
wal_size = os.path.getsize(wal_path) / 1024
|
||||
print(f"WAL 文件: {wal_size:.2f} KB")
|
||||
if os.path.exists(shm_path):
|
||||
shm_size = os.path.getsize(shm_path) / 1024
|
||||
print(f"SHM 文件: {shm_size:.2f} KB")
|
||||
|
||||
conn = _connect()
|
||||
try:
|
||||
print(f"SQLite 版本: {sqlite3.sqlite_version}")
|
||||
|
||||
# PRAGMA 信息
|
||||
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
page_size = conn.execute("PRAGMA page_size").fetchone()[0]
|
||||
page_count = conn.execute("PRAGMA page_count").fetchone()[0]
|
||||
free_pages = conn.execute("PRAGMA freelist_count").fetchone()[0]
|
||||
|
||||
print(f"Journal 模式: {journal_mode}")
|
||||
print(f"同步级别: {synchronous}")
|
||||
print(f"页大小: {page_size} bytes")
|
||||
|
||||
# 表统计
|
||||
tables = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
).fetchall()
|
||||
|
||||
print(f"\n表统计 ({len(tables)} 个表):")
|
||||
print("-" * 40)
|
||||
total_rows = 0
|
||||
for (table_name,) in tables:
|
||||
count = conn.execute(f"SELECT COUNT(*) FROM [{table_name}]").fetchone()[0]
|
||||
total_rows += count
|
||||
print(f" {table_name:20s} {count:>8d} 行")
|
||||
print("-" * 40)
|
||||
print(f" {'总计':20s} {total_rows:>8d} 行")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
if cmd == "backup":
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else f"backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||
backup(output)
|
||||
elif cmd == "export":
|
||||
output = sys.argv[2] if len(sys.argv) > 2 else f"export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json"
|
||||
export_json(output)
|
||||
elif cmd == "import":
|
||||
if len(sys.argv) < 3:
|
||||
print("用法: python db_tools.py import <input.json>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
import_json(sys.argv[2])
|
||||
elif cmd == "vacuum":
|
||||
vacuum()
|
||||
elif cmd == "wal-checkpoint":
|
||||
wal_checkpoint()
|
||||
elif cmd == "info":
|
||||
show_info()
|
||||
else:
|
||||
print(f"未知命令: {cmd}", file=sys.stderr)
|
||||
print(__doc__)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+108
-76
@@ -29,6 +29,7 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||
@@ -36,6 +37,7 @@ from datetime import datetime, date, time as dt_time, timezone, timedelta
|
||||
from sqlalchemy import (
|
||||
create_engine, MetaData, Table,
|
||||
select, insert, update, delete, func, and_,
|
||||
event,
|
||||
)
|
||||
from sqlalchemy import types as sa_types
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -43,12 +45,32 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 数据库配置 ──────────────────────────────────────────────────────────
|
||||
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db")
|
||||
|
||||
# ── SQLite 连接配置(针对 Docker/多线程环境优化)──────────────────────
|
||||
|
||||
def _configure_sqlite(dbapi_connection, connection_record):
|
||||
"""为每个 SQLite 连接设置 PRAGMA,优化并发与持久化性能。
|
||||
|
||||
- WAL 模式:读写不互斥,并发性能大幅提升,Docker 卷挂载下更稳定
|
||||
- NORMAL 同步级别:在断电时可能丢失最近提交的事务(但不会损坏数据库)
|
||||
配合 WAL checkpoint 足够安全,性能显著提升
|
||||
- foreign_keys=ON:启用外键约束
|
||||
- busy_timeout:锁等待超时 5 秒,避免 SQLITE_BUSY 错误
|
||||
- mmap_size:使用内存映射 I/O 提升读取性能
|
||||
"""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA mmap_size=268435456") # 256MB
|
||||
cursor.close()
|
||||
|
||||
# ── 日志模块常量 ────────────────────────────────────────────────────────
|
||||
|
||||
MAX_MESSAGE_LEN = 8192 # message 上限 8KB
|
||||
@@ -76,69 +98,66 @@ def _json_default(o):
|
||||
class DBServer:
|
||||
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8005):
|
||||
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self.engine = create_engine(DATABASE_URL)
|
||||
self.engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
# 为 SQLite 连接应用 PRAGMA 优化
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
event.listen(self.engine, "connect", _configure_sqlite)
|
||||
self.metadata = MetaData()
|
||||
self._server = MsgServer(addr, port)
|
||||
self._server = RpcServer(
|
||||
addr, port, workers=workers,
|
||||
json_default=_json_default,
|
||||
on_error=lambda route, e: traceback.print_exc(),
|
||||
)
|
||||
self._server.add_route("execute", self._handle_execute)
|
||||
|
||||
# 日志表缓存(避免重复反射)
|
||||
self._logs_table_cache = {}
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"execute": self._handle_execute,
|
||||
}
|
||||
# 业务表缓存:仅缓存"已完整反射"的表。
|
||||
# 注意不能用 metadata.tables 作完成标记 —— Table(autoload_with=...)
|
||||
# 会先注册进 metadata.tables(此时无列)再填充列,锁外快路径可能
|
||||
# 读到反射半途的空列表。
|
||||
self._tables_cache = {}
|
||||
|
||||
# 表反射锁:workers>0 时多个 worker 线程可能对同一新表并发做
|
||||
# check-then-act 反射(Table(..., autoload_with=...) 对已定义表名会
|
||||
# 抛 InvalidRequestError),反射操作需串行化
|
||||
self._reflect_lock = threading.Lock()
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
|
||||
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前)
|
||||
self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前)
|
||||
self._start_cleanup_thread()
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req, sock=sock)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False,
|
||||
default=_json_default))
|
||||
|
||||
# ── 表反射 ---------------------------------------------------------
|
||||
def _get_table(self, table_name):
|
||||
if table_name not in self.metadata.tables:
|
||||
Table(table_name, self.metadata, autoload_with=self.engine)
|
||||
return self.metadata.tables[table_name]
|
||||
# double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL
|
||||
# 下安全),锁外快路径读到的要么是完整表要么是 None
|
||||
table = self._tables_cache.get(table_name)
|
||||
if table is None:
|
||||
with self._reflect_lock:
|
||||
table = self._tables_cache.get(table_name)
|
||||
if table is None:
|
||||
# 持锁期间无其他反射在进行,metadata 中的表必然完整
|
||||
table = self.metadata.tables.get(table_name)
|
||||
if table is None:
|
||||
table = Table(table_name, self.metadata,
|
||||
autoload_with=self.engine)
|
||||
self._tables_cache[table_name] = table
|
||||
return table
|
||||
|
||||
# ── 日期/时间类型适配 ------------------------------------------------
|
||||
def _coerce_value(self, column, value):
|
||||
@@ -222,7 +241,7 @@ class DBServer:
|
||||
# 路由处理 — execute
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
def _handle_execute(self, data, **kw):
|
||||
def _handle_execute(self, data):
|
||||
action = data.get("action", "select")
|
||||
table_name = data.get("table")
|
||||
if not table_name:
|
||||
@@ -316,35 +335,43 @@ class DBServer:
|
||||
if "Logs" in self._logs_table_cache:
|
||||
return self._logs_table_cache["Logs"]
|
||||
|
||||
with self.engine.connect() as conn:
|
||||
conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
)
|
||||
""")
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
|
||||
)
|
||||
conn.commit()
|
||||
# 与 _get_table 共用反射锁,double-check 保证并发下只建/反射一次;
|
||||
# Logs 可能已被 _get_table("Logs") 反射进 metadata,需幂等处理
|
||||
with self._reflect_lock:
|
||||
if "Logs" in self._logs_table_cache:
|
||||
return self._logs_table_cache["Logs"]
|
||||
|
||||
table = Table("Logs", self.metadata, autoload_with=self.engine)
|
||||
self._logs_table_cache["Logs"] = table
|
||||
return table
|
||||
table = self.metadata.tables.get("Logs")
|
||||
if table is None:
|
||||
with self.engine.connect() as conn:
|
||||
conn.exec_driver_sql("""
|
||||
CREATE TABLE IF NOT EXISTS Logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
service TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
extra TEXT
|
||||
)
|
||||
""")
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)"
|
||||
)
|
||||
conn.exec_driver_sql(
|
||||
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
|
||||
)
|
||||
conn.commit()
|
||||
table = Table("Logs", self.metadata, autoload_with=self.engine)
|
||||
|
||||
self._logs_table_cache["Logs"] = table
|
||||
return table
|
||||
|
||||
# ── 内部日志(直接写库,无 HTTP)──────────────────────────────────
|
||||
def _log_internal(self, service, level, message,
|
||||
@@ -429,12 +456,17 @@ class DBServer:
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
default_addr = os.environ.get("LISTEN_ADDR", "0.0.0.0")
|
||||
default_port = int(os.environ.get("LISTEN_PORT", "8005"))
|
||||
|
||||
parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8005, help="监听端口")
|
||||
parser.add_argument("--addr", default=default_addr, help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=default_port, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="回调并发线程数(0=同步串行模式)")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = DBServer(args.addr, args.port)
|
||||
server = DBServer(args.addr, args.port, workers=args.workers)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
DATA_DIR="/app/data"
|
||||
DB_PATH="${DATABASE_URL#sqlite:///}"
|
||||
if [ "$DB_PATH" = "$DATABASE_URL" ]; then
|
||||
# URL 不是 sqlite:/// 格式,使用默认路径
|
||||
DB_PATH="$DATA_DIR/database.db"
|
||||
fi
|
||||
|
||||
echo "数据目录: $DATA_DIR"
|
||||
echo "数据库路径: $DB_PATH"
|
||||
|
||||
# 确保数据目录存在并可写
|
||||
mkdir -p "$(dirname "$DB_PATH")"
|
||||
|
||||
# 初始化数据库表(如果不存在)
|
||||
if [[ "$DATABASE_URL" == sqlite:* ]]; then
|
||||
echo "初始化数据库 schema..."
|
||||
python -c "
|
||||
import os
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
db_url = os.environ.get('DATABASE_URL', 'sqlite:////app/data/database.db')
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
connect_args={'check_same_thread': False} if db_url.startswith('sqlite') else {},
|
||||
)
|
||||
# 应用 SQLite PRAGMA 优化
|
||||
from sqlalchemy import event
|
||||
@event.listens_for(engine, 'connect')
|
||||
def _configure_sqlite(dbapi_conn, _):
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute('PRAGMA journal_mode=WAL')
|
||||
cur.execute('PRAGMA synchronous=NORMAL')
|
||||
cur.execute('PRAGMA foreign_keys=ON')
|
||||
cur.execute('PRAGMA busy_timeout=5000')
|
||||
cur.close()
|
||||
|
||||
with open('create_sqlite3.sql', 'r') as f:
|
||||
schema_sql = f.read()
|
||||
with engine.connect() as conn:
|
||||
for stmt in schema_sql.split(';'):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
try:
|
||||
conn.execute(text(stmt))
|
||||
except Exception as e:
|
||||
pass
|
||||
conn.commit()
|
||||
print('Schema 初始化完成.')
|
||||
"
|
||||
fi
|
||||
|
||||
echo "启动 dbv2 服务 ${LISTEN_ADDR:-0.0.0.0}:${LISTEN_PORT:-8005}..."
|
||||
exec python dbv2.py --addr "${LISTEN_ADDR:-0.0.0.0}" --port "${LISTEN_PORT:-8005}" --workers "${WORKERS:-4}"
|
||||
@@ -0,0 +1,82 @@
|
||||
# db 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `dbv2.py`、`db_tools.py`、`log_client` 相关
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 3 | 0 | 3 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: 零认证对外开放完整 CRUD(依赖 127.0.0.1 网络隔离)
|
||||
- **位置**: `dbv2.py` RpcServer
|
||||
- **问题**:
|
||||
- 完全没有认证机制
|
||||
- 任何能访问 8005 端口的本地进程/用户都可执行任意 select/insert/update/delete
|
||||
- 可读取 Users 表密码哈希、Logs 表所有日志
|
||||
- SELECT 无 limit 上限,可被拖库
|
||||
- 无表级/操作级权限控制
|
||||
- **当前缓解**: 默认监听 `127.0.0.1`,Docker 也绑定到 127.0.0.1
|
||||
- **修复建议**:
|
||||
1. 添加 API Token 认证(预共享密钥,所有调用需携带 `_auth` 字段)
|
||||
2. SELECT 强制添加 LIMIT 上限(默认 1000 条),防止拖库
|
||||
3. 添加操作权限:日志写入只允许 insert,禁止 delete/truncate
|
||||
4. 拒绝无条件 UPDATE/DELETE(必须带 where 条件)
|
||||
5. DDL 操作仅限本地运维工具,RPC 接口完全禁用
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | db_tools.py 运维工具存在 SQL 字符串拼接风险 | 运维脚本中可能使用字符串拼接构建 SQL |
|
||||
| P1-2 | 无操作审计日志 | 谁在什么时候修改了什么数据无独立审计记录 |
|
||||
| P1-3 | Logs 表自动清理可被利用销毁证据 | 攻击者可通过触发大量日志加速清理,删除攻击痕迹 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | 日志表自动清理的保留策略可配置化(目前硬编码 30天/100万条) |
|
||||
| P2-2 | 错误信息可能泄露表结构/字段名给调用方 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **SQLAlchemy Core 参数化查询**:核心 CRUD 路径使用表反射 + 参数化,零 SQL 注入风险
|
||||
2. ✅ **仅监听 127.0.0.1**:不对外暴露
|
||||
3. ✅ **集中式日志 fail-safe**:db 不可用时静默丢弃,不阻塞业务
|
||||
4. ✅ **自动提交/回滚**:异常时自动回滚事务
|
||||
5. ✅ **日期/时间适配**:ISO 8601 处理,`"$now"` 哨兵
|
||||
6. ✅ **日志自动清理**:后台线程每小时清理,防止磁盘占满
|
||||
7. ✅ **log_client 健壮性**:异步队列、后台线程、fail-safe、队列上限(从各服务副本)
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] SELECT 添加强制 LIMIT 上限(防止拖库)
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 添加 API Token 认证
|
||||
- [ ] 无条件 UPDATE/DELETE 拒绝执行
|
||||
- [ ] 错误信息脱敏
|
||||
|
||||
### 中期
|
||||
- [ ] 表级权限控制(日志表只写不删/改)
|
||||
- [ ] 管理操作审计日志
|
||||
+135
-64
@@ -1,13 +1,14 @@
|
||||
"""
|
||||
统一日志客户端 SDK — 各服务复制即用。
|
||||
统一日志客户端 SDK — 各服务复制即用(模板)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 通过 pymsg RpcClient(高层 RPC 封装)调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
|
||||
|
||||
复制到目标服务目录后,按需修改 service_name 即可::
|
||||
复制到目标服务目录后,按需修改底部全局单例的 service_name 即可:
|
||||
|
||||
from log_client import logs
|
||||
|
||||
@@ -27,22 +28,36 @@ import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目
|
||||
|
||||
|
||||
def _safe_json(obj):
|
||||
"""安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。"""
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
"""生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
通过 RpcClient(pymsg 高层 RPC 封装)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。
|
||||
"""
|
||||
|
||||
@@ -52,9 +67,10 @@ class LogClient:
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._flush_done = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
@@ -63,14 +79,30 @@ class LogClient:
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
"""推送一条日志(非阻塞,立即返回)。
|
||||
|
||||
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
|
||||
"""
|
||||
entry = {
|
||||
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略)
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
pass # 极端竞争下仍失败则静默丢弃
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
@@ -89,8 +121,15 @@ class LogClient:
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_done.clear()
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
# 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
|
||||
self._flush_done.wait(timeout=self._flush_interval + 2.0)
|
||||
# 主线程排空队列中剩余条目
|
||||
self._drain_queue_to_db()
|
||||
|
||||
def _drain_queue_to_db(self):
|
||||
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
@@ -102,73 +141,103 @@ class LogClient:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
|
||||
|
||||
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
|
||||
都不会死亡,避免日志队列无限增长导致 OOM。
|
||||
"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.0, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
# _send 内部已有细粒度异常处理,此处是最终安全网
|
||||
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
if self._flush_signal.is_set():
|
||||
self._flush_signal.clear()
|
||||
self._flush_done.set()
|
||||
except Exception as e:
|
||||
# 最外层安全网:任何意外异常都不应杀死日志线程
|
||||
print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
time.sleep(0.5) # 防止异常风暴导致 CPU 满载
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
|
||||
self._flush_done.set()
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
"""通过 pymsg RpcClient(execute 路由)批量发送到 dbv2 Logs 表。
|
||||
|
||||
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
|
||||
不抛异常给调用方。
|
||||
"""
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr)
|
||||
return
|
||||
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
try:
|
||||
extra_json = _safe_json(extra_val) if extra_val is not None else None
|
||||
except Exception:
|
||||
extra_json = None
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": entry.get("ts", _now_iso()),
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": extra_json,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
@@ -180,5 +249,7 @@ class LogClient:
|
||||
# 各服务复制时,把 "example" 改为自己的服务名即可。
|
||||
# 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs")
|
||||
# 例如 smtp/log_client.py → logs = LogClient(service_name="smtp")
|
||||
# 例如 static/log_client.py → logs = LogClient(service_name="static")
|
||||
# 例如 confs/log_client.py → logs = LogClient(service_name="confs")
|
||||
|
||||
logs = LogClient()
|
||||
logs = LogClient(service_name="example")
|
||||
@@ -0,0 +1,18 @@
|
||||
.env
|
||||
.env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
!README.md
|
||||
.envrc
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
*.bin
|
||||
.DS_Store
|
||||
@@ -0,0 +1,48 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
LABEL maintainer="qingsong"
|
||||
LABEL service="dnfs"
|
||||
LABEL description="Dendrachy Network File-System - 树状网络文件系统"
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# 安装系统依赖(如果有需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 先复制pymsg共享库并安装
|
||||
COPY pymsg/ /app/pymsg/
|
||||
RUN pip install /app/pymsg/
|
||||
|
||||
# 复制requirements并安装依赖
|
||||
COPY dnfs/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
|
||||
# 移除flask_sqlalchemy遗留依赖(已不使用)
|
||||
RUN pip uninstall -y flask_sqlalchemy 2>/dev/null || true
|
||||
|
||||
# 复制应用代码
|
||||
COPY dnfs/ /app/dnfs/
|
||||
|
||||
# 创建非root用户运行(安全最佳实践)
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
WORKDIR /app/dnfs
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8006
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)" || exit 1
|
||||
|
||||
# 启动服务
|
||||
CMD ["python", "dnfs.py"]
|
||||
+54
-9
@@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点
|
||||
|
||||
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
|
||||
|
||||
支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -44,9 +46,26 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
|
||||
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||
- `delete_note` 与 `update_note` 校验所有权:
|
||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
|
||||
- **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。
|
||||
- `get_notes` 不需要鉴权。
|
||||
|
||||
## 特殊节点(管理员专属)
|
||||
|
||||
`content` 为以下信封 JSON 时即为特殊节点,前端 `dnfs.js` 按 `_sp` 类型差异化渲染;普通 Markdown 内容不受影响:
|
||||
|
||||
| `_sp` | 信封字段 | 前端表现 |
|
||||
|-------|---------|---------|
|
||||
| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 |
|
||||
| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 |
|
||||
| `image` | `name`、`data`(`data:image/` DataURL,≤3.5M 字符)、`note?` | 图片直接显示 |
|
||||
| `file` | `name`、`mime`、`data`(Base64,≤3.5M 字符)、`note?` | 下载按钮(Base64→Blob→下载) |
|
||||
|
||||
规则:
|
||||
|
||||
- **仅管理员可发布/更新**(`add_note` / `update_note` 中按 `is_admin` 强制,非管理员返回 403);前端发布面板也仅对管理员显示。
|
||||
- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`html` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。
|
||||
- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。
|
||||
|
||||
## 接口文档
|
||||
|
||||
所有接口均为 `POST`,`Content-Type: application/json`。
|
||||
@@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \
|
||||
|
||||
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
||||
|
||||
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
|
||||
仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。
|
||||
|
||||
**Request**
|
||||
|
||||
@@ -196,15 +215,41 @@ curl -X POST http://localhost:8006/update_note \
|
||||
|
||||
## 配置
|
||||
|
||||
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py):
|
||||
dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致):
|
||||
|
||||
| 常量 | 值 | 说明 |
|
||||
|------|----|------|
|
||||
| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 |
|
||||
| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 |
|
||||
| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 |
|
||||
**加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级)
|
||||
|
||||
调用超时均为 10 秒。
|
||||
### 配置文件
|
||||
|
||||
集中托管在 [confs/configs/dnfs.ini](../confs/configs/dnfs.ini),由 confs 服务分发:
|
||||
|
||||
| Section | Key | 默认值 | 说明 |
|
||||
|---------|-----|--------|------|
|
||||
| `[server]` | `host` | `127.0.0.1` | 监听地址 |
|
||||
| `[server]` | `port` | `8006` | 监听端口 |
|
||||
| `[database]` | `host` | `127.0.0.1` | db 服务地址 |
|
||||
| `[database]` | `port` | `8005` | db 服务端口 |
|
||||
| `[database]` | `timeout` | `10` | RPC 调用超时(秒) |
|
||||
| `[ai_review]` | `url` | `http://127.0.0.1:8007/review` | AI 审核接口地址 |
|
||||
| `[ai_review]` | `timeout` | `10` | AI 审核请求超时(秒) |
|
||||
| `[confs]` | `host` | `127.0.0.1` | confs 服务地址 |
|
||||
| `[confs]` | `port` | `8010` | confs 服务端口 |
|
||||
|
||||
### 环境变量(Docker 环境必需,优先级最高)
|
||||
|
||||
环境变量覆盖配置文件值,容器化部署时通过 docker-compose.yml 传入服务名:
|
||||
|
||||
| 变量 | 容器内值 | 说明 |
|
||||
|------|---------|------|
|
||||
| `LISTEN_ADDR` | `0.0.0.0` | 监听地址(容器内需绑定所有网卡) |
|
||||
| `LISTEN_PORT` | `8006` | 监听端口 |
|
||||
| `DB_HOST` | `db` | db 服务主机名(docker-compose 服务名) |
|
||||
| `DB_PORT` | `8005` | db 服务端口 |
|
||||
| `REVIEW_URL` | `http://ai_review:8007/review` | ai_review 服务地址 |
|
||||
| `CONFS_HOST` | - | 可选,confs 容器化后使用 |
|
||||
| `CONFS_PORT` | - | 可选,confs 容器化后使用 |
|
||||
|
||||
配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层。本地裸机运行时 confs 不可用会自动回退到内置默认值 + 环境变量。
|
||||
|
||||
## 前端
|
||||
|
||||
|
||||
+229
-119
@@ -1,9 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
'''
|
||||
|
||||
Dendrachy Network File-System (dnfs)
|
||||
@@ -18,11 +14,13 @@ Dendrachy Network File-System (dnfs)
|
||||
|
||||
from flask import Flask, request, jsonify, render_template
|
||||
from flask_cors import CORS
|
||||
#from flask_sqlalchemy import SQLAlchemy
|
||||
#from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信
|
||||
from waitress import serve
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import base64
|
||||
import configparser
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Any
|
||||
@@ -30,39 +28,112 @@ from security_middleware import apply_security_headers
|
||||
from log_client import logs
|
||||
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
# 我们有在依赖列表中加入pymsg
|
||||
# 这个可以省略也许
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
# ==================== 配置加载(三层 fail-safe) ====================
|
||||
# 加载顺序:内置默认值 → confs 统一配置服务(dnfs.ini) → 环境变量覆盖
|
||||
# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1)
|
||||
|
||||
_dnfs_config = {
|
||||
'server': {'host': '127.0.0.1', 'port': '8006'},
|
||||
'database': {'host': '127.0.0.1', 'port': '8005', 'timeout': '10'},
|
||||
'ai_review': {'url': 'http://127.0.0.1:8007/review', 'timeout': '10'},
|
||||
'confs': {'host': '127.0.0.1', 'port': '8010'},
|
||||
}
|
||||
|
||||
|
||||
def _merge_ini(text):
|
||||
"""把 ini 文本合并进 _dnfs_config(仅覆盖已知的 section/key,白名单合并)。"""
|
||||
ini = configparser.ConfigParser(interpolation=None)
|
||||
ini.read_string(text)
|
||||
for section in _dnfs_config:
|
||||
if ini.has_section(section):
|
||||
for key, value in ini[section].items():
|
||||
if key in _dnfs_config[section]:
|
||||
_dnfs_config[section][key] = value
|
||||
|
||||
|
||||
def _fetch_from_confs():
|
||||
"""从 confs 统一配置服务拉取 dnfs.ini 全文,失败返回 None(fail-safe)。"""
|
||||
try:
|
||||
host = os.environ.get('CONFS_HOST', _dnfs_config['confs']['host'])
|
||||
port = int(os.environ.get('CONFS_PORT', _dnfs_config['confs']['port']))
|
||||
data = RpcClient(host, port, 'dnfs', timeout=3).call(
|
||||
'get', {'name': 'dnfs.ini'})
|
||||
return data.get('content')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_confs_text = _fetch_from_confs()
|
||||
if _confs_text is not None:
|
||||
_merge_ini(_confs_text)
|
||||
logs.info("dnfs", "配置来源: confs 统一配置服务 (dnfs.ini)")
|
||||
else:
|
||||
logs.warning("dnfs", "confs 不可用,使用内置默认值 + 环境变量")
|
||||
|
||||
# 环境变量覆盖(优先级最高,Docker 环境需要)
|
||||
if os.environ.get('LISTEN_ADDR'):
|
||||
_dnfs_config['server']['host'] = os.environ['LISTEN_ADDR']
|
||||
if os.environ.get('LISTEN_PORT'):
|
||||
_dnfs_config['server']['port'] = os.environ['LISTEN_PORT']
|
||||
if os.environ.get('DB_HOST'):
|
||||
_dnfs_config['database']['host'] = os.environ['DB_HOST']
|
||||
if os.environ.get('DB_PORT'):
|
||||
_dnfs_config['database']['port'] = os.environ['DB_PORT']
|
||||
if os.environ.get('REVIEW_URL'):
|
||||
_dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL']
|
||||
if os.environ.get('CONFS_HOST'):
|
||||
_dnfs_config['confs']['host'] = os.environ['CONFS_HOST']
|
||||
if os.environ.get('CONFS_PORT'):
|
||||
_dnfs_config['confs']['port'] = os.environ['CONFS_PORT']
|
||||
|
||||
# 提取配置常量
|
||||
_DBV2_ADDR = _dnfs_config['database']['host']
|
||||
_DBV2_PORT = int(_dnfs_config['database']['port'])
|
||||
_DB_TIMEOUT = int(_dnfs_config['database']['timeout'])
|
||||
_REVIEW_SERVER_URL = _dnfs_config['ai_review']['url']
|
||||
_REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout'])
|
||||
_ADDR = _dnfs_config['server']['host']
|
||||
_PORT = int(_dnfs_config['server']['port'])
|
||||
|
||||
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
|
||||
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
|
||||
# 旧 Token 无此字段,按非管理员处理(安全方向)。
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
||||
"""通过 pymsg 短连接调用 dbv2 数据库服务。
|
||||
自动注入 ``_route: "execute"``。
|
||||
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||
|
||||
|
||||
def _call_dbv2(payload: dict, timeout: int = None) -> dict:
|
||||
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
|
||||
|
||||
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
|
||||
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
|
||||
网络层故障抛 ConnectionError。
|
||||
"""
|
||||
payload = {**payload, "_route": "execute"}
|
||||
if timeout is None:
|
||||
timeout = _DB_TIMEOUT
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||
resp_text = client.call(json.dumps(payload), timeout=timeout)
|
||||
return json.loads(resp_text)
|
||||
data = _rpc.call("execute", payload, timeout=timeout)
|
||||
return {"success": True, "data": data}
|
||||
except RpcError as e:
|
||||
return {"success": False, "error": str(e)}
|
||||
except Exception as e:
|
||||
raise ConnectionError(
|
||||
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
|
||||
) from None
|
||||
|
||||
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review'
|
||||
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
# AI审核服务地址
|
||||
def review_content(content: str, timeout: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
|
||||
|
||||
Args:
|
||||
content: 待审核的文本内容,不可为空。
|
||||
timeout: 请求超时时间(秒),默认 10 秒。
|
||||
timeout: 请求超时时间(秒),默认使用配置值。
|
||||
|
||||
Returns:
|
||||
字典,包含接口返回的字段,例如:
|
||||
@@ -72,7 +143,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
- 请求参数错误时:{"error": "content is required"} 等
|
||||
- 网络或异常时:{"error": "request_failed", "detail": "..."}
|
||||
"""
|
||||
url = REVIEW_SERVER_URL
|
||||
if timeout is None:
|
||||
timeout = _REVIEW_TIMEOUT
|
||||
url = _REVIEW_SERVER_URL
|
||||
payload = {"content": content}
|
||||
json_data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
@@ -102,47 +175,112 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
||||
except Exception as e:
|
||||
raise e
|
||||
|
||||
class DnfsExp(Exception):
|
||||
'''dnfs异常类'''
|
||||
def __init__(self, msg: str, code: int):
|
||||
super().__init__(self, msg)
|
||||
self.status = code
|
||||
def get_status(self) -> int:
|
||||
return self.status
|
||||
|
||||
# ==================== 特殊节点(管理员专属) ====================
|
||||
# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染;
|
||||
# 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。
|
||||
_SP_TYPES = ('link', 'html', 'image', 'file')
|
||||
_SP_MAX_URL = 2048 # link 节点 url 长度上限
|
||||
_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符)
|
||||
_SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据)
|
||||
_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度
|
||||
|
||||
|
||||
#basedir = os.path.abspath(os.path.dirname(__file__))
|
||||
def _parse_sp(content):
|
||||
"""识别特殊节点信封:合法信封返回 dict;普通内容返回 None;
|
||||
以 {"_sp" 开头但 JSON 非法视为格式错误(拒绝发布,防半截信封流入)。"""
|
||||
if not isinstance(content, str):
|
||||
return None
|
||||
s = content.lstrip()
|
||||
if not s.startswith('{"_sp"'):
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(s)
|
||||
except Exception:
|
||||
raise DnfsExp('特殊节点格式非法(JSON 解析失败)', 400)
|
||||
if isinstance(obj, dict) and obj.get('_sp') in _SP_TYPES:
|
||||
return obj
|
||||
return None
|
||||
|
||||
|
||||
def _validate_sp(sp):
|
||||
"""校验特殊节点信封字段与大小上限,非法抛 DnfsExp。"""
|
||||
t = sp['_sp']
|
||||
if t == 'link':
|
||||
url = sp.get('url', '')
|
||||
if not isinstance(url, str) or not url.startswith(('http://', 'https://')):
|
||||
raise DnfsExp('链接节点必须是 http/https URL', 400)
|
||||
if len(url) > _SP_MAX_URL:
|
||||
raise DnfsExp('链接过长', 400)
|
||||
elif t == 'html':
|
||||
code = sp.get('code', '')
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
raise DnfsExp('网页代码不能为空', 400)
|
||||
if len(code) > _SP_MAX_CODE:
|
||||
raise DnfsExp('网页代码过大(上限 512KB)', 400)
|
||||
elif t == 'image':
|
||||
data = sp.get('data', '')
|
||||
if not isinstance(data, str) or not data.startswith('data:image/'):
|
||||
raise DnfsExp('图片节点 data 必须是 data:image/ 开头的 DataURL', 400)
|
||||
if len(data) > _SP_MAX_DATA:
|
||||
raise DnfsExp('图片过大(上限约 2.6MB)', 400)
|
||||
elif t == 'file':
|
||||
name = sp.get('name', '')
|
||||
data = sp.get('data', '')
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise DnfsExp('文件节点缺少文件名', 400)
|
||||
if len(name) > 255:
|
||||
raise DnfsExp('文件名过长', 400)
|
||||
if not isinstance(data, str) or not data:
|
||||
raise DnfsExp('文件节点缺少数据', 400)
|
||||
if len(data) > _SP_MAX_DATA:
|
||||
raise DnfsExp('文件过大(上限约 2.6MB)', 400)
|
||||
|
||||
|
||||
def _sp_review_text(title, sp):
|
||||
"""特殊节点的 AI 审核文本:剔除 base64 负载(体量大且无审核意义),仅保留文本字段。"""
|
||||
parts = [title or '']
|
||||
note = sp.get('note')
|
||||
if isinstance(note, str) and note.strip():
|
||||
parts.append(note)
|
||||
t = sp['_sp']
|
||||
if t == 'link':
|
||||
parts.append(str(sp.get('url', '')))
|
||||
elif t == 'html':
|
||||
parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX])
|
||||
else: # image / file
|
||||
parts.append(str(sp.get('name', '')))
|
||||
return '\n'.join(parts)
|
||||
|
||||
# 这个也许不用这么复杂 大不了每个异常点我们抛出DnfsExp类
|
||||
#class InvalidRequest(DnfsExp):
|
||||
# '''非法参数异常'''
|
||||
# def __init__(self, msg: str):
|
||||
# super().__init__(msg)
|
||||
|
||||
def _handle_exp(func):
|
||||
'''处理视图函数中抛出的异常,并在日志模块中进行记录'''
|
||||
def wrapper():
|
||||
try: return func()
|
||||
except DnfsExp as e:
|
||||
logs.warning('dnfs', str(e), extra={})
|
||||
return jsonify({'error': str(e)}), e.get_status()
|
||||
except Exception as e:
|
||||
logs.warning('dnfs', str(e), extra={})
|
||||
return jsonify({'error': str(e)}), 500
|
||||
return wrapper
|
||||
|
||||
app = Flask(__name__)
|
||||
apply_security_headers(app)
|
||||
CORS(app)
|
||||
|
||||
#app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'notes.db')
|
||||
|
||||
#app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
|
||||
#db = SQLAlchemy(app)
|
||||
|
||||
#class Note(db.Model):
|
||||
|
||||
# id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# pid = db.Column(db.Integer, nullable=False)
|
||||
|
||||
# title = db.Column(db.String(128), nullable=False)
|
||||
|
||||
# content = db.Column(db.Text, nullable=False)
|
||||
|
||||
|
||||
|
||||
# def __repr__(self):
|
||||
|
||||
# return f"<Node {self.title}>"
|
||||
|
||||
#with app.app_context():
|
||||
|
||||
# db.create_all()
|
||||
|
||||
|
||||
|
||||
# eg: {
|
||||
# "pid": 0,
|
||||
# "title": "test",
|
||||
# "content": "hello"
|
||||
#}
|
||||
|
||||
def decode_token(token: str):
|
||||
idx = token.find('_')
|
||||
@@ -155,32 +293,26 @@ def decode_token(token: str):
|
||||
|
||||
|
||||
@app.route('/add_note', methods=['POST'])
|
||||
|
||||
@_handle_exp
|
||||
def add_note():
|
||||
|
||||
# 获取请求数据
|
||||
data = request.get_json()
|
||||
|
||||
pid: int = data.get('pid')
|
||||
|
||||
title: str = data.get('title')
|
||||
|
||||
content: str = data.get('content')
|
||||
|
||||
if pid is None or not title or not content:
|
||||
|
||||
logs.warning('dnfs', 'add_note 参数缺失', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Pid, title and content are required"}), 400
|
||||
raise DnfsExp("非法参数", 400)
|
||||
|
||||
info = None
|
||||
auth: str = request.headers.get('Authorization', '')
|
||||
if auth.startswith('Bearer '):
|
||||
token: str = auth[7:]
|
||||
info = decode_token(token)
|
||||
|
||||
if info is None:
|
||||
logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr})
|
||||
return jsonify({"error": "Warning: no Authorization"}), 401
|
||||
raise DnfsExp("未授权访问,从"+request.remote_addr, 401)
|
||||
|
||||
|
||||
# 父节点是否存在
|
||||
if pid != 0:
|
||||
|
||||
select_payload = {
|
||||
@@ -193,19 +325,22 @@ def add_note():
|
||||
|
||||
notes = _call_dbv2(select_payload)
|
||||
|
||||
# 不存在的父节点
|
||||
if len( notes['data']) == 0:
|
||||
logs.warning('dnfs', f'add_note 父节点不存在 pid={pid}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'the new node need a parent'}), 400
|
||||
raise DnfsExp('父节点不存在', 400)
|
||||
|
||||
|
||||
# 内容审查
|
||||
review = review_content(title + '\n' + content, 10)
|
||||
if True != review:
|
||||
#return jsonify({'error': 'review failed', 'data': review}), 400
|
||||
logs.warning('dnfs', f'add_note 内容审核未通过 title={title}', extra={'user': info['user']})
|
||||
return jsonify({'error': 'review failed'}), 400
|
||||
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
|
||||
sp = _parse_sp(content)
|
||||
if sp is not None:
|
||||
if not info.get('is_admin'):
|
||||
raise DnfsExp('特殊节点仅管理员可发布,用户'+info.get('user')+',从'+request.remote_addr, 403)
|
||||
_validate_sp(sp)
|
||||
|
||||
# 内容审查(特殊节点剔除 base64 负载)
|
||||
review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content)
|
||||
if True != review: raise DnfsExp('内容审核不通过,用户'+info.get('user')+',从'+request.remote_addr, 400)
|
||||
|
||||
# 执行插入
|
||||
insert_payload = {
|
||||
'action': 'insert',
|
||||
'table': 'Notes',
|
||||
@@ -218,20 +353,17 @@ def add_note():
|
||||
}
|
||||
|
||||
result = _call_dbv2(insert_payload)
|
||||
|
||||
success = result['success']
|
||||
|
||||
if success is True:
|
||||
logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']})
|
||||
return jsonify({'success': success}), 200
|
||||
else:
|
||||
logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']})
|
||||
return jsonify({'success': success}), 400
|
||||
raise DnfsExp('插入节点失败,用户'+info.get('user')+',从'+request.remote_addr, 400)
|
||||
|
||||
|
||||
# {“pid”: 0}
|
||||
@_handle_exp
|
||||
@app.route('/get_notes', methods=["POST"])
|
||||
|
||||
def get_notes():
|
||||
|
||||
data = request.get_json()
|
||||
@@ -273,36 +405,6 @@ def get_notes():
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
# 当前用户身份查询:配合前端用户面板,返回 user / is_admin。
|
||||
# 与 get_notes 同款可选解析:无 Token 或畸形 Token 按未登录处理(不 500)。
|
||||
@app.route('/whoami', methods=["POST"])
|
||||
def whoami():
|
||||
info = None
|
||||
auth: str = request.headers.get('Authorization', '')
|
||||
if auth.startswith('Bearer '):
|
||||
try:
|
||||
info = decode_token(auth[7:])
|
||||
except Exception:
|
||||
info = None
|
||||
|
||||
if info is None:
|
||||
return jsonify({'login': False}), 200
|
||||
|
||||
resp = {
|
||||
'login': True,
|
||||
'user': info.get('user'),
|
||||
'is_admin': bool(info.get('is_admin'))
|
||||
}
|
||||
# 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间
|
||||
if info.get('iat'):
|
||||
resp['iat'] = info['iat']
|
||||
if info.get('created_at'):
|
||||
resp['created_at'] = info['created_at']
|
||||
if info.get('keep_time'):
|
||||
resp['keep_time'] = info['keep_time']
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
# eg: {"id": 1}
|
||||
@app.route('/delete_note', methods=['POST'])
|
||||
|
||||
@@ -454,8 +556,19 @@ def update_note():
|
||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": 'It\'s not your note'}), 403
|
||||
|
||||
# 内容审查
|
||||
review = review_content(title + '\n' + content, 10)
|
||||
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
|
||||
sp = _parse_sp(content)
|
||||
if sp is not None:
|
||||
if not info.get('is_admin'):
|
||||
logs.warning('dnfs', f'update_note 特殊节点非管理员拒绝 id={id}', extra={'user': info['user']})
|
||||
return jsonify({"error": '特殊节点仅管理员可发布'}), 403
|
||||
try:
|
||||
_validate_sp(sp)
|
||||
except DnfsExp as e:
|
||||
return jsonify({"error": str(e)}), e.get_status()
|
||||
|
||||
# 内容审查(特殊节点剔除 base64 负载)
|
||||
review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content)
|
||||
if True != review:
|
||||
#return jsonify({'error': 'review failed', 'data': review}), 400
|
||||
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
|
||||
@@ -545,10 +658,7 @@ def get_note():
|
||||
# """返回应用主页"""
|
||||
# return render_template('index.html')
|
||||
|
||||
ADDR='127.0.0.1'
|
||||
PORT=8006
|
||||
if __name__ == "__main__":
|
||||
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
|
||||
print(f'Listen at http://{ADDR}:{PORT}')
|
||||
#app.run(host="127.0.0.1", port=8006)
|
||||
serve(app, host='127.0.0.1', port=8006)
|
||||
logs.info('dnfs', f'服务启动,监听 http://{_ADDR}:{_PORT}(db={_DBV2_ADDR}:{_DBV2_PORT}, ai_review={_REVIEW_SERVER_URL})')
|
||||
print(f'Listen at http://{_ADDR}:{_PORT}')
|
||||
serve(app, host=_ADDR, port=_PORT)
|
||||
@@ -0,0 +1,85 @@
|
||||
# dnfs 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `dnfs.py`、`security_middleware.py`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 2 | 0 | 2 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: Token 无 RSA 签名验证,可伪造任意用户身份(含管理员)
|
||||
- **位置**: `dnfs.py:285-292`
|
||||
- **问题**:
|
||||
```python
|
||||
def decode_token(token: str):
|
||||
idx = token.find('_')
|
||||
if idx != -1:
|
||||
raw = token[:idx]
|
||||
# FIXME: 验证数字签名
|
||||
decoded = base64.b64decode(raw)
|
||||
jj = decoded.decode('utf-8')
|
||||
return json.loads(jj)
|
||||
```
|
||||
代码明确标注 `FIXME: 验证数字签名`,但**完全跳过了 RSA 签名验证**:
|
||||
1. 攻击者可自行构造 base64 编码的 JSON payload(`{"user": "任意邮箱", "is_admin": true, ...}`)
|
||||
2. 下划线后拼接任意字符串作为"签名"
|
||||
3. 直接伪造管理员身份,绕过所有权限校验,可增删改任意节点
|
||||
- **修复建议**:
|
||||
1. 立即实现 RSA 签名验证(与 gateway 验签逻辑一致)
|
||||
2. dnfs 需要持有 gateway 的 RSA 公钥(启动时从配置加载,或内置公钥)
|
||||
3. 添加 Token 过期校验(`created_at` + `keep_time`)
|
||||
4. 校验失败返回 401
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | Token 过期校验不严谨 | 需严格验证 `created_at` + `keep_time`,防止过期 Token 被复用 |
|
||||
| P1-2 | is_admin 权限完全依赖 Token 字段,无二次验证 | 管理员权限应在服务端再次校验,而非仅信任 Token 声明 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | AI 审核 fail-closed 实现需验证:ai_review 不可用时是否确实拒绝写入 |
|
||||
| P2-2 | 节点删除无确认机制,批量删除无速率限制 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **特殊节点校验**: link/html/image/file 各类型有格式与大小限制(URL 前缀、大小上限)
|
||||
2. ✅ **AI 内容审核集成**: 新增节点前调用 ai_review 服务审核
|
||||
3. ✅ **security_middleware.py**: 安全响应头
|
||||
4. ✅ **CORS 配置**: 跨域控制
|
||||
5. ✅ **通过 pymsg 调 db 服务**: 不直连数据库,规避 SQL 注入
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 实现 Token RSA 签名验证(最高优先级)
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] Token 过期严格校验
|
||||
- [ ] is_admin 二次验证
|
||||
|
||||
### 中期
|
||||
- [ ] AI 审核 fail-closed 验证测试
|
||||
- [ ] 节点操作速率限制
|
||||
+244
-174
@@ -1,174 +1,244 @@
|
||||
"""
|
||||
统一日志客户端 — 各服务共用。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient()
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目
|
||||
|
||||
|
||||
def _safe_json(obj):
|
||||
"""安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。"""
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
"""生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._flush_done = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。
|
||||
|
||||
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
|
||||
"""
|
||||
entry = {
|
||||
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略)
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
pass # 极端竞争下仍失败则静默丢弃
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_done.clear()
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
|
||||
self._flush_done.wait(timeout=self._flush_interval + 2.0)
|
||||
# 主线程排空队列中剩余条目
|
||||
self._drain_queue_to_db()
|
||||
|
||||
def _drain_queue_to_db(self):
|
||||
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
|
||||
|
||||
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
|
||||
都不会死亡,避免日志队列无限增长导致 OOM。
|
||||
"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
# _send 内部已有细粒度异常处理,此处是最终安全网
|
||||
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
if self._flush_signal.is_set():
|
||||
self._flush_signal.clear()
|
||||
self._flush_done.set()
|
||||
except Exception as e:
|
||||
# 最外层安全网:任何意外异常都不应杀死日志线程
|
||||
print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
time.sleep(0.5) # 防止异常风暴导致 CPU 满载
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
|
||||
self._flush_done.set()
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
|
||||
|
||||
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
|
||||
不抛异常给调用方。
|
||||
"""
|
||||
try:
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
except Exception as e:
|
||||
print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr)
|
||||
return
|
||||
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
try:
|
||||
extra_json = _safe_json(extra_val) if extra_val is not None else None
|
||||
except Exception:
|
||||
extra_json = None
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": entry.get("ts", _now_iso()),
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": extra_json,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient()
|
||||
@@ -0,0 +1,109 @@
|
||||
version: '3.8'
|
||||
|
||||
# ── 持久化说明 ──────────────────────────────────────────────────────
|
||||
# 默认使用 Docker named volume(适合生产,性能好)。
|
||||
# 开发调试或需要直接访问数据库文件时,可改为 bind mount:
|
||||
# volumes:
|
||||
# - ./data/db:/app/data # SQLite 数据库文件
|
||||
# - ./backups:/app/backups # 备份输出目录
|
||||
#
|
||||
# 注意:bind mount 在 macOS/Windows 上文件系统性能比 named volume 差,
|
||||
# 但方便直接复制/查看数据库文件。
|
||||
|
||||
services:
|
||||
# ── 数据库服务 (pymsg TCP协议) ──────────────────────────────────
|
||||
db:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: db/Dockerfile
|
||||
container_name: micro-db
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8005:8005"
|
||||
volumes:
|
||||
# 方式1: Docker named volume(推荐生产)
|
||||
- db_data:/app/data
|
||||
# 方式2: Bind mount(开发/需要直接访问文件时取消注释)
|
||||
# - ./data/db:/app/data
|
||||
# 备份输出目录(bind mount 方便取备份文件)
|
||||
- ./backups:/app/backups
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8005
|
||||
- DATABASE_URL=sqlite:////app/data/database.db
|
||||
- WORKERS=4
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import socket; s=socket.socket(); s.settimeout(2); s.connect(('localhost', 8005)); s.close()"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
# ── AI内容审核服务 ──────────────────────────────────────────────
|
||||
ai_review:
|
||||
build:
|
||||
context: ./ai_review
|
||||
dockerfile: Dockerfile
|
||||
container_name: micro-ai-review
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8007:8007"
|
||||
volumes:
|
||||
- chroma_data:/app/chroma
|
||||
# - ./data/chroma:/app/chroma # bind mount 选项
|
||||
environment:
|
||||
- AI_REVIEW_HOST=0.0.0.0
|
||||
- AI_REVIEW_PORT=8007
|
||||
- CHROMA_PERSIST_DIR=/app/chroma
|
||||
env_file:
|
||||
- ./ai_review/.env
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8007/health', timeout=5)"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
# ── DNFS 树状文件系统服务 ──────────────────────────────────────
|
||||
dnfs:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dnfs/Dockerfile
|
||||
container_name: micro-dnfs
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:8006:8006"
|
||||
environment:
|
||||
- LISTEN_ADDR=0.0.0.0
|
||||
- LISTEN_PORT=8006
|
||||
- DB_HOST=db
|
||||
- DB_PORT=8005
|
||||
- REVIEW_URL=http://ai_review:8007/review
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ai_review:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8006/get_notes', data=b'{\"pid\":0}', timeout=3)"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
networks:
|
||||
- micro_net
|
||||
|
||||
networks:
|
||||
micro_net:
|
||||
driver: bridge
|
||||
name: micro-network
|
||||
|
||||
volumes:
|
||||
db_data:
|
||||
name: micro-db-data
|
||||
chroma_data:
|
||||
name: micro-chroma-data
|
||||
@@ -0,0 +1,386 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# 微服务容器化管理脚本
|
||||
# 用法: ./docker.sh [command]
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker &> /dev/null; then
|
||||
error "Docker 未安装,请先安装 Docker Desktop"
|
||||
fi
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
error "Docker Compose 未安装"
|
||||
fi
|
||||
}
|
||||
|
||||
# 使用 docker compose 或 docker-compose(兼容旧版本)
|
||||
docker_compose() {
|
||||
if docker compose version &> /dev/null; then
|
||||
docker compose "$@"
|
||||
else
|
||||
docker-compose "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
info "构建所有服务镜像..."
|
||||
docker_compose build --progress=plain "$@"
|
||||
info "构建完成"
|
||||
}
|
||||
|
||||
cmd_up() {
|
||||
info "启动服务(后台运行)..."
|
||||
docker_compose up -d "$@"
|
||||
info "服务已启动"
|
||||
echo ""
|
||||
cmd_ps
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
info "停止并移除容器..."
|
||||
docker_compose down "$@"
|
||||
info "服务已停止"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
info "启动已存在的容器..."
|
||||
docker_compose start "$@"
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
info "停止运行中的容器..."
|
||||
docker_compose stop "$@"
|
||||
}
|
||||
|
||||
cmd_restart() {
|
||||
info "重启服务..."
|
||||
docker_compose restart "$@"
|
||||
}
|
||||
|
||||
cmd_ps() {
|
||||
info "服务状态:"
|
||||
docker_compose ps
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
if [ -z "$1" ]; then
|
||||
docker_compose logs -f --tail=100
|
||||
else
|
||||
docker_compose logs -f --tail=100 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_shell() {
|
||||
local service=${1:-dnfs}
|
||||
info "进入 $service 容器 shell..."
|
||||
docker_compose exec "$service" /bin/bash || docker_compose exec "$service" /bin/sh
|
||||
}
|
||||
|
||||
cmd_rebuild() {
|
||||
info "重新构建并启动服务..."
|
||||
docker_compose build --no-cache "$@"
|
||||
docker_compose up -d "$@"
|
||||
}
|
||||
|
||||
cmd_clean() {
|
||||
warn "此操作将停止所有容器并删除数据卷(数据库和ChromaDB数据将丢失!)"
|
||||
read -p "确认继续? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
docker_compose down -v
|
||||
info "已清理所有容器和数据卷"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_pull() {
|
||||
info "拉取基础镜像..."
|
||||
docker pull python:3.11-slim
|
||||
}
|
||||
|
||||
cmd_health() {
|
||||
info "检查服务健康状态..."
|
||||
local services=("db:8005" "ai_review:8007" "dnfs:8006")
|
||||
for svc in "${services[@]}"; do
|
||||
IFS=':' read -r name port <<< "$svc"
|
||||
if nc -z 127.0.0.1 "$port" 2>/dev/null; then
|
||||
echo -e " ${GREEN}✓${NC} $name :$port 健康"
|
||||
else
|
||||
echo -e " ${RED}✗${NC} $name :$port 不可达"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ── 数据库管理命令 ──────────────────────────────────────────────
|
||||
|
||||
DB_CONTAINER="micro-db"
|
||||
|
||||
_check_db_running() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${DB_CONTAINER}$"; then
|
||||
error "db 容器未运行,请先启动服务: ./docker.sh up"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_info() {
|
||||
_check_db_running
|
||||
info "数据库信息:"
|
||||
docker_compose exec db python db_tools.py info
|
||||
}
|
||||
|
||||
cmd_db_backup() {
|
||||
_check_db_running
|
||||
local backup_file="${1:-}"
|
||||
if [ -z "$backup_file" ]; then
|
||||
backup_file="backup_$(date +%Y%m%d_%H%M%S).db"
|
||||
fi
|
||||
info "执行数据库备份..."
|
||||
# 先做 WAL checkpoint 确保数据完整
|
||||
docker_compose exec db python db_tools.py wal-checkpoint
|
||||
# 备份到容器内的 /app/backups 目录(映射到宿主机 ./backups)
|
||||
docker_compose exec db python db_tools.py backup "/app/backups/$backup_file"
|
||||
echo ""
|
||||
info "备份文件: ./backups/$backup_file"
|
||||
ls -lh "./backups/$backup_file" 2>/dev/null || info "(文件已写入容器 /app/backups/) "
|
||||
}
|
||||
|
||||
cmd_db_export() {
|
||||
_check_db_running
|
||||
local export_file="${1:-}"
|
||||
if [ -z "$export_file" ]; then
|
||||
export_file="export_$(date +%Y%m%d_%H%M%S).json"
|
||||
fi
|
||||
info "导出数据库为 JSON..."
|
||||
docker_compose exec db python db_tools.py export "/app/backups/$export_file"
|
||||
echo ""
|
||||
info "导出文件: ./backups/$export_file"
|
||||
}
|
||||
|
||||
cmd_db_import() {
|
||||
local import_file="$1"
|
||||
if [ -z "$import_file" ]; then
|
||||
error "用法: ./docker.sh db-import <json文件路径>"
|
||||
fi
|
||||
if [ ! -f "./backups/$import_file" ] && [ ! -f "$import_file" ]; then
|
||||
# 检查是否在backups目录
|
||||
error "导入文件不存在: $import_file (请放在 ./backups/ 目录下或提供绝对路径)"
|
||||
fi
|
||||
_check_db_running
|
||||
|
||||
local container_path="/app/backups/$(basename "$import_file")"
|
||||
# 如果是宿主机路径且不在backups下,先复制到backups
|
||||
if [ -f "$import_file" ] && [ ! -f "./backups/$(basename "$import_file")" ]; then
|
||||
warn "复制文件到 backups 目录..."
|
||||
cp "$import_file" "./backups/"
|
||||
fi
|
||||
|
||||
warn "即将导入数据,现有数据将被覆盖!"
|
||||
read -p "确认继续? [y/N] " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# 先备份当前数据
|
||||
cmd_db_backup "before_import_$(date +%Y%m%d_%H%M%S).db"
|
||||
info "开始导入..."
|
||||
docker_compose exec db python db_tools.py import "$container_path"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_vacuum() {
|
||||
_check_db_running
|
||||
info "执行 VACUUM 压缩数据库..."
|
||||
docker_compose exec db python db_tools.py vacuum
|
||||
}
|
||||
|
||||
cmd_db_restore() {
|
||||
local backup_file="$1"
|
||||
if [ -z "$backup_file" ]; then
|
||||
error "用法: ./docker.sh db-restore <备份文件路径> (从 ./backups/ 目录)"
|
||||
error "示例: ./docker.sh db-restore backup_20260730_120000.db"
|
||||
fi
|
||||
|
||||
local backup_path="./backups/$backup_file"
|
||||
if [ ! -f "$backup_path" ]; then
|
||||
# 尝试绝对路径
|
||||
if [ -f "$backup_file" ]; then
|
||||
backup_path="$backup_file"
|
||||
else
|
||||
error "备份文件不存在: $backup_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
_check_db_running
|
||||
|
||||
warn "即将从备份恢复数据库,当前数据将被覆盖!"
|
||||
warn "备份文件: $backup_path"
|
||||
read -p "确认继续? [y/N] 输入 YES 确认: " -r
|
||||
echo
|
||||
if [[ "$REPLY" == "YES" ]]; then
|
||||
info "停止 dnfs 和 ai_review 服务..."
|
||||
docker_compose stop dnfs ai_review
|
||||
|
||||
info "复制备份文件到容器..."
|
||||
docker cp "$backup_path" "$DB_CONTAINER:/app/data/database.db"
|
||||
|
||||
info "重启 db 服务..."
|
||||
docker_compose restart db
|
||||
|
||||
# 等待db健康
|
||||
info "等待 db 服务恢复..."
|
||||
sleep 5
|
||||
|
||||
info "启动其他服务..."
|
||||
docker_compose start dnfs ai_review
|
||||
|
||||
info "恢复完成!"
|
||||
else
|
||||
info "已取消"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_db_list_backups() {
|
||||
info "备份文件列表 (./backups/):"
|
||||
if [ -d "./backups" ]; then
|
||||
ls -lhS ./backups/ 2>/dev/null | grep -v "total" | grep -v ".gitkeep" || echo " (无备份文件)"
|
||||
else
|
||||
echo " backups 目录不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "微服务容器化管理脚本"
|
||||
echo ""
|
||||
echo "用法: ./docker.sh <command> [options]"
|
||||
echo ""
|
||||
echo "── 服务管理 ──"
|
||||
echo " build 构建所有服务镜像"
|
||||
echo " up 启动所有服务(后台运行)"
|
||||
echo " down 停止并移除所有容器"
|
||||
echo " start 启动已停止的容器"
|
||||
echo " stop 停止运行中的容器"
|
||||
echo " restart 重启服务"
|
||||
echo " ps 查看服务状态"
|
||||
echo " logs [svc] 查看日志(默认所有服务,可指定服务名)"
|
||||
echo " shell [svc] 进入容器shell(默认dnfs)"
|
||||
echo " rebuild 重新构建(无缓存)并启动"
|
||||
echo " health 检查服务健康状态"
|
||||
echo " pull 拉取最新基础镜像"
|
||||
echo " clean 停止并删除所有容器+数据卷(危险!)"
|
||||
echo ""
|
||||
echo "── 数据库管理 (SQLite) ──"
|
||||
echo " db-info 查看数据库信息和表统计"
|
||||
echo " db-backup [file] 备份数据库(.db 二进制文件,输出到 ./backups/)"
|
||||
echo " db-export [file] 导出数据为 JSON(跨平台传输,输出到 ./backups/)"
|
||||
echo " db-import <file> 从 JSON 导入数据(文件放在 ./backups/ 下)"
|
||||
echo " db-restore <file> 从 .db 备份文件恢复"
|
||||
echo " db-vacuum VACUUM 压缩数据库"
|
||||
echo " db-ls 列出备份文件"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " ./docker.sh build dnfs # 仅构建dnfs服务"
|
||||
echo " ./docker.sh logs dnfs # 查看dnfs日志"
|
||||
echo " ./docker.sh shell db # 进入db容器"
|
||||
echo " ./docker.sh db-backup # 立即备份"
|
||||
echo " ./docker.sh db-export # 导出JSON用于迁移"
|
||||
echo " ./docker.sh db-ls # 查看现有备份"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
check_docker
|
||||
|
||||
case "${1:-}" in
|
||||
build)
|
||||
shift
|
||||
cmd_build "$@"
|
||||
;;
|
||||
up)
|
||||
shift
|
||||
cmd_up "$@"
|
||||
;;
|
||||
down)
|
||||
shift
|
||||
cmd_down "$@"
|
||||
;;
|
||||
start)
|
||||
shift
|
||||
cmd_start "$@"
|
||||
;;
|
||||
stop)
|
||||
shift
|
||||
cmd_stop "$@"
|
||||
;;
|
||||
restart)
|
||||
shift
|
||||
cmd_restart "$@"
|
||||
;;
|
||||
ps|status)
|
||||
cmd_ps
|
||||
;;
|
||||
logs)
|
||||
shift
|
||||
cmd_logs "$@"
|
||||
;;
|
||||
shell)
|
||||
shift
|
||||
cmd_shell "$@"
|
||||
;;
|
||||
rebuild)
|
||||
shift
|
||||
cmd_rebuild "$@"
|
||||
;;
|
||||
health)
|
||||
cmd_health
|
||||
;;
|
||||
pull)
|
||||
cmd_pull
|
||||
;;
|
||||
clean)
|
||||
cmd_clean
|
||||
;;
|
||||
# ── 数据库管理命令 ──
|
||||
db-info|dbinfo)
|
||||
cmd_db_info
|
||||
;;
|
||||
db-backup|dbbackup)
|
||||
shift
|
||||
cmd_db_backup "$@"
|
||||
;;
|
||||
db-export|dbexport)
|
||||
shift
|
||||
cmd_db_export "$@"
|
||||
;;
|
||||
db-import|dbimport)
|
||||
shift
|
||||
cmd_db_import "$@"
|
||||
;;
|
||||
db-restore|dbrestore)
|
||||
shift
|
||||
cmd_db_restore "$@"
|
||||
;;
|
||||
db-vacuum|dbvacuum)
|
||||
cmd_db_vacuum
|
||||
;;
|
||||
db-ls|dbls)
|
||||
cmd_db_list_backups
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,78 @@
|
||||
# 部署配置安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `docker-compose.yml`、`docker.sh`、`_inst.sh`、`_setup.*.sh`、`.env.example`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 2 | 0 | 2 |
|
||||
| P2 | 3 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: WAF 规则默认全部注释,WAF 形同虚设
|
||||
- **位置**: `waf/rules/local/custom.conf`
|
||||
- **问题**: 所有 WAF 安全规则默认被注释掉:
|
||||
```
|
||||
# SecRule ... SQL 注入规则...
|
||||
# SecRule ... XSS 规则...
|
||||
```
|
||||
部署后 WAF 实际不拦截任何攻击,恶意请求直接透传到后端
|
||||
- **修复建议**:
|
||||
1. 启用核心规则集(SQLi、XSS、命令注入等关键规则)
|
||||
2. docker.sh 启动时检测规则是否启用,若全注释则打印警告
|
||||
3. 异常模式先运行一周,观察误报后切换到拦截模式
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 位置 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P1-1 | 默认密钥/密码需检查 | `.env.example`、各 `_set.sh` | 确认没有硬编码的默认数据库密码、API Key |
|
||||
| P1-2 | Docker 容器权限过高 | `docker-compose.yml` | 检查是否使用 `--privileged`、是否挂载了敏感目录(`/etc`、`/root`) |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | Docker 镜像未指定非 root 用户运行 |
|
||||
| P2-2 | 没有健康检查配置,异常服务不会自动重启 |
|
||||
| P2-3 | 数据库文件/配置文件未配置定期备份 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **端口绑定限制**: 内部服务(db、confs、smtp、captcha、pymsg 服务)均绑定 `127.0.0.1`,不对外暴露
|
||||
2. ✅ **Docker Compose 编排**: 容器化部署,环境一致性
|
||||
3. ✅ **.env.example 模板**: 环境变量配置模板,不硬编码密钥
|
||||
4. ✅ **独立 Docker 网络**: 服务间通过内部网络通信
|
||||
5. ✅ **waf 前置**: 所有公网流量先经过 WAF 再到 gateway
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 启用 WAF 核心规则(关键攻击类型)
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 检查所有默认密码/密钥
|
||||
- [ ] 容器非 root 用户运行
|
||||
- [ ] 移除不必要的 privileged 权限
|
||||
|
||||
### 中期
|
||||
- [ ] 添加健康检查配置
|
||||
- [ ] 自动备份策略
|
||||
- [ ] Docker 镜像安全扫描
|
||||
@@ -0,0 +1,68 @@
|
||||
# C++ 共享库安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `libth/`(线程库)、`libhttp/`(HTTP/WebSocket)、`libcrypt/`(加密)、`libsqlite3/`(SQLite封装)
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 2 | 0 | 2 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: libhttp HTTP 解析器需 fuzz 测试,潜在缓冲区溢出
|
||||
- **位置**: `libhttp/src/HttpServer.cpp`
|
||||
- **问题**: 自研 C++ HTTP 解析器未经过大规模 fuzz 测试:
|
||||
- 异常 HTTP 方法、超长 header、畸形 chunked 编码可能触发缓冲区溢出
|
||||
- 边界条件处理不当可导致拒绝服务或远程代码执行
|
||||
- **修复建议**:
|
||||
1. 使用 AFL++ 或 libFuzzer 对 HTTP 解析器进行 fuzz 测试
|
||||
2. 重点测试:header 解析、URL 解析、chunked 编码、Content-Length 处理
|
||||
3. 考虑替换为成熟库(如 llhttp、picohttpparser)
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 位置 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P1-1 | libcrypt RSA 实现需审计 | `libcrypt/src/Crypto_Basic.cpp`、`inc/us/MyRSA.hpp` | 自研 RSA 实现需验证侧信道攻击防护、填充方案(PKCS#1 v1.5/OAEP)正确性 |
|
||||
| P1-2 | WebSocket 帧解析需 fuzz | `libhttp/src/WebSocketServer.cpp` | 畸形帧、超大帧、分片帧处理需验证 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | libsqlite3 封装需验证 SQL 注入防护(确认使用参数化查询) |
|
||||
| P2-2 | libth 线程库同步原语需验证死锁/竞态条件 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **RAII 智能指针**: 减少内存泄漏和 use-after-free
|
||||
2. ✅ **C++ 类型安全**: 相比纯 C 减少类型混淆漏洞
|
||||
3. ✅ **项目内统一库**: 代码可控,可快速修复
|
||||
4. ✅ **libcrypt 已实现**: PBKDF2、SHA256、AES-CBC、RSA、Base64
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 对 libhttp HTTP/WebSocket 解析器进行 fuzz 测试
|
||||
- [ ] 验证 libsqlite3 封装的参数化查询正确使用
|
||||
|
||||
### 中期
|
||||
- [ ] libcrypt RSA 实现安全审计
|
||||
- [ ] 考虑替换 HTTP 解析器为成熟第三方库
|
||||
@@ -0,0 +1,427 @@
|
||||
# micro_servers 项目整体安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: 全部模块(core/waf/gateway/pymsg/db/static/dnfs/ai_review/smtp/captcha/confs/aibox + C++共享库 + 部署配置)
|
||||
**代码版本**: dev_qing 分支(工作区版本)
|
||||
|
||||
---
|
||||
|
||||
## 问题严重等级定义
|
||||
|
||||
| 等级 | 颜色 | 说明 | 响应时间 |
|
||||
|------|------|------|----------|
|
||||
| **P0** | 🔴 严重 | 可直接被利用获取系统权限、接管账户、或造成服务完全不可用 | **立即修复(24小时内)** |
|
||||
| **P1** | 🟠 高危 | 可被利用获取敏感数据、绕过认证、或造成部分功能不可用 | 72小时内修复 |
|
||||
| **P2** | 🟡 中危 | 存在安全隐患但利用难度较高或影响有限 | 一周内修复 |
|
||||
| **P3** | 🟢 低危 | 健壮性/信息泄露/最佳实践类问题,风险较低 | 版本迭代时修复 |
|
||||
|
||||
---
|
||||
|
||||
## 修复进度总览
|
||||
|
||||
| 模块 | P0 总数 | 已修复 | 待修复 | P1 总数 | 已修复 | 待修复 |
|
||||
|------|---------|--------|--------|---------|--------|--------|
|
||||
| aibox | 2 | 0 | 2 | 4 | 0 | 4 |
|
||||
| waf | 3 | 2 | 1 | 5 | 1 | 4 |
|
||||
| confs | 1 | 1 | 0 | 7 | 7 | 0 |
|
||||
| pymsg | 3 | 1 | 2 | 5 | 0 | 5 |
|
||||
| db | 1 | 0 | 1 | 3 | 0 | 3 |
|
||||
| static | 3 | 0 | 3 | 3 | 0 | 3 |
|
||||
| dnfs | 1 | 0 | 1 | 2 | 0 | 2 |
|
||||
| smtp | 2 | 0 | 2 | 2 | 0 | 2 |
|
||||
| ai_review | 1 | 0 | 1 | 1 | 0 | 1 |
|
||||
| captcha | 0 | 0 | 0 | 1 | 0 | 1 |
|
||||
| core | 1 | 0 | 1 | 1 | 0 | 1 |
|
||||
| gateway | 0 | 0 | 0 | 2 | 0 | 2 |
|
||||
| **合计** | **18** | **4** | **14** | **36** | **8** | **28** |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题(必须立即修复)
|
||||
|
||||
### P0-1: static 服务无认证密码重置,可直接接管任意账户
|
||||
- **位置**: [static/fsys.py#L278-L292](file:///Users/qingsong/micro_servers/static/fsys.py#L278-L292)
|
||||
- **问题描述**:
|
||||
```python
|
||||
@app.route('/api/reset_password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
data = request.get_json(force=True)
|
||||
email = data.get('email', '').strip()
|
||||
new_hash = data.get('password_hash', '').strip()
|
||||
if not email or not new_hash:
|
||||
return jsonify({"success": False, "error": "missing params"}), 400
|
||||
conn = _sql.connect(GATEWAY_DB)
|
||||
conn.execute("UPDATE Users SET password_hash=? WHERE email=?", (new_hash, email))
|
||||
conn.commit()
|
||||
```
|
||||
`/api/reset_password` 接口**没有任何认证校验**,任何人只要知道邮箱即可修改任意用户密码(包括管理员),直接接管账户。
|
||||
- **利用方式**: 发送 POST 请求到 `/api/reset_password`,传入 `email` 和 `password_hash` 即可重置密码。
|
||||
- **修复建议**:
|
||||
1. 此接口必须在 gateway 层做严格的管理员权限校验(`is_admin=true`),或直接删除此接口(密码重置应走邮件验证码流程)
|
||||
2. 短期缓解:在 static 服务的 security_middleware 中添加 IP 白名单或 token 校验,只允许本机访问
|
||||
3. 长期:彻底移除此接口,密码重置功能通过 smtp 验证码 + gateway 统一实现
|
||||
|
||||
---
|
||||
|
||||
### P0-2: dnfs Token 无签名验证,可伪造任意用户身份
|
||||
- **位置**: [dnfs/dnfs.py#L285-L292](file:///Users/qingsong/micro_servers/dnfs/dnfs.py#L285-L292)
|
||||
- **问题描述**:
|
||||
```python
|
||||
def decode_token(token: str):
|
||||
idx = token.find('_')
|
||||
if idx != -1:
|
||||
raw = token[:idx]
|
||||
# FIXME: 验证数字签名
|
||||
decoded = base64.b64decode(raw)
|
||||
jj = decoded.decode('utf-8')
|
||||
return json.loads(jj)
|
||||
```
|
||||
代码中明确标注了 `FIXME: 验证数字签名`,但实际上**完全跳过了 RSA 签名验证**。攻击者可以:
|
||||
1. 自行构造 base64 编码的 JSON payload(包含任意 `user`、`is_admin=true`)
|
||||
2. 拼接任意字符串作为"签名"部分
|
||||
3. 直接伪造管理员身份,绕过所有权限校验
|
||||
- **修复建议**:
|
||||
1. 立即实现 RSA 签名验证,与 gateway 的 Token 验签逻辑保持一致
|
||||
2. dnfs 需要持有 gateway 的 RSA 公钥,或改为通过 pymsg 调 gateway 统一验签
|
||||
3. 添加 Token 过期校验(检查 `created_at` + `keep_time`)
|
||||
|
||||
---
|
||||
|
||||
### P0-3: static yhdet OAuth Token 为 base64(email:password),解码即得明文密码
|
||||
- **位置**: [static/fsys.py#L398-L400](file:///Users/qingsong/micro_servers/static/fsys.py#L398-L400)
|
||||
- **问题描述**:
|
||||
```python
|
||||
token_data = f"{email}:{password}"
|
||||
token = base64.urlsafe_b64encode(token_data.encode()).decode()
|
||||
return jsonify({"success": True, "token": token})
|
||||
```
|
||||
yhdet 登录接口签发的 Token 仅仅是 `email:password` 的 base64 编码,**没有任何签名或加密**:
|
||||
1. 任何获得此 Token 的人(如 XSS、日志泄露、网络抓包)都可以直接 base64 解码得到用户明文密码
|
||||
2. 密码可能在其他网站复用,导致更大范围的账户接管
|
||||
3. `/api/yhdet_verify` 接口直接解码后查数据库比对,等价于明文密码传输
|
||||
- **修复建议**:
|
||||
1. 立即废弃此 Token 设计,改为使用与 gateway 一致的 RSA 签名 Token
|
||||
2. yhdet OAuth 回调后应调用 gateway 的登录接口获取正规 Token,而非自行签发
|
||||
3. 现有 yhdet_* 用户需要强制重置密码
|
||||
|
||||
---
|
||||
|
||||
### P0-4: db 服务零认证对外开放完整 CRUD,虽绑定 127.0.0.1 但无纵深防御
|
||||
- **位置**: [db/dbv2.py](file:///Users/qingsong/micro_servers/db/dbv2.py)
|
||||
- **问题描述**:
|
||||
- db 服务虽然默认监听 `127.0.0.1:8005`,但**完全没有认证机制**
|
||||
- 任何能访问本机 8005 端口的进程/用户都可以执行任意 SQL(select/insert/update/delete)
|
||||
- 可读取 Users 表的密码哈希、Logs 表的所有日志、修改/删除任意数据
|
||||
- 没有表级/操作级权限控制
|
||||
- SELECT 无 limit 上限,可被拖库
|
||||
- **当前缓解**: 依赖 Docker/防火墙绑定 127.0.0.1 作为唯一防护
|
||||
- **修复建议**:
|
||||
1. 添加 API Token 认证(所有 pymsg 调用需携带预共享密钥)
|
||||
2. 添加操作权限控制(如日志写入只允许 insert,不允许 delete)
|
||||
3. SELECT 强制添加 LIMIT 上限(如默认 1000 条),防止拖库
|
||||
4. 敏感操作(如 DROP、UPDATE 不带 WHERE)直接拒绝
|
||||
|
||||
---
|
||||
|
||||
### P0-5: pymsg recv 超时可被绕过(Slowloris 变种 DoS)
|
||||
- **位置**: [pymsg/pymsg/__init__.py#L102-L153](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py#L102-L153)
|
||||
- **问题描述**:
|
||||
虽然添加了 `recv_timeout=10s`,但 `sock.settimeout()` 设置的是**单次系统调用**超时,不是整个消息接收的总超时:
|
||||
- 4 字节长度头最长可占用 `4 * 10s = 40秒`
|
||||
- 载荷部分按字节发送可使单个连接存活任意长时间
|
||||
- 少量攻击者即可耗尽服务端连接/线程资源
|
||||
- **影响范围**: 所有使用 pymsg 的服务(db/confs/smtp/captcha/waf logclient)
|
||||
- **修复建议**: 使用 deadline 模式替代单次 timeout:
|
||||
```python
|
||||
deadline = time.monotonic() + self._recv_timeout
|
||||
while got < n:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError(...)
|
||||
sock.settimeout(remaining)
|
||||
chunk = sock.recv_into(view[got:], n - got)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-6: pymsg UnicodeDecodeError 可导致同步模式服务崩溃
|
||||
- **位置**: [pymsg/pymsg/__init__.py#L111](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py#L111)、[pymsg/pymsg/__init__.py#L434-L448](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py#L434-L448)
|
||||
- **问题描述**:
|
||||
`payload.decode("utf-8")` 抛出的 `UnicodeDecodeError` 是 `ValueError` 子类,不在 `_handle_client` 的异常捕获列表 `(ConnectionError, ConnectionResetError, OSError, TimeoutError)` 中。
|
||||
**同步模式(workers=0)下收到无效 UTF-8 字节会直接导致整个服务进程崩溃!**
|
||||
- **影响范围**: 所有使用 pymsg 且 workers=0 的服务(默认配置)
|
||||
- **修复建议**: 在异常捕获列表中添加 `UnicodeDecodeError` 和 `json.JSONDecodeError`
|
||||
|
||||
---
|
||||
|
||||
### P0-7: core PMC HTTP 服务无认证,可执行任意系统命令
|
||||
- **位置**: [core/src/main/HttpTask.cpp#L53](file:///Users/qingsong/micro_servers/core/src/main/HttpTask.cpp#L53)、[core/src/main/HttpTask.cpp#L130-L142](file:///Users/qingsong/micro_servers/core/src/main/HttpTask.cpp#L130-L142)
|
||||
- **问题描述**:
|
||||
- HTTP 服务虽然绑定 `127.0.0.1:5200`,但**没有任何认证**
|
||||
- `/exec` 接口可以执行任意 shell 命令(通过 `boost::process::shell`)
|
||||
- `/kill` 接口可以杀死任意进程
|
||||
- 消息队列通道(Pipe/Mq)的 kill/exec 虽然被注释了,但 HTTP 接口是开放的
|
||||
- 任何本地用户/进程都可以通过 `POST http://127.0.0.1:5200/exec?command=...` 执行任意命令(core 通常以 root 运行)
|
||||
- **修复建议**:
|
||||
1. 短期:确保 core HTTP 服务不暴露在公网,配置文件中默认关闭 serve 模式
|
||||
2. 中期:添加 Unix socket 替代 TCP,利用文件系统权限控制访问
|
||||
3. 长期:添加 token 认证或仅允许 root 用户访问
|
||||
|
||||
---
|
||||
|
||||
### P0-8: aibox 命令注入漏洞(shell=True)
|
||||
- **位置**: [aibox/aibox.py](file:///Users/qingsong/micro_servers/aibox/aibox.py) `execute_local_command` 工具(~L2670)
|
||||
- **问题描述**: 使用 `subprocess.run(cmd, shell=True)` 并直接拼接用户/AI 输入的命令,可执行任意系统操作
|
||||
- **修复建议**:
|
||||
1. 禁止 `shell=True`,改用参数列表形式
|
||||
2. 添加命令白名单机制
|
||||
3. 限制工作目录
|
||||
|
||||
---
|
||||
|
||||
### P0-9: aibox WebSocket 无认证且默认监听 0.0.0.0
|
||||
- **位置**: [aibox/aibox.py](file:///Users/qingsong/micro_servers/aibox/aibox.py) WebSocketHandler
|
||||
- **问题描述**:
|
||||
- WebSocket 服务监听 `0.0.0.0:8765` 公网可访问
|
||||
- 无任何 token/密码认证
|
||||
- 结合工具调用漏洞可直接控制服务器、消耗 API 费用
|
||||
- **修复建议**: 默认绑定 127.0.0.1,通过 gateway 统一接入认证
|
||||
|
||||
---
|
||||
|
||||
### P0-10: WAF 规则默认全部注释,WAF 启动后无任何防护
|
||||
- **位置**: [waf/rules/local/custom.conf](file:///Users/qingsong/micro_servers/waf/rules/local/custom.conf)
|
||||
- **问题描述**: 所有本地自定义规则(SQL注入、XSS、路径遍历、命令注入、扫描器UA)全部被注释掉,WAF 处于"裸奔"状态
|
||||
- **修复建议**:
|
||||
1. 至少启用一组保守的默认规则(扫描器UA拦截、明显SQL注入特征)
|
||||
2. 或在启动日志中明确警告当前为检测-only模式
|
||||
3. README 中醒目标注需要取消规则注释
|
||||
|
||||
---
|
||||
|
||||
### P0-11: smtp.py 旧版服务存在大量致命漏洞
|
||||
- **位置**: [smtp/smtp.py](file:///Users/qingsong/micro_servers/smtp/smtp.py)(旧版,smtp_v2.py 为新版)
|
||||
- **问题描述**:
|
||||
- 旧版 smtp.py 缺乏速率限制、无冷却机制、无验证码过期清理
|
||||
- 可被用于邮件炸弹攻击
|
||||
- 建议确认 smtp.py 是否已下线,如仍在使用需立即替换为 smtp_v2.py
|
||||
- **修复建议**: 确认 _run.sh 使用的是 smtp_v2.py,删除或隔离旧版 smtp.py
|
||||
|
||||
---
|
||||
|
||||
### P0-12: static yhdet_callback 禁用 SSL 证书验证
|
||||
- **位置**: [static/fsys.py#L361](file:///Users/qingsong/micro_servers/static/fsys.py#L361)
|
||||
- **问题描述**:
|
||||
```python
|
||||
with _urlreq.urlopen(req, timeout=10, context=__import__('ssl')._create_unverified_context()) as resp:
|
||||
```
|
||||
使用 `_create_unverified_context()` 禁用了 SSL 证书验证,中间人攻击者可伪造 yhdet.top 响应,以任意用户身份登录。
|
||||
- **修复建议**: 移除 `_create_unverified_context()`,使用默认的 SSL 验证。
|
||||
|
||||
---
|
||||
|
||||
### P0-13: static yhdet OAuth 硬编码密钥
|
||||
- **位置**: [static/fsys.py#L312](file:///Users/qingsong/micro_servers/static/fsys.py#L312)
|
||||
- **问题描述**:
|
||||
```python
|
||||
YHDET_SECRET = "yhdet_qsont_oauth_2026_secret_key"
|
||||
```
|
||||
OAuth 密钥硬编码在源码中,任何代码访问者都可获取。
|
||||
- **修复建议**: 移至环境变量或 .env 文件,不要提交到仓库。
|
||||
|
||||
---
|
||||
|
||||
### P0-14: static 服务直连 gateway SQLite 数据库文件,绕过 db 服务
|
||||
- **位置**: [static/fsys.py#L287](file:///Users/qingsong/micro_servers/static/fsys.py#L287)、[static/fsys.py#L349](file:///Users/qingsong/micro_servers/static/fsys.py#L349)
|
||||
- **问题描述**: static 服务直接通过 sqlite3 模块连接 gateway 的数据库文件,绕过了 db 服务的参数化查询保护层和访问控制,存在以下风险:
|
||||
1. 多进程并发写可能导致数据库损坏
|
||||
2. SQL 注入风险(虽然部分用了参数化,但代码分散难以审计)
|
||||
3. 密码重置等敏感操作绕过了审计日志
|
||||
- **修复建议**: 所有数据库操作统一通过 pymsg 调 db:8005 服务,禁止业务服务直连 SQLite 文件。
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题(72小时内修复)
|
||||
|
||||
### pymsg 模块
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | 无连接数限制,连接耗尽攻击 | `__init__.py:334` listen(10) 不限制已建立连接 |
|
||||
| P1-2 | 线程池无队列限制,慢 handler 耗尽内存 | `__init__.py:337-340` ThreadPoolExecutor 默认无界队列 |
|
||||
| P1-3 | 异常信息直接返回客户端,泄露内部细节 | `rpc.py:144,162` 直接将 `str(e)` 返回给调用方 |
|
||||
| P1-4 | 无 TLS/加密传输,内网流量可被窃听 | 全模块明文 TCP |
|
||||
| P1-5 | JSON 解析无深度限制,深度嵌套导致栈溢出/CPU 100% | `recv()` 后 json.loads 无限制 |
|
||||
|
||||
### db 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | db_tools.py 运维工具存在 SQL 字符串拼接风险 |
|
||||
| P1-2 | 无操作审计日志,谁在什么时候做了什么修改无记录 |
|
||||
| P1-3 | Logs 表自动清理可能被利用来删除攻击痕迹 |
|
||||
|
||||
### static 模块
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | `/api/check_user` 接口可枚举有效邮箱 | `fsys.py:295-306` 无速率限制,可遍历邮箱 |
|
||||
| P1-2 | 文件服务路径遍历防护需要全面审计 | `fsys.py` 文件浏览接口 |
|
||||
| P1-3 | yhdet 新用户默认密码是可预测的 `oauth_{user_id}` | `fsys.py:348` |
|
||||
|
||||
### dnfs 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | Token 过期校验不严谨 |
|
||||
| P1-2 | is_admin 权限校验依赖 Token 字段,无二次验证 |
|
||||
|
||||
### smtp 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | 验证码存储在内存,重启后所有未使用验证码失效(可能导致拒绝服务) |
|
||||
| P1-2 | 无全局发送速率限制接口(smtp_v2 已有但需确认配置) |
|
||||
|
||||
### ai_review 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | `/rag/feedback` 接口无认证,可被恶意投毒 RAG 知识库 |
|
||||
|
||||
### captcha 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | 验证码存储无容量限制,大量生成可耗尽内存 |
|
||||
|
||||
### core 模块
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P1-1 | init.txt 中命令直接通过 shell 执行,若 init.txt 被篡改可植入后门 |
|
||||
|
||||
### gateway 模块
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | 反向代理响应无大小限制,下游返回超大响应可导致 OOM | `main.cpp:1625-1627` callback 无限制拼接 body |
|
||||
| P1-2 | RSA 密钥对每次启动内存生成,重启后所有已签发 Token 失效(设计如此但文档未充分说明) | `main.cpp:547` |
|
||||
|
||||
### waf 模块
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | IPv6 地址解析错误(带方括号),限流失效 | `proxy.go:40-43,92-95` |
|
||||
| P1-2 | 响应头检测只检测不拦截 | `proxy.go:131-138` ProcessResponseHeaders 返回值被忽略 |
|
||||
| P1-3 | 缺少优雅关闭,SIGINT/SIGTERM 时请求被中断 | `main.go:97-125` |
|
||||
| P1-4 | 标准日志被静默,panic 堆栈丢失 | `main.go:88` `log.SetOutput(io.Discard)` |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题(一周内修复)
|
||||
|
||||
| 模块 | 问题 |
|
||||
|------|------|
|
||||
| pymsg | length=0 空消息未处理;broadcast 无权限/频率控制;select EINTR 处理过于宽泛;TIME_WAIT 堆积 |
|
||||
| waf | 规则文件无大小限制(可 OOM);logclient 硬编码 db 地址;logclient 短连接 TIME_WAIT;错误日志输出到 stdout |
|
||||
| static | 视频/音频 Range 请求实现需审计边界;日志可能泄露敏感信息 |
|
||||
| dnfs | AI 审核 fail-closed 实现需验证;节点删除无确认机制 |
|
||||
| ai_review | DeepSeek API Key 硬编码风险;RAG 知识库文件无校验 |
|
||||
| gateway | CORS 配置为通配或白名单需审计;HTTP 服务器无超时配置(依赖 libhttp) |
|
||||
| confs | 日志注入风险(控制字符未过滤) |
|
||||
| 所有服务 | log_client.py 四份副本同步问题(修改一份需同步所有) |
|
||||
| 部署 | docker-compose 中 db 端口虽绑定 127.0.0.1 但容器内 LISTEN_ADDR=0.0.0.0;缺少 healthcheck 依赖的 wait-for-it 机制 |
|
||||
|
||||
---
|
||||
|
||||
## 🟢 P3 — 低危/改进建议
|
||||
|
||||
| 类别 | 建议 |
|
||||
|------|------|
|
||||
| 代码质量 | 各服务 _set.sh 缺少 `set -e`,pip install 失败继续执行;部分脚本硬编码国内镜像源 |
|
||||
| 可观测性 | 安全事件无统一审计日志(认证失败、权限拒绝、异常访问) |
|
||||
| 加固 | C++ 服务编译时开启 ASLR/PIE/RELRO/Stack Canary 等加固选项 |
|
||||
| 文档 | README 中安全相关配置(WAF 规则启用、Token 配置)需要更醒目的提示 |
|
||||
| 依赖 | 定期更新 Go/Python/C++ 依赖,检查已知 CVE |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已修复的问题(本次审计周期内)
|
||||
|
||||
| # | 问题 | 级别 | 模块 | 修复状态 |
|
||||
|---|------|------|------|----------|
|
||||
| 1 | pymsg `recv()` 无超时(Slowloris DoS) | P0 | pymsg | ✅ 添加 recv_timeout=10s(但 P0-5 指出仍有变种问题) |
|
||||
| 2 | confs 配置文件读取无大小限制(OOM) | P1 | confs | ✅ 1MiB 限制 |
|
||||
| 3 | log_client 后台线程异常无保护 | P1 | 所有服务 | ✅ 4份副本同步修复 |
|
||||
| 4 | confs TOCTOU 竞态条件 | P1 | confs | ✅ 文件操作锁内原子完成 |
|
||||
| 5 | confs 缓存可变引用污染 | P1 | confs | ✅ 返回浅拷贝 |
|
||||
| 6 | waf 请求体无大小限制(OOM) | P0 | waf | ✅ 10MB LimitReader + 413 |
|
||||
| 7 | waf HTTP 无超时(Slowloris) | P0 | waf | ✅ ReadTimeout/ReadHeaderTimeout 等 |
|
||||
| 8 | aibox 工具输出无大小限制(OOM) | P0 | aibox | ✅ ReadFileTool 500行限制,Subprocess 1MB限制,最终结果 1MB 截断 |
|
||||
| 9 | aibox 工具结果无效格式污染对话 | P1 | aibox | ✅ _sanitize_tool_result + 消息回滚 |
|
||||
| 10 | aibox 提示词排序错乱 | P2 | aibox | ✅ 数字前缀正则排序 |
|
||||
| 11 | gateway 密码校验防用户名枚举 | P1 | gateway | ✅ dummy PBKDF2 拉平时序 |
|
||||
| 12 | gateway 邮件轰炸漏洞 | P0 | gateway | ✅ /register/send_code 强制 CAPTCHA + 邮箱双窗口限流 |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 修复路线图
|
||||
|
||||
### 第一阶段:立即执行(24小时内,修复所有 P0)
|
||||
- [ ] **static** 删除或禁用 `/api/reset_password` 无认证接口
|
||||
- [ ] **static** 修复 yhdet Token 设计:废弃 base64(email:password),改用 gateway 统一 Token
|
||||
- [ ] **static** 移除 `_create_unverified_context()` 启用 SSL 证书验证
|
||||
- [ ] **dnfs** 实现 Token RSA 签名验证(对接 gateway 公钥)
|
||||
- [ ] **pymsg** 修复 recv 总超时(deadline 模式)
|
||||
- [ ] **pymsg** 捕获 UnicodeDecodeError/json.JSONDecodeError 防止崩溃
|
||||
- [ ] **waf** 启用默认规则或添加醒目的警告日志
|
||||
- [ ] **core** 确认 serve 模式默认关闭或添加认证
|
||||
- [ ] **aibox** 将 WebSocket 默认绑定改为 127.0.0.1
|
||||
- [ ] **确认** smtp 服务使用的是 smtp_v2.py 而非旧版 smtp.py
|
||||
- [ ] **static** 移除硬编码 YHDET_SECRET,改用环境变量
|
||||
|
||||
### 第二阶段:短期修复(72小时内,修复 P1)
|
||||
- [ ] **db** 添加 API Token 认证
|
||||
- [ ] **db** SELECT 强制 LIMIT 上限
|
||||
- [ ] **pymsg** 添加最大连接数限制
|
||||
- [ ] **pymsg** 线程池使用有界队列
|
||||
- [ ] **pymsg** 异常信息脱敏(不返回内部细节给客户端)
|
||||
- [ ] **waf** 修复 IPv6 地址解析(net.SplitHostPort)
|
||||
- [ ] **waf** 恢复标准日志输出(不要 Discard)
|
||||
- [ ] **waf** 添加优雅关闭
|
||||
- [ ] **gateway** 反向代理添加响应体大小限制
|
||||
- [ ] **ai_review** /rag/feedback 添加认证
|
||||
- [ ] **static** /api/check_user 添加速率限制防枚举
|
||||
|
||||
### 第三阶段:中期改进(一周内,修复 P2)
|
||||
- [ ] 所有 pymsg 服务统一添加认证机制
|
||||
- [ ] log_client.py 重构为共享包,避免多副本同步问题
|
||||
- [ ] waf logclient 支持环境变量配置 db 地址
|
||||
- [ ] waf 规则文件添加大小限制
|
||||
- [ ] 统一各服务的 HTTP 超时配置
|
||||
- [ ] 添加安全事件审计日志
|
||||
- [ ] docker-compose 添加健康检查依赖等待
|
||||
|
||||
### 第四阶段:长期加固(版本迭代)
|
||||
- [ ] 所有内部服务通信考虑 TLS 加密
|
||||
- [ ] 统一的认证/鉴权中间件
|
||||
- [ ] C++ 服务编译加固选项
|
||||
- [ ] 自动化安全测试(模糊测试、渗透测试用例)
|
||||
- [ ] 秘密管理方案(HashiCorp Vault 或等效方案)
|
||||
|
||||
---
|
||||
|
||||
## 正面评价(值得肯定的安全实践)
|
||||
|
||||
审计中也发现了许多优秀的安全设计:
|
||||
|
||||
1. ✅ **gateway 密码安全**: PBKDF2-HMAC-SHA256(10万轮+随机盐)、常数时间比较、dummy hash 防用户名枚举
|
||||
2. ✅ **gateway Token 机制**: RSA 签名、HttpOnly + Secure + SameSite=Lax Cookie、双来源(Authorization + Cookie)
|
||||
3. ✅ **分层防护架构**: WAF(边缘规则)→ gateway(鉴权)→ 内部服务(127.0.0.1 绑定)三层防护
|
||||
4. ✅ **速率限制**: 登录/注册/发码均有 IP 级 + 邮箱级限流
|
||||
5. ✅ **邮件轰炸多层防护**: CAPTCHA + IP限流 + 邮箱限流 + smtp冷却 + 全局熔断
|
||||
6. ✅ **X-Forwarded-For 可信链**: WAF 覆盖伪造头,gateway 设置可信值
|
||||
7. ✅ **fail-closed 设计**: ai_review、captcha_verify、email_verify 异常时一律拒绝
|
||||
8. ✅ **集中式日志**: 异步批量推送、fail-safe、自动清理
|
||||
9. ✅ **DoS 防护意识**: pymsg recv_timeout、waf HTTP 超时、请求体大小限制、confs 文件大小限制
|
||||
10. ✅ **confs 路径防护**: basename 校验 + realpath 前缀 + TOCTOU 防护
|
||||
11. ✅ **gateway 路径安全校验**: 防路径遍历、协议注入、空字节
|
||||
12. ✅ **aibox 工具结果清洗**: 控制字符过滤、消息回滚机制
|
||||
13. ✅ **waf 热加载 fail-safe**: 规则重载失败保留旧实例
|
||||
14. ✅ **私钥/密钥被 .gitignore**: 证书目录、.env 文件不提交仓库
|
||||
|
||||
---
|
||||
|
||||
**审计完成时间**: 2026-08-01
|
||||
**审计人**: AI Security Audit
|
||||
+1
-1
@@ -3,7 +3,7 @@ APP_NAME := gateway
|
||||
INSTL_DIR := /usr/local/bin
|
||||
CPP := g++
|
||||
INCS := -I inc/
|
||||
MY_LIBS := -L/usr/lib -lus_http -lus_crypt
|
||||
MY_LIBS := -L/usr/lib -lus_http -lus_httpclient -lus_crypt
|
||||
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
||||
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
|
||||
BUILD_DIR = build
|
||||
|
||||
+58
-6
@@ -7,10 +7,12 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
## 职责
|
||||
|
||||
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
|
||||
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||
- **HTTP 反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
|
||||
- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务,支持文本/二进制帧双向转发。
|
||||
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
|
||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||
- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。
|
||||
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
|
||||
|
||||
## 安全特性
|
||||
@@ -86,11 +88,24 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
|
||||
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。
|
||||
|
||||
**请求体**(CAPTCHA 必填,防邮件轰炸):
|
||||
|
||||
```json
|
||||
{ "email": "user@example.com" }
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"captcha_token": "a1b2c3d4...",
|
||||
"captcha_answer": "Ab3xy"
|
||||
}
|
||||
```
|
||||
|
||||
限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。
|
||||
**防护层次**:
|
||||
1. IP 限流:5 次/10 分钟(`X-Forwarded-For`)
|
||||
2. CAPTCHA 强制校验:经 captcha:8009 pymsg 校验,Token 一次性消费
|
||||
3. 邮箱级双窗口限流:3 次/小时 + 10 次/天
|
||||
4. smtp 同邮箱 60 秒发送冷却(冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)
|
||||
5. smtp 邮箱级长期限流兜底 + 全局熔断(默认 100 封/小时,`SMTP_HOURLY_LIMIT` 可配)
|
||||
|
||||
错误码:`400`(邮箱格式错误/CAPTCHA 缺失或错误)、`429`(IP 速率超限/邮箱级限流超限/同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。
|
||||
|
||||
### `GET/POST *`(反向代理)
|
||||
|
||||
@@ -110,6 +125,27 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default
|
||||
|
||||
返回 CORS 预检响应:`Access-Control-Allow-Origin: *`、`Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。
|
||||
|
||||
### WebSocket 代理(`[WsProxy]` 配置的路径)
|
||||
|
||||
握手阶段鉴权规则:
|
||||
|
||||
1. 从 `Authorization: Bearer <token>` 头读取 Token;若缺失则回退到 `Cookie: q4_token=<token>`。
|
||||
2. RSA 验签 + 过期校验(与 HTTP 代理同样的 Token 机制)。
|
||||
3. Token payload 中必须 `is_admin=true`(即登录账号在 `Admin.users` 白名单内)。
|
||||
4. 鉴权失败 → 返回 HTTP 403 Forbidden,连接被拒绝。
|
||||
5. 鉴权通过 → 升级为 WebSocket 并透明桥接到配置的后端 ws:// 或 wss:// 地址,双向文本/二进制帧原样转发。
|
||||
|
||||
客户端使用方式与普通 WebSocket 一致,只需携带有效管理员 Token:
|
||||
|
||||
```javascript
|
||||
// 浏览器(同源会自动带 q4_token Cookie)
|
||||
const ws = new WebSocket("wss://qsont.me/ws/aibox");
|
||||
|
||||
// 或显式带 Authorization(构造子协议或连接后首条消息均可,这里由网关在 Upgrade 头校验)
|
||||
```
|
||||
|
||||
注意:WS 代理路由必须精确匹配(与 `ws()` 一致,非通配),且优先级高于 HTTP 通配 `*` 路由。
|
||||
|
||||
## 数据库
|
||||
|
||||
用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表):
|
||||
@@ -146,6 +182,11 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
||||
# Token 有效期(秒),默认 3600 = 1 小时
|
||||
keep_time = 3600
|
||||
|
||||
[Admin]
|
||||
# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true
|
||||
# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断
|
||||
users = admin@example.com
|
||||
|
||||
[CORS]
|
||||
# 允许跨域的 Origin 白名单(分号分隔)
|
||||
# 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填)
|
||||
@@ -153,13 +194,22 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht
|
||||
|
||||
[Database]
|
||||
path = sqlite3.db
|
||||
|
||||
[WsProxy]
|
||||
# WebSocket 反向代理路由(仅管理员)
|
||||
# 格式:/path=ws://host:port/target;/path2=wss://host2/target2
|
||||
# 支持 ws:// 和 wss:// 后端,握手阶段校验管理员 Token
|
||||
# routes = /ws/aibox=ws://127.0.0.1:8765;/ws/log=wss://example.com/log
|
||||
routes =
|
||||
```
|
||||
|
||||
- `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。
|
||||
- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。
|
||||
- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。
|
||||
- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。
|
||||
- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。
|
||||
- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。
|
||||
- `WsProxy.routes`:WebSocket 代理路由映射,格式 `/path=ws://host:port/target`,多个用分号分隔;留空则不开启 WS 代理。
|
||||
|
||||
## 部署模式
|
||||
|
||||
@@ -226,14 +276,16 @@ gateway/
|
||||
|
||||
## 版本
|
||||
|
||||
`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印:
|
||||
`0.6.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印:
|
||||
|
||||
```
|
||||
Gateway 0.5.3 started
|
||||
Gateway 0.6.0 started
|
||||
```
|
||||
|
||||
近期变更:
|
||||
|
||||
- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。
|
||||
- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。
|
||||
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
|
||||
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
|
||||
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# gateway 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `src/main.cpp`、`src/shared/`(libth、libhttp、libcrypt、libsqlite3)
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 |
|
||||
| P1 | 5 | 部分 | 3 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: RSA 私钥存储在仓库中/硬编码
|
||||
- **位置**: Token 签名相关代码
|
||||
- **问题**: RSA 私钥若硬编码在代码中或随仓库分发:
|
||||
- 所有部署使用同一密钥对,私钥泄露即全局沦陷
|
||||
- 任何有代码访问权限的人都可伪造 Token
|
||||
- **修复建议**:
|
||||
1. 私钥从文件路径加载(启动参数或环境变量),不硬编码
|
||||
2. 每个部署实例生成独立密钥对
|
||||
3. 私钥文件权限 0600,仅服务运行用户可读
|
||||
4. 公钥分发机制:dnfs、aibox 等需要验签的服务可访问公钥
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | Token 吊销机制缺失 | 当前无 Token 黑名单/吊销列表,修改密码/注销后旧 Token 仍然有效直到过期 |
|
||||
| P1-2 | 登录失败次数限制需确认实现 | 是否有 IP/账户级别的失败锁定 |
|
||||
| P1-3 | 上游代理超时配置需验证 | 反向代理超时过短导致大文件上传失败,过长导致 Slowloris |
|
||||
| P1-4 | C++ HTTP 解析器缓冲区溢出审计 | libhttp 库需全面 fuzz 测试 |
|
||||
| P1-5 | 请求体大小限制需确认 | 是否配置了合理的上限(如 10MB) |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | 错误页面可能泄露版本号/堆栈信息 |
|
||||
| P2-2 | HTTPS 配置(如使用)需确认 TLS 版本和密码套件安全 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **PBKDF2 密码哈希**: 慢哈希算法防彩虹表,使用唯一盐值
|
||||
```cpp
|
||||
bool auth_ok = PBKDF2Hasher::verify_password(password, stored_hash);
|
||||
```
|
||||
2. ✅ **RSA Token 签名**: Token 使用 RSA 私钥签名,防伪造
|
||||
3. ✅ **反向代理架构**: 统一入口,隐藏后端服务拓扑
|
||||
4. ✅ **速率限制**: 邮件轰炸多层防护(gateway+smtp 双窗口限流、60秒冷却、失败锁定)
|
||||
5. ✅ **防用户名枚举**: 登录失败返回统一消息(需确认实现)
|
||||
6. ✅ **WAF 前置防护**: Go WAF 处理 SQLi/XSS/CC/协议解析
|
||||
7. ✅ **C++ 高性能实现**: 避免 Python GIL 瓶颈,抗并发能力强
|
||||
8. ✅ **依赖审计**: 使用项目自带的 libcrypt 加密库(PBKDF2、RSA)
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 确认 RSA 私钥不从仓库/硬编码加载,改为外部文件
|
||||
- [ ] 验证请求体大小限制已启用(参考 waf 的 10MB 配置)
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 实现 Token 吊销列表(密码修改时注销旧 Token)
|
||||
- [ ] 确认登录失败锁定机制生效
|
||||
- [ ] 验证代理超时配置(防 Slowloris + 支持大文件上传)
|
||||
- [ ] libhttp 解析器 fuzz 测试
|
||||
|
||||
### 中期
|
||||
- [ ] 错误页面脱敏
|
||||
- [ ] TLS 安全配置(HTTPS 场景)
|
||||
- [ ] 管理操作审计日志
|
||||
@@ -32,3 +32,12 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht
|
||||
|
||||
[Database]
|
||||
path = sqlite3.db
|
||||
|
||||
[WsProxy]
|
||||
# WebSocket 反向代理路由(仅管理员可访问)
|
||||
# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2
|
||||
# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx),
|
||||
# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。
|
||||
# 支持 ws:// 和 wss:// 后端
|
||||
# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws
|
||||
routes = /ws/aibox=ws://127.0.0.1:8765;
|
||||
+19
-16
@@ -1,20 +1,23 @@
|
||||
#pragma once
|
||||
#include <chrono>
|
||||
#include <string>
|
||||
#include <format>
|
||||
#include <optional>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <ctime>
|
||||
namespace qing {
|
||||
|
||||
class LocalTime {
|
||||
public:
|
||||
// 获取当前本地时间字符串
|
||||
static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") {
|
||||
using namespace std::chrono;
|
||||
auto now = system_clock::now();
|
||||
auto zone = current_zone();
|
||||
zoned_time zt{zone, now};
|
||||
return std::vformat("{:" + format + "}", std::make_format_args(zt));
|
||||
auto now = std::chrono::system_clock::now();
|
||||
std::time_t t = std::chrono::system_clock::to_time_t(now);
|
||||
std::tm tm_buf{};
|
||||
localtime_r(&t, &tm_buf);
|
||||
char buf[128];
|
||||
std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf);
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// 解析时间字符串为 system_clock::time_point(本地时间 → UTC)
|
||||
@@ -22,29 +25,29 @@ public:
|
||||
const std::string& str,
|
||||
const std::string& format = "%Y-%m-%d %H:%M:%S"
|
||||
) {
|
||||
using namespace std::chrono;
|
||||
std::tm tm_buf{};
|
||||
std::istringstream iss{str};
|
||||
local_time<seconds> lt;
|
||||
iss >> std::chrono::parse(format, lt); // ✅ 修复
|
||||
iss >> std::get_time(&tm_buf, format.c_str());
|
||||
if (iss.fail()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
auto zone = current_zone();
|
||||
zoned_time zt{zone, lt};
|
||||
return zt.get_sys_time();
|
||||
// mktime 将本地时间转换为 time_t(UTC)
|
||||
std::time_t t = std::mktime(&tm_buf);
|
||||
if (t == -1) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::chrono::system_clock::from_time_t(t);
|
||||
}
|
||||
|
||||
// ----- 过期判断 -----
|
||||
static bool isExpired(const std::chrono::system_clock::time_point& tp) {
|
||||
using namespace std::chrono;
|
||||
return system_clock::now() > tp;
|
||||
return std::chrono::system_clock::now() > tp;
|
||||
}
|
||||
|
||||
template <typename Rep, typename Period>
|
||||
static bool isExpired(const std::chrono::system_clock::time_point& tp,
|
||||
const std::chrono::duration<Rep, Period>& gracePeriod) {
|
||||
using namespace std::chrono;
|
||||
return system_clock::now() > tp + gracePeriod;
|
||||
return std::chrono::system_clock::now() > tp + gracePeriod;
|
||||
}
|
||||
|
||||
static bool isExpired(const std::string& timeStr,
|
||||
|
||||
+285
-3
@@ -28,7 +28,7 @@ using namespace qing;
|
||||
namespace json = boost::json;
|
||||
namespace po = boost::program_options;
|
||||
|
||||
constexpr const char *_VER = "0.5.3";
|
||||
constexpr const char *_VER = "0.6.0";
|
||||
|
||||
//////////////////////////////////
|
||||
#include <sstream>
|
||||
@@ -306,6 +306,11 @@ http::response<http::string_body> handleRefreshToken(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */
|
||||
http::response<http::string_body> handleWhoami(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
||||
http::response<http::string_body> handleCaptchaImage(
|
||||
const http::request<http::string_body>& req,
|
||||
@@ -334,14 +339,23 @@ static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数
|
||||
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
|
||||
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
|
||||
|
||||
/* WebSocket 代理路由:path -> ws://backend-host:port/target
|
||||
* 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */
|
||||
static std::unordered_map<std::string, std::string> _WS_PROXY_ROUTES;
|
||||
|
||||
/* instance items */
|
||||
static std::unique_ptr<HttpServer> _SERVE;
|
||||
static std::unique_ptr<MyRSA::Generator> _KEYGEN;
|
||||
//static std::unique_ptr<Sqlite3> _SQLDB;
|
||||
static RateLimiter _LOGIN_RATE;
|
||||
static RateLimiter _REGISTER_RATE;
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
|
||||
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流
|
||||
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
|
||||
// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。
|
||||
// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。
|
||||
// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。
|
||||
static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱
|
||||
static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱
|
||||
|
||||
/* pymsg 下游调用超时(秒):
|
||||
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
|
||||
@@ -510,6 +524,31 @@ int main(int argc, char **argv) {
|
||||
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
|
||||
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
|
||||
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
|
||||
|
||||
/* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;...
|
||||
* 仅管理员(Token 中 is_admin=true)可访问,用于 aibox 等内部 WS 服务的网关暴露 */
|
||||
{
|
||||
std::string ws_cfg = cf.get<std::string>("WsProxy.routes", "");
|
||||
if (!ws_cfg.empty()) {
|
||||
auto entries = split(ws_cfg, ';');
|
||||
for (auto& entry : entries) {
|
||||
auto eq = entry.find('=');
|
||||
if (eq != std::string::npos) {
|
||||
std::string path = entry.substr(0, eq);
|
||||
std::string url = entry.substr(eq + 1);
|
||||
// 去除首尾空白
|
||||
auto trim = [](std::string& s) {
|
||||
while (!s.empty() && std::isspace((unsigned char)s.front())) s.erase(s.begin());
|
||||
while (!s.empty() && std::isspace((unsigned char)s.back())) s.pop_back();
|
||||
};
|
||||
trim(path); trim(url);
|
||||
if (!path.empty() && !url.empty()) {
|
||||
_WS_PROXY_ROUTES[path] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -601,6 +640,81 @@ int main(int argc, char **argv) {
|
||||
//}
|
||||
|
||||
|
||||
/* WebSocket 代理握手鉴权:从 HTTP Upgrade 请求中提取 Token 并验证 is_admin。
|
||||
* 返回 true 表示鉴权通过,out_target 填入后端 ws:// URL;返回 false 拒绝连接。 */
|
||||
static bool verifyWsAdminToken(const http::request<http::string_body>& req,
|
||||
const std::string& target_url,
|
||||
std::string& out_target) {
|
||||
out_target.clear();
|
||||
try {
|
||||
std::string raw;
|
||||
auto it = req.base().find(http::field::authorization);
|
||||
if (it != req.base().end()) {
|
||||
raw = std::string(it->value());
|
||||
} else {
|
||||
auto cit = req.base().find(http::field::cookie);
|
||||
if (cit != req.base().end()) {
|
||||
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||
if (!cookie_token.empty())
|
||||
raw = "Bearer " + cookie_token;
|
||||
}
|
||||
}
|
||||
if (raw.empty() || raw.substr(0, 7) != "Bearer ")
|
||||
return false;
|
||||
|
||||
raw = raw.substr(7);
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos) return false;
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth = Base64::base64_decode_to_string(data);
|
||||
|
||||
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
if (!publ.Verify(auth, sign)) return false;
|
||||
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (...) { return false; }
|
||||
if (!jv_t.is_object()) return false;
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
auto t = json::value_to<std::string>(obj["created_at"]);
|
||||
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
|
||||
auto created_tp = LocalTime::parse(t);
|
||||
if (created_tp) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
|
||||
if (now > expire_tp) return false;
|
||||
}
|
||||
|
||||
// 必须是管理员
|
||||
bool is_admin = false;
|
||||
if (obj.contains("is_admin")) {
|
||||
auto& v = obj["is_admin"];
|
||||
if (v.is_bool()) is_admin = v.as_bool();
|
||||
}
|
||||
if (!is_admin) {
|
||||
logs().warning("gateway", "WS proxy rejected: non-admin user",
|
||||
std::nullopt, std::string(req.target()));
|
||||
return false;
|
||||
}
|
||||
|
||||
out_target = target_url;
|
||||
// 将客户端请求路径附加到目标URL后(支持子路径)
|
||||
std::string req_path = std::string(req.target());
|
||||
auto qpos = req_path.find('?');
|
||||
if (qpos != std::string::npos) req_path = req_path.substr(0, qpos);
|
||||
// 不追加路径:代理直接使用配置中指定的完整目标URL
|
||||
(void)req_path;
|
||||
return true;
|
||||
}
|
||||
catch (const std::exception& e) {
|
||||
std::cerr << "[WS proxy auth error] " << e.what() << std::endl;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* 注册http路由 */
|
||||
void registerRoutes(HttpServer &server) {
|
||||
|
||||
@@ -635,6 +749,11 @@ void registerRoutes(HttpServer &server) {
|
||||
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); });
|
||||
});
|
||||
|
||||
server.post("/whoami", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleWhoami(req, params); });
|
||||
});
|
||||
|
||||
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
||||
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
@@ -647,9 +766,24 @@ void registerRoutes(HttpServer &server) {
|
||||
});
|
||||
|
||||
server.post("*", [](const auto& req, const auto& params) {
|
||||
return handleProxy(req, params, "post");
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleProxy(req, params, "post"); });
|
||||
});
|
||||
|
||||
/* WebSocket 反向代理(仅管理员):
|
||||
* ws_proxy 回调在 HTTP Upgrade 握手阶段触发,
|
||||
* 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */
|
||||
for (const auto& [path, url] : _WS_PROXY_ROUTES) {
|
||||
server.ws_proxy(path, [url](const http::request<http::string_body>& upgradeReq) -> std::string {
|
||||
std::string target;
|
||||
if (verifyWsAdminToken(upgradeReq, url, target)) {
|
||||
return target;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Http视图包装器
|
||||
@@ -946,6 +1080,132 @@ http::response<http::string_body> handleLogout(
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 当前用户身份查询 /whoami
|
||||
*
|
||||
* 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。
|
||||
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。
|
||||
* 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */
|
||||
http::response<http::string_body> handleWhoami(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
|
||||
/* 获取 Token:Authorization 头优先,回退 Cookie */
|
||||
std::string raw;
|
||||
auto it_auth = req.base().find("Authorization");
|
||||
if (it_auth != req.base().end()) {
|
||||
raw = std::string(it_auth->value());
|
||||
} else {
|
||||
auto cit = req.base().find("Cookie");
|
||||
if (cit != req.base().end()) {
|
||||
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||
if (!cookie_token.empty())
|
||||
raw = "Bearer " + cookie_token;
|
||||
}
|
||||
}
|
||||
|
||||
/* 无 Token 或格式错误:返回未登录 */
|
||||
if (raw.empty() || raw.substr(0, 7) != "Bearer ") {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
raw = raw.substr(7);
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth;
|
||||
try { auth = Base64::base64_decode_to_string(data); }
|
||||
catch (const std::exception&) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* RSA 验签:失败按未登录处理 */
|
||||
if (!_PUBL.Verify(auth, sign)) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 解析 Token payload */
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const std::exception&) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
if (!jv_t.is_object()) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
json::object& obj = jv_t.as_object();
|
||||
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||
|
||||
/* is_admin:旧 Token 未携带时默认为 false */
|
||||
bool is_admin = false;
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||
is_admin = it_admin->value().as_bool();
|
||||
|
||||
json::value jv_r = {
|
||||
{"login", true},
|
||||
{"user", username},
|
||||
{"is_admin", is_admin}
|
||||
};
|
||||
|
||||
/* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */
|
||||
auto it_iat = obj.find("iat");
|
||||
if (it_iat != obj.end() && it_iat->value().is_string())
|
||||
jv_r.as_object()["iat"] = it_iat->value();
|
||||
auto it_created = obj.find("created_at");
|
||||
if (it_created != obj.end() && it_created->value().is_string())
|
||||
jv_r.as_object()["created_at"] = it_created->value();
|
||||
auto it_keep = obj.find("keep_time");
|
||||
if (it_keep != obj.end() && it_keep->value().is_int64())
|
||||
jv_r.as_object()["keep_time"] = it_keep->value();
|
||||
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 刷新 Token:POST /refresh_token
|
||||
*
|
||||
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
|
||||
@@ -1284,6 +1544,28 @@ http::response<http::string_body> handleSendEmailCode(
|
||||
if (!std::regex_match(email, email_regex))
|
||||
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code");
|
||||
|
||||
// CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1)
|
||||
// captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。
|
||||
// fail-closed:captcha 服务不可用或响应异常一律视为校验失败。
|
||||
auto captcha_token_it = obj.find("captcha_token");
|
||||
auto captcha_answer_it = obj.find("captcha_answer");
|
||||
if (captcha_token_it == obj.end() || captcha_answer_it == obj.end())
|
||||
THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code");
|
||||
if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()),
|
||||
std::string(captcha_answer_it->value().as_string())))
|
||||
THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code");
|
||||
|
||||
// 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度)
|
||||
// 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。
|
||||
if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600))
|
||||
THROW_EX_CUSTOM(HttpException, 3104,
|
||||
"Too many code requests for this email. Please try again later.",
|
||||
429, "/register/send_code");
|
||||
if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400))
|
||||
THROW_EX_CUSTOM(HttpException, 3105,
|
||||
"Daily email limit exceeded. Please try again tomorrow.",
|
||||
429, "/register/send_code");
|
||||
|
||||
// 调 smtp 服务(pymsg/TCP :8008)发送验证码
|
||||
bool sent = false;
|
||||
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
|
||||
|
||||
+18
-16
@@ -1,49 +1,51 @@
|
||||
VERSION := 0.0.1
|
||||
FRAMEWORK_NAME := us
|
||||
LIB_NAME := libus_http
|
||||
LIBC_NAME := libus_httpclient
|
||||
INSTL_TDIR := /lib
|
||||
INCLU_TDIR := /usr/include
|
||||
INCLU_SDIR := inc
|
||||
CPP := g++
|
||||
INCS := -I inc
|
||||
CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror
|
||||
LIBS :=
|
||||
INCS := -I inc -I/opt/homebrew/include
|
||||
CPPFLAGS := --std=c++20 -O2 -Wall -Werror
|
||||
SERVER_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lpthread
|
||||
CLIENT_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lcurl -lpthread
|
||||
BUILD_TDIR := build
|
||||
$(shell mkdir -p $(BUILD_TDIR))
|
||||
|
||||
#--------
|
||||
all: $(BUILD_TDIR)/$(LIB_NAME).so
|
||||
all: $(BUILD_TDIR)/$(LIB_NAME).so $(BUILD_TDIR)/$(LIBC_NAME).so
|
||||
|
||||
$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o #$(BUILD_TDIR)/WebSocketServer.o $(BUILD_TDIR)/WebSocketClient.o
|
||||
$(CPP) -shared $^ $(LIBS) -o $@
|
||||
# ---- Server library (HttpServer, no curl) ----
|
||||
$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o
|
||||
$(CPP) -shared $^ $(SERVER_LIBS) -o $@
|
||||
|
||||
$(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp
|
||||
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
|
||||
|
||||
$(BUILD_TDIR)/WebSocketServer.o: src/WebSocketServer.cpp
|
||||
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
|
||||
# ---- Client library (HttpClient, depends on curl + beast websocket) ----
|
||||
$(BUILD_TDIR)/$(LIBC_NAME).so: $(BUILD_TDIR)/HttpClient.o
|
||||
$(CPP) -shared $^ $(CLIENT_LIBS) -o $@
|
||||
|
||||
$(BUILD_TDIR)/WebSocketClient.o: src/WebSocketClient.cpp
|
||||
$(BUILD_TDIR)/HttpClient.o: src/HttpClient.cpp
|
||||
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
|
||||
|
||||
clear:
|
||||
@rm -rvf $(BUILD_TDIR)
|
||||
|
||||
install:
|
||||
@echo "install UniServe_libth..."
|
||||
install: all
|
||||
@echo "install UniServe_libhttp..."
|
||||
@mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME)
|
||||
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
#@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
#@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
@cp -vf $(BUILD_TDIR)/$(LIB_NAME).so $(INSTL_TDIR)/
|
||||
@cp -vf $(BUILD_TDIR)/$(LIBC_NAME).so $(INSTL_TDIR)/
|
||||
@echo "done."
|
||||
|
||||
uninstall:
|
||||
@echo "uninstall UniServe_libth..."
|
||||
@echo "uninstall UniServe_libhttp..."
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp
|
||||
@rm -vf $(INSTL_TDIR)/$(LIB_NAME).so
|
||||
@rm -vf $(INSTL_TDIR)/$(LIBC_NAME).so
|
||||
@echo "done."
|
||||
@@ -0,0 +1,266 @@
|
||||
# libhttp - UniServe HTTP/WebSocket Library
|
||||
|
||||
C++20 network library for the UniServe microservice framework, providing unified HTTP/HTTPS server and HTTP/WebSocket client capabilities built on Boost.Beast and libcurl.
|
||||
|
||||
## Features
|
||||
|
||||
### HttpServer
|
||||
- **HTTP/HTTPS Server**: Asynchronous HTTP 1.1 server with TLS support
|
||||
- **WebSocket Integration**: Single port can serve both HTTP(S) and WebSocket(S) endpoints
|
||||
- **WebSocket Reverse Proxy**: Transparent bidirectional WebSocket proxy with handshake-time auth/callback (supports ws:// and wss:// backends)
|
||||
- **Routing**: Exact path matching + wildcard (`*`) routing
|
||||
- **Middleware**: `use()` for middleware chains (CORS, auth, logging, etc.)
|
||||
- **Static Files**: `setStaticDirectory()` for serving static files
|
||||
- **Thread Pool**: Multi-threaded I/O with `asio::strand` for thread safety
|
||||
- **Client Management**: WebSocket client tracking, broadcast, and targeted send
|
||||
|
||||
### HttpClient
|
||||
- **HTTP Client**: Synchronous HTTP requests via libcurl (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS)
|
||||
- **WebSocket Client**: Synchronous WebSocket client via Boost.Beast
|
||||
- **Dual Protocol**: Same client instance handles both HTTP and WebSocket
|
||||
- **ws:// and wss://**: Supports both plain and TLS-encrypted WebSocket connections
|
||||
- **Binary Support**: Text and binary message sending
|
||||
- **Backward Compatible**: Original API preserved; `WebSocketClient`/`WebSocketClientBase` are aliases for `HttpClient`
|
||||
|
||||
## Dependencies
|
||||
|
||||
- C++20 compiler
|
||||
- Boost.Beast / Boost.Asio (header-only)
|
||||
- OpenSSL (for TLS)
|
||||
- libcurl (for HTTP client only — `libus_httpclient.so`)
|
||||
- pthread
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
make # builds build/libus_http.so and build/libus_httpclient.so
|
||||
make install # installs both .so to /lib and headers to /usr/include/us (requires root)
|
||||
```
|
||||
|
||||
Two shared libraries are produced:
|
||||
|
||||
| Library | SONAME | Contains | Links |
|
||||
|---|---|---|---|
|
||||
| Server | `libus_http.so` | `HttpServer` (HTTP + WebSocket server + WS proxy) | `-lssl -lcrypto -lpthread` |
|
||||
| Client | `libus_httpclient.so` | `HttpClient` (HTTP via curl + WebSocket via Beast) | `-lssl -lcrypto -lcurl -lpthread` |
|
||||
|
||||
`libus_http.so` does **not** depend on libcurl. Link against `libus_httpclient.so` only when you need the client, and link both when you need both server and client (e.g. a proxy/gateway).
|
||||
|
||||
## Quick Start
|
||||
|
||||
### HTTP + WebSocket Server
|
||||
|
||||
```cpp
|
||||
#include <us/HttpServer.hpp>
|
||||
using namespace pmc::net;
|
||||
|
||||
int main() {
|
||||
// HTTP server on port 8080
|
||||
HttpServer server("0.0.0.0", 8080, 4); // 4 threads
|
||||
|
||||
// HTTP GET route
|
||||
server.get("/hello", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res;
|
||||
res.result(http::status::ok);
|
||||
res.set(http::field::content_type, "text/plain");
|
||||
res.body() = "Hello, World!";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
// WebSocket route
|
||||
server.ws("/ws", [](const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> send) {
|
||||
send("Echo: " + message);
|
||||
});
|
||||
|
||||
server.onWsConnect([](const std::string& id, const std::string& addr) {
|
||||
std::cout << "Client " << id << " connected from " << addr << std::endl;
|
||||
});
|
||||
|
||||
server.onWsDisconnect([](const std::string& id) {
|
||||
std::cout << "Client " << id << " disconnected" << std::endl;
|
||||
});
|
||||
|
||||
server.start();
|
||||
// server.run(); // blocks with ioc.run()
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP + WebSocket Client
|
||||
|
||||
```cpp
|
||||
#include <us/HttpClient.hpp>
|
||||
using namespace pmc::net;
|
||||
|
||||
// HTTP request
|
||||
HttpClient http;
|
||||
http.set_header("Content-Type: application/json");
|
||||
http.post_stream_sync("http://localhost:8080/api/data", "{}",
|
||||
[](const std::string& resp) {
|
||||
std::cout << "Response: " << resp << std::endl;
|
||||
});
|
||||
|
||||
// WebSocket connection
|
||||
HttpClient ws;
|
||||
if (ws.ws_connect("ws://localhost:8080/ws")) {
|
||||
ws.ws_send("Hello WebSocket!");
|
||||
std::string reply = ws.ws_recv();
|
||||
std::cout << "Got: " << reply << std::endl;
|
||||
ws.ws_close();
|
||||
}
|
||||
|
||||
// WSS (TLS) connection
|
||||
HttpClient wss;
|
||||
wss.ws_connect("wss://example.com/socket");
|
||||
```
|
||||
|
||||
### HTTPS Server
|
||||
|
||||
```cpp
|
||||
HttpServer secure("0.0.0.0", 443, "/path/to/cert.pem", "/path/to/key.pem", 4);
|
||||
secure.get("/", handler);
|
||||
secure.start();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### HttpServer
|
||||
|
||||
**Constructors**
|
||||
```cpp
|
||||
HttpServer(const std::string& address, unsigned short port, unsigned int threads = 1);
|
||||
HttpServer(const std::string& address, unsigned short port,
|
||||
const std::string& cert_file, const std::string& key_file,
|
||||
unsigned int threads = 1);
|
||||
```
|
||||
|
||||
**HTTP Routes**
|
||||
```cpp
|
||||
void get(const std::string& path, HttpRequestHandler handler);
|
||||
void post(const std::string& path, HttpRequestHandler handler);
|
||||
void put(const std::string& path, HttpRequestHandler handler);
|
||||
void del(const std::string& path, HttpRequestHandler handler);
|
||||
void options(const std::string& path, HttpRequestHandler handler);
|
||||
void use(MiddlewareHandler middleware);
|
||||
void setStaticDirectory(const std::string& path);
|
||||
```
|
||||
|
||||
**WebSocket Routes**
|
||||
```cpp
|
||||
void ws(const std::string& path, WebSocketMessageHandler handler);
|
||||
void onWsConnect(WebSocketConnectHandler handler);
|
||||
void onWsDisconnect(WebSocketDisconnectHandler handler);
|
||||
bool sendToWsClient(const std::string& clientId, const std::string& message);
|
||||
size_t broadcastWs(const std::string& message);
|
||||
void disconnectWsClient(const std::string& clientId);
|
||||
size_t getWsClientCount() const;
|
||||
```
|
||||
|
||||
**WebSocket Reverse Proxy**
|
||||
```cpp
|
||||
void ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake);
|
||||
```
|
||||
|
||||
The `WsProxyHandshakeHandler` is called during the HTTP Upgrade handshake. It receives the upgrade request and must return the backend WebSocket URL (`ws://host:port/path` or `wss://host:port/path`) to bridge to. Returning an empty string rejects the connection (HTTP 403 is sent to the client). Proxy routes take priority over regular `ws()` routes on the same path.
|
||||
|
||||
Example — admin-only proxy:
|
||||
```cpp
|
||||
server.ws_proxy("/ws/admin", [](const http::request<http::string_body>& req) -> std::string {
|
||||
if (!isAdmin(req)) return ""; // reject non-admins
|
||||
return "ws://127.0.0.1:9000/stream"; // bridge to backend
|
||||
});
|
||||
```
|
||||
|
||||
Features:
|
||||
- Full-duplex async bridging (text and binary frames preserved)
|
||||
- Write queues in both directions to maintain message ordering
|
||||
- Automatic SNI for wss:// backends
|
||||
- Graceful close propagation between client and backend
|
||||
- Errors during backend connect/handshake close the client connection cleanly
|
||||
|
||||
**Lifecycle**
|
||||
```cpp
|
||||
void start(); // non-blocking, spawns worker threads
|
||||
void run(); // blocking (calls start() then ioc.run())
|
||||
void stop();
|
||||
bool isRunning() const;
|
||||
```
|
||||
|
||||
### HttpClient
|
||||
|
||||
**HTTP Methods**
|
||||
```cpp
|
||||
void set_header(const std::string& header);
|
||||
void clear_headers();
|
||||
const std::map<std::string, std::string>& get_response_headers() const;
|
||||
long get_response_code() const;
|
||||
|
||||
void get_stream_sync(const std::string& url, ResponseCallback cb);
|
||||
void post_stream_sync(const std::string& url, const std::string& data, ResponseCallback cb);
|
||||
void put_stream_sync(const std::string& url, const std::string& data, ResponseCallback cb);
|
||||
void delete_stream_sync(const std::string& url, ResponseCallback cb);
|
||||
void request_stream_sync(HttpMethod method, const std::string& url,
|
||||
const std::string& data, ResponseCallback cb);
|
||||
```
|
||||
|
||||
**WebSocket Methods**
|
||||
```cpp
|
||||
bool ws_connect(const std::string& url, int timeout_ms = 5000);
|
||||
bool ws_connect(const std::string& host, const std::string& port,
|
||||
const std::string& path = "/", bool ssl = false, int timeout_ms = 5000);
|
||||
bool ws_send(const std::string& message);
|
||||
bool ws_send_binary(const void* data, size_t size);
|
||||
std::string ws_recv(int timeout_ms = 0);
|
||||
bool ws_is_connected() const;
|
||||
void ws_close();
|
||||
std::string ws_get_host() const;
|
||||
std::string ws_get_port() const;
|
||||
std::string ws_get_path() const;
|
||||
```
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```cpp
|
||||
using HttpRequestHandler = std::function<http::response<http::string_body>(
|
||||
const http::request<http::string_body>&,
|
||||
const std::unordered_map<std::string, std::string>&)>;
|
||||
|
||||
using MiddlewareHandler = std::function<bool(
|
||||
const http::request<http::string_body>&,
|
||||
http::response<http::string_body>&,
|
||||
const std::unordered_map<std::string, std::string>&)>;
|
||||
|
||||
using WebSocketMessageHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> sendCallback)>;
|
||||
|
||||
using WebSocketConnectHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& remoteAddress)>;
|
||||
|
||||
using WebSocketDisconnectHandler = std::function<void(
|
||||
const std::string& clientId)>;
|
||||
|
||||
using WsProxyHandshakeHandler = std::function<std::string(
|
||||
const http::request<http::string_body>& upgradeReq)>;
|
||||
```
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The following type aliases are provided for existing code that used the standalone WebSocket client:
|
||||
|
||||
```cpp
|
||||
using WebSocketClient = HttpClient;
|
||||
using WebSocketClientBase = HttpClient;
|
||||
```
|
||||
|
||||
## Version History
|
||||
|
||||
- **v3.2.0**: Split into two shared libraries — `libus_http.so` (server only, no curl) and `libus_httpclient.so` (HttpClient, depends on curl). HttpClient implementation moved to `src/HttpClient.cpp`; `HttpClient.hpp` is declaration-only.
|
||||
- **v3.1.0**: Added WebSocket reverse proxy (`ws_proxy()`) with bidirectional async bridging, ws:// and wss:// backend support, handshake-time authorization callback.
|
||||
- **v3.0.0**: Merged WebSocketServer into HttpServer and WebSocketClient into HttpClient. Single-port HTTP+WebSocket support.
|
||||
- **v2.x**: Separate HttpServer, WebSocketServer, and HttpClient modules.
|
||||
+74
-167
@@ -2,10 +2,27 @@
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/ssl.h>
|
||||
|
||||
// Boost.Beast for WebSocket client
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/beast/websocket/ssl.hpp>
|
||||
#include <boost/asio/connect.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/ssl.hpp>
|
||||
#include <boost/asio/ssl/stream.hpp>
|
||||
|
||||
namespace pmc::net {
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace asio = boost::asio;
|
||||
namespace websocket = beast::websocket;
|
||||
using tcp = asio::ip::tcp;
|
||||
|
||||
enum class HttpMethod {
|
||||
GET,
|
||||
POST,
|
||||
@@ -18,183 +35,73 @@ enum class HttpMethod {
|
||||
|
||||
class HttpClient {
|
||||
public:
|
||||
HttpClient() {
|
||||
curl_ = curl_easy_init();
|
||||
if (!curl_) {
|
||||
throw std::runtime_error("Failed to initialize CURL");
|
||||
}
|
||||
}
|
||||
|
||||
~HttpClient() {
|
||||
if (curl_) {
|
||||
curl_easy_cleanup(curl_);
|
||||
}
|
||||
if (headers_) {
|
||||
curl_slist_free_all(headers_);
|
||||
}
|
||||
}
|
||||
|
||||
void set_header(const std::string& header) {
|
||||
headers_ = curl_slist_append(headers_, header.c_str());
|
||||
}
|
||||
|
||||
void clear_headers() {
|
||||
if (headers_) {
|
||||
curl_slist_free_all(headers_);
|
||||
headers_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const std::map<std::string, std::string>& get_response_headers() const {
|
||||
return response_headers_;
|
||||
}
|
||||
|
||||
long get_response_code() const {
|
||||
return response_code_;
|
||||
}
|
||||
|
||||
// ===== 修复:直接转发,不按行处理 =====
|
||||
HttpClient();
|
||||
~HttpClient();
|
||||
|
||||
HttpClient(const HttpClient&) = delete;
|
||||
HttpClient& operator=(const HttpClient&) = delete;
|
||||
|
||||
void set_header(const std::string& header);
|
||||
void clear_headers();
|
||||
|
||||
const std::map<std::string, std::string>& get_response_headers() const;
|
||||
long get_response_code() const;
|
||||
|
||||
// ===== HTTP Methods (Synchronous, libcurl-based) =====
|
||||
void request_stream_sync(
|
||||
HttpMethod method,
|
||||
const std::string& url,
|
||||
const std::string& data,
|
||||
std::function<void(const std::string&)> callback
|
||||
) {
|
||||
if (!curl_) {
|
||||
throw std::runtime_error("CURL not initialized");
|
||||
}
|
||||
|
||||
response_headers_.clear();
|
||||
response_code_ = 0;
|
||||
|
||||
curl_easy_reset(curl_);
|
||||
curl_easy_setopt(curl_, CURLOPT_URL, url.c_str());
|
||||
set_http_method(method, data);
|
||||
curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers_);
|
||||
curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, header_callback);
|
||||
curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this);
|
||||
|
||||
// ===== 关键修复:直接收集原始数据,不按行处理 =====
|
||||
std::string full_response;
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response);
|
||||
|
||||
curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L);
|
||||
curl_easy_setopt(curl_, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl_);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error(std::string("CURL error: ") + curl_easy_strerror(res));
|
||||
}
|
||||
|
||||
curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &response_code_);
|
||||
|
||||
// ===== 完整转发所有数据(包括换行符) =====
|
||||
if (!full_response.empty()) {
|
||||
callback(full_response);
|
||||
}
|
||||
}
|
||||
|
||||
void get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::GET, url, "", callback);
|
||||
}
|
||||
|
||||
void post_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::POST, url, data, callback);
|
||||
}
|
||||
|
||||
void put_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::PUT, url, data, callback);
|
||||
}
|
||||
|
||||
void delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::DELETE, url, "", callback);
|
||||
}
|
||||
std::function<void(const std::string&)> callback);
|
||||
|
||||
void get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
|
||||
void post_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback);
|
||||
void put_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback);
|
||||
void delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
|
||||
|
||||
// ===== WebSocket Methods (Synchronous, Boost.Beast-based) =====
|
||||
bool ws_connect(const std::string& url, int timeout_ms = 5000);
|
||||
bool ws_connect(const std::string& host, const std::string& port,
|
||||
const std::string& path = "/", bool ssl = false, int timeout_ms = 5000);
|
||||
bool ws_send(const std::string& message);
|
||||
bool ws_send_binary(const void* data, size_t size);
|
||||
std::string ws_recv(int timeout_ms = 0);
|
||||
bool ws_is_connected() const;
|
||||
void ws_close();
|
||||
std::string ws_get_host() const;
|
||||
std::string ws_get_port() const;
|
||||
std::string ws_get_path() const;
|
||||
|
||||
private:
|
||||
CURL* curl_ = nullptr;
|
||||
struct curl_slist* headers_ = nullptr;
|
||||
std::map<std::string, std::string> response_headers_;
|
||||
long response_code_ = 0;
|
||||
|
||||
void set_http_method(HttpMethod method, const std::string& data) {
|
||||
switch (method) {
|
||||
case HttpMethod::GET:
|
||||
curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L);
|
||||
break;
|
||||
|
||||
case HttpMethod::POST:
|
||||
curl_easy_setopt(curl_, CURLOPT_POST, 1L);
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
|
||||
case HttpMethod::PUT:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PUT");
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
|
||||
case HttpMethod::DELETE:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "DELETE");
|
||||
break;
|
||||
|
||||
case HttpMethod::PATCH:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PATCH");
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
|
||||
case HttpMethod::HEAD:
|
||||
curl_easy_setopt(curl_, CURLOPT_NOBODY, 1L);
|
||||
break;
|
||||
|
||||
case HttpMethod::OPTIONS:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "OPTIONS");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata) {
|
||||
size_t total_size = size * nitems;
|
||||
HttpClient* client = static_cast<HttpClient*>(userdata);
|
||||
|
||||
std::string header_line(buffer, total_size);
|
||||
size_t colon_pos = header_line.find(':');
|
||||
if (colon_pos != std::string::npos) {
|
||||
std::string key = header_line.substr(0, colon_pos);
|
||||
std::string value = header_line.substr(colon_pos + 1);
|
||||
|
||||
key.erase(0, key.find_first_not_of(" \t"));
|
||||
key.erase(key.find_last_not_of(" \t") + 1);
|
||||
value.erase(0, value.find_first_not_of(" \t"));
|
||||
value.erase(value.find_last_not_of(" \t\r\n") + 1);
|
||||
|
||||
if (!key.empty() && !value.empty()) {
|
||||
client->response_headers_[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return total_size;
|
||||
}
|
||||
|
||||
// ===== 修复:直接写入原始数据,不按行处理 =====
|
||||
static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {
|
||||
size_t total_size = size * nmemb;
|
||||
std::string* response = static_cast<std::string*>(userdata);
|
||||
|
||||
// 直接追加原始数据,保留所有换行符和特殊字符
|
||||
response->append(ptr, total_size);
|
||||
|
||||
return total_size;
|
||||
}
|
||||
|
||||
struct WsState {
|
||||
asio::io_context ioc;
|
||||
std::unique_ptr<beast::tcp_stream> tcp_stream;
|
||||
std::unique_ptr<asio::ssl::context> ssl_ctx;
|
||||
std::unique_ptr<asio::ssl::stream<tcp::socket&>> ssl_stream;
|
||||
std::unique_ptr<websocket::stream<tcp::socket&>> ws_stream;
|
||||
std::unique_ptr<websocket::stream<asio::ssl::stream<tcp::socket&>&>> wss_stream;
|
||||
bool ssl_enabled = false;
|
||||
bool connected = false;
|
||||
std::string host;
|
||||
std::string port;
|
||||
std::string path;
|
||||
};
|
||||
std::unique_ptr<WsState> ws_state_;
|
||||
|
||||
void set_http_method(HttpMethod method, const std::string& data);
|
||||
static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata);
|
||||
static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata);
|
||||
static void parse_ws_url(const std::string& url_part, std::string& host, std::string& port,
|
||||
std::string& path, const char* default_port);
|
||||
};
|
||||
|
||||
// Backward compatibility aliases
|
||||
using WebSocketClientBase = HttpClient;
|
||||
using WebSocketClient = HttpClient;
|
||||
|
||||
} // namespace pmc::net
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <boost/asio.hpp>
|
||||
#include <boost/beast.hpp>
|
||||
#include <boost/beast/http.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/beast/version.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/ssl.hpp>
|
||||
@@ -19,12 +20,14 @@
|
||||
#include <atomic>
|
||||
#include <mutex>
|
||||
#include <regex>
|
||||
#include <deque>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace http = beast::http;
|
||||
namespace websocket = beast::websocket;
|
||||
namespace asio = boost::asio;
|
||||
using tcp = boost::asio::ip::tcp;
|
||||
|
||||
@@ -37,6 +40,24 @@ using MiddlewareHandler = std::function<bool(
|
||||
http::response<http::string_body>&,
|
||||
const std::unordered_map<std::string, std::string>&)>;
|
||||
|
||||
// WebSocket 回调类型
|
||||
using WebSocketMessageHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> sendCallback)>;
|
||||
|
||||
using WebSocketConnectHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& remoteAddress)>;
|
||||
|
||||
using WebSocketDisconnectHandler = std::function<void(
|
||||
const std::string& clientId)>;
|
||||
|
||||
// WebSocket 代理:握手回调接收 HTTP upgrade 请求,返回目标 ws:// 或 wss:// URL
|
||||
// 返回空字符串表示拒绝(返回 403)
|
||||
using WsProxyHandshakeHandler = std::function<std::string(
|
||||
const http::request<http::string_body>& upgradeReq)>;
|
||||
|
||||
struct RouteEntry {
|
||||
std::string path;
|
||||
std::regex pattern;
|
||||
@@ -44,6 +65,16 @@ struct RouteEntry {
|
||||
bool is_wildcard;
|
||||
};
|
||||
|
||||
struct WsRouteEntry {
|
||||
std::string path;
|
||||
WebSocketMessageHandler handler;
|
||||
};
|
||||
|
||||
struct WsProxyRouteEntry {
|
||||
std::string path;
|
||||
WsProxyHandshakeHandler handshake;
|
||||
};
|
||||
|
||||
class HttpServer {
|
||||
public:
|
||||
explicit HttpServer(const std::string& address, unsigned short port,
|
||||
@@ -60,10 +91,20 @@ public:
|
||||
void post(const std::string& path, HttpRequestHandler handler);
|
||||
void put(const std::string& path, HttpRequestHandler handler);
|
||||
void del(const std::string& path, HttpRequestHandler handler);
|
||||
void options(const std::string& path, HttpRequestHandler handler); // 新增 OPTIONS
|
||||
void options(const std::string& path, HttpRequestHandler handler);
|
||||
void use(MiddlewareHandler middleware);
|
||||
void setStaticDirectory(const std::string& path);
|
||||
|
||||
// WebSocket 接口
|
||||
void ws(const std::string& path, WebSocketMessageHandler handler);
|
||||
void ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake);
|
||||
void onWsConnect(WebSocketConnectHandler handler);
|
||||
void onWsDisconnect(WebSocketDisconnectHandler handler);
|
||||
bool sendToWsClient(const std::string& clientId, const std::string& message);
|
||||
size_t broadcastWs(const std::string& message);
|
||||
void disconnectWsClient(const std::string& clientId);
|
||||
size_t getWsClientCount() const;
|
||||
|
||||
bool isRunning() const;
|
||||
unsigned short getPort() const;
|
||||
std::string getAddress() const;
|
||||
@@ -103,10 +144,19 @@ private:
|
||||
std::vector<RouteEntry> post_handlers_;
|
||||
std::vector<RouteEntry> put_handlers_;
|
||||
std::vector<RouteEntry> delete_handlers_;
|
||||
std::vector<RouteEntry> options_handlers_; // 新增 OPTIONS 路由
|
||||
std::vector<RouteEntry> options_handlers_;
|
||||
std::vector<MiddlewareHandler> middlewares_;
|
||||
std::string static_directory_;
|
||||
|
||||
// WebSocket 路由和处理
|
||||
std::vector<WsRouteEntry> ws_handlers_;
|
||||
std::vector<WsProxyRouteEntry> ws_proxy_handlers_;
|
||||
WebSocketConnectHandler ws_connect_handler_;
|
||||
WebSocketDisconnectHandler ws_disconnect_handler_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Session>> ws_clients_;
|
||||
mutable std::mutex ws_clients_mutex_;
|
||||
std::atomic<uint64_t> ws_client_counter_{0};
|
||||
|
||||
std::vector<std::weak_ptr<Session>> sessions_;
|
||||
std::mutex sessions_mutex_;
|
||||
|
||||
@@ -115,12 +165,22 @@ private:
|
||||
bool matchPath(const std::string& path, const std::regex& pattern);
|
||||
http::response<http::string_body> handleRequest(const http::request<http::string_body>& req);
|
||||
HttpRequestHandler findRoute(const std::string& method, const std::string& path);
|
||||
bool routeExists(const std::string& method, const std::string& path); // 新增辅助方法
|
||||
bool routeExists(const std::string& method, const std::string& path);
|
||||
WebSocketMessageHandler findWsRoute(const std::string& path);
|
||||
WsProxyHandshakeHandler findWsProxyRoute(const std::string& path);
|
||||
|
||||
// WebSocket 客户端管理
|
||||
std::string generateWsClientId();
|
||||
void addWsClient(const std::string& clientId, std::shared_ptr<Session> session);
|
||||
void removeWsClient(const std::string& clientId);
|
||||
std::shared_ptr<Session> getWsClient(const std::string& clientId);
|
||||
|
||||
void addSession(const std::shared_ptr<Session>& session);
|
||||
void removeSession(const Session* session);
|
||||
void closeAllSessions();
|
||||
void initSSLContext();
|
||||
|
||||
friend class Session;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
#include <us/HttpClient.hpp>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
namespace pmc::net {
|
||||
|
||||
HttpClient::HttpClient() {
|
||||
curl_ = curl_easy_init();
|
||||
if (!curl_) {
|
||||
throw std::runtime_error("Failed to initialize CURL");
|
||||
}
|
||||
}
|
||||
|
||||
HttpClient::~HttpClient() {
|
||||
ws_close();
|
||||
if (curl_) {
|
||||
curl_easy_cleanup(curl_);
|
||||
}
|
||||
if (headers_) {
|
||||
curl_slist_free_all(headers_);
|
||||
}
|
||||
}
|
||||
|
||||
void HttpClient::set_header(const std::string& header) {
|
||||
headers_ = curl_slist_append(headers_, header.c_str());
|
||||
}
|
||||
|
||||
void HttpClient::clear_headers() {
|
||||
if (headers_) {
|
||||
curl_slist_free_all(headers_);
|
||||
headers_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
const std::map<std::string, std::string>& HttpClient::get_response_headers() const {
|
||||
return response_headers_;
|
||||
}
|
||||
|
||||
long HttpClient::get_response_code() const {
|
||||
return response_code_;
|
||||
}
|
||||
|
||||
void HttpClient::request_stream_sync(
|
||||
HttpMethod method,
|
||||
const std::string& url,
|
||||
const std::string& data,
|
||||
std::function<void(const std::string&)> callback)
|
||||
{
|
||||
if (!curl_) {
|
||||
throw std::runtime_error("CURL not initialized");
|
||||
}
|
||||
|
||||
response_headers_.clear();
|
||||
response_code_ = 0;
|
||||
|
||||
curl_easy_reset(curl_);
|
||||
curl_easy_setopt(curl_, CURLOPT_URL, url.c_str());
|
||||
set_http_method(method, data);
|
||||
curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers_);
|
||||
curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, header_callback);
|
||||
curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this);
|
||||
|
||||
std::string full_response;
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response);
|
||||
|
||||
curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L);
|
||||
curl_easy_setopt(curl_, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
|
||||
CURLcode res = curl_easy_perform(curl_);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
throw std::runtime_error(std::string("CURL error: ") + curl_easy_strerror(res));
|
||||
}
|
||||
|
||||
curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &response_code_);
|
||||
|
||||
if (!full_response.empty()) {
|
||||
callback(full_response);
|
||||
}
|
||||
}
|
||||
|
||||
void HttpClient::get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::GET, url, "", callback);
|
||||
}
|
||||
|
||||
void HttpClient::post_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::POST, url, data, callback);
|
||||
}
|
||||
|
||||
void HttpClient::put_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::PUT, url, data, callback);
|
||||
}
|
||||
|
||||
void HttpClient::delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback) {
|
||||
request_stream_sync(HttpMethod::DELETE, url, "", callback);
|
||||
}
|
||||
|
||||
bool HttpClient::ws_connect(const std::string& url, int timeout_ms) {
|
||||
std::string host, port, path;
|
||||
bool ssl = false;
|
||||
|
||||
if (url.size() >= 6 && url.substr(0, 6) == "wss://") {
|
||||
ssl = true;
|
||||
parse_ws_url(url.substr(6), host, port, path, "443");
|
||||
} else if (url.size() >= 5 && url.substr(0, 5) == "ws://") {
|
||||
ssl = false;
|
||||
parse_ws_url(url.substr(5), host, port, path, "80");
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ws_connect(host, port, path, ssl, timeout_ms);
|
||||
}
|
||||
|
||||
bool HttpClient::ws_connect(const std::string& host, const std::string& port,
|
||||
const std::string& path, bool ssl, int timeout_ms) {
|
||||
ws_close();
|
||||
ws_state_ = std::make_unique<WsState>();
|
||||
ws_state_->ssl_enabled = ssl;
|
||||
ws_state_->host = host;
|
||||
ws_state_->port = port;
|
||||
ws_state_->path = path.empty() ? "/" : path;
|
||||
|
||||
try {
|
||||
tcp::resolver resolver(ws_state_->ioc);
|
||||
auto const results = resolver.resolve(host, port);
|
||||
|
||||
if (ssl) {
|
||||
ws_state_->ssl_ctx = std::make_unique<asio::ssl::context>(asio::ssl::context::tlsv12_client);
|
||||
ws_state_->ssl_ctx->set_verify_mode(asio::ssl::verify_none);
|
||||
|
||||
ws_state_->tcp_stream = std::make_unique<beast::tcp_stream>(ws_state_->ioc);
|
||||
|
||||
ws_state_->tcp_stream->expires_after(std::chrono::milliseconds(timeout_ms));
|
||||
asio::connect(ws_state_->tcp_stream->socket(), results);
|
||||
|
||||
ws_state_->ssl_stream = std::make_unique<asio::ssl::stream<tcp::socket&>>(
|
||||
ws_state_->tcp_stream->socket(), *ws_state_->ssl_ctx);
|
||||
|
||||
if (!SSL_set_tlsext_host_name(ws_state_->ssl_stream->native_handle(), host.c_str())) {
|
||||
throw std::runtime_error("Failed to set SNI hostname");
|
||||
}
|
||||
|
||||
ws_state_->ssl_stream->handshake(asio::ssl::stream_base::client);
|
||||
|
||||
ws_state_->wss_stream = std::make_unique<websocket::stream<asio::ssl::stream<tcp::socket&>&>>(
|
||||
*ws_state_->ssl_stream);
|
||||
|
||||
ws_state_->tcp_stream->expires_never();
|
||||
ws_state_->wss_stream->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client));
|
||||
ws_state_->wss_stream->set_option(websocket::stream_base::decorator(
|
||||
[](websocket::request_type& req) {
|
||||
req.set(beast::http::field::user_agent, "UniServe-HttpClient");
|
||||
}));
|
||||
ws_state_->wss_stream->handshake(host, ws_state_->path);
|
||||
} else {
|
||||
ws_state_->tcp_stream = std::make_unique<beast::tcp_stream>(ws_state_->ioc);
|
||||
|
||||
ws_state_->tcp_stream->expires_after(std::chrono::milliseconds(timeout_ms));
|
||||
asio::connect(ws_state_->tcp_stream->socket(), results);
|
||||
|
||||
ws_state_->ws_stream = std::make_unique<websocket::stream<tcp::socket&>>(
|
||||
ws_state_->tcp_stream->socket());
|
||||
|
||||
ws_state_->tcp_stream->expires_never();
|
||||
ws_state_->ws_stream->set_option(websocket::stream_base::timeout::suggested(beast::role_type::client));
|
||||
ws_state_->ws_stream->set_option(websocket::stream_base::decorator(
|
||||
[](websocket::request_type& req) {
|
||||
req.set(beast::http::field::user_agent, "UniServe-HttpClient");
|
||||
}));
|
||||
ws_state_->ws_stream->handshake(host, ws_state_->path);
|
||||
}
|
||||
|
||||
ws_state_->connected = true;
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[HttpClient::ws_connect] " << e.what() << std::endl;
|
||||
ws_state_.reset();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool HttpClient::ws_send(const std::string& message) {
|
||||
if (!ws_state_ || !ws_state_->connected) return false;
|
||||
try {
|
||||
if (ws_state_->ssl_enabled && ws_state_->wss_stream) {
|
||||
ws_state_->wss_stream->write(asio::buffer(message));
|
||||
} else if (ws_state_->ws_stream) {
|
||||
ws_state_->ws_stream->write(asio::buffer(message));
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[HttpClient::ws_send] " << e.what() << std::endl;
|
||||
ws_state_->connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool HttpClient::ws_send_binary(const void* data, size_t size) {
|
||||
if (!ws_state_ || !ws_state_->connected) return false;
|
||||
try {
|
||||
if (ws_state_->ssl_enabled && ws_state_->wss_stream) {
|
||||
ws_state_->wss_stream->binary(true);
|
||||
ws_state_->wss_stream->write(asio::buffer(data, size));
|
||||
ws_state_->wss_stream->text(true);
|
||||
} else if (ws_state_->ws_stream) {
|
||||
ws_state_->ws_stream->binary(true);
|
||||
ws_state_->ws_stream->write(asio::buffer(data, size));
|
||||
ws_state_->ws_stream->text(true);
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[HttpClient::ws_send_binary] " << e.what() << std::endl;
|
||||
ws_state_->connected = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::string HttpClient::ws_recv(int timeout_ms) {
|
||||
(void)timeout_ms;
|
||||
if (!ws_state_ || !ws_state_->connected) return "";
|
||||
try {
|
||||
beast::flat_buffer buffer;
|
||||
|
||||
if (ws_state_->ssl_enabled && ws_state_->wss_stream) {
|
||||
ws_state_->wss_stream->read(buffer);
|
||||
} else if (ws_state_->ws_stream) {
|
||||
ws_state_->ws_stream->read(buffer);
|
||||
}
|
||||
return beast::buffers_to_string(buffer.data());
|
||||
} catch (const beast::system_error& e) {
|
||||
if (e.code() == websocket::error::closed) {
|
||||
ws_state_->connected = false;
|
||||
} else {
|
||||
std::cerr << "[HttpClient::ws_recv] " << e.what() << std::endl;
|
||||
ws_state_->connected = false;
|
||||
}
|
||||
return "";
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "[HttpClient::ws_recv] " << e.what() << std::endl;
|
||||
ws_state_->connected = false;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
bool HttpClient::ws_is_connected() const {
|
||||
return ws_state_ && ws_state_->connected;
|
||||
}
|
||||
|
||||
void HttpClient::ws_close() {
|
||||
if (!ws_state_) return;
|
||||
try {
|
||||
if (ws_state_->connected) {
|
||||
beast::error_code ec;
|
||||
if (ws_state_->ssl_enabled && ws_state_->wss_stream) {
|
||||
ws_state_->wss_stream->close(websocket::close_code::normal, ec);
|
||||
} else if (ws_state_->ws_stream) {
|
||||
ws_state_->ws_stream->close(websocket::close_code::normal, ec);
|
||||
}
|
||||
}
|
||||
} catch (...) {
|
||||
// Ignore close errors
|
||||
}
|
||||
ws_state_.reset();
|
||||
}
|
||||
|
||||
std::string HttpClient::ws_get_host() const { return ws_state_ ? ws_state_->host : ""; }
|
||||
std::string HttpClient::ws_get_port() const { return ws_state_ ? ws_state_->port : ""; }
|
||||
std::string HttpClient::ws_get_path() const { return ws_state_ ? ws_state_->path : ""; }
|
||||
|
||||
void HttpClient::set_http_method(HttpMethod method, const std::string& data) {
|
||||
switch (method) {
|
||||
case HttpMethod::GET:
|
||||
curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L);
|
||||
break;
|
||||
case HttpMethod::POST:
|
||||
curl_easy_setopt(curl_, CURLOPT_POST, 1L);
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
case HttpMethod::PUT:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PUT");
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
case HttpMethod::DELETE:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "DELETE");
|
||||
break;
|
||||
case HttpMethod::PATCH:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PATCH");
|
||||
if (!data.empty()) {
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str());
|
||||
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size());
|
||||
}
|
||||
break;
|
||||
case HttpMethod::HEAD:
|
||||
curl_easy_setopt(curl_, CURLOPT_NOBODY, 1L);
|
||||
break;
|
||||
case HttpMethod::OPTIONS:
|
||||
curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "OPTIONS");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
size_t HttpClient::header_callback(char* buffer, size_t size, size_t nitems, void* userdata) {
|
||||
size_t total_size = size * nitems;
|
||||
HttpClient* client = static_cast<HttpClient*>(userdata);
|
||||
std::string header_line(buffer, total_size);
|
||||
size_t colon_pos = header_line.find(':');
|
||||
if (colon_pos != std::string::npos) {
|
||||
std::string key = header_line.substr(0, colon_pos);
|
||||
std::string value = header_line.substr(colon_pos + 1);
|
||||
key.erase(0, key.find_first_not_of(" \t"));
|
||||
key.erase(key.find_last_not_of(" \t") + 1);
|
||||
value.erase(0, value.find_first_not_of(" \t"));
|
||||
value.erase(value.find_last_not_of(" \t\r\n") + 1);
|
||||
if (!key.empty() && !value.empty()) {
|
||||
client->response_headers_[key] = value;
|
||||
}
|
||||
}
|
||||
return total_size;
|
||||
}
|
||||
|
||||
size_t HttpClient::write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {
|
||||
size_t total_size = size * nmemb;
|
||||
std::string* response = static_cast<std::string*>(userdata);
|
||||
response->append(ptr, total_size);
|
||||
return total_size;
|
||||
}
|
||||
|
||||
void HttpClient::parse_ws_url(const std::string& url_part, std::string& host, std::string& port,
|
||||
std::string& path, const char* default_port) {
|
||||
size_t path_pos = url_part.find('/');
|
||||
std::string host_port;
|
||||
if (path_pos != std::string::npos) {
|
||||
host_port = url_part.substr(0, path_pos);
|
||||
path = url_part.substr(path_pos);
|
||||
} else {
|
||||
host_port = url_part;
|
||||
path = "/";
|
||||
}
|
||||
size_t port_pos = host_port.find(':');
|
||||
if (port_pos != std::string::npos) {
|
||||
host = host_port.substr(0, port_pos);
|
||||
port = host_port.substr(port_pos + 1);
|
||||
} else {
|
||||
host = host_port;
|
||||
port = default_port;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace pmc::net
|
||||
+816
-103
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,145 @@
|
||||
#include "us/HttpServer.hpp"
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <signal.h>
|
||||
|
||||
using namespace pmc::net;
|
||||
|
||||
static std::unique_ptr<HttpServer> g_server;
|
||||
|
||||
void signalHandler(int signum) {
|
||||
std::cout << "\nStopping server..." << std::endl;
|
||||
if (g_server) g_server->stop();
|
||||
}
|
||||
|
||||
int main() {
|
||||
signal(SIGINT, signalHandler);
|
||||
signal(SIGTERM, signalHandler);
|
||||
|
||||
try {
|
||||
// 创建 HTTP 服务器(同时支持 HTTP 和 WebSocket)
|
||||
g_server = std::make_unique<HttpServer>("0.0.0.0", 9090, 2);
|
||||
|
||||
// 注册普通 HTTP GET 接口
|
||||
g_server->get("/hello", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "text/plain; charset=utf-8");
|
||||
res.result(http::status::ok);
|
||||
res.body() = "Hello from HTTP! Server is working correctly.";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
// 注册 HTTP POST 接口
|
||||
g_server->post("/echo", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "application/json; charset=utf-8");
|
||||
res.result(http::status::ok);
|
||||
res.body() = std::string("{\"received\":\"") + req.body() + "\"}";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
// WebSocket 测试页面
|
||||
g_server->get("/test", [](const auto& req, const auto& params) {
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "text/html; charset=utf-8");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"HTML(<!DOCTYPE html>
|
||||
<html><head><title>WebSocket Test</title>
|
||||
<style>body{font-family:monospace;padding:20px}#log{border:1px solid #ccc;padding:10px;height:300px;overflow-y:scroll}</style>
|
||||
</head><body>
|
||||
<h2>HTTP+WebSocket Integration Test</h2>
|
||||
<p>HTTP: <a href="/hello">/hello</a> | <a href="#" onclick="testPost();return false">POST /echo</a></p>
|
||||
<button onclick="testWs('/ws')">Connect /ws (echo)</button>
|
||||
<button onclick="testWs('/chat')">Connect /chat</button>
|
||||
<button onclick="clearLog()">Clear</button>
|
||||
<div id="log"></div>
|
||||
<script>
|
||||
const logDiv = document.getElementById('log');
|
||||
function log(msg, color) {
|
||||
color = color || 'black';
|
||||
logDiv.innerHTML += '<span style="color:'+color+'">'+new Date().toLocaleTimeString()+' - '+msg+'</span><br>';
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
}
|
||||
function clearLog() { logDiv.innerHTML = ''; }
|
||||
|
||||
function testPost() {
|
||||
fetch('/echo',{method:'POST',body:'Hello POST'})
|
||||
.then(function(r){return r.text()}).then(function(t){log('POST /echo: '+t,'blue')});
|
||||
}
|
||||
|
||||
function testWs(path) {
|
||||
log('Connecting to ws://'+location.host+path+'...');
|
||||
var ws = new WebSocket('ws://'+location.host+path);
|
||||
ws.onopen = function() { log(path+' connected','green'); ws.send('Hello from browser to '+path); };
|
||||
ws.onmessage = function(e) { log(path+' received: '+e.data,'blue'); };
|
||||
ws.onerror = function(e) { log(path+' error','red'); };
|
||||
ws.onclose = function() { log(path+' closed','gray'); };
|
||||
}
|
||||
log('Page loaded. HTTP server working. Use buttons above to test WebSocket.');
|
||||
</script></body></html>)HTML";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
});
|
||||
|
||||
// 注册 WebSocket 接口 - Echo 服务器
|
||||
g_server->ws("/ws", [](const std::string& clientId, const std::string& message,
|
||||
std::function<void(const std::string&)> send) {
|
||||
std::cout << "[WS] Received from " << clientId << ": " << message << std::endl;
|
||||
// Echo back with prefix
|
||||
send(std::string("Echo: ") + message);
|
||||
});
|
||||
|
||||
// WebSocket 另一个接口 - Chat
|
||||
g_server->ws("/chat", [](const std::string& clientId, const std::string& message,
|
||||
std::function<void(const std::string&)> send) {
|
||||
std::cout << "[Chat] Received from " << clientId << ": " << message << std::endl;
|
||||
send(std::string("[Chat] ") + message);
|
||||
});
|
||||
|
||||
// WebSocket 连接回调
|
||||
g_server->onWsConnect([](const std::string& clientId, const std::string& address) {
|
||||
std::cout << "[WS] Client connected: " << clientId << " from " << address << std::endl;
|
||||
});
|
||||
|
||||
// WebSocket 断开回调
|
||||
g_server->onWsDisconnect([](const std::string& clientId) {
|
||||
std::cout << "[WS] Client disconnected: " << clientId << std::endl;
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
g_server->showStatus();
|
||||
g_server->start();
|
||||
|
||||
std::cout << "\n=== Test Server Running ===" << std::endl;
|
||||
std::cout << "HTTP test: curl http://localhost:9090/hello" << std::endl;
|
||||
std::cout << "WebSocket test: ws://localhost:9090/ws (echo) or ws://localhost:9090/chat (chat)" << std::endl;
|
||||
std::cout << "Press Ctrl+C to stop" << std::endl;
|
||||
std::cout << std::endl;
|
||||
|
||||
// 3秒后广播一条消息给所有 WebSocket 客户端
|
||||
std::thread broadcast_thread([&]() {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||||
if (g_server && g_server->getWsClientCount() > 0) {
|
||||
std::cout << "Broadcasting to all clients..." << std::endl;
|
||||
g_server->broadcastWs("Server broadcast after 3 seconds!");
|
||||
}
|
||||
});
|
||||
broadcast_thread.detach();
|
||||
|
||||
// 运行事件循环
|
||||
g_server->run();
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "Server error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "Server stopped." << std::endl;
|
||||
return 0;
|
||||
}
|
||||
+86
-50
@@ -141,6 +141,8 @@ server.start(on_message) # 阻塞当前线程
|
||||
|------|------|
|
||||
| `addr` | 监听地址 |
|
||||
| `port` | 监听端口 |
|
||||
| `workers` | 工作线程数(关键字参数,默认 `0`);`>0` 时回调在线程池中并发执行 |
|
||||
| `recv_timeout` | 接收单条客户端消息的超时秒数(关键字参数,默认 `10.0`);超时未收到完整消息则主动断开连接,防御 Slowloris 式半开连接攻击 |
|
||||
|
||||
回调签名为 `on_message(sock, addr, text)`:
|
||||
- `sock` — 客户端 socket,可用 `reply()` 回复
|
||||
@@ -159,68 +161,106 @@ server.start(on_message) # 阻塞当前线程
|
||||
|
||||
服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `select` 实现非阻塞 I/O,轮询间隔 100ms。
|
||||
|
||||
**Slowloris 防护**:每个客户端连接在 `recv_timeout`(默认 10 秒)内必须发送完一条完整消息(4 字节头 + 载荷),超时未完成则服务端主动断开该连接,防止慢速发送攻击阻塞主事件循环。
|
||||
|
||||
**并发模式(v0.2.0)**:
|
||||
|
||||
```python
|
||||
server = MsgServer("0.0.0.0", 8005, workers=4)
|
||||
```
|
||||
|
||||
默认 `workers=0` 为同步模式:回调在 select 事件循环线程内串行执行,慢回调会阻塞所有连接。`workers>0` 时回调提交到 `ThreadPoolExecutor` 并发执行,I/O 密集回调(如 SQL 查询)可获得真实并行度。保证:
|
||||
|
||||
- **同一连接消息严格串行**:回调完成前该连接暂停 select 监听(恢复粒度 ≤100ms,对短连接 RPC 无影响);
|
||||
- **发送不交错**:`reply()` / `broadcast()` 经每连接锁串行化;
|
||||
- **故障隔离**:回调抛异常仅移除该连接并打印 traceback,服务整体存活(同步模式下异常会直接使 `start()` 崩溃,两种模式语义不同,请注意)。
|
||||
|
||||
## 线程安全
|
||||
|
||||
- `MsgServer.stop()` 可从任意线程安全调用:设置原子标志并关闭监听 socket,`select` 循环会干净退出。
|
||||
- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内(同一线程)调用是安全的。服务端运行期间从其他线程并发调用不支持。
|
||||
- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内调用是安全的(线程池模式下经每连接发送锁串行化)。`broadcast()` 会遍历客户端列表,仅限回调线程调用,不要从无关的第三方线程并发调用。
|
||||
- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。
|
||||
- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。
|
||||
|
||||
## JSON RPC 约定层(v0.3.0)
|
||||
|
||||
项目内所有 pymsg 通信都遵循同一 JSON RPC 约定(与 C++/Go 客户端字节级兼容):
|
||||
|
||||
```
|
||||
请求: {"_route": "<路由名>", ...业务字段}
|
||||
响应: {"success": true, "data": ...} | {"success": false, "error": "..."}
|
||||
```
|
||||
|
||||
`RpcServer` / `RpcClient` 把这套约定的编解码、路由分发、错误包装收敛为开箱即用的类(`pymsg/rpc.py`),各服务无需再手写 `_dispatch` / `_reply`。
|
||||
|
||||
### `RpcServer` — JSON RPC 服务端
|
||||
|
||||
```python
|
||||
from pymsg import RpcServer
|
||||
|
||||
rpc = RpcServer("127.0.0.1", 8005, workers=4)
|
||||
|
||||
@rpc.route("execute")
|
||||
def _execute(req): # handler(req: dict) -> data
|
||||
return {"rows": []} # 返回值自动包成 {"success": true, "data": ...}
|
||||
# 抛异常自动包成 {"success": false, "error": str(e)}
|
||||
rpc.start() # 阻塞当前线程
|
||||
```
|
||||
|
||||
| 构造参数 | 含义 |
|
||||
|----------|------|
|
||||
| `addr` / `port` | 监听地址与端口 |
|
||||
| `workers` | 回调并发线程数(透传 `MsgServer`,默认 0) |
|
||||
| `json_default` | 响应 JSON 序列化兜底函数(如日期转 ISO 8601) |
|
||||
| `on_error` | 可选钩子 `on_error(route, exc)`,handler 异常时调用(记日志用) |
|
||||
|
||||
### `RpcClient` — JSON RPC 客户端
|
||||
|
||||
```python
|
||||
from pymsg import RpcClient, RpcError
|
||||
|
||||
rpc = RpcClient("127.0.0.1", 8005, "dnfs", timeout=10.0)
|
||||
try:
|
||||
data = rpc.call("execute", {"action": "select", "table": "Users"})
|
||||
except RpcError as e:
|
||||
... # 服务端业务错误(success=false)
|
||||
```
|
||||
|
||||
`call(route, payload=None, *, timeout=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。
|
||||
|
||||
## 实际用例
|
||||
|
||||
### 服务端 — 数据库服务(dbv2)
|
||||
|
||||
db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。
|
||||
db 服务基于 `RpcServer` 暴露通用 CRUD 接口(路由 `execute`):
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgServer
|
||||
from pymsg import RpcServer
|
||||
|
||||
class DBServer:
|
||||
def __init__(self):
|
||||
self._server = MsgServer("127.0.0.1", 8005)
|
||||
self._routes = {"execute": self._handle_execute}
|
||||
def __init__(self, addr="127.0.0.1", port=8005, workers=4):
|
||||
self._server = RpcServer(addr, port, workers=workers,
|
||||
json_default=_json_default)
|
||||
self._server.add_route("execute", self._handle_execute)
|
||||
|
||||
def start(self):
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def _dispatch(self, sock, addr, text):
|
||||
req = json.loads(text)
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": "未知路由"}))
|
||||
return
|
||||
try:
|
||||
result = handler(req)
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": True, "data": result}))
|
||||
except Exception as e:
|
||||
self._server.reply(sock,
|
||||
json.dumps({"success": False, "error": str(e)}))
|
||||
self._server.start()
|
||||
|
||||
def _handle_execute(self, data):
|
||||
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表
|
||||
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表;异常自动包装为错误响应
|
||||
pass
|
||||
```
|
||||
|
||||
### 客户端 — RPC 调用(dnfs → db)
|
||||
|
||||
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。
|
||||
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 `RpcClient` 调用 db:
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
from pymsg import RpcClient
|
||||
|
||||
def _call_dbv2(payload):
|
||||
client = MsgClient("127.0.0.1", 8005, "dnfs")
|
||||
resp = client.call(json.dumps(payload), timeout=10.0)
|
||||
return json.loads(resp)
|
||||
_rpc = RpcClient("127.0.0.1", 8005, "dnfs")
|
||||
|
||||
# 创建笔记
|
||||
result = _call_dbv2({
|
||||
"_route": "execute",
|
||||
data = _rpc.call("execute", {
|
||||
"action": "insert",
|
||||
"table": "Notes",
|
||||
"values": {"title": "会议记录", "content": "..."}
|
||||
@@ -229,24 +269,19 @@ result = _call_dbv2({
|
||||
|
||||
### 客户端 — 异步日志推送(log_client)
|
||||
|
||||
各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。
|
||||
各服务通过统一 `log_client.py` 将日志异步推送到 db,底层为 `RpcClient`:
|
||||
|
||||
```python
|
||||
import json
|
||||
from pymsg import MsgClient
|
||||
from pymsg import RpcClient
|
||||
|
||||
def _send(batch):
|
||||
client = MsgClient("127.0.0.1", 8005, "gateway-log")
|
||||
for entry in batch:
|
||||
client.call(json.dumps({
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now, "service": entry["service"],
|
||||
"level": entry["level"], "message": entry["message"],
|
||||
}
|
||||
}), timeout=5)
|
||||
client = RpcClient("127.0.0.1", 8005, "log-client")
|
||||
for entry in batch:
|
||||
client.call("execute", {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {"ts": now, "service": entry["service"],
|
||||
"level": entry["level"], "message": entry["message"]},
|
||||
}, timeout=5)
|
||||
```
|
||||
|
||||
## 跨语言互操作
|
||||
@@ -276,6 +311,7 @@ pymsg 的线格式有意设计得极其简单,使其他语言的服务也能
|
||||
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
|
||||
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
|
||||
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
|
||||
| `pymsg.RpcError` | `RpcClient.call()` 收到 `success=false` 业务错误或非法 RPC 响应 |
|
||||
|
||||
`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。
|
||||
|
||||
|
||||
+74
-25
@@ -1,7 +1,7 @@
|
||||
# pymsg 模块技术手册
|
||||
|
||||
> 文档版本:2026-07-30
|
||||
> 代码版本:0.1.0
|
||||
> 代码版本:0.3.0
|
||||
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
|
||||
> 定位:跨语言 TCP 消息帧协议库
|
||||
|
||||
@@ -144,7 +144,11 @@ log_client.push(entry)
|
||||
```
|
||||
pymsg/
|
||||
├── pymsg/
|
||||
│ └── __init__.py # 核心实现(~370 行)
|
||||
│ ├── __init__.py # 核心协议与 MsgClient/MsgServer(~470 行)
|
||||
│ └── rpc.py # JSON RPC 约定层 RpcClient/RpcServer(~170 行,v0.3.0)
|
||||
├── tests/
|
||||
│ ├── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib)
|
||||
│ └── test_rpc.py # RPC 约定层单元测试(纯 stdlib)
|
||||
├── README.md # 用户文档
|
||||
├── pyproject.toml # 项目配置
|
||||
└── .gitignore
|
||||
@@ -165,23 +169,40 @@ pymsg/
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
|---------|------|------|
|
||||
| `send(sock, data, *, timeout=None)` | L48 | 底层发送 |
|
||||
| `recv(sock, *, timeout=None)` | L72 | 底层接收 |
|
||||
| `_recv_exact(sock, n)` | L103 | 精确读 n 字节 |
|
||||
| `_timeout(sock, seconds)` | L34 | 超时 context manager |
|
||||
| `TimeoutError` | L30 | 超时异常 |
|
||||
| `MsgClient` | L120 | TCP 客户端 |
|
||||
| `MsgClient.__init__` | L136 | 构造器 |
|
||||
| `MsgClient.connect()` | L144 | 建立长连接 |
|
||||
| `MsgClient.send(msg)` | L150 | 短连接发送(带 name 前缀) |
|
||||
| `MsgClient.call(msg, *, timeout=None)` | L163 | 短连接 RPC |
|
||||
| `MsgClient.listen(on_message)` | L193 | 长连接监听 |
|
||||
| `MsgClient.stop()` | L220 | 停止监听 |
|
||||
| `MsgServer` | L234 | TCP 服务端 |
|
||||
| `MsgServer.start(on_message)` | L259 | 启动服务 |
|
||||
| `MsgServer.broadcast(msg, except_sock)` | L296 | 广播 |
|
||||
| `MsgServer.reply(sock, msg)` | L321 | 回复 |
|
||||
| `MsgServer.stop()` | L329 | 停止服务 |
|
||||
| `send(sock, data, *, timeout=None)` | L51 | 底层发送 |
|
||||
| `recv(sock, *, timeout=None)` | L75 | 底层接收 |
|
||||
| `_recv_exact(sock, n)` | L106 | 精确读 n 字节 |
|
||||
| `_timeout(sock, seconds)` | L38 | 超时 context manager |
|
||||
| `TimeoutError` | L33 | 超时异常 |
|
||||
| `MsgClient` | L123 | TCP 客户端 |
|
||||
| `MsgClient.__init__` | L139 | 构造器 |
|
||||
| `MsgClient.connect()` | L147 | 建立长连接 |
|
||||
| `MsgClient.send(msg)` | L153 | 短连接发送(带 name 前缀) |
|
||||
| `MsgClient.call(msg, *, timeout=None)` | L166 | 短连接 RPC |
|
||||
| `MsgClient.listen(on_message)` | L196 | 长连接监听 |
|
||||
| `MsgClient.stop()` | L223 | 停止监听 |
|
||||
| `MsgServer` | L237 | TCP 服务端 |
|
||||
| `MsgServer.__init__(addr, port, *, workers=0)` | L262 | 构造器(workers>0 启用线程池) |
|
||||
| `MsgServer.start(on_message)` | L276 | 启动服务 |
|
||||
| `MsgServer.broadcast(msg, except_sock)` | L329 | 广播(每连接锁串行化) |
|
||||
| `MsgServer.reply(sock, msg)` | L355 | 回复(每连接锁串行化) |
|
||||
| `MsgServer._locked_send(sock, msg)` | L363 | 带每连接锁的发送 |
|
||||
| `MsgServer.stop()` | L373 | 停止服务 |
|
||||
| `MsgServer._run_task(...)` | L413 | 工作线程:执行回调、失败打标记 |
|
||||
| `MsgServer._reap_failed(socketlist)` | L434 | 主循环统一移除失败连接 |
|
||||
|
||||
#### RPC 约定层(rpc.py,v0.3.0)
|
||||
|
||||
| 函数/类 | 行号 | 功能 |
|
||||
|---------|------|------|
|
||||
| `RpcError` | L28 | 业务错误异常(success=false / 非法响应) |
|
||||
| `RpcClient` | L32 | JSON RPC 客户端 |
|
||||
| `RpcClient.call(route, payload, *, timeout)` | L46 | 注入 _route、编解码、检查 success,返回 data |
|
||||
| `RpcServer` | L80 | JSON RPC 服务端(基于 MsgServer) |
|
||||
| `RpcServer.route(name)` / `add_route` | L119/L126 | 注册路由 handler(`fn(req) -> data`) |
|
||||
| `RpcServer.start()` / `stop()` | L131/L135 | 启动(阻塞)/ 停止 |
|
||||
| `RpcServer._dispatch` | L140 | 路由分发与异常包装 |
|
||||
| `RpcServer._reply` | L164 | 响应包装(支持 json_default) |
|
||||
|
||||
#### C++ 实现(Msg.hpp)
|
||||
|
||||
@@ -319,12 +340,38 @@ client.listen(on_message) # 阻塞,直到 stop()
|
||||
#### 构造
|
||||
|
||||
```python
|
||||
server = MsgServer(addr: str, port: int)
|
||||
server = MsgServer(addr: str, port: int, *, workers: int = 0)
|
||||
```
|
||||
|
||||
**参数**:
|
||||
- `addr`:监听地址(`0.0.0.0` 表示所有网卡)
|
||||
- `port`:监听端口
|
||||
- `workers`:工作线程数(v0.2.0+)。默认 `0` 为同步模式(回调在 select 循环线程内串行执行);`>0` 时回调提交到 `ThreadPoolExecutor` 并发执行
|
||||
|
||||
#### 并发模型(v0.2.0)
|
||||
|
||||
```
|
||||
客户端 A ──┐
|
||||
客户端 B ──┼──► select 主循环(单线程)
|
||||
客户端 C ──┘ │
|
||||
├── accept / recv(主线程,快速 I/O)
|
||||
│
|
||||
├── workers=0:on_message 就地同步执行(串行)
|
||||
│
|
||||
└── workers>0:标记 busy → submit 到 ThreadPoolExecutor
|
||||
│
|
||||
▼
|
||||
worker 线程执行 on_message
|
||||
├── reply() 经该连接发送锁串行化
|
||||
├── 异常 → 标记 _failed(主循环下轮统一移除)
|
||||
└── 完成 → 清除 busy,连接恢复 select 监听
|
||||
```
|
||||
|
||||
保证:
|
||||
- **同连接串行**:busy 标记使回调完成前该连接暂停 select 监听,同一连接的消息不会并发执行(恢复粒度 ≤100ms,短连接 RPC 无感)
|
||||
- **发送原子性**:`reply()`/`broadcast()` 经每连接 `Lock` 串行化,多 worker 向同一 socket 写入不会帧交错
|
||||
- **故障隔离**:回调抛非连接异常时打印 traceback 并移除该连接,服务整体存活(同步模式下异常会使 `start()` 崩溃,两模式语义不同)
|
||||
- **fd 安全**:worker 线程不直接 close socket,连接移除统一由主循环执行,避免与 accept/select 产生 fd 复用竞态
|
||||
|
||||
#### 方法
|
||||
|
||||
@@ -530,11 +577,11 @@ func pymsgCall(payload []byte) ([]byte, error) {
|
||||
| 场景 | 安全性 | 说明 |
|
||||
|------|--------|------|
|
||||
| `MsgServer.stop()` 跨线程调用 | ✅ 安全 | 设置原子标志并关闭监听 socket |
|
||||
| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
|
||||
| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
|
||||
| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 经每连接发送锁串行化,worker 线程内同样安全 |
|
||||
| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 发送经每连接锁;但会遍历客户端列表,仅限回调线程,勿从无关第三方线程并发调用 |
|
||||
| `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` |
|
||||
| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 调用方需自行串行化 |
|
||||
| `MsgServer` 并发调用 broadcast/reply | ⚠️ 不支持 | 仅在 `on_message` 回调内(同一线程)安全 |
|
||||
| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 底层函数无锁,调用方需自行串行化(MsgServer 内部已用每连接锁保护) |
|
||||
| 同一连接消息并发执行回调 | ✅ 不会 | busy 标记暂停 select 监听,保证同连接串行 |
|
||||
|
||||
---
|
||||
|
||||
@@ -702,7 +749,9 @@ python test_runner.py
|
||||
|
||||
| 版本 | 日期 | 主要变更 |
|
||||
|------|------|---------|
|
||||
| 0.1.0 | 当前 | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
|
||||
| 0.1.0 | — | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
|
||||
| 0.2.0 | 2026-07-30 | `MsgServer` 新增 `workers` 参数:回调可提交线程池并发执行;同连接串行(busy 标记)、每连接发送锁防帧交错、回调异常故障隔离;`tests/test_threadpool.py` 单元测试 |
|
||||
| 0.3.0 | 2026-07-30 | 新增 `rpc.py` JSON RPC 约定层:`RpcClient`(_route 注入 + 编解码 + success 检查 + `RpcError`)、`RpcServer`(路由分发 + 响应包装 + `json_default`/`on_error` 钩子);db/smtp/captcha 服务端与 dnfs/log_client 客户端完成迁移;`tests/test_rpc.py` 单元测试 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# pymsg 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `pymsg/__init__.py`、`pymsg/rpc.py`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 问题严重等级
|
||||
|
||||
| 等级 | 说明 | 响应时间 |
|
||||
|------|------|----------|
|
||||
| P0 🔴 | 可导致服务崩溃、DoS 或远程接管 | 立即修复 |
|
||||
| P1 🟠 | 可导致资源耗尽、信息泄露 | 72小时内 |
|
||||
| P2 🟡 | 健壮性/配置问题 | 一周内 |
|
||||
| P3 🟢 | 优化建议 | 版本迭代 |
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 3 | 1 | 2 |
|
||||
| P1 | 5 | 0 | 5 |
|
||||
| P2 | 4 | 0 | 4 |
|
||||
| P3 | 4 | 0 | 4 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: recv 超时可被绕过(Slowloris 变种 DoS)
|
||||
- **位置**: `__init__.py:102-153` (`recv` / `_recv_exact`)
|
||||
- **问题**: `sock.settimeout()` 设置的是**单次系统调用**超时,不是整个消息接收总超时。攻击者可每次在快超时时发送 1 字节:
|
||||
- 4 字节长度头最长可占用 `4 * recv_timeout = 40秒`
|
||||
- 载荷部分按字节发送可使连接存活任意长时间
|
||||
- **影响**: 少量攻击者即可耗尽连接/线程资源,造成所有依赖 pymsg 的服务拒绝服务
|
||||
- **修复建议**: 使用 deadline 模式:
|
||||
```python
|
||||
deadline = time.monotonic() + self._recv_timeout
|
||||
while got < n:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError("Message receive timeout")
|
||||
sock.settimeout(remaining)
|
||||
chunk = sock.recv_into(view[got:], n - got)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-2: UnicodeDecodeError 可导致同步模式服务崩溃
|
||||
- **位置**: `__init__.py:111`、`__init__.py:434-448`
|
||||
- **问题**: `payload.decode("utf-8")` 抛出的 `UnicodeDecodeError`(`ValueError` 子类)不在 `_handle_client` 的异常捕获列表 `(ConnectionError, ConnectionResetError, OSError, TimeoutError)` 中。**同步模式(workers=0,默认配置)下收到无效 UTF-8 字节会直接导致整个服务进程崩溃!**
|
||||
- **影响范围**: db/confs/smtp/captcha 等所有默认配置的 pymsg 服务
|
||||
- **修复建议**: 在异常捕获中添加 `UnicodeDecodeError` 和 `json.JSONDecodeError`:
|
||||
```python
|
||||
except (ConnectionError, ConnectionResetError, OSError, TimeoutError,
|
||||
UnicodeDecodeError, json.JSONDecodeError):
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### P0-3: 无认证/鉴权机制(已部分缓解:绑定 127.0.0.1)
|
||||
- **位置**: `rpc.py:80-171` 整个 RpcServer
|
||||
- **问题**: RPC 服务完全没有身份验证,任何能连接到端口的客户端都能调用所有路由
|
||||
- **当前缓解**: 所有服务均绑定 127.0.0.1,依赖网络层隔离
|
||||
- **修复建议**:
|
||||
1. 添加基于预共享 token 的认证(如 `_auth` 字段)
|
||||
2. 支持路由级权限控制
|
||||
3. 文档明确警告不要暴露到公网
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | 无连接数限制,连接耗尽攻击 | `__init__.py:334` `listen(10)` 不限制已建立连接数 |
|
||||
| P1-2 | 线程池无队列限制,慢 handler 耗尽内存 | `__init__.py:337-340` ThreadPoolExecutor 默认无界队列 |
|
||||
| P1-3 | 异常信息直接返回客户端,泄露内部细节 | `rpc.py:144,162` 直接将 `str(e)` 返回调用方 |
|
||||
| P1-4 | 无 TLS/加密传输,内网流量可被窃听 | 全模块明文 TCP |
|
||||
| P1-5 | JSON 解析无深度限制,深度嵌套导致栈溢出/CPU 100% | `recv()` 后 `json.loads()` 无限制 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | length=0 空消息未特殊处理,可用于心跳攻击 |
|
||||
| P2-2 | broadcast() 无频率/权限控制,可被用于放大攻击 |
|
||||
| P2-3 | select 异常处理过于宽泛,EINTR 临时错误直接退出循环 |
|
||||
| P2-4 | MsgClient 短连接模式导致 TIME_WAIT 堆积 |
|
||||
|
||||
---
|
||||
|
||||
## 🟢 P3 — 低危/建议
|
||||
|
||||
| # | 建议 |
|
||||
|---|------|
|
||||
| P3-1 | 安全事件(超大消息、超时、JSON解析失败)无日志 |
|
||||
| P3-2 | `name` 参数未过滤控制字符,可导致日志注入 |
|
||||
| P3-3 | listen backlog=10 在高并发下偏小 |
|
||||
| P3-4 | 无优雅关闭(直接 close() 可能发送 RST) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ 64 MiB 消息大小上限(`_MAX_MSG_SIZE`),防止 OOM
|
||||
2. ✅ send/recv 双端都检查消息大小
|
||||
3. ✅ recv_timeout 防 Slowloris(基础实现,需修复 P0-1)
|
||||
4. ✅ 线程池模式下同一连接串行处理,避免帧交错
|
||||
5. ✅ 使用 `sendmsg` 散射-聚集 I/O 减少内存拷贝
|
||||
6. ✅ 使用 `recv_into` 预分配 buffer
|
||||
7. ✅ 线程安全的发送锁
|
||||
8. ✅ 回调异常隔离(线程池模式)
|
||||
9. ✅ 单元测试覆盖消息大小边界
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 修复 P0-1: recv 总超时(deadline 模式)
|
||||
- [ ] 修复 P0-2: 捕获 UnicodeDecodeError/JSONDecodeError
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] 添加最大连接数限制
|
||||
- [ ] 线程池改用有界队列
|
||||
- [ ] 异常信息脱敏
|
||||
- [ ] JSON 解析深度限制
|
||||
|
||||
### 中期
|
||||
- [ ] 统一认证机制
|
||||
- [ ] TLS 支持选项
|
||||
- [ ] 安全事件日志
|
||||
+168
-32
@@ -21,11 +21,18 @@ import contextlib
|
||||
import select
|
||||
import socket as _socket
|
||||
import struct
|
||||
import threading
|
||||
import traceback
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
__all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"]
|
||||
|
||||
_HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int
|
||||
|
||||
# 消息大小安全上限(64 MiB),与 C++ 端 Msg.hpp 保持一致。
|
||||
# 防止恶意客户端发送超大长度头导致 OOM。
|
||||
_MAX_MSG_SIZE = 64 * 1024 * 1024
|
||||
|
||||
|
||||
class TimeoutError(Exception):
|
||||
"""Raised when a send or recv operation exceeds its timeout."""
|
||||
@@ -59,10 +66,14 @@ def send(
|
||||
timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
|
||||
"""
|
||||
payload = data.encode("utf-8")
|
||||
if len(payload) > _MAX_MSG_SIZE:
|
||||
raise ValueError(
|
||||
f"payload too large: {len(payload)} bytes (max {_MAX_MSG_SIZE})"
|
||||
)
|
||||
header = _HEADER.pack(len(payload))
|
||||
with _timeout(sock, timeout):
|
||||
try:
|
||||
sock.sendall(header + payload)
|
||||
_send_all(sock, header, payload)
|
||||
except _socket.timeout:
|
||||
raise TimeoutError(
|
||||
f"send timed out after {timeout}s"
|
||||
@@ -92,6 +103,10 @@ def recv(
|
||||
try:
|
||||
header = _recv_exact(sock, _HEADER.size)
|
||||
(length,) = _HEADER.unpack(header)
|
||||
if length > _MAX_MSG_SIZE:
|
||||
raise ConnectionError(
|
||||
f"message too large: {length} bytes (max {_MAX_MSG_SIZE})"
|
||||
)
|
||||
payload = _recv_exact(sock, length)
|
||||
return payload.decode("utf-8")
|
||||
except _socket.timeout:
|
||||
@@ -100,17 +115,42 @@ def recv(
|
||||
) from None
|
||||
|
||||
|
||||
def _recv_exact(sock: "_socket.socket", n: int) -> bytes:
|
||||
"""Read exactly n bytes from sock, raising on premature close."""
|
||||
buf = bytearray()
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError(
|
||||
f"connection closed: expected {n} bytes, got {len(buf)}"
|
||||
def _send_all(sock: "_socket.socket", header: bytes, payload: bytes) -> None:
|
||||
"""通过 sendmsg 散射-聚集 I/O 发送 header + payload,避免拼接拷贝。
|
||||
|
||||
内核直接从两块独立内存读取数据,无需在用户态拼接 header + payload。
|
||||
处理 partial send:sendmsg 可能只发出部分数据,需循环补发。
|
||||
"""
|
||||
header_len = len(header)
|
||||
total = header_len + len(payload)
|
||||
sent = sock.sendmsg([memoryview(header), memoryview(payload)])
|
||||
while sent < total:
|
||||
if sent < header_len:
|
||||
sent += sock.sendmsg(
|
||||
[memoryview(header)[sent:], memoryview(payload)]
|
||||
)
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
else:
|
||||
offset = sent - header_len
|
||||
sent += sock.sendmsg([memoryview(payload)[offset:]])
|
||||
|
||||
|
||||
def _recv_exact(sock: "_socket.socket", n: int) -> bytearray:
|
||||
"""Read exactly n bytes from sock, raising on premature close.
|
||||
|
||||
使用 recv_into 直接写入预分配 buffer,避免 bytearray 逐步增长和
|
||||
最终 bytes() 转换的额外拷贝。
|
||||
"""
|
||||
buf = bytearray(n)
|
||||
view = memoryview(buf)
|
||||
got = 0
|
||||
while got < n:
|
||||
chunk = sock.recv_into(view[got:], n - got)
|
||||
if chunk == 0:
|
||||
raise ConnectionError(
|
||||
f"connection closed: expected {n} bytes, got {got}"
|
||||
)
|
||||
got += chunk
|
||||
return buf
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -246,14 +286,32 @@ class MsgServer:
|
||||
server.start(on_msg) # 阻塞当前线程
|
||||
|
||||
# 在另一线程中调用 server.stop() 可优雅退出
|
||||
|
||||
并发模式::
|
||||
|
||||
server = MsgServer("0.0.0.0", 8003, workers=4)
|
||||
|
||||
``workers > 0`` 时回调提交到线程池并发执行(I/O 密集回调可获得真实
|
||||
并行度)。同一连接上的消息仍严格串行处理;``reply()`` / ``broadcast()``
|
||||
经每连接发送锁串行化,不会发生帧交错。
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int) -> None:
|
||||
_RECV_TIMEOUT = 10.0 # 服务端接收单条消息的超时(秒),防止 Slowloris 式半开连接卡死主线程
|
||||
|
||||
def __init__(self, addr: str, port: int, *, workers: int = 0,
|
||||
recv_timeout: float = _RECV_TIMEOUT) -> None:
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._workers = workers
|
||||
self._recv_timeout = recv_timeout
|
||||
self._running = False
|
||||
self._server_sock: "_socket.socket | None" = None
|
||||
self._clients: "dict" = {} # socket -> (ip, port)
|
||||
self._send_locks: "dict" = {} # socket -> Lock(防并发发送帧交错)
|
||||
self._pool: "ThreadPoolExecutor | None" = None
|
||||
self._lock = threading.Lock() # 保护 _busy / _failed
|
||||
self._busy: "set" = set() # 回调执行中的 socket(暂停 select 监听)
|
||||
self._failed: "set" = set() # 回调失败的 socket(主循环统一移除)
|
||||
|
||||
# ---- 启动(阻塞)----------------------------------------------------
|
||||
def start(self, on_message: "callable") -> None:
|
||||
@@ -264,6 +322,10 @@ class MsgServer:
|
||||
- sock: 发送消息的客户端套接字
|
||||
- addr: (ip, port) 元组
|
||||
- text: 解码后的字符串消息
|
||||
|
||||
``workers > 0`` 时回调提交到线程池并发执行;同一连接的消息保持串行
|
||||
(回调完成前该连接暂停 select 监听,恢复粒度为一个 select 周期,
|
||||
最坏约 100ms——对短连接 RPC 模式无影响)。
|
||||
"""
|
||||
self._running = True
|
||||
servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
||||
@@ -271,26 +333,38 @@ class MsgServer:
|
||||
servsock.bind((self.addr, self.port))
|
||||
servsock.listen(10)
|
||||
self._server_sock = servsock
|
||||
if self._workers > 0:
|
||||
self._pool = ThreadPoolExecutor(
|
||||
max_workers=self._workers,
|
||||
thread_name_prefix=f"pymsg-{self.port}",
|
||||
)
|
||||
|
||||
socketlist = [servsock]
|
||||
while self._running:
|
||||
try:
|
||||
readable, _, exception = select.select(
|
||||
socketlist, [], socketlist, 0.1
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
break
|
||||
try:
|
||||
while self._running:
|
||||
self._reap_failed(socketlist)
|
||||
with self._lock:
|
||||
watched = [s for s in socketlist if s not in self._busy]
|
||||
try:
|
||||
readable, _, exception = select.select(
|
||||
watched, [], watched, 0.1
|
||||
)
|
||||
except (ValueError, OSError):
|
||||
break
|
||||
|
||||
for s in exception:
|
||||
self._remove_client(s, socketlist)
|
||||
for s in exception:
|
||||
self._remove_client(s, socketlist)
|
||||
|
||||
for s in readable:
|
||||
if s is servsock:
|
||||
self._accept_client(servsock, socketlist)
|
||||
else:
|
||||
self._handle_client(s, socketlist, on_message)
|
||||
|
||||
self._shutdown(servsock, socketlist)
|
||||
for s in readable:
|
||||
if s is servsock:
|
||||
self._accept_client(servsock, socketlist)
|
||||
else:
|
||||
self._handle_client(s, socketlist, on_message)
|
||||
finally:
|
||||
if self._pool is not None:
|
||||
self._pool.shutdown(wait=True)
|
||||
self._pool = None
|
||||
self._shutdown(servsock, socketlist)
|
||||
|
||||
# ---- 广播 -----------------------------------------------------------
|
||||
def broadcast(
|
||||
@@ -307,11 +381,12 @@ class MsgServer:
|
||||
if client is except_sock:
|
||||
continue
|
||||
try:
|
||||
send(client, msg)
|
||||
self._locked_send(client, msg)
|
||||
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||
disconnected.append(client)
|
||||
for client in disconnected:
|
||||
self._clients.pop(client, None)
|
||||
self._send_locks.pop(client, None)
|
||||
try:
|
||||
client.close()
|
||||
except OSError:
|
||||
@@ -322,8 +397,18 @@ class MsgServer:
|
||||
"""向指定客户端套接字发送一条应答消息(pymsg 协议)。
|
||||
|
||||
用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。
|
||||
经每连接发送锁串行化,``workers > 0`` 时可在回调线程安全调用。
|
||||
"""
|
||||
send(sock, msg)
|
||||
self._locked_send(sock, msg)
|
||||
|
||||
def _locked_send(self, sock: "_socket.socket", msg: str) -> None:
|
||||
"""带每连接锁的发送:防止多线程向同一 socket 并发写入导致帧交错。"""
|
||||
lock = self._send_locks.get(sock)
|
||||
if lock is None:
|
||||
send(sock, msg)
|
||||
return
|
||||
with lock:
|
||||
send(sock, msg)
|
||||
|
||||
# ---- 停止 -----------------------------------------------------------
|
||||
def stop(self) -> None:
|
||||
@@ -342,18 +427,62 @@ class MsgServer:
|
||||
if client_sock.fileno() != -1:
|
||||
socketlist.append(client_sock)
|
||||
self._clients[client_sock] = client_addr
|
||||
self._send_locks[client_sock] = threading.Lock()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _handle_client(self, s, socketlist, on_message):
|
||||
try:
|
||||
text = recv(s)
|
||||
on_message(s, self._clients.get(s, ("unknown", 0)), text)
|
||||
text = recv(s, timeout=self._recv_timeout)
|
||||
except (ConnectionError, ConnectionResetError, OSError, TimeoutError):
|
||||
# TimeoutError: 客户端在超时时间内未发送完整消息(Slowloris 防护),关闭连接
|
||||
self._remove_client(s, socketlist)
|
||||
return
|
||||
addr = self._clients.get(s, ("unknown", 0))
|
||||
if self._pool is None:
|
||||
# 同步模式(默认):回调在主循环线程内执行,行为与旧版一致
|
||||
try:
|
||||
on_message(s, addr, text)
|
||||
except (ConnectionError, ConnectionResetError, OSError):
|
||||
self._remove_client(s, socketlist)
|
||||
return
|
||||
# 线程池模式:先标记 busy(暂停 select 监听该连接,保证同连接串行),
|
||||
# 再把回调提交到线程池执行
|
||||
with self._lock:
|
||||
self._busy.add(s)
|
||||
self._pool.submit(self._run_task, s, addr, text, on_message)
|
||||
|
||||
def _run_task(self, s, addr, text, on_message):
|
||||
"""工作线程:执行用户回调。
|
||||
|
||||
出错时只打标记(``_failed``),实际的连接关闭与移除由主循环统一
|
||||
执行,避免 worker 线程与 accept/select 并发操作 fd。
|
||||
"""
|
||||
try:
|
||||
on_message(s, addr, text)
|
||||
except (ConnectionError, ConnectionResetError, OSError):
|
||||
with self._lock:
|
||||
self._failed.add(s)
|
||||
except Exception:
|
||||
# 同步模式下此类异常会直接使 start() 崩溃;线程池模式无法传播,
|
||||
# 打印 traceback 并移除该连接,保证服务整体可用
|
||||
traceback.print_exc()
|
||||
with self._lock:
|
||||
self._failed.add(s)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy.discard(s)
|
||||
|
||||
def _reap_failed(self, socketlist):
|
||||
"""主循环每轮统一移除回调失败的连接。"""
|
||||
with self._lock:
|
||||
failed, self._failed = self._failed, set()
|
||||
for s in failed:
|
||||
self._remove_client(s, socketlist)
|
||||
|
||||
def _remove_client(self, s, socketlist):
|
||||
self._clients.pop(s, None)
|
||||
self._send_locks.pop(s, None)
|
||||
if s in socketlist:
|
||||
socketlist.remove(s)
|
||||
try:
|
||||
@@ -368,3 +497,10 @@ class MsgServer:
|
||||
except OSError:
|
||||
pass
|
||||
self._clients.clear()
|
||||
self._send_locks.clear()
|
||||
|
||||
|
||||
# ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)──
|
||||
from .rpc import RpcClient, RpcError, RpcServer # noqa: E402
|
||||
|
||||
__all__ += ["RpcClient", "RpcServer", "RpcError"]
|
||||
@@ -0,0 +1,171 @@
|
||||
"""pymsg.rpc — pymsg 线协议之上的 JSON RPC 约定层。
|
||||
|
||||
约定(与 C++/Go 客户端字节级兼容)::
|
||||
|
||||
请求: {"_route": "<路由名>", ...业务字段}
|
||||
响应: {"success": true, "data": ...}
|
||||
{"success": false, "error": "..."}
|
||||
|
||||
把各服务(db / smtp / captcha)重复的 ``_dispatch`` / ``_reply`` 服务端
|
||||
分发逻辑,以及各调用方(dnfs / log_client)重复的 ``_route`` 注入 +
|
||||
JSON 编解码客户端逻辑,收敛为 ``RpcServer`` / ``RpcClient`` 两个类。
|
||||
|
||||
异常分层::
|
||||
|
||||
RpcError — 远端返回的业务错误(success=false)或非法响应
|
||||
pymsg.TimeoutError — 网络层超时(来自 MsgClient)
|
||||
ConnectionRefusedError — 目标不可达(来自 MsgClient)
|
||||
ConnectionError — 连接中断(来自 MsgClient)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from . import MsgClient, MsgServer
|
||||
|
||||
__all__ = ["RpcClient", "RpcServer", "RpcError"]
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
"""远端返回 ``success=false`` 的业务错误,或响应不符合 RPC 约定。"""
|
||||
|
||||
|
||||
class RpcClient:
|
||||
"""JSON RPC 客户端(短连接,每次调用独立 TCP 连接)。
|
||||
|
||||
Usage::
|
||||
|
||||
rpc = RpcClient("127.0.0.1", 8005, "dnfs")
|
||||
data = rpc.call("execute", {"action": "select", "table": "Users"})
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int, name: str = "rpc",
|
||||
timeout: float = 10.0) -> None:
|
||||
self._client = MsgClient(addr, port, name)
|
||||
self._timeout = timeout
|
||||
|
||||
def call(self, route: str, payload: "dict | None" = None, *,
|
||||
timeout: "float | None" = None):
|
||||
"""调用指定路由,返回响应中的 ``data`` 字段。
|
||||
|
||||
Args:
|
||||
route: 路由名(注入为 ``_route`` 字段)。
|
||||
payload: 业务字段 dict(``_route`` 键会被覆盖)。
|
||||
timeout: 本次调用的超时秒数,缺省用构造时的值。
|
||||
|
||||
Returns:
|
||||
服务端返回的 ``data``(可能为 None)。
|
||||
|
||||
Raises:
|
||||
RpcError: 服务端返回 success=false,或响应非法。
|
||||
pymsg.TimeoutError: 等待响应超时。
|
||||
ConnectionRefusedError: 目标服务不可达。
|
||||
"""
|
||||
req = dict(payload or {})
|
||||
req["_route"] = route
|
||||
resp = self._client.call(
|
||||
json.dumps(req, ensure_ascii=False),
|
||||
timeout=self._timeout if timeout is None else timeout,
|
||||
)
|
||||
try:
|
||||
obj = json.loads(resp)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RpcError(f"响应不是合法 JSON: {e}") from None
|
||||
if not isinstance(obj, dict) or "success" not in obj:
|
||||
raise RpcError(f"非法 RPC 响应: {resp[:200]!r}")
|
||||
if not obj["success"]:
|
||||
raise RpcError(obj.get("error", "未知错误"))
|
||||
return obj.get("data")
|
||||
|
||||
|
||||
class RpcServer:
|
||||
"""JSON RPC 服务端:基于 MsgServer,自带路由分发与响应包装。
|
||||
|
||||
handler 签名为 ``handler(req: dict) -> data``:
|
||||
|
||||
- 返回值放入 ``{"success": true, "data": ...}`` 回复;
|
||||
- 抛出的异常自动包装为 ``{"success": false, "error": str(e)}``,
|
||||
服务不会因此崩溃。
|
||||
|
||||
Usage::
|
||||
|
||||
rpc = RpcServer("127.0.0.1", 8005, workers=4)
|
||||
|
||||
@rpc.route("execute")
|
||||
def _execute(req):
|
||||
return {"rows": []}
|
||||
|
||||
rpc.start() # 阻塞当前线程
|
||||
"""
|
||||
|
||||
def __init__(self, addr: str, port: int, *, workers: int = 0,
|
||||
json_default: "callable | None" = None,
|
||||
on_error: "callable | None" = None) -> None:
|
||||
"""
|
||||
Args:
|
||||
addr: 监听地址。
|
||||
port: 监听端口。
|
||||
workers: 回调并发线程数(透传 MsgServer,0=同步串行)。
|
||||
json_default: 响应 JSON 序列化的兜底函数(如日期转 ISO),
|
||||
透传 ``json.dumps(default=...)``。
|
||||
on_error: 可选钩子 ``on_error(route, exc)``,handler 抛异常
|
||||
时调用(用于记日志);None 表示静默。
|
||||
"""
|
||||
self._server = MsgServer(addr, port, workers=workers)
|
||||
self._routes: "dict" = {}
|
||||
self._json_default = json_default
|
||||
self._on_error = on_error
|
||||
|
||||
# ── 路由注册 -------------------------------------------------------
|
||||
def route(self, name: str):
|
||||
"""装饰器:注册路由处理函数。"""
|
||||
def deco(fn):
|
||||
self._routes[name] = fn
|
||||
return fn
|
||||
return deco
|
||||
|
||||
def add_route(self, name: str, fn: "callable") -> None:
|
||||
"""直接注册路由处理函数(等价于 route 装饰器)。"""
|
||||
self._routes[name] = fn
|
||||
|
||||
# ── 启动 / 停止 ----------------------------------------------------
|
||||
def start(self) -> None:
|
||||
"""启动服务(阻塞当前线程)。"""
|
||||
self._server.start(self._dispatch)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 分发与响应包装 --------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
if self._on_error is not None:
|
||||
try:
|
||||
self._on_error(route, e)
|
||||
except Exception:
|
||||
pass
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(
|
||||
resp, ensure_ascii=False, default=self._json_default))
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pymsg"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
description = "Length-prefixed variable-length socket message framing"
|
||||
requires-python = ">=3.9"
|
||||
license = "MIT"
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""消息大小上限(_MAX_MSG_SIZE)单元测试。
|
||||
|
||||
验证 recv() 拒绝超大长度头、send() 拒绝超大载荷,
|
||||
防止恶意客户端通过 4 字节 header 声明超大长度导致 OOM。
|
||||
|
||||
cd pymsg
|
||||
python -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
import pymsg
|
||||
from pymsg import MsgServer, recv, send, _MAX_MSG_SIZE
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class _ServerFixture:
|
||||
"""后台线程运行 MsgServer。"""
|
||||
|
||||
def __init__(self, handler, workers=0):
|
||||
self.port = _free_port()
|
||||
self.server = MsgServer("127.0.0.1", self.port, workers=workers)
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.start,
|
||||
args=(lambda sock, addr, text: handler(self.server, sock, addr, text),),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError("server did not start in time")
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.stop()
|
||||
self.thread.join(timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
class TestMaxMsgSize(unittest.TestCase):
|
||||
|
||||
def test_recv_rejects_oversized_header(self):
|
||||
"""服务端 recv() 收到超大长度头时应断开连接,不分配内存。"""
|
||||
with _ServerFixture(lambda srv, sock, addr, text: srv.reply(sock, "ok")) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
# 发送合法 header 但声明长度为 _MAX_MSG_SIZE + 1
|
||||
s.sendall(struct.pack("!I", _MAX_MSG_SIZE + 1))
|
||||
# 服务端应断开连接(ConnectionError -> _remove_client)
|
||||
# 客户端读到 EOF
|
||||
data = s.recv(1024)
|
||||
self.assertEqual(data, b"")
|
||||
|
||||
def test_recv_rejects_max_uint32(self):
|
||||
"""声明 0xFFFFFFFF(4 GiB)也应被拒绝。"""
|
||||
with _ServerFixture(lambda srv, sock, addr, text: srv.reply(sock, "ok")) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
s.sendall(struct.pack("!I", 0xFFFFFFFF))
|
||||
data = s.recv(1024)
|
||||
self.assertEqual(data, b"")
|
||||
|
||||
def test_server_survives_oversized_message(self):
|
||||
"""拒绝超大消息后服务仍然存活,后续正常请求不受影响。"""
|
||||
from pymsg import MsgClient
|
||||
|
||||
def handler(server, sock, addr, text):
|
||||
server.reply(sock, f"ok:{text}")
|
||||
|
||||
with _ServerFixture(handler, workers=2) as fx:
|
||||
# 发送恶意超大 header
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
s.sendall(struct.pack("!I", _MAX_MSG_SIZE + 1))
|
||||
s.recv(1024) # 等待服务端断开
|
||||
|
||||
# 等待服务端清理连接
|
||||
time.sleep(0.2)
|
||||
|
||||
# 后续正常 RPC 仍然工作
|
||||
resp = MsgClient("127.0.0.1", fx.port, "t").call("ping", timeout=5)
|
||||
self.assertEqual(resp, "ok:ping")
|
||||
|
||||
def test_send_rejects_oversized_payload(self):
|
||||
"""send() 对超大载荷应直接抛 ValueError,不触碰网络。"""
|
||||
# 用 mock 将上限临时降到 100 字节,避免分配 64 MB 内存
|
||||
with unittest.mock.patch.object(pymsg, "_MAX_MSG_SIZE", 100):
|
||||
with _ServerFixture(lambda srv, sock, addr, text: None) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
oversized = "x" * 101
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
send(s, oversized)
|
||||
self.assertIn("too large", str(cm.exception))
|
||||
|
||||
def test_normal_messages_unaffected(self):
|
||||
"""合法大小的消息不受影响。"""
|
||||
from pymsg import MsgClient
|
||||
|
||||
def handler(server, sock, addr, text):
|
||||
server.reply(sock, f"echo:{text}")
|
||||
|
||||
with _ServerFixture(handler) as fx:
|
||||
# 1 MB 消息(远小于 64 MiB 上限)
|
||||
big = "A" * (1024 * 1024)
|
||||
resp = MsgClient("127.0.0.1", fx.port, "t").call(big, timeout=10)
|
||||
self.assertEqual(resp, f"echo:{big}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""RpcClient / RpcServer(JSON RPC 约定层)单元测试。
|
||||
|
||||
纯标准库,直接运行:
|
||||
|
||||
cd pymsg
|
||||
python -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from pymsg import RpcClient, RpcError, RpcServer, send
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class _RpcFixture:
|
||||
"""后台线程运行 RpcServer;routes: {name: fn}。"""
|
||||
|
||||
def __init__(self, routes, workers=0, json_default=None):
|
||||
self.port = _free_port()
|
||||
self.server = RpcServer("127.0.0.1", self.port, workers=workers,
|
||||
json_default=json_default)
|
||||
for name, fn in routes.items():
|
||||
self.server.add_route(name, fn)
|
||||
self.thread = threading.Thread(target=self.server.start, daemon=True)
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError("server did not start in time")
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.stop()
|
||||
self.thread.join(timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
class TestRpc(unittest.TestCase):
|
||||
|
||||
def test_call_returns_data(self):
|
||||
with _RpcFixture({"echo": lambda req: {"got": req["msg"]}}) as fx:
|
||||
rpc = RpcClient("127.0.0.1", fx.port, "t")
|
||||
self.assertEqual(rpc.call("echo", {"msg": "hi"}), {"got": "hi"})
|
||||
|
||||
def test_route_field_is_injected(self):
|
||||
seen = {}
|
||||
|
||||
def handler(req):
|
||||
seen.update(req)
|
||||
return None
|
||||
|
||||
with _RpcFixture({"r": handler}) as fx:
|
||||
RpcClient("127.0.0.1", fx.port).call("r", {"a": 1})
|
||||
# _route 已被服务端 pop,handler 只看到业务字段
|
||||
self.assertEqual(seen, {"a": 1})
|
||||
|
||||
def test_business_error_raises_rpcerror(self):
|
||||
def handler(req):
|
||||
raise ValueError("bad input")
|
||||
|
||||
with _RpcFixture({"x": handler}) as fx:
|
||||
with self.assertRaises(RpcError) as cm:
|
||||
RpcClient("127.0.0.1", fx.port).call("x")
|
||||
self.assertIn("bad input", str(cm.exception))
|
||||
|
||||
def test_unknown_route_raises_rpcerror(self):
|
||||
with _RpcFixture({}) as fx:
|
||||
with self.assertRaises(RpcError) as cm:
|
||||
RpcClient("127.0.0.1", fx.port).call("nope")
|
||||
self.assertIn("未知路由", str(cm.exception))
|
||||
|
||||
def test_malformed_json_gets_error_response(self):
|
||||
"""服务端收到非法 JSON:返回 success=false 且不崩溃,后续请求正常。"""
|
||||
with _RpcFixture({"ok": lambda req: "fine"}) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
send(s, "not-json{{{")
|
||||
from pymsg import recv
|
||||
resp = json.loads(recv(s, timeout=5))
|
||||
self.assertFalse(resp["success"])
|
||||
self.assertIn("JSON 解析失败", resp["error"])
|
||||
# 服务仍然存活
|
||||
self.assertEqual(RpcClient("127.0.0.1", fx.port).call("ok"), "fine")
|
||||
|
||||
def test_data_none_when_absent(self):
|
||||
with _RpcFixture({"n": lambda req: None}) as fx:
|
||||
self.assertIsNone(RpcClient("127.0.0.1", fx.port).call("n"))
|
||||
|
||||
def test_json_default_serialization(self):
|
||||
def default(o):
|
||||
if isinstance(o, datetime):
|
||||
return o.isoformat()
|
||||
return str(o)
|
||||
|
||||
now = datetime(2026, 7, 30, 12, 0, tzinfo=timezone.utc)
|
||||
with _RpcFixture({"t": lambda req: {"ts": now}}, json_default=default) as fx:
|
||||
data = RpcClient("127.0.0.1", fx.port).call("t")
|
||||
self.assertEqual(data["ts"], now.isoformat())
|
||||
|
||||
def test_concurrent_rpc_with_workers(self):
|
||||
def handler(req):
|
||||
time.sleep(0.3)
|
||||
return req["i"] * 2
|
||||
|
||||
with _RpcFixture({"dbl": handler}, workers=4) as fx:
|
||||
results = [None] * 6
|
||||
|
||||
def w(i):
|
||||
results[i] = RpcClient("127.0.0.1", fx.port).call("dbl", {"i": i})
|
||||
|
||||
t0 = time.monotonic()
|
||||
ts = [threading.Thread(target=w, args=(i,)) for i in range(6)]
|
||||
for t in ts:
|
||||
t.start()
|
||||
for t in ts:
|
||||
t.join(10)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
self.assertEqual(results, [i * 2 for i in range(6)])
|
||||
self.assertLess(elapsed, 1.2, f"workers=4 应并发,实际 {elapsed:.2f}s")
|
||||
|
||||
def test_per_call_timeout_override(self):
|
||||
with _RpcFixture({"slow": lambda req: time.sleep(2)}) as fx:
|
||||
rpc = RpcClient("127.0.0.1", fx.port, timeout=10.0)
|
||||
from pymsg import TimeoutError
|
||||
with self.assertRaises(TimeoutError):
|
||||
rpc.call("slow", timeout=0.3)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,164 @@
|
||||
"""MsgServer 线程池模式(workers > 0)与默认同步模式的单元测试。
|
||||
|
||||
纯标准库实现,直接运行:
|
||||
|
||||
cd pymsg
|
||||
python -m unittest discover -s tests -v
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import unittest.mock
|
||||
|
||||
from pymsg import MsgClient, MsgServer, recv, send
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
class _ServerFixture:
|
||||
"""在后台线程运行 MsgServer;handler 签名 handler(server, sock, addr, text)。"""
|
||||
|
||||
def __init__(self, handler, workers=0):
|
||||
self.port = _free_port()
|
||||
self.server = MsgServer("127.0.0.1", self.port, workers=workers)
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.start,
|
||||
args=(lambda sock, addr, text: handler(self.server, sock, addr, text),),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
self.thread.start()
|
||||
deadline = time.time() + 5
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", self.port), timeout=0.2):
|
||||
return self
|
||||
except OSError:
|
||||
time.sleep(0.02)
|
||||
raise RuntimeError("server did not start in time")
|
||||
|
||||
def __exit__(self, *exc):
|
||||
self.server.stop()
|
||||
self.thread.join(timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
def _parallel_calls(port, messages, timeout=10.0):
|
||||
"""多线程并发 call,按 messages 顺序返回响应列表。"""
|
||||
results = [None] * len(messages)
|
||||
|
||||
def worker(i, msg):
|
||||
results[i] = MsgClient("127.0.0.1", port, f"t{i}").call(msg, timeout=timeout)
|
||||
|
||||
threads = [threading.Thread(target=worker, args=(i, m)) for i, m in enumerate(messages)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join(timeout=timeout + 5)
|
||||
return results
|
||||
|
||||
|
||||
class TestSyncMode(unittest.TestCase):
|
||||
"""workers=0(默认):行为与旧版一致。"""
|
||||
|
||||
def test_rpc_echo(self):
|
||||
def handler(server, sock, addr, text):
|
||||
server.reply(sock, f"echo:{text}")
|
||||
|
||||
with _ServerFixture(handler, workers=0) as fx:
|
||||
client = MsgClient("127.0.0.1", fx.port, "t")
|
||||
self.assertEqual(client.call("hello", timeout=5), "echo:hello")
|
||||
|
||||
def test_serial_baseline(self):
|
||||
"""同步模式处理慢回调是串行的:6 × 0.4s 应超过 2.0s。"""
|
||||
def handler(server, sock, addr, text):
|
||||
time.sleep(0.4)
|
||||
server.reply(sock, "ok")
|
||||
|
||||
with _ServerFixture(handler, workers=0) as fx:
|
||||
t0 = time.monotonic()
|
||||
results = _parallel_calls(fx.port, ["m"] * 6)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertEqual(results, ["ok"] * 6)
|
||||
self.assertGreater(elapsed, 2.0, f"同步模式应为串行,实际 {elapsed:.2f}s")
|
||||
|
||||
|
||||
class TestThreadPoolMode(unittest.TestCase):
|
||||
"""workers>0:回调并发执行,同一连接保持串行。"""
|
||||
|
||||
def test_concurrent_calls(self):
|
||||
"""6 个并发 call、回调各 sleep 0.4s、4 个 worker:约 2 批 ≈ 0.8s。"""
|
||||
def handler(server, sock, addr, text):
|
||||
time.sleep(0.4)
|
||||
server.reply(sock, f"ok:{text}")
|
||||
|
||||
with _ServerFixture(handler, workers=4) as fx:
|
||||
t0 = time.monotonic()
|
||||
results = _parallel_calls(fx.port, [f"m{i}" for i in range(6)])
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertEqual(results, [f"ok:m{i}" for i in range(6)])
|
||||
self.assertLess(elapsed, 1.8, f"线程池模式应并发,实际 {elapsed:.2f}s")
|
||||
|
||||
def test_same_connection_serialized(self):
|
||||
"""同一连接连发两条消息,回调执行区间不得重叠。"""
|
||||
intervals = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def handler(server, sock, addr, text):
|
||||
start = time.monotonic()
|
||||
time.sleep(0.2)
|
||||
end = time.monotonic()
|
||||
with lock:
|
||||
intervals.append((start, end))
|
||||
server.reply(sock, f"r:{text}")
|
||||
|
||||
with _ServerFixture(handler, workers=4) as fx:
|
||||
with socket.create_connection(("127.0.0.1", fx.port), timeout=5) as s:
|
||||
send(s, "first")
|
||||
send(s, "second")
|
||||
r1 = recv(s, timeout=5)
|
||||
r2 = recv(s, timeout=5)
|
||||
|
||||
self.assertEqual((r1, r2), ("r:first", "r:second"))
|
||||
self.assertEqual(len(intervals), 2)
|
||||
(s1, e1), (s2, e2) = sorted(intervals)
|
||||
self.assertLessEqual(e1, s2, "同一连接的两次回调发生了并发执行")
|
||||
|
||||
def test_large_frames_not_interleaved(self):
|
||||
"""并发回复大载荷(100KB)时帧不得交错损坏。"""
|
||||
payload = "x" * 100_000
|
||||
|
||||
def handler(server, sock, addr, text):
|
||||
time.sleep(0.01) # 放大交错窗口
|
||||
server.reply(sock, f"{text}|{payload}")
|
||||
|
||||
with _ServerFixture(handler, workers=4) as fx:
|
||||
messages = [f"client{i}" for i in range(8)]
|
||||
results = _parallel_calls(fx.port, messages)
|
||||
self.assertEqual(results, [f"client{i}|{payload}" for i in range(8)])
|
||||
|
||||
def test_callback_exception_keeps_server_alive(self):
|
||||
"""回调抛非连接异常:打印 traceback、移除该连接,服务整体存活。"""
|
||||
def handler(server, sock, addr, text):
|
||||
if text == "boom":
|
||||
raise ValueError("bad message")
|
||||
server.reply(sock, "alive")
|
||||
|
||||
with _ServerFixture(handler, workers=2) as fx:
|
||||
with unittest.mock.patch("traceback.print_exc"):
|
||||
with self.assertRaises(ConnectionError):
|
||||
MsgClient("127.0.0.1", fx.port, "t").call("boom", timeout=5)
|
||||
# 后续请求不受影响
|
||||
resp = MsgClient("127.0.0.1", fx.port, "t").call("ping", timeout=5)
|
||||
self.assertEqual(resp, "alive")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,67 @@
|
||||
# smtp 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `smtp.py`(旧版)、`smtp_v2.py`(新版)
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 2 | 1(v2已修复) | 1(旧版需下线) |
|
||||
| P1 | 2 | 部分 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线)
|
||||
- **位置**: `smtp.py`
|
||||
- **问题**: 旧版 smtp.py 缺乏关键防护:
|
||||
1. 无发送速率限制(可被用于邮件炸弹)
|
||||
2. 无同邮箱 60 秒发送冷却
|
||||
3. 无验证码过期清理机制
|
||||
4. 无连续失败锁定机制
|
||||
- **修复建议**:
|
||||
1. **确认 `_run.sh` 使用的是 `smtp_v2.py`**
|
||||
2. 删除或重命名旧版 `smtp.py` 防止误用
|
||||
3. 在代码注释中明确标记废弃
|
||||
|
||||
### P0-2: 依赖 pymsg 无认证(缓解:绑定 127.0.0.1)
|
||||
- **说明**: 与其他 pymsg 服务相同,当前依赖网络层隔离
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | 验证码存储在进程内存,服务重启后所有未使用验证码失效 | 可被利用造成拒绝服务(频繁重启导致用户无法注册) |
|
||||
| P1-2 | 全局发送熔断配置需确认生效(`SMTP_HOURLY_LIMIT`) | smtp_v2 已有但需验证环境变量正确加载 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制(smtp_v2.py)
|
||||
|
||||
1. ✅ **同邮箱 60 秒发送冷却**: 冷却期拒绝并返回 `retry_after`
|
||||
2. ✅ **邮箱级限流**(gateway 层双窗口:3次/小时 + 10次/天)
|
||||
3. ✅ **验证码一次性消费**: 校验通过立即删除,防重放
|
||||
4. ✅ **连续失败 5 次自动作废**: 防暴力破解验证码
|
||||
5. ✅ **10 分钟有效期**: 惰性过期清理
|
||||
6. ✅ **发送失败不计入冷却**: 防止网络错误误限制
|
||||
7. ✅ **全局熔断(可选)**: `SMTP_HOURLY_LIMIT` 默认 100 封/小时
|
||||
8. ✅ **fail-closed**: SMTP 异常时验证码校验失败
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线
|
||||
- [ ] 删除或隔离 smtp.py 防止误用
|
||||
|
||||
### 短期
|
||||
- [ ] 验证全局熔断配置生效
|
||||
- [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例)
|
||||
+244
-182
@@ -1,182 +1,244 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("smtp", "验证码发送成功")
|
||||
logs.error("smtp", "验证码发送失败", extra={"errno": 2002})
|
||||
logs.push("smtp", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="smtp",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程响应(queue.get 超时最长 0.5s)
|
||||
time.sleep(0.6)
|
||||
# 排空主队列中尚未被线程取走的条目
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
# 限制最长阻塞时间,以便及时响应 flush() 信号
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="smtp")
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import os as _os
|
||||
import sys as _sys
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目
|
||||
|
||||
|
||||
def _safe_json(obj):
|
||||
"""安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。"""
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
"""生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._flush_done = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
atexit.register(self._shutdown)
|
||||
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。
|
||||
|
||||
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
|
||||
"""
|
||||
entry = {
|
||||
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略)
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
pass # 极端竞争下仍失败则静默丢弃
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
|
||||
def info(self, service, message, **kw):
|
||||
self.push(service, 'INFO', message, **kw)
|
||||
|
||||
def warning(self, service, message, **kw):
|
||||
self.push(service, 'WARNING', message, **kw)
|
||||
|
||||
def error(self, service, message, **kw):
|
||||
self.push(service, 'ERROR', message, **kw)
|
||||
|
||||
def critical(self, service, message, **kw):
|
||||
self.push(service, 'CRITICAL', message, **kw)
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_done.clear()
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
# 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
|
||||
self._flush_done.wait(timeout=self._flush_interval + 2.0)
|
||||
# 主线程排空队列中剩余条目
|
||||
self._drain_queue_to_db()
|
||||
|
||||
def _drain_queue_to_db(self):
|
||||
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
if len(batch) >= self._batch_size:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
except queue.Empty:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
|
||||
|
||||
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
|
||||
都不会死亡,避免日志队列无限增长导致 OOM。
|
||||
"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
# _send 内部已有细粒度异常处理,此处是最终安全网
|
||||
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
if self._flush_signal.is_set():
|
||||
self._flush_signal.clear()
|
||||
self._flush_done.set()
|
||||
except Exception as e:
|
||||
# 最外层安全网:任何意外异常都不应杀死日志线程
|
||||
print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
time.sleep(0.5) # 防止异常风暴导致 CPU 满载
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
|
||||
self._flush_done.set()
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
|
||||
|
||||
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
|
||||
不抛异常给调用方。
|
||||
"""
|
||||
try:
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
except Exception as e:
|
||||
print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr)
|
||||
return
|
||||
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
try:
|
||||
extra_json = _safe_json(extra_val) if extra_val is not None else None
|
||||
except Exception:
|
||||
extra_json = None
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": entry.get("ts", _now_iso()),
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": extra_json,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
self._stop.set()
|
||||
self.flush()
|
||||
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient()
|
||||
+71
-44
@@ -19,7 +19,6 @@ SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
|
||||
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
@@ -60,7 +59,7 @@ from log_client import logs
|
||||
# pymsg 导入
|
||||
import sys as _sys
|
||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgServer # noqa: E402
|
||||
from pymsg import RpcServer # noqa: E402
|
||||
|
||||
# ── 配置 ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -74,6 +73,13 @@ CODE_LEN = 6
|
||||
CODE_TTL = 600 # 验证码有效期(秒)
|
||||
SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒)
|
||||
MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码)
|
||||
|
||||
# ── 邮件轰炸防护(兜底:gateway 层已有 CAPTCHA + 邮箱级限流,此处为最后防线) ──
|
||||
# 邮箱级双窗口:与 gateway 对齐,但作为 gateway 被绕过/重启时的兜底
|
||||
EMAIL_HOURLY_LIMIT = int(os.environ.get("EMAIL_HOURLY_LIMIT", "3")) # 单邮箱 3 次/小时
|
||||
EMAIL_DAILY_LIMIT = int(os.environ.get("EMAIL_DAILY_LIMIT", "10")) # 单邮箱 10 次/天
|
||||
# 全局熔断:防止 smtp 账号被服务商封禁;正常注册流量通常 <10/小时,默认值已是 10 倍异常
|
||||
SMTP_HOURLY_LIMIT = int(os.environ.get("SMTP_HOURLY_LIMIT", "100")) # 全局 100 封/小时
|
||||
# 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER;
|
||||
# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖
|
||||
MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER
|
||||
@@ -83,6 +89,14 @@ DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60}
|
||||
|
||||
code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int}
|
||||
send_history = {} # email -> 上次成功发送的时间戳(发送冷却用)
|
||||
|
||||
# 邮件轰炸防护:邮箱级长期限流与全局熔断的发送日志。
|
||||
# send_log[email] = {'hour': [ts,...], 'day': [ts,...]} 仅记录成功发送的时间戳。
|
||||
# global_send_log = [ts,...] 全局成功发送时间戳(小时窗口)。
|
||||
# 惰性清理:每次检查时剔除已滑出窗口的时间戳,避免内存无限增长。
|
||||
send_log = {} # email -> {'hour': [ts], 'day': [ts]}
|
||||
global_send_log = [] # [ts] 全局小时窗口发送时间戳
|
||||
|
||||
STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
@@ -93,17 +107,17 @@ STORE_LOCK = threading.Lock()
|
||||
class SmtpServer:
|
||||
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
|
||||
|
||||
def __init__(self, addr="127.0.0.1", port=8008):
|
||||
def __init__(self, addr="127.0.0.1", port=8008, workers=4):
|
||||
self.addr = addr
|
||||
self.port = port
|
||||
self._server = MsgServer(addr, port)
|
||||
|
||||
# 路由表
|
||||
self._routes = {
|
||||
"send_email": self._handle_send_email,
|
||||
"verify_email": self._handle_verify_email,
|
||||
"health": self._handle_health,
|
||||
}
|
||||
self._server = RpcServer(
|
||||
addr, port, workers=workers,
|
||||
on_error=lambda route, e: logs.error(
|
||||
"smtp", f"路由 {route} 处理异常: {e}"),
|
||||
)
|
||||
self._server.add_route("send_email", self._handle_send_email)
|
||||
self._server.add_route("verify_email", self._handle_verify_email)
|
||||
self._server.add_route("health", self._handle_health)
|
||||
|
||||
# ── 启动 / 停止 ──────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
@@ -118,42 +132,12 @@ class SmtpServer:
|
||||
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
|
||||
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
|
||||
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
|
||||
self._server.start(self._dispatch)
|
||||
self._server.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""从另一线程停止服务。"""
|
||||
self._server.stop()
|
||||
|
||||
# ── 消息调度 -------------------------------------------------------
|
||||
def _dispatch(self, sock, addr, text):
|
||||
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
|
||||
try:
|
||||
req = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
self._reply(sock, False, error=f"JSON 解析失败: {e}")
|
||||
return
|
||||
|
||||
route = req.pop("_route", "")
|
||||
handler = self._routes.get(route)
|
||||
if handler is None:
|
||||
self._reply(sock, False, error=f"未知路由: {route}")
|
||||
return
|
||||
|
||||
try:
|
||||
result = handler(req)
|
||||
self._reply(sock, True, data=result)
|
||||
except Exception as e:
|
||||
logs.error("smtp", f"路由 {route} 处理异常: {e}")
|
||||
self._reply(sock, False, error=str(e))
|
||||
|
||||
def _reply(self, sock, success, data=None, error=None):
|
||||
resp = {"success": success}
|
||||
if data is not None:
|
||||
resp["data"] = data
|
||||
if error is not None:
|
||||
resp["error"] = error
|
||||
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 路由处理
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@@ -182,6 +166,37 @@ class SmtpServer:
|
||||
logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝")
|
||||
return {"sent": False, "retry_after": wait,
|
||||
"error": f"发送过于频繁,请 {wait} 秒后重试"}
|
||||
# 立即占位:防止并发请求同时通过冷却检查导致同邮箱重复发送;
|
||||
# 发送失败时在下方回滚,"失败不计入冷却"语义不变
|
||||
send_history[email] = time.time()
|
||||
|
||||
# 邮件轰炸防护:邮箱级长期限流 + 全局熔断(预占模式,与 60s 冷却一致)
|
||||
# gateway 层已有 CAPTCHA + 邮箱级限流,此处为 gateway 被绕过/重启时的兜底。
|
||||
# 预占:检查时立即记录时间戳;发送失败时回滚,"失败不计入配额"语义不变。
|
||||
now = time.time()
|
||||
with STORE_LOCK:
|
||||
log = send_log.setdefault(email, {'hour': [], 'day': []})
|
||||
# 清理已滑出窗口的时间戳(惰性清理,避免内存无限增长)
|
||||
log['hour'] = [t for t in log['hour'] if now - t < 3600]
|
||||
log['day'] = [t for t in log['day'] if now - t < 86400]
|
||||
global_send_log[:] = [t for t in global_send_log if now - t < 3600]
|
||||
# 邮箱级配额检查
|
||||
if len(log['hour']) >= EMAIL_HOURLY_LIMIT:
|
||||
return {"sent": False,
|
||||
"error": f"该邮箱每小时发送上限({EMAIL_HOURLY_LIMIT}次)已达,请稍后重试"}
|
||||
if len(log['day']) >= EMAIL_DAILY_LIMIT:
|
||||
return {"sent": False,
|
||||
"error": f"该邮箱每日发送上限({EMAIL_DAILY_LIMIT}次)已达,请明日重试"}
|
||||
# 全局熔断:防 smtp 账号被服务商封禁
|
||||
if len(global_send_log) >= SMTP_HOURLY_LIMIT:
|
||||
logs.error("smtp", f"全局熔断:近 1 小时已发送 {len(global_send_log)} 封,"
|
||||
f"达到上限 {SMTP_HOURLY_LIMIT},拒绝发送给 {email}")
|
||||
return {"sent": False,
|
||||
"error": "邮件服务发送量已达上限,请稍后重试"}
|
||||
# 预占(发送失败时在下方回滚)
|
||||
log['hour'].append(now)
|
||||
log['day'].append(now)
|
||||
global_send_log.append(now)
|
||||
|
||||
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
|
||||
subject = str(req.get("subject") or DEFAULT_SUBJECT)
|
||||
@@ -211,10 +226,20 @@ class SmtpServer:
|
||||
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
|
||||
with STORE_LOCK:
|
||||
code_store.pop(email, None)
|
||||
send_history.pop(email, None) # 回滚冷却占位,允许立即重试
|
||||
# 回滚限流预占(移除刚才追加的时间戳)
|
||||
log = send_log.get(email)
|
||||
if log:
|
||||
if log['hour'] and log['hour'][-1] == now:
|
||||
log['hour'].pop()
|
||||
if log['day'] and log['day'][-1] == now:
|
||||
log['day'].pop()
|
||||
if global_send_log and global_send_log[-1] == now:
|
||||
global_send_log.pop()
|
||||
logs.error("smtp", f"{email}: 验证码发送失败({err})")
|
||||
return {"sent": False, "error": f"验证码发送失败:{err}"}
|
||||
|
||||
# 发送成功:记录冷却起点,并惰性清理已失效的冷却记录
|
||||
# 发送成功:刷新冷却起点(检查阶段已占位),并惰性清理已失效的冷却记录
|
||||
with STORE_LOCK:
|
||||
send_history[email] = time.time()
|
||||
stale = [k for k, v in send_history.items()
|
||||
@@ -280,9 +305,11 @@ if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
|
||||
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
|
||||
parser.add_argument("--port", type=int, default=8008, help="监听端口")
|
||||
parser.add_argument("--workers", type=int, default=4,
|
||||
help="回调并发线程数(0=同步串行模式)")
|
||||
args = parser.parse_args()
|
||||
|
||||
server = SmtpServer(args.addr, args.port)
|
||||
server = SmtpServer(args.addr, args.port, workers=args.workers)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
+8
-2
@@ -21,6 +21,7 @@ static :9200 (Flask + CORS)
|
||||
├── index.html 社区首页
|
||||
├── forgot.html 忘记密码
|
||||
├── dnfs.html 树状笔记(调用 dnfs:8006)
|
||||
├── aibox.html aibox 智能体 Web 客户端(管理员专属,经 gateway WS 反向代理)
|
||||
├── __view.html 文件浏览器(系统文件,不在列表中显示)
|
||||
├── allcolor.html 调色板
|
||||
├── LICENSE.html 许可证页面
|
||||
@@ -50,6 +51,7 @@ static :9200 (Flask + CORS)
|
||||
| `POST /api/hash` | SHA256 密码哈希 |
|
||||
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
||||
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
||||
| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) |
|
||||
|
||||
### yhdet OAuth 路由
|
||||
|
||||
@@ -68,15 +70,19 @@ static :9200 (Flask + CORS)
|
||||
| `POST /add_note` | 新增笔记(代理至 dnfs:8006) |
|
||||
| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) |
|
||||
| `POST /update_note` | 更新笔记(代理至 dnfs:8006) |
|
||||
| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) |
|
||||
|
||||
以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
|
||||
以上 4 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
|
||||
|
||||
> `/whoami` 已上移至 gateway,由 gateway 直接验签 Token 返回用户身份,不再经 static 代理到 dnfs。
|
||||
|
||||
## 功能
|
||||
|
||||
- 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
|
||||
- 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动
|
||||
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
||||
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
|
||||
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制
|
||||
- aibox Web 客户端:`aibox.html` 移动端适配聊天页,经 gateway `[WsProxy]` 反向代理(如 `/ws/aibox → ws://127.0.0.1:8765`)与 aibox 智能体通信,握手由 gateway 强制 `is_admin` 验签;协议与 BotOps/client.py 兼容(chunk 流式 / reasoning 思考区 / task 任务面板 / 指数退避重连)
|
||||
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
||||
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
||||
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# static 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `fsys.py`、`security_middleware.py`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 5 | 0 | 5 |
|
||||
| P1 | 3 | 0 | 3 |
|
||||
| P2 | 2 | 0 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: 无认证密码重置接口,可接管任意账户
|
||||
- **位置**: `fsys.py:278-292`
|
||||
- **问题**:
|
||||
```python
|
||||
@app.route('/api/reset_password', methods=['POST'])
|
||||
def api_reset_password():
|
||||
data = request.get_json(force=True)
|
||||
email = data.get('email', '').strip()
|
||||
new_hash = data.get('password_hash', '').strip()
|
||||
conn = _sql.connect(GATEWAY_DB)
|
||||
conn.execute("UPDATE Users SET password_hash=? WHERE email=?", (new_hash, email))
|
||||
conn.commit()
|
||||
```
|
||||
`/api/reset_password` **无任何认证**,任何人只要知道邮箱即可修改任意用户密码(包括管理员),直接接管账户。
|
||||
- **修复建议**:
|
||||
1. **立即禁用/删除此接口**,或仅允许本机访问 + 管理员 Token
|
||||
2. 长期:密码重置应走邮件验证码流程(smtp + gateway 统一实现)
|
||||
|
||||
---
|
||||
|
||||
### P0-2: yhdet OAuth Token 为 base64(email:password),解码即得明文密码
|
||||
- **位置**: `fsys.py:398-400`
|
||||
- **问题**:
|
||||
```python
|
||||
token_data = f"{email}:{password}"
|
||||
token = base64.urlsafe_b64encode(token_data.encode()).decode()
|
||||
```
|
||||
Token 仅仅是 `email:password` 的 base64 编码,**无签名无加密**:
|
||||
1. XSS/日志泄露/网络抓包即可解码得到明文密码
|
||||
2. 密码若复用其他站点,导致更大范围账户接管
|
||||
3. `/api/yhdet_verify` 直接解码后查库,等价于明文密码传输
|
||||
- **修复建议**:
|
||||
1. 废弃此 Token 设计,改为调用 gateway 登录接口获取正规 RSA 签名 Token
|
||||
2. 现有 yhdet_* 用户强制密码重置
|
||||
|
||||
---
|
||||
|
||||
### P0-3: yhdet_callback 禁用 SSL 证书验证(中间人攻击)
|
||||
- **位置**: `fsys.py:361`
|
||||
- **问题**:
|
||||
```python
|
||||
with _urlreq.urlopen(req, timeout=10, context=__import__('ssl')._create_unverified_context()) as resp:
|
||||
```
|
||||
使用 `_create_unverified_context()` 禁用 SSL 证书验证,中间人可伪造 yhdet.top 响应,以任意用户身份登录。
|
||||
- **修复建议**: 移除 `_create_unverified_context()`,使用默认 SSL 验证。
|
||||
|
||||
---
|
||||
|
||||
### P0-4: yhdet OAuth 硬编码密钥
|
||||
- **位置**: `fsys.py:312`
|
||||
- **问题**:
|
||||
```python
|
||||
YHDET_SECRET = "yhdet_qsont_oauth_2026_secret_key"
|
||||
```
|
||||
OAuth 密钥硬编码在源码中并提交到仓库。
|
||||
- **修复建议**: 移至环境变量或 `.env` 文件,从代码中移除。
|
||||
|
||||
---
|
||||
|
||||
### P0-5: 直连 gateway SQLite 数据库文件,绕过 db 服务
|
||||
- **位置**: `fsys.py:287,349` 等多处
|
||||
- **问题**: static 直接通过 sqlite3 模块连接 gateway 的数据库文件:
|
||||
1. 多进程并发写可能导致数据库损坏
|
||||
2. 绕过 db 服务的访问控制和审计
|
||||
3. 密码重置等敏感操作无日志
|
||||
- **修复建议**: 所有数据库操作统一通过 pymsg 调 `db:8005` 服务。
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| P1-1 | `/api/check_user` 无速率限制,可枚举有效邮箱 | `fsys.py:295-306` |
|
||||
| P1-2 | 文件服务路径遍历防护需全面审计 | 文件浏览/下载接口 |
|
||||
| P1-3 | yhdet 新用户默认密码可预测:`oauth_{user_id}` | `fsys.py:348` |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 |
|
||||
|---|------|
|
||||
| P2-1 | 视频/音频 Range 请求实现需审计边界条件 |
|
||||
| P2-2 | 日志可能记录敏感信息(密码、Token) |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **security_middleware.py**: 安全响应头(CSP、X-Frame-Options、nosniff 等)
|
||||
2. ✅ **路径遍历防护**: `safe_path_join()` realpath 前缀校验
|
||||
3. ✅ **dnfs 代理**: 解决浏览器跨域问题
|
||||
4. ✅ **静态文件托管**: 独立服务,经 gateway 代理访问
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 禁用/删除无认证的 `/api/reset_password` 接口
|
||||
- [ ] 移除 `_create_unverified_context()` 启用 SSL 验证
|
||||
- [ ] 从代码中移除硬编码 YHDET_SECRET
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] yhdet Token 改用 gateway 统一 RSA 签名 Token
|
||||
- [ ] `/api/check_user` 添加速率限制
|
||||
- [ ] yhdet 新用户密码改为随机生成
|
||||
|
||||
### 中期
|
||||
- [ ] 所有数据库操作迁移到 pymsg 调 db 服务
|
||||
- [ ] 文件服务路径遍历全面审计
|
||||
+105
-21
@@ -12,6 +12,8 @@ import sys
|
||||
|
||||
# pymsg 日志客户端(集中式日志 → dbv2 Logs 表)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
# 附加 ../pymsg 源码路径作为无 venv 时的开发兜底(生产由 venv 提供 pymsg)
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "pymsg"))
|
||||
from log_client import logs # noqa: E402
|
||||
from security_middleware import apply_security_headers # noqa: E402
|
||||
|
||||
@@ -41,6 +43,8 @@ app = Flask(__name__)
|
||||
apply_security_headers(app)
|
||||
|
||||
# ==================== 配置文件加载 ====================
|
||||
# 加载顺序:内置默认值 → confs 统一配置服务(static.ini);
|
||||
# confs 不可用时 fail-safe 回退本地 config.ini
|
||||
CONFIG_FILE = args.config
|
||||
if not os.path.isabs(CONFIG_FILE):
|
||||
CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE)
|
||||
@@ -53,15 +57,42 @@ file_config = {
|
||||
'cors': {'allowed_origins': 'https://qsont.me,https://yhdet.top'},
|
||||
}
|
||||
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
config_parser = configparser.ConfigParser()
|
||||
config_parser.read(CONFIG_FILE, encoding='utf-8')
|
||||
|
||||
def _merge_ini(text):
|
||||
"""把 ini 文本合并进 file_config(仅覆盖已知的 section/key)。"""
|
||||
ini = configparser.ConfigParser()
|
||||
ini.read_string(text)
|
||||
for section in ['server', 'directory', 'ssl', 'cors']:
|
||||
if section in config_parser:
|
||||
for key, value in config_parser[section].items():
|
||||
if ini.has_section(section):
|
||||
for key, value in ini[section].items():
|
||||
if key in file_config[section]:
|
||||
file_config[section][key] = value
|
||||
|
||||
|
||||
def _fetch_from_confs():
|
||||
"""从 confs 统一配置服务拉取 static.ini 全文,失败返回 None(fail-safe)。"""
|
||||
try:
|
||||
from pymsg import RpcClient
|
||||
host = os.environ.get('CONFS_HOST', '127.0.0.1')
|
||||
port = int(os.environ.get('CONFS_PORT', '8010'))
|
||||
data = RpcClient(host, port, 'static', timeout=3).call(
|
||||
'get', {'name': 'static.ini'})
|
||||
return data.get('content')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_confs_text = _fetch_from_confs()
|
||||
if _confs_text is not None:
|
||||
_merge_ini(_confs_text)
|
||||
logger.info("配置来源: confs 统一配置服务 (static.ini)")
|
||||
logs.info("static", "配置来源: confs 统一配置服务 (static.ini)")
|
||||
elif os.path.exists(CONFIG_FILE):
|
||||
with open(CONFIG_FILE, encoding='utf-8') as f:
|
||||
_merge_ini(f.read())
|
||||
logger.warning(f"confs 不可用,回退本地配置文件 {CONFIG_FILE}")
|
||||
logs.warning("static", f"confs 不可用,回退本地配置文件 {CONFIG_FILE}")
|
||||
|
||||
# ==================== CORS(配置加载完成后初始化) ====================
|
||||
_cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()]
|
||||
CORS(app, origins=_cors_origins)
|
||||
@@ -474,22 +505,6 @@ def proxy_update_note():
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
|
||||
@app.route('/whoami', methods=['POST', 'OPTIONS'])
|
||||
def proxy_whoami():
|
||||
if request.method == "OPTIONS": return _cors(make_response("", 204))
|
||||
try:
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
h = {"Content-Type": "application/json"}
|
||||
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
|
||||
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
|
||||
req = _urlreq.Request(f"{DNFS_URL}/whoami", data=_json.dumps(data).encode(), headers=h, method="POST")
|
||||
with _urlreq.urlopen(req, timeout=15) as r:
|
||||
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
|
||||
except Exception as e:
|
||||
logs.warning("static", "dnfs whoami 代理失败", extra={"error": str(e)})
|
||||
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
|
||||
|
||||
|
||||
@app.route('/api/proxy_send_code', methods=['POST'])
|
||||
def proxy_send_code():
|
||||
from flask import request, jsonify
|
||||
@@ -516,6 +531,75 @@ def proxy_send_code():
|
||||
|
||||
|
||||
|
||||
# ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ====================
|
||||
# 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。
|
||||
# SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_FAVICON_MAX_BYTES = 512 * 1024
|
||||
_FAVICON_TIMEOUT = 5
|
||||
|
||||
|
||||
def _is_private_host(hostname):
|
||||
"""主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。"""
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except Exception:
|
||||
return True # 解析失败一律拒绝
|
||||
if not infos:
|
||||
return True
|
||||
for info in infos:
|
||||
try:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
except ValueError:
|
||||
return True
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler):
|
||||
"""重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。"""
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
if _is_private_host(urlparse(newurl).hostname or ''):
|
||||
return None
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
@app.route('/api/favicon', methods=['GET'])
|
||||
def api_favicon():
|
||||
target = (request.args.get('url') or '').strip()
|
||||
try:
|
||||
parsed = urlparse(target)
|
||||
except Exception:
|
||||
return jsonify({'error': 'bad url'}), 400
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
|
||||
return jsonify({'error': 'bad url'}), 400
|
||||
if _is_private_host(parsed.hostname):
|
||||
return jsonify({'error': 'forbidden host'}), 403
|
||||
|
||||
favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||
try:
|
||||
opener = _urlreq.build_opener(_FaviconRedirectHandler())
|
||||
req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'})
|
||||
with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp:
|
||||
data = resp.read(_FAVICON_MAX_BYTES + 1)
|
||||
if len(data) > _FAVICON_MAX_BYTES:
|
||||
return jsonify({'error': 'favicon too large'}), 502
|
||||
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip()
|
||||
if not ctype.startswith('image/'):
|
||||
ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底
|
||||
out = Response(data, 200, mimetype=ctype)
|
||||
out.headers['Cache-Control'] = 'public, max-age=86400'
|
||||
return out
|
||||
except Exception as e:
|
||||
logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)})
|
||||
return jsonify({'error': 'fetch failed'}), 502
|
||||
|
||||
|
||||
# ==================== 主路由 ====================
|
||||
@app.route('/')
|
||||
def serve_view():
|
||||
|
||||
+137
-70
@@ -1,17 +1,19 @@
|
||||
"""
|
||||
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
|
||||
"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
|
||||
|
||||
设计要点:
|
||||
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务
|
||||
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
|
||||
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
|
||||
|
||||
用法:
|
||||
from log_client import logs
|
||||
|
||||
logs.info("static", "服务启动成功")
|
||||
logs.error("static", "文件系统未启用", extra={"path": "/data"})
|
||||
logs.info("dnfs", "节点新增成功 id=42")
|
||||
logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
|
||||
logs.push("gateway", "WARNING", "登录失败次数超限",
|
||||
trace_id="req-abc", extra={"ip": "1.2.3.4"})
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -24,33 +26,45 @@ import atexit
|
||||
from datetime import datetime, timezone
|
||||
|
||||
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), ".."))
|
||||
from pymsg import MsgClient # noqa: E402
|
||||
from pymsg import RpcClient, RpcError # noqa: E402
|
||||
|
||||
# ── 配置 ──────────────────────────────────────────────────────────────
|
||||
|
||||
_DBV2_ADDR = '127.0.0.1'
|
||||
_DBV2_PORT = 8005
|
||||
_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
|
||||
_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
_BATCH_SIZE = 50 # 队列满 50 条时 flush
|
||||
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
|
||||
_TIMEOUT = 5 # socket 超时(秒)
|
||||
_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目
|
||||
|
||||
|
||||
def _safe_json(obj):
|
||||
"""安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。"""
|
||||
try:
|
||||
return json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return repr(obj)
|
||||
|
||||
|
||||
def _now_iso():
|
||||
"""生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。"""
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
class LogClient:
|
||||
"""异步批量日志推送客户端。
|
||||
"""异步批量日志推送客户端。"""
|
||||
|
||||
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。
|
||||
"""
|
||||
|
||||
def __init__(self, service_name="static",
|
||||
def __init__(self, service_name="log",
|
||||
batch_size=_BATCH_SIZE,
|
||||
flush_interval=_FLUSH_INTERVAL):
|
||||
self._service_name = service_name
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._queue = queue.Queue()
|
||||
self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||
self._stop = threading.Event()
|
||||
self._flush_signal = threading.Event()
|
||||
self._flush_done = threading.Event()
|
||||
self._thread = threading.Thread(target=self._flush_loop,
|
||||
daemon=True, name='log-client')
|
||||
self._thread.start()
|
||||
@@ -59,14 +73,30 @@ class LogClient:
|
||||
# ── 公开接口 ────────────────────────────────────────────────────────
|
||||
|
||||
def push(self, service, level, message, trace_id=None, extra=None):
|
||||
"""推送一条日志(非阻塞,立即返回)。"""
|
||||
self._queue.put({
|
||||
"""推送一条日志(非阻塞,立即返回)。
|
||||
|
||||
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
|
||||
"""
|
||||
entry = {
|
||||
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
|
||||
'service': service,
|
||||
'level': level,
|
||||
'message': message,
|
||||
'trace_id': trace_id,
|
||||
'extra': extra,
|
||||
})
|
||||
}
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
# 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略)
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except queue.Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(entry)
|
||||
except queue.Full:
|
||||
pass # 极端竞争下仍失败则静默丢弃
|
||||
|
||||
def debug(self, service, message, **kw):
|
||||
self.push(service, 'DEBUG', message, **kw)
|
||||
@@ -85,8 +115,15 @@ class LogClient:
|
||||
|
||||
def flush(self):
|
||||
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
|
||||
self._flush_done.clear()
|
||||
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
|
||||
time.sleep(0.6) # 等待后台线程响应
|
||||
# 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
|
||||
self._flush_done.wait(timeout=self._flush_interval + 2.0)
|
||||
# 主线程排空队列中剩余条目
|
||||
self._drain_queue_to_db()
|
||||
|
||||
def _drain_queue_to_db(self):
|
||||
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
|
||||
batch = []
|
||||
while True:
|
||||
try:
|
||||
@@ -98,73 +135,103 @@ class LogClient:
|
||||
break
|
||||
if batch:
|
||||
self._send(batch)
|
||||
self._flush_signal.clear()
|
||||
|
||||
# ── 内部实现 ────────────────────────────────────────────────────────
|
||||
|
||||
def _flush_loop(self):
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。"""
|
||||
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
|
||||
|
||||
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
|
||||
都不会死亡,避免日志队列无限增长导致 OOM。
|
||||
"""
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
|
||||
while not self._stop.is_set():
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
timeout = self._flush_interval - (time.monotonic() - last_flush)
|
||||
chk_timeout = max(0.1, min(timeout, 0.5))
|
||||
try:
|
||||
item = self._queue.get(timeout=chk_timeout)
|
||||
batch.append(item)
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
batch.append(self._queue.get_nowait())
|
||||
except queue.Empty:
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
self._send(batch)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
should_flush = (
|
||||
len(batch) >= self._batch_size or
|
||||
self._flush_signal.is_set() or
|
||||
(batch and time.monotonic() - last_flush >= self._flush_interval)
|
||||
)
|
||||
if should_flush and batch:
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
# _send 内部已有细粒度异常处理,此处是最终安全网
|
||||
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
batch = []
|
||||
last_flush = time.monotonic()
|
||||
if self._flush_signal.is_set():
|
||||
self._flush_signal.clear()
|
||||
self._flush_done.set()
|
||||
except Exception as e:
|
||||
# 最外层安全网:任何意外异常都不应杀死日志线程
|
||||
print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}",
|
||||
file=_sys.stderr)
|
||||
time.sleep(0.5) # 防止异常风暴导致 CPU 满载
|
||||
|
||||
# 退出前 flush 剩余
|
||||
if batch:
|
||||
self._send(batch)
|
||||
try:
|
||||
self._send(batch)
|
||||
except Exception as e:
|
||||
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
|
||||
self._flush_done.set()
|
||||
|
||||
def _send(self, batch):
|
||||
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
"""通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
|
||||
|
||||
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
|
||||
不抛异常给调用方。
|
||||
"""
|
||||
try:
|
||||
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
payload = {
|
||||
"_route": "execute",
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": now,
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": json.dumps(extra_val, ensure_ascii=False)
|
||||
if extra_val is not None else None,
|
||||
},
|
||||
}
|
||||
resp = client.call(json.dumps(payload), timeout=_TIMEOUT)
|
||||
resp_obj = json.loads(resp)
|
||||
if not resp_obj.get("success"):
|
||||
import sys
|
||||
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}",
|
||||
file=sys.stderr)
|
||||
client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log")
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"[log_client] 发送失败: {e}", file=sys.stderr)
|
||||
print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr)
|
||||
return
|
||||
|
||||
for entry in batch:
|
||||
extra_val = entry.get("extra")
|
||||
try:
|
||||
extra_json = _safe_json(extra_val) if extra_val is not None else None
|
||||
except Exception:
|
||||
extra_json = None
|
||||
payload = {
|
||||
"action": "insert",
|
||||
"table": "Logs",
|
||||
"values": {
|
||||
"ts": entry.get("ts", _now_iso()),
|
||||
"service": entry.get("service"),
|
||||
"level": entry.get("level"),
|
||||
"message": entry.get("message"),
|
||||
"trace_id": entry.get("trace_id"),
|
||||
"extra": extra_json,
|
||||
},
|
||||
}
|
||||
try:
|
||||
client.call("execute", payload, timeout=_TIMEOUT)
|
||||
except RpcError as e:
|
||||
# 业务错误(如表结构不符):打印后继续下一条
|
||||
print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
|
||||
except Exception as e:
|
||||
# 网络错误:db 不可用,放弃本批剩余(fail-safe)
|
||||
print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
|
||||
break
|
||||
|
||||
def _shutdown(self):
|
||||
"""进程退出时 flush 剩余日志。"""
|
||||
@@ -174,4 +241,4 @@ class LogClient:
|
||||
|
||||
# ── 全局单例 ──────────────────────────────────────────────────────────
|
||||
|
||||
logs = LogClient(service_name="static")
|
||||
logs = LogClient()
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!-- viewport-fit=cover:配合 safe-area 适配刘海屏;maximum-scale 防输入框聚焦自动缩放 -->
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover">
|
||||
<title>aibox 智能体</title>
|
||||
<link rel="stylesheet" href="css/theme.css">
|
||||
<link rel="stylesheet" href="css/aibox.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<header id="topbar">
|
||||
<span class="title">🤖 aibox 智能体</span>
|
||||
<span id="conn-status" class="disconnected"><span class="dot"></span><b id="status-text">未连接</b></span>
|
||||
<button id="task-toggle" type="button">📋 任务<span id="task-badge"></span></button>
|
||||
<button id="conn-btn" type="button">连接</button>
|
||||
</header>
|
||||
<div id="admin-tip" style="display:none"></div>
|
||||
<main id="chat"></main>
|
||||
<div id="input-bar">
|
||||
<button id="interrupt-btn" type="button" style="display:none" title="中断当前生成">⏸</button>
|
||||
<textarea id="input" rows="1" placeholder="输入消息,/help 查看命令"></textarea>
|
||||
<button id="send-btn" type="button">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
<aside id="task-panel">
|
||||
<header>任务面板<button id="task-close" type="button" aria-label="关闭">×</button></header>
|
||||
<div id="task-list"></div>
|
||||
</aside>
|
||||
<div id="backdrop"></div>
|
||||
<script src="js/markdown-it-12_0_0.js"></script>
|
||||
<script src="js/aibox.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,216 @@
|
||||
/* ===== 全屏应用布局(覆盖 theme.css 的居中卡片页默认) ===== */
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
display: block;
|
||||
padding: 0;
|
||||
height: 100dvh; /* 移动端动态视口高度,规避地址栏遮挡 */
|
||||
overflow: hidden;
|
||||
}
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg);
|
||||
transition: margin-right 0.25s;
|
||||
}
|
||||
|
||||
/* ===== 顶栏 ===== */
|
||||
#topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
padding-top: calc(10px + env(safe-area-inset-top));
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#topbar .title { font-size: 1rem; font-weight: 700; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
#conn-status { display: flex; align-items: center; gap: 5px; font-size: 0.75rem; color: var(--fg-secondary); flex: none; }
|
||||
#conn-status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
||||
#conn-status.connected .dot { background: var(--success); box-shadow: 0 0 6px var(--success); }
|
||||
#conn-status.connecting .dot { background: var(--warning); }
|
||||
#conn-status.disconnected .dot { background: var(--error); }
|
||||
#topbar button { flex: none; padding: 0.45rem 0.8rem; font-size: 0.8rem; min-height: 36px; }
|
||||
#task-toggle { background: var(--accent-soft); color: var(--accent-dark); position: relative; }
|
||||
#task-badge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -6px; right: -6px;
|
||||
min-width: 16px; height: 16px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
#conn-btn { background: var(--accent-soft); color: var(--accent-dark); }
|
||||
#conn-btn.on { background: var(--error-bg); color: var(--error); }
|
||||
|
||||
/* ===== 非管理员提示条 ===== */
|
||||
#admin-tip {
|
||||
flex: none;
|
||||
padding: 8px 12px;
|
||||
font-size: 0.78rem;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== 聊天区 ===== */
|
||||
#chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 12px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.msg { margin-bottom: 12px; overflow-wrap: break-word; }
|
||||
.msg-user {
|
||||
margin-left: 15%;
|
||||
padding: 0.65rem 0.9rem;
|
||||
background: var(--accent-soft);
|
||||
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
|
||||
font-size: 0.92rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg-ai {
|
||||
margin-right: 6%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.msg-ai p { margin: 0.4em 0; }
|
||||
.msg-ai p:first-child { margin-top: 0; }
|
||||
.msg-ai p:last-child { margin-bottom: 0; }
|
||||
.msg-ai pre {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.6rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.msg-ai code { font-size: 0.82em; background: var(--accent-soft); border-radius: 4px; padding: 0.1em 0.3em; }
|
||||
.msg-ai pre code { background: none; padding: 0; }
|
||||
.msg-ai a { color: var(--accent); }
|
||||
.msg-ai.streaming::after {
|
||||
content: '▌';
|
||||
color: var(--accent);
|
||||
animation: blink 0.9s steps(1) infinite;
|
||||
}
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.msg-system, .msg-error { text-align: center; font-size: 0.78rem; color: var(--fg-secondary); }
|
||||
.msg-error { color: var(--error); }
|
||||
|
||||
/* 思考区域:灰色斜体可折叠 */
|
||||
.msg-reasoning {
|
||||
margin-right: 6%;
|
||||
border-left: 3px solid var(--border);
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
}
|
||||
.reasoning-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-style: normal;
|
||||
font-size: 0.75rem;
|
||||
color: var(--fg-secondary);
|
||||
min-height: 28px;
|
||||
}
|
||||
.reasoning-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.reasoning-body.folded { display: none; }
|
||||
|
||||
/* ===== 输入栏 ===== */
|
||||
#input-bar {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
padding-bottom: calc(10px + env(safe-area-inset-bottom));
|
||||
background: var(--card);
|
||||
border-top: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#input {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
resize: none;
|
||||
font-size: 16px; /* iOS <16px 会聚焦自动缩放 */
|
||||
line-height: 1.4;
|
||||
padding: 0.65rem 0.9rem;
|
||||
}
|
||||
#send-btn { flex: none; min-height: 44px; background: var(--accent); color: #fff; }
|
||||
#interrupt-btn { flex: none; min-height: 44px; min-width: 44px; padding: 0.45rem 0.7rem; background: var(--error-bg); color: var(--error); }
|
||||
#send-btn:disabled, #interrupt-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ===== 任务面板:移动端为抽屉式覆盖层 ===== */
|
||||
#task-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0; bottom: 0;
|
||||
width: min(85vw, 340px);
|
||||
background: var(--card);
|
||||
border-left: 1px solid var(--border-light);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 100;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
}
|
||||
#task-panel.open { transform: translateX(0); }
|
||||
#task-panel header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
flex: none;
|
||||
}
|
||||
#task-close { background: none; border: none; font-size: 1.3rem; color: var(--fg-secondary); padding: 4px 8px; min-height: 36px; min-width: 36px; }
|
||||
#task-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
#backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s;
|
||||
z-index: 99;
|
||||
}
|
||||
#backdrop.show { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* ===== 桌面端:任务面板固化为右侧栏 ===== */
|
||||
@media (min-width: 900px) {
|
||||
#app { margin-right: 340px; max-width: none; }
|
||||
#task-panel { transform: none; box-shadow: none; }
|
||||
#task-toggle, #task-close, #backdrop { display: none; }
|
||||
|
||||
/* 桌面端所有对话一律左对齐(移动端保留用户右/AI 左的对话式布局) */
|
||||
.msg-user, .msg-ai, .msg-reasoning { margin-left: 0; margin-right: 0; }
|
||||
}
|
||||
@@ -119,6 +119,21 @@ textarea:focus {
|
||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||
textarea { min-height: 80px; resize: vertical; }
|
||||
|
||||
select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||
|
||||
/* ===== Buttons ===== */
|
||||
button, input[type="submit"], input[type="reset"], input[type="button"] {
|
||||
padding: 0.55rem 1.3rem;
|
||||
@@ -190,6 +205,18 @@ input[type="button"]:disabled {
|
||||
}
|
||||
.code-box input[type="text"] { width: auto; flex: 1; min-width: 100px; display: inline-block; }
|
||||
.code-box input[type="button"] { white-space: nowrap; }
|
||||
/* 图形验证码图片(register 图形/邮箱验证码同排布局) */
|
||||
.code-box .captcha-img {
|
||||
width: 120px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
cursor: pointer;
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-sizing: border-box;
|
||||
object-fit: cover;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ===== Validation Tips ===== */
|
||||
.tip-red { color: var(--error); font-size: 0.78rem; }
|
||||
@@ -480,6 +507,30 @@ input[type="button"]:disabled {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
|
||||
.sp-link { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; }
|
||||
.sp-anchor { color: var(--accent); word-break: break-all; }
|
||||
.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
||||
.sp-code {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); }
|
||||
.sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; }
|
||||
#sp-panel { margin-top: 0.4rem; }
|
||||
#sp-panel select { margin-bottom: 0.4rem; }
|
||||
#sp-file-tip { font-size: 0.78rem; }
|
||||
|
||||
/* ===== Node Form (dnfs) ===== */
|
||||
.node-form { margin-top: 1rem; }
|
||||
.node-form .form-actions { margin-top: 0.75rem; }
|
||||
@@ -491,7 +542,10 @@ input[type="button"]:disabled {
|
||||
padding: 0.9rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
min-height: 120px;
|
||||
max-height: 400px;
|
||||
line-height: 1.7;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* ===== Markdown Content ===== */
|
||||
|
||||
+15
-2
@@ -15,8 +15,21 @@
|
||||
<div class="recommend-list" id="recommend-list"></div>
|
||||
<div class="card node-form">
|
||||
<h3>节点操作</h3>
|
||||
<label>新增节点</label><input id="input-note-title">
|
||||
<label>节点内容</label><textarea id="input-note-content"></textarea>
|
||||
<label>新增节点</label><input id="input-note-title">
|
||||
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
|
||||
<div id="sp-panel" style="display:none">
|
||||
<label>节点类型</label>
|
||||
<select id="sp-type">
|
||||
<option value="text">普通文本</option>
|
||||
<option value="link">链接(自动显示站点图标)</option>
|
||||
<option value="html">网页代码(可审计/可运行)</option>
|
||||
<option value="image">图片(自动显示)</option>
|
||||
<option value="file">文件(可下载)</option>
|
||||
</select>
|
||||
<input type="file" id="sp-file" style="display:none">
|
||||
<span id="sp-file-tip"></span>
|
||||
</div>
|
||||
<label>节点内容</label><textarea id="input-note-content"></textarea>
|
||||
<div class="form-actions">
|
||||
<button id="input-note-submit">提交</button>
|
||||
<button id="input-note-clear">重置</button>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
'use strict';
|
||||
/* ============================================================
|
||||
* aibox Web 客户端(经 gateway WS 反向代理,仅管理员可用)
|
||||
* 协议与 BotOps/aibox client.py 完全兼容:
|
||||
* 发送:{"type":"message","content":...} / {"type":"command","command":"/..."} / {"type":"interrupt",...}
|
||||
* 接收:chunk(增量片段) / reasoning_start / reasoning / reasoning_end / complete /
|
||||
* line(过程行) / task_process / task_status / task_error / task_added /
|
||||
* dialogue_ended / dialogue_reset / interrupt_ack / error / info /
|
||||
* command_result / connection_ack / notification / pong
|
||||
* ============================================================ */
|
||||
|
||||
/* gateway [WsProxy] routes 中配置的路径(HttpOnly q4_token Cookie 随握手自动携带,JS 不经手 Token)。
|
||||
* 需与 gateway.ini 的 [WsProxy] routes 条目一致,如:routes = /ws/aibox=ws://127.0.0.1:8765; */
|
||||
const WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/aibox';
|
||||
const RECONNECT_MIN = 2000; /* 指数退避起点(与 BotOps 一致:2s→30s 封顶) */
|
||||
const RECONNECT_MAX = 30000;
|
||||
const REASONING_TAIL = 3000; /* 思考区域超长时仅显示末尾字符数 */
|
||||
|
||||
const md = window.markdownit({ html: false, linkify: true, breaks: true });
|
||||
/* 链接一律新标签打开且不带 referrer(CSP connect-src 'self' 允许 WS 同源) */
|
||||
const defaultLinkOpen = md.renderer.rules.link_open || function(tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); };
|
||||
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
||||
tokens[idx].attrSet('target', '_blank');
|
||||
tokens[idx].attrSet('rel', 'noopener noreferrer');
|
||||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
const chatEl = document.getElementById('chat');
|
||||
const inputEl = document.getElementById('input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const interruptBtn = document.getElementById('interrupt-btn');
|
||||
const connBtn = document.getElementById('conn-btn');
|
||||
const statusEl = document.getElementById('conn-status');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const taskPanel = document.getElementById('task-panel');
|
||||
const taskList = document.getElementById('task-list');
|
||||
const taskToggle = document.getElementById('task-toggle');
|
||||
const taskBadge = document.getElementById('task-badge');
|
||||
const backdrop = document.getElementById('backdrop');
|
||||
|
||||
let ws = null;
|
||||
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||
let reconnectDelay = RECONNECT_MIN;
|
||||
let reconnectTimer = null;
|
||||
|
||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前,与 BotOps 同策略) */
|
||||
let aiBubble = null, aiText = '', aiActive = false;
|
||||
let reasoningBox = null, reasoningBody = null, reasoningHead = null, reasoningText = '';
|
||||
let streaming = false;
|
||||
let unreadTasks = 0;
|
||||
|
||||
/* ---------- 工具 ---------- */
|
||||
function nearBottom() {
|
||||
return chatEl.scrollHeight - chatEl.scrollTop - chatEl.clientHeight < 80;
|
||||
}
|
||||
function scrollToBottom(force) {
|
||||
if (force || nearBottom()) chatEl.scrollTop = chatEl.scrollHeight;
|
||||
}
|
||||
function escapeText(s) { return String(s == null ? '' : s); }
|
||||
|
||||
function addSystem(text, isError) {
|
||||
const div = document.createElement('div');
|
||||
div.className = isError ? 'msg msg-error' : 'msg msg-system';
|
||||
div.textContent = text;
|
||||
chatEl.appendChild(div);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function addTaskLine(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
taskList.appendChild(div);
|
||||
taskList.scrollTop = taskList.scrollHeight;
|
||||
if (!taskPanel.classList.contains('open')) {
|
||||
unreadTasks += 1;
|
||||
taskBadge.style.display = '';
|
||||
taskBadge.textContent = unreadTasks > 99 ? '99+' : String(unreadTasks);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 消息气泡 ---------- */
|
||||
function addUserBubble(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'msg msg-user';
|
||||
div.textContent = text;
|
||||
chatEl.appendChild(div);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
function ensureAiBubble() {
|
||||
if (aiBubble) return;
|
||||
aiBubble = document.createElement('div');
|
||||
aiBubble.className = 'msg msg-ai streaming';
|
||||
chatEl.appendChild(aiBubble);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
/* 服务端 chunk 为增量片段(发送后即清零),直接拼接 */
|
||||
function appendAi(delta) {
|
||||
if (!delta || aiActive === false) return;
|
||||
ensureAiBubble();
|
||||
aiText += delta;
|
||||
aiBubble.innerHTML = md.render(aiText);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/* complete 为收尾信号:chunk 流式是内容权威来源,仅异常丢帧时用 full_text 补建 */
|
||||
function finishAi(fullText) {
|
||||
if (aiActive === false && !aiBubble && !reasoningBox) return;
|
||||
if (!aiBubble && fullText) {
|
||||
ensureAiBubble();
|
||||
aiText = fullText;
|
||||
aiBubble.innerHTML = md.render(aiText);
|
||||
}
|
||||
if (aiBubble) aiBubble.classList.remove('streaming');
|
||||
aiBubble = null; aiText = ''; aiActive = false;
|
||||
reasoningBox = null; reasoningBody = null; reasoningHead = null; reasoningText = '';
|
||||
setStreaming(false);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
/* ---------- 思考区域(可折叠) ---------- */
|
||||
function startReasoning(hint) {
|
||||
if (reasoningBox) return;
|
||||
reasoningText = '';
|
||||
reasoningBox = document.createElement('div');
|
||||
reasoningBox.className = 'msg msg-reasoning';
|
||||
reasoningHead = document.createElement('div');
|
||||
reasoningHead.className = 'reasoning-head';
|
||||
reasoningHead.textContent = '💭 ' + (hint || '思考中...');
|
||||
reasoningBody = document.createElement('div');
|
||||
reasoningBody.className = 'reasoning-body';
|
||||
reasoningHead.addEventListener('click', function() { reasoningBody.classList.toggle('folded'); });
|
||||
reasoningBox.appendChild(reasoningHead);
|
||||
reasoningBox.appendChild(reasoningBody);
|
||||
chatEl.appendChild(reasoningBox);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
function appendReasoning(delta) {
|
||||
if (!delta) return;
|
||||
if (!reasoningBox) startReasoning();
|
||||
reasoningText += delta;
|
||||
const display = reasoningText.length > REASONING_TAIL
|
||||
? '...(前略)\n' + reasoningText.slice(-REASONING_TAIL)
|
||||
: reasoningText;
|
||||
reasoningBody.textContent = display;
|
||||
scrollToBottom();
|
||||
}
|
||||
function endReasoning() {
|
||||
if (reasoningHead && reasoningText) {
|
||||
reasoningHead.textContent = '💭 思考过程(' + reasoningText.length + ' 字符,点击折叠/展开)';
|
||||
reasoningBody.classList.add('folded'); /* 结束后默认折叠,避免占屏 */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 连接状态 ---------- */
|
||||
function setStatus(cls, text) {
|
||||
statusEl.className = cls;
|
||||
statusText.textContent = text;
|
||||
}
|
||||
function setStreaming(on) {
|
||||
streaming = on;
|
||||
interruptBtn.style.display = on ? '' : 'none';
|
||||
sendBtn.disabled = false; /* 流式期间仍可发送(服务端排队),仅中断按钮显隐 */
|
||||
}
|
||||
|
||||
/* ---------- WebSocket 生命周期(指数退避自动重连,手动断开抑制) ---------- */
|
||||
function connect() {
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
clearTimeout(reconnectTimer);
|
||||
manualClose = false;
|
||||
setStatus('connecting', '连接中');
|
||||
try {
|
||||
ws = new WebSocket(WS_URL);
|
||||
} catch (e) {
|
||||
addSystem('连接失败:' + e.message, true);
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws.onopen = function() {
|
||||
reconnectDelay = RECONNECT_MIN;
|
||||
setStatus('connected', '已连接');
|
||||
connBtn.textContent = '断开';
|
||||
connBtn.classList.add('on');
|
||||
};
|
||||
ws.onmessage = function(ev) {
|
||||
let data;
|
||||
try { data = JSON.parse(ev.data); }
|
||||
catch (e) { data = { type: 'chunk', content: ev.data }; }
|
||||
routeMessage(data);
|
||||
};
|
||||
ws.onclose = function() {
|
||||
setStatus('disconnected', '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
finishAi('');
|
||||
ws = null;
|
||||
if (!manualClose) scheduleReconnect();
|
||||
};
|
||||
ws.onerror = function() { /* onclose 随后触发,统一在 onclose 处理 */ };
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (manualClose) return;
|
||||
setStatus('connecting', Math.round(reconnectDelay / 1000) + 's 后重连');
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(function() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
||||
connect();
|
||||
}, reconnectDelay);
|
||||
}
|
||||
|
||||
function disconnect(manual) {
|
||||
manualClose = manual !== false;
|
||||
clearTimeout(reconnectTimer);
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
}
|
||||
|
||||
/* ---------- aibox 协议消息路由(对应 BotOps app.py 的 _on_ws_message) ---------- */
|
||||
function routeMessage(data) {
|
||||
const t = data.type || '';
|
||||
const content = typeof data.content === 'string' ? data.content : (data.content == null ? '' : String(data.content));
|
||||
|
||||
if (t === 'chunk') {
|
||||
appendAi(content);
|
||||
} else if (t === 'reasoning_start') {
|
||||
startReasoning(content || '深度思考中...');
|
||||
} else if (t === 'reasoning') {
|
||||
appendReasoning(content);
|
||||
} else if (t === 'reasoning_end') {
|
||||
endReasoning();
|
||||
} else if (t === 'line') {
|
||||
/* 过程行:工具调用提示 / 工具输出 → 任务面板;助手前缀 / 思考提示聊天区已有承载,跳过 */
|
||||
const stripped = content.trim();
|
||||
if (!stripped) return;
|
||||
if (/^(💬 助手|💬 Assistant|🤔 思考中|🤔 Thinking|🤔 深度思考中)/.test(stripped)) return;
|
||||
addTaskLine(stripped);
|
||||
} else if (t === 'complete') {
|
||||
finishAi(content);
|
||||
} else if (t === 'dialogue_ended') {
|
||||
finishAi(aiText);
|
||||
if (content) addSystem('✅ ' + content);
|
||||
} else if (t === 'task_added') {
|
||||
addSystem('➕ ' + content);
|
||||
} else if (t === 'dialogue_reset') {
|
||||
if (content) addSystem('🔄 ' + content);
|
||||
} else if (t === 'pong') {
|
||||
/* 心跳响应,忽略 */
|
||||
} else if (t === 'task_process') {
|
||||
const sub = data.msg_type || '';
|
||||
const title = data.task_title || '未知任务';
|
||||
if (sub === 'chunk') {
|
||||
const tail = content.slice(-80);
|
||||
if (tail.trim()) addTaskLine('⚡ ' + title + ': ' + tail);
|
||||
} else if (sub === 'complete') {
|
||||
addTaskLine('✅ 任务完成: ' + title);
|
||||
}
|
||||
} else if (t === 'task_status') {
|
||||
const icon = { executing: '⚙️', complete: '✅', failed: '❌', added: '➕', batch_start: '🚀', batch_complete: '🏁' }[data.status] || '📋';
|
||||
addTaskLine(icon + ' [' + (data.task_id || '?') + '] ' + (data.title || '') + ': ' + (data.message || content));
|
||||
} else if (t === 'task_error') {
|
||||
addTaskLine('❌ [' + (data.task_id || '?') + '] ' + (data.task_title || '?') + ': ' + (data.error || '未知错误'));
|
||||
} else if (t === 'interrupt_ack') {
|
||||
addSystem('🛑 ' + (content || '已中断'));
|
||||
} else if (t === 'error') {
|
||||
addSystem('❌ ' + content, true);
|
||||
} else if (t === 'info') {
|
||||
/* 过滤隐式通知(对话开始等),避免污染聊天流 */
|
||||
if (!/(Conversation started|对话开始|🎭)/.test(content)) addSystem('ℹ️ ' + content);
|
||||
} else if (t === 'command_result') {
|
||||
addSystem(content);
|
||||
} else if (t === 'connection_ack') {
|
||||
addSystem('✅ ' + content);
|
||||
} else if (t === 'notification') {
|
||||
addSystem('🔔 ' + content);
|
||||
} else if (content) {
|
||||
appendAi(content); /* 未知类型兜底:按 AI 增量处理(与 BotOps 一致) */
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 发送 ---------- */
|
||||
function wsSend(obj) {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||
addSystem('未连接到服务器', true);
|
||||
return false;
|
||||
}
|
||||
ws.send(JSON.stringify(obj));
|
||||
return true;
|
||||
}
|
||||
|
||||
const LOCAL_COMMANDS = ['/clear', '/help', '/connect', '/disconnect', '/interrupt'];
|
||||
|
||||
function handleLocalCommand(cmd) {
|
||||
switch (cmd) {
|
||||
case '/clear':
|
||||
chatEl.replaceChildren();
|
||||
finishAi('');
|
||||
return true;
|
||||
case '/help':
|
||||
addSystem('本地命令:/clear 清屏 · /connect 连接 · /disconnect 断开 · /interrupt 中断 · 其余 / 命令透传服务器');
|
||||
return true;
|
||||
case '/connect':
|
||||
connect();
|
||||
return true;
|
||||
case '/disconnect':
|
||||
disconnect(true);
|
||||
return true;
|
||||
case '/interrupt':
|
||||
sendInterrupt();
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sendCurrentInput() {
|
||||
const text = inputEl.value.trim();
|
||||
if (!text) return;
|
||||
if (text.startsWith('/') && handleLocalCommand(text.split(/\s/)[0])) {
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
return;
|
||||
}
|
||||
/* 与 BotOps 一致:/ 开头按 command 发送,否则 message */
|
||||
const data = text.startsWith('/')
|
||||
? { type: 'command', command: text }
|
||||
: { type: 'message', content: text };
|
||||
if (!wsSend(data)) return;
|
||||
addUserBubble(text);
|
||||
aiActive = true; /* 开始接收本轮 AI 流 */
|
||||
setStreaming(true);
|
||||
inputEl.value = '';
|
||||
autoResize();
|
||||
inputEl.focus();
|
||||
}
|
||||
|
||||
function sendInterrupt() {
|
||||
wsSend({ type: 'interrupt', content: '用户按下了 Ctrl+C 中断当前操作' });
|
||||
}
|
||||
|
||||
/* ---------- 输入区自适应高度 ---------- */
|
||||
function autoResize() {
|
||||
inputEl.style.height = 'auto';
|
||||
inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px';
|
||||
}
|
||||
inputEl.addEventListener('input', autoResize);
|
||||
inputEl.addEventListener('keydown', function(e) {
|
||||
/* Enter 发送,Shift+Enter 换行(移动端依赖发送按钮,不影响) */
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
sendCurrentInput();
|
||||
}
|
||||
});
|
||||
sendBtn.addEventListener('click', sendCurrentInput);
|
||||
interruptBtn.addEventListener('click', sendInterrupt);
|
||||
|
||||
/* ---------- 连接按钮与任务面板 ---------- */
|
||||
connBtn.addEventListener('click', function() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) disconnect(true);
|
||||
else connect();
|
||||
});
|
||||
|
||||
function openTaskPanel(open) {
|
||||
taskPanel.classList.toggle('open', open);
|
||||
backdrop.classList.toggle('show', open);
|
||||
if (open) {
|
||||
unreadTasks = 0;
|
||||
taskBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
taskToggle.addEventListener('click', function() { openTaskPanel(!taskPanel.classList.contains('open')); });
|
||||
document.getElementById('task-close').addEventListener('click', function() { openTaskPanel(false); });
|
||||
backdrop.addEventListener('click', function() { openTaskPanel(false); });
|
||||
|
||||
/* ---------- 启动:管理员预检(WS 握手由 gateway 强制 is_admin,此处仅提示) ---------- */
|
||||
(async function init() {
|
||||
try {
|
||||
const res = await fetch('/whoami', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||
const data = res.ok ? await res.json() : null;
|
||||
const tip = document.getElementById('admin-tip');
|
||||
if (!data || !data.login) {
|
||||
tip.textContent = '未登录:aibox 仅管理员可用,请先登录管理员账号';
|
||||
tip.style.display = '';
|
||||
} else if (!data.is_admin) {
|
||||
tip.textContent = '当前账号不是管理员,连接将被网关拒绝';
|
||||
tip.style.display = '';
|
||||
}
|
||||
} catch (e) { /* 预检失败不阻塞,由 WS 握手兜底 */ }
|
||||
connect();
|
||||
})();
|
||||
+270
-7
@@ -105,6 +105,7 @@ async function addNode(pid, title, content) {
|
||||
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit(); /* 退出编辑模式并重置高度 */
|
||||
getNode(stack[stack.length - 1]).then(load_notes);
|
||||
getNode(stack[stack.length - 1]).then(loadMap)
|
||||
|
||||
@@ -417,6 +418,151 @@ function renderMarkdown(element, content) {
|
||||
element.innerHTML = md.render(content, {html: false});
|
||||
}
|
||||
|
||||
/* ===== by qing — 特殊节点(管理员专属)=====
|
||||
* 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。
|
||||
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
|
||||
|
||||
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
|
||||
var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024;
|
||||
|
||||
function parseSp(content) {
|
||||
if (typeof content !== 'string') return null;
|
||||
var s = content.trim();
|
||||
if (s.indexOf('{"_sp"') !== 0) return null;
|
||||
try {
|
||||
var obj = JSON.parse(s);
|
||||
} catch (e) {
|
||||
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
|
||||
}
|
||||
if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
|
||||
return null;
|
||||
}
|
||||
|
||||
/* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */
|
||||
function renderContent(container, item) {
|
||||
var sp = parseSp(item.content);
|
||||
if (!sp) {
|
||||
renderMarkdown(container, item.content);
|
||||
return;
|
||||
}
|
||||
function appendNote() {
|
||||
if (typeof sp.note === 'string' && sp.note.trim()) {
|
||||
var note = document.createElement('div');
|
||||
note.className = 'sp-note';
|
||||
note.textContent = sp.note;
|
||||
container.appendChild(note);
|
||||
}
|
||||
}
|
||||
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
|
||||
else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote);
|
||||
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
|
||||
else if (sp._sp === 'file') renderSpFile(container, sp, appendNote);
|
||||
}
|
||||
|
||||
/* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */
|
||||
function renderSpLink(container, sp, appendNote) {
|
||||
var box = document.createElement('div');
|
||||
box.className = 'sp-link';
|
||||
var icon = document.createElement('img');
|
||||
icon.className = 'sp-favicon';
|
||||
icon.alt = '';
|
||||
icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url);
|
||||
icon.addEventListener('error', function() { icon.style.display = 'none'; });
|
||||
var anchor = document.createElement('a');
|
||||
anchor.className = 'sp-anchor';
|
||||
anchor.href = sp.url;
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
anchor.textContent = sp.url;
|
||||
anchor.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||
box.appendChild(icon);
|
||||
box.appendChild(anchor);
|
||||
container.appendChild(box);
|
||||
appendNote();
|
||||
}
|
||||
|
||||
/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */
|
||||
function renderSpHtml(container, sp, appendNote) {
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'sp-actions';
|
||||
var audit_btn = document.createElement('button');
|
||||
audit_btn.className = 'act-btn act-edit';
|
||||
audit_btn.textContent = '审计源码';
|
||||
var run_btn = document.createElement('button');
|
||||
run_btn.className = 'act-btn act-move';
|
||||
run_btn.textContent = '运行';
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'sp-code';
|
||||
pre.style.display = 'none';
|
||||
pre.textContent = sp.code;
|
||||
audit_btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var hidden = (pre.style.display === 'none');
|
||||
pre.style.display = hidden ? 'block' : 'none';
|
||||
audit_btn.textContent = hidden ? '收起源码' : '审计源码';
|
||||
});
|
||||
run_btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var win = window.open('', '_blank');
|
||||
if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; }
|
||||
win.document.open();
|
||||
win.document.write(sp.code);
|
||||
win.document.close();
|
||||
});
|
||||
bar.appendChild(audit_btn);
|
||||
bar.appendChild(run_btn);
|
||||
container.appendChild(bar);
|
||||
container.appendChild(pre);
|
||||
appendNote();
|
||||
}
|
||||
|
||||
/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */
|
||||
function renderSpImage(container, sp, appendNote) {
|
||||
var img = document.createElement('img');
|
||||
img.className = 'sp-image';
|
||||
img.src = sp.data;
|
||||
img.alt = sp.name || '图片';
|
||||
container.appendChild(img);
|
||||
appendNote();
|
||||
}
|
||||
|
||||
/* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */
|
||||
function renderSpFile(container, sp, appendNote) {
|
||||
var bar = document.createElement('div');
|
||||
bar.className = 'sp-actions';
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'act-btn act-edit';
|
||||
btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')';
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
var bin = atob(sp.data);
|
||||
var bytes = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' });
|
||||
var a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = sp.name || 'download';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000);
|
||||
} catch (err) {
|
||||
alert('文件解码失败:' + err.message);
|
||||
}
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
container.appendChild(bar);
|
||||
appendNote();
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
var units = ['B', 'KB', 'MB', 'GB'];
|
||||
var i = 0;
|
||||
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||
return n.toFixed(1) + units[i];
|
||||
}
|
||||
|
||||
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
|
||||
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
|
||||
@@ -449,16 +595,96 @@ var recommend_list = document.getElementById('recommend-list');
|
||||
var delete_note = document.getElementById('delete-note');
|
||||
var minimap = document.getElementById("minimap");
|
||||
var movebar = document.getElementById("movebar");
|
||||
/* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */
|
||||
var sp_panel = document.getElementById('sp-panel');
|
||||
var sp_type = document.getElementById('sp-type');
|
||||
var sp_file = document.getElementById('sp-file');
|
||||
var sp_file_tip = document.getElementById('sp-file-tip');
|
||||
var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
|
||||
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
|
||||
var PANEL_EXPAND_MIN_WIDTH = 768;
|
||||
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
|
||||
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
|
||||
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
|
||||
|
||||
/* ===== 输入框自动高度调整 ===== */
|
||||
function autoResizeTextarea(el) {
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 400) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden';
|
||||
}
|
||||
input_note_content.addEventListener('input', function() {
|
||||
autoResizeTextarea(input_note_content);
|
||||
});
|
||||
function resetTextareaHeight() {
|
||||
input_note_content.style.height = '120px';
|
||||
input_note_content.style.overflowY = 'hidden';
|
||||
}
|
||||
autoResizeTextarea(input_note_content); /* 初始化高度 */
|
||||
|
||||
/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */
|
||||
sp_type.addEventListener('change', function() {
|
||||
var t = sp_type.value;
|
||||
var is_file = (t === 'image' || t === 'file');
|
||||
sp_file.style.display = is_file ? '' : 'none';
|
||||
input_note_content.style.display = is_file ? 'none' : '';
|
||||
sp_file_tip.textContent = '';
|
||||
sp_file_tip.className = is_file ? 'tip-yellow' : '';
|
||||
if (!is_file) {
|
||||
sp_file.value = '';
|
||||
sp_edit_original = null;
|
||||
}
|
||||
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
|
||||
else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码';
|
||||
else input_note_content.placeholder = '';
|
||||
});
|
||||
|
||||
function readFileAsDataURL(f) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var r = new FileReader();
|
||||
r.onload = function() { resolve(r.result); };
|
||||
r.onerror = function() { reject(new Error('文件读取失败')); };
|
||||
r.readAsDataURL(f);
|
||||
});
|
||||
}
|
||||
|
||||
/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。
|
||||
* 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */
|
||||
async function buildSubmitContent() {
|
||||
var t = (current_user && current_user.is_admin) ? sp_type.value : 'text';
|
||||
if (t === 'text') return input_note_content.value;
|
||||
if (t === 'link') {
|
||||
var url = input_note_content.value.trim();
|
||||
if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; }
|
||||
return JSON.stringify({ _sp: 'link', url: url });
|
||||
}
|
||||
if (t === 'html') {
|
||||
var code = input_note_content.value;
|
||||
if (!code.trim()) { alert('请输入 HTML 代码'); return null; }
|
||||
return JSON.stringify({ _sp: 'html', code: code });
|
||||
}
|
||||
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
|
||||
if (sp_file.files.length === 0) {
|
||||
if (sp_edit_original) return sp_edit_original;
|
||||
alert('请选择要上传的' + (t === 'image' ? '图片' : '文件'));
|
||||
return null;
|
||||
}
|
||||
var f = sp_file.files[0];
|
||||
if (f.size > SP_MAX_FILE_BYTES) {
|
||||
alert('文件过大(上限约 2.5MB):' + f.name);
|
||||
return null;
|
||||
}
|
||||
var dataUrl = await readFileAsDataURL(f);
|
||||
if (t === 'image') {
|
||||
if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; }
|
||||
return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl });
|
||||
}
|
||||
return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] });
|
||||
}
|
||||
|
||||
input_note_submit.addEventListener('click', function(event) {
|
||||
if (input_note_submit.disabled) return; // 防重复点击
|
||||
var title = input_note_title.value
|
||||
var content = input_note_content.value
|
||||
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
//alert(stack.length)
|
||||
//alert(pid)
|
||||
@@ -466,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) {
|
||||
input_note_submit.disabled = true;
|
||||
input_note_clear.disabled = true;
|
||||
delete_note.disabled = true;
|
||||
// 编辑模式下保存修改,否则新增节点
|
||||
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
|
||||
Promise.resolve(req)
|
||||
// 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件)
|
||||
buildSubmitContent()
|
||||
.then(function(content) {
|
||||
if (content == null) return null; // 校验失败,已弹提示
|
||||
return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
|
||||
})
|
||||
.catch(function(err) {
|
||||
alert(err.message || '内容处理失败');
|
||||
console.error('构造提交内容异常:', err);
|
||||
})
|
||||
.finally(() => {
|
||||
input_note_submit.disabled = false;
|
||||
input_note_clear.disabled = false;
|
||||
@@ -497,12 +730,30 @@ delete_note.addEventListener('click', (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */
|
||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
|
||||
* 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
|
||||
function startEdit(item) {
|
||||
editing_id = item.id;
|
||||
input_note_title.value = item.title;
|
||||
input_note_content.value = item.content;
|
||||
var sp = parseSp(item.content);
|
||||
sp_edit_original = null;
|
||||
if (sp && (sp._sp === 'link' || sp._sp === 'html')) {
|
||||
sp_type.value = sp._sp;
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
|
||||
} else if (sp) {
|
||||
sp_type.value = sp._sp;
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
sp_edit_original = item.content;
|
||||
sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件');
|
||||
input_note_content.value = '';
|
||||
} else {
|
||||
sp_type.value = 'text';
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = item.content;
|
||||
}
|
||||
input_note_submit.textContent = "保存修改";
|
||||
autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */
|
||||
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
input_note_title.focus();
|
||||
}
|
||||
@@ -510,7 +761,15 @@ function startEdit(item) {
|
||||
/* by qing — 退出编辑模式,表单恢复为新增状态 */
|
||||
function cancelEdit() {
|
||||
editing_id = null;
|
||||
sp_edit_original = null;
|
||||
sp_file.value = '';
|
||||
sp_file_tip.textContent = '';
|
||||
if (sp_type.value !== 'text') {
|
||||
sp_type.value = 'text';
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
}
|
||||
input_note_submit.textContent = "提交";
|
||||
resetTextareaHeight(); /* 恢复初始高度 */
|
||||
}
|
||||
|
||||
function load_notes(main_node) {
|
||||
@@ -579,7 +838,7 @@ function load_notes(main_node) {
|
||||
ttl.appendChild(wrt_div);
|
||||
ttl.appendChild(sts_div);
|
||||
let ctt = document.createElement('p');
|
||||
renderMarkdown(ctt, item.content);
|
||||
renderContent(ctt, item);
|
||||
card.appendChild(ctt)
|
||||
if (item.can_operate === true) {
|
||||
let act_div = document.createElement('div');
|
||||
@@ -681,6 +940,10 @@ async function loadUserPanel() {
|
||||
});
|
||||
if (!res.ok) return;
|
||||
current_user = await res.json();
|
||||
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
|
||||
if (current_user.login && current_user.is_admin) {
|
||||
sp_panel.style.display = '';
|
||||
}
|
||||
renderUserPanel();
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败:', e);
|
||||
|
||||
@@ -7,11 +7,45 @@ const repeatPasswordInput = document.getElementById('repwd');
|
||||
const emlTip = document.getElementById('emlTip');
|
||||
const pwdTip = document.getElementById('pwdTip');
|
||||
const codeTip = document.getElementById('codeTip');
|
||||
const captchaImg = document.getElementById('captchaImg');
|
||||
const captchaInput = document.getElementById('captchaAnswer');
|
||||
const captchaTip = document.getElementById('captchaTip');
|
||||
const submitButton = document.getElementById('formsub');
|
||||
|
||||
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
|
||||
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
|
||||
|
||||
/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha() {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error('captcha load failed');
|
||||
captchaToken = res.headers.get('X-Captcha-Token') || '';
|
||||
const blob = await res.blob();
|
||||
if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src);
|
||||
captchaImg.src = URL.createObjectURL(blob);
|
||||
captchaImg.alt = '点击刷新';
|
||||
} catch (e) {
|
||||
captchaImg.removeAttribute('src');
|
||||
captchaImg.alt = '加载失败,点击重试';
|
||||
} finally {
|
||||
captchaLoading = false;
|
||||
}
|
||||
}
|
||||
captchaImg.addEventListener('click', refreshCaptcha);
|
||||
refreshCaptcha();
|
||||
|
||||
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
|
||||
function startResendCooldown(seconds) {
|
||||
clearInterval(cooldownTimer);
|
||||
@@ -34,16 +68,20 @@ function startResendCooldown(seconds) {
|
||||
form.addEventListener('reset', function () {
|
||||
clearTipStyle();
|
||||
codeTarget = '';
|
||||
captchaInput.value = '';
|
||||
refreshCaptcha();
|
||||
});
|
||||
|
||||
function clearTipStyle() {
|
||||
emlTip.innerHTML = '';
|
||||
pwdTip.innerHTML = '';
|
||||
codeTip.innerHTML = '';
|
||||
captchaTip.innerHTML = '';
|
||||
|
||||
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
}
|
||||
clearTipStyle();
|
||||
|
||||
@@ -101,6 +139,23 @@ function checkCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
function checkCaptcha() {
|
||||
captchaTip.innerHTML = '';
|
||||
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
|
||||
const v = captchaInput.value.trim();
|
||||
if (!v) {
|
||||
captchaTip.textContent = '请输入图形验证码!';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
if (!captchaToken) {
|
||||
captchaTip.textContent = '图形验证码未加载,请点击图片刷新';
|
||||
captchaTip.classList.add('tip-red');
|
||||
return false;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
passwordInput.addEventListener('focus', checkPassword);
|
||||
repeatPasswordInput.addEventListener('focus', checkPassword);
|
||||
|
||||
@@ -126,9 +181,9 @@ codeInput.addEventListener('blur', checkCode);
|
||||
getCodeButton.addEventListener('click', async function () {
|
||||
if (getCodeButton.disabled) return; // 防重复点击
|
||||
const email = checkEmail();
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
if (!email) return;
|
||||
const captchaAnswer = checkCaptcha();
|
||||
if (!captchaAnswer) return;
|
||||
|
||||
getCodeButton.disabled = true;
|
||||
codeTip.textContent = '验证码发送中…';
|
||||
@@ -139,7 +194,11 @@ getCodeButton.addEventListener('click', async function () {
|
||||
const res = await fetch('/register/send_code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email })
|
||||
body: JSON.stringify({
|
||||
email,
|
||||
captcha_token: captchaToken,
|
||||
captcha_answer: captchaAnswer
|
||||
})
|
||||
});
|
||||
httpStatus = res.status;
|
||||
const responseText = await res.text();
|
||||
@@ -160,6 +219,9 @@ getCodeButton.addEventListener('click', async function () {
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新
|
||||
captchaInput.value = '';
|
||||
refreshCaptcha();
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
<div>
|
||||
<label>邮箱号:</label>
|
||||
<input type="email" id="regemail" placeholder="请输入邮箱号">
|
||||
<div class="code-box">验证码:
|
||||
<span id="emlTip"></span>
|
||||
<div class="code-box">图形验证码:
|
||||
<input type="text" id="captchaAnswer" placeholder="请输入图形验证码" maxlength="4" autocomplete="off">
|
||||
<img id="captchaImg" class="captcha-img" alt="加载中…" title="点击刷新验证码">
|
||||
<span id="captchaTip"></span>
|
||||
</div>
|
||||
<div class="code-box">邮箱验证码:
|
||||
<input type="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
|
||||
<input type="button" id="getCode" value="获取验证码">
|
||||
<span id="codeTip"></span>
|
||||
</div>
|
||||
<span id="emlTip"></span>
|
||||
<label>密码:</label>
|
||||
<input type="password" id="regpsw" placeholder="密码至少6位">
|
||||
<label>验证密码:</label>
|
||||
@@ -36,6 +41,6 @@
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
|
||||
</footer>
|
||||
<script src="js/validation.js"></script>
|
||||
<script src="js/register.js?v=6"></script>
|
||||
<script src="js/register.js?v=7"></script>
|
||||
</body>
|
||||
</html>
|
||||
+72
-7
@@ -248,29 +248,94 @@ kill -HUP $(pgrep waf)
|
||||
|
||||
## 请求处理流程
|
||||
|
||||
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||
[proxy.go:81-172](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||
|
||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
||||
5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截)
|
||||
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
||||
7. defer 中确保 `r.Body` 耗尽并关闭,保证 TCP 连接复用
|
||||
|
||||
### 请求体大小限制
|
||||
|
||||
WAF 在代码层面对请求体实施 **10MB 硬限制**([proxy.go#L19-L22](internal/proxy/proxy.go#L19-L22) `maxRequestBodySize`),与 `waf.conf` 中的 `SecRequestBodyLimit 10485760` 保持一致:
|
||||
|
||||
- 使用 `io.LimitReader` + 多读 1 字节检测超限
|
||||
- 超限直接返回 `413 Request Entity Too Large`,不经过 Coraza 规则引擎
|
||||
- 读取错误(客户端断开)记录日志后直接返回,不继续处理
|
||||
- 从根本上防止超大请求体导致 OOM 崩溃
|
||||
|
||||
### HTTP 服务器超时防护
|
||||
|
||||
[main.go#L111-L122](main.go#L111) 为 `http.Server` 配置了多层超时,防御 Slowloris 等慢连接 DoS 攻击:
|
||||
|
||||
| 参数 | 值 | 作用 |
|
||||
|------|-----|------|
|
||||
| `ReadHeaderTimeout` | 10s | 读取请求头超时,**直接防御 Slowloris** |
|
||||
| `ReadTimeout` | 15s | 读取整个请求(含 body)超时,防慢 POST |
|
||||
| `WriteTimeout` | 30s | 写入响应超时 |
|
||||
| `IdleTimeout` | 120s | keep-alive 空闲连接超时 |
|
||||
| `MaxHeaderBytes` | 1MB | 请求头大小限制 |
|
||||
|
||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||
|
||||
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
|
||||
|
||||
## WebSocket 代理支持
|
||||
|
||||
WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。实现要点:
|
||||
|
||||
- `responseRecorder` 实现了 `http.Hijacker` 接口,将 Hijack 调用委托给底层 `http.ResponseWriter`
|
||||
- WebSocket 握手阶段(HTTP Upgrade 请求/响应)仍然经过 WAF 规则检测(请求头 phase:1)
|
||||
- 握手成功(101 Switching Protocols)后,连接被 Hijack,由 `httputil.ReverseProxy` 自动进行双向字节流转发
|
||||
- 升级后连接转为二进制帧协议,跳过 HTTP 响应阶段的 WAF 检测,记录 Info 日志
|
||||
- WebSocket 帧内容不做 WAF 检测(帧是二进制双向流,Coraza 规则引擎基于 HTTP 请求/响应模型)
|
||||
- `http.Server` 的超时设置不影响 Hijack 后的 WebSocket 长连接(Hijack 后连接脱离 Server 超时管理)
|
||||
- 安全响应头(CSP/HSTS 等)不注入 WebSocket 升级响应(101 响应由协议协商决定,注入非标准头可能导致客户端兼容问题)
|
||||
|
||||
配置要求:
|
||||
- 无特殊配置,WebSocket 连接(`Upgrade: websocket`)自动识别并代理
|
||||
- 需要上游服务正确处理 WebSocket 升级(如 aibox 监听 ws:// 端口)
|
||||
|
||||
## 已知限制
|
||||
|
||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
||||
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
|
||||
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
|
||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
||||
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。
|
||||
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。
|
||||
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。
|
||||
- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。
|
||||
- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。
|
||||
|
||||
## 版本日志
|
||||
|
||||
### v1.2(2026-08-01)— WebSocket 支持 + 修复
|
||||
|
||||
- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口
|
||||
- WebSocket 握手阶段仍经过 WAF 请求头检测,升级后自动双向转发
|
||||
- WebSocket 升级成功记录 Info 日志,便于追踪
|
||||
- **fix(P2-3)**: `ProcessResponseHeaders` 协议版本从硬编码 `"HTTP/1.1"` 改为 `r.Proto`,支持 HTTP/2
|
||||
- **fix(P2-4)**: responseRecorder 添加 Write 方法确保 WriteHeader 在首次 Write 前调用
|
||||
- 补充 `bufio`/`net` import 用于 Hijacker 实现
|
||||
|
||||
### v1.1(2026-08-01)— 安全加固
|
||||
|
||||
- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413
|
||||
- **P0 修复:Slowloris DoS 防护**:`http.Server` 添加 ReadHeaderTimeout(10s)/ReadTimeout(15s)/WriteTimeout(30s)/IdleTimeout(120s)/MaxHeaderBytes(1MB)
|
||||
- **P1 修复:请求体读取错误处理**:`io.ReadAll` 失败时记录日志并返回,不再传递出错 body 给 ReverseProxy;`ReadRequestBodyFrom`/`ProcessRequestBody` 错误均记录日志
|
||||
- **P2 修复:r.Body 连接复用**:defer 中耗尽并关闭 r.Body,确保 TCP 连接可复用
|
||||
- 413 响应同样注入安全响应头
|
||||
|
||||
### v1.0
|
||||
|
||||
- 初始版本:TLS 终止 + Coraza WAF 规则匹配 + 反向代理 + SIGHUP 热加载 + 异步日志推送 + 安全响应头注入
|
||||
|
||||
## 依赖
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# WAF 模块安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-01
|
||||
**审计范围**: `/waf/` 全部代码
|
||||
**代码版本**: 当前工作区版本(dev_qing 分支)
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数量 | 已修复 | 待修复 |
|
||||
|--------|--------|--------|--------|
|
||||
| **P0 (Critical)** | 3 | 2 | 1 |
|
||||
| **P1 (High)** | 5 | 1 | 4 |
|
||||
| **P2 (Medium)** | 6 | 3 | 3 |
|
||||
| **P3 (Low)** | 3 | 0 | 3 |
|
||||
|
||||
---
|
||||
|
||||
## 问题总览
|
||||
|
||||
| 优先级 | 数量 | 说明 |
|
||||
|--------|------|------|
|
||||
| **P0 (Critical)** | 3 | 可导致服务崩溃、OOM、DoS,必须立即修复 |
|
||||
| **P1 (High)** | 5 | 安全功能缺失、IP 解析错误、功能与文档不符 |
|
||||
| **P2 (Medium)** | 6 | 健壮性、可观测性、配置灵活性问题 |
|
||||
| **P3 (Low)** | 3 | 代码风格、优化建议 |
|
||||
|
||||
---
|
||||
|
||||
## P0 - Critical 严重问题
|
||||
|
||||
### ✅ P0-1: 请求体无大小限制导致 OOM 崩溃(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L114-L125)
|
||||
|
||||
**问题描述**:
|
||||
`waf.conf` 中配置了 `SecRequestBodyLimit 10485760`(10MB),但这只是 **Coraza 内部的解析限制**,并不能阻止 `io.ReadAll` 将整个请求体读入内存。攻击者可以发送超大请求体导致 OOM。
|
||||
|
||||
**修复方案**(2026-08-01):
|
||||
1. 添加常量 `maxRequestBodySize = 10 * 1024 * 1024`(10MB,与 SecRequestBodyLimit 一致)
|
||||
2. 使用 `io.LimitReader(r.Body, maxRequestBodySize+1)` 包装后再读取
|
||||
3. 多读 1 字节用于判断是否超限,超限返回 `413 Request Entity Too Large`
|
||||
4. 同时修复了 P1-3(请求体读取错误被静默忽略)和 P2-6(r.Body 未关闭耗尽)的问题
|
||||
5. 在 defer 中确保 r.Body 被关闭和耗尽,保证 TCP 连接可复用
|
||||
6. 对 ReadRequestBodyFrom 和 ProcessRequestBody 的返回错误添加了日志记录
|
||||
|
||||
---
|
||||
|
||||
### ✅ P0-2: HTTP 服务器无超时设置,存在 Slowloris 攻击风险(已修复)
|
||||
|
||||
**位置**: `main.go`(原 L110-L116)
|
||||
|
||||
**问题描述**:
|
||||
所有超时均为零值(无限等待):
|
||||
- **Slowloris 攻击**: 攻击者以极慢速度发送请求头(每隔几秒发一个字节),可以长时间占用连接,耗尽文件描述符导致服务不可用
|
||||
- **慢 POST 攻击**: 声明大的 Content-Length 但缓慢发送 body,同样耗尽连接
|
||||
- **空闲连接泄漏**: 无 IdleTimeout,已建立的 keep-alive 连接永不超时
|
||||
- **大 Header 攻击**: MaxHeaderBytes 未限制
|
||||
|
||||
这与 pymsg 模块中修复的 recv_timeout 问题是同一类 DoS 漏洞。
|
||||
|
||||
**修复方案**(2026-08-01):
|
||||
添加以下超时和大小限制:
|
||||
- `ReadTimeout: 15s` - 读取整个请求(包括 body)的超时,防止慢 POST
|
||||
- `ReadHeaderTimeout: 10s` - 读取请求头的超时,直接防御 Slowloris
|
||||
- `WriteTimeout: 30s` - 写入响应的超时
|
||||
- `IdleTimeout: 120s` - keep-alive 连接空闲超时
|
||||
- `MaxHeaderBytes: 1<<20` - 请求头最大 1MB,防止大 Header 攻击
|
||||
|
||||
---
|
||||
|
||||
### P0-3: 规则默认全部注释,WAF 启动后无任何防护
|
||||
|
||||
**位置**: `rules/local/custom.conf`
|
||||
|
||||
**问题描述**:
|
||||
所有本地自定义规则(SQL注入、XSS、路径遍历、命令注入、扫描器UA)**全部被注释掉**了。WAF 启动后:
|
||||
- 引擎正常加载
|
||||
- 流量正常转发
|
||||
- **没有任何攻击会被拦截**
|
||||
- 安全团队以为 WAF 在工作,实际上是"裸奔"状态
|
||||
|
||||
README 中没有明确警示用户需要手动启用规则。
|
||||
|
||||
**修复建议**:
|
||||
1. 至少启用一组保守的默认规则(如扫描器UA拦截、明显的SQL注入特征)
|
||||
2. 或者在启动日志中明确警告:"当前未启用任何拦截规则,WAF 处于检测-only模式"
|
||||
3. 在 README 中以醒目标注提示用户需要取消规则注释
|
||||
|
||||
---
|
||||
|
||||
## P1 - High 高危问题
|
||||
|
||||
### P1-1: IPv6 地址解析错误,X-Forwarded-For 包含多余方括号
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L40-L43` 和 `internal/proxy/proxy.go#L92-L95`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
clientIP := req.RemoteAddr
|
||||
if idx := strings.LastIndexByte(req.RemoteAddr, ':'); idx != -1 {
|
||||
clientIP = req.RemoteAddr[:idx]
|
||||
}
|
||||
```
|
||||
|
||||
使用 `LastIndexByte(':')` 无法正确处理 IPv6 地址:
|
||||
- 对于 IPv4: `1.2.3.4:54321` → 正确得到 `1.2.3.4`
|
||||
- 对于 IPv6: `[::1]:54321` → 错误得到 `[::1]`(含方括号)
|
||||
- 对于 IPv6: `2001:db8::1:54321` → 错误截断
|
||||
|
||||
Go 标准库提供了 `net.SplitHostPort` 专门处理这个问题。Gateway 的 RateLimiter 如果收到带方括号的 IP,会将 `[::1]`、`[::1]:0` 等当成不同的 IP,导致 IPv6 地址下的限流完全失效。
|
||||
|
||||
**修复建议**: 使用 `net.SplitHostPort(req.RemoteAddr)` 替代手动解析。
|
||||
|
||||
---
|
||||
|
||||
### P1-2: 响应头检测只检测不拦截,与文档描述不符
|
||||
|
||||
**位置**: `internal/proxy/proxy.go#L131-L138`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
// 响应阶段:把 upstream 的 header 喂给 WAF
|
||||
for k, vs := range rw.headers {
|
||||
for _, v := range vs {
|
||||
tx.AddResponseHeader(k, v)
|
||||
}
|
||||
}
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") // ← 返回值被丢弃!
|
||||
```
|
||||
|
||||
`ProcessResponseHeaders` 返回 `(MatchedRule, Intervention)`,命中规则时会返回非 nil 的 MatchedRule。但代码完全忽略了返回值,没有任何拦截动作。README 中声称"响应头检测已启用",但实际上只"检测"不"拦截"。
|
||||
|
||||
**修复建议**: 捕获返回值记录高优先级告警,或明确在文档中说明"响应头检测仅做日志审计,不执行拦截"。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P1-3: 请求体读取错误被静默忽略,Body 状态不一致(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L116-L124)
|
||||
|
||||
**问题描述**:
|
||||
原来的代码中 `io.ReadAll` 失败时错误被完全忽略,r.Body 状态不一致,可能导致代理转发挂起;`ReadRequestBodyFrom` 的返回错误也被忽略。
|
||||
|
||||
**修复方案**(2026-08-01,与 P0-1 一并修复):
|
||||
1. `io.ReadAll` 返回错误时,记录 Info 级别日志并直接返回(客户端断开无需继续处理)
|
||||
2. `ReadRequestBodyFrom` 和 `ProcessRequestBody` 返回错误时记录 Warning 日志
|
||||
3. 不再对出错的 body 继续调用 ProcessRequestBody
|
||||
|
||||
---
|
||||
|
||||
### P1-4: 缺少优雅关闭,SIGINT/SIGTERM 时请求被暴力中断
|
||||
|
||||
**位置**: `main.go#L97-L125`
|
||||
|
||||
**问题描述**:
|
||||
当前只处理了 SIGHUP(热加载),没有处理 SIGINT/SIGTERM。进程被终止时:
|
||||
1. 正在处理的请求被强制中断,客户端收到连接重置错误
|
||||
2. logclient 队列中的日志可能丢失(只有在代理初始化失败/TLS监听失败路径才调用 Flush)
|
||||
3. 没有给正在进行的请求完成的宽限期
|
||||
|
||||
**修复建议**: 监听 SIGINT/SIGTERM,调用 `server.Shutdown(ctx)` 优雅关闭,并在退出前 `logclient.Flush()` 刷新剩余日志。
|
||||
|
||||
---
|
||||
|
||||
### P1-5: 标准日志被完全静默,panic 堆栈和运行时错误丢失
|
||||
|
||||
**位置**: `main.go#L88`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
log.SetOutput(io.Discard) // 静默标准 log
|
||||
```
|
||||
|
||||
这一行把 Go 标准库 `log` 的所有输出都丢弃了,包括:
|
||||
- net/http 服务器捕获的 panic 堆栈(请求处理中 panic 时,服务器用 log.Printf 输出堆栈然后返回 502)
|
||||
- net/http 服务器的内部错误(如 TLS 握手错误、连接错误)
|
||||
- Coraza 库内部可能的 log 输出
|
||||
- 任何第三方库使用标准 log 输出的诊断信息
|
||||
|
||||
出现问题时无法排查根因。
|
||||
|
||||
**修复建议**: 将标准日志输出到 stderr(systemd/journalctl 可捕获),而非完全丢弃。
|
||||
|
||||
---
|
||||
|
||||
## P2 - Medium 中危问题
|
||||
|
||||
### P2-1: 规则文件读取无大小限制,超大规则文件导致 OOM
|
||||
|
||||
**位置**: `internal/engine/engine.go#L58-L80` 和 `internal/engine/engine.go#L157`
|
||||
|
||||
**问题描述**:
|
||||
`WithDirectivesFromFile` 和 `os.ReadFile` 都直接读取整个规则文件到内存。与之前 confs 模块修复的问题一样,如果攻击者或错误配置放入 GB 级的 `.conf` 文件,Reload/ListRules 时会导致 OOM。
|
||||
|
||||
**修复建议**: 加载规则文件前用 `os.Stat` 检查大小,设置合理上限(如 1 MiB)。
|
||||
|
||||
---
|
||||
|
||||
### P2-2: logclient 硬编码 db 地址,不支持环境变量配置
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L24`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
const dbAddr = "127.0.0.1:8005"
|
||||
```
|
||||
|
||||
db 地址硬编码为常量。项目约定其他服务(db、ai_review、dnfs)都支持通过环境变量配置监听地址/端口,但 WAF 的 logclient 不支持,在非标准部署场景下无法灵活配置。
|
||||
|
||||
**修复建议**: 通过环境变量读取,提供默认值(`LOG_DB_ADDR` 或 `DB_HOST`+`DB_PORT`)。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P2-3: ProcessResponseHeaders 硬编码 HTTP/1.1,协议版本错误(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L137)
|
||||
|
||||
**问题描述**:
|
||||
硬编码 `"HTTP/1.1"`,HTTP/2 请求时协议版本不正确,Coraza 规则中依赖协议版本的检测可能不准确。
|
||||
|
||||
**修复方案**(2026-08-01,与 WebSocket 支持一并修复):
|
||||
将 `tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")` 改为 `tx.ProcessResponseHeaders(rw.status, r.Proto)`,从请求中获取正确的协议版本。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P2-4: responseRecorder 不支持 http.Hijacker,WebSocket 升级无法工作(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L157-L180)
|
||||
|
||||
**问题描述**:
|
||||
`responseRecorder` 嵌入了 `http.ResponseWriter` 但只实现了 `WriteHeader` 和 `Flush`,没有实现 `http.Hijacker` 接口,WebSocket 升级(需要接管底层 TCP 连接)无法工作。
|
||||
|
||||
**修复方案**(2026-08-01):
|
||||
1. 添加 `bufio` 和 `net` import
|
||||
2. 给 `responseRecorder` 添加 `hijacked bool` 和 `statusSent bool` 字段
|
||||
3. 实现 `Hijack()` 方法,将调用委托给底层 `ResponseWriter.(http.Hijacker)`,成功后标记 `hijacked=true` 并设置 status=101
|
||||
4. 添加 `Write()` 方法确保首次 Write 前 WriteHeader 被调用(符合 http.ResponseWriter 约定)
|
||||
5. 在 `ServeHTTP` 中检测 `rw.hijacked`,WebSocket 升级后跳过响应阶段 WAF 检测,记录 Info 日志
|
||||
6. ReverseProxy(Go 1.22)内置 WebSocket 代理支持,Hijack 后自动进行双向字节流转发
|
||||
|
||||
---
|
||||
|
||||
### P2-5: logclient 在 db 不可用时错误输出到 stdout
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L223`
|
||||
|
||||
**问题描述**:
|
||||
```go
|
||||
if json.Unmarshal(resp, &r) != nil || !r.Success {
|
||||
fmt.Printf("[logclient] INSERT failed: %s\n", r.Error)
|
||||
}
|
||||
```
|
||||
|
||||
db 插入失败时用 `fmt.Printf` 输出到 stdout。stdout 输出没有时间戳、没有级别,且在 systemd 等守护进程下可能被缓冲或重定向。建议输出到 stderr。
|
||||
|
||||
---
|
||||
|
||||
### ✅ P2-6: 错误处理时未确保 r.Body 耗尽/关闭,影响连接复用(已修复)
|
||||
|
||||
**位置**: `internal/proxy/proxy.go`(原 L81-L138)
|
||||
|
||||
**问题描述**:
|
||||
ServeHTTP 中 defer 只关闭了 WAF 事务 `tx`,但没有显式关闭/耗尽 `r.Body`。在拦截提前返回路径上,TCP 连接可能无法正确复用。
|
||||
|
||||
**修复方案**(2026-08-01,与 P0-1 一并修复):
|
||||
在 defer 中添加 r.Body 耗尽和关闭逻辑:先丢弃最多 1KB 剩余数据(避免大量未读数据),然后 Close。
|
||||
|
||||
---
|
||||
|
||||
## P3 - Low 优化建议
|
||||
|
||||
### P3-1: logclient 每次发送日志新建 TCP 连接,TIME_WAIT 堆积
|
||||
|
||||
**位置**: `internal/logclient/logclient.go#L228-L233`
|
||||
|
||||
每条日志都通过 `net.DialTimeout` 建立新的短连接。在短时间内大量拦截(如攻击风暴)时,会产生大量 TIME_WAIT 状态的 TCP 连接,占用临时端口。考虑复用连接池或保持长连接。
|
||||
|
||||
---
|
||||
|
||||
### P3-2: TLS Cipher Suites 未限定,允许弱加密套件
|
||||
|
||||
**位置**: `main.go#L113-L115`
|
||||
|
||||
当前只设置了 `MinVersion: tls.VersionTLS12`,未设置 `CipherSuites` 和 `CurvePreferences`,允许 Go 默认的所有套件,包括一些较弱的套件。建议显式指定安全的密码套件列表。
|
||||
|
||||
---
|
||||
|
||||
### P3-3: CheckInput 中的 URL 编码逻辑不完整
|
||||
|
||||
**位置**: `internal/engine/engine.go#L115-L129`
|
||||
|
||||
`CheckInput`(CLI `-check` 用)中的 query 字符串编码只处理了简单的 `key=value&key=value` 格式,对于无值的 key、重复 key、复杂嵌套结构编码可能不正确。但这是 CLI 调试工具,不影响线上安全,优先级低。
|
||||
|
||||
---
|
||||
|
||||
## 正面评价(值得肯定的设计)
|
||||
|
||||
审计中也发现了一些优秀的设计实践:
|
||||
|
||||
1. ✅ **热加载 fail-safe**: Reload 失败时保留旧规则实例,服务不中断
|
||||
2. ✅ **规则加载路径确定性**: 不使用 coraza 的 Include 指令(cwd 相对路径易出错),自行扫描目录按固定顺序加载
|
||||
3. ✅ **日志客户端 fail-safe**: db 不可用时静默丢弃日志,绝不阻塞 WAF 主流程
|
||||
4. ✅ **X-Forwarded-For 可信链设计**: WAF 从 TCP 连接获取真实 IP 直接覆盖 XFF,Gateway 侧先剥离伪造头再设置可信值,防止 IP 欺骗
|
||||
5. ✅ **安全头统一在边缘注入**: ModifyResponse 用 `Header.Set()` 覆盖下游同名头,防止下游服务意外覆盖 CSP 等安全头
|
||||
6. ✅ **证书和密钥目录被 .gitignore 排除**: 不会意外提交私钥到仓库
|
||||
7. ✅ **GOTOOLCHAIN=local**: 避免国内环境 Go 自动下载 toolchain 超时
|
||||
8. ✅ **并发安全**: Engine 使用 sync.RWMutex 保护 WAF 实例热替换,读请求用 RLock 不阻塞
|
||||
9. ✅ **upstream 502 错误也注入安全头**: ErrorHandler 中也调用 setSecurityHeaders,避免错误页面泄露信息
|
||||
10. ✅ **静态编译**: CGO_ENABLED=0 编译单二进制,无系统库依赖,部署方便
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级建议
|
||||
|
||||
建议按以下顺序修复:
|
||||
|
||||
**立即修复(P0)**:
|
||||
1. P0-1: 请求体大小限制(OOM 防护)
|
||||
2. P0-2: HTTP 超时设置(Slowloris 防护)
|
||||
3. P0-3: 启用默认规则或明确警告
|
||||
|
||||
**短期修复(P1)**:
|
||||
4. P1-1: IPv6 地址解析(`net.SplitHostPort`)
|
||||
5. P1-4: 优雅关闭
|
||||
6. P1-5: 恢复标准日志输出
|
||||
7. P1-2/P1-3: 响应头处理和 body 读取错误处理
|
||||
|
||||
**中期改进(P2)**:
|
||||
8. 规则文件大小限制、logclient 配置化等
|
||||
@@ -25,6 +25,8 @@ const (
|
||||
batchSize = 50
|
||||
flushInterval = 2 * time.Second
|
||||
socketTimeout = 5 * time.Second
|
||||
// 消息大小安全上限(64 MiB),与 Python / C++ 端保持一致。
|
||||
maxMsgSize = 64 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Entry 单条日志。
|
||||
@@ -247,6 +249,9 @@ func pymsgCall(payload []byte) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
respLen := binary.BigEndian.Uint32(header)
|
||||
if respLen > maxMsgSize {
|
||||
return nil, fmt.Errorf("response too large: %d bytes (max %d)", respLen, maxMsgSize)
|
||||
}
|
||||
resp := make([]byte, respLen)
|
||||
if _, err := io.ReadFull(conn, resp); err != nil {
|
||||
return nil, err
|
||||
|
||||
+99
-10
@@ -2,9 +2,11 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
@@ -16,6 +18,10 @@ import (
|
||||
"github.com/micro_servers/waf/internal/logclient"
|
||||
)
|
||||
|
||||
// maxRequestBodySize 请求体最大大小(10MB),与 rules/waf.conf 中 SecRequestBodyLimit 保持一致。
|
||||
// 超过此限制的请求直接返回 413 Request Entity Too Large,防止 OOM。
|
||||
const maxRequestBodySize = 10 * 1024 * 1024
|
||||
|
||||
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
||||
type Proxy struct {
|
||||
rp *httputil.ReverseProxy
|
||||
@@ -84,6 +90,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
_ = tx.Close()
|
||||
// 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用
|
||||
if r.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024))
|
||||
_ = r.Body.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 解析 RemoteAddr 中的 IP 与端口
|
||||
@@ -111,14 +122,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发
|
||||
// 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err == nil {
|
||||
_, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes))
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
// 限制读取大小:多读 1 字节,若能读到说明超限
|
||||
limitReader := io.LimitReader(r.Body, maxRequestBodySize+1)
|
||||
bodyBytes, err := io.ReadAll(limitReader)
|
||||
if err != nil {
|
||||
// 客户端中途断开或读取错误,直接返回无需继续处理
|
||||
logclient.Info("waf", fmt.Sprintf("body_read_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
return
|
||||
}
|
||||
if it, _ := tx.ProcessRequestBody(); it != nil {
|
||||
if len(bodyBytes) > maxRequestBodySize {
|
||||
// 请求体超限,返回 413
|
||||
logclient.Warning("waf", fmt.Sprintf("body_too_large %s %s size=%d", r.Method, r.URL.Path, len(bodyBytes)),
|
||||
map[string]any{
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"size": len(bodyBytes),
|
||||
"limit": maxRequestBodySize,
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
setSecurityHeaders(w.Header())
|
||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||
_, _ = w.Write([]byte("request body too large\n"))
|
||||
return
|
||||
}
|
||||
// 喂给 coraza 进行请求体检测
|
||||
if _, _, err := tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)); err != nil {
|
||||
logclient.Warning("waf", fmt.Sprintf("body_parse_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
}
|
||||
// 恢复 r.Body 供 ReverseProxy 转发
|
||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
it, err := tx.ProcessRequestBody()
|
||||
if err != nil {
|
||||
logclient.Warning("waf", fmt.Sprintf("body_process_error path=%s err=%v", r.URL.Path, err),
|
||||
map[string]any{"ip": r.RemoteAddr})
|
||||
}
|
||||
if it != nil {
|
||||
p.block(w, r, it.RuleID, start)
|
||||
return
|
||||
}
|
||||
@@ -128,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rw := &responseRecorder{ResponseWriter: w, status: 200}
|
||||
p.rp.ServeHTTP(rw, r)
|
||||
|
||||
// WebSocket 升级成功后连接已被 Hijack,转为二进制帧协议,无需执行 HTTP 响应阶段检测
|
||||
if rw.hijacked {
|
||||
logclient.Info("waf", fmt.Sprintf("websocket_upgraded %s %s cost_ms=%d",
|
||||
r.Method, r.URL.Path, time.Since(start).Milliseconds()),
|
||||
map[string]any{
|
||||
"ip": r.RemoteAddr,
|
||||
"method": r.Method,
|
||||
"path": r.URL.Path,
|
||||
"cost_ms": time.Since(start).Milliseconds(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 响应阶段:把 upstream 的 header 喂给 WAF
|
||||
for k, vs := range rw.headers {
|
||||
for _, v := range vs {
|
||||
tx.AddResponseHeader(k, v)
|
||||
}
|
||||
}
|
||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
|
||||
tx.ProcessResponseHeaders(rw.status, r.Proto)
|
||||
}
|
||||
|
||||
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
|
||||
@@ -155,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start
|
||||
}
|
||||
|
||||
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
|
||||
// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。
|
||||
type responseRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
headers http.Header
|
||||
status int
|
||||
statusSent bool
|
||||
headers http.Header
|
||||
hijacked bool
|
||||
}
|
||||
|
||||
func (r *responseRecorder) WriteHeader(code int) {
|
||||
if r.hijacked {
|
||||
return
|
||||
}
|
||||
r.status = code
|
||||
r.statusSent = true
|
||||
if r.headers == nil {
|
||||
r.headers = http.Header{}
|
||||
}
|
||||
// 拷贝上游响应头
|
||||
// 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定)
|
||||
for k, vs := range r.Header() {
|
||||
r.headers[k] = vs
|
||||
}
|
||||
r.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||
if !r.statusSent {
|
||||
r.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return r.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Flush 实现 http.Flusher,支持 SSE/流式响应。
|
||||
func (r *responseRecorder) Flush() {
|
||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Hijack 实现 http.Hijacker,支持 WebSocket 升级。
|
||||
// 委托给底层 ResponseWriter 的 Hijack 方法,并标记连接已被劫持。
|
||||
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
hijacker, ok := r.ResponseWriter.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijacker")
|
||||
}
|
||||
conn, rw, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
r.hijacked = true
|
||||
// WebSocket 升级响应是 101 Switching Protocols
|
||||
r.status = http.StatusSwitchingProtocols
|
||||
r.statusSent = true
|
||||
}
|
||||
return conn, rw, err
|
||||
}
|
||||
+8
-2
@@ -22,6 +22,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -108,8 +109,13 @@ func main() {
|
||||
}()
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: p,
|
||||
Addr: cfg.Listen,
|
||||
Handler: p,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20, // 1MB
|
||||
TLSConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user