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:
qing committed 2026-08-01 06:12:13 +08:00
commit 18387586a4
92 files changed
+10238 -1140

No files matched your search

+61
View File
@@ -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/
+11
View File
@@ -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
+4
View File
@@ -1,3 +1,7 @@
gateway/shufeng.ini gateway/shufeng.ini
core/Nidus.service.shufeng core/Nidus.service.shufeng
ai_review/.env ai_review/.env
confs/__pycache__/
libhttp/test_server
*.pyc
__pycache__/
+24 -1
View File
@@ -58,4 +58,27 @@ BotOps/
与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释):
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` - 发送:`{"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 增量流式渲染、任务独立侧栏、自动重连、快捷键支持
+25 -9
View File
@@ -58,6 +58,13 @@ class BotOpsApp(App):
color: yellow; color: yellow;
padding: 0 1; padding: 0 1;
} }
.msg-reasoning {
color: $text-muted;
background: $surface;
padding: 0 1;
margin: 0 1;
text-style: italic;
}
#status { #status {
height: 1; height: 1;
} }
@@ -140,9 +147,9 @@ class BotOpsApp(App):
await self.notify_system("未连接到服务器,先 /connect", level="error") await self.notify_system("未连接到服务器,先 /connect", level="error")
return return
await self.chat.add_user(text) 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): 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") await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error")
# ── 快捷键 ──────────────────────────────────────────────────────────── # ── 快捷键 ────────────────────────────────────────────────────────────
@@ -163,21 +170,27 @@ class BotOpsApp(App):
if t == "chunk": if t == "chunk":
# 服务端 chunk 为增量片段(发送后即清零),直接拼接 # 服务端 chunk 为增量片段(发送后即清零),直接拼接
await self.chat.append_ai(content) 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": elif t == "line":
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀 # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
stripped = content.strip() stripped = content.strip()
if not stripped: if not stripped:
pass pass
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")): elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")):
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示 pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示
else: else:
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏 # 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
self.tasks.write(stripped) self.tasks.write(stripped)
elif t == "reasoning":
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
stripped = content.strip()
if stripped:
self.tasks.write(f"💭 {stripped[-200:]}")
elif t == "complete": elif t == "complete":
# complete 携带完整全文,用于对齐已显示内容 # complete 携带完整全文,用于对齐已显示内容
await self.chat.finish_ai(content) await self.chat.finish_ai(content)
@@ -217,6 +230,9 @@ class BotOpsApp(App):
elif t == "error": elif t == "error":
await self.notify_system(f"❌ {content}", level="error") await self.notify_system(f"❌ {content}", level="error")
elif t == "info": elif t == "info":
# 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了)
skip_keywords = ("Conversation started", "对话开始", "🎭")
if not any(k in content for k in skip_keywords):
await self.notify_system(f"ℹ️ {content}") await self.notify_system(f"ℹ️ {content}")
elif t == "command_result": elif t == "command_result":
await self.notify_system(content) await self.notify_system(content)
+50
View File
@@ -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 配置
- [ ] 确认命令执行白名单
+98 -21
View File
@@ -1,8 +1,17 @@
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。 """聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
服务端协议(aibox/main.py output_callback 原样透传 msg_type): 服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append - chunk 增量片段(正式回答内容),直接 append 到 AI 消息
- complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序) - 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.containers import VerticalScroll
from textual.widgets import Markdown, Static from textual.widgets import Markdown, Static
@@ -11,6 +20,8 @@ from textual.widgets import Markdown, Static
class ChatView(VerticalScroll): class ChatView(VerticalScroll):
"""可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。
不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载,
天然保证思考过程在正式回答之前。
滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长 滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长
(流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚, (流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚,
下一条新消息再重新锚定。 下一条新消息再重新锚定。
@@ -19,7 +30,10 @@ class ChatView(VerticalScroll):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self._ai_widget: Markdown | None = None self._ai_widget: Markdown | None = None
self._reasoning_widget: Static | None = None
self._ai_text = "" self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True # 初始无活跃 AI 消息
@property @property
def ai_text(self) -> str: def ai_text(self) -> str:
@@ -38,10 +52,52 @@ class ChatView(VerticalScroll):
self._anchor_bottom() self._anchor_bottom()
async def start_ai(self): async def start_ai(self):
"""预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" """开始AI回复(标记状态,不预创建空widget)。
if self._ai_widget is not None:
为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget,
此时思考widget(如果有)已经先挂载,顺序自然正确。
"""
if not self._ai_finalized:
return return
self._ai_text = "" self._ai_text = ""
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") self._ai_widget = Markdown("", classes="msg-ai")
await self.mount(self._ai_widget) await self.mount(self._ai_widget)
self._anchor_bottom() self._anchor_bottom()
@@ -50,8 +106,11 @@ class ChatView(VerticalScroll):
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" """追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
if not delta: if not delta:
return return
if self._ai_widget is None: if self._ai_finalized:
await self.start_ai() # 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据
return
# 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了)
await self._ensure_ai_widget()
self._ai_widget.append(delta) self._ai_widget.append(delta)
self._ai_text += delta self._ai_text += delta
# anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次
@@ -60,31 +119,49 @@ class ChatView(VerticalScroll):
async def finish_ai(self, full_text: str = ""): async def finish_ai(self, full_text: str = ""):
"""收尾一条 AI 消息。 """收尾一条 AI 消息。
- full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐; chunk 流式追加是内容权威来源,complete 只做收尾:
- full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。 - 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载)
- widget 存在且有流式内容:保持已渲染内容不变,仅清空引用
- widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充
- widget 不存在(未收到任何 chunk)且有 full_text:补建
- widget 不存在且无内容:正常无内容场景
""" """
if self._ai_finalized:
return
w = self._ai_widget w = self._ai_widget
self._ai_widget = None 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: if full_text:
self._ai_text = 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() 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._ai_text = ""
self._reasoning_widget = None
self._reasoning_text = ""
self._ai_finalized = True
self.call_after_refresh(self.scroll_end, animate=False) self.call_after_refresh(self.scroll_end, animate=False)
async def reset(self): async def reset(self):
"""清空聊天区(/clear)。""" """清空聊天区(/clear)。"""
self._ai_widget = None self._ai_widget = None
self._reasoning_widget = None
self._ai_text = "" self._ai_text = ""
self._reasoning_text = ""
self._ai_finalized = True
await self.remove_children() await self.remove_children()
+17 -6
View File
@@ -1,7 +1,7 @@
# micro_servers # micro_servers
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。 一套 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 ──────────────┘ ├────────────►│ └─────── 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,支持人工反馈纠偏 | | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 |
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 | | [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 | | [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 拉起但与主干微服务无调用关系 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) | | [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 登录。 - **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。
- 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。 - 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。
- 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。 - 可使用 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(树状菜单服务) ### dnfs(树状菜单服务)
@@ -140,6 +142,13 @@
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。 - 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`)。 - 验证码存于进程内存(锁保护 + 惰性过期清理),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 智能体系统) ### aibox(独立 AI 智能体系统)
- 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs 等主干微服务无调用关系。 - 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs 等主干微服务无调用关系。
@@ -185,6 +194,7 @@
| 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) | | 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) |
| 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) | | 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) | | 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
| 8010 | confs | 127.0.0.1 | 统一配置服务(pymsg/TCP,仅内部调用) |
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) | | 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) | | — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
@@ -218,7 +228,7 @@ bash _setup.deb.sh # Debian/Ubuntu
# 或 # 或
bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora 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 bash _inst.sh
``` ```
@@ -231,7 +241,7 @@ bash _run.sh # 运行
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。 部署路径约定为 `/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`。 > **注意**: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): 由 `core` 守护的子进程通过 `init.txt` 自启动(顺序很重要,waf 必须先于 gateway):
``` ```
bash /opt/micro_servers/ai_review/_run.sh
bash /opt/micro_servers/db/_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/dnfs/_run.sh
bash /opt/micro_servers/smtp/_run.sh bash /opt/micro_servers/smtp/_run.sh
bash /opt/micro_servers/static/_run.sh bash /opt/micro_servers/static/_run.sh
+2
View File
@@ -35,4 +35,6 @@ inst_module "static"
inst_module "dnfs" inst_module "dnfs"
inst_module "db" inst_module "db"
inst_module "smtp" inst_module "smtp"
inst_module "captcha"
inst_module "confs"
inst_module "core" # core last inst_module "core" # core last
+18
View File
@@ -0,0 +1,18 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
chroma/
.DS_Store
+43
View File
@@ -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"]
+75
View File
@@ -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
View File
@@ -19,6 +19,8 @@
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化 - 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词) - 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
- 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量 - 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量
- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM
- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换)
## 🚀 快速开始 ## 🚀 快速开始
@@ -32,7 +34,7 @@
```bash ```bash
# 克隆或下载代码后,安装依赖 # 克隆或下载代码后,安装依赖
pip install websockets requests beautifulsoup4 pip install -r requirements.txt
``` ```
### 配置 ### 配置
@@ -46,18 +48,18 @@ export DEEPSEEK_API_KEY="your-api-key"
3. **启动服务器**: 3. **启动服务器**:
```bash ```bash
python main.py python aibox.py
``` ```
启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。 启动后,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/ aibox/
├── main.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统) ├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统 + 任务调度器)
├── client.py # 示例 WebSocket 客户端 ├── client.py # 示例 WebSocket 客户端
├── voice_client.py # 语音客户端 ├── voice_client.py # 语音客户端
├── entry.html # 前端入口页 ├── entry.html # 前端入口页
@@ -85,12 +87,22 @@ aibox/
``` ```
~/.aibox/ ~/.aibox/
├── config.json # 配置文件 ├── config.json # 配置文件
├── prompts/ # 系统提示词目录 ├── prompts/ # 系统提示词目录(按文件名数字前缀排序加载)
│ ├── 00_IDENTITY.md # 用户身份信息 │ ├── 00_IDENTITY.md # 用户身份信息(最先加载)
│ ├── 1_*.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 灵魂定义(角色、性格、能力边界) │ ├── 99_SOUL.md # AI 灵魂定义(角色、性格、能力边界)
│ └── 100_STATUS.md # AI 状态记录 │ └── 100_STATUS.md # AI 状态记录(最后加载)
├── skills/ # 扩展技能目录(运行时从仓库 skills/ 复制或用户自建) ├── skills/ # 扩展技能目录(运行时从仓库 skills/ 复制或用户自建)
├── workspace/ # AI 专用工作目录(临时文件、脚本、数据文件默认存放位置)
├── memories.db # 记忆和任务数据库(SQLite) ├── memories.db # 记忆和任务数据库(SQLite)
├── current_conversation.json # 当前对话 ├── current_conversation.json # 当前对话
├── conversation_history.json # 对话索引 ├── conversation_history.json # 对话索引
@@ -98,6 +110,16 @@ aibox/
└── tool_results/ # 工具结果缓存 └── tool_results/ # 工具结果缓存
``` ```
## 📂 工作目录规范
为避免 AI 产生的临时文件污染系统其他目录,所有 AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/` 目录:
- **文件操作**:`write_file` 工具默认路径为 `~/.aibox/workspace/`(用户明确指定路径时除外)
- **命令执行**:`run_command` 工具默认 `work_dir` 为 `~/.aibox/workspace/`,命令产生的文件落在该专用工作区
- **用户指定路径优先**:当用户明确要求在特定路径操作时,按用户指定路径执行
- **读取不受限**:读取系统文件、项目文件时使用实际路径,不受工作目录限制
- **技能调用**:`~/.aibox/skills/` 下的技能执行不受此限制
## 🔧 可用工具 ## 🔧 可用工具
AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,在 `DeepSeekChat` 中注册): AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,在 `DeepSeekChat` 中注册):
@@ -192,15 +214,23 @@ const ws = new WebSocket('ws://localhost:8765');
| 类型 | 说明 | | 类型 | 说明 |
|-----|------| |-----|------|
| `chunk` | AI 响应的片段(流式输出) | | `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
| `complete` | AI 响应完成 | | `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) |
| `reasoning` | AI 思考过程(思考模式) | | `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) |
| `error` | 错误信息 | | `reasoning_end` | AI 思考结束信号(切换到正式回答) |
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) |
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
| `command_result` | 命令执行结果 | | `command_result` | 命令执行结果 |
| `task_status` | 任务状态更新(executing/complete/failed) | | `task_status` | 任务状态更新(executing/complete/failed) |
| `task_process` | 任务执行过程中的 AI 输出 | | `task_process` | 任务执行过程中的 AI 输出 |
| `task_error` | 任务执行错误 |
| `task_added` | 任务添加成功 |
| `dialogue_ended` | 对话结束通知 | | `dialogue_ended` | 对话结束通知 |
| `dialogue_reset` | 对话重置完成 | | `dialogue_reset` | 对话重置完成 |
| `notification` | 通知消息 |
| `connection_ack` | 连接确认 |
| `interrupt_ack` | 中断确认 |
| `info` | 系统信息 | | `info` | 系统信息 |
| `pong` | Ping 响应 | | `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` 工具更新 | | `00_IDENTITY.md` | 用户身份信息 | AI 通过 `update_identity` 工具更新 |
| `1_*.md` | 基础技能提示词 | 手动编辑,AI 通过 `reload_prompts` 重载 | | `N_*.md`(N=1~10) | 基础技能提示词(记忆搜索/保存、文件/网页/命令/任务操作、技能扩展、身份管理、语言、任务规则) | 手动编辑,AI 通过 `/reload_prompts` 重载 |
| `99_SOUL.md` | AI 灵魂定义(角色、性格) | AI 通过 `update_soul` 工具更新 | | `99_SOUL.md` | AI 灵魂定义(角色、性格、能力边界、行为准则) | AI 通过 `update_soul` 工具更新 |
| `100_STATUS.md` | AI 状态记录 | AI 通过 `save_memory_and_end_conversation` 更新 | | `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`,首次启动自动生成): 主要配置项(`~/.aibox/config.json`,首次启动自动生成):
@@ -385,7 +417,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"websocket_host": "localhost", "websocket_host": "localhost",
"websocket_port": 8765, "websocket_port": 8765,
"skills_dir": "~/.aibox/skills", "skills_dir": "~/.aibox/skills",
"prompts_dir": "~/.aibox/prompts" "prompts_dir": "~/.aibox/prompts",
"workspace_dir": "~/.aibox/workspace"
}, },
"scheduler": { "scheduler": {
"enabled": true, "enabled": true,
@@ -396,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"api": { "api": {
"deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash", "deepseek_model": "deepseek-v4-flash",
"reasoning_enabled": false,
"api_key_env": "DEEPSEEK_API_KEY", "api_key_env": "DEEPSEEK_API_KEY",
"timeout": 30, "timeout": 120,
"temperature": 0.7 "temperature": 0.7
}, },
"context": { "context": {
@@ -441,7 +475,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
"archive_dir": "archive" "archive_dir": "archive"
}, },
"tools": { "tools": {
"max_result_length": 10000, "max_result_length": 1048576,
"save_result_to_file": false, "save_result_to_file": false,
"tool_results_dir": "tool_results" "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 ```bash
python main.py [选项] python aibox.py [选项]
选项: 选项:
--verbose, -v 显示详细日志 --verbose, -v 显示详细日志
@@ -487,19 +523,19 @@ python main.py [选项]
```bash ```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: "帮我查天气"} Client -> Server: {type: "message", content: "帮我查天气"}
Server -> Client: {type: "chunk", content: "正在查"} Server -> Client: {type: "line", content: "🔧 调用工具: search_skills\n"}
Server -> Client: {type: "chunk", content: "询天气..."} Server -> Client: {type: "line", content: "🔧 调用工具: bing_search\n"}
Server -> Client: {type: "tool_call", tool: "search_skills", args: {keyword: "天气"}}
Server -> Client: {type: "chunk", content: "今日天气晴朗..."} Server -> Client: {type: "chunk", content: "今日天气晴朗..."}
Server -> Client: {type: "complete", content: "今日天气晴朗,温度25°C"} 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()` 2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
3. 可选实现 `execute_streaming()` 支持流式输出 3. 可选实现 `execute_streaming()` 支持流式输出(通过 `output_queue` 发送 `("line", content)` 和 `("complete", final_result)`)
4. 在 `DeepSeekChat` 的工具注册处注册 4. 在 `DeepSeekChat._register_tools()` 中注册
```python ```python
class MyTool(Tool): class MyTool(Tool):
@@ -593,6 +628,11 @@ class MyTool(Tool):
return f"执行结果: {param}" 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`)中添加: 在 `WebSocketHandler` 的命令处理分支(`handle_command`)中添加:
@@ -603,6 +643,10 @@ elif command == "/mycommand":
await self.send_message(websocket, {"type": "command_result", "content": result}) 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 无效怎么办? ### Q: API Key 无效怎么办?
@@ -648,15 +692,65 @@ elif command == "/mycommand":
### Q: WebSocket 连接断开? ### Q: WebSocket 连接断开?
**A:** **A:**
1. 检查服务器是否运行:`ps aux | grep main.py` 1. 检查服务器是否运行:`ps aux | grep aibox.py`
2. 确认端口未被占用:`netstat -tlnp | grep 8765` 2. 确认端口未被占用:`netstat -tlnp | grep 8765`
3. 检查防火墙设置 3. 检查防火墙设置
4. 查看服务器日志:`tail -f ~/.aibox/ai.log` 4. 查看服务器日志:`tail -f ~/.aibox/ai.log`
5. 考虑使用 `--host 0.0.0.0` 允许外部连接 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` 工具(智能体自我进化) - ✅ 新增 `update_soul` 工具(智能体自我进化)
- ✅ 新增 `create_skill` 工具(AI 自主创建技能) - ✅ 新增 `create_skill` 工具(AI 自主创建技能)
- ✅ 新增 `install_skill` 工具(技能安装指引) - ✅ 新增 `install_skill` 工具(技能安装指引)
+215
View File
@@ -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
+508 -234
View File
File diff suppressed because it is too large. Load diff
+58
View File
@@ -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)添加权限控制
- [ ] 工具结果敏感信息过滤全面验证
### 中期
- [ ] 工具调用沙箱化(容器/受限用户)
- [ ] 工具调用审计日志
+2
View File
@@ -0,0 +1,2 @@
# 数据库备份输出目录
# 容器内备份会输出到此目录,可直接复制走
+10 -42
View File
@@ -21,7 +21,6 @@ captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。
import base64 import base64
import io import io
import json
import os import os
import random import random
import secrets import secrets
@@ -34,7 +33,7 @@ from PIL import Image, ImageDraw, ImageFont
# pymsg 导入 # pymsg 导入
import sys as _sys import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) _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: class CaptchaServer:
"""基于 pymsg 的验证码服务,通过 TCP 长连接收发 JSON。""" """基于 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.addr = addr
self.port = port self.port = port
self._server = MsgServer(addr, port) self._server = RpcServer(addr, port, workers=workers)
self._server.add_route("image", self._handle_image)
# 路由表 self._server.add_route("verify", self._handle_verify)
self._routes = { self._server.add_route("health", self._handle_health)
"image": self._handle_image,
"verify": self._handle_verify,
"health": self._handle_health,
}
# ── 启动 / 停止 ────────────────────────────────────────────────── # ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None: def start(self) -> None:
"""启动服务(阻塞当前线程)。""" """启动服务(阻塞当前线程)。"""
print(f"[captcha] 监听 tcp://{self.addr}:{self.port}") print(f"[captcha] 监听 tcp://{self.addr}:{self.port}")
self._server.start(self._dispatch) self._server.start()
def stop(self) -> None: def stop(self) -> None:
"""从另一线程停止服务。""" """从另一线程停止服务。"""
self._server.stop() 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 = argparse.ArgumentParser(description="captcha — pymsg 验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8009, help="监听端口") parser.add_argument("--port", type=int, default=8009, help="监听端口")
parser.add_argument("--workers", type=int, default=4,
help="回调并发线程数(0=同步串行模式)")
args = parser.parse_args() args = parser.parse_args()
server = CaptchaServer(args.addr, args.port) server = CaptchaServer(args.addr, args.port, workers=args.workers)
try: try:
server.start() server.start()
except KeyboardInterrupt: except KeyboardInterrupt:
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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
+10
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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()
+111
View File
@@ -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)**: 实际风险极低或属于运维/部署优化项
+244
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
../pymsg
nuitka
+89
View File
@@ -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
View File
@@ -1,4 +1,5 @@
bash /opt/micro_servers/db/_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/ai_review/_run.sh
bash /opt/micro_servers/dnfs/_run.sh bash /opt/micro_servers/dnfs/_run.sh
bash /opt/micro_servers/smtp/_run.sh bash /opt/micro_servers/smtp/_run.sh
+20
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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 ```python
from log_client import logs from log_client import logs
@@ -202,11 +202,16 @@ logs.push("gateway", "WARNING", "登录失败次数超限",
客户端特性: 客户端特性:
- 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送 - 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送
- fail-safe:db 不可用时静默丢弃,绝不影响业务 - **RpcClient 高层 RPC**:直接传 dict 对象,无需手动拼 `_route` / json.dumps / json.loads
- fail-safe:db 不可用时静默丢弃,绝不影响业务;后台线程外层 try/except 捕获所有异常,任何网络/序列化错误都不会杀死日志线程(防止队列无限增长导致 OOM)
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - 缓冲批量:队列满 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,唯一在跑的实现) | | `dbv2.py` | 服务主程序(pymsg RpcServer/TCP,唯一在跑的实现) |
| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 | | `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 建表脚本 | | `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 |
| `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 | | `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 |
| `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 | | `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 |
+304
View File
@@ -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()
+81 -49
View File
@@ -29,6 +29,7 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。
import json import json
import os import os
import threading
import time import time
import traceback import traceback
from datetime import datetime, date, time as dt_time, timezone, timedelta 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 ( from sqlalchemy import (
create_engine, MetaData, Table, create_engine, MetaData, Table,
select, insert, update, delete, func, and_, select, insert, update, delete, func, and_,
event,
) )
from sqlalchemy import types as sa_types from sqlalchemy import types as sa_types
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
@@ -43,12 +45,32 @@ from sqlalchemy.exc import SQLAlchemyError
# pymsg 导入 # pymsg 导入
import sys as _sys import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) _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") 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 MAX_MESSAGE_LEN = 8192 # message 上限 8KB
@@ -76,69 +98,66 @@ def _json_default(o):
class DBServer: class DBServer:
"""基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。""" """基于 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.addr = addr
self.port = port 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.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._logs_table_cache = {}
# 路由表 # 业务表缓存:仅缓存"已完整反射"的表。
self._routes = { # 注意不能用 metadata.tables 作完成标记 —— Table(autoload_with=...)
"execute": self._handle_execute, # 会先注册进 metadata.tables(此时无列)再填充列,锁外快路径可能
} # 读到反射半途的空列表。
self._tables_cache = {}
# 表反射锁:workers>0 时多个 worker 线程可能对同一新表并发做
# check-then-act 反射(Table(..., autoload_with=...) 对已定义表名会
# 抛 InvalidRequestError),反射操作需串行化
self._reflect_lock = threading.Lock()
# ── 启动 / 停止 ────────────────────────────────────────────────── # ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None: def start(self) -> None:
"""启动服务(阻塞当前线程)。""" """启动服务(阻塞当前线程)。"""
print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}") print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}")
self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前) self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前)
self._start_cleanup_thread() self._start_cleanup_thread()
self._server.start(self._dispatch) self._server.start()
def stop(self) -> None: def stop(self) -> None:
"""从另一线程停止服务。""" """从另一线程停止服务。"""
self._server.stop() 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): def _get_table(self, table_name):
if table_name not in self.metadata.tables: # double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL
Table(table_name, self.metadata, autoload_with=self.engine) # 下安全),锁外快路径读到的要么是完整表要么是 None
return self.metadata.tables[table_name] 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): def _coerce_value(self, column, value):
@@ -222,7 +241,7 @@ class DBServer:
# 路由处理 — execute # 路由处理 — execute
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
def _handle_execute(self, data, **kw): def _handle_execute(self, data):
action = data.get("action", "select") action = data.get("action", "select")
table_name = data.get("table") table_name = data.get("table")
if not table_name: if not table_name:
@@ -316,6 +335,14 @@ class DBServer:
if "Logs" in self._logs_table_cache: if "Logs" in self._logs_table_cache:
return self._logs_table_cache["Logs"] return self._logs_table_cache["Logs"]
# 与 _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 = self.metadata.tables.get("Logs")
if table is None:
with self.engine.connect() as conn: with self.engine.connect() as conn:
conn.exec_driver_sql(""" conn.exec_driver_sql("""
CREATE TABLE IF NOT EXISTS Logs ( CREATE TABLE IF NOT EXISTS Logs (
@@ -341,8 +368,8 @@ class DBServer:
"CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)" "CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)"
) )
conn.commit() conn.commit()
table = Table("Logs", self.metadata, autoload_with=self.engine) table = Table("Logs", self.metadata, autoload_with=self.engine)
self._logs_table_cache["Logs"] = table self._logs_table_cache["Logs"] = table
return table return table
@@ -429,12 +456,17 @@ class DBServer:
if __name__ == "__main__": if __name__ == "__main__":
import argparse 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 = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--addr", default=default_addr, help="监听地址")
parser.add_argument("--port", type=int, default=8005, 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() args = parser.parse_args()
server = DBServer(args.addr, args.port) server = DBServer(args.addr, args.port, workers=args.workers)
try: try:
server.start() server.start()
except KeyboardInterrupt: except KeyboardInterrupt:
+58
View File
@@ -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}"
+82
View File
@@ -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 拒绝执行
- [ ] 错误信息脱敏
### 中期
- [ ] 表级权限控制(日志表只写不删/改)
- [ ] 管理操作审计日志
+103 -32
View File
@@ -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 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
复制到目标服务目录后,按需修改 service_name 即可:: 复制到目标服务目录后,按需修改底部全局单例的 service_name 即可:
from log_client import logs from log_client import logs
@@ -27,22 +28,36 @@ import atexit
from datetime import datetime, timezone from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) _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_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = 8005 _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush _BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒) _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: class LogClient:
"""异步批量日志推送客户端。 """异步批量日志推送客户端。
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 通过 RpcClient(pymsg 高层 RPC 封装)逐条 INSERT 到 dbv2 的 Logs 表。
service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。 service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。
""" """
@@ -52,9 +67,10 @@ class LogClient:
self._service_name = service_name self._service_name = service_name
self._batch_size = batch_size self._batch_size = batch_size
self._flush_interval = flush_interval self._flush_interval = flush_interval
self._queue = queue.Queue() self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
self._stop = threading.Event() self._stop = threading.Event()
self._flush_signal = threading.Event() self._flush_signal = threading.Event()
self._flush_done = threading.Event()
self._thread = threading.Thread(target=self._flush_loop, self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client') daemon=True, name='log-client')
self._thread.start() self._thread.start()
@@ -63,14 +79,30 @@ class LogClient:
# ── 公开接口 ──────────────────────────────────────────────────────── # ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None): def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。""" """推送一条日志(非阻塞,立即返回)。
self._queue.put({
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
"""
entry = {
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
'service': service, 'service': service,
'level': level, 'level': level,
'message': message, 'message': message,
'trace_id': trace_id, 'trace_id': trace_id,
'extra': extra, '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): def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw) self.push(service, 'DEBUG', message, **kw)
@@ -89,8 +121,15 @@ class LogClient:
def flush(self): def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_done.clear()
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch 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 = [] batch = []
while True: while True:
try: try:
@@ -102,18 +141,22 @@ class LogClient:
break break
if batch: if batch:
self._send(batch) self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ──────────────────────────────────────────────────────── # ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self): def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
都不会死亡,避免日志队列无限增长导致 OOM。
"""
batch = [] batch = []
last_flush = time.monotonic() last_flush = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
try:
timeout = self._flush_interval - (time.monotonic() - last_flush) timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.0, min(timeout, 0.5)) chk_timeout = max(0.1, min(timeout, 0.5))
try: try:
item = self._queue.get(timeout=chk_timeout) item = self._queue.get(timeout=chk_timeout)
batch.append(item) batch.append(item)
@@ -131,44 +174,70 @@ class LogClient:
(batch and time.monotonic() - last_flush >= self._flush_interval) (batch and time.monotonic() - last_flush >= self._flush_interval)
) )
if should_flush and batch: if should_flush and batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
# _send 内部已有细粒度异常处理,此处是最终安全网
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
file=_sys.stderr)
batch = [] batch = []
last_flush = time.monotonic() 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 剩余 # 退出前 flush 剩余
if batch: if batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
self._flush_done.set()
def _send(self, batch): def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" """通过 pymsg RpcClient(execute 路由)批量发送到 dbv2 Logs 表。
now = datetime.now(timezone.utc).isoformat()
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
不抛异常给调用方。
"""
try: try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") 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: for entry in batch:
extra_val = entry.get("extra") 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 = { payload = {
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Logs", "table": "Logs",
"values": { "values": {
"ts": now, "ts": entry.get("ts", _now_iso()),
"service": entry.get("service"), "service": entry.get("service"),
"level": entry.get("level"), "level": entry.get("level"),
"message": entry.get("message"), "message": entry.get("message"),
"trace_id": entry.get("trace_id"), "trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False) "extra": extra_json,
if extra_val is not None else None,
}, },
} }
resp = client.call(json.dumps(payload), timeout=_TIMEOUT) try:
resp_obj = json.loads(resp) client.call("execute", payload, timeout=_TIMEOUT)
if not resp_obj.get("success"): except RpcError as e:
import sys # 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
file=sys.stderr)
except Exception as e: except Exception as e:
import sys # 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=sys.stderr) print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self): def _shutdown(self):
"""进程退出时 flush 剩余日志。""" """进程退出时 flush 剩余日志。"""
@@ -180,5 +249,7 @@ class LogClient:
# 各服务复制时,把 "example" 改为自己的服务名即可。 # 各服务复制时,把 "example" 改为自己的服务名即可。
# 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs") # 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs")
# 例如 smtp/log_client.py → logs = LogClient(service_name="smtp") # 例如 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")
+18
View File
@@ -0,0 +1,18 @@
.env
.env/
__pycache__/
*.pyc
*.pyo
*.pyd
.Python
*.so
.git
.gitignore
*.md
!README.md
.envrc
.venv/
venv/
ENV/
*.bin
.DS_Store
+48
View File
@@ -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
View File
@@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。 监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。
## 架构 ## 架构
``` ```
@@ -44,9 +46,26 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
- payload 中取 `user` 字段作为操作者,写入 `owner`。 - payload 中取 `user` 字段作为操作者,写入 `owner`。
- `delete_note` 与 `update_note` 校验所有权: - `delete_note` 与 `update_note` 校验所有权:
- 节点 `owner` 必须等于当前 `user`,否则 `403`; - 节点 `owner` 必须等于当前 `user`,否则 `403`;
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。 - **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。
- `get_notes` 不需要鉴权。 - `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`。 所有接口均为 `POST`,`Content-Type: application/json`。
@@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。 仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。
**Request** **Request**
@@ -196,15 +215,41 @@ curl -X POST http://localhost:8006/update_note \
## 配置 ## 配置
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py): dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致):
| 常量 | 值 | 说明 | **加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级)
|------|----|------|
| `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 服务 |
调用超时均为 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
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
''' '''
Dendrachy Network File-System (dnfs) Dendrachy Network File-System (dnfs)
@@ -18,11 +14,13 @@ Dendrachy Network File-System (dnfs)
from flask import Flask, request, jsonify, render_template from flask import Flask, request, jsonify, render_template
from flask_cors import CORS from flask_cors import CORS
#from flask_sqlalchemy import SQLAlchemy #from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信
from waitress import serve from waitress import serve
import os import os
import sys
import json import json
import base64 import base64
import configparser
import urllib.request import urllib.request
import urllib.error import urllib.error
from typing import Dict, Any from typing import Dict, Any
@@ -30,39 +28,112 @@ from security_middleware import apply_security_headers
from log_client import logs from log_client import logs
# pymsg 导入 # pymsg 导入
import sys as _sys # 我们有在依赖列表中加入pymsg
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) # 这个可以省略也许
from pymsg import MsgClient # noqa: E402 sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import RpcClient, RpcError # noqa: E402
_DBV2_ADDR = '127.0.0.1' # ==================== 配置加载(三层 fail-safe) ====================
_DBV2_PORT = 8005 # 加载顺序:内置默认值 → 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 # 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。 # 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
# 旧 Token 无此字段,按非管理员处理(安全方向)。 # 旧 Token 无此字段,按非管理员处理(安全方向)。
def _call_dbv2(payload: dict, timeout: int = 10) -> dict: _rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
"""通过 pymsg 短连接调用 dbv2 数据库服务。
自动注入 ``_route: "execute"``。
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: try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs") data = _rpc.call("execute", payload, timeout=timeout)
resp_text = client.call(json.dumps(payload), timeout=timeout) return {"success": True, "data": data}
return json.loads(resp_text) except RpcError as e:
return {"success": False, "error": str(e)}
except Exception as e: except Exception as e:
raise ConnectionError( raise ConnectionError(
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}" f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
) from None ) from None
REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review' # AI审核服务地址
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: def review_content(content: str, timeout: int = None) -> Dict[str, Any]:
""" """
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。 调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
Args: Args:
content: 待审核的文本内容,不可为空。 content: 待审核的文本内容,不可为空。
timeout: 请求超时时间(秒),默认 10 秒。 timeout: 请求超时时间(秒),默认使用配置值。
Returns: Returns:
字典,包含接口返回的字段,例如: 字典,包含接口返回的字段,例如:
@@ -72,7 +143,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
- 请求参数错误时:{"error": "content is required"} 等 - 请求参数错误时:{"error": "content is required"} 等
- 网络或异常时:{"error": "request_failed", "detail": "..."} - 网络或异常时:{"error": "request_failed", "detail": "..."}
""" """
url = REVIEW_SERVER_URL if timeout is None:
timeout = _REVIEW_TIMEOUT
url = _REVIEW_SERVER_URL
payload = {"content": content} payload = {"content": content}
json_data = json.dumps(payload).encode("utf-8") 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: except Exception as e:
raise 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__) app = Flask(__name__)
apply_security_headers(app) apply_security_headers(app)
CORS(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): def decode_token(token: str):
idx = token.find('_') idx = token.find('_')
@@ -155,32 +293,26 @@ def decode_token(token: str):
@app.route('/add_note', methods=['POST']) @app.route('/add_note', methods=['POST'])
@_handle_exp
def add_note(): def add_note():
# 获取请求数据
data = request.get_json() data = request.get_json()
pid: int = data.get('pid') pid: int = data.get('pid')
title: str = data.get('title') title: str = data.get('title')
content: str = data.get('content') content: str = data.get('content')
if pid is None or not title or not content: if pid is None or not title or not content:
raise DnfsExp("非法参数", 400)
logs.warning('dnfs', 'add_note 参数缺失', extra={'ip': request.remote_addr})
return jsonify({"error": "Pid, title and content are required"}), 400
info = None info = None
auth: str = request.headers.get('Authorization', '') auth: str = request.headers.get('Authorization', '')
if auth.startswith('Bearer '): if auth.startswith('Bearer '):
token: str = auth[7:] token: str = auth[7:]
info = decode_token(token) info = decode_token(token)
if info is None: if info is None:
logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr}) raise DnfsExp("未授权访问,从"+request.remote_addr, 401)
return jsonify({"error": "Warning: no Authorization"}), 401
# 父节点是否存在
if pid != 0: if pid != 0:
select_payload = { select_payload = {
@@ -193,19 +325,22 @@ def add_note():
notes = _call_dbv2(select_payload) notes = _call_dbv2(select_payload)
# 不存在的父节点
if len( notes['data']) == 0: if len( notes['data']) == 0:
logs.warning('dnfs', f'add_note 父节点不存在 pid={pid}', extra={'user': info['user']}) raise DnfsExp('父节点不存在', 400)
return jsonify({'error': 'the new node need a parent'}), 400
# 内容审查 # 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
review = review_content(title + '\n' + content, 10) sp = _parse_sp(content)
if True != review: if sp is not None:
#return jsonify({'error': 'review failed', 'data': review}), 400 if not info.get('is_admin'):
logs.warning('dnfs', f'add_note 内容审核未通过 title={title}', extra={'user': info['user']}) raise DnfsExp('特殊节点仅管理员可发布,用户'+info.get('user')+',从'+request.remote_addr, 403)
return jsonify({'error': 'review failed'}), 400 _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 = { insert_payload = {
'action': 'insert', 'action': 'insert',
'table': 'Notes', 'table': 'Notes',
@@ -218,20 +353,17 @@ def add_note():
} }
result = _call_dbv2(insert_payload) result = _call_dbv2(insert_payload)
success = result['success'] success = result['success']
if success is True: if success is True:
logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']}) logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']})
return jsonify({'success': success}), 200 return jsonify({'success': success}), 200
else: else:
logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']}) raise DnfsExp('插入节点失败,用户'+info.get('user')+',从'+request.remote_addr, 400)
return jsonify({'success': success}), 400
# {“pid”: 0} # {“pid”: 0}
@_handle_exp
@app.route('/get_notes', methods=["POST"]) @app.route('/get_notes', methods=["POST"])
def get_notes(): def get_notes():
data = request.get_json() data = request.get_json()
@@ -273,36 +405,6 @@ def get_notes():
return jsonify(result), 200 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} # eg: {"id": 1}
@app.route('/delete_note', methods=['POST']) @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']}) logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403 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: if True != review:
#return jsonify({'error': 'review failed', 'data': review}), 400 #return jsonify({'error': 'review failed', 'data': review}), 400
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']}) 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') # return render_template('index.html')
ADDR='127.0.0.1'
PORT=8006
if __name__ == "__main__": if __name__ == "__main__":
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}') logs.info('dnfs', f'服务启动,监听 http://{_ADDR}:{_PORT}(db={_DBV2_ADDR}:{_DBV2_PORT}, ai_review={_REVIEW_SERVER_URL})')
print(f'Listen at http://{ADDR}:{PORT}') print(f'Listen at http://{_ADDR}:{_PORT}')
#app.run(host="127.0.0.1", port=8006) serve(app, host=_ADDR, port=_PORT)
serve(app, host='127.0.0.1', port=8006)
+85
View File
@@ -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 验证测试
- [ ] 节点操作速率限制
+100 -30
View File
@@ -1,11 +1,11 @@
""" """统一日志客户端 — 各服务共用(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 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
用法: 用法:
from log_client import logs from log_client import logs
@@ -26,28 +26,45 @@ import atexit
from datetime import datetime, timezone from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) _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_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = 8005 _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush _BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒) _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: class LogClient:
"""异步批量日志推送客户端。""" """异步批量日志推送客户端。"""
def __init__(self, batch_size=_BATCH_SIZE, def __init__(self, service_name="log",
batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL): flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name
self._batch_size = batch_size self._batch_size = batch_size
self._flush_interval = flush_interval self._flush_interval = flush_interval
self._queue = queue.Queue() self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
self._stop = threading.Event() self._stop = threading.Event()
self._flush_signal = threading.Event() self._flush_signal = threading.Event()
self._flush_done = threading.Event()
self._thread = threading.Thread(target=self._flush_loop, self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client') daemon=True, name='log-client')
self._thread.start() self._thread.start()
@@ -56,14 +73,30 @@ class LogClient:
# ── 公开接口 ──────────────────────────────────────────────────────── # ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None): def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。""" """推送一条日志(非阻塞,立即返回)。
self._queue.put({
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
"""
entry = {
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
'service': service, 'service': service,
'level': level, 'level': level,
'message': message, 'message': message,
'trace_id': trace_id, 'trace_id': trace_id,
'extra': extra, '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): def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw) self.push(service, 'DEBUG', message, **kw)
@@ -82,8 +115,15 @@ class LogClient:
def flush(self): def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_done.clear()
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch 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 = [] batch = []
while True: while True:
try: try:
@@ -95,16 +135,20 @@ class LogClient:
break break
if batch: if batch:
self._send(batch) self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ──────────────────────────────────────────────────────── # ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self): def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
都不会死亡,避免日志队列无限增长导致 OOM。
"""
batch = [] batch = []
last_flush = time.monotonic() last_flush = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
try:
timeout = self._flush_interval - (time.monotonic() - last_flush) timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.1, min(timeout, 0.5)) chk_timeout = max(0.1, min(timeout, 0.5))
try: try:
@@ -124,44 +168,70 @@ class LogClient:
(batch and time.monotonic() - last_flush >= self._flush_interval) (batch and time.monotonic() - last_flush >= self._flush_interval)
) )
if should_flush and batch: if should_flush and batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
# _send 内部已有细粒度异常处理,此处是最终安全网
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
file=_sys.stderr)
batch = [] batch = []
last_flush = time.monotonic() 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 剩余 # 退出前 flush 剩余
if batch: if batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
self._flush_done.set()
def _send(self, batch): def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
now = datetime.now(timezone.utc).isoformat()
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
不抛异常给调用方。
"""
try: try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log") 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: for entry in batch:
extra_val = entry.get("extra") 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 = { payload = {
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Logs", "table": "Logs",
"values": { "values": {
"ts": now, "ts": entry.get("ts", _now_iso()),
"service": entry.get("service"), "service": entry.get("service"),
"level": entry.get("level"), "level": entry.get("level"),
"message": entry.get("message"), "message": entry.get("message"),
"trace_id": entry.get("trace_id"), "trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False) "extra": extra_json,
if extra_val is not None else None,
}, },
} }
resp = client.call(json.dumps(payload), timeout=_TIMEOUT) try:
resp_obj = json.loads(resp) client.call("execute", payload, timeout=_TIMEOUT)
if not resp_obj.get("success"): except RpcError as e:
import sys # 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
file=sys.stderr)
except Exception as e: except Exception as e:
import sys # 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=sys.stderr) print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self): def _shutdown(self):
"""进程退出时 flush 剩余日志。""" """进程退出时 flush 剩余日志。"""
+109
View File
@@ -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
Executable
+386
View File
@@ -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
+78
View File
@@ -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 镜像安全扫描
+68
View File
@@ -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 解析器为成熟第三方库
+427
View File
@@ -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
View File
@@ -3,7 +3,7 @@ APP_NAME := gateway
INSTL_DIR := /usr/local/bin INSTL_DIR := /usr/local/bin
CPP := g++ CPP := g++
INCS := -I inc/ 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 LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
BUILD_DIR = build BUILD_DIR = build
+58 -6
View File
@@ -7,10 +7,12 @@ UniServe Framework 的应用层网关,C++20 实现。
## 职责 ## 职责
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 - **账号鉴权**:`/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 分钟过期,一次性使用)。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 - **邮箱验证码**:`/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 头,允许跨域。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
## 安全特性 ## 安全特性
@@ -86,11 +88,24 @@ UniServe Framework 的应用层网关,C++20 实现。
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。
**请求体**(CAPTCHA 必填,防邮件轰炸):
```json ```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 *`(反向代理) ### `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`。 返回 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 不再自动建表): 用户数据存于 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 小时 # Token 有效期(秒),默认 3600 = 1 小时
keep_time = 3600 keep_time = 3600
[Admin]
# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true
# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断
users = admin@example.com
[CORS] [CORS]
# 允许跨域的 Origin 白名单(分号分隔) # 允许跨域的 Origin 白名单(分号分隔)
# 仅命中的 Origin 才会回填 Access-Control-Allow-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] [Database]
path = sqlite3.db 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.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。
- `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 - `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。
- `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 - `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。
- `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。 - `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。
- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。
- `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。 - `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.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.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.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
+86
View File
@@ -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 场景)
- [ ] 管理操作审计日志
+9
View File
@@ -32,3 +32,12 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht
[Database] [Database]
path = sqlite3.db 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
View File
@@ -1,20 +1,23 @@
#pragma once #pragma once
#include <chrono> #include <chrono>
#include <string> #include <string>
#include <format>
#include <optional> #include <optional>
#include <sstream> #include <sstream>
#include <iomanip>
#include <ctime>
namespace qing { namespace qing {
class LocalTime { class LocalTime {
public: public:
// 获取当前本地时间字符串 // 获取当前本地时间字符串
static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") { static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") {
using namespace std::chrono; auto now = std::chrono::system_clock::now();
auto now = system_clock::now(); std::time_t t = std::chrono::system_clock::to_time_t(now);
auto zone = current_zone(); std::tm tm_buf{};
zoned_time zt{zone, now}; localtime_r(&t, &tm_buf);
return std::vformat("{:" + format + "}", std::make_format_args(zt)); char buf[128];
std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf);
return std::string(buf);
} }
// 解析时间字符串为 system_clock::time_point(本地时间 → UTC) // 解析时间字符串为 system_clock::time_point(本地时间 → UTC)
@@ -22,29 +25,29 @@ public:
const std::string& str, const std::string& str,
const std::string& format = "%Y-%m-%d %H:%M:%S" const std::string& format = "%Y-%m-%d %H:%M:%S"
) { ) {
using namespace std::chrono; std::tm tm_buf{};
std::istringstream iss{str}; std::istringstream iss{str};
local_time<seconds> lt; iss >> std::get_time(&tm_buf, format.c_str());
iss >> std::chrono::parse(format, lt); // ✅ 修复
if (iss.fail()) { if (iss.fail()) {
return std::nullopt; return std::nullopt;
} }
auto zone = current_zone(); // mktime 将本地时间转换为 time_t(UTC)
zoned_time zt{zone, lt}; std::time_t t = std::mktime(&tm_buf);
return zt.get_sys_time(); 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) { static bool isExpired(const std::chrono::system_clock::time_point& tp) {
using namespace std::chrono; return std::chrono::system_clock::now() > tp;
return system_clock::now() > tp;
} }
template <typename Rep, typename Period> template <typename Rep, typename Period>
static bool isExpired(const std::chrono::system_clock::time_point& tp, static bool isExpired(const std::chrono::system_clock::time_point& tp,
const std::chrono::duration<Rep, Period>& gracePeriod) { const std::chrono::duration<Rep, Period>& gracePeriod) {
using namespace std::chrono; return std::chrono::system_clock::now() > tp + gracePeriod;
return system_clock::now() > tp + gracePeriod;
} }
static bool isExpired(const std::string& timeStr, static bool isExpired(const std::string& timeStr,
+285 -3
View File
@@ -28,7 +28,7 @@ using namespace qing;
namespace json = boost::json; namespace json = boost::json;
namespace po = boost::program_options; namespace po = boost::program_options;
constexpr const char *_VER = "0.5.3"; constexpr const char *_VER = "0.6.0";
////////////////////////////////// //////////////////////////////////
#include <sstream> #include <sstream>
@@ -306,6 +306,11 @@ http::response<http::string_body> handleRefreshToken(
const http::request<http::string_body>& req, const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params); 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 图片 */ /* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
http::response<http::string_body> handleCaptchaImage( http::response<http::string_body> handleCaptchaImage(
const http::request<http::string_body>& req, 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> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin 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 */ /* instance items */
static std::unique_ptr<HttpServer> _SERVE; static std::unique_ptr<HttpServer> _SERVE;
static std::unique_ptr<MyRSA::Generator> _KEYGEN; static std::unique_ptr<MyRSA::Generator> _KEYGEN;
//static std::unique_ptr<Sqlite3> _SQLDB; //static std::unique_ptr<Sqlite3> _SQLDB;
static RateLimiter _LOGIN_RATE; static RateLimiter _LOGIN_RATE;
static RateLimiter _REGISTER_RATE; static RateLimiter _REGISTER_RATE;
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 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 下游调用超时(秒): /* pymsg 下游调用超时(秒):
* captcha / smtp verify_email 均为本机内存操作,5s 足够; * 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); _TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';'); _CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';'); _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路由 */ /* 注册http路由 */
void registerRoutes(HttpServer &server) { void registerRoutes(HttpServer &server) {
@@ -635,6 +749,11 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); }); [](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("*") 通配符之前注册) */ /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> { server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params, return wrapperHttp(req, params,
@@ -647,9 +766,24 @@ void registerRoutes(HttpServer &server) {
}); });
server.post("*", [](const auto& req, const auto& params) { 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视图包装器 /* Http视图包装器
@@ -946,6 +1080,132 @@ http::response<http::string_body> handleLogout(
return res; 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:POST /refresh_token
* *
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。 * 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
@@ -1284,6 +1544,28 @@ http::response<http::string_body> handleSendEmailCode(
if (!std::regex_match(email, email_regex)) if (!std::regex_match(email, email_regex))
THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); 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)发送验证码 // 调 smtp 服务(pymsg/TCP :8008)发送验证码
bool sent = false; bool sent = false;
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
+18 -16
View File
@@ -1,49 +1,51 @@
VERSION := 0.0.1 VERSION := 0.0.1
FRAMEWORK_NAME := us FRAMEWORK_NAME := us
LIB_NAME := libus_http LIB_NAME := libus_http
LIBC_NAME := libus_httpclient
INSTL_TDIR := /lib INSTL_TDIR := /lib
INCLU_TDIR := /usr/include INCLU_TDIR := /usr/include
INCLU_SDIR := inc INCLU_SDIR := inc
CPP := g++ CPP := g++
INCS := -I inc INCS := -I inc -I/opt/homebrew/include
CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror CPPFLAGS := --std=c++20 -O2 -Wall -Werror
LIBS := SERVER_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lpthread
CLIENT_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lcurl -lpthread
BUILD_TDIR := build BUILD_TDIR := build
$(shell mkdir -p $(BUILD_TDIR)) $(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 # ---- Server library (HttpServer, no curl) ----
$(CPP) -shared $^ $(LIBS) -o $@ $(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o
$(CPP) -shared $^ $(SERVER_LIBS) -o $@
$(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp $(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
$(BUILD_TDIR)/WebSocketServer.o: src/WebSocketServer.cpp # ---- Client library (HttpClient, depends on curl + beast websocket) ----
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ $(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 $@ $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
clear: clear:
@rm -rvf $(BUILD_TDIR) @rm -rvf $(BUILD_TDIR)
install: install: all
@echo "install UniServe_libth..." @echo "install UniServe_libhttp..."
@mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME) @mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME)
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp $(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)/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)/$(LIB_NAME).so $(INSTL_TDIR)/
@cp -vf $(BUILD_TDIR)/$(LIBC_NAME).so $(INSTL_TDIR)/
@echo "done." @echo "done."
uninstall: uninstall:
@echo "uninstall UniServe_libth..." @echo "uninstall UniServe_libhttp..."
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpClient.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)/$(LIB_NAME).so
@rm -vf $(INSTL_TDIR)/$(LIBC_NAME).so
@echo "done." @echo "done."
+266
View File
@@ -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.
+66 -159
View File
@@ -2,10 +2,27 @@
#include <string> #include <string>
#include <functional> #include <functional>
#include <map> #include <map>
#include <memory>
#include <stdexcept>
#include <curl/curl.h> #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 pmc::net {
namespace beast = boost::beast;
namespace asio = boost::asio;
namespace websocket = beast::websocket;
using tcp = asio::ip::tcp;
enum class HttpMethod { enum class HttpMethod {
GET, GET,
POST, POST,
@@ -18,99 +35,42 @@ enum class HttpMethod {
class HttpClient { class HttpClient {
public: public:
HttpClient() { HttpClient();
curl_ = curl_easy_init(); ~HttpClient();
if (!curl_) {
throw std::runtime_error("Failed to initialize CURL");
}
}
~HttpClient() { HttpClient(const HttpClient&) = delete;
if (curl_) { HttpClient& operator=(const HttpClient&) = delete;
curl_easy_cleanup(curl_);
}
if (headers_) {
curl_slist_free_all(headers_);
}
}
void set_header(const std::string& header) { void set_header(const std::string& header);
headers_ = curl_slist_append(headers_, header.c_str()); void clear_headers();
}
void clear_headers() { const std::map<std::string, std::string>& get_response_headers() const;
if (headers_) { long get_response_code() const;
curl_slist_free_all(headers_);
headers_ = nullptr;
}
}
const std::map<std::string, std::string>& get_response_headers() const { // ===== HTTP Methods (Synchronous, libcurl-based) =====
return response_headers_;
}
long get_response_code() const {
return response_code_;
}
// ===== 修复:直接转发,不按行处理 =====
void request_stream_sync( void request_stream_sync(
HttpMethod method, HttpMethod method,
const std::string& url, const std::string& url,
const std::string& data, const std::string& data,
std::function<void(const std::string&)> callback std::function<void(const std::string&)> callback);
) {
if (!curl_) {
throw std::runtime_error("CURL not initialized");
}
response_headers_.clear(); void get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
response_code_ = 0; 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);
curl_easy_reset(curl_); // ===== WebSocket Methods (Synchronous, Boost.Beast-based) =====
curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); bool ws_connect(const std::string& url, int timeout_ms = 5000);
set_http_method(method, data); bool ws_connect(const std::string& host, const std::string& port,
curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers_); const std::string& path = "/", bool ssl = false, int timeout_ms = 5000);
curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, header_callback); bool ws_send(const std::string& message);
curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this); bool ws_send_binary(const void* data, size_t size);
std::string ws_recv(int timeout_ms = 0);
// ===== 关键修复:直接收集原始数据,不按行处理 ===== bool ws_is_connected() const;
std::string full_response; void ws_close();
curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback); std::string ws_get_host() const;
curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response); std::string ws_get_port() const;
std::string ws_get_path() const;
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);
}
private: private:
CURL* curl_ = nullptr; CURL* curl_ = nullptr;
@@ -118,83 +78,30 @@ private:
std::map<std::string, std::string> response_headers_; std::map<std::string, std::string> response_headers_;
long response_code_ = 0; long response_code_ = 0;
void set_http_method(HttpMethod method, const std::string& data) { struct WsState {
switch (method) { asio::io_context ioc;
case HttpMethod::GET: std::unique_ptr<beast::tcp_stream> tcp_stream;
curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L); std::unique_ptr<asio::ssl::context> ssl_ctx;
break; 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_;
case HttpMethod::POST: void set_http_method(HttpMethod method, const std::string& data);
curl_easy_setopt(curl_, CURLOPT_POST, 1L); static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata);
if (!data.empty()) { static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata);
curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str()); static void parse_ws_url(const std::string& url_part, std::string& host, std::string& port,
curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); std::string& path, const char* default_port);
}
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;
}
}; };
// Backward compatibility aliases
using WebSocketClientBase = HttpClient;
using WebSocketClient = HttpClient;
} // namespace pmc::net } // namespace pmc::net
+63 -3
View File
@@ -4,6 +4,7 @@
#include <boost/asio.hpp> #include <boost/asio.hpp>
#include <boost/beast.hpp> #include <boost/beast.hpp>
#include <boost/beast/http.hpp> #include <boost/beast/http.hpp>
#include <boost/beast/websocket.hpp>
#include <boost/beast/version.hpp> #include <boost/beast/version.hpp>
#include <boost/asio/ip/tcp.hpp> #include <boost/asio/ip/tcp.hpp>
#include <boost/asio/ssl.hpp> #include <boost/asio/ssl.hpp>
@@ -19,12 +20,14 @@
#include <atomic> #include <atomic>
#include <mutex> #include <mutex>
#include <regex> #include <regex>
#include <deque>
namespace pmc { namespace pmc {
namespace net { namespace net {
namespace beast = boost::beast; namespace beast = boost::beast;
namespace http = beast::http; namespace http = beast::http;
namespace websocket = beast::websocket;
namespace asio = boost::asio; namespace asio = boost::asio;
using tcp = boost::asio::ip::tcp; using tcp = boost::asio::ip::tcp;
@@ -37,6 +40,24 @@ using MiddlewareHandler = std::function<bool(
http::response<http::string_body>&, http::response<http::string_body>&,
const std::unordered_map<std::string, std::string>&)>; 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 { struct RouteEntry {
std::string path; std::string path;
std::regex pattern; std::regex pattern;
@@ -44,6 +65,16 @@ struct RouteEntry {
bool is_wildcard; bool is_wildcard;
}; };
struct WsRouteEntry {
std::string path;
WebSocketMessageHandler handler;
};
struct WsProxyRouteEntry {
std::string path;
WsProxyHandshakeHandler handshake;
};
class HttpServer { class HttpServer {
public: public:
explicit HttpServer(const std::string& address, unsigned short port, explicit HttpServer(const std::string& address, unsigned short port,
@@ -60,10 +91,20 @@ public:
void post(const std::string& path, HttpRequestHandler handler); void post(const std::string& path, HttpRequestHandler handler);
void put(const std::string& path, HttpRequestHandler handler); void put(const std::string& path, HttpRequestHandler handler);
void del(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 use(MiddlewareHandler middleware);
void setStaticDirectory(const std::string& path); 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; bool isRunning() const;
unsigned short getPort() const; unsigned short getPort() const;
std::string getAddress() const; std::string getAddress() const;
@@ -103,10 +144,19 @@ private:
std::vector<RouteEntry> post_handlers_; std::vector<RouteEntry> post_handlers_;
std::vector<RouteEntry> put_handlers_; std::vector<RouteEntry> put_handlers_;
std::vector<RouteEntry> delete_handlers_; std::vector<RouteEntry> delete_handlers_;
std::vector<RouteEntry> options_handlers_; // 新增 OPTIONS 路由 std::vector<RouteEntry> options_handlers_;
std::vector<MiddlewareHandler> middlewares_; std::vector<MiddlewareHandler> middlewares_;
std::string static_directory_; 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::vector<std::weak_ptr<Session>> sessions_;
std::mutex sessions_mutex_; std::mutex sessions_mutex_;
@@ -115,12 +165,22 @@ private:
bool matchPath(const std::string& path, const std::regex& pattern); bool matchPath(const std::string& path, const std::regex& pattern);
http::response<http::string_body> handleRequest(const http::request<http::string_body>& req); http::response<http::string_body> handleRequest(const http::request<http::string_body>& req);
HttpRequestHandler findRoute(const std::string& method, const std::string& path); 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 addSession(const std::shared_ptr<Session>& session);
void removeSession(const Session* session); void removeSession(const Session* session);
void closeAllSessions(); void closeAllSessions();
void initSSLContext(); void initSSLContext();
friend class Session;
}; };
} // namespace net } // namespace net
+357
View File
@@ -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
File diff suppressed because it is too large. Load diff
+145
View File
@@ -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;
}
+83 -47
View File
@@ -141,6 +141,8 @@ server.start(on_message) # 阻塞当前线程
|------|------| |------|------|
| `addr` | 监听地址 | | `addr` | 监听地址 |
| `port` | 监听端口 | | `port` | 监听端口 |
| `workers` | 工作线程数(关键字参数,默认 `0`);`>0` 时回调在线程池中并发执行 |
| `recv_timeout` | 接收单条客户端消息的超时秒数(关键字参数,默认 `10.0`);超时未收到完整消息则主动断开连接,防御 Slowloris 式半开连接攻击 |
回调签名为 `on_message(sock, addr, text)`: 回调签名为 `on_message(sock, addr, text)`:
- `sock` — 客户端 socket,可用 `reply()` 回复 - `sock` — 客户端 socket,可用 `reply()` 回复
@@ -159,68 +161,106 @@ server.start(on_message) # 阻塞当前线程
服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `select` 实现非阻塞 I/O,轮询间隔 100ms。 服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `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.stop()` 可从任意线程安全调用:设置原子标志并关闭监听 socket,`select` 循环会干净退出。
- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内(同一线程)调用是安全的。服务端运行期间从其他线程并发调用不支持。 - `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内调用是安全的(线程池模式下经每连接发送锁串行化)。`broadcast()` 会遍历客户端列表,仅限回调线程调用,不要从无关的第三方线程并发调用。
- `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。 - `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。
- `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。 - `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) ### 服务端 — 数据库服务(dbv2)
db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。 db 服务基于 `RpcServer` 暴露通用 CRUD 接口(路由 `execute`):
```python ```python
import json from pymsg import RpcServer
from pymsg import MsgServer
class DBServer: class DBServer:
def __init__(self): def __init__(self, addr="127.0.0.1", port=8005, workers=4):
self._server = MsgServer("127.0.0.1", 8005) self._server = RpcServer(addr, port, workers=workers,
self._routes = {"execute": self._handle_execute} json_default=_json_default)
self._server.add_route("execute", self._handle_execute)
def start(self): def start(self):
self._server.start(self._dispatch) self._server.start()
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)}))
def _handle_execute(self, data): def _handle_execute(self, data):
# ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表 # ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表;异常自动包装为错误响应
pass pass
``` ```
### 客户端 — RPC 调用(dnfs → db) ### 客户端 — RPC 调用(dnfs → db)
dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。 dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 `RpcClient` 调用 db:
```python ```python
import json from pymsg import RpcClient
from pymsg import MsgClient
def _call_dbv2(payload): _rpc = RpcClient("127.0.0.1", 8005, "dnfs")
client = MsgClient("127.0.0.1", 8005, "dnfs")
resp = client.call(json.dumps(payload), timeout=10.0)
return json.loads(resp)
# 创建笔记 data = _rpc.call("execute", {
result = _call_dbv2({
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Notes", "table": "Notes",
"values": {"title": "会议记录", "content": "..."} "values": {"title": "会议记录", "content": "..."}
@@ -229,24 +269,19 @@ result = _call_dbv2({
### 客户端 — 异步日志推送(log_client) ### 客户端 — 异步日志推送(log_client)
各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。 各服务通过统一 `log_client.py` 将日志异步推送到 db,底层为 `RpcClient`:
```python ```python
import json from pymsg import RpcClient
from pymsg import MsgClient
def _send(batch): client = RpcClient("127.0.0.1", 8005, "log-client")
client = MsgClient("127.0.0.1", 8005, "gateway-log")
for entry in batch: for entry in batch:
client.call(json.dumps({ client.call("execute", {
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Logs", "table": "Logs",
"values": { "values": {"ts": now, "service": entry["service"],
"ts": now, "service": entry["service"], "level": entry["level"], "message": entry["message"]},
"level": entry["level"], "message": entry["message"], }, timeout=5)
}
}), timeout=5)
``` ```
## 跨语言互操作 ## 跨语言互操作
@@ -276,6 +311,7 @@ pymsg 的线格式有意设计得极其简单,使其他语言的服务也能
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 | | `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 | | `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 | | `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
| `pymsg.RpcError` | `RpcClient.call()` 收到 `success=false` 业务错误或非法 RPC 响应 |
`MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。 `MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。
+74 -25
View File
@@ -1,7 +1,7 @@
# pymsg 模块技术手册 # pymsg 模块技术手册
> 文档版本:2026-07-30 > 文档版本:2026-07-30
> 代码版本:0.1.0 > 代码版本:0.3.0
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成) > 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
> 定位:跨语言 TCP 消息帧协议库 > 定位:跨语言 TCP 消息帧协议库
@@ -144,7 +144,11 @@ log_client.push(entry)
``` ```
pymsg/ pymsg/
├── 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 # 用户文档 ├── README.md # 用户文档
├── pyproject.toml # 项目配置 ├── pyproject.toml # 项目配置
└── .gitignore └── .gitignore
@@ -165,23 +169,40 @@ pymsg/
| 函数/类 | 行号 | 功能 | | 函数/类 | 行号 | 功能 |
|---------|------|------| |---------|------|------|
| `send(sock, data, *, timeout=None)` | L48 | 底层发送 | | `send(sock, data, *, timeout=None)` | L51 | 底层发送 |
| `recv(sock, *, timeout=None)` | L72 | 底层接收 | | `recv(sock, *, timeout=None)` | L75 | 底层接收 |
| `_recv_exact(sock, n)` | L103 | 精确读 n 字节 | | `_recv_exact(sock, n)` | L106 | 精确读 n 字节 |
| `_timeout(sock, seconds)` | L34 | 超时 context manager | | `_timeout(sock, seconds)` | L38 | 超时 context manager |
| `TimeoutError` | L30 | 超时异常 | | `TimeoutError` | L33 | 超时异常 |
| `MsgClient` | L120 | TCP 客户端 | | `MsgClient` | L123 | TCP 客户端 |
| `MsgClient.__init__` | L136 | 构造器 | | `MsgClient.__init__` | L139 | 构造器 |
| `MsgClient.connect()` | L144 | 建立长连接 | | `MsgClient.connect()` | L147 | 建立长连接 |
| `MsgClient.send(msg)` | L150 | 短连接发送(带 name 前缀) | | `MsgClient.send(msg)` | L153 | 短连接发送(带 name 前缀) |
| `MsgClient.call(msg, *, timeout=None)` | L163 | 短连接 RPC | | `MsgClient.call(msg, *, timeout=None)` | L166 | 短连接 RPC |
| `MsgClient.listen(on_message)` | L193 | 长连接监听 | | `MsgClient.listen(on_message)` | L196 | 长连接监听 |
| `MsgClient.stop()` | L220 | 停止监听 | | `MsgClient.stop()` | L223 | 停止监听 |
| `MsgServer` | L234 | TCP 服务端 | | `MsgServer` | L237 | TCP 服务端 |
| `MsgServer.start(on_message)` | L259 | 启动服务 | | `MsgServer.__init__(addr, port, *, workers=0)` | L262 | 构造器(workers>0 启用线程池) |
| `MsgServer.broadcast(msg, except_sock)` | L296 | 广播 | | `MsgServer.start(on_message)` | L276 | 启动服务 |
| `MsgServer.reply(sock, msg)` | L321 | 回复 | | `MsgServer.broadcast(msg, except_sock)` | L329 | 广播(每连接锁串行化) |
| `MsgServer.stop()` | 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) #### C++ 实现(Msg.hpp)
@@ -319,12 +340,38 @@ client.listen(on_message) # 阻塞,直到 stop()
#### 构造 #### 构造
```python ```python
server = MsgServer(addr: str, port: int) server = MsgServer(addr: str, port: int, *, workers: int = 0)
``` ```
**参数**: **参数**:
- `addr`:监听地址(`0.0.0.0` 表示所有网卡) - `addr`:监听地址(`0.0.0.0` 表示所有网卡)
- `port`:监听端口 - `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.stop()` 跨线程调用 | ✅ 安全 | 设置原子标志并关闭监听 socket |
| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 同一线程内调用 | | `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 经每连接发送锁串行化,worker 线程内同样安全 |
| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 同一线程内调用 | | `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 发送经每连接锁;但会遍历客户端列表,仅限回调线程,勿从无关第三方线程并发调用 |
| `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` | | `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` |
| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 调用方需自行串行化 | | `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 底层函数无锁,调用方需自行串行化(MsgServer 内部已用每连接锁保护) |
| `MsgServer` 并发调用 broadcast/reply | ⚠️ 不支持 | 仅在 `on_message` 回调内(同一线程)安全 | | 同一连接消息并发执行回调 | ✅ 不会 | 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` 单元测试 |
--- ---
+139
View File
@@ -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 支持选项
- [ ] 安全事件日志
+153 -17
View File
@@ -21,11 +21,18 @@ import contextlib
import select import select
import socket as _socket import socket as _socket
import struct import struct
import threading
import traceback
from concurrent.futures import ThreadPoolExecutor
__all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"] __all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"]
_HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int _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): class TimeoutError(Exception):
"""Raised when a send or recv operation exceeds its timeout.""" """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. timeout: Optional timeout in seconds. Raises TimeoutError on expiry.
""" """
payload = data.encode("utf-8") 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)) header = _HEADER.pack(len(payload))
with _timeout(sock, timeout): with _timeout(sock, timeout):
try: try:
sock.sendall(header + payload) _send_all(sock, header, payload)
except _socket.timeout: except _socket.timeout:
raise TimeoutError( raise TimeoutError(
f"send timed out after {timeout}s" f"send timed out after {timeout}s"
@@ -92,6 +103,10 @@ def recv(
try: try:
header = _recv_exact(sock, _HEADER.size) header = _recv_exact(sock, _HEADER.size)
(length,) = _HEADER.unpack(header) (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) payload = _recv_exact(sock, length)
return payload.decode("utf-8") return payload.decode("utf-8")
except _socket.timeout: except _socket.timeout:
@@ -100,17 +115,42 @@ def recv(
) from None ) from None
def _recv_exact(sock: "_socket.socket", n: int) -> bytes: def _send_all(sock: "_socket.socket", header: bytes, payload: bytes) -> None:
"""Read exactly n bytes from sock, raising on premature close.""" """通过 sendmsg 散射-聚集 I/O 发送 header + payload,避免拼接拷贝。
buf = bytearray()
while len(buf) < n: 内核直接从两块独立内存读取数据,无需在用户态拼接 header + payload。
chunk = sock.recv(n - len(buf)) 处理 partial send:sendmsg 可能只发出部分数据,需循环补发。
if not chunk: """
raise ConnectionError( header_len = len(header)
f"connection closed: expected {n} bytes, got {len(buf)}" 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) else:
return bytes(buf) 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.start(on_msg) # 阻塞当前线程
# 在另一线程中调用 server.stop() 可优雅退出 # 在另一线程中调用 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.addr = addr
self.port = port self.port = port
self._workers = workers
self._recv_timeout = recv_timeout
self._running = False self._running = False
self._server_sock: "_socket.socket | None" = None self._server_sock: "_socket.socket | None" = None
self._clients: "dict" = {} # socket -> (ip, port) 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: def start(self, on_message: "callable") -> None:
@@ -264,6 +322,10 @@ class MsgServer:
- sock: 发送消息的客户端套接字 - sock: 发送消息的客户端套接字
- addr: (ip, port) 元组 - addr: (ip, port) 元组
- text: 解码后的字符串消息 - text: 解码后的字符串消息
``workers > 0`` 时回调提交到线程池并发执行;同一连接的消息保持串行
(回调完成前该连接暂停 select 监听,恢复粒度为一个 select 周期,
最坏约 100ms——对短连接 RPC 模式无影响)。
""" """
self._running = True self._running = True
servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
@@ -271,12 +333,21 @@ class MsgServer:
servsock.bind((self.addr, self.port)) servsock.bind((self.addr, self.port))
servsock.listen(10) servsock.listen(10)
self._server_sock = servsock self._server_sock = servsock
if self._workers > 0:
self._pool = ThreadPoolExecutor(
max_workers=self._workers,
thread_name_prefix=f"pymsg-{self.port}",
)
socketlist = [servsock] socketlist = [servsock]
try:
while self._running: while self._running:
self._reap_failed(socketlist)
with self._lock:
watched = [s for s in socketlist if s not in self._busy]
try: try:
readable, _, exception = select.select( readable, _, exception = select.select(
socketlist, [], socketlist, 0.1 watched, [], watched, 0.1
) )
except (ValueError, OSError): except (ValueError, OSError):
break break
@@ -289,7 +360,10 @@ class MsgServer:
self._accept_client(servsock, socketlist) self._accept_client(servsock, socketlist)
else: else:
self._handle_client(s, socketlist, on_message) 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) self._shutdown(servsock, socketlist)
# ---- 广播 ----------------------------------------------------------- # ---- 广播 -----------------------------------------------------------
@@ -307,11 +381,12 @@ class MsgServer:
if client is except_sock: if client is except_sock:
continue continue
try: try:
send(client, msg) self._locked_send(client, msg)
except (BrokenPipeError, ConnectionResetError, OSError): except (BrokenPipeError, ConnectionResetError, OSError):
disconnected.append(client) disconnected.append(client)
for client in disconnected: for client in disconnected:
self._clients.pop(client, None) self._clients.pop(client, None)
self._send_locks.pop(client, None)
try: try:
client.close() client.close()
except OSError: except OSError:
@@ -322,7 +397,17 @@ class MsgServer:
"""向指定客户端套接字发送一条应答消息(pymsg 协议)。 """向指定客户端套接字发送一条应答消息(pymsg 协议)。
用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。 用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。
经每连接发送锁串行化,``workers > 0`` 时可在回调线程安全调用。
""" """
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) send(sock, msg)
# ---- 停止 ----------------------------------------------------------- # ---- 停止 -----------------------------------------------------------
@@ -342,18 +427,62 @@ class MsgServer:
if client_sock.fileno() != -1: if client_sock.fileno() != -1:
socketlist.append(client_sock) socketlist.append(client_sock)
self._clients[client_sock] = client_addr self._clients[client_sock] = client_addr
self._send_locks[client_sock] = threading.Lock()
except OSError: except OSError:
pass pass
def _handle_client(self, s, socketlist, on_message): def _handle_client(self, s, socketlist, on_message):
try: try:
text = recv(s) text = recv(s, timeout=self._recv_timeout)
on_message(s, self._clients.get(s, ("unknown", 0)), text) 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): except (ConnectionError, ConnectionResetError, OSError):
self._remove_client(s, socketlist) 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): def _remove_client(self, s, socketlist):
self._clients.pop(s, None) self._clients.pop(s, None)
self._send_locks.pop(s, None)
if s in socketlist: if s in socketlist:
socketlist.remove(s) socketlist.remove(s)
try: try:
@@ -368,3 +497,10 @@ class MsgServer:
except OSError: except OSError:
pass pass
self._clients.clear() self._clients.clear()
self._send_locks.clear()
# ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)──
from .rpc import RpcClient, RpcError, RpcServer # noqa: E402
__all__ += ["RpcClient", "RpcServer", "RpcError"]
+171
View File
@@ -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))
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pymsg" name = "pymsg"
version = "0.1.0" version = "0.3.0"
description = "Length-prefixed variable-length socket message framing" description = "Length-prefixed variable-length socket message framing"
requires-python = ">=3.9" requires-python = ">=3.9"
license = "MIT" license = "MIT"
+123
View File
@@ -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()
+145
View File
@@ -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()
+164
View File
@@ -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()
+67
View File
@@ -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 防止误用
### 短期
- [ ] 验证全局熔断配置生效
- [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例)
+103 -41
View File
@@ -1,18 +1,18 @@
""" """统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
设计要点: 设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
用法: 用法:
from log_client import logs from log_client import logs
logs.info("smtp", "验证码发送成功") logs.info("dnfs", "节点新增成功 id=42")
logs.error("smtp", "验证码发送失败", extra={"errno": 2002}) logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
logs.push("smtp", "WARNING", "登录失败次数超限", logs.push("gateway", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"}) trace_id="req-abc", extra={"ip": "1.2.3.4"})
""" """
@@ -26,33 +26,45 @@ import atexit
from datetime import datetime, timezone from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) _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_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = 8005 _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush _BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒) _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: class LogClient:
"""异步批量日志推送客户端。 """异步批量日志推送客户端。"""
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 def __init__(self, service_name="log",
"""
def __init__(self, service_name="smtp",
batch_size=_BATCH_SIZE, batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL): flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name self._service_name = service_name
self._batch_size = batch_size self._batch_size = batch_size
self._flush_interval = flush_interval self._flush_interval = flush_interval
self._queue = queue.Queue() self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
self._stop = threading.Event() self._stop = threading.Event()
self._flush_signal = threading.Event() self._flush_signal = threading.Event()
self._flush_done = threading.Event()
self._thread = threading.Thread(target=self._flush_loop, self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client') daemon=True, name='log-client')
self._thread.start() self._thread.start()
@@ -61,14 +73,30 @@ class LogClient:
# ── 公开接口 ──────────────────────────────────────────────────────── # ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None): def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。""" """推送一条日志(非阻塞,立即返回)。
self._queue.put({
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
"""
entry = {
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
'service': service, 'service': service,
'level': level, 'level': level,
'message': message, 'message': message,
'trace_id': trace_id, 'trace_id': trace_id,
'extra': extra, '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): def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw) self.push(service, 'DEBUG', message, **kw)
@@ -87,10 +115,15 @@ class LogClient:
def flush(self): def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_done.clear()
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch
# 等待后台线程响应(queue.get 超时最长 0.5s) # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量)
time.sleep(0.6) self._flush_done.wait(timeout=self._flush_interval + 2.0)
# 排空主队列中尚未被线程取走的条目 # 主线程排空队列中剩余条目
self._drain_queue_to_db()
def _drain_queue_to_db(self):
"""排空主线程可见的队列中剩余条目,直接发送到 db。"""
batch = [] batch = []
while True: while True:
try: try:
@@ -102,18 +135,21 @@ class LogClient:
break break
if batch: if batch:
self._send(batch) self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ──────────────────────────────────────────────────────── # ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self): def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
都不会死亡,避免日志队列无限增长导致 OOM。
"""
batch = [] batch = []
last_flush = time.monotonic() last_flush = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
try:
timeout = self._flush_interval - (time.monotonic() - last_flush) timeout = self._flush_interval - (time.monotonic() - last_flush)
# 限制最长阻塞时间,以便及时响应 flush() 信号
chk_timeout = max(0.1, min(timeout, 0.5)) chk_timeout = max(0.1, min(timeout, 0.5))
try: try:
item = self._queue.get(timeout=chk_timeout) item = self._queue.get(timeout=chk_timeout)
@@ -132,44 +168,70 @@ class LogClient:
(batch and time.monotonic() - last_flush >= self._flush_interval) (batch and time.monotonic() - last_flush >= self._flush_interval)
) )
if should_flush and batch: if should_flush and batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
# _send 内部已有细粒度异常处理,此处是最终安全网
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
file=_sys.stderr)
batch = [] batch = []
last_flush = time.monotonic() 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 剩余 # 退出前 flush 剩余
if batch: if batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
self._flush_done.set()
def _send(self, batch): def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
now = datetime.now(timezone.utc).isoformat()
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
不抛异常给调用方。
"""
try: try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") 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: for entry in batch:
extra_val = entry.get("extra") 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 = { payload = {
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Logs", "table": "Logs",
"values": { "values": {
"ts": now, "ts": entry.get("ts", _now_iso()),
"service": entry.get("service"), "service": entry.get("service"),
"level": entry.get("level"), "level": entry.get("level"),
"message": entry.get("message"), "message": entry.get("message"),
"trace_id": entry.get("trace_id"), "trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False) "extra": extra_json,
if extra_val is not None else None,
}, },
} }
resp = client.call(json.dumps(payload), timeout=_TIMEOUT) try:
resp_obj = json.loads(resp) client.call("execute", payload, timeout=_TIMEOUT)
if not resp_obj.get("success"): except RpcError as e:
import sys # 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
file=sys.stderr)
except Exception as e: except Exception as e:
import sys # 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=sys.stderr) print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self): def _shutdown(self):
"""进程退出时 flush 剩余日志。""" """进程退出时 flush 剩余日志。"""
@@ -179,4 +241,4 @@ class LogClient:
# ── 全局单例 ────────────────────────────────────────────────────────── # ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="smtp") logs = LogClient()
+71 -44
View File
@@ -19,7 +19,6 @@ SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
(__PASSWORD 兼容别名 SMTP_PASSWORD)。 (__PASSWORD 兼容别名 SMTP_PASSWORD)。
""" """
import json
import os import os
import random import random
import smtplib import smtplib
@@ -60,7 +59,7 @@ from log_client import logs
# pymsg 导入 # pymsg 导入
import sys as _sys import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) _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 # 验证码有效期(秒) CODE_TTL = 600 # 验证码有效期(秒)
SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒)
MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) 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; # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER;
# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖
MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER 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} code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int}
send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) 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() STORE_LOCK = threading.Lock()
@@ -93,17 +107,17 @@ STORE_LOCK = threading.Lock()
class SmtpServer: class SmtpServer:
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" """基于 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.addr = addr
self.port = port self.port = port
self._server = MsgServer(addr, port) self._server = RpcServer(
addr, port, workers=workers,
# 路由表 on_error=lambda route, e: logs.error(
self._routes = { "smtp", f"路由 {route} 处理异常: {e}"),
"send_email": self._handle_send_email, )
"verify_email": self._handle_verify_email, self._server.add_route("send_email", self._handle_send_email)
"health": self._handle_health, self._server.add_route("verify_email", self._handle_verify_email)
} self._server.add_route("health", self._handle_health)
# ── 启动 / 停止 ────────────────────────────────────────────────── # ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None: def start(self) -> None:
@@ -118,42 +132,12 @@ class SmtpServer:
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
logs.info("smtp", f"开始监听 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: def stop(self) -> None:
"""从另一线程停止服务。""" """从另一线程停止服务。"""
self._server.stop() 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,已拒绝") logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝")
return {"sent": False, "retry_after": wait, return {"sent": False, "retry_after": wait,
"error": f"发送过于频繁,请 {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)) code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
subject = str(req.get("subject") or DEFAULT_SUBJECT) subject = str(req.get("subject") or DEFAULT_SUBJECT)
@@ -211,10 +226,20 @@ class SmtpServer:
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态 # 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
with STORE_LOCK: with STORE_LOCK:
code_store.pop(email, None) 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})") logs.error("smtp", f"{email}: 验证码发送失败({err})")
return {"sent": False, "error": f"验证码发送失败:{err}"} return {"sent": False, "error": f"验证码发送失败:{err}"}
# 发送成功:记录冷却起点,并惰性清理已失效的冷却记录 # 发送成功:刷新冷却起点(检查阶段已占位),并惰性清理已失效的冷却记录
with STORE_LOCK: with STORE_LOCK:
send_history[email] = time.time() send_history[email] = time.time()
stale = [k for k, v in send_history.items() stale = [k for k, v in send_history.items()
@@ -280,9 +305,11 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8008, help="监听端口") parser.add_argument("--port", type=int, default=8008, help="监听端口")
parser.add_argument("--workers", type=int, default=4,
help="回调并发线程数(0=同步串行模式)")
args = parser.parse_args() args = parser.parse_args()
server = SmtpServer(args.addr, args.port) server = SmtpServer(args.addr, args.port, workers=args.workers)
try: try:
server.start() server.start()
except KeyboardInterrupt: except KeyboardInterrupt:
+8 -2
View File
@@ -21,6 +21,7 @@ static :9200 (Flask + CORS)
├── index.html 社区首页 ├── index.html 社区首页
├── forgot.html 忘记密码 ├── forgot.html 忘记密码
├── dnfs.html 树状笔记(调用 dnfs:8006) ├── dnfs.html 树状笔记(调用 dnfs:8006)
├── aibox.html aibox 智能体 Web 客户端(管理员专属,经 gateway WS 反向代理)
├── __view.html 文件浏览器(系统文件,不在列表中显示) ├── __view.html 文件浏览器(系统文件,不在列表中显示)
├── allcolor.html 调色板 ├── allcolor.html 调色板
├── LICENSE.html 许可证页面 ├── LICENSE.html 许可证页面
@@ -50,6 +51,7 @@ static :9200 (Flask + CORS)
| `POST /api/hash` | SHA256 密码哈希 | | `POST /api/hash` | SHA256 密码哈希 |
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) | | `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) | | `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) |
### yhdet OAuth 路由 ### yhdet OAuth 路由
@@ -68,15 +70,19 @@ static :9200 (Flask + CORS)
| `POST /add_note` | 新增笔记(代理至 dnfs:8006) | | `POST /add_note` | 新增笔记(代理至 dnfs:8006) |
| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) | | `POST /delete_note` | 删除笔记(代理至 dnfs:8006) |
| `POST /update_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`)、文件统计 - 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
- 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动 - 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好 - 深色模式:`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` 内 - 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定) - Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头 - 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
+132
View File
@@ -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
View File
@@ -12,6 +12,8 @@ import sys
# pymsg 日志客户端(集中式日志 → dbv2 Logs 表) # pymsg 日志客户端(集中式日志 → dbv2 Logs 表)
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 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 log_client import logs # noqa: E402
from security_middleware import apply_security_headers # noqa: E402 from security_middleware import apply_security_headers # noqa: E402
@@ -41,6 +43,8 @@ app = Flask(__name__)
apply_security_headers(app) apply_security_headers(app)
# ==================== 配置文件加载 ==================== # ==================== 配置文件加载 ====================
# 加载顺序:内置默认值 → confs 统一配置服务(static.ini);
# confs 不可用时 fail-safe 回退本地 config.ini
CONFIG_FILE = args.config CONFIG_FILE = args.config
if not os.path.isabs(CONFIG_FILE): if not os.path.isabs(CONFIG_FILE):
CONFIG_FILE = os.path.join(BASE_DIR, 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'}, 'cors': {'allowed_origins': 'https://qsont.me,https://yhdet.top'},
} }
if os.path.exists(CONFIG_FILE):
config_parser = configparser.ConfigParser() def _merge_ini(text):
config_parser.read(CONFIG_FILE, encoding='utf-8') """把 ini 文本合并进 file_config(仅覆盖已知的 section/key)。"""
ini = configparser.ConfigParser()
ini.read_string(text)
for section in ['server', 'directory', 'ssl', 'cors']: for section in ['server', 'directory', 'ssl', 'cors']:
if section in config_parser: if ini.has_section(section):
for key, value in config_parser[section].items(): for key, value in ini[section].items():
if key in file_config[section]: if key in file_config[section]:
file_config[section][key] = value 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(配置加载完成后初始化) ====================
_cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()] _cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()]
CORS(app, origins=_cors_origins) 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"})) 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']) @app.route('/api/proxy_send_code', methods=['POST'])
def proxy_send_code(): def proxy_send_code():
from flask import request, jsonify 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('/') @app.route('/')
def serve_view(): def serve_view():
+104 -37
View File
@@ -1,17 +1,19 @@
""" """统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。
统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。
设计要点: 设计要点:
- 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
- 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
- fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
- 时间戳:日志入队时即记录 ts,反映事件真实发生时间
用法: 用法:
from log_client import logs from log_client import logs
logs.info("static", "服务启动成功") logs.info("dnfs", "节点新增成功 id=42")
logs.error("static", "文件系统未启用", extra={"path": "/data"}) logs.error("dnfs", "数据库连接失败", extra={"errno": 2002})
logs.push("gateway", "WARNING", "登录失败次数超限",
trace_id="req-abc", extra={"ip": "1.2.3.4"})
""" """
import json import json
@@ -24,33 +26,45 @@ import atexit
from datetime import datetime, timezone from datetime import datetime, timezone
_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) _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_ADDR = _os.environ.get('DB_HOST', '127.0.0.1')
_DBV2_PORT = 8005 _DBV2_PORT = int(_os.environ.get('DB_PORT', '8005'))
_BATCH_SIZE = 50 # 队列满 50 条时 flush _BATCH_SIZE = 50 # 队列满 50 条时 flush
_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush
_TIMEOUT = 5 # socket 超时(秒) _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: class LogClient:
"""异步批量日志推送客户端。 """异步批量日志推送客户端。"""
通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 def __init__(self, service_name="log",
"""
def __init__(self, service_name="static",
batch_size=_BATCH_SIZE, batch_size=_BATCH_SIZE,
flush_interval=_FLUSH_INTERVAL): flush_interval=_FLUSH_INTERVAL):
self._service_name = service_name self._service_name = service_name
self._batch_size = batch_size self._batch_size = batch_size
self._flush_interval = flush_interval self._flush_interval = flush_interval
self._queue = queue.Queue() self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE)
self._stop = threading.Event() self._stop = threading.Event()
self._flush_signal = threading.Event() self._flush_signal = threading.Event()
self._flush_done = threading.Event()
self._thread = threading.Thread(target=self._flush_loop, self._thread = threading.Thread(target=self._flush_loop,
daemon=True, name='log-client') daemon=True, name='log-client')
self._thread.start() self._thread.start()
@@ -59,14 +73,30 @@ class LogClient:
# ── 公开接口 ──────────────────────────────────────────────────────── # ── 公开接口 ────────────────────────────────────────────────────────
def push(self, service, level, message, trace_id=None, extra=None): def push(self, service, level, message, trace_id=None, extra=None):
"""推送一条日志(非阻塞,立即返回)。""" """推送一条日志(非阻塞,立即返回)。
self._queue.put({
队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。
"""
entry = {
'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间
'service': service, 'service': service,
'level': level, 'level': level,
'message': message, 'message': message,
'trace_id': trace_id, 'trace_id': trace_id,
'extra': extra, '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): def debug(self, service, message, **kw):
self.push(service, 'DEBUG', message, **kw) self.push(service, 'DEBUG', message, **kw)
@@ -85,8 +115,15 @@ class LogClient:
def flush(self): def flush(self):
"""手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。"""
self._flush_done.clear()
self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch 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 = [] batch = []
while True: while True:
try: try:
@@ -98,16 +135,20 @@ class LogClient:
break break
if batch: if batch:
self._send(batch) self._send(batch)
self._flush_signal.clear()
# ── 内部实现 ──────────────────────────────────────────────────────── # ── 内部实现 ────────────────────────────────────────────────────────
def _flush_loop(self): def _flush_loop(self):
"""后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。
外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等)
都不会死亡,避免日志队列无限增长导致 OOM。
"""
batch = [] batch = []
last_flush = time.monotonic() last_flush = time.monotonic()
while not self._stop.is_set(): while not self._stop.is_set():
try:
timeout = self._flush_interval - (time.monotonic() - last_flush) timeout = self._flush_interval - (time.monotonic() - last_flush)
chk_timeout = max(0.1, min(timeout, 0.5)) chk_timeout = max(0.1, min(timeout, 0.5))
try: try:
@@ -127,44 +168,70 @@ class LogClient:
(batch and time.monotonic() - last_flush >= self._flush_interval) (batch and time.monotonic() - last_flush >= self._flush_interval)
) )
if should_flush and batch: if should_flush and batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
# _send 内部已有细粒度异常处理,此处是最终安全网
print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}",
file=_sys.stderr)
batch = [] batch = []
last_flush = time.monotonic() 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 剩余 # 退出前 flush 剩余
if batch: if batch:
try:
self._send(batch) self._send(batch)
except Exception as e:
print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr)
self._flush_done.set()
def _send(self, batch): def _send(self, batch):
"""通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。
now = datetime.now(timezone.utc).isoformat()
整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe),
不抛异常给调用方。
"""
try: try:
client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") 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: for entry in batch:
extra_val = entry.get("extra") 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 = { payload = {
"_route": "execute",
"action": "insert", "action": "insert",
"table": "Logs", "table": "Logs",
"values": { "values": {
"ts": now, "ts": entry.get("ts", _now_iso()),
"service": entry.get("service"), "service": entry.get("service"),
"level": entry.get("level"), "level": entry.get("level"),
"message": entry.get("message"), "message": entry.get("message"),
"trace_id": entry.get("trace_id"), "trace_id": entry.get("trace_id"),
"extra": json.dumps(extra_val, ensure_ascii=False) "extra": extra_json,
if extra_val is not None else None,
}, },
} }
resp = client.call(json.dumps(payload), timeout=_TIMEOUT) try:
resp_obj = json.loads(resp) client.call("execute", payload, timeout=_TIMEOUT)
if not resp_obj.get("success"): except RpcError as e:
import sys # 业务错误(如表结构不符):打印后继续下一条
print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr)
file=sys.stderr)
except Exception as e: except Exception as e:
import sys # 网络错误:db 不可用,放弃本批剩余(fail-safe)
print(f"[log_client] 发送失败: {e}", file=sys.stderr) print(f"[log_client] 发送失败: {e}", file=_sys.stderr)
break
def _shutdown(self): def _shutdown(self):
"""进程退出时 flush 剩余日志。""" """进程退出时 flush 剩余日志。"""
@@ -174,4 +241,4 @@ class LogClient:
# ── 全局单例 ────────────────────────────────────────────────────────── # ── 全局单例 ──────────────────────────────────────────────────────────
logs = LogClient(service_name="static") logs = LogClient()
+35
View File
@@ -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>
+216
View File
@@ -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; }
}
+54
View File
@@ -119,6 +119,21 @@ textarea:focus {
input::placeholder, textarea::placeholder { color: var(--muted); } input::placeholder, textarea::placeholder { color: var(--muted); }
textarea { min-height: 80px; resize: vertical; } 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 ===== */ /* ===== Buttons ===== */
button, input[type="submit"], input[type="reset"], input[type="button"] { button, input[type="submit"], input[type="reset"], input[type="button"] {
padding: 0.55rem 1.3rem; 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="text"] { width: auto; flex: 1; min-width: 100px; display: inline-block; }
.code-box input[type="button"] { white-space: nowrap; } .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 ===== */ /* ===== Validation Tips ===== */
.tip-red { color: var(--error); font-size: 0.78rem; } .tip-red { color: var(--error); font-size: 0.78rem; }
@@ -480,6 +507,30 @@ input[type="button"]:disabled {
border-color: var(--accent); 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 (dnfs) ===== */
.node-form { margin-top: 1rem; } .node-form { margin-top: 1rem; }
.node-form .form-actions { margin-top: 0.75rem; } .node-form .form-actions { margin-top: 0.75rem; }
@@ -491,7 +542,10 @@ input[type="button"]:disabled {
padding: 0.9rem 1rem; padding: 0.9rem 1rem;
font-size: 0.95rem; font-size: 0.95rem;
min-height: 120px; min-height: 120px;
max-height: 400px;
line-height: 1.7; line-height: 1.7;
overflow-y: hidden;
resize: none;
} }
/* ===== Markdown Content ===== */ /* ===== Markdown Content ===== */
+13
View File
@@ -16,6 +16,19 @@
<div class="card node-form"> <div class="card node-form">
<h3>节点操作</h3> <h3>节点操作</h3>
<label>新增节点</label><input id="input-note-title"> <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> <label>节点内容</label><textarea id="input-note-content"></textarea>
<div class="form-actions"> <div class="form-actions">
<button id="input-note-submit">提交</button> <button id="input-note-submit">提交</button>
+393
View File
@@ -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();
})();
+269 -6
View File
@@ -105,6 +105,7 @@ async function addNode(pid, title, content) {
input_note_title.value = ""; input_note_title.value = "";
input_note_content.value = ""; input_note_content.value = "";
cancelEdit(); /* 退出编辑模式并重置高度 */
getNode(stack[stack.length - 1]).then(load_notes); getNode(stack[stack.length - 1]).then(load_notes);
getNode(stack[stack.length - 1]).then(loadMap) getNode(stack[stack.length - 1]).then(loadMap)
@@ -417,6 +418,151 @@ function renderMarkdown(element, content) {
element.innerHTML = md.render(content, {html: false}); 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 自动跟随主题色 */ /* 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_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>'; 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 delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap"); var minimap = document.getElementById("minimap");
var movebar = document.getElementById("movebar"); 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):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */ /* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
var PANEL_EXPAND_MIN_WIDTH = 768; var PANEL_EXPAND_MIN_WIDTH = 768;
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},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) { input_note_submit.addEventListener('click', function(event) {
if (input_note_submit.disabled) return; // 防重复点击 if (input_note_submit.disabled) return; // 防重复点击
var title = input_note_title.value var title = input_note_title.value
var content = input_note_content.value
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
//alert(stack.length) //alert(stack.length)
//alert(pid) //alert(pid)
@@ -466,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) {
input_note_submit.disabled = true; input_note_submit.disabled = true;
input_note_clear.disabled = true; input_note_clear.disabled = true;
delete_note.disabled = true; delete_note.disabled = true;
// 编辑模式下保存修改,否则新增节点 // 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件)
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); buildSubmitContent()
Promise.resolve(req) .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(() => { .finally(() => {
input_note_submit.disabled = false; input_note_submit.disabled = false;
input_note_clear.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) { function startEdit(item) {
editing_id = item.id; editing_id = item.id;
input_note_title.value = item.title; input_note_title.value = item.title;
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_content.value = item.content;
}
input_note_submit.textContent = "保存修改"; input_note_submit.textContent = "保存修改";
autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
input_note_title.focus(); input_note_title.focus();
} }
@@ -510,7 +761,15 @@ function startEdit(item) {
/* by qing — 退出编辑模式,表单恢复为新增状态 */ /* by qing — 退出编辑模式,表单恢复为新增状态 */
function cancelEdit() { function cancelEdit() {
editing_id = null; 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 = "提交"; input_note_submit.textContent = "提交";
resetTextareaHeight(); /* 恢复初始高度 */
} }
function load_notes(main_node) { function load_notes(main_node) {
@@ -579,7 +838,7 @@ function load_notes(main_node) {
ttl.appendChild(wrt_div); ttl.appendChild(wrt_div);
ttl.appendChild(sts_div); ttl.appendChild(sts_div);
let ctt = document.createElement('p'); let ctt = document.createElement('p');
renderMarkdown(ctt, item.content); renderContent(ctt, item);
card.appendChild(ctt) card.appendChild(ctt)
if (item.can_operate === true) { if (item.can_operate === true) {
let act_div = document.createElement('div'); let act_div = document.createElement('div');
@@ -681,6 +940,10 @@ async function loadUserPanel() {
}); });
if (!res.ok) return; if (!res.ok) return;
current_user = await res.json(); current_user = await res.json();
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
if (current_user.login && current_user.is_admin) {
sp_panel.style.display = '';
}
renderUserPanel(); renderUserPanel();
} catch (e) { } catch (e) {
console.error('获取用户信息失败:', e); console.error('获取用户信息失败:', e);
+66 -4
View File
@@ -7,11 +7,45 @@ const repeatPasswordInput = document.getElementById('repwd');
const emlTip = document.getElementById('emlTip'); const emlTip = document.getElementById('emlTip');
const pwdTip = document.getElementById('pwdTip'); const pwdTip = document.getElementById('pwdTip');
const codeTip = document.getElementById('codeTip'); 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'); const submitButton = document.getElementById('formsub');
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐) 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 发送冷却 // 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
function startResendCooldown(seconds) { function startResendCooldown(seconds) {
clearInterval(cooldownTimer); clearInterval(cooldownTimer);
@@ -34,16 +68,20 @@ function startResendCooldown(seconds) {
form.addEventListener('reset', function () { form.addEventListener('reset', function () {
clearTipStyle(); clearTipStyle();
codeTarget = ''; codeTarget = '';
captchaInput.value = '';
refreshCaptcha();
}); });
function clearTipStyle() { function clearTipStyle() {
emlTip.innerHTML = ''; emlTip.innerHTML = '';
pwdTip.innerHTML = ''; pwdTip.innerHTML = '';
codeTip.innerHTML = ''; codeTip.innerHTML = '';
captchaTip.innerHTML = '';
emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
pwdTip.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'); codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
} }
clearTipStyle(); clearTipStyle();
@@ -101,6 +139,23 @@ function checkCode() {
return code; 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); passwordInput.addEventListener('focus', checkPassword);
repeatPasswordInput.addEventListener('focus', checkPassword); repeatPasswordInput.addEventListener('focus', checkPassword);
@@ -126,9 +181,9 @@ codeInput.addEventListener('blur', checkCode);
getCodeButton.addEventListener('click', async function () { getCodeButton.addEventListener('click', async function () {
if (getCodeButton.disabled) return; // 防重复点击 if (getCodeButton.disabled) return; // 防重复点击
const email = checkEmail(); const email = checkEmail();
if (!email) { if (!email) return;
return; const captchaAnswer = checkCaptcha();
} if (!captchaAnswer) return;
getCodeButton.disabled = true; getCodeButton.disabled = true;
codeTip.textContent = '验证码发送中…'; codeTip.textContent = '验证码发送中…';
@@ -139,7 +194,11 @@ getCodeButton.addEventListener('click', async function () {
const res = await fetch('/register/send_code', { const res = await fetch('/register/send_code', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }) body: JSON.stringify({
email,
captcha_token: captchaToken,
captcha_answer: captchaAnswer
})
}); });
httpStatus = res.status; httpStatus = res.status;
const responseText = await res.text(); const responseText = await res.text();
@@ -160,6 +219,9 @@ getCodeButton.addEventListener('click', async function () {
codeTip.classList.add('tip-red'); codeTip.classList.add('tip-red');
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐 if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
} finally { } finally {
// CAPTCHA Token 一次性消费,无论成败都刷新
captchaInput.value = '';
refreshCaptcha();
if (!cooldownTimer) getCodeButton.disabled = false; if (!cooldownTimer) getCodeButton.disabled = false;
} }
}); });
+8 -3
View File
@@ -13,12 +13,17 @@
<div> <div>
<label>邮箱号:</label> <label>邮箱号:</label>
<input type="email" id="regemail" placeholder="请输入邮箱号"> <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="text" id="code" placeholder="请输入6位验证码" inputmode="numeric" maxlength="6">
<input type="button" id="getCode" value="获取验证码"> <input type="button" id="getCode" value="获取验证码">
<span id="codeTip"></span> <span id="codeTip"></span>
</div> </div>
<span id="emlTip"></span>
<label>密码:</label> <label>密码:</label>
<input type="password" id="regpsw" placeholder="密码至少6位"> <input type="password" id="regpsw" placeholder="密码至少6位">
<label>验证密码:</label> <label>验证密码:</label>
@@ -36,6 +41,6 @@
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a> <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer> </footer>
<script src="js/validation.js"></script> <script src="js/validation.js"></script>
<script src="js/register.js?v=6"></script> <script src="js/register.js?v=7"></script>
</body> </body>
</html> </html>
+72 -7
View File
@@ -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) 1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403 2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block 3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway 4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
5. 捕获响应头 → `ProcessResponseHeaders` 5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截)
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头) 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。 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。 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`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 - **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 - 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 的内联脚本),待前端清理后可收紧。 - CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 - 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 热加载 + 异步日志推送 + 安全响应头注入
## 依赖 ## 依赖
+331
View File
@@ -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 配置化等
+5
View File
@@ -25,6 +25,8 @@ const (
batchSize = 50 batchSize = 50
flushInterval = 2 * time.Second flushInterval = 2 * time.Second
socketTimeout = 5 * time.Second socketTimeout = 5 * time.Second
// 消息大小安全上限(64 MiB),与 Python / C++ 端保持一致。
maxMsgSize = 64 * 1024 * 1024
) )
// Entry 单条日志。 // Entry 单条日志。
@@ -247,6 +249,9 @@ func pymsgCall(payload []byte) ([]byte, error) {
return nil, err return nil, err
} }
respLen := binary.BigEndian.Uint32(header) 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) resp := make([]byte, respLen)
if _, err := io.ReadFull(conn, resp); err != nil { if _, err := io.ReadFull(conn, resp); err != nil {
return nil, err return nil, err
+97 -8
View File
@@ -2,9 +2,11 @@
package proxy package proxy
import ( import (
"bufio"
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
@@ -16,6 +18,10 @@ import (
"github.com/micro_servers/waf/internal/logclient" "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 引擎引用。 // Proxy 持有 ReverseProxy 与 WAF 引擎引用。
type Proxy struct { type Proxy struct {
rp *httputil.ReverseProxy rp *httputil.ReverseProxy
@@ -84,6 +90,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer func() { defer func() {
tx.ProcessLogging() tx.ProcessLogging()
_ = tx.Close() _ = tx.Close()
// 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用
if r.Body != nil {
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024))
_ = r.Body.Close()
}
}() }()
// 解析 RemoteAddr 中的 IP 与端口 // 解析 RemoteAddr 中的 IP 与端口
@@ -111,14 +122,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发 // 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM
if r.Body != nil && r.Body != http.NoBody { if r.Body != nil && r.Body != http.NoBody {
bodyBytes, err := io.ReadAll(r.Body) // 限制读取大小:多读 1 字节,若能读到说明超限
if err == nil { limitReader := io.LimitReader(r.Body, maxRequestBodySize+1)
_, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)) bodyBytes, err := io.ReadAll(limitReader)
r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) 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) p.block(w, r, it.RuleID, start)
return return
} }
@@ -128,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rw := &responseRecorder{ResponseWriter: w, status: 200} rw := &responseRecorder{ResponseWriter: w, status: 200}
p.rp.ServeHTTP(rw, r) 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 // 响应阶段:把 upstream 的 header 喂给 WAF
for k, vs := range rw.headers { for k, vs := range rw.headers {
for _, v := range vs { for _, v := range vs {
tx.AddResponseHeader(k, v) tx.AddResponseHeader(k, v)
} }
} }
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") tx.ProcessResponseHeaders(rw.status, r.Proto)
} }
// block 命中规则时统一返回 403,并将关键输入推送到日志库。 // block 命中规则时统一返回 403,并将关键输入推送到日志库。
@@ -155,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start
} }
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。 // responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。
type responseRecorder struct { type responseRecorder struct {
http.ResponseWriter http.ResponseWriter
status int status int
statusSent bool
headers http.Header headers http.Header
hijacked bool
} }
func (r *responseRecorder) WriteHeader(code int) { func (r *responseRecorder) WriteHeader(code int) {
if r.hijacked {
return
}
r.status = code r.status = code
r.statusSent = true
if r.headers == nil { if r.headers == nil {
r.headers = http.Header{} r.headers = http.Header{}
} }
// 拷贝上游响应头 // 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定)
for k, vs := range r.Header() { for k, vs := range r.Header() {
r.headers[k] = vs r.headers[k] = vs
} }
r.ResponseWriter.WriteHeader(code) 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() { func (r *responseRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok { if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush() 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
}
+6
View File
@@ -22,6 +22,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -110,6 +111,11 @@ func main() {
server := &http.Server{ server := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: p, 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{ TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
}, },