diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..37ecf92 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6933624 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 49abecf..544d409 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ gateway/shufeng.ini core/Nidus.service.shufeng ai_review/.env +confs/__pycache__/ +libhttp/test_server +*.pyc +__pycache__/ diff --git a/BotOps/README.md b/BotOps/README.md index 3de9e43..938f9c6 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -58,4 +58,27 @@ BotOps/ 与 aibox 服务端协议完全兼容(详见 aibox/client.py 注释): - 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}` -- 接收:`chunk`(**增量片段**,服务端 `chunk_buffer` 发送后即清零,前端直接拼接)/ `complete`(**完整全文**,用于对齐已显示内容)/ `line`(过程行:🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏)/ `reasoning`(思考流,仅服务端 debug_level≥3 发送)/ `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong` +- 接收: + - `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源 + - `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程 + - `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 3000 字符) + - `reasoning_end`(**思考结束**):思考结束,切换到正式回答 + - **顺序保证**:不预创建空 AI widget,按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),思考区域必然在 AI 正式回答**之前** + - `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾(重置状态) + - `line`(过程行):🔧 工具调用提示、工具输出,路由到任务侧栏;助手前缀/思考提示在聊天区已由对应区域承载,不重复展示 + - `info`(系统信息):过滤"Conversation started"/"对话开始"等隐式通知,避免污染聊天流 + - `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `command_result` / `connection_ack` / `notification` / `pong` + +## 更新日志 + +### v1.2 +- ✅ **彻底修复思考区域顺序问题**:移除预创建空 AI widget 的逻辑,改为第一次收到 `chunk` 时才创建 widget。按消息到达顺序自然挂载(`reasoning_start`→`reasoning`→`chunk`),从根本上保证思考区域始终在正式回答之前 +- ✅ 简化 `complete` 处理逻辑:仅作为收尾重置状态信号,不再进行补建逻辑 + +### v1.1 +- ✅ 支持 DeepSeek 思考模式展示:新增 `reasoning_start`/`reasoning`/`reasoning_end` 消息处理,思考内容以灰色斜体区域展示 +- ✅ 超长思考内容自动折叠(显示最后 3000 字符) +- ✅ 过滤"Conversation started"/"对话开始"等隐式系统通知,避免在回答末尾显示无用信息 + +### v1.0 +- 初始版本:Markdown 增量流式渲染、任务独立侧栏、自动重连、快捷键支持 diff --git a/BotOps/app.py b/BotOps/app.py index 645528d..5ea6049 100644 --- a/BotOps/app.py +++ b/BotOps/app.py @@ -58,6 +58,13 @@ class BotOpsApp(App): color: yellow; padding: 0 1; } + .msg-reasoning { + color: $text-muted; + background: $surface; + padding: 0 1; + margin: 0 1; + text-style: italic; + } #status { height: 1; } @@ -140,9 +147,9 @@ class BotOpsApp(App): await self.notify_system("未连接到服务器,先 /connect", level="error") return await self.chat.add_user(text) - await self.chat.start_ai() # 预建 AI 消息框,即时反馈 + await self.chat.start_ai() # 标记AI回复开始状态(不预创建空widget) if not await self.ws.send_message(text): - await self.chat.finish_ai("") # 发送失败:移除预建的空消息框 + await self.chat.finish_ai("") # 发送失败:重置AI状态 await self.notify_system("❌ 发送失败:连接已断开,请 /connect 重连", level="error") # ── 快捷键 ──────────────────────────────────────────────────────────── @@ -163,21 +170,27 @@ class BotOpsApp(App): if t == "chunk": # 服务端 chunk 为增量片段(发送后即清零),直接拼接 await self.chat.append_ai(content) + elif t == "reasoning_start": + # 开始深度思考,创建思考区域 + await self.chat.start_reasoning(content or "🤔 深度思考中...") + elif t == "reasoning": + # 思考内容增量 + stripped = content + if stripped: + await self.chat.append_reasoning(stripped) + elif t == "reasoning_end": + # 思考结束 + await self.chat.end_reasoning() elif t == "line": # 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀 stripped = content.strip() if not stripped: pass - elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")): - pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示 + elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")): + pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示 else: # 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏 self.tasks.write(stripped) - elif t == "reasoning": - # 思考流(服务端 debug_level>=3 才发送),记入任务侧栏 - stripped = content.strip() - if stripped: - self.tasks.write(f"💭 {stripped[-200:]}") elif t == "complete": # complete 携带完整全文,用于对齐已显示内容 await self.chat.finish_ai(content) @@ -217,7 +230,10 @@ class BotOpsApp(App): elif t == "error": await self.notify_system(f"❌ {content}", level="error") elif t == "info": - await self.notify_system(f"ℹ️ {content}") + # 过滤掉对话开始这类隐式通知(用户发消息自然知道对话开始了) + skip_keywords = ("Conversation started", "对话开始", "🎭") + if not any(k in content for k in skip_keywords): + await self.notify_system(f"ℹ️ {content}") elif t == "command_result": await self.notify_system(content) elif t == "connection_ack": diff --git a/BotOps/docs/SECURITY_AUDIT_20260801.md b/BotOps/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..2416ee4 --- /dev/null +++ b/BotOps/docs/SECURITY_AUDIT_20260801.md @@ -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 配置 +- [ ] 确认命令执行白名单 diff --git a/BotOps/widgets/chat_view.py b/BotOps/widgets/chat_view.py index 718ef2c..2dcf3a4 100644 --- a/BotOps/widgets/chat_view.py +++ b/BotOps/widgets/chat_view.py @@ -1,8 +1,17 @@ """聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。 -服务端协议(aibox/main.py output_callback 原样透传 msg_type): - - chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append - - complete 完整全文,用于对齐:与已显示文本不一致时整体替换(防丢帧/乱序) +服务端协议(aibox/aibox.py output_callback 原样透传 msg_type): + - chunk 增量片段(正式回答内容),直接 append 到 AI 消息 + - reasoning_start 思考开始信号,创建思考区域(在AI回答之前) + - reasoning 思考内容增量片段,追加到思考区域 + - reasoning_end 思考结束信号 + - complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾; + 仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建 + +顺序保证:不预创建空AI widget,按消息到达顺序自然挂载。 + - 先到 reasoning_start → 思考区域挂在最后 + - 后到 chunk → AI回答挂在思考区域之后 + - 无思考直接 chunk → AI回答挂在最后 """ from textual.containers import VerticalScroll from textual.widgets import Markdown, Static @@ -11,6 +20,8 @@ from textual.widgets import Markdown, Static class ChatView(VerticalScroll): """可滚动聊天区。AI 回复用单个 Markdown 部件增量 append,实现流式渲染。 + 不预创建空widget:避免重排复杂性,消息按接收顺序自然挂载, + 天然保证思考过程在正式回答之前。 滚动策略:每条新消息挂载后调用 anchor() 锚定底部——之后内容增长 (流式 append)容器会自动保持吸底;用户上翻时 Textual 自动脱锚, 下一条新消息再重新锚定。 @@ -19,7 +30,10 @@ class ChatView(VerticalScroll): def __init__(self, **kwargs): super().__init__(**kwargs) self._ai_widget: Markdown | None = None + self._reasoning_widget: Static | None = None self._ai_text = "" + self._reasoning_text = "" + self._ai_finalized = True # 初始无活跃 AI 消息 @property def ai_text(self) -> str: @@ -38,20 +52,65 @@ class ChatView(VerticalScroll): self._anchor_bottom() async def start_ai(self): - """预建一条 AI 消息(发送用户消息后即可调用,给出即时反馈)。""" - if self._ai_widget is not None: + """开始AI回复(标记状态,不预创建空widget)。 + + 为保持顺序正确,空widget不在此处创建。第一个chunk到来时才创建AI widget, + 此时思考widget(如果有)已经先挂载,顺序自然正确。 + """ + if not self._ai_finalized: return self._ai_text = "" - self._ai_widget = Markdown("", classes="msg-ai") - await self.mount(self._ai_widget) + self._ai_finalized = False + + async def start_reasoning(self, hint: str = "🤔 思考中..."): + """开始展示思考过程区域。直接挂载在当前末尾位置。""" + if self._reasoning_widget is not None: + return + self._reasoning_text = "" + self._reasoning_widget = Static(hint + "\n", classes="msg-reasoning", markup=False) + await self.mount(self._reasoning_widget) self._anchor_bottom() + async def append_reasoning(self, delta: str): + """追加思考内容增量。""" + if not delta: + return + if self._reasoning_widget is None: + await self.start_reasoning() + self._reasoning_text += delta + # 限制思考内容显示长度(最多显示最后 3000 字符,避免思考过长占满屏幕) + display = self._reasoning_text + if len(display) > 3000: + display = "...(前略)\n" + display[-3000:] + self._reasoning_widget.update(f"💭 思考中...\n{display}") + self.call_after_refresh(self.scroll_end, animate=False) + + async def end_reasoning(self): + """结束思考区域,更新提示文字。""" + if self._reasoning_widget is not None and self._reasoning_text: + # 思考结束后保持展示,更新标签为已完成 + display = self._reasoning_text + if len(display) > 3000: + display = "...(前略)\n" + display[-3000:] + self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}") + # 思考区域保留显示,不删除 + + async def _ensure_ai_widget(self): + """确保AI widget存在(第一次chunk到来时创建)。""" + if self._ai_widget is None: + self._ai_widget = Markdown("", classes="msg-ai") + await self.mount(self._ai_widget) + self._anchor_bottom() + async def append_ai(self, delta: str): """追加增量片段(服务端 chunk 为增量语义,直接拼接)。""" if not delta: return - if self._ai_widget is None: - await self.start_ai() + if self._ai_finalized: + # 收尾后收到的延迟 chunk,丢弃以避免在上一条回复后追加脏数据 + return + # 第一次收到chunk时创建widget(此时思考widget如果存在已经在前面了) + await self._ensure_ai_widget() self._ai_widget.append(delta) self._ai_text += delta # anchor 状态下容器会自动吸底;保险起见刷新后再滚一次 @@ -60,31 +119,49 @@ class ChatView(VerticalScroll): async def finish_ai(self, full_text: str = ""): """收尾一条 AI 消息。 - - full_text 为 complete 携带的完整全文:与已显示不一致时整体替换对齐; - - full_text 为空且未收到任何内容(如发送失败):移除预建的空消息框。 + chunk 流式追加是内容权威来源,complete 只做收尾: + - 已收尾则直接返回(防止 dialogue_ended + complete 双发导致重复挂载) + - widget 存在且有流式内容:保持已渲染内容不变,仅清空引用 + - widget 存在但无内容(异常场景)且有 full_text:用 full_text 填充 + - widget 不存在(未收到任何 chunk)且有 full_text:补建 + - widget 不存在且无内容:正常无内容场景 """ + if self._ai_finalized: + return w = self._ai_widget self._ai_widget = None - if w is None: - # 流式期间丢帧,只在 complete 收到全文:补建完整消息 + + if w is not None: + if self._ai_text: + # 有流式内容,chunk 已完整渲染,直接保留,不做替换 + pass + elif full_text: + # widget存在但没收到任何chunk(异常):用full_text填充 + w.update(full_text) + self._ai_text = full_text + else: + # widget存在但没有任何输出:移除空框(正常不会走到这里,因为不预创建) + await w.remove() + else: + # widget不存在(start_ai后没收到任何chunk),有full_text则补建 if full_text: self._ai_text = full_text - await self.mount(Markdown(full_text, classes="msg-ai")) + self._ai_widget = Markdown(full_text, classes="msg-ai") + await self.mount(self._ai_widget) + self._ai_widget = None self._anchor_bottom() - return - if full_text and full_text != self._ai_text: - # 已显示内容与全文不一致(丢字/乱序/丢帧),整体替换对齐 - await w.remove() - self._ai_text = full_text - await self.mount(Markdown(full_text, classes="msg-ai")) - elif not full_text and not self._ai_text: - # 预建后没有任何输出:移除空框 - await w.remove() + self._ai_text = "" + self._reasoning_widget = None + self._reasoning_text = "" + self._ai_finalized = True self.call_after_refresh(self.scroll_end, animate=False) async def reset(self): """清空聊天区(/clear)。""" self._ai_widget = None + self._reasoning_widget = None self._ai_text = "" + self._reasoning_text = "" + self._ai_finalized = True await self.remove_children() diff --git a/README.md b/README.md index 4403c6e..7997201 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # micro_servers 一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。 -`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。 +`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`confs` 提供统一配置分发,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。 ## 架构总览 @@ -24,7 +24,8 @@ └──────────────────►│ ├─────────────►│─────────────────┘ │ ├────────────►│ └─────── HTTP /review ──────────────┘ └────────────►│ - 日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox ──(log_client, pymsg)──► db:8005 (Logs 表) + 日志链路: waf / gateway / static / dnfs / ai_review / captcha / smtp / aibox / confs ──(log_client, pymsg)──► db:8005 (Logs 表) + 配置链路: static 等业务服务 ──(pymsg get)──► confs:8010(配置统一存放于 confs/configs/,mtime 变化自动重载) ``` ### 请求链路 @@ -50,6 +51,7 @@ | [ai_review](ai_review/) | Python(Flask) | 8007 | AI 内容审核服务,DeepSeek LLM + ChromaDB RAG,支持人工反馈纠偏 | | [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 | | [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env | +| [confs](confs/) | Python(pymsg) | 8010 | 统一配置服务,集中存放并分发各服务配置文件(pymsg 路由 get/list/reload/health),内存缓存 + mtime 自动重载,路径遍历防护 | | [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 | | [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) | @@ -92,7 +94,7 @@ - **账号旁路**:`/api/check_user`、`/api/reset_password` 等接口直接读写 `gateway/sqlite3.db` 的 Users 表;`/yhdet_callback`、`/api/yhdet_*` 对接 yhdet.top OAuth 登录。 - 统一品牌"树状菜单",支持深色模式(`css/theme.css`)。 - 可使用 Nuitka 编译为独立二进制(`fsys.bin`)。 -- 配置见 [static/config.ini](static/config.ini)(注:CORS 白名单硬编码于 `fsys.py`,config.ini 的 cors 段未生效)。 +- 配置由 `confs` 统一配置服务分发(`confs/configs/static.ini`,启动时经 pymsg 拉取,`CONFS_HOST`/`CONFS_PORT` 环境变量可覆盖);本地 [static/config.ini](static/config.ini) 作为 confs 不可用时的 fail-safe 回退。 ### dnfs(树状菜单服务) @@ -140,6 +142,13 @@ - pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。 - 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。 +### confs(统一配置服务) + +- 集中存放各微服务的配置文件(`confs/configs/`),基于 pymsg(RpcServer,TCP 长度前缀帧 + JSON)分发,监听 127.0.0.1:8010。 +- pymsg 路由:`get`(取配置文件全文;带 `key` 时按键提取,ini 用 `Section.option`、json 用点号路径)、`list`(列出全部配置及元信息)、`reload`(重载指定配置,缺省清空全部缓存)、`health`。 +- 内存缓存 + 文件 mtime 变化自动重载,改配置无需重启 confs;配置名校验(basename + realpath 前缀 + 扩展名白名单)防路径遍历。 +- 消费方约定:服务启动时用 `RpcClient(8010).call("get", {"name": "<服务名>.ini"})` 拉取配置,confs 不可用时回退本地配置文件(fail-safe)。目前 `static` 已接入。 + ### aibox(独立 AI 智能体系统) - 一套相对独立的单智能体产品,基于 DeepSeek + WebSocket(:8765),由 `core` 拉起但与 waf / gateway / dnfs 等主干微服务无调用关系。 @@ -185,6 +194,7 @@ | 8007 | ai_review | 127.0.0.1 | AI 审核(仅内部调用) | | 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) | | 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) | +| 8010 | confs | 127.0.0.1 | 统一配置服务(pymsg/TCP,仅内部调用) | | 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) | | — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) | @@ -218,7 +228,7 @@ bash _setup.deb.sh # Debian/Ubuntu # 或 bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora -# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> core) +# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> captcha -> confs -> core) bash _inst.sh ``` @@ -231,7 +241,7 @@ bash _run.sh # 运行 部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。 -> **注意**:`ai_review` 与 `captcha` 未包含在 `_inst.sh` 中,需分别进入 `ai_review/`、`captcha/` 目录执行 `_set.sh` 安装。其中 `ai_review` 需额外配置 `.env` 文件(DeepSeek API 密钥等)。 +> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需进入 `ai_review/` 目录执行 `_set.sh` 安装,并额外配置 `.env` 文件(DeepSeek API 密钥等)。 > **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。 @@ -240,8 +250,9 @@ bash _run.sh # 运行 由 `core` 守护的子进程通过 `init.txt` 自启动(顺序很重要,waf 必须先于 gateway): ``` -bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/db/_run.sh +bash /opt/micro_servers/confs/_run.sh +bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/dnfs/_run.sh bash /opt/micro_servers/smtp/_run.sh bash /opt/micro_servers/static/_run.sh diff --git a/_inst.sh b/_inst.sh index 03c5f4d..94cd456 100755 --- a/_inst.sh +++ b/_inst.sh @@ -35,4 +35,6 @@ inst_module "static" inst_module "dnfs" inst_module "db" inst_module "smtp" +inst_module "captcha" +inst_module "confs" inst_module "core" # core last diff --git a/ai_review/.dockerignore b/ai_review/.dockerignore new file mode 100644 index 0000000..7b284e0 --- /dev/null +++ b/ai_review/.dockerignore @@ -0,0 +1,18 @@ +.env +.env/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +.git +.gitignore +*.md +!README.md +.envrc +.venv/ +venv/ +ENV/ +chroma/ +.DS_Store diff --git a/ai_review/Dockerfile b/ai_review/Dockerfile new file mode 100644 index 0000000..a31deb5 --- /dev/null +++ b/ai_review/Dockerfile @@ -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"] diff --git a/ai_review/docs/SECURITY_AUDIT_20260801.md b/ai_review/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..3fa27c4 --- /dev/null +++ b/ai_review/docs/SECURITY_AUDIT_20260801.md @@ -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 知识库文件校验 diff --git a/aibox/README.md b/aibox/README.md index 3b3e313..a1105a8 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -19,6 +19,8 @@ - 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化 - 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词) - 📦 **对话压缩**:消息数或 Token 数达到阈值时自动压缩,保持上下文质量 +- 🛡️ **内存安全防护**:文件读取按需加载不全载、命令输出 1MB 限制、单行超长截断、工具结果多层防护防止 OOM +- 🔒 **错误自动恢复**:API 请求失败自动回滚消息,工具结果安全清洗(控制字符过滤、非字符串转换) ## 🚀 快速开始 @@ -32,7 +34,7 @@ ```bash # 克隆或下载代码后,安装依赖 -pip install websockets requests beautifulsoup4 +pip install -r requirements.txt ``` ### 配置 @@ -46,18 +48,18 @@ export DEEPSEEK_API_KEY="your-api-key" 3. **启动服务器**: ```bash -python main.py +python aibox.py ``` 启动后,WebSocket 服务器将运行在 `ws://localhost:8765`(地址与端口可在 `config.json` 的 `system.websocket_host` / `system.websocket_port` 中修改)。 -> 注意:本模块入口文件为 `main.py`(不是 `agent.py`)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`_run.sh` 为被 `core` 拉起的启动脚本。 +> 注意:本模块入口文件为 `aibox.py`(单文件约 8450 行,集成 WebSocket 服务器、AI 引擎、工具/技能系统、任务调度器于一体)。`client.py` 为示例 WebSocket 客户端,`voice_client.py` 为语音客户端,`_run.sh` 为被 `core` 拉起的启动脚本。 ## 📁 目录结构 ``` aibox/ -├── main.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统) +├── aibox.py # 服务主程序(WebSocket 服务器 + AI 引擎 + 工具/技能系统 + 任务调度器) ├── client.py # 示例 WebSocket 客户端 ├── voice_client.py # 语音客户端 ├── entry.html # 前端入口页 @@ -85,12 +87,22 @@ aibox/ ``` ~/.aibox/ ├── config.json # 配置文件 -├── prompts/ # 系统提示词目录 -│ ├── 00_IDENTITY.md # 用户身份信息 -│ ├── 1_*.md # 基础技能提示词(文件操作、网页访问等) +├── prompts/ # 系统提示词目录(按文件名数字前缀排序加载) +│ ├── 00_IDENTITY.md # 用户身份信息(最先加载) +│ ├── 1_memory_search.md # 基础技能提示词(1-10,按数字升序加载) +│ ├── 2_memory_save.md +│ ├── 3_file_operations.md +│ ├── 4_web_access.md +│ ├── 5_memo_management.md +│ ├── 6_command_execution.md +│ ├── 7_extension_skills.md +│ ├── 8_identity_management.md +│ ├── 9_language_instruction.md +│ ├── 10_task_handling.md │ ├── 99_SOUL.md # AI 灵魂定义(角色、性格、能力边界) -│ └── 100_STATUS.md # AI 状态记录 +│ └── 100_STATUS.md # AI 状态记录(最后加载) ├── skills/ # 扩展技能目录(运行时从仓库 skills/ 复制或用户自建) +├── workspace/ # AI 专用工作目录(临时文件、脚本、数据文件默认存放位置) ├── memories.db # 记忆和任务数据库(SQLite) ├── current_conversation.json # 当前对话 ├── conversation_history.json # 对话索引 @@ -98,6 +110,16 @@ aibox/ └── tool_results/ # 工具结果缓存 ``` +## 📂 工作目录规范 + +为避免 AI 产生的临时文件污染系统其他目录,所有 AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/` 目录: + +- **文件操作**:`write_file` 工具默认路径为 `~/.aibox/workspace/`(用户明确指定路径时除外) +- **命令执行**:`run_command` 工具默认 `work_dir` 为 `~/.aibox/workspace/`,命令产生的文件落在该专用工作区 +- **用户指定路径优先**:当用户明确要求在特定路径操作时,按用户指定路径执行 +- **读取不受限**:读取系统文件、项目文件时使用实际路径,不受工作目录限制 +- **技能调用**:`~/.aibox/skills/` 下的技能执行不受此限制 + ## 🔧 可用工具 AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,在 `DeepSeekChat` 中注册): @@ -192,15 +214,23 @@ const ws = new WebSocket('ws://localhost:8765'); | 类型 | 说明 | |-----|------| -| `chunk` | AI 响应的片段(流式输出) | -| `complete` | AI 响应完成 | -| `reasoning` | AI 思考过程(思考模式) | -| `error` | 错误信息 | +| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) | +| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) | +| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) | +| `reasoning_end` | AI 思考结束信号(切换到正式回答) | +| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) | +| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) | +| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) | | `command_result` | 命令执行结果 | | `task_status` | 任务状态更新(executing/complete/failed) | | `task_process` | 任务执行过程中的 AI 输出 | +| `task_error` | 任务执行错误 | +| `task_added` | 任务添加成功 | | `dialogue_ended` | 对话结束通知 | | `dialogue_reset` | 对话重置完成 | +| `notification` | 通知消息 | +| `connection_ack` | 连接确认 | +| `interrupt_ack` | 中断确认 | | `info` | 系统信息 | | `pong` | Ping 响应 | @@ -358,15 +388,17 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i ## 🧩 提示词系统 -提示词文件位于 `~/.aibox/prompts/`: +提示词文件位于 `~/.aibox/prompts/`,按文件名**数字前缀升序**加载(identity → 技能 1~10 → soul → status): | 文件 | 用途 | 更新方式 | |-----|------|---------| | `00_IDENTITY.md` | 用户身份信息 | AI 通过 `update_identity` 工具更新 | -| `1_*.md` | 基础技能提示词 | 手动编辑,AI 通过 `reload_prompts` 重载 | -| `99_SOUL.md` | AI 灵魂定义(角色、性格) | AI 通过 `update_soul` 工具更新 | +| `N_*.md`(N=1~10) | 基础技能提示词(记忆搜索/保存、文件/网页/命令/任务操作、技能扩展、身份管理、语言、任务规则) | 手动编辑,AI 通过 `/reload_prompts` 重载 | +| `99_SOUL.md` | AI 灵魂定义(角色、性格、能力边界、行为准则) | AI 通过 `update_soul` 工具更新 | | `100_STATUS.md` | AI 状态记录 | AI 通过 `save_memory_and_end_conversation` 更新 | +> **加载顺序保证**:`00_IDENTITY.md` 最先加载(AI 先知道用户是谁),然后是 1~10 号技能提示词(按数字升序),接着是 `99_SOUL.md`(AI 的角色定位),最后是 `100_STATUS.md`(当前状态)。每轮用户输入前会附加一段简洁的工作流程提醒(不重复 system prompt 中的详细说明),工具调用后递归继续时不再重复注入。 + ## ⚙️ 配置说明 主要配置项(`~/.aibox/config.json`,首次启动自动生成): @@ -385,7 +417,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i "websocket_host": "localhost", "websocket_port": 8765, "skills_dir": "~/.aibox/skills", - "prompts_dir": "~/.aibox/prompts" + "prompts_dir": "~/.aibox/prompts", + "workspace_dir": "~/.aibox/workspace" }, "scheduler": { "enabled": true, @@ -396,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i "api": { "deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_model": "deepseek-v4-flash", + "reasoning_enabled": false, "api_key_env": "DEEPSEEK_API_KEY", - "timeout": 30, + "timeout": 120, "temperature": 0.7 }, "context": { @@ -441,7 +475,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i "archive_dir": "archive" }, "tools": { - "max_result_length": 10000, + "max_result_length": 1048576, "save_result_to_file": false, "tool_results_dir": "tool_results" }, @@ -461,12 +495,14 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i } ``` -> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 +> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。 +> +> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。 ## 🛠️ 命令行参数 ```bash -python main.py [选项] +python aibox.py [选项] 选项: --verbose, -v 显示详细日志 @@ -487,19 +523,19 @@ python main.py [选项] ```bash # 启动服务器(详细日志) -python main.py --verbose +python aibox.py --verbose # 指定端口 -python main.py --port 9000 +python aibox.py --port 9000 # 检查任务 -python main.py --check-memo +python aibox.py --check-memo # 添加任务(命令行) -python main.py --memo-add "提醒开会|下午3点会议室A|+1h" +python aibox.py --memo-add "提醒开会|下午3点会议室A|+1h" # 查看所有任务 -python main.py --memo-list +python aibox.py --memo-list ``` ## 🔄 架构流程 @@ -539,9 +575,8 @@ python main.py --memo-list ### 普通对话流程 ``` Client -> Server: {type: "message", content: "帮我查天气"} -Server -> Client: {type: "chunk", content: "正在查"} -Server -> Client: {type: "chunk", content: "询天气..."} -Server -> Client: {type: "tool_call", tool: "search_skills", args: {keyword: "天气"}} +Server -> Client: {type: "line", content: "🔧 调用工具: search_skills\n"} +Server -> Client: {type: "line", content: "🔧 调用工具: bing_search\n"} Server -> Client: {type: "chunk", content: "今日天气晴朗..."} Server -> Client: {type: "complete", content: "今日天气晴朗,温度25°C"} ``` @@ -569,10 +604,10 @@ Server -> Client: {type: "dialogue_reset", content: "✅ 对话状态已完全 ### 添加新工具 -1. 继承 `Tool` 基类(位于 `main.py`) +1. 继承 `Tool` 基类(位于 `aibox.py`) 2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()` -3. 可选实现 `execute_streaming()` 支持流式输出 -4. 在 `DeepSeekChat` 的工具注册处注册 +3. 可选实现 `execute_streaming()` 支持流式输出(通过 `output_queue` 发送 `("line", content)` 和 `("complete", final_result)`) +4. 在 `DeepSeekChat._register_tools()` 中注册 ```python class MyTool(Tool): @@ -593,6 +628,11 @@ class MyTool(Tool): return f"执行结果: {param}" ``` +> **注意**:`execute()` 必须返回字符串。工具结果会经过多层安全防护: +> 1. **工具层源头限制**:`read_file` 按需读取不全载(最多500行,单行≤10KB),`run_command` 输出≤1MB(超出立即终止进程) +> 2. **`_sanitize_tool_result()`**:None/非字符串转为安全字符串,过滤控制字符 +> 3. **最终兜底截断**:超过 `tools.max_result_length`(默认1MB字符)时截断并提示,避免破坏 API 请求格式 + ### 添加新命令 在 `WebSocketHandler` 的命令处理分支(`handle_command`)中添加: @@ -603,6 +643,10 @@ elif command == "/mycommand": await self.send_message(websocket, {"type": "command_result", "content": result}) ``` +### 错误恢复机制 + +`think_and_respond()` 在每轮对话开始前记录消息列表长度 `msgs_before_turn`。若 API 请求失败(网络错误、HTTP 错误、JSON 解析错误或其他异常),会自动调用 `_rollback_messages(msgs_before_turn)` 回滚本轮添加的所有消息(包括 user 消息、可能的 assistant/tool_calls 消息、tool 响应消息),防止格式错误的消息污染对话历史导致后续请求持续失败。客户端收到 `error` 消息后可安全重试。 + ## ❓ 常见问题 ### Q: API Key 无效怎么办? @@ -648,15 +692,65 @@ elif command == "/mycommand": ### Q: WebSocket 连接断开? **A:** -1. 检查服务器是否运行:`ps aux | grep main.py` +1. 检查服务器是否运行:`ps aux | grep aibox.py` 2. 确认端口未被占用:`netstat -tlnp | grep 8765` 3. 检查防火墙设置 4. 查看服务器日志:`tail -f ~/.aibox/ai.log` 5. 考虑使用 `--host 0.0.0.0` 允许外部连接 +### Q: 工具调用后 API 报错无法继续? +**A:** 此问题已在 v2.1 修复:工具结果会经过安全清洗(控制字符过滤、非字符串转换、超长截断),API 请求失败时自动回滚本轮消息。如果仍出现问题: +1. 查看日志:`tail -f ~/.aibox/ai.log` +2. 使用 `/new` 命令重置对话 +3. 确认工具返回值为字符串且不含二进制/控制字符 + ## 📄 更新日志 -### v2.0 (当前版本) +### v2.5 +- ✅ **新增 DeepSeek 思考模式支持**(Reasoning Mode): + - 新增 `api.reasoning_enabled` 配置项(默认 `false`),启用后在请求中添加 `thinking: {type: "enabled"}` 参数 + - 使用 `deepseek-v4-flash` 模型原生思考能力,**思考模式下完整支持工具调用**(文件操作、命令执行、记忆搜索等均可正常使用) + - 实时展示 AI 思考过程:新增 `reasoning_start`/`reasoning`/`reasoning_end` WebSocket 消息类型 + - 思考内容在前端以灰色斜体区域独立展示,超长思考自动折叠显示最后 2000 字符 + - 即使未启用思考模式,若模型返回 `reasoning_content` 也会自动实时展示 + - API 超时默认从 30s 提高到 120s,适配思考模式更长的响应时间 +- ✅ 删除重复定义的 `_add_assistant_message_with_reasoning` 方法 + +### v2.4 +- ✅ **新增专用工作目录规范**: + - AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录 + - `write_file` 工具默认路径设为工作目录(用户明确指定路径时除外) + - `run_command` 工具默认 `work_dir` 设为工作目录,命令产生的文件落在专用工作区 + - 新增 `system.workspace_dir` 配置项(默认 `~/.aibox/workspace`),可自定义工作目录位置 + - 在文件操作和命令执行提示词中明确工作目录规范 +- ✅ 命令执行提示词补充 1MB 输出大小限制说明 + +### v2.3 +- ✅ **修复工具输出OOM崩溃问题**: + - `read_file`:不再一次性 `readlines()` 加载整个文件,改为按行范围按需读取;大文件 `tail=true` 使用反向块读取(>1MB文件不全文遍历);新增单行长度限制(10KB/行,超长截断);`max_lines` 硬上限500行防止请求超大范围 + - `run_command`:非流式执行从 `capture_output=True` 改为逐行读取并实时计数,输出达到1MB立即终止进程;流式执行同样增加1MB输出大小限制,超出终止并提示截断;统一stdout/stderr合并计数 + - `tools.max_result_length` 默认值从10KB提高到1MB字符,作为最终兜底截断 + - 形成"工具层源头限制→结果清洗→最终兜底"三层防护,彻底防止大文件/大输出导致内存溢出 + +### v2.2 +- ✅ 修复提示词系统排序 bug:`SKILL_PREFIX = "1_"` 只匹配 `1_` 开头文件,导致 `2_`~`10_` 技能提示词排序错乱(`10_task_handling.md` 排在 `1_memory_search.md` 前面);改用正则按数字前缀排序 +- ✅ 修复 `_init_system_prompts` 使用未排序列表的问题,确保 system prompt 按 identity → 技能 → soul → status 正确顺序加载 +- ✅ 精简每轮用户输入的 pre_prompt:原来约 30 行重复指令(与 system prompt 内容重复),每轮浪费 ~300 token;精简为 5 条简洁提醒 +- ✅ 修复工具调用后递归继续时重复注入工作流指令的问题:新增 `is_continuation` 参数,递归时跳过 pre_prompt +- ✅ 优化默认大文件阈值:50KB/200行 → 500KB/2000行,增加 `tail=true` 和二进制文件处理指导 +- ✅ 优化记忆搜索习惯:"每次都要做"→"根据话题相关性判断",避免简单问候也触发无意义搜索 + +### v2.1 +- ✅ 修复工具调用结果破坏对话导致 API 持续请求失败的问题 + - 不再回传 `reasoning_content`(API 响应字段)到请求中 + - `tool_call.id` 为空时自动生成 fallback id,防止 API 400 + - 新增 `_sanitize_tool_result()`:None/非字符串安全转换、控制字符过滤、超长结果截断 + - `execute_tool` 异常捕获,避免工具执行异常中断整个对话 + - API 请求失败时自动回滚本轮消息(`_rollback_messages`),防止坏消息污染历史 +- ✅ 修复 `complete` 消息语义:chunk 流式为内容权威来源,complete 仅用于收尾 +- ✅ 修正文档:入口文件为 `aibox.py`(非 `main.py`),补充缺失的消息类型(`line`/`task_error`/`notification` 等) + +### v2.0 - ✅ 新增 `update_soul` 工具(智能体自我进化) - ✅ 新增 `create_skill` 工具(AI 自主创建技能) - ✅ 新增 `install_skill` 工具(技能安装指引) diff --git a/aibox/SECURITY_AUDIT.md b/aibox/SECURITY_AUDIT.md new file mode 100644 index 0000000..ba136af --- /dev/null +++ b/aibox/SECURITY_AUDIT.md @@ -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 diff --git a/aibox/aibox.py b/aibox/aibox.py index 9c9604f..2ee8098 100644 --- a/aibox/aibox.py +++ b/aibox/aibox.py @@ -436,7 +436,7 @@ class PromptManager: 文件命名规范: - 00_IDENTITY.md: 用户身份信息 - - 1_*.md/txt: 基础技能(必须从1_开头) + - N_*.md/txt: 基础技能提示词(N 为数字,如 1_memory_search.md、10_task_handling.md),按数字升序加载 - 99_SOUL.md: 智能体灵魂文件(角色定位、身份定义、性格等) - 100_STATUS.md: 智能体状态记录 """ @@ -450,8 +450,8 @@ class PromptManager: STATUS_FILE = "100_STATUS.md" SOUL_FILE = "99_SOUL.md" # 智能体灵魂文件 - 所有角色定位内容 - # 基础技能前缀 - SKILL_PREFIX = "1_" # 基础技能文件必须以这个开头 + # 基础技能文件名模式:N_xxx.md(N 为数字,如 1_memory_search.md、10_task_handling.md) + _SKILL_FILE_RE = re.compile(r'^(\d+)_') def __new__(cls): if cls._instance is None: @@ -642,14 +642,17 @@ class PromptManager: print(f"⚠️ 创建默认灵魂文件失败: {e}") def _ensure_skill_files(self): - """确保基础技能文件存在 - 以1_开头的文件""" + """确保基础技能文件存在 - N_ 开头的文件(N 为数字,按数字顺序加载)""" skill_files = [ { 'filename': '1_memory_search.md', 'content': """# 记忆搜索习惯 -## 重要习惯 -在每次回答用户问题前,**必须**先使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆。 +## 核心习惯 +在回答用户问题前,**应根据话题相关性判断是否需要搜索记忆**: +- 涉及过往讨论、用户偏好、之前做过的事 → 必须搜索 +- 全新话题、简单问候("你好")、确认回应("好的""明白了")→ 无需搜索 +- 不确定时,搜索一下也无妨 ## 为什么要这样做? - 保持对话的连贯性 @@ -665,11 +668,9 @@ memory search keywords=["关键词1", "关键词2"] ## 示例 如果用户说:"上次我们讨论的那个项目怎么样了?" 你应该: -1. 先搜索 "项目" 相关的记忆 +1. 搜索 "项目" 相关的记忆 2. 根据搜索结果回答 3. 如果找不到相关记忆,诚实告知用户 - -记住:这是你的核心习惯,每次都要做! """ }, { @@ -705,25 +706,34 @@ save_memory_and_end_conversation( 'filename': '3_file_operations.md', 'content': """# 文件操作能力 +## 工作目录规范(重要) +**所有你主动创建的临时文件、脚本、数据文件,必须写入 `~/.aibox/workspace/` 目录,不要在其他目录随便写文件。** +- 该目录是你的专用工作区,用于存放代码片段、临时脚本、下载的数据、生成的文件等 +- 用户明确指定文件路径时,按照用户要求的路径操作 +- 读取系统文件、项目文件时使用实际路径,不限制 + ## 可用工具 - **read_file**: 读取文件内容 - **write_file**: 写入文件 - **list_files**: 列出目录 ## 大文件处理规则 -当遇到以下情况时,**必须先告知用户文件过大**: -1. 文件大小超过 50KB -2. 文件行数超过 200 行 +当遇到以下情况时,**必须分页读取**,不要一次性读取整个文件: +1. 文件大小超过 500KB +2. 文件行数超过 2000 行 对于大文件,应该: -- 使用 head/tail 参数只读取开头或结尾 +- 先用 head=true 读取前50行了解文件结构 - 使用 start_line/end_line 参数指定行范围 -- 建议用户提供更具体的查询条件 +- 使用 tail=true 读取文件末尾 +- 对于二进制文件或特殊格式文件,使用 run_command 配合 head/strings 等命令查看 +- 建议用户提供更具体的查询条件以缩小范围 ## 示例 ``` read_file filepath="/path/to/file.txt" head=true max_lines=50 -read_file filepath="/path/to/file.txt" start_line=10 end_line=20 +read_file filepath="/path/to/file.txt" start_line=100 end_line=200 +read_file filepath="/path/to/file.txt" tail=true max_lines=50 ``` """ }, @@ -779,6 +789,12 @@ web action="get" url="https://www.baidu.com/s?wd=人工智能" 'filename': '6_command_execution.md', 'content': """# 命令执行能力 +## 工作目录规范(重要) +**执行命令时,默认 work_dir 设置为 `~/.aibox/workspace/`**,所有命令执行产生的文件都落在这个专用工作区,不会污染其他目录。 +- 只有当用户明确要求在特定目录执行命令时,才修改 work_dir +- 技能调用(~/.aibox/skills/)不受此限制 +- 需要在用户项目目录操作时,按用户指定路径设置 work_dir + ## 可用工具 - **run_command**: 执行系统命令 @@ -786,17 +802,18 @@ web action="get" url="https://www.baidu.com/s?wd=人工智能" - command: 要执行的命令(必填) - timeout: 超时时间(秒),默认30秒 - stream: 是否实时输出,默认true -- work_dir: 工作目录,默认当前目录 +- work_dir: 工作目录,默认 `~/.aibox/workspace/` ## 安全提醒 - 命令执行功能可能被禁用(根据配置) - 执行危险命令前请三思 - 超时机制可以防止命令卡死 +- 命令输出有大小限制(1MB),超出会自动截断 ## 示例 ``` run_command command="ls -la" timeout=10 -run_command command="python script.py" stream=true +run_command command="python script.py" stream=true work_dir="~/.aibox/workspace" ``` """ }, @@ -1047,42 +1064,55 @@ update_soul content="我是...(新的自我定义)" """创建默认提示词(已由各 _ensure_* 方法处理)""" pass + @classmethod + def _skill_sort_key(cls, filename: str): + """技能文件按前缀数字排序,非技能文件返回 None。""" + m = cls._SKILL_FILE_RE.match(filename) + return int(m.group(1)) if m else None + + def _prompt_sort_key(self, p): + """排序规则:00_IDENTITY → 技能文件(按数字升序) → 99_SOUL → 100_STATUS → 其他""" + file = p['file'] + if file == self.IDENTITY_FILE: + return (0,) + skill_num = self._skill_sort_key(file) + if skill_num is not None: + return (1, skill_num) + if file == self.SOUL_FILE: + return (2,) + if file == self.STATUS_FILE: + return (4,) + return (3, file) # 其他文件按名字排序 + def get_prompts(self, join_with: str = "\n\n") -> str: - """获取所有提示词,按顺序拼接""" + """获取所有提示词,按顺序拼接(identity → 技能文件 → soul → status)""" if not self._prompts: return "" - - # 排序规则:先基础技能(1_开头),再其他,最后灵魂文件 - def sort_key(p): - file = p['file'] - if file == self.SOUL_FILE: - return 3 # 灵魂文件最后加载 - elif file.startswith(self.SKILL_PREFIX): - return 1 # 基础技能 - elif file == self.IDENTITY_FILE: - return 0 # 身份文件最先 - elif file == self.STATUS_FILE: - return 4 # 状态文件最后 - else: - return 2 - - sorted_prompts = sorted(self._prompts, key=sort_key) + sorted_prompts = sorted(self._prompts, key=self._prompt_sort_key) contents = [p['content'] for p in sorted_prompts] return join_with.join(contents) - + + def get_sorted_prompts_list(self) -> List[Dict]: + """获取按正确顺序排列的提示词列表(用于 system 消息构建)。""" + return sorted(self._prompts, key=self._prompt_sort_key) + def get_prompts_list(self) -> List[Dict]: - """获取所有提示词列表""" + """获取所有提示词列表(原始加载顺序,已弃用,请用 get_sorted_prompts_list)。""" return self._prompts.copy() - + + def get_skill_prompts(self) -> List[Dict]: + """获取所有基础技能提示词(N_ 开头的文件)。""" + return [p for p in self._prompts if self._skill_sort_key(p['file']) is not None] + def reload(self): """重新加载所有提示词""" self._prompts = [] self._load_prompts() return len(self._prompts) - + def get_prompt_count(self) -> int: return len(self._prompts) - + def get_prompts_dir_path(self) -> str: return self._prompts_dir @@ -1125,6 +1155,7 @@ class ConfigManager: "log_backup_count": 5, "skills_dir": "~/.aibox/skills", "prompts_dir": "~/.aibox/prompts", + "workspace_dir": "~/.aibox/workspace", "websocket_host": "localhost", "websocket_port": 8765, }, @@ -1137,8 +1168,9 @@ class ConfigManager: "api": { "deepseek_api_url": "https://api.deepseek.com/chat/completions", "deepseek_model": "deepseek-v4-flash", + "reasoning_enabled": false, "api_key_env": "DEEPSEEK_API_KEY", - "timeout": 30, + "timeout": 120, "temperature": 0.7 }, "context": { @@ -1325,7 +1357,11 @@ class Constants: @property def DEEPSEEK_MODEL(cls): - return cls._get_config().get("api.deepseek_model") + return cls._get_config().get("api.deepseek_model", "deepseek-v4-flash") + + @property + def REASONING_ENABLED(cls): + return cls._get_config().get("api.reasoning_enabled", False) @property def DEFAULT_TIMEOUT(cls): @@ -1355,6 +1391,10 @@ class Constants: def SKILLS_DIR(cls): return os.path.expanduser(cls._get_config().get("system.skills_dir", "~/.aibox/skills")) + @property + def WORKSPACE_DIR(cls): + return os.path.expanduser(cls._get_config().get("system.workspace_dir", "~/.aibox/workspace")) + @property def PROMPTS_DIR(cls): return os.path.expanduser(cls._get_config().get("system.prompts_dir", "~/.aibox/prompts")) @@ -1498,7 +1538,7 @@ class Constants: @property def TOOL_MAX_RESULT_LENGTH(cls): - return cls._get_config().get("tools.max_result_length", 10000) + return cls._get_config().get("tools.max_result_length", 1048576) # 1MB 字符限制 @property def COMPRESSOR_MIN_MESSAGES(cls): @@ -3492,77 +3532,75 @@ class CommandExecutor(ABC): class SubprocessCommandExecutor(CommandExecutor): - """基于subprocess的命令执行器 - 支持超时和工作目录""" + """基于subprocess的命令执行器 - 支持超时、工作目录和输出大小限制""" - def execute(self, command: str, timeout: int = 30, work_dir: str = ".", env: Dict = None, **kwargs) -> str: - """执行命令并返回完整结果""" + # 命令输出最大字节数(1MB),超出截断 + MAX_OUTPUT_BYTES = 1 * 1024 * 1024 + + def _read_with_limit(self, process, timeout: int, start_time: float, output_queue=None) -> Tuple[str, bool, bool]: + """从进程读取输出,限制总大小和超时。 + 返回 (output_str, timeout_occurred, truncated) + """ + output_chunks = [] + total_bytes = 0 + timeout_occurred = False + truncated = False + + while True: + # 检查超时 + if timeout > 0 and time.time() - start_time > timeout: + process.terminate() + timeout_occurred = True + break + + # 检查输出大小 + if total_bytes >= self.MAX_OUTPUT_BYTES: + truncated = True + process.terminate() + break + + # 读取一行 + line = process.stdout.readline() + if not line and process.poll() is not None: + break + if line: + line_bytes = len(line.encode('utf-8', errors='replace')) + if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated: + allowed = self.MAX_OUTPUT_BYTES - total_bytes + if allowed > 0: + output_chunks.append(line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace')) + total_bytes = self.MAX_OUTPUT_BYTES + truncated = True + process.terminate() + break + + output_chunks.append(line) + total_bytes += line_bytes + + if output_queue: + output_queue.put(("line", line.rstrip('\n') + "\n")) + + return ''.join(output_chunks), timeout_occurred, truncated + + def execute(self, command: str, timeout: int = 30, work_dir: str = None, env: Dict = None, **kwargs) -> str: + """执行命令并返回完整结果(带输出大小限制)""" if not self.is_enabled(): return "❌ 命令执行功能已禁用" - self.logger.info(f"执行命令: {command}") + # 默认工作目录为workspace,避免在启动目录散落文件 + if work_dir is None or work_dir == ".": + work_dir = constants.WORKSPACE_DIR + + self.logger.info(f"执行命令: {command} (work_dir={work_dir})") # 准备环境变量 exec_env = os.environ.copy() if env: exec_env.update(env) + process = None try: - result = subprocess.run( - command, - shell=True, - capture_output=True, - text=True, - timeout=timeout, - env=exec_env, - cwd=os.path.expanduser(work_dir) - ) - - data = {} - - if result.stdout: - data["输出"] = result.stdout.strip() - if result.stderr: - data["错误"] = result.stderr.strip() - - data["返回码"] = result.returncode - - if not result.stdout and not result.stderr: - return self.format_result(True, "命令执行成功,无输出", data) - - return self.format_result(result.returncode == 0, "命令执行完成", data) - - except subprocess.TimeoutExpired: - return f"❌ 命令执行超时({timeout}秒)" - except FileNotFoundError as e: - return f"❌ 命令执行错误: 命令未找到 - {str(e)}" - except PermissionError as e: - return f"❌ 命令执行错误: 权限不足 - {str(e)}" - except Exception as e: - return f"❌ 执行错误: {str(e)}" - - def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, - work_dir: str = ".", env: Dict = None, **kwargs): - """流式执行命令,支持超时控制和工作目录""" - if not self.is_enabled(): - output_queue.put(("line", "❌ 命令执行功能已禁用\n")) - output_queue.put(("complete", "❌ 命令执行功能已禁用")) - return - - self.logger.info(f"流式执行命令: {command}") - output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) - output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) - if work_dir != ".": - output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) - output_queue.put(("line", "-" * 50 + "\n")) - - # 准备环境变量 - exec_env = os.environ.copy() - if env: - exec_env.update(env) - - try: - # 使用管道来实时获取输出 - self.process = subprocess.Popen( + process = subprocess.Popen( command, shell=True, stdout=subprocess.PIPE, @@ -3575,57 +3613,165 @@ class SubprocessCommandExecutor(CommandExecutor): cwd=os.path.expanduser(work_dir) ) + start_time = time.time() + output_str, timeout_occurred, truncated = self._read_with_limit(process, timeout, start_time) + + # 确保进程终止 + if process.poll() is None: + if timeout_occurred or truncated: + process.kill() + else: + process.terminate() + try: + process.wait(timeout=2) + except: + process.kill() + + return_code = process.returncode if process.returncode is not None else -1 + + data = { + "输出": output_str.strip() if output_str.strip() else "(无输出)", + "返回码": return_code + } + + if timeout_occurred: + return self.format_result(False, f"命令执行超时({timeout}秒)", data) + + if truncated: + data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断" + return self.format_result(False, "命令执行完成(输出已截断)", data) + + return self.format_result(return_code == 0, "命令执行完成", data) + + except subprocess.TimeoutExpired: + return f"❌ 命令执行超时({timeout}秒)" + except FileNotFoundError as e: + return f"❌ 命令执行错误: 命令未找到 - {str(e)}" + except PermissionError as e: + return f"❌ 命令执行错误: 权限不足 - {str(e)}" + except Exception as e: + return f"❌ 执行错误: {str(e)}" + finally: + if process and process.poll() is None: + process.kill() + + def execute_streaming(self, command: str, output_queue: queue.Queue, timeout: int = 30, + work_dir: str = None, env: Dict = None, **kwargs): + """流式执行命令,支持超时控制、工作目录和输出大小限制""" + if not self.is_enabled(): + output_queue.put(("line", "❌ 命令执行功能已禁用\n")) + output_queue.put(("complete", "❌ 命令执行功能已禁用")) + return + + # 默认工作目录为workspace,避免在启动目录散落文件 + if work_dir is None or work_dir == ".": + work_dir = constants.WORKSPACE_DIR + + self.logger.info(f"流式执行命令: {command} (work_dir={work_dir})") + output_queue.put(("line", f"\n🔧 执行命令: {command}\n")) + output_queue.put(("line", f"⏱️ 超时设置: {timeout} 秒\n")) + output_queue.put(("line", f"📦 输出限制: {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB\n")) + output_queue.put(("line", f"📁 工作目录: {work_dir}\n")) + output_queue.put(("line", "-" * 50 + "\n")) + + # 准备环境变量 + exec_env = os.environ.copy() + if env: + exec_env.update(env) + + process = None + try: + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + stdin=subprocess.PIPE, + text=True, + bufsize=1, + universal_newlines=True, + env=exec_env, + cwd=os.path.expanduser(work_dir) + ) + self.process = process + start_time = time.time() output_lines = [] + total_bytes = 0 timeout_occurred = False + truncated = False # 实时读取输出 while True: # 检查超时 if timeout > 0 and time.time() - start_time > timeout: - self.process.terminate() + process.terminate() timeout_occurred = True output_queue.put(("line", f"\n⏰ 命令执行超时({timeout}秒)\n")) break + # 检查输出大小 + if total_bytes >= self.MAX_OUTPUT_BYTES: + truncated = True + process.terminate() + output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n")) + break + # 读取一行输出 - line = self.process.stdout.readline() - if not line and self.process.poll() is not None: + line = process.stdout.readline() + if not line and process.poll() is not None: break if line: - line = line.rstrip('\n') - output_queue.put(("line", line + "\n")) + line_bytes = len(line.encode('utf-8', errors='replace')) + + if total_bytes + line_bytes > self.MAX_OUTPUT_BYTES and not truncated: + allowed = self.MAX_OUTPUT_BYTES - total_bytes + if allowed > 0: + truncated_line = line.encode('utf-8', errors='replace')[:allowed].decode('utf-8', errors='replace') + output_queue.put(("line", truncated_line)) + output_lines.append(truncated_line) + total_bytes = self.MAX_OUTPUT_BYTES + truncated = True + process.terminate() + output_queue.put(("line", f"\n📦 输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断\n")) + break + + stripped = line.rstrip('\n') + output_queue.put(("line", stripped + "\n")) output_lines.append(line) + total_bytes += line_bytes - # 如果超时,确保进程被终止 - if timeout_occurred and self.process.poll() is None: - self.process.kill() - self.process.wait(timeout=2) + # 如果超时或截断,确保进程被终止 + if (timeout_occurred or truncated) and process.poll() is None: + process.kill() + try: + process.wait(timeout=2) + except: + pass - # 获取剩余输出 - remaining, _ = self.process.communicate(timeout=5) - if remaining: - output_queue.put(("line", remaining)) - output_lines.append(remaining) - - return_code = self.process.returncode if self.process.returncode is not None else -1 + return_code = process.returncode if process.returncode is not None else -1 output_queue.put(("line", "-" * 50 + "\n")) if timeout_occurred: output_queue.put(("line", f"⏰ 命令执行超时({timeout}秒),返回码: {return_code}\n")) + elif truncated: + output_queue.put(("line", f"📦 输出已截断,返回码: {return_code}\n")) else: output_queue.put(("line", f"✅ 命令执行完成,返回码: {return_code}\n")) # 准备完整结果 - full_output = "\n".join(output_lines) + full_output = "".join(output_lines) data = { - "输出": full_output if full_output else "(无输出)", + "输出": full_output.strip() if full_output.strip() else "(无输出)", "返回码": return_code } if timeout_occurred: result = self.format_result(False, f"命令执行超时({timeout}秒)", data) + elif truncated: + data["警告"] = f"输出超过 {self.MAX_OUTPUT_BYTES/1024/1024:.0f}MB 限制,结果已截断" + result = self.format_result(False, "命令执行完成(输出已截断)", data) else: result = self.format_result(return_code == 0, "命令执行完成", data) @@ -3635,12 +3781,13 @@ class SubprocessCommandExecutor(CommandExecutor): output_queue.put(("line", f"❌ 执行错误: {str(e)}\n")) output_queue.put(("complete", f"❌ 执行错误: {str(e)}")) finally: - if self.process and self.process.poll() is None: - self.process.terminate() + if process and process.poll() is None: + process.terminate() try: - self.process.wait(timeout=5) + process.wait(timeout=2) except: - self.process.kill() + process.kill() + self.process = None # ==================== 工具基类 ==================== @@ -5823,6 +5970,63 @@ class ReadFileTool(FileToolBase): return start, end, desc + @staticmethod + def _count_lines(filepath: str, encoding: str) -> int: + """高效统计文件行数,不存储内容""" + line_count = 0 + with open(filepath, 'r', encoding=encoding) as f: + for _ in f: + line_count += 1 + return line_count + + @staticmethod + def _read_lines_range(filepath: str, encoding: str, start: int, end: int) -> List[str]: + """读取指定行范围 [start, end)(0-indexed),只读取需要的行""" + lines = [] + with open(filepath, 'r', encoding=encoding) as f: + for i, line in enumerate(f): + if i >= end: + break + if i >= start: + lines.append(line) + return lines + + @staticmethod + def _read_tail_lines(filepath: str, encoding: str, num_lines: int) -> Tuple[List[str], int]: + """高效读取文件末尾N行,无需遍历整个文件。返回 (lines, total_line_count)""" + # 对于小文件直接读全部 + file_size = os.path.getsize(filepath) + if file_size < 1024 * 1024: # < 1MB 直接读 + with open(filepath, 'r', encoding=encoding) as f: + all_lines = f.readlines() + return all_lines[-num_lines:] if num_lines > 0 else [], len(all_lines) + + # 大文件:从后向前找换行符 + with open(filepath, 'rb') as f: + block_size = 8192 + blocks = [] + lines_found = 0 + position = file_size + + while position > 0 and lines_found <= num_lines: + read_size = min(block_size, position) + position -= read_size + f.seek(position) + block = f.read(read_size) + blocks.append(block) + lines_found += block.count(b'\n') + + # 拼接并解码 + data = b''.join(reversed(blocks)) + text = data.decode(encoding, errors='replace') + all_lines = text.splitlines() + + # 统计总行数 + f.seek(0) + total_lines = sum(1 for _ in f) + + return all_lines[-num_lines:], total_lines + def execute(self, filepath: str, encoding: str = "utf-8", start_line: int = 1, end_line: int = 0, max_lines: int = 100, head: bool = False, @@ -5834,13 +6038,15 @@ class ReadFileTool(FileToolBase): if not is_safe: return error + # 限制max_lines上限,防止请求超大范围 + max_lines = min(max_lines, 500) + try: file_size = os.path.getsize(abs_path) modified_time = datetime.fromtimestamp(os.path.getmtime(abs_path)) - with open(abs_path, 'r', encoding=encoding) as f: - all_lines = f.readlines() - line_count = len(all_lines) + # 高效读取:先统计行数 + line_count = self._count_lines(abs_path, encoding) start, end, range_desc = self._calculate_line_range( line_count, start_line, end_line, max_lines, head, tail @@ -5849,7 +6055,25 @@ class ReadFileTool(FileToolBase): if end <= start: return f"❌ 无效的行范围" - display_lines = all_lines[start:end] + lines_to_read = end - start + + # 对于tail模式,用高效的尾部读取 + if tail and line_count > 200: + display_lines_raw, _ = self._read_tail_lines(abs_path, encoding, lines_to_read) + else: + display_lines_raw = self._read_lines_range(abs_path, encoding, start, end) + + # 限制单行大小(防止单行超大导致内存问题) + MAX_LINE_LENGTH = 10000 + content_lines = [] + truncated_lines = 0 + for i, line in enumerate(display_lines_raw): + line_num = start + i + 1 + content = line.rstrip('\n\r') + if len(content) > MAX_LINE_LENGTH: + content = content[:MAX_LINE_LENGTH] + f"... (行超长,已截断,原始长度 {len(content)})" + truncated_lines += 1 + content_lines.append(f" {line_num:4d} | {content}") data = { "路径": abs_path, @@ -5859,17 +6083,13 @@ class ReadFileTool(FileToolBase): "显示范围": f"{range_desc} (行 {start+1}-{end})" } - if display_lines: - content_lines = [] - for i, line in enumerate(display_lines): - line_num = start + i + 1 - content = line.rstrip('\n\r') - content_lines.append(f" {line_num:4d} | {content}") - + if content_lines: data["内容"] = "\n" + "\n".join(content_lines) if end < line_count: data["提示"] = f"还有 {line_count - end} 行未显示" + if truncated_lines > 0: + data["提示"] += f",{truncated_lines} 行因超长被截断" return self.format_result(True, "文件读取成功", data) @@ -6536,19 +6756,19 @@ class DeepSeekChat: self._register_tools(exit_callback, command_executor) def _init_system_prompts(self, system_prompt: str = None): - """初始化系统提示词 - 从 PromptManager 加载""" + """初始化系统提示词 - 从 PromptManager 加载(按 identity → 技能 → soul → status 顺序)""" if system_prompt: # 如果明确指定了 system_prompt,使用它 self._add_message("system", system_prompt) self.logger.info("使用指定的系统提示词") else: - # 从 PromptManager 加载所有提示词 - prompts = self.prompt_manager.get_prompts_list() + # 从 PromptManager 加载所有提示词,按正确顺序排列 + prompts = self.prompt_manager.get_sorted_prompts_list() if prompts: for prompt in prompts: if prompt['content'].strip(): self._add_message("system", prompt['content'].strip()) - self.logger.info(f"已加载 {len(prompts)} 个系统提示词") + self.logger.info(f"已加载 {len(prompts)} 个系统提示词(顺序: identity → 技能 → soul → status)") else: # 如果没有任何提示词,添加一个默认的 default_prompt = "你是一个智能AI助手。" @@ -6710,6 +6930,25 @@ class DeepSeekChat: self.logger.error(f"保存工具结果失败: {e}") return None + @staticmethod + def _sanitize_tool_result(result) -> str: + """将工具返回值安全转换为字符串,清理控制字符,防止破坏 API 请求。""" + if result is None: + return "" + if not isinstance(result, str): + try: + result = str(result) + except Exception: + return f"⚠️ 工具返回了无法序列化的结果类型: {type(result).__name__}" + # 清理控制字符:保留 \n \t \r,移除其他可能导致 JSON/API 问题的控制字符 + cleaned_chars = [] + for ch in result: + code = ord(ch) + if ch in ('\n', '\t', '\r') or code >= 0x20: + cleaned_chars.append(ch) + # 跳过 NULL、BEL、BS 等控制字符 + return ''.join(cleaned_chars) + def _handle_tool_calls(self, tool_calls: List[Dict], output_callback: Callable = None) -> List[Dict]: """处理工具调用 - 所有工具的输出都进行发送,并在开始和结束时发送两个回车""" if not tool_calls: @@ -6727,25 +6966,29 @@ class DeepSeekChat: for tool_call in tool_calls: function = tool_call.get("function", {}) - tool_name = function.get("name") + tool_name = function.get("name", "unknown") arguments = function.get("arguments", "{}") + tc_id = tool_call.get("id") or f"call_fallback_{self.stats['tool_calls']}_{int(time.time()*1000)}" try: args = json.loads(arguments) if arguments else {} except json.JSONDecodeError: args = {} - # 【修改】开始调用工具时发送两个回车 + # 开始调用工具时发送提示 if output_callback: output_callback("line", "\n\n") output_callback("line", f"🔧 调用工具: {tool_name}\n") - # 直接调用 execute_tool(同步)。 - # - 若工具支持流式:execute_tool 内部开线程调 execute_streaming(**args), - # 收集输出后转发到 current_output_queue(即此处的 output_queue),join 后返回。 - # - 若工具不支持流式:execute_tool 直接调 execute(**args) 返回结果。 - # 两种情况下 execute_tool 返回时执行均已完成,args 完整传入,不再有参数丢失。 - result = self.tool_registry.execute_tool(tool_name, **args) + result_raw = None + try: + result_raw = self.tool_registry.execute_tool(tool_name, **args) + except Exception as e: + result_raw = f"❌ 工具 {tool_name} 执行异常: {str(e)}" + self.logger.error(f"工具 {tool_name} 执行异常: {e}") + + # 安全转换并清理结果 + result = self._sanitize_tool_result(result_raw) # 排空 output_queue,把流式行输出转发给 output_callback has_stream_output = False @@ -6753,7 +6996,7 @@ class DeepSeekChat: try: msg_type, content = output_queue.get_nowait() if output_callback and msg_type in ("line", "complete"): - output_callback("line", content) + output_callback("line", str(content) if content is not None else "") has_stream_output = True except queue.Empty: break @@ -6762,36 +7005,39 @@ class DeepSeekChat: if not has_stream_output and output_callback and result: output_callback("line", result + "\n") - # 检查结果大小,如果超过阈值,不返回结果,只返回提示信息 - if result and len(result) > MAX_RESULT_LENGTH: - # 不保存文件,直接返回提示信息 - result = f"⚠️ 工具 {tool_name} 执行结果过大({len(result)} 字符),超过限制({MAX_RESULT_LENGTH} 字符),已放弃返回结果。\n\n请考虑:\n1. 使用更精确的查询条件\n2. 使用其他工具获取部分数据\n3. 优化查询方式减少结果数量" + # 检查结果大小,如果超过阈值,截断返回提示信息 + if len(result) > MAX_RESULT_LENGTH: + truncated_note = f"\n\n⚠️ 结果已截断(原始 {len(result)} 字符,限制 {MAX_RESULT_LENGTH} 字符)。请使用更精确的查询获取所需信息。" + result = result[:MAX_RESULT_LENGTH] + truncated_note if output_callback: - output_callback("line", result + "\n") + output_callback("line", truncated_note + "\n") - # 【修改】结束调用工具时发送两个回车 + # 结束调用工具时发送换行 if output_callback: output_callback("line", "\n\n") + # 历史记录(安全截断) if self.history_manager: - self.history_manager.add_tool_call(tool_name, args, result[:200] + "..." if len(result) > 200 else result) + try: + history_preview = result[:200] + "..." if len(result) > 200 else result + self.history_manager.add_tool_call(tool_name, args, history_preview) + except Exception: + pass - if tool_name == "save_memory_and_end_conversation" and "✅" in result: + # 检查是否结束对话 + end_conversation = (tool_name == "save_memory_and_end_conversation" and "✅" in result) + if end_conversation: self.logger.info("检测到保存记忆并结束对话工具调用") self.conversation_active = False - tool_messages.append({ - "role": "tool", - "tool_call_id": tool_call.get("id"), - "content": result - }) - # 【修复】不直接返回,而是添加后继续处理,但设置标志让后续停止 - break tool_messages.append({ "role": "tool", - "tool_call_id": tool_call.get("id"), + "tool_call_id": tc_id, "content": result }) + + if end_conversation: + break # 清除输出队列 self.tool_registry.set_output_queue(None) @@ -6821,8 +7067,15 @@ class DeepSeekChat: # 默认情况下,如果记忆库存在,搜索一下也无妨 return True - def think_and_respond(self, input_text: str, output_callback: Callable = None) -> Optional[str]: - """思考并回应 - 支持实时流式输出,支持思考模式""" + def think_and_respond(self, input_text: str, output_callback: Callable = None, is_continuation: bool = False) -> Optional[str]: + """思考并回应 - 支持实时流式输出,支持思考模式。 + + Args: + input_text: 用户输入或继续提示 + output_callback: 流式输出回调 + is_continuation: 是否为工具调用后的递归继续;为 True 时不注入冗长的工作流指令, + 因为 system prompt 中已有完整行为规范,继续时只需传递工具结果 + """ if not self.conversation_active: self.logger.info("对话已结束,重置状态") self.conversation_active = True @@ -6854,54 +7107,17 @@ class DeepSeekChat: self.stats["api_calls"] += 1 - # 前置提示词 - pre_prompt = """【系统指令 - 核心工作流程】 - - 在开始任何任务之前,你必须严格按照以下优先级执行: - - ## 第一步:搜索可用技能(最高优先级)⭐ - 当用户提出任何任务、需求或问题时,**必须先使用 `search_skills` 工具**搜索相关技能: - - 使用 `search_skills keyword="[任务关键词]"` 搜索是否有现成的扩展技能可以完成该任务 - - 例如:用户说"帮我查天气",你应该先执行 `search_skills keyword="天气"` - - 例如:用户说"帮我计算BMI",你应该先执行 `search_skills keyword="BMI"` - - 如果搜索结果中有匹配的技能,优先使用该技能完成任务 - - 只有在没有合适技能的情况下,才考虑使用其他工具或方法 - - ## 第二步:搜索相关记忆 - 在每次回答用户问题前,**必须**使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。 - - ## 第三步:积极向用户提问 - 如果你有任何无法确定的事情,那么你需要用户提供额外信息, - 请务必使用 `ask_user` 工具向用户提问,以便获得更准确的指引。 - - ## 第四步:执行任务或回答问题 - 根据搜索结果,选择合适的工具或直接回答。 - - 【重要提醒】 - - 技能优先原则:有技能先用技能,不要自己硬编码实现 - - 如果没有合适的技能,可以使用其他工具或直接回答 - - 对话结束时,必须使用 `save_memory_and_end_conversation` 工具保存本次对话的重要信息并更新智能体状态 - - 当了解到用户的身份信息时,使用 `update_identity` 工具更新 00_IDENTITY.md - - 当你的角色、性格、能力边界需要调整时,使用 `update_soul` 工具更新 99_SOUL.md 文件来重新定义自己 - - 绝对不要直接打开大文件来读,应该使用额外的压缩工具 - - 现在请处理用户的输入:""" + if is_continuation: + # 递归继续(工具调用后):直接使用输入文本,不重复注入工作流指令 + # system prompt 中已有完整行为规范,此处只需传递工具结果的上下文 + enhanced_input = input_text + else: + # 首轮回话:附加简洁的工作流程提醒(不重复 system prompt 中已有的详细说明) + pre_prompt = "【工作流程提醒】\n1. 如果用户提出新任务,先用 search_skills 搜索是否有现成技能可用\n2. 根据话题相关性决定是否 memory search(简单问候/确认无需搜索)\n3. 信息不足时用 ask_user 提问,不要猜测\n4. 遇到大文件用分页读取(head/tail/start_line/end_line),不要直接打开大文件\n5. 话题完成时用 save_memory_and_end_conversation 保存记忆并结束" + enhanced_input = pre_prompt + "\n\n" + input_text - # 后置提示词 - post_prompt = """ - - 【注意】 - 当本次对话的话题讨论完成时,请使用 `save_memory_and_end_conversation` 工具结束对话并保存重要信息到记忆库,同时更新你的状态到 100_STATUS.md。 - - 【自我认知更新】 - 在对话过程中,如果你发现: - - 自己的角色定位需要调整 - - 性格特点需要改变 - - 能力边界需要重新定义 - - 行为准则需要优化 - 请使用 `update_soul` 工具更新 99_SOUL.md 文件,这有助于你更好地理解自己并为用户提供更精准的服务。""" - - enhanced_input = pre_prompt + "\n\n" + input_text + post_prompt + # 记录本轮开始前的消息长度,用于 API 失败时回滚,防止坏消息污染对话历史 + msgs_before_turn = len(self.messages) self._add_message("user", enhanced_input) @@ -6921,19 +7137,32 @@ class DeepSeekChat: if msg["role"] == "system": enhanced_messages.append(msg) - # 添加非系统消息,但确保消息结构正确 + # 添加非系统消息,确保消息结构正确 for msg in self.messages: if msg["role"] != "system": if msg["role"] == "assistant": assistant_msg = {"role": "assistant"} - if "content" in msg and msg["content"]: + has_content = "content" in msg and msg["content"] is not None + has_tool_calls = "tool_calls" in msg and msg["tool_calls"] + if has_content: assistant_msg["content"] = msg["content"] - if "reasoning_content" in msg: - assistant_msg["reasoning_content"] = msg["reasoning_content"] - if "tool_calls" in msg and msg["tool_calls"]: + if has_tool_calls: assistant_msg["tool_calls"] = msg["tool_calls"] + # 有 tool_calls 时 content 必须为 null(API 要求) assistant_msg["content"] = None + elif not has_content: + # 无 content 也无 tool_calls 时,设为空字符串避免 API 拒绝 + assistant_msg["content"] = "" + # 注意:不回传 reasoning_content,它是 API 响应字段,回传可能导致请求失败 enhanced_messages.append(assistant_msg) + elif msg["role"] == "tool": + # 确保 tool 消息 content 始终是字符串 + tool_msg = { + "role": "tool", + "tool_call_id": msg.get("tool_call_id", ""), + "content": str(msg.get("content", "")) if msg.get("content") is not None else "" + } + enhanced_messages.append(tool_msg) else: enhanced_messages.append(msg) @@ -6945,11 +7174,13 @@ class DeepSeekChat: "stream": True } - # 可选:添加 thinking 模式配置(如果有) - payload["thinking"] = { - "type": "enabled" - } + # 思考模式:启用 thinking 参数 + if constants.REASONING_ENABLED: + payload["thinking"] = { + "type": "enabled" + } + # 工具调用(v4-flash 支持思考+工具调用) tools = self.tool_registry.get_tools_schemas() if tools: payload["tools"] = tools @@ -6958,10 +7189,16 @@ class DeepSeekChat: current_time = datetime.now().strftime("%H:%M:%S") if output_callback: output_callback("line", f"\n{i18n.get('thinking', current_time)}\n") - output_callback("line", i18n.get('assistant_prefix')) + if constants.REASONING_ENABLED: + output_callback("reasoning_start", "🤔 思考中...") + else: + # 普通模式先显示助手前缀 + output_callback("line", i18n.get('assistant_prefix')) full_response = "" full_reasoning = "" # 存储思考内容 + reasoning_started = False + reasoning_ended = False tool_calls_buffer = [] current_tool_calls = {} @@ -7012,10 +7249,25 @@ class DeepSeekChat: if 'reasoning_content' in delta and delta['reasoning_content']: reasoning = delta['reasoning_content'] full_reasoning += reasoning - # 可选:将思考内容也发送给前端(可以单独处理) - if output_callback and self.debug_level >= 3: + + # 发送思考开始信号(仅第一次,且未提前发送过) + if not reasoning_started and output_callback: + if not constants.REASONING_ENABLED: + # 非显式思考模式但模型返回了思考内容,动态发送开始信号 + output_callback("reasoning_start", "🤔 思考中...") + reasoning_started = True + + # 实时发送思考内容给前端(无需debug_level限制) + if output_callback: output_callback("reasoning", reasoning) + # 思考结束:当开始收到content且之前有reasoning时,发送结束信号 + if 'content' in delta and delta['content'] and reasoning_started and not reasoning_ended: + reasoning_ended = True + if output_callback: + output_callback("reasoning_end", "") + output_callback("line", i18n.get('assistant_prefix')) + # 处理正常内容 if 'content' in delta and delta['content']: content = delta['content'] @@ -7036,14 +7288,22 @@ class DeepSeekChat: index = tc.get('index', 0) if index not in current_tool_calls: + # 确保 tool_call id 非空,空 id 会导致 API 400 错误污染对话历史 + tc_id = tc.get('id', '') or f"call_{self.stats['tool_calls']}_{index}_{int(time.time()*1000)}" current_tool_calls[index] = { - 'id': tc.get('id', ''), + 'id': tc_id, 'type': 'function', 'function': { 'name': '', 'arguments': '' } } + else: + # 流式传输中 id 可能分片到达,后续补充 + existing_id = current_tool_calls[index].get('id', '') + new_id = tc.get('id', '') + if new_id and (not existing_id or existing_id.startswith('call_')): + current_tool_calls[index]['id'] = new_id if 'function' in tc: if 'name' in tc['function']: @@ -7058,7 +7318,12 @@ class DeepSeekChat: # 处理工具调用 if current_tool_calls: tool_calls_buffer = list(current_tool_calls.values()) - if output_callback: + + # 如果之前在思考,先结束思考状态 + if reasoning_started and not reasoning_ended and output_callback: + output_callback("reasoning_end", "") + output_callback("line", i18n.get('calling_tools') + "\n") + elif output_callback: output_callback("line", i18n.get('calling_tools') + "\n") # 【关键修复1】先添加助手消息,包含 tool_calls @@ -7096,9 +7361,8 @@ class DeepSeekChat: if output_callback: output_callback("line", i18n.get('analyzing_tool_results') + "\n") - # 【关键修复4】递归调用,让AI基于工具结果继续回答 - # 使用一个新的输入提示,避免重复 - return self.think_and_respond("请基于以上工具执行结果继续回答用户的问题。", output_callback) + # 递归调用,让AI基于工具结果继续回答;is_continuation=True 避免重复注入工作流指令 + return self.think_and_respond("请基于以上工具执行结果继续回答。", output_callback, is_continuation=True) elif full_response: # 正常添加助手消息,包含 reasoning_content @@ -7119,37 +7383,44 @@ class DeepSeekChat: return full_response if full_response else "" except requests.exceptions.RequestException as e: + # 网络/HTTP 错误:回滚本轮添加的消息(user 消息 + 可能的 assistant/tool 消息), + # 防止格式错误的消息污染历史导致后续请求持续失败 + self._rollback_messages(msgs_before_turn) error_msg = f"\n{i18n.get('error_prefix')}API请求失败: {str(e)}" if output_callback: output_callback("error", error_msg) self.stats["errors"].append(str(e)) + self.logger.error(f"API请求失败,已回滚消息到位置 {msgs_before_turn}: {e}") return error_msg except json.JSONDecodeError as e: + # JSON 解析错误:回滚消息 + self._rollback_messages(msgs_before_turn) error_msg = f"\n{i18n.get('error_prefix')}API响应解析失败: {str(e)}" if output_callback: output_callback("error", error_msg) self.stats["errors"].append(str(e)) + self.logger.error(f"API响应解析失败,已回滚消息到位置 {msgs_before_turn}: {e}") return error_msg except Exception as e: + # 未知错误:回滚消息,防止对话状态被破坏 + self._rollback_messages(msgs_before_turn) error_msg = f"\n{i18n.get('error_prefix')}{str(e)}" if output_callback: output_callback("error", error_msg) self.stats["errors"].append(str(e)) + self.logger.error(f"未知错误,已回滚消息到位置 {msgs_before_turn}: {e}") return error_msg + def _rollback_messages(self, to_length: int): + """回滚 self.messages 到指定长度,用于 API 失败时恢复对话状态。""" + try: + current_len = len(self.messages) + if current_len > to_length: + del self.messages[to_length:] + self.logger.info(f"消息已回滚,从 {current_len} 条回滚到 {len(self.messages)} 条") + except Exception as e: + self.logger.error(f"消息回滚失败: {e}") - def _add_assistant_message_with_reasoning(self, content: str, reasoning_content: str = None, tool_calls: List = None): - """添加助手消息,包含 reasoning_content""" - msg = {"role": "assistant"} - if content: - msg["content"] = content - if reasoning_content: - msg["reasoning_content"] = reasoning_content - if tool_calls: - msg["tool_calls"] = tool_calls - msg["content"] = None - self.messages.append(msg) - def reset_conversation(self, reload_prompts: bool = True): """ 重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词 @@ -8177,6 +8448,9 @@ def main(): # 确保 prompts 目录存在 os.makedirs(constants.PROMPTS_DIR, exist_ok=True) + # 确保 workspace 工作目录存在(智能体专用文件区) + os.makedirs(constants.WORKSPACE_DIR, exist_ok=True) + Logger.init_file_logging() # 创建命令执行器 - 现在只使用本地执行器 diff --git a/aibox/docs/SECURITY_AUDIT_20260801.md b/aibox/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..c43c3b1 --- /dev/null +++ b/aibox/docs/SECURITY_AUDIT_20260801.md @@ -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)添加权限控制 +- [ ] 工具结果敏感信息过滤全面验证 + +### 中期 +- [ ] 工具调用沙箱化(容器/受限用户) +- [ ] 工具调用审计日志 diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..bbb98fc --- /dev/null +++ b/backups/.gitkeep @@ -0,0 +1,2 @@ +# 数据库备份输出目录 +# 容器内备份会输出到此目录,可直接复制走 diff --git a/captcha/captcha_service.py b/captcha/captcha_service.py index 6eb7953..c93331e 100644 --- a/captcha/captcha_service.py +++ b/captcha/captcha_service.py @@ -21,7 +21,6 @@ captcha — 基于 pymsg 协议(MsgServer)的图形验证码服务。 import base64 import io -import json import os import random import secrets @@ -34,7 +33,7 @@ from PIL import Image, ImageDraw, ImageFont # pymsg 导入 import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import MsgServer # noqa: E402 +from pymsg import RpcServer # noqa: E402 # ── 配置 ──────────────────────────────────────────────────────────────── @@ -97,57 +96,24 @@ def generate_image(text): class CaptchaServer: """基于 pymsg 的验证码服务,通过 TCP 长连接收发 JSON。""" - def __init__(self, addr="127.0.0.1", port=8009): + def __init__(self, addr="127.0.0.1", port=8009, workers=4): self.addr = addr self.port = port - self._server = MsgServer(addr, port) - - # 路由表 - self._routes = { - "image": self._handle_image, - "verify": self._handle_verify, - "health": self._handle_health, - } + self._server = RpcServer(addr, port, workers=workers) + self._server.add_route("image", self._handle_image) + self._server.add_route("verify", self._handle_verify) + self._server.add_route("health", self._handle_health) # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: """启动服务(阻塞当前线程)。""" print(f"[captcha] 监听 tcp://{self.addr}:{self.port}") - self._server.start(self._dispatch) + self._server.start() def stop(self) -> None: """从另一线程停止服务。""" self._server.stop() - # ── 消息调度 ------------------------------------------------------- - def _dispatch(self, sock, addr, text): - """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" - try: - req = json.loads(text) - except json.JSONDecodeError as e: - self._reply(sock, False, error=f"JSON 解析失败: {e}") - return - - route = req.pop("_route", "") - handler = self._routes.get(route) - if handler is None: - self._reply(sock, False, error=f"未知路由: {route}") - return - - try: - result = handler(req) - self._reply(sock, True, data=result) - except Exception as e: - self._reply(sock, False, error=str(e)) - - def _reply(self, sock, success, data=None, error=None): - resp = {"success": success} - if data is not None: - resp["data"] = data - if error is not None: - resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) - # ═══════════════════════════════════════════════════════════════════ # 路由处理 # ═══════════════════════════════════════════════════════════════════ @@ -209,9 +175,11 @@ if __name__ == '__main__': parser = argparse.ArgumentParser(description="captcha — pymsg 验证码服务") parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--port", type=int, default=8009, help="监听端口") + parser.add_argument("--workers", type=int, default=4, + help="回调并发线程数(0=同步串行模式)") args = parser.parse_args() - server = CaptchaServer(args.addr, args.port) + server = CaptchaServer(args.addr, args.port, workers=args.workers) try: server.start() except KeyboardInterrupt: diff --git a/captcha/docs/SECURITY_AUDIT_20260801.md b/captcha/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..9fa2e8c --- /dev/null +++ b/captcha/docs/SECURITY_AUDIT_20260801.md @@ -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 存储支持水平扩展(当前单实例足够) diff --git a/confs/README.md b/confs/README.md new file mode 100644 index 0000000..e1b232f --- /dev/null +++ b/confs/README.md @@ -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) diff --git a/confs/_run.sh b/confs/_run.sh new file mode 100755 index 0000000..f8815d1 --- /dev/null +++ b/confs/_run.sh @@ -0,0 +1,6 @@ +#!/bin/bash +cd "$(dirname "$0")" || exit +source ./venv/bin/activate +#exec ./smtp.bin +sleep 2 +exec python3 confs.py diff --git a/confs/_set.sh b/confs/_set.sh new file mode 100755 index 0000000..7a5e8a6 --- /dev/null +++ b/confs/_set.sh @@ -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 diff --git a/confs/configs/dnfs.ini b/confs/configs/dnfs.ini new file mode 100644 index 0000000..8288444 --- /dev/null +++ b/confs/configs/dnfs.ini @@ -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 diff --git a/confs/configs/example.ini b/confs/configs/example.ini new file mode 100644 index 0000000..cbb1dd4 --- /dev/null +++ b/confs/configs/example.ini @@ -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 diff --git a/confs/configs/static.ini b/confs/configs/static.ini new file mode 100644 index 0000000..fbccc07 --- /dev/null +++ b/confs/configs/static.ini @@ -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 diff --git a/confs/confs.py b/confs/confs.py new file mode 100644 index 0000000..8e7c2aa --- /dev/null +++ b/confs/confs.py @@ -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() diff --git a/confs/docs/SECURITY_AUDIT_20260801.md b/confs/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..570c277 --- /dev/null +++ b/confs/docs/SECURITY_AUDIT_20260801.md @@ -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)**: 实际风险极低或属于运维/部署优化项 diff --git a/confs/log_client.py b/confs/log_client.py new file mode 100644 index 0000000..0dd516f --- /dev/null +++ b/confs/log_client.py @@ -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() diff --git a/confs/requirements.txt b/confs/requirements.txt new file mode 100644 index 0000000..4ccd809 --- /dev/null +++ b/confs/requirements.txt @@ -0,0 +1,2 @@ +../pymsg +nuitka diff --git a/core/docs/SECURITY_AUDIT_20260801.md b/core/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..ee0314b --- /dev/null +++ b/core/docs/SECURITY_AUDIT_20260801.md @@ -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 完整性校验 +- [ ] 管理操作审计日志 +- [ ] 信号处理函数安全改造 diff --git a/core/init.txt b/core/init.txt index 9343f3d..a3e4926 100644 --- a/core/init.txt +++ b/core/init.txt @@ -1,4 +1,5 @@ bash /opt/micro_servers/db/_run.sh +bash /opt/micro_servers/confs/_run.sh bash /opt/micro_servers/ai_review/_run.sh bash /opt/micro_servers/dnfs/_run.sh bash /opt/micro_servers/smtp/_run.sh diff --git a/db/.dockerignore b/db/.dockerignore new file mode 100644 index 0000000..ba73f43 --- /dev/null +++ b/db/.dockerignore @@ -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 diff --git a/db/Dockerfile b/db/Dockerfile new file mode 100644 index 0000000..49472cb --- /dev/null +++ b/db/Dockerfile @@ -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"] diff --git a/db/README.md b/db/README.md index 8f57728..67b6baa 100644 --- a/db/README.md +++ b/db/README.md @@ -189,7 +189,7 @@ CREATE TABLE IF NOT EXISTS Logs ( ### 写入方式 -**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg 调 `execute` 路由逐条 INSERT 到 Logs 表: +**外部服务**:复制 `example_log_client.py` 为各自目录下的 `log_client.py`,改 `service_name` 即用。客户端底层通过 pymsg `RpcClient`(高层 RPC 封装)调 `execute` 路由逐条 INSERT 到 Logs 表: ```python from log_client import logs @@ -202,11 +202,16 @@ logs.push("gateway", "WARNING", "登录失败次数超限", 客户端特性: - 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送 -- fail-safe:db 不可用时静默丢弃,绝不影响业务 +- **RpcClient 高层 RPC**:直接传 dict 对象,无需手动拼 `_route` / json.dumps / json.loads +- fail-safe:db 不可用时静默丢弃,绝不影响业务;后台线程外层 try/except 捕获所有异常,任何网络/序列化错误都不会杀死日志线程(防止队列无限增长导致 OOM) - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush -- 长度保护:`message` 上限 8192 字节、`extra` 上限 4096 字节,超长自动截断 +- 时间戳准确:日志入队时即生成 UTC ISO 时间戳,而非发送时生成,队列积压时时间仍反映事件真实发生时间 +- 队列保护:队列上限 10000 条,满时丢弃最旧条目(drop-oldest),`push()` 永远不阻塞业务线程 +- 安全序列化:`extra` 字段包含不可 JSON 序列化对象时自动 fallback 为 `str()`/`repr()`,不会因 TypeError 中断 +- flush 可靠:`flush()` 使用 Event 通知而非固定 sleep,精确等待后台线程完成批量发送 +- 长度保护:服务端 `_log_internal()` 对内部日志有 `message` 上限 8192 字节、`extra` 上限 4096 字节的自动截断;外部客户端建议在业务层控制日志长度 -**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。 +**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归),并在写入前做长度截断。 ### 查询日志 @@ -239,8 +244,9 @@ SQLAlchemy 会自动生成适配各数据库的 SQL 方言,无需修改接口 | 文件 | 说明 | |------|------| -| `dbv2.py` | 服务主程序(pymsg/TCP,唯一在跑的实现) | -| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用 | +| `dbv2.py` | 服务主程序(pymsg RpcServer/TCP,唯一在跑的实现) | +| `example_log_client.py` | 日志客户端 SDK 模板,各服务复制为 `log_client.py` 使用(基于 RpcClient,含线程保护/队列上限/安全序列化等健壮性设计) | +| `db_tools.py` | SQLite 运维工具:backup / export / import / vacuum / wal-checkpoint / info,供 docker.sh 等脚本调用 | | `create_sqlite3.sql` | Users / Notes / Logs 建表脚本 | | `_view_db.sqlite3.py` | 非交互式查看 database.db 的工具脚本 | | `_set.sh` / `_run.sh` | 环境安装 / 启动脚本 | diff --git a/db/db_tools.py b/db/db_tools.py new file mode 100644 index 0000000..75671f0 --- /dev/null +++ b/db/db_tools.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +db_tools — SQLite 数据库备份、导出、导入工具 + +用法: + python db_tools.py backup # 安全备份数据库(使用 sqlite3 backup API) + python db_tools.py export # 导出所有表数据为 JSON + python db_tools.py import # 从 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 ", 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() diff --git a/db/dbv2.py b/db/dbv2.py index e940015..771b206 100644 --- a/db/dbv2.py +++ b/db/dbv2.py @@ -29,6 +29,7 @@ dbv2 — 基于 pymsg 协议(MsgServer)的数据库服务。 import json import os +import threading import time import traceback from datetime import datetime, date, time as dt_time, timezone, timedelta @@ -36,6 +37,7 @@ from datetime import datetime, date, time as dt_time, timezone, timedelta from sqlalchemy import ( create_engine, MetaData, Table, select, insert, update, delete, func, and_, + event, ) from sqlalchemy import types as sa_types from sqlalchemy.exc import SQLAlchemyError @@ -43,12 +45,32 @@ from sqlalchemy.exc import SQLAlchemyError # pymsg 导入 import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import MsgServer # noqa: E402 +from pymsg import RpcServer # noqa: E402 # ── 数据库配置 ────────────────────────────────────────────────────────── DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///database.db") +# ── SQLite 连接配置(针对 Docker/多线程环境优化)────────────────────── + +def _configure_sqlite(dbapi_connection, connection_record): + """为每个 SQLite 连接设置 PRAGMA,优化并发与持久化性能。 + + - WAL 模式:读写不互斥,并发性能大幅提升,Docker 卷挂载下更稳定 + - NORMAL 同步级别:在断电时可能丢失最近提交的事务(但不会损坏数据库) + 配合 WAL checkpoint 足够安全,性能显著提升 + - foreign_keys=ON:启用外键约束 + - busy_timeout:锁等待超时 5 秒,避免 SQLITE_BUSY 错误 + - mmap_size:使用内存映射 I/O 提升读取性能 + """ + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA journal_mode=WAL") + cursor.execute("PRAGMA synchronous=NORMAL") + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.execute("PRAGMA mmap_size=268435456") # 256MB + cursor.close() + # ── 日志模块常量 ──────────────────────────────────────────────────────── MAX_MESSAGE_LEN = 8192 # message 上限 8KB @@ -76,69 +98,66 @@ def _json_default(o): class DBServer: """基于 pymsg 的数据库服务,通过 TCP 长连接收发 JSON。""" - def __init__(self, addr="127.0.0.1", port=8005): + def __init__(self, addr="127.0.0.1", port=8005, workers=4): self.addr = addr self.port = port - self.engine = create_engine(DATABASE_URL) + self.engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, + ) + # 为 SQLite 连接应用 PRAGMA 优化 + if DATABASE_URL.startswith("sqlite"): + event.listen(self.engine, "connect", _configure_sqlite) self.metadata = MetaData() - self._server = MsgServer(addr, port) + self._server = RpcServer( + addr, port, workers=workers, + json_default=_json_default, + on_error=lambda route, e: traceback.print_exc(), + ) + self._server.add_route("execute", self._handle_execute) # 日志表缓存(避免重复反射) self._logs_table_cache = {} - # 路由表 - self._routes = { - "execute": self._handle_execute, - } + # 业务表缓存:仅缓存"已完整反射"的表。 + # 注意不能用 metadata.tables 作完成标记 —— Table(autoload_with=...) + # 会先注册进 metadata.tables(此时无列)再填充列,锁外快路径可能 + # 读到反射半途的空列表。 + self._tables_cache = {} + + # 表反射锁:workers>0 时多个 worker 线程可能对同一新表并发做 + # check-then-act 反射(Table(..., autoload_with=...) 对已定义表名会 + # 抛 InvalidRequestError),反射操作需串行化 + self._reflect_lock = threading.Lock() # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: """启动服务(阻塞当前线程)。""" print(f"[dbv2] 监听 tcp://{self.addr}:{self.port}") - self._ensure_logs_table() # 确保 Logs 表已建好(外部 MsgClient 写日志前) + self._ensure_logs_table() # 确保 Logs 表已建好(外部写日志前) self._start_cleanup_thread() - self._server.start(self._dispatch) + self._server.start() def stop(self) -> None: """从另一线程停止服务。""" self._server.stop() - # ── 消息调度 ------------------------------------------------------- - def _dispatch(self, sock, addr, text): - """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" - try: - req = json.loads(text) - except json.JSONDecodeError as e: - self._reply(sock, False, error=f"JSON 解析失败: {e}") - return - - route = req.pop("_route", "") - handler = self._routes.get(route) - if handler is None: - self._reply(sock, False, error=f"未知路由: {route}") - return - - try: - result = handler(req, sock=sock) - self._reply(sock, True, data=result) - except Exception as e: - traceback.print_exc() - self._reply(sock, False, error=str(e)) - - def _reply(self, sock, success, data=None, error=None): - resp = {"success": success} - if data is not None: - resp["data"] = data - if error is not None: - resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False, - default=_json_default)) - # ── 表反射 --------------------------------------------------------- def _get_table(self, table_name): - if table_name not in self.metadata.tables: - Table(table_name, self.metadata, autoload_with=self.engine) - return self.metadata.tables[table_name] + # double-check:_tables_cache 只在反射完整完成后写入(dict 读写 GIL + # 下安全),锁外快路径读到的要么是完整表要么是 None + table = self._tables_cache.get(table_name) + if table is None: + with self._reflect_lock: + table = self._tables_cache.get(table_name) + if table is None: + # 持锁期间无其他反射在进行,metadata 中的表必然完整 + table = self.metadata.tables.get(table_name) + if table is None: + table = Table(table_name, self.metadata, + autoload_with=self.engine) + self._tables_cache[table_name] = table + return table # ── 日期/时间类型适配 ------------------------------------------------ def _coerce_value(self, column, value): @@ -222,7 +241,7 @@ class DBServer: # 路由处理 — execute # ═══════════════════════════════════════════════════════════════════ - def _handle_execute(self, data, **kw): + def _handle_execute(self, data): action = data.get("action", "select") table_name = data.get("table") if not table_name: @@ -316,35 +335,43 @@ class DBServer: if "Logs" in self._logs_table_cache: return self._logs_table_cache["Logs"] - with self.engine.connect() as conn: - conn.exec_driver_sql(""" - CREATE TABLE IF NOT EXISTS Logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL, - service TEXT NOT NULL, - level TEXT NOT NULL, - message TEXT NOT NULL, - trace_id TEXT, - extra TEXT - ) - """) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)" - ) - conn.exec_driver_sql( - "CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)" - ) - conn.commit() + # 与 _get_table 共用反射锁,double-check 保证并发下只建/反射一次; + # Logs 可能已被 _get_table("Logs") 反射进 metadata,需幂等处理 + with self._reflect_lock: + if "Logs" in self._logs_table_cache: + return self._logs_table_cache["Logs"] - table = Table("Logs", self.metadata, autoload_with=self.engine) - self._logs_table_cache["Logs"] = table - return table + table = self.metadata.tables.get("Logs") + if table is None: + with self.engine.connect() as conn: + conn.exec_driver_sql(""" + CREATE TABLE IF NOT EXISTS Logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL, + service TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + trace_id TEXT, + extra TEXT + ) + """) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_logs_service_ts ON Logs(service, ts)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_logs_level_ts ON Logs(level, ts)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_logs_ts ON Logs(ts)" + ) + conn.exec_driver_sql( + "CREATE INDEX IF NOT EXISTS idx_logs_trace ON Logs(trace_id)" + ) + conn.commit() + table = Table("Logs", self.metadata, autoload_with=self.engine) + + self._logs_table_cache["Logs"] = table + return table # ── 内部日志(直接写库,无 HTTP)────────────────────────────────── def _log_internal(self, service, level, message, @@ -429,12 +456,17 @@ class DBServer: if __name__ == "__main__": import argparse + default_addr = os.environ.get("LISTEN_ADDR", "0.0.0.0") + default_port = int(os.environ.get("LISTEN_PORT", "8005")) + parser = argparse.ArgumentParser(description="dbv2 — pymsg 数据库服务") - parser.add_argument("--addr", default="127.0.0.1", help="监听地址") - parser.add_argument("--port", type=int, default=8005, help="监听端口") + parser.add_argument("--addr", default=default_addr, help="监听地址") + parser.add_argument("--port", type=int, default=default_port, help="监听端口") + parser.add_argument("--workers", type=int, default=4, + help="回调并发线程数(0=同步串行模式)") args = parser.parse_args() - server = DBServer(args.addr, args.port) + server = DBServer(args.addr, args.port, workers=args.workers) try: server.start() except KeyboardInterrupt: diff --git a/db/docker-entrypoint.sh b/db/docker-entrypoint.sh new file mode 100644 index 0000000..4eb7f19 --- /dev/null +++ b/db/docker-entrypoint.sh @@ -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}" diff --git a/db/docs/SECURITY_AUDIT_20260801.md b/db/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..0e367de --- /dev/null +++ b/db/docs/SECURITY_AUDIT_20260801.md @@ -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 拒绝执行 +- [ ] 错误信息脱敏 + +### 中期 +- [ ] 表级权限控制(日志表只写不删/改) +- [ ] 管理操作审计日志 diff --git a/db/example_log_client.py b/db/example_log_client.py index 1ffe09a..8a41e67 100644 --- a/db/example_log_client.py +++ b/db/example_log_client.py @@ -1,13 +1,14 @@ """ -统一日志客户端 SDK — 各服务复制即用。 +统一日志客户端 SDK — 各服务复制即用(模板)。 设计要点: - - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + - 通过 pymsg RpcClient(高层 RPC 封装)调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 -复制到目标服务目录后,按需修改 service_name 即可:: +复制到目标服务目录后,按需修改底部全局单例的 service_name 即可: from log_client import logs @@ -27,22 +28,36 @@ import atexit from datetime import datetime, timezone _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import MsgClient # noqa: E402 +from pymsg import RpcClient, RpcError # noqa: E402 # ── 配置 ────────────────────────────────────────────────────────────── -_DBV2_ADDR = '127.0.0.1' -_DBV2_PORT = 8005 +_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') +_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) _BATCH_SIZE = 50 # 队列满 50 条时 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _TIMEOUT = 5 # socket 超时(秒) +_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 + + +def _safe_json(obj): + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + return repr(obj) + + +def _now_iso(): + """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + return datetime.now(timezone.utc).isoformat() class LogClient: """异步批量日志推送客户端。 - 通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 + 通过 RpcClient(pymsg 高层 RPC 封装)逐条 INSERT 到 dbv2 的 Logs 表。 service_name 用于在 dbv2 连接中标识来源,各服务复制时自行修改。 """ @@ -52,9 +67,10 @@ class LogClient: self._service_name = service_name self._batch_size = batch_size self._flush_interval = flush_interval - self._queue = queue.Queue() + self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) self._stop = threading.Event() self._flush_signal = threading.Event() + self._flush_done = threading.Event() self._thread = threading.Thread(target=self._flush_loop, daemon=True, name='log-client') self._thread.start() @@ -63,14 +79,30 @@ class LogClient: # ── 公开接口 ──────────────────────────────────────────────────────── def push(self, service, level, message, trace_id=None, extra=None): - """推送一条日志(非阻塞,立即返回)。""" - self._queue.put({ + """推送一条日志(非阻塞,立即返回)。 + + 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 + """ + entry = { + 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra, - }) + } + try: + self._queue.put_nowait(entry) + except queue.Full: + # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(entry) + except queue.Full: + pass # 极端竞争下仍失败则静默丢弃 def debug(self, service, message, **kw): self.push(service, 'DEBUG', message, **kw) @@ -89,8 +121,15 @@ class LogClient: def flush(self): """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" + self._flush_done.clear() self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - time.sleep(0.6) # 等待后台线程响应 + # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) + self._flush_done.wait(timeout=self._flush_interval + 2.0) + # 主线程排空队列中剩余条目 + self._drain_queue_to_db() + + def _drain_queue_to_db(self): + """排空主线程可见的队列中剩余条目,直接发送到 db。""" batch = [] while True: try: @@ -102,73 +141,103 @@ class LogClient: break if batch: self._send(batch) - self._flush_signal.clear() # ── 内部实现 ──────────────────────────────────────────────────────── def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" + """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 + + 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) + 都不会死亡,避免日志队列无限增长导致 OOM。 + """ batch = [] last_flush = time.monotonic() while not self._stop.is_set(): - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.0, min(timeout, 0.5)) try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass + timeout = self._flush_interval - (time.monotonic() - last_flush) + chk_timeout = max(0.1, min(timeout, 0.5)) + try: + item = self._queue.get(timeout=chk_timeout) + batch.append(item) + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + except queue.Empty: + pass - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - self._send(batch) - batch = [] - last_flush = time.monotonic() + should_flush = ( + len(batch) >= self._batch_size or + self._flush_signal.is_set() or + (batch and time.monotonic() - last_flush >= self._flush_interval) + ) + if should_flush and batch: + try: + self._send(batch) + except Exception as e: + # _send 内部已有细粒度异常处理,此处是最终安全网 + print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + batch = [] + last_flush = time.monotonic() + if self._flush_signal.is_set(): + self._flush_signal.clear() + self._flush_done.set() + except Exception as e: + # 最外层安全网:任何意外异常都不应杀死日志线程 + print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + time.sleep(0.5) # 防止异常风暴导致 CPU 满载 # 退出前 flush 剩余 if batch: - self._send(batch) + try: + self._send(batch) + except Exception as e: + print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) + self._flush_done.set() def _send(self, batch): - """通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" - now = datetime.now(timezone.utc).isoformat() + """通过 pymsg RpcClient(execute 路由)批量发送到 dbv2 Logs 表。 + + 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), + 不抛异常给调用方。 + """ try: - client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - for entry in batch: - extra_val = entry.get("extra") - payload = { - "_route": "execute", - "action": "insert", - "table": "Logs", - "values": { - "ts": now, - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": json.dumps(extra_val, ensure_ascii=False) - if extra_val is not None else None, - }, - } - resp = client.call(json.dumps(payload), timeout=_TIMEOUT) - resp_obj = json.loads(resp) - if not resp_obj.get("success"): - import sys - print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", - file=sys.stderr) + client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") except Exception as e: - import sys - print(f"[log_client] 发送失败: {e}", file=sys.stderr) + print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) + return + + for entry in batch: + extra_val = entry.get("extra") + try: + extra_json = _safe_json(extra_val) if extra_val is not None else None + except Exception: + extra_json = None + payload = { + "action": "insert", + "table": "Logs", + "values": { + "ts": entry.get("ts", _now_iso()), + "service": entry.get("service"), + "level": entry.get("level"), + "message": entry.get("message"), + "trace_id": entry.get("trace_id"), + "extra": extra_json, + }, + } + try: + client.call("execute", payload, timeout=_TIMEOUT) + except RpcError as e: + # 业务错误(如表结构不符):打印后继续下一条 + print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) + except Exception as e: + # 网络错误:db 不可用,放弃本批剩余(fail-safe) + print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + break def _shutdown(self): """进程退出时 flush 剩余日志。""" @@ -180,5 +249,7 @@ class LogClient: # 各服务复制时,把 "example" 改为自己的服务名即可。 # 例如 dnfs/log_client.py → logs = LogClient(service_name="dnfs") # 例如 smtp/log_client.py → logs = LogClient(service_name="smtp") +# 例如 static/log_client.py → logs = LogClient(service_name="static") +# 例如 confs/log_client.py → logs = LogClient(service_name="confs") -logs = LogClient() +logs = LogClient(service_name="example") diff --git a/dnfs/.dockerignore b/dnfs/.dockerignore new file mode 100644 index 0000000..38c4359 --- /dev/null +++ b/dnfs/.dockerignore @@ -0,0 +1,18 @@ +.env +.env/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.so +.git +.gitignore +*.md +!README.md +.envrc +.venv/ +venv/ +ENV/ +*.bin +.DS_Store diff --git a/dnfs/Dockerfile b/dnfs/Dockerfile new file mode 100644 index 0000000..7ef7505 --- /dev/null +++ b/dnfs/Dockerfile @@ -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"] diff --git a/dnfs/README.md b/dnfs/README.md index f0ad6ef..1413830 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点 监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。 +支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。 + ## 架构 ``` @@ -44,9 +46,26 @@ Authorization: Bearer _ - payload 中取 `user` 字段作为操作者,写入 `owner`。 - `delete_note` 与 `update_note` 校验所有权: - 节点 `owner` 必须等于当前 `user`,否则 `403`; - - **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。 + - **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。 - `get_notes` 不需要鉴权。 +## 特殊节点(管理员专属) + +`content` 为以下信封 JSON 时即为特殊节点,前端 `dnfs.js` 按 `_sp` 类型差异化渲染;普通 Markdown 内容不受影响: + +| `_sp` | 信封字段 | 前端表现 | +|-------|---------|---------| +| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 | +| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 | +| `image` | `name`、`data`(`data:image/` DataURL,≤3.5M 字符)、`note?` | 图片直接显示 | +| `file` | `name`、`mime`、`data`(Base64,≤3.5M 字符)、`note?` | 下载按钮(Base64→Blob→下载) | + +规则: + +- **仅管理员可发布/更新**(`add_note` / `update_note` 中按 `is_admin` 强制,非管理员返回 403);前端发布面板也仅对管理员显示。 +- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`html` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。 +- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。 + ## 接口文档 所有接口均为 `POST`,`Content-Type: application/json`。 @@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \ 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。 -仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。 +仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。 **Request** @@ -196,15 +215,41 @@ curl -X POST http://localhost:8006/update_note \ ## 配置 -服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py): +dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致): -| 常量 | 值 | 说明 | -|------|----|------| -| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 | -| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 | -| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 | +**加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级) -调用超时均为 10 秒。 +### 配置文件 + +集中托管在 [confs/configs/dnfs.ini](../confs/configs/dnfs.ini),由 confs 服务分发: + +| Section | Key | 默认值 | 说明 | +|---------|-----|--------|------| +| `[server]` | `host` | `127.0.0.1` | 监听地址 | +| `[server]` | `port` | `8006` | 监听端口 | +| `[database]` | `host` | `127.0.0.1` | db 服务地址 | +| `[database]` | `port` | `8005` | db 服务端口 | +| `[database]` | `timeout` | `10` | RPC 调用超时(秒) | +| `[ai_review]` | `url` | `http://127.0.0.1:8007/review` | AI 审核接口地址 | +| `[ai_review]` | `timeout` | `10` | AI 审核请求超时(秒) | +| `[confs]` | `host` | `127.0.0.1` | confs 服务地址 | +| `[confs]` | `port` | `8010` | confs 服务端口 | + +### 环境变量(Docker 环境必需,优先级最高) + +环境变量覆盖配置文件值,容器化部署时通过 docker-compose.yml 传入服务名: + +| 变量 | 容器内值 | 说明 | +|------|---------|------| +| `LISTEN_ADDR` | `0.0.0.0` | 监听地址(容器内需绑定所有网卡) | +| `LISTEN_PORT` | `8006` | 监听端口 | +| `DB_HOST` | `db` | db 服务主机名(docker-compose 服务名) | +| `DB_PORT` | `8005` | db 服务端口 | +| `REVIEW_URL` | `http://ai_review:8007/review` | ai_review 服务地址 | +| `CONFS_HOST` | - | 可选,confs 容器化后使用 | +| `CONFS_PORT` | - | 可选,confs 容器化后使用 | + +配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层。本地裸机运行时 confs 不可用会自动回退到内置默认值 + 环境变量。 ## 前端 diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index c06f7c4..ff78c71 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -1,9 +1,5 @@ #!/usr/bin/env python3 - - - - ''' Dendrachy Network File-System (dnfs) @@ -18,11 +14,13 @@ Dendrachy Network File-System (dnfs) from flask import Flask, request, jsonify, render_template from flask_cors import CORS -#from flask_sqlalchemy import SQLAlchemy +#from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信 from waitress import serve import os +import sys import json import base64 +import configparser import urllib.request import urllib.error from typing import Dict, Any @@ -30,39 +28,112 @@ from security_middleware import apply_security_headers from log_client import logs # pymsg 导入 -import sys as _sys -_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import MsgClient # noqa: E402 +# 我们有在依赖列表中加入pymsg +# 这个可以省略也许 +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from pymsg import RpcClient, RpcError # noqa: E402 -_DBV2_ADDR = '127.0.0.1' -_DBV2_PORT = 8005 +# ==================== 配置加载(三层 fail-safe) ==================== +# 加载顺序:内置默认值 → confs 统一配置服务(dnfs.ini) → 环境变量覆盖 +# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1) + +_dnfs_config = { + 'server': {'host': '127.0.0.1', 'port': '8006'}, + 'database': {'host': '127.0.0.1', 'port': '8005', 'timeout': '10'}, + 'ai_review': {'url': 'http://127.0.0.1:8007/review', 'timeout': '10'}, + 'confs': {'host': '127.0.0.1', 'port': '8010'}, +} + + +def _merge_ini(text): + """把 ini 文本合并进 _dnfs_config(仅覆盖已知的 section/key,白名单合并)。""" + ini = configparser.ConfigParser(interpolation=None) + ini.read_string(text) + for section in _dnfs_config: + if ini.has_section(section): + for key, value in ini[section].items(): + if key in _dnfs_config[section]: + _dnfs_config[section][key] = value + + +def _fetch_from_confs(): + """从 confs 统一配置服务拉取 dnfs.ini 全文,失败返回 None(fail-safe)。""" + try: + host = os.environ.get('CONFS_HOST', _dnfs_config['confs']['host']) + port = int(os.environ.get('CONFS_PORT', _dnfs_config['confs']['port'])) + data = RpcClient(host, port, 'dnfs', timeout=3).call( + 'get', {'name': 'dnfs.ini'}) + return data.get('content') + except Exception: + return None + + +_confs_text = _fetch_from_confs() +if _confs_text is not None: + _merge_ini(_confs_text) + logs.info("dnfs", "配置来源: confs 统一配置服务 (dnfs.ini)") +else: + logs.warning("dnfs", "confs 不可用,使用内置默认值 + 环境变量") + +# 环境变量覆盖(优先级最高,Docker 环境需要) +if os.environ.get('LISTEN_ADDR'): + _dnfs_config['server']['host'] = os.environ['LISTEN_ADDR'] +if os.environ.get('LISTEN_PORT'): + _dnfs_config['server']['port'] = os.environ['LISTEN_PORT'] +if os.environ.get('DB_HOST'): + _dnfs_config['database']['host'] = os.environ['DB_HOST'] +if os.environ.get('DB_PORT'): + _dnfs_config['database']['port'] = os.environ['DB_PORT'] +if os.environ.get('REVIEW_URL'): + _dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL'] +if os.environ.get('CONFS_HOST'): + _dnfs_config['confs']['host'] = os.environ['CONFS_HOST'] +if os.environ.get('CONFS_PORT'): + _dnfs_config['confs']['port'] = os.environ['CONFS_PORT'] + +# 提取配置常量 +_DBV2_ADDR = _dnfs_config['database']['host'] +_DBV2_PORT = int(_dnfs_config['database']['port']) +_DB_TIMEOUT = int(_dnfs_config['database']['timeout']) +_REVIEW_SERVER_URL = _dnfs_config['ai_review']['url'] +_REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout']) +_ADDR = _dnfs_config['server']['host'] +_PORT = int(_dnfs_config['server']['port']) # 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload # 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。 # 旧 Token 无此字段,按非管理员处理(安全方向)。 -def _call_dbv2(payload: dict, timeout: int = 10) -> dict: - """通过 pymsg 短连接调用 dbv2 数据库服务。 - 自动注入 ``_route: "execute"``。 +_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs") + + +def _call_dbv2(payload: dict, timeout: int = None) -> dict: + """通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。 + + 返回完整响应 dict(``{"success": ..., "data"/"error": ...}``), + 与历史行为一致,调用方无需改动。服务端业务错误返回 success=False; + 网络层故障抛 ConnectionError。 """ - payload = {**payload, "_route": "execute"} + if timeout is None: + timeout = _DB_TIMEOUT try: - client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs") - resp_text = client.call(json.dumps(payload), timeout=timeout) - return json.loads(resp_text) + data = _rpc.call("execute", payload, timeout=timeout) + return {"success": True, "data": data} + except RpcError as e: + return {"success": False, "error": str(e)} except Exception as e: raise ConnectionError( f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}" ) from None -REVIEW_SERVER_URL = 'http://127.0.0.1:8007/review' -def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: +# AI审核服务地址 +def review_content(content: str, timeout: int = None) -> Dict[str, Any]: """ 调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。 Args: content: 待审核的文本内容,不可为空。 - timeout: 请求超时时间(秒),默认 10 秒。 + timeout: 请求超时时间(秒),默认使用配置值。 Returns: 字典,包含接口返回的字段,例如: @@ -72,7 +143,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: - 请求参数错误时:{"error": "content is required"} 等 - 网络或异常时:{"error": "request_failed", "detail": "..."} """ - url = REVIEW_SERVER_URL + if timeout is None: + timeout = _REVIEW_TIMEOUT + url = _REVIEW_SERVER_URL payload = {"content": content} json_data = json.dumps(payload).encode("utf-8") @@ -102,47 +175,112 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]: except Exception as e: raise e +class DnfsExp(Exception): + '''dnfs异常类''' + def __init__(self, msg: str, code: int): + super().__init__(self, msg) + self.status = code + def get_status(self) -> int: + return self.status + +# ==================== 特殊节点(管理员专属) ==================== +# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染; +# 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。 +_SP_TYPES = ('link', 'html', 'image', 'file') +_SP_MAX_URL = 2048 # link 节点 url 长度上限 +_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符) +_SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据) +_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度 -#basedir = os.path.abspath(os.path.dirname(__file__)) +def _parse_sp(content): + """识别特殊节点信封:合法信封返回 dict;普通内容返回 None; + 以 {"_sp" 开头但 JSON 非法视为格式错误(拒绝发布,防半截信封流入)。""" + if not isinstance(content, str): + return None + s = content.lstrip() + if not s.startswith('{"_sp"'): + return None + try: + obj = json.loads(s) + except Exception: + raise DnfsExp('特殊节点格式非法(JSON 解析失败)', 400) + if isinstance(obj, dict) and obj.get('_sp') in _SP_TYPES: + return obj + return None + + +def _validate_sp(sp): + """校验特殊节点信封字段与大小上限,非法抛 DnfsExp。""" + t = sp['_sp'] + if t == 'link': + url = sp.get('url', '') + if not isinstance(url, str) or not url.startswith(('http://', 'https://')): + raise DnfsExp('链接节点必须是 http/https URL', 400) + if len(url) > _SP_MAX_URL: + raise DnfsExp('链接过长', 400) + elif t == 'html': + code = sp.get('code', '') + if not isinstance(code, str) or not code.strip(): + raise DnfsExp('网页代码不能为空', 400) + if len(code) > _SP_MAX_CODE: + raise DnfsExp('网页代码过大(上限 512KB)', 400) + elif t == 'image': + data = sp.get('data', '') + if not isinstance(data, str) or not data.startswith('data:image/'): + raise DnfsExp('图片节点 data 必须是 data:image/ 开头的 DataURL', 400) + if len(data) > _SP_MAX_DATA: + raise DnfsExp('图片过大(上限约 2.6MB)', 400) + elif t == 'file': + name = sp.get('name', '') + data = sp.get('data', '') + if not isinstance(name, str) or not name.strip(): + raise DnfsExp('文件节点缺少文件名', 400) + if len(name) > 255: + raise DnfsExp('文件名过长', 400) + if not isinstance(data, str) or not data: + raise DnfsExp('文件节点缺少数据', 400) + if len(data) > _SP_MAX_DATA: + raise DnfsExp('文件过大(上限约 2.6MB)', 400) + + +def _sp_review_text(title, sp): + """特殊节点的 AI 审核文本:剔除 base64 负载(体量大且无审核意义),仅保留文本字段。""" + parts = [title or ''] + note = sp.get('note') + if isinstance(note, str) and note.strip(): + parts.append(note) + t = sp['_sp'] + if t == 'link': + parts.append(str(sp.get('url', ''))) + elif t == 'html': + parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX]) + else: # image / file + parts.append(str(sp.get('name', ''))) + return '\n'.join(parts) + +# 这个也许不用这么复杂 大不了每个异常点我们抛出DnfsExp类 +#class InvalidRequest(DnfsExp): +# '''非法参数异常''' +# def __init__(self, msg: str): +# super().__init__(msg) + +def _handle_exp(func): + '''处理视图函数中抛出的异常,并在日志模块中进行记录''' + def wrapper(): + try: return func() + except DnfsExp as e: + logs.warning('dnfs', str(e), extra={}) + return jsonify({'error': str(e)}), e.get_status() + except Exception as e: + logs.warning('dnfs', str(e), extra={}) + return jsonify({'error': str(e)}), 500 + return wrapper app = Flask(__name__) apply_security_headers(app) CORS(app) -#app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'notes.db') - -#app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -#db = SQLAlchemy(app) - -#class Note(db.Model): - -# id = db.Column(db.Integer, primary_key=True) - -# pid = db.Column(db.Integer, nullable=False) - -# title = db.Column(db.String(128), nullable=False) - -# content = db.Column(db.Text, nullable=False) - - - -# def __repr__(self): - -# return f"" - -#with app.app_context(): - -# db.create_all() - - - -# eg: { -# "pid": 0, -# "title": "test", -# "content": "hello" -#} def decode_token(token: str): idx = token.find('_') @@ -155,32 +293,26 @@ def decode_token(token: str): @app.route('/add_note', methods=['POST']) - +@_handle_exp def add_note(): - + # 获取请求数据 data = request.get_json() - pid: int = data.get('pid') - title: str = data.get('title') - content: str = data.get('content') - if pid is None or not title or not content: - - logs.warning('dnfs', 'add_note 参数缺失', extra={'ip': request.remote_addr}) - return jsonify({"error": "Pid, title and content are required"}), 400 + raise DnfsExp("非法参数", 400) info = None auth: str = request.headers.get('Authorization', '') if auth.startswith('Bearer '): token: str = auth[7:] info = decode_token(token) - if info is None: - logs.warning('dnfs', 'add_note 未授权访问', extra={'ip': request.remote_addr}) - return jsonify({"error": "Warning: no Authorization"}), 401 + raise DnfsExp("未授权访问,从"+request.remote_addr, 401) + + # 父节点是否存在 if pid != 0: select_payload = { @@ -193,19 +325,22 @@ def add_note(): notes = _call_dbv2(select_payload) - # 不存在的父节点 if len( notes['data']) == 0: - logs.warning('dnfs', f'add_note 父节点不存在 pid={pid}', extra={'user': info['user']}) - return jsonify({'error': 'the new node need a parent'}), 400 + raise DnfsExp('父节点不存在', 400) - # 内容审查 - review = review_content(title + '\n' + content, 10) - if True != review: - #return jsonify({'error': 'review failed', 'data': review}), 400 - logs.warning('dnfs', f'add_note 内容审核未通过 title={title}', extra={'user': info['user']}) - return jsonify({'error': 'review failed'}), 400 + # 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验 + sp = _parse_sp(content) + if sp is not None: + if not info.get('is_admin'): + raise DnfsExp('特殊节点仅管理员可发布,用户'+info.get('user')+',从'+request.remote_addr, 403) + _validate_sp(sp) + # 内容审查(特殊节点剔除 base64 负载) + review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content) + if True != review: raise DnfsExp('内容审核不通过,用户'+info.get('user')+',从'+request.remote_addr, 400) + + # 执行插入 insert_payload = { 'action': 'insert', 'table': 'Notes', @@ -218,20 +353,17 @@ def add_note(): } result = _call_dbv2(insert_payload) - success = result['success'] - if success is True: logs.info('dnfs', f'add_note 成功 title={title} pid={pid}', extra={'user': info['user']}) return jsonify({'success': success}), 200 else: - logs.error('dnfs', f'add_note 写入失败 title={title}', extra={'user': info['user']}) - return jsonify({'success': success}), 400 + raise DnfsExp('插入节点失败,用户'+info.get('user')+',从'+request.remote_addr, 400) # {“pid”: 0} +@_handle_exp @app.route('/get_notes', methods=["POST"]) - def get_notes(): data = request.get_json() @@ -273,36 +405,6 @@ def get_notes(): return jsonify(result), 200 -# 当前用户身份查询:配合前端用户面板,返回 user / is_admin。 -# 与 get_notes 同款可选解析:无 Token 或畸形 Token 按未登录处理(不 500)。 -@app.route('/whoami', methods=["POST"]) -def whoami(): - info = None - auth: str = request.headers.get('Authorization', '') - if auth.startswith('Bearer '): - try: - info = decode_token(auth[7:]) - except Exception: - info = None - - if info is None: - return jsonify({'login': False}), 200 - - resp = { - 'login': True, - 'user': info.get('user'), - 'is_admin': bool(info.get('is_admin')) - } - # 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 - if info.get('iat'): - resp['iat'] = info['iat'] - if info.get('created_at'): - resp['created_at'] = info['created_at'] - if info.get('keep_time'): - resp['keep_time'] = info['keep_time'] - return jsonify(resp), 200 - - # eg: {"id": 1} @app.route('/delete_note', methods=['POST']) @@ -454,8 +556,19 @@ def update_note(): logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 - # 内容审查 - review = review_content(title + '\n' + content, 10) + # 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验 + sp = _parse_sp(content) + if sp is not None: + if not info.get('is_admin'): + logs.warning('dnfs', f'update_note 特殊节点非管理员拒绝 id={id}', extra={'user': info['user']}) + return jsonify({"error": '特殊节点仅管理员可发布'}), 403 + try: + _validate_sp(sp) + except DnfsExp as e: + return jsonify({"error": str(e)}), e.get_status() + + # 内容审查(特殊节点剔除 base64 负载) + review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content) if True != review: #return jsonify({'error': 'review failed', 'data': review}), 400 logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']}) @@ -545,10 +658,7 @@ def get_note(): # """返回应用主页""" # return render_template('index.html') -ADDR='127.0.0.1' -PORT=8006 if __name__ == "__main__": - logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}') - print(f'Listen at http://{ADDR}:{PORT}') - #app.run(host="127.0.0.1", port=8006) - serve(app, host='127.0.0.1', port=8006) + logs.info('dnfs', f'服务启动,监听 http://{_ADDR}:{_PORT}(db={_DBV2_ADDR}:{_DBV2_PORT}, ai_review={_REVIEW_SERVER_URL})') + print(f'Listen at http://{_ADDR}:{_PORT}') + serve(app, host=_ADDR, port=_PORT) diff --git a/dnfs/docs/SECURITY_AUDIT_20260801.md b/dnfs/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..e85cf33 --- /dev/null +++ b/dnfs/docs/SECURITY_AUDIT_20260801.md @@ -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 验证测试 +- [ ] 节点操作速率限制 diff --git a/dnfs/log_client.py b/dnfs/log_client.py index f3188b6..0dd516f 100644 --- a/dnfs/log_client.py +++ b/dnfs/log_client.py @@ -1,174 +1,244 @@ -""" -统一日志客户端 — 各服务共用。 - -设计要点: - - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - -用法: - from log_client import logs - - logs.info("dnfs", "节点新增成功 id=42") - logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) - logs.push("gateway", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import threading -import queue -import time -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import MsgClient # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = '127.0.0.1' -_DBV2_PORT = 8005 - -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) - - -class LogClient: - """异步批量日志推送客户端。""" - - def __init__(self, batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue() - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service, level, message, trace_id=None, extra=None): - """推送一条日志(非阻塞,立即返回)。""" - self._queue.put({ - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - }) - - def debug(self, service, message, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service, message, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service, message, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service, message, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service, message, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - time.sleep(0.6) # 等待后台线程响应 - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) - self._flush_signal.clear() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - self._send(batch) - batch = [] - last_flush = time.monotonic() - - # 退出前 flush 剩余 - if batch: - self._send(batch) - - def _send(self, batch): - """通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" - now = datetime.now(timezone.utc).isoformat() - try: - client = MsgClient(_DBV2_ADDR, _DBV2_PORT, "dnfs-log") - for entry in batch: - extra_val = entry.get("extra") - payload = { - "_route": "execute", - "action": "insert", - "table": "Logs", - "values": { - "ts": now, - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": json.dumps(extra_val, ensure_ascii=False) - if extra_val is not None else None, - }, - } - resp = client.call(json.dumps(payload), timeout=_TIMEOUT) - resp_obj = json.loads(resp) - if not resp_obj.get("success"): - import sys - print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", - file=sys.stderr) - except Exception as e: - import sys - print(f"[log_client] 发送失败: {e}", file=sys.stderr) - - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() - - -# ── 全局单例 ────────────────────────────────────────────────────────── - -logs = LogClient() +"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 + +设计要点: + - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 + - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 + +用法: + from log_client import logs + + logs.info("dnfs", "节点新增成功 id=42") + logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) + logs.push("gateway", "WARNING", "登录失败次数超限", + trace_id="req-abc", extra={"ip": "1.2.3.4"}) +""" + +import json +import os as _os +import sys as _sys +import threading +import queue +import time +import atexit +from datetime import datetime, timezone + +_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) +from pymsg import RpcClient, RpcError # noqa: E402 + +# ── 配置 ────────────────────────────────────────────────────────────── + +_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') +_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) + +_BATCH_SIZE = 50 # 队列满 50 条时 flush +_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush +_TIMEOUT = 5 # socket 超时(秒) +_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 + + +def _safe_json(obj): + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + return repr(obj) + + +def _now_iso(): + """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + return datetime.now(timezone.utc).isoformat() + + +class LogClient: + """异步批量日志推送客户端。""" + + def __init__(self, service_name="log", + batch_size=_BATCH_SIZE, + flush_interval=_FLUSH_INTERVAL): + self._service_name = service_name + self._batch_size = batch_size + self._flush_interval = flush_interval + self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) + self._stop = threading.Event() + self._flush_signal = threading.Event() + self._flush_done = threading.Event() + self._thread = threading.Thread(target=self._flush_loop, + daemon=True, name='log-client') + self._thread.start() + atexit.register(self._shutdown) + + # ── 公开接口 ──────────────────────────────────────────────────────── + + def push(self, service, level, message, trace_id=None, extra=None): + """推送一条日志(非阻塞,立即返回)。 + + 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 + """ + entry = { + 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 + 'service': service, + 'level': level, + 'message': message, + 'trace_id': trace_id, + 'extra': extra, + } + try: + self._queue.put_nowait(entry) + except queue.Full: + # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(entry) + except queue.Full: + pass # 极端竞争下仍失败则静默丢弃 + + def debug(self, service, message, **kw): + self.push(service, 'DEBUG', message, **kw) + + def info(self, service, message, **kw): + self.push(service, 'INFO', message, **kw) + + def warning(self, service, message, **kw): + self.push(service, 'WARNING', message, **kw) + + def error(self, service, message, **kw): + self.push(service, 'ERROR', message, **kw) + + def critical(self, service, message, **kw): + self.push(service, 'CRITICAL', message, **kw) + + def flush(self): + """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" + self._flush_done.clear() + self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch + # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) + self._flush_done.wait(timeout=self._flush_interval + 2.0) + # 主线程排空队列中剩余条目 + self._drain_queue_to_db() + + def _drain_queue_to_db(self): + """排空主线程可见的队列中剩余条目,直接发送到 db。""" + batch = [] + while True: + try: + batch.append(self._queue.get_nowait()) + if len(batch) >= self._batch_size: + self._send(batch) + batch = [] + except queue.Empty: + break + if batch: + self._send(batch) + + # ── 内部实现 ──────────────────────────────────────────────────────── + + def _flush_loop(self): + """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 + + 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) + 都不会死亡,避免日志队列无限增长导致 OOM。 + """ + batch = [] + last_flush = time.monotonic() + + while not self._stop.is_set(): + try: + timeout = self._flush_interval - (time.monotonic() - last_flush) + chk_timeout = max(0.1, min(timeout, 0.5)) + try: + item = self._queue.get(timeout=chk_timeout) + batch.append(item) + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + except queue.Empty: + pass + + should_flush = ( + len(batch) >= self._batch_size or + self._flush_signal.is_set() or + (batch and time.monotonic() - last_flush >= self._flush_interval) + ) + if should_flush and batch: + try: + self._send(batch) + except Exception as e: + # _send 内部已有细粒度异常处理,此处是最终安全网 + print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + batch = [] + last_flush = time.monotonic() + if self._flush_signal.is_set(): + self._flush_signal.clear() + self._flush_done.set() + except Exception as e: + # 最外层安全网:任何意外异常都不应杀死日志线程 + print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + time.sleep(0.5) # 防止异常风暴导致 CPU 满载 + + # 退出前 flush 剩余 + if batch: + try: + self._send(batch) + except Exception as e: + print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) + self._flush_done.set() + + def _send(self, batch): + """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 + + 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), + 不抛异常给调用方。 + """ + try: + client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") + except Exception as e: + print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) + return + + for entry in batch: + extra_val = entry.get("extra") + try: + extra_json = _safe_json(extra_val) if extra_val is not None else None + except Exception: + extra_json = None + payload = { + "action": "insert", + "table": "Logs", + "values": { + "ts": entry.get("ts", _now_iso()), + "service": entry.get("service"), + "level": entry.get("level"), + "message": entry.get("message"), + "trace_id": entry.get("trace_id"), + "extra": extra_json, + }, + } + try: + client.call("execute", payload, timeout=_TIMEOUT) + except RpcError as e: + # 业务错误(如表结构不符):打印后继续下一条 + print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) + except Exception as e: + # 网络错误:db 不可用,放弃本批剩余(fail-safe) + print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + break + + def _shutdown(self): + """进程退出时 flush 剩余日志。""" + self._stop.set() + self.flush() + + +# ── 全局单例 ────────────────────────────────────────────────────────── + +logs = LogClient() diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b1be30d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker.sh b/docker.sh new file mode 100755 index 0000000..281629a --- /dev/null +++ b/docker.sh @@ -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 " + 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 [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 从 JSON 导入数据(文件放在 ./backups/ 下)" + echo " db-restore 从 .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 diff --git a/docs/DEPLOY_SECURITY_AUDIT_20260801.md b/docs/DEPLOY_SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..cbba463 --- /dev/null +++ b/docs/DEPLOY_SECURITY_AUDIT_20260801.md @@ -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 镜像安全扫描 diff --git a/docs/DEPS_SECURITY_AUDIT_20260801.md b/docs/DEPS_SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..9c7b7b5 --- /dev/null +++ b/docs/DEPS_SECURITY_AUDIT_20260801.md @@ -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 解析器为成熟第三方库 diff --git a/docs/SECURITY_AUDIT_20260801.md b/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..afb11b3 --- /dev/null +++ b/docs/SECURITY_AUDIT_20260801.md @@ -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 diff --git a/gateway/Makefile b/gateway/Makefile index e144517..7e9e18d 100644 --- a/gateway/Makefile +++ b/gateway/Makefile @@ -3,7 +3,7 @@ APP_NAME := gateway INSTL_DIR := /usr/local/bin CPP := g++ INCS := -I inc/ -MY_LIBS := -L/usr/lib -lus_http -lus_crypt +MY_LIBS := -L/usr/lib -lus_http -lus_httpclient -lus_crypt LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror BUILD_DIR = build diff --git a/gateway/README.md b/gateway/README.md index 66b643d..e3236db 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -7,10 +7,12 @@ UniServe Framework 的应用层网关,C++20 实现。 ## 职责 - **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。 -- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 +- **HTTP 反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。 +- **WebSocket 反向代理(仅管理员)**:`[WsProxy]` 段配置的路径在 HTTP Upgrade 握手时校验 Token(`is_admin=true`),通过后透明桥接到后端 ws:// 或 wss:// 服务,支持文本/二进制帧双向转发。 - **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。 - **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。 -- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 +- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。 +- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。 - **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。 ## 安全特性 @@ -86,11 +88,24 @@ UniServe Framework 的应用层网关,C++20 实现。 向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。 +**请求体**(CAPTCHA 必填,防邮件轰炸): + ```json -{ "email": "user@example.com" } +{ + "email": "user@example.com", + "captcha_token": "a1b2c3d4...", + "captcha_answer": "Ab3xy" +} ``` -限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 +**防护层次**: +1. IP 限流:5 次/10 分钟(`X-Forwarded-For`) +2. CAPTCHA 强制校验:经 captcha:8009 pymsg 校验,Token 一次性消费 +3. 邮箱级双窗口限流:3 次/小时 + 10 次/天 +4. smtp 同邮箱 60 秒发送冷却(冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却) +5. smtp 邮箱级长期限流兜底 + 全局熔断(默认 100 封/小时,`SMTP_HOURLY_LIMIT` 可配) + +错误码:`400`(邮箱格式错误/CAPTCHA 缺失或错误)、`429`(IP 速率超限/邮箱级限流超限/同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。 ### `GET/POST *`(反向代理) @@ -110,6 +125,27 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default 返回 CORS 预检响应:`Access-Control-Allow-Origin: *`、`Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。 +### WebSocket 代理(`[WsProxy]` 配置的路径) + +握手阶段鉴权规则: + +1. 从 `Authorization: Bearer ` 头读取 Token;若缺失则回退到 `Cookie: q4_token=`。 +2. RSA 验签 + 过期校验(与 HTTP 代理同样的 Token 机制)。 +3. Token payload 中必须 `is_admin=true`(即登录账号在 `Admin.users` 白名单内)。 +4. 鉴权失败 → 返回 HTTP 403 Forbidden,连接被拒绝。 +5. 鉴权通过 → 升级为 WebSocket 并透明桥接到配置的后端 ws:// 或 wss:// 地址,双向文本/二进制帧原样转发。 + +客户端使用方式与普通 WebSocket 一致,只需携带有效管理员 Token: + +```javascript +// 浏览器(同源会自动带 q4_token Cookie) +const ws = new WebSocket("wss://qsont.me/ws/aibox"); + +// 或显式带 Authorization(构造子协议或连接后首条消息均可,这里由网关在 Upgrade 头校验) +``` + +注意:WS 代理路由必须精确匹配(与 `ws()` 一致,非通配),且优先级高于 HTTP 通配 `*` 路由。 + ## 数据库 用户数据存于 db 服务(pymsg/TCP :8005)的 SQLite 库,表 `Users`(由 [db/create_sqlite3.sql](../db/create_sqlite3.sql) 建立,gateway 不再自动建表): @@ -146,6 +182,11 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006; # Token 有效期(秒),默认 3600 = 1 小时 keep_time = 3600 +[Admin] +# 管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 is_admin=true +# WebSocket 代理仅允许管理员访问,其他需管理员权限的下游同样凭此字段判断 +users = admin@example.com + [CORS] # 允许跨域的 Origin 白名单(分号分隔) # 仅命中的 Origin 才会回填 Access-Control-Allow-Origin;缺省为空(不回填) @@ -153,13 +194,22 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht [Database] path = sqlite3.db + +[WsProxy] +# WebSocket 反向代理路由(仅管理员) +# 格式:/path=ws://host:port/target;/path2=wss://host2/target2 +# 支持 ws:// 和 wss:// 后端,握手阶段校验管理员 Token +# routes = /ws/aibox=ws://127.0.0.1:8765;/ws/log=wss://example.com/log +routes = ``` - `Proxy.default`:未带 `Target-Url` header 时的默认转发地址(不需 Token,主要服务前端静态资源)。 - `Proxy.opened`:允许外部带 Token 访问的内部服务白名单(分号分隔)。 - `Proxy.closed`:仅记录用途,实际靠"不在 opened 中即拒绝"实现隔离。 - `Token.keep_time`:登录签发 Token 的有效期(秒),缺省 3600。 +- `Admin.users`:管理员白名单(分号分隔邮箱),登录时命中即在 Token 中写入 `is_admin=true`。 - `CORS.allowed_origins`:OPTIONS 预检允许跨域的 Origin 白名单(分号分隔),缺省为空(不回填 ACAO)。 +- `WsProxy.routes`:WebSocket 代理路由映射,格式 `/path=ws://host:port/target`,多个用分号分隔;留空则不开启 WS 代理。 ## 部署模式 @@ -226,14 +276,16 @@ gateway/ ## 版本 -`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印: +`0.6.0`(见 [src/main.cpp:31](src/main.cpp#L31))。启动时打印: ``` -Gateway 0.5.3 started +Gateway 0.6.0 started ``` 近期变更: +- `0.6.0`:WebSocket 反向代理(仅管理员)——新增 `[WsProxy]` 配置段,支持 `routes = /path=ws://host:port/target;...` 精确路径映射;握手阶段校验 Token 中 `is_admin=true`(基于 `Admin.users` 白名单),通过后双向透明桥接文本/二进制帧到后端,支持 `ws://` 和 `wss://`;后端连接错误/鉴权失败均优雅关闭。依赖 libhttp v3.1+ 的 `ws_proxy()` 接口。顺带修复 `LocalTime.hpp` 在 Apple clang 下 C++20 tzdb 不可用的编译问题(改用 `strftime`/`get_time`/`mktime`)。 +- `0.5.4`:修复邮件轰炸漏洞(审计报告 P0-1)——`/register/send_code` 强制 CAPTCHA 校验(经 captcha:8009,Token 一次性消费),新增邮箱级双窗口限流(3 次/小时 + 10 次/天);smtp 层同步增加邮箱级长期限流兜底 + 全局熔断(`SMTP_HOURLY_LIMIT` 环境变量,默认 100 封/小时);前端 `register.html`/`register.js` 同步接入图形验证码(复用 `/captcha/image` 代理路由,方案 A:仅在「获取验证码」时要求 CAPTCHA,注册提交不加)。 - `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。 - `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。 - `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。 diff --git a/gateway/docs/SECURITY_AUDIT_20260801.md b/gateway/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..bb91e1d --- /dev/null +++ b/gateway/docs/SECURITY_AUDIT_20260801.md @@ -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 场景) +- [ ] 管理操作审计日志 diff --git a/gateway/example.ini b/gateway/example.ini index 9221437..495f72f 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -32,3 +32,12 @@ allowed_origins = https://qsont.me;https://www.qsont.me;http://127.0.0.1:8443;ht [Database] path = sqlite3.db + +[WsProxy] +# WebSocket 反向代理路由(仅管理员可访问) +# 格式:routes = /path=ws://host:port/target;/path2=ws://host2:port2/target2 +# 握手阶段会校验 HTTP Upgrade 请求中的 Token(Authorization: Bearer xxx 或 Cookie: q4_token=xxx), +# 仅 Token 中 is_admin=true 的用户才能升级连接;非管理员或无 Token 直接拒绝。 +# 支持 ws:// 和 wss:// 后端 +# 示例:routes = /ws/aibox=ws://127.0.0.1:8765;/ws/other=wss://example.com/ws +routes = /ws/aibox=ws://127.0.0.1:8765; diff --git a/gateway/inc/LocalTime.hpp b/gateway/inc/LocalTime.hpp index be7d415..2883845 100644 --- a/gateway/inc/LocalTime.hpp +++ b/gateway/inc/LocalTime.hpp @@ -1,20 +1,23 @@ #pragma once #include #include -#include #include #include +#include +#include namespace qing { class LocalTime { public: // 获取当前本地时间字符串 static std::string getCurrent(const std::string& format = "%Y-%m-%d %H:%M:%S") { - using namespace std::chrono; - auto now = system_clock::now(); - auto zone = current_zone(); - zoned_time zt{zone, now}; - return std::vformat("{:" + format + "}", std::make_format_args(zt)); + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + std::tm tm_buf{}; + localtime_r(&t, &tm_buf); + char buf[128]; + std::strftime(buf, sizeof(buf), format.c_str(), &tm_buf); + return std::string(buf); } // 解析时间字符串为 system_clock::time_point(本地时间 → UTC) @@ -22,29 +25,29 @@ public: const std::string& str, const std::string& format = "%Y-%m-%d %H:%M:%S" ) { - using namespace std::chrono; + std::tm tm_buf{}; std::istringstream iss{str}; - local_time lt; - iss >> std::chrono::parse(format, lt); // ✅ 修复 + iss >> std::get_time(&tm_buf, format.c_str()); if (iss.fail()) { return std::nullopt; } - auto zone = current_zone(); - zoned_time zt{zone, lt}; - return zt.get_sys_time(); + // mktime 将本地时间转换为 time_t(UTC) + std::time_t t = std::mktime(&tm_buf); + if (t == -1) { + return std::nullopt; + } + return std::chrono::system_clock::from_time_t(t); } // ----- 过期判断 ----- static bool isExpired(const std::chrono::system_clock::time_point& tp) { - using namespace std::chrono; - return system_clock::now() > tp; + return std::chrono::system_clock::now() > tp; } template static bool isExpired(const std::chrono::system_clock::time_point& tp, const std::chrono::duration& gracePeriod) { - using namespace std::chrono; - return system_clock::now() > tp + gracePeriod; + return std::chrono::system_clock::now() > tp + gracePeriod; } static bool isExpired(const std::string& timeStr, diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index 70a5be1..2437b17 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -28,7 +28,7 @@ using namespace qing; namespace json = boost::json; namespace po = boost::program_options; -constexpr const char *_VER = "0.5.3"; +constexpr const char *_VER = "0.6.0"; ////////////////////////////////// #include @@ -306,6 +306,11 @@ http::response handleRefreshToken( const http::request& req, const std::unordered_map& params); +/* 当前用户身份查询:验签 Token 后返回 user / is_admin / Token 生命周期信息 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params); + /* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ http::response handleCaptchaImage( const http::request& req, @@ -334,14 +339,23 @@ static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数 static std::vector _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin +/* WebSocket 代理路由:path -> ws://backend-host:port/target + * 仅管理员用户可访问,握手阶段由 ws_proxy 回调验签 Token 中的 is_admin */ +static std::unordered_map _WS_PROXY_ROUTES; + /* instance items */ static std::unique_ptr _SERVE; static std::unique_ptr _KEYGEN; //static std::unique_ptr _SQLDB; static RateLimiter _LOGIN_RATE; static RateLimiter _REGISTER_RATE; -static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流 +static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码 IP 级限流 static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流 +// 邮箱级双窗口限流:同一邮箱在 gateway 层的发送配额。 +// 配合 IP 限流(IP 可被代理池绕过)与 smtp 同邮箱 60s 冷却(仅防短期重复)。 +// 双窗口设计:小时窗限制短期轰炸,天窗限制长期骚扰。 +static RateLimiter _EMAIL_ADDR_RATE_HOUR; // 3 次/小时/邮箱 +static RateLimiter _EMAIL_ADDR_RATE_DAY; // 10 次/天/邮箱 /* pymsg 下游调用超时(秒): * captcha / smtp verify_email 均为本机内存操作,5s 足够; @@ -510,6 +524,31 @@ int main(int argc, char **argv) { _TOKEN_MAX_REFRESH = cf.get("Token.max_refresh", 10); _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); _ADMIN_USERS = split(cf.get("Admin.users", ""), ';'); + + /* [WsProxy] WebSocket 代理路由:格式 /path=ws://host:port/target;... + * 仅管理员(Token 中 is_admin=true)可访问,用于 aibox 等内部 WS 服务的网关暴露 */ + { + std::string ws_cfg = cf.get("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& 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(obj["created_at"]); + auto keep_time_val = json::value_to(obj["keep_time"]); + auto created_tp = LocalTime::parse(t); + if (created_tp) { + auto now = std::chrono::system_clock::now(); + auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val); + if (now > expire_tp) return false; + } + + // 必须是管理员 + bool is_admin = false; + if (obj.contains("is_admin")) { + auto& v = obj["is_admin"]; + if (v.is_bool()) is_admin = v.as_bool(); + } + if (!is_admin) { + logs().warning("gateway", "WS proxy rejected: non-admin user", + std::nullopt, std::string(req.target())); + return false; + } + + out_target = target_url; + // 将客户端请求路径附加到目标URL后(支持子路径) + std::string req_path = std::string(req.target()); + auto qpos = req_path.find('?'); + if (qpos != std::string::npos) req_path = req_path.substr(0, qpos); + // 不追加路径:代理直接使用配置中指定的完整目标URL + (void)req_path; + return true; + } + catch (const std::exception& e) { + std::cerr << "[WS proxy auth error] " << e.what() << std::endl; + return false; + } +} + /* 注册http路由 */ void registerRoutes(HttpServer &server) { @@ -635,6 +749,11 @@ void registerRoutes(HttpServer &server) { [](const auto& req, const auto& params) { return handleRefreshToken(req, params); }); }); + server.post("/whoami", [](const auto& req, const auto& params) -> http::response { + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleWhoami(req, params); }); + }); + /* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */ server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response { return wrapperHttp(req, params, @@ -647,9 +766,24 @@ void registerRoutes(HttpServer &server) { }); server.post("*", [](const auto& req, const auto& params) { - return handleProxy(req, params, "post"); + return wrapperHttp(req, params, + [](const auto& req, const auto& params) { return handleProxy(req, params, "post"); }); }); + /* WebSocket 反向代理(仅管理员): + * ws_proxy 回调在 HTTP Upgrade 握手阶段触发, + * 返回非空 ws:// URL 表示鉴权通过并桥接到该后端;返回空串则拒绝连接。 */ + for (const auto& [path, url] : _WS_PROXY_ROUTES) { + server.ws_proxy(path, [url](const http::request& upgradeReq) -> std::string { + std::string target; + if (verifyWsAdminToken(upgradeReq, url, target)) { + return target; + } + return ""; + }); + std::cout << " WS-PROXY " << path << " -> " << url << " (admin only)" << std::endl; + } + } /* Http视图包装器 @@ -946,6 +1080,132 @@ http::response handleLogout( return res; } +/* 当前用户身份查询 /whoami + * + * 验签 Token 后返回 user / is_admin / Token 生命周期信息(iat / created_at / keep_time)。 + * Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。 + * 无 Token 或验签失败返回 {"login": false},与 dnfs 旧接口契约一致。 */ +http::response handleWhoami( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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(obj.at("user")); + + /* is_admin:旧 Token 未携带时默认为 false */ + bool is_admin = false; + auto it_admin = obj.find("is_admin"); + if (it_admin != obj.end() && it_admin->value().is_bool()) + is_admin = it_admin->value().as_bool(); + + json::value jv_r = { + {"login", true}, + {"user", username}, + {"is_admin", is_admin} + }; + + /* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */ + auto it_iat = obj.find("iat"); + if (it_iat != obj.end() && it_iat->value().is_string()) + jv_r.as_object()["iat"] = it_iat->value(); + auto it_created = obj.find("created_at"); + if (it_created != obj.end() && it_created->value().is_string()) + jv_r.as_object()["created_at"] = it_created->value(); + auto it_keep = obj.find("keep_time"); + if (it_keep != obj.end() && it_keep->value().is_int64()) + jv_r.as_object()["keep_time"] = it_keep->value(); + + res.set(http::field::content_type, "application/json"); + res.set("Connection", "close"); + res.result(http::status::ok); + res.body() = json::serialize(jv_r); + res.prepare_payload(); + return res; +} + /* 刷新 Token:POST /refresh_token * * 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。 @@ -1284,6 +1544,28 @@ http::response handleSendEmailCode( if (!std::regex_match(email, email_regex)) THROW_EX_CUSTOM(HttpException, 3004, "Invalid email format", 400, "/register/send_code"); + // CAPTCHA 强制校验(防邮件轰炸:挡住自动化脚本,见审计报告 P0-1) + // captcha_token / captcha_answer 必填,经 captcha:8009 pymsg 校验,Token 一次性消费。 + // fail-closed:captcha 服务不可用或响应异常一律视为校验失败。 + auto captcha_token_it = obj.find("captcha_token"); + auto captcha_answer_it = obj.find("captcha_answer"); + if (captcha_token_it == obj.end() || captcha_answer_it == obj.end()) + THROW_EX_CUSTOM(HttpException, 3007, "CAPTCHA required", 400, "/register/send_code"); + if (!verify_captcha_remote(std::string(captcha_token_it->value().as_string()), + std::string(captcha_answer_it->value().as_string()))) + THROW_EX_CUSTOM(HttpException, 2201, "Invalid CAPTCHA", 400, "/register/send_code"); + + // 邮箱级双窗口限流(CAPTCHA 通过后才计入配额,避免无效请求消耗限流额度) + // 3 次/小时 + 10 次/天;IP 限流可被代理池绕过,邮箱维度是更可靠的限制。 + if (!_EMAIL_ADDR_RATE_HOUR.check_and_record(email, 3, 3600)) + THROW_EX_CUSTOM(HttpException, 3104, + "Too many code requests for this email. Please try again later.", + 429, "/register/send_code"); + if (!_EMAIL_ADDR_RATE_DAY.check_and_record(email, 10, 86400)) + THROW_EX_CUSTOM(HttpException, 3105, + "Daily email limit exceeded. Please try again tomorrow.", + 429, "/register/send_code"); + // 调 smtp 服务(pymsg/TCP :8008)发送验证码 bool sent = false; int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝 diff --git a/libhttp/Makefile b/libhttp/Makefile index 5fc8f46..41a1bb0 100644 --- a/libhttp/Makefile +++ b/libhttp/Makefile @@ -1,49 +1,51 @@ VERSION := 0.0.1 FRAMEWORK_NAME := us LIB_NAME := libus_http +LIBC_NAME := libus_httpclient INSTL_TDIR := /lib INCLU_TDIR := /usr/include INCLU_SDIR := inc CPP := g++ -INCS := -I inc -CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror -LIBS := +INCS := -I inc -I/opt/homebrew/include +CPPFLAGS := --std=c++20 -O2 -Wall -Werror +SERVER_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lpthread +CLIENT_LIBS := -L/opt/homebrew/lib -lssl -lcrypto -lcurl -lpthread BUILD_TDIR := build $(shell mkdir -p $(BUILD_TDIR)) #-------- -all: $(BUILD_TDIR)/$(LIB_NAME).so +all: $(BUILD_TDIR)/$(LIB_NAME).so $(BUILD_TDIR)/$(LIBC_NAME).so -$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o #$(BUILD_TDIR)/WebSocketServer.o $(BUILD_TDIR)/WebSocketClient.o - $(CPP) -shared $^ $(LIBS) -o $@ +# ---- Server library (HttpServer, no curl) ---- +$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/HttpServer.o + $(CPP) -shared $^ $(SERVER_LIBS) -o $@ $(BUILD_TDIR)/HttpServer.o: src/HttpServer.cpp $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ -$(BUILD_TDIR)/WebSocketServer.o: src/WebSocketServer.cpp - $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ +# ---- Client library (HttpClient, depends on curl + beast websocket) ---- +$(BUILD_TDIR)/$(LIBC_NAME).so: $(BUILD_TDIR)/HttpClient.o + $(CPP) -shared $^ $(CLIENT_LIBS) -o $@ -$(BUILD_TDIR)/WebSocketClient.o: src/WebSocketClient.cpp +$(BUILD_TDIR)/HttpClient.o: src/HttpClient.cpp $(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@ clear: @rm -rvf $(BUILD_TDIR) -install: - @echo "install UniServe_libth..." +install: all + @echo "install UniServe_libhttp..." @mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME) @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ @cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ - #@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ - #@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/ @cp -vf $(BUILD_TDIR)/$(LIB_NAME).so $(INSTL_TDIR)/ + @cp -vf $(BUILD_TDIR)/$(LIBC_NAME).so $(INSTL_TDIR)/ @echo "done." uninstall: - @echo "uninstall UniServe_libth..." + @echo "uninstall UniServe_libhttp..." @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpServer.hpp @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/HttpClient.hpp - @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketServer.hpp - @rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketClient.hpp @rm -vf $(INSTL_TDIR)/$(LIB_NAME).so + @rm -vf $(INSTL_TDIR)/$(LIBC_NAME).so @echo "done." diff --git a/libhttp/README.md b/libhttp/README.md new file mode 100644 index 0000000..4813da3 --- /dev/null +++ b/libhttp/README.md @@ -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 +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 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 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 +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& 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& 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( + const http::request&, + const std::unordered_map&)>; + +using MiddlewareHandler = std::function&, + http::response&, + const std::unordered_map&)>; + +using WebSocketMessageHandler = std::function sendCallback)>; + +using WebSocketConnectHandler = std::function; + +using WebSocketDisconnectHandler = std::function; + +using WsProxyHandshakeHandler = std::function& 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. diff --git a/libhttp/inc/us/HttpClient.hpp b/libhttp/inc/us/HttpClient.hpp index 0cf2479..6d1df70 100644 --- a/libhttp/inc/us/HttpClient.hpp +++ b/libhttp/inc/us/HttpClient.hpp @@ -2,10 +2,27 @@ #include #include #include +#include +#include #include +#include + +// Boost.Beast for WebSocket client +#include +#include +#include +#include +#include +#include +#include namespace pmc::net { +namespace beast = boost::beast; +namespace asio = boost::asio; +namespace websocket = beast::websocket; +using tcp = asio::ip::tcp; + enum class HttpMethod { GET, POST, @@ -18,183 +35,73 @@ enum class HttpMethod { class HttpClient { public: - HttpClient() { - curl_ = curl_easy_init(); - if (!curl_) { - throw std::runtime_error("Failed to initialize CURL"); - } - } - - ~HttpClient() { - if (curl_) { - curl_easy_cleanup(curl_); - } - if (headers_) { - curl_slist_free_all(headers_); - } - } - - void set_header(const std::string& header) { - headers_ = curl_slist_append(headers_, header.c_str()); - } - - void clear_headers() { - if (headers_) { - curl_slist_free_all(headers_); - headers_ = nullptr; - } - } - - const std::map& get_response_headers() const { - return response_headers_; - } - - long get_response_code() const { - return response_code_; - } - - // ===== 修复:直接转发,不按行处理 ===== + HttpClient(); + ~HttpClient(); + + HttpClient(const HttpClient&) = delete; + HttpClient& operator=(const HttpClient&) = delete; + + void set_header(const std::string& header); + void clear_headers(); + + const std::map& get_response_headers() const; + long get_response_code() const; + + // ===== HTTP Methods (Synchronous, libcurl-based) ===== void request_stream_sync( HttpMethod method, const std::string& url, const std::string& data, - std::function callback - ) { - if (!curl_) { - throw std::runtime_error("CURL not initialized"); - } - - response_headers_.clear(); - response_code_ = 0; - - curl_easy_reset(curl_); - curl_easy_setopt(curl_, CURLOPT_URL, url.c_str()); - set_http_method(method, data); - curl_easy_setopt(curl_, CURLOPT_HTTPHEADER, headers_); - curl_easy_setopt(curl_, CURLOPT_HEADERFUNCTION, header_callback); - curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this); - - // ===== 关键修复:直接收集原始数据,不按行处理 ===== - std::string full_response; - curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback); - curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response); - - curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L); - curl_easy_setopt(curl_, CURLOPT_CONNECTTIMEOUT, 10L); - - CURLcode res = curl_easy_perform(curl_); - - if (res != CURLE_OK) { - throw std::runtime_error(std::string("CURL error: ") + curl_easy_strerror(res)); - } - - curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &response_code_); - - // ===== 完整转发所有数据(包括换行符) ===== - if (!full_response.empty()) { - callback(full_response); - } - } - - void get_stream_sync(const std::string& url, std::function callback) { - request_stream_sync(HttpMethod::GET, url, "", callback); - } - - void post_stream_sync(const std::string& url, const std::string& data, std::function callback) { - request_stream_sync(HttpMethod::POST, url, data, callback); - } - - void put_stream_sync(const std::string& url, const std::string& data, std::function callback) { - request_stream_sync(HttpMethod::PUT, url, data, callback); - } - - void delete_stream_sync(const std::string& url, std::function callback) { - request_stream_sync(HttpMethod::DELETE, url, "", callback); - } + std::function callback); + + void get_stream_sync(const std::string& url, std::function callback); + void post_stream_sync(const std::string& url, const std::string& data, std::function callback); + void put_stream_sync(const std::string& url, const std::string& data, std::function callback); + void delete_stream_sync(const std::string& url, std::function callback); + + // ===== WebSocket Methods (Synchronous, Boost.Beast-based) ===== + bool ws_connect(const std::string& url, int timeout_ms = 5000); + bool ws_connect(const std::string& host, const std::string& port, + const std::string& path = "/", bool ssl = false, int timeout_ms = 5000); + bool ws_send(const std::string& message); + bool ws_send_binary(const void* data, size_t size); + std::string ws_recv(int timeout_ms = 0); + bool ws_is_connected() const; + void ws_close(); + std::string ws_get_host() const; + std::string ws_get_port() const; + std::string ws_get_path() const; private: CURL* curl_ = nullptr; struct curl_slist* headers_ = nullptr; std::map response_headers_; long response_code_ = 0; - - void set_http_method(HttpMethod method, const std::string& data) { - switch (method) { - case HttpMethod::GET: - curl_easy_setopt(curl_, CURLOPT_HTTPGET, 1L); - break; - - case HttpMethod::POST: - curl_easy_setopt(curl_, CURLOPT_POST, 1L); - if (!data.empty()) { - curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str()); - curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); - } - break; - - case HttpMethod::PUT: - curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PUT"); - if (!data.empty()) { - curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str()); - curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); - } - break; - - case HttpMethod::DELETE: - curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "DELETE"); - break; - - case HttpMethod::PATCH: - curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "PATCH"); - if (!data.empty()) { - curl_easy_setopt(curl_, CURLOPT_POSTFIELDS, data.c_str()); - curl_easy_setopt(curl_, CURLOPT_POSTFIELDSIZE, data.size()); - } - break; - - case HttpMethod::HEAD: - curl_easy_setopt(curl_, CURLOPT_NOBODY, 1L); - break; - - case HttpMethod::OPTIONS: - curl_easy_setopt(curl_, CURLOPT_CUSTOMREQUEST, "OPTIONS"); - break; - } - } - - static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata) { - size_t total_size = size * nitems; - HttpClient* client = static_cast(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(userdata); - - // 直接追加原始数据,保留所有换行符和特殊字符 - response->append(ptr, total_size); - - return total_size; - } + + struct WsState { + asio::io_context ioc; + std::unique_ptr tcp_stream; + std::unique_ptr ssl_ctx; + std::unique_ptr> ssl_stream; + std::unique_ptr> ws_stream; + std::unique_ptr&>> wss_stream; + bool ssl_enabled = false; + bool connected = false; + std::string host; + std::string port; + std::string path; + }; + std::unique_ptr ws_state_; + + void set_http_method(HttpMethod method, const std::string& data); + static size_t header_callback(char* buffer, size_t size, size_t nitems, void* userdata); + static size_t write_callback(char* ptr, size_t size, size_t nmemb, void* userdata); + static void parse_ws_url(const std::string& url_part, std::string& host, std::string& port, + std::string& path, const char* default_port); }; +// Backward compatibility aliases +using WebSocketClientBase = HttpClient; +using WebSocketClient = HttpClient; + } // namespace pmc::net diff --git a/libhttp/inc/us/HttpServer.hpp b/libhttp/inc/us/HttpServer.hpp index f157f38..f2c07e4 100644 --- a/libhttp/inc/us/HttpServer.hpp +++ b/libhttp/inc/us/HttpServer.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -19,12 +20,14 @@ #include #include #include +#include namespace pmc { namespace net { namespace beast = boost::beast; namespace http = beast::http; +namespace websocket = beast::websocket; namespace asio = boost::asio; using tcp = boost::asio::ip::tcp; @@ -37,6 +40,24 @@ using MiddlewareHandler = std::function&, const std::unordered_map&)>; +// WebSocket 回调类型 +using WebSocketMessageHandler = std::function sendCallback)>; + +using WebSocketConnectHandler = std::function; + +using WebSocketDisconnectHandler = std::function; + +// WebSocket 代理:握手回调接收 HTTP upgrade 请求,返回目标 ws:// 或 wss:// URL +// 返回空字符串表示拒绝(返回 403) +using WsProxyHandshakeHandler = std::function& upgradeReq)>; + struct RouteEntry { std::string path; std::regex pattern; @@ -44,6 +65,16 @@ struct RouteEntry { bool is_wildcard; }; +struct WsRouteEntry { + std::string path; + WebSocketMessageHandler handler; +}; + +struct WsProxyRouteEntry { + std::string path; + WsProxyHandshakeHandler handshake; +}; + class HttpServer { public: explicit HttpServer(const std::string& address, unsigned short port, @@ -60,10 +91,20 @@ public: void post(const std::string& path, HttpRequestHandler handler); void put(const std::string& path, HttpRequestHandler handler); void del(const std::string& path, HttpRequestHandler handler); - void options(const std::string& path, HttpRequestHandler handler); // 新增 OPTIONS + void options(const std::string& path, HttpRequestHandler handler); void use(MiddlewareHandler middleware); void setStaticDirectory(const std::string& path); + // WebSocket 接口 + void ws(const std::string& path, WebSocketMessageHandler handler); + void ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake); + void onWsConnect(WebSocketConnectHandler handler); + void onWsDisconnect(WebSocketDisconnectHandler handler); + bool sendToWsClient(const std::string& clientId, const std::string& message); + size_t broadcastWs(const std::string& message); + void disconnectWsClient(const std::string& clientId); + size_t getWsClientCount() const; + bool isRunning() const; unsigned short getPort() const; std::string getAddress() const; @@ -103,10 +144,19 @@ private: std::vector post_handlers_; std::vector put_handlers_; std::vector delete_handlers_; - std::vector options_handlers_; // 新增 OPTIONS 路由 + std::vector options_handlers_; std::vector middlewares_; std::string static_directory_; + // WebSocket 路由和处理 + std::vector ws_handlers_; + std::vector ws_proxy_handlers_; + WebSocketConnectHandler ws_connect_handler_; + WebSocketDisconnectHandler ws_disconnect_handler_; + std::unordered_map> ws_clients_; + mutable std::mutex ws_clients_mutex_; + std::atomic ws_client_counter_{0}; + std::vector> sessions_; std::mutex sessions_mutex_; @@ -115,12 +165,22 @@ private: bool matchPath(const std::string& path, const std::regex& pattern); http::response handleRequest(const http::request& req); HttpRequestHandler findRoute(const std::string& method, const std::string& path); - bool routeExists(const std::string& method, const std::string& path); // 新增辅助方法 + bool routeExists(const std::string& method, const std::string& path); + WebSocketMessageHandler findWsRoute(const std::string& path); + WsProxyHandshakeHandler findWsProxyRoute(const std::string& path); + + // WebSocket 客户端管理 + std::string generateWsClientId(); + void addWsClient(const std::string& clientId, std::shared_ptr session); + void removeWsClient(const std::string& clientId); + std::shared_ptr getWsClient(const std::string& clientId); void addSession(const std::shared_ptr& session); void removeSession(const Session* session); void closeAllSessions(); void initSSLContext(); + + friend class Session; }; } // namespace net diff --git a/libhttp/src/HttpClient.cpp b/libhttp/src/HttpClient.cpp new file mode 100644 index 0000000..3b31389 --- /dev/null +++ b/libhttp/src/HttpClient.cpp @@ -0,0 +1,357 @@ +#include +#include +#include + +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& 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 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 callback) { + request_stream_sync(HttpMethod::GET, url, "", callback); +} + +void HttpClient::post_stream_sync(const std::string& url, const std::string& data, std::function callback) { + request_stream_sync(HttpMethod::POST, url, data, callback); +} + +void HttpClient::put_stream_sync(const std::string& url, const std::string& data, std::function callback) { + request_stream_sync(HttpMethod::PUT, url, data, callback); +} + +void HttpClient::delete_stream_sync(const std::string& url, std::function 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(); + 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::tlsv12_client); + ws_state_->ssl_ctx->set_verify_mode(asio::ssl::verify_none); + + ws_state_->tcp_stream = std::make_unique(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>( + 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&>>( + *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(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>( + 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(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(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 diff --git a/libhttp/src/HttpServer.cpp b/libhttp/src/HttpServer.cpp index 588cad8..aaa9580 100644 --- a/libhttp/src/HttpServer.cpp +++ b/libhttp/src/HttpServer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace pmc { namespace net { @@ -29,25 +30,34 @@ public: // ===================== Session ===================== class HttpServer::Session : public std::enable_shared_from_this { + using tcp_stream = beast::tcp_stream; + using ssl_stream = asio::ssl::stream; + using ws_stream = websocket::stream; + using wss_stream = websocket::stream; + + // Proxy outbound types + using proxy_ws_stream = websocket::stream; + using proxy_wss_stream = websocket::stream&>; + public: - // HTTP 构造 Session(tcp::socket&& socket, HttpServer* server) - : socket_(std::move(socket)), server_(server), ssl_enabled_(false), - strand_(asio::make_strand(socket_.get_executor())) {} + : stream_(std::move(socket)), server_(server), ssl_enabled_(false), + is_websocket_(false), is_ws_proxy_(false), client_id_("") {} - // HTTPS 构造 Session(tcp::socket&& socket, asio::ssl::context& ssl_ctx, HttpServer* server) - : socket_(std::move(socket)), server_(server), ssl_enabled_(true), - strand_(asio::make_strand(socket_.get_executor())) { - ssl_stream_ = std::make_unique>( - std::move(socket_), ssl_ctx); + : stream_(std::move(socket)), server_(server), ssl_enabled_(true), + is_websocket_(false), is_ws_proxy_(false), client_id_("") { + ssl_stream_ = std::make_unique(stream_, ssl_ctx); } ~Session() { close(); } void run() { - if (ssl_enabled_) doSSLHandshake(); - else doRead(); + if (ssl_enabled_) { + doSSLHandshake(); + } else { + doRead(); + } } void setRemoteInfo(const std::string& ip, unsigned short port) { @@ -55,16 +65,36 @@ public: remote_port_ = port; } + void wsSend(const std::string& message) { + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), message]() { + self->ws_send_queue_.push_back(message); + if (self->ws_send_queue_.size() == 1) { + self->doWsWrite(); + } + }); + } + + std::string getClientId() const { return client_id_; } + std::string getRemoteAddress() const { return remote_ip_ + ":" + std::to_string(remote_port_); } + bool isWebSocket() const { return is_websocket_; } + private: void close() { beast::error_code ec; - if (ssl_enabled_ && ssl_stream_) { - ssl_stream_->shutdown(ec); - ssl_stream_->lowest_layer().close(ec); - } else if (socket_.is_open()) { - socket_.shutdown(tcp::socket::shutdown_send, ec); - socket_.close(ec); + closeProxy(websocket::close_code::going_away); + if (is_websocket_) { + if (ssl_enabled_ && wss_stream_) { + wss_stream_->close(websocket::close_code::normal, ec); + } else if (ws_stream_) { + ws_stream_->close(websocket::close_code::normal, ec); + } } + if (ssl_stream_) { + ssl_stream_->shutdown(ec); + } + stream_.socket().shutdown(tcp::socket::shutdown_send, ec); + stream_.close(); } void doSSLHandshake() { @@ -74,39 +104,87 @@ private: ssl_stream_->async_handshake( asio::ssl::stream_base::server, - asio::bind_executor(strand_, - [this, self](beast::error_code ec) { - if (!ec) { - std::cout << "[SSL] Handshake succeeded: " - << remote_ip_ << ":" << remote_port_ << std::endl; - doRead(); - } else { - std::cerr << "[SSL] Handshake error: " - << remote_ip_ << ":" << remote_port_ - << " - " << ec.message() - << " (code=" << ec.value() << ")" << std::endl; - ERR_print_errors_fp(stderr); - } - })); + [self](beast::error_code ec) { + if (ec) { + std::cerr << "[SSL] Handshake error: " + << self->remote_ip_ << ":" << self->remote_port_ + << " - " << ec.message() + << " (code=" << ec.value() << ")" << std::endl; + ERR_print_errors_fp(stderr); + return; + } + std::cout << "[SSL] Handshake succeeded: " + << self->remote_ip_ << ":" << self->remote_port_ << std::endl; + self->doRead(); + }); } void doRead() { auto self = shared_from_this(); + request_ = {}; + buffer_.consume(buffer_.size()); + if (ssl_enabled_ && ssl_stream_) { http::async_read(*ssl_stream_, buffer_, request_, - asio::bind_executor(strand_, - [this, self](beast::error_code ec, std::size_t) { handleRead(ec); })); + [self](beast::error_code ec, std::size_t) { + self->handleRead(ec); + }); } else { - http::async_read(socket_, buffer_, request_, - asio::bind_executor(strand_, - [this, self](beast::error_code ec, std::size_t) { handleRead(ec); })); + http::async_read(stream_, buffer_, request_, + [self](beast::error_code ec, std::size_t) { + self->handleRead(ec); + }); } } void handleRead(beast::error_code ec) { if (!ec) { + if (isWebSocketUpgrade(request_)) { + std::string path = std::string(request_.target()); + auto qpos = path.find('?'); + if (qpos != std::string::npos) path = path.substr(0, qpos); + + // Check proxy routes first (higher priority) + auto proxy_handshake = server_->findWsProxyRoute(path); + if (proxy_handshake) { + std::string target = proxy_handshake(request_); + if (target.empty()) { + // Rejected by handshake callback → 403 + auto resp = std::make_shared>(); + resp->version(request_.version()); + resp->result(http::status::forbidden); + resp->set(http::field::content_type, "text/plain"); + resp->set(http::field::connection, "close"); + resp->body() = "403 Forbidden\n"; + resp->prepare_payload(); + doWrite(resp); + return; + } + ws_path_ = path; + is_ws_proxy_ = true; + ws_proxy_target_ = target; + if (ssl_enabled_) { + doWssUpgrade(); + } else { + doWsUpgrade(); + } + return; + } + + auto ws_handler = server_->findWsRoute(path); + if (ws_handler) { + ws_handler_ = ws_handler; + ws_path_ = path; + if (ssl_enabled_) { + doWssUpgrade(); + } else { + doWsUpgrade(); + } + return; + } + } + auto req_copy = std::move(request_); - request_ = {}; processRequest(std::move(req_copy)); } else if (ec != http::error::end_of_stream) { std::cerr << "[Read] Error from " << remote_ip_ << ":" << remote_port_ @@ -114,6 +192,516 @@ private: } } + bool isWebSocketUpgrade(const http::request& req) { + if (req.method() != http::verb::get) return false; + + auto upgrade = req.find(http::field::upgrade); + if (upgrade == req.end()) return false; + + std::string upgrade_val(upgrade->value()); + boost::to_lower(upgrade_val); + boost::trim(upgrade_val); + if (upgrade_val != "websocket") return false; + + auto connection = req.find(http::field::connection); + if (connection == req.end()) return false; + + std::string conn_val(connection->value()); + boost::to_lower(conn_val); + return conn_val.find("upgrade") != std::string::npos; + } + + void doWsUpgrade() { + auto self = shared_from_this(); + ws_stream_ = std::make_unique(stream_); + ws_stream_->text(true); + + ws_stream_->async_accept( + request_, + [self](beast::error_code ec) { + self->onWsAccept(ec); + }); + } + + void doWssUpgrade() { + auto self = shared_from_this(); + wss_stream_ = std::make_unique(*ssl_stream_); + wss_stream_->text(true); + + wss_stream_->async_accept( + request_, + [self](beast::error_code ec) { + self->onWsAccept(ec); + }); + } + + void onWsAccept(beast::error_code ec) { + if (ec) { + std::cerr << "[WS] Accept error from " << remote_ip_ << ":" << remote_port_ + << ": " << ec.message() << std::endl; + return; + } + + is_websocket_ = true; + client_id_ = server_->generateWsClientId(); + server_->addWsClient(client_id_, shared_from_this()); + + std::cout << "[WS] Client " << client_id_ << " connected from " + << remote_ip_ << ":" << remote_port_ << " at " << ws_path_ << std::endl; + + if (is_ws_proxy_) { + std::cout << "[WS-Proxy] Proxying " << ws_path_ << " -> " << ws_proxy_target_ << std::endl; + doProxyConnect(ws_proxy_target_); + return; + } + + if (server_->ws_connect_handler_) { + server_->ws_connect_handler_(client_id_, getRemoteAddress()); + } + + doWsRead(); + } + + // ======== Regular WebSocket message handling ======== + + void doWsRead() { + auto self = shared_from_this(); + ws_buffer_.consume(ws_buffer_.size()); + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->async_read( + ws_buffer_, + [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onWsRead(ec, bytes_transferred); + }); + } else if (ws_stream_) { + ws_stream_->async_read( + ws_buffer_, + [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onWsRead(ec, bytes_transferred); + }); + } + } + + void onWsRead(beast::error_code ec, std::size_t bytes_transferred) { + if (ec == websocket::error::closed) { + onWsClose(); + return; + } + if (ec) { + std::cerr << "[WS] Read error from " << client_id_ << ": " << ec.message() << std::endl; + onWsClose(); + return; + } + + std::string message = beast::buffers_to_string(ws_buffer_.data()); + ws_buffer_.consume(bytes_transferred); + + if (ws_handler_) { + auto self = shared_from_this(); + ws_handler_(client_id_, message, + [self](const std::string& response) { + self->wsSend(response); + }); + } + + doWsRead(); + } + + void doWsWrite() { + if (ws_send_queue_.empty()) return; + + auto self = shared_from_this(); + std::string& message = ws_send_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS] Write error to " << self->client_id_ << ": " << ec.message() << std::endl; + self->onWsClose(); + return; + } + self->ws_send_queue_.pop_front(); + if (!self->ws_send_queue_.empty()) { + self->doWsWrite(); + } + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->text(true); + wss_stream_->async_write(asio::buffer(message), write_handler); + } else if (ws_stream_) { + ws_stream_->text(true); + ws_stream_->async_write(asio::buffer(message), write_handler); + } + } + + void onWsClose() { + if (client_id_.empty()) return; + std::cout << "[WS] Client " << client_id_ << " disconnected" << std::endl; + + closeProxy(websocket::close_code::normal); + server_->removeWsClient(client_id_); + + if (server_->ws_disconnect_handler_) { + server_->ws_disconnect_handler_(client_id_); + } + client_id_.clear(); + is_websocket_ = false; + is_ws_proxy_ = false; + } + + // ======== WebSocket Proxy (bidirectional bridge) ======== + + static void parseProxyUrl(const std::string& url, bool& ssl, std::string& host, + std::string& port, std::string& path) { + std::string rest; + if (url.size() >= 6 && url.substr(0, 6) == "wss://") { + ssl = true; + rest = url.substr(6); + } else if (url.size() >= 5 && url.substr(0, 5) == "ws://") { + ssl = false; + rest = url.substr(5); + } else { + ssl = false; + rest = url; + } + + size_t path_pos = rest.find('/'); + std::string host_port; + if (path_pos != std::string::npos) { + host_port = rest.substr(0, path_pos); + path = rest.substr(path_pos); + } else { + host_port = rest; + 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 = ssl ? "443" : "80"; + } + } + + void doProxyConnect(const std::string& target_url) { + auto self = shared_from_this(); + bool ssl; + std::string host, port, path; + parseProxyUrl(target_url, ssl, host, port, path); + proxy_ssl_ = ssl; + proxy_host_ = host; + proxy_port_ = port; + proxy_path_ = path; + + auto exec = stream_.get_executor(); + proxy_resolver_ = std::make_unique(asio::make_strand(exec)); + + proxy_resolver_->async_resolve(host, port, + asio::bind_executor(exec, + [self](beast::error_code ec, tcp::resolver::results_type results) { + self->onProxyResolve(ec, results); + })); + } + + void onProxyResolve(beast::error_code ec, tcp::resolver::results_type results) { + if (ec) { + std::cerr << "[WS-Proxy] Resolve error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + auto exec = stream_.get_executor(); + + proxy_socket_ = std::make_unique(exec); + proxy_tcp_stream_ = std::make_unique(std::move(*proxy_socket_)); + proxy_socket_.reset(); + + proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); + asio::async_connect(proxy_tcp_stream_->socket(), results, + asio::bind_executor(exec, + [self](beast::error_code ec, tcp::endpoint ep) { + self->onProxyConnect(ec, ep); + })); + } + + void onProxyConnect(beast::error_code ec, tcp::endpoint /*ep*/) { + if (ec) { + std::cerr << "[WS-Proxy] Connect error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + if (proxy_ssl_) { + proxy_ssl_ctx_ = std::make_unique(asio::ssl::context::tlsv12_client); + proxy_ssl_ctx_->set_verify_mode(asio::ssl::verify_none); + proxy_ssl_stream_ = std::make_unique>( + proxy_tcp_stream_->socket(), *proxy_ssl_ctx_); + + if (!SSL_set_tlsext_host_name(proxy_ssl_stream_->native_handle(), proxy_host_.c_str())) { + std::cerr << "[WS-Proxy] Failed to set SNI" << std::endl; + close(); + return; + } + + proxy_tcp_stream_->expires_after(std::chrono::seconds(10)); + auto exec = stream_.get_executor(); + proxy_ssl_stream_->async_handshake(asio::ssl::stream_base::client, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxySslHandshake(ec); + })); + } else { + proxy_ws_stream_ = std::make_unique(proxy_tcp_stream_->socket()); + proxy_tcp_stream_->expires_never(); + + proxy_ws_stream_->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(http::field::user_agent, "UniServe-WsProxy"); + })); + + auto exec = self->stream_.get_executor(); + proxy_ws_stream_->async_handshake(proxy_host_, proxy_path_, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxyHandshake(ec); + })); + } + } + + void onProxySslHandshake(beast::error_code ec) { + if (ec) { + std::cerr << "[WS-Proxy] SSL handshake error: " << ec.message() << std::endl; + close(); + return; + } + + auto self = shared_from_this(); + proxy_wss_stream_ = std::make_unique(*proxy_ssl_stream_); + proxy_tcp_stream_->expires_never(); + + proxy_wss_stream_->set_option(websocket::stream_base::decorator( + [](websocket::request_type& req) { + req.set(http::field::user_agent, "UniServe-WsProxy"); + })); + + auto exec = stream_.get_executor(); + proxy_wss_stream_->async_handshake(proxy_host_, proxy_path_, + asio::bind_executor(exec, + [self](beast::error_code ec) { + self->onProxyHandshake(ec); + })); + } + + void onProxyHandshake(beast::error_code ec) { + if (ec) { + std::cerr << "[WS-Proxy] Backend handshake error: " << ec.message() << std::endl; + close(); + return; + } + + proxy_connected_ = true; + proxy_closed_ = false; + std::cout << "[WS-Proxy] Connected to backend " << proxy_host_ << ":" << proxy_port_ + << proxy_path_ << std::endl; + + if (server_->ws_connect_handler_) { + server_->ws_connect_handler_(client_id_, getRemoteAddress()); + } + + // Start bidirectional bridging + doProxyReadClient(); + doProxyReadBackend(); + } + + // ---- Client → Backend direction ---- + + void doProxyReadClient() { + if (proxy_closed_) return; + auto self = shared_from_this(); + proxy_client_buffer_.consume(proxy_client_buffer_.size()); + + auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onProxyReadClient(ec, bytes_transferred); + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->async_read(proxy_client_buffer_, read_handler); + } else if (ws_stream_) { + ws_stream_->async_read(proxy_client_buffer_, read_handler); + } + } + + void onProxyReadClient(beast::error_code ec, std::size_t bytes_transferred) { + if (ec) { + if (ec != websocket::error::closed) { + std::cerr << "[WS-Proxy] Client read error: " << ec.message() << std::endl; + } + closeProxy(websocket::close_code::normal); + return; + } + + bool is_binary = false; + if (ssl_enabled_ && wss_stream_) { + is_binary = wss_stream_->got_binary(); + } else if (ws_stream_) { + is_binary = ws_stream_->got_binary(); + } + + std::string data = beast::buffers_to_string(proxy_client_buffer_.data()); + proxy_client_buffer_.consume(bytes_transferred); + + // Queue to backend + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { + self->proxy_to_backend_queue_.emplace_back(std::move(data), is_binary); + if (self->proxy_to_backend_queue_.size() == 1) { + self->doProxyWriteBackend(); + } + }); + + doProxyReadClient(); + } + + void doProxyWriteBackend() { + if (proxy_to_backend_queue_.empty() || !proxy_connected_ || proxy_closed_) return; + + auto self = shared_from_this(); + auto& [msg, is_binary] = proxy_to_backend_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS-Proxy] Backend write error: " << ec.message() << std::endl; + self->closeProxy(websocket::close_code::internal_error); + return; + } + self->proxy_to_backend_queue_.pop_front(); + if (!self->proxy_to_backend_queue_.empty()) { + self->doProxyWriteBackend(); + } + }; + + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->binary(is_binary); + proxy_wss_stream_->async_write(asio::buffer(msg), write_handler); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->binary(is_binary); + proxy_ws_stream_->async_write(asio::buffer(msg), write_handler); + } + } + + // ---- Backend → Client direction ---- + + void doProxyReadBackend() { + if (proxy_closed_) return; + auto self = shared_from_this(); + proxy_backend_buffer_.consume(proxy_backend_buffer_.size()); + + auto read_handler = [self](beast::error_code ec, std::size_t bytes_transferred) { + self->onProxyReadBackend(ec, bytes_transferred); + }; + + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->async_read(proxy_backend_buffer_, read_handler); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->async_read(proxy_backend_buffer_, read_handler); + } + } + + void onProxyReadBackend(beast::error_code ec, std::size_t bytes_transferred) { + if (ec) { + if (ec != websocket::error::closed) { + std::cerr << "[WS-Proxy] Backend read error: " << ec.message() << std::endl; + } + closeProxy(websocket::close_code::normal); + return; + } + + bool is_binary = false; + if (proxy_ssl_ && proxy_wss_stream_) { + is_binary = proxy_wss_stream_->got_binary(); + } else if (proxy_ws_stream_) { + is_binary = proxy_ws_stream_->got_binary(); + } + + std::string data = beast::buffers_to_string(proxy_backend_buffer_.data()); + proxy_backend_buffer_.consume(bytes_transferred); + + // Queue to client + auto exec = stream_.get_executor(); + asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() { + self->proxy_to_client_queue_.emplace_back(std::move(data), is_binary); + if (self->proxy_to_client_queue_.size() == 1) { + self->doProxyWriteClient(); + } + }); + + doProxyReadBackend(); + } + + void doProxyWriteClient() { + if (proxy_to_client_queue_.empty() || !proxy_connected_ || proxy_closed_) return; + + auto self = shared_from_this(); + auto& [msg, is_binary] = proxy_to_client_queue_.front(); + + auto write_handler = [self](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[WS-Proxy] Client write error: " << ec.message() << std::endl; + self->closeProxy(websocket::close_code::internal_error); + return; + } + self->proxy_to_client_queue_.pop_front(); + if (!self->proxy_to_client_queue_.empty()) { + self->doProxyWriteClient(); + } + }; + + if (ssl_enabled_ && wss_stream_) { + wss_stream_->binary(is_binary); + wss_stream_->async_write(asio::buffer(msg), write_handler); + } else if (ws_stream_) { + ws_stream_->binary(is_binary); + ws_stream_->async_write(asio::buffer(msg), write_handler); + } + } + + void closeProxy(websocket::close_code code) { + if (proxy_closed_) return; + proxy_closed_ = true; + proxy_connected_ = false; + + beast::error_code ec; + // Close backend connection + if (proxy_ssl_ && proxy_wss_stream_) { + proxy_wss_stream_->close(code, ec); + } else if (proxy_ws_stream_) { + proxy_ws_stream_->close(code, ec); + } + if (proxy_ssl_stream_) { + proxy_ssl_stream_->shutdown(ec); + } + if (proxy_tcp_stream_) { + proxy_tcp_stream_->socket().shutdown(tcp::socket::shutdown_send, ec); + proxy_tcp_stream_->close(); + } + + proxy_to_backend_queue_.clear(); + proxy_to_client_queue_.clear(); + proxy_ws_stream_.reset(); + proxy_wss_stream_.reset(); + proxy_ssl_stream_.reset(); + proxy_ssl_ctx_.reset(); + proxy_tcp_stream_.reset(); + proxy_resolver_.reset(); + } + + // ======== Regular HTTP ======== + void processRequest(http::request req) { try { auto resp = std::make_shared>( @@ -135,42 +723,71 @@ private: auto self = shared_from_this(); if (ssl_enabled_ && ssl_stream_) { http::async_write(*ssl_stream_, *response, - asio::bind_executor(strand_, - [this, self, response](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[Write] Error to " << remote_ip_ << ":" << remote_port_ - << ": " << ec.message() << std::endl; - return; - } - if (!response->keep_alive()) close(); - else doRead(); - })); + [self, response](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ + << ": " << ec.message() << std::endl; + return; + } + if (!response->keep_alive()) self->close(); + else self->doRead(); + }); } else { - http::async_write(socket_, *response, - asio::bind_executor(strand_, - [this, self, response](beast::error_code ec, std::size_t) { - if (ec) { - std::cerr << "[Write] Error to " << remote_ip_ << ":" << remote_port_ - << ": " << ec.message() << std::endl; - return; - } - if (!response->keep_alive()) close(); - else doRead(); - })); + http::async_write(stream_, *response, + [self, response](beast::error_code ec, std::size_t) { + if (ec) { + std::cerr << "[Write] Error to " << self->remote_ip_ << ":" << self->remote_port_ + << ": " << ec.message() << std::endl; + return; + } + if (!response->keep_alive()) self->close(); + else self->doRead(); + }); } } - tcp::socket socket_; - std::unique_ptr> ssl_stream_; + // ---- Client stream (inbound) ---- + tcp_stream stream_; + std::unique_ptr ssl_stream_; + std::unique_ptr ws_stream_; + std::unique_ptr wss_stream_; + beast::flat_buffer buffer_; + beast::flat_buffer ws_buffer_; http::request request_; + HttpServer* server_; bool ssl_enabled_; - asio::strand strand_; - std::string remote_ip_ = "unknown"; unsigned short remote_port_ = 0; + + bool is_websocket_; + bool is_ws_proxy_; + std::string client_id_; + std::string ws_path_; + WebSocketMessageHandler ws_handler_; + std::deque ws_send_queue_; + + // ---- Proxy state (outbound to backend) ---- + std::string ws_proxy_target_; + bool proxy_ssl_ = false; + bool proxy_connected_ = false; + bool proxy_closed_ = false; + std::string proxy_host_, proxy_port_, proxy_path_; + + std::unique_ptr proxy_resolver_; + std::unique_ptr proxy_socket_; + std::unique_ptr proxy_tcp_stream_; + std::unique_ptr proxy_ssl_ctx_; + std::unique_ptr> proxy_ssl_stream_; + std::unique_ptr proxy_ws_stream_; + std::unique_ptr proxy_wss_stream_; + + beast::flat_buffer proxy_client_buffer_; + beast::flat_buffer proxy_backend_buffer_; + std::deque> proxy_to_backend_queue_; + std::deque> proxy_to_client_queue_; }; // ===================== Listener ===================== @@ -204,8 +821,10 @@ private: } void doAccept() { + auto self = shared_from_this(); acceptor_.async_accept( - [this, self = shared_from_this()](beast::error_code ec, tcp::socket socket) { + asio::make_strand(ioc_), + [self](beast::error_code ec, tcp::socket socket) { if (!ec) { auto ep = socket.remote_endpoint(ec); std::string ip = ec ? "unknown" : ep.address().to_string(); @@ -213,17 +832,18 @@ private: std::cout << "[Listener] New connection from " << ip << ":" << port << std::endl; std::shared_ptr session; - if (ssl_enabled_ && ssl_ctx_) { - session = std::make_shared(std::move(socket), *ssl_ctx_, server_); + if (self->ssl_enabled_ && self->ssl_ctx_) { + session = std::make_shared(std::move(socket), *self->ssl_ctx_, self->server_); } else { - session = std::make_shared(std::move(socket), server_); + session = std::make_shared(std::move(socket), self->server_); } session->setRemoteInfo(ip, port); + self->server_->addSession(session); session->run(); } else { std::cerr << "[Listener] Accept error: " << ec.message() << std::endl; } - doAccept(); + self->doAccept(); }); } @@ -236,7 +856,6 @@ private: // ===================== HttpServer 实现 ===================== -// ---- 构造函数 ---- HttpServer::HttpServer(const std::string& address, unsigned short port, unsigned int threads) : address_(address), port_(port), threads_(threads), ssl_enabled_(false), ioc_(std::make_unique(threads)) { @@ -255,7 +874,6 @@ HttpServer::HttpServer(const std::string& address, unsigned short port, HttpServer::~HttpServer() { stop(); } -// ---- SSL 初始化 ---- void HttpServer::setPrivateKeyPassword(const std::string& password) { private_key_password_ = password; } @@ -294,7 +912,6 @@ void HttpServer::initSSLContext() { } } -// ---- 启动/停止 ---- void HttpServer::start() { if (running_) return; try { @@ -318,7 +935,11 @@ void HttpServer::start() { } std::cout << (ssl_enabled_ ? "HTTPS" : "HTTP") << " Server started on " << address_ << ":" << port_ - << " with " << threads_ << " threads\n"; + << " with " << threads_ << " threads"; + if (!ws_handlers_.empty()) { + std::cout << " (WebSocket enabled, " << ws_handlers_.size() << " WS route(s))"; + } + std::cout << "\n"; } catch (const std::exception& e) { std::cerr << "[Start] Failed: " << e.what() << std::endl; running_ = false; @@ -328,6 +949,12 @@ void HttpServer::start() { void HttpServer::stop() { if (!running_) return; running_ = false; + + { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_.clear(); + } + closeAllSessions(); if (ioc_) ioc_->stop(); for (auto& t : worker_threads_) if (t.joinable()) t.join(); @@ -341,13 +968,12 @@ void HttpServer::run() { if (ioc_) ioc_->run(); } -// ---- 状态查询 ---- bool HttpServer::isRunning() const { return running_; } unsigned short HttpServer::getPort() const { return port_; } std::string HttpServer::getAddress() const { return address_; } bool HttpServer::isSSLEnabled() const { return ssl_enabled_; } -// ---- 路由注册 ---- +// ---- HTTP 路由注册 ---- void HttpServer::get(const std::string& path, HttpRequestHandler handler) { RouteEntry entry; entry.path = path; @@ -388,7 +1014,6 @@ void HttpServer::del(const std::string& path, HttpRequestHandler handler) { delete_handlers_.push_back(entry); } -// ---- 新增 OPTIONS 路由注册 ---- void HttpServer::options(const std::string& path, HttpRequestHandler handler) { RouteEntry entry; entry.path = path; @@ -407,6 +1032,69 @@ void HttpServer::setStaticDirectory(const std::string& p) { static_directory_ = p; } +// ---- WebSocket 路由注册 ---- +void HttpServer::ws(const std::string& path, WebSocketMessageHandler handler) { + WsRouteEntry entry; + entry.path = path; + entry.handler = handler; + ws_handlers_.push_back(entry); +} + +void HttpServer::ws_proxy(const std::string& path, WsProxyHandshakeHandler handshake) { + WsProxyRouteEntry entry; + entry.path = path; + entry.handshake = handshake; + ws_proxy_handlers_.push_back(entry); +} + +void HttpServer::onWsConnect(WebSocketConnectHandler handler) { + ws_connect_handler_ = handler; +} + +void HttpServer::onWsDisconnect(WebSocketDisconnectHandler handler) { + ws_disconnect_handler_ = handler; +} + +bool HttpServer::sendToWsClient(const std::string& clientId, const std::string& message) { + std::shared_ptr session; + { + std::lock_guard lock(ws_clients_mutex_); + auto it = ws_clients_.find(clientId); + if (it != ws_clients_.end()) { + session = it->second; + } + } + if (session) { + session->wsSend(message); + return true; + } + return false; +} + +size_t HttpServer::broadcastWs(const std::string& message) { + std::vector> clients; + { + std::lock_guard lock(ws_clients_mutex_); + for (auto& pair : ws_clients_) { + clients.push_back(pair.second); + } + } + for (auto& session : clients) { + session->wsSend(message); + } + return clients.size(); +} + +void HttpServer::disconnectWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_.erase(clientId); +} + +size_t HttpServer::getWsClientCount() const { + std::lock_guard lock(ws_clients_mutex_); + return ws_clients_.size(); +} + // ---- 通配符转换 ---- std::regex HttpServer::wildcardToRegex(const std::string& pattern) { std::string r; @@ -453,22 +1141,55 @@ HttpRequestHandler HttpServer::findRoute(const std::string& method, const std::s else if (method == "OPTIONS") handlers = &options_handlers_; else return nullptr; - // 精确匹配优先 for (const auto& e : *handlers) { if (!e.is_wildcard && e.path == path) return e.handler; } - // 通配符匹配 for (const auto& e : *handlers) { if (e.is_wildcard && matchPath(path, e.pattern)) return e.handler; } return nullptr; } -// ---- 新增辅助方法:检查路由是否存在 ---- bool HttpServer::routeExists(const std::string& method, const std::string& path) { return findRoute(method, path) != nullptr; } +WebSocketMessageHandler HttpServer::findWsRoute(const std::string& path) { + for (const auto& e : ws_handlers_) { + if (e.path == path) return e.handler; + } + return nullptr; +} + +WsProxyHandshakeHandler HttpServer::findWsProxyRoute(const std::string& path) { + for (const auto& e : ws_proxy_handlers_) { + if (e.path == path) return e.handshake; + } + return nullptr; +} + +// ---- WebSocket 客户端管理 ---- +std::string HttpServer::generateWsClientId() { + return "ws_client_" + std::to_string(++ws_client_counter_); +} + +void HttpServer::addWsClient(const std::string& clientId, std::shared_ptr session) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_[clientId] = session; +} + +void HttpServer::removeWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + ws_clients_.erase(clientId); +} + +std::shared_ptr HttpServer::getWsClient(const std::string& clientId) { + std::lock_guard lock(ws_clients_mutex_); + auto it = ws_clients_.find(clientId); + if (it != ws_clients_.end()) return it->second; + return nullptr; +} + // ---- 请求处理核心 ---- http::response HttpServer::handleRequest(const http::request& req) { std::string target_path(req.target().data(), req.target().size()); @@ -486,7 +1207,6 @@ http::response HttpServer::handleRequest(const http::request< res.set(http::field::content_type, "text/plain"); res.keep_alive(req.keep_alive()); - // 执行中间件 bool middleware_handled = false; for (auto& m : middlewares_) { if (!m(req, res, params)) { @@ -500,10 +1220,8 @@ http::response HttpServer::handleRequest(const http::request< } std::string method = http::to_string(req.method()); - - // ========== 处理 OPTIONS 预检请求 ========== + if (method == "OPTIONS") { - // 检查是否有自定义 OPTIONS 路由 auto handler = findRoute("OPTIONS", target_path); if (handler) { try { @@ -516,34 +1234,33 @@ http::response HttpServer::handleRequest(const http::request< return res; } } - - // 自动生成 OPTIONS 响应 + auto origin = req.find(http::field::origin); if (origin != req.end()) { - // 构建允许的方法列表 std::vector all_methods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"}; std::vector allowed_methods; - - // 检查每个方法是否有对应的路由 + for (const auto& m : all_methods) { if (m == "OPTIONS" || routeExists(m, target_path)) { allowed_methods.push_back(m); } } - - if (!allowed_methods.empty()) { + + if (!allowed_methods.empty() || findWsRoute(target_path)) { + if (allowed_methods.empty()) allowed_methods.push_back("GET"); + std::string allow_methods; for (size_t i = 0; i < allowed_methods.size(); ++i) { if (i > 0) allow_methods += ", "; allow_methods += allowed_methods[i]; } - + res.result(http::status::ok); res.set(http::field::allow, allow_methods); - res.set(http::field::access_control_allow_origin, + res.set(http::field::access_control_allow_origin, std::string(origin->value())); res.set(http::field::access_control_allow_methods, allow_methods); - res.set(http::field::access_control_allow_headers, + res.set(http::field::access_control_allow_headers, "Content-Type, Authorization, X-Requested-With, Accept, Origin"); res.set(http::field::access_control_max_age, "86400"); res.body() = ""; @@ -552,19 +1269,15 @@ http::response HttpServer::handleRequest(const http::request< return res; } } - - // 没有 Origin 头或没有匹配的方法 + res.result(http::status::not_found); res.body() = "404 Not Found\n"; res.prepare_payload(); return res; } - // ========== OPTIONS 处理结束 ========== - // 查找普通路由 auto handler = findRoute(method, target_path); - // 静态文件 if (!handler && !static_directory_.empty()) { std::string file_path = static_directory_ + target_path; std::ifstream file(file_path, std::ios::binary); @@ -592,7 +1305,6 @@ http::response HttpServer::handleRequest(const http::request< } } - // 路由处理 if (handler) { try { return handler(req, params); @@ -605,14 +1317,12 @@ http::response HttpServer::handleRequest(const http::request< } } - // 404 res.result(http::status::not_found); res.body() = "404 Not Found\n"; res.prepare_payload(); return res; } -// ---- 状态显示 ---- void HttpServer::showStatus() const { std::cout << "=== " << (ssl_enabled_ ? "HTTPS" : "HTTP") << " Server Status ===" << std::endl; std::cout << "Protocol: " << (ssl_enabled_ ? "HTTPS (SSL/TLS)" : "HTTP") << std::endl; @@ -638,9 +1348,16 @@ void HttpServer::showStatus() const { std::cout << " DELETE: " << delete_handlers_.size() << " handler(s)" << std::endl; for (const auto& entry : delete_handlers_) std::cout << " DELETE " << entry.path << (entry.is_wildcard ? " (wildcard)" : "") << std::endl; - std::cout << " OPTIONS: " << options_handlers_.size() << " handler(s)" << std::endl; // 新增 + std::cout << " OPTIONS: " << options_handlers_.size() << " handler(s)" << std::endl; for (const auto& entry : options_handlers_) std::cout << " OPTIONS " << entry.path << (entry.is_wildcard ? " (wildcard)" : "") << std::endl; + std::cout << " WebSocket: " << ws_handlers_.size() << " route(s)" << std::endl; + for (const auto& entry : ws_handlers_) + std::cout << " WS " << entry.path << std::endl; + std::cout << " WebSocket-Proxy: " << ws_proxy_handlers_.size() << " route(s)" << std::endl; + for (const auto& entry : ws_proxy_handlers_) + std::cout << " WS-PROXY " << entry.path << std::endl; + std::cout << " WebSocket clients: " << getWsClientCount() << std::endl; std::cout << "Middlewares: " << middlewares_.size() << std::endl; std::cout << "==========================" << std::endl; } @@ -664,10 +1381,6 @@ void HttpServer::removeSession(const Session* session) { void HttpServer::closeAllSessions() { std::lock_guard lock(sessions_mutex_); - for (auto& weak : sessions_) { - auto sp = weak.lock(); - if (sp) { /* 会话析构时自动关闭 socket */ } - } sessions_.clear(); } diff --git a/libhttp/test_server.cpp b/libhttp/test_server.cpp new file mode 100644 index 0000000..db400f6 --- /dev/null +++ b/libhttp/test_server.cpp @@ -0,0 +1,145 @@ +#include "us/HttpServer.hpp" +#include +#include +#include +#include + +using namespace pmc::net; + +static std::unique_ptr 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("0.0.0.0", 9090, 2); + + // 注册普通 HTTP GET 接口 + g_server->get("/hello", [](const auto& req, const auto& params) { + http::response 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 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 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( +WebSocket Test + + +

HTTP+WebSocket Integration Test

+

HTTP: /hello | POST /echo

+ + + +
+)HTML"; + res.prepare_payload(); + return res; + }); + + // 注册 WebSocket 接口 - Echo 服务器 + g_server->ws("/ws", [](const std::string& clientId, const std::string& message, + std::function 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 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; +} diff --git a/pymsg/README.md b/pymsg/README.md index b6cd64a..ad76e58 100644 --- a/pymsg/README.md +++ b/pymsg/README.md @@ -141,6 +141,8 @@ server.start(on_message) # 阻塞当前线程 |------|------| | `addr` | 监听地址 | | `port` | 监听端口 | +| `workers` | 工作线程数(关键字参数,默认 `0`);`>0` 时回调在线程池中并发执行 | +| `recv_timeout` | 接收单条客户端消息的超时秒数(关键字参数,默认 `10.0`);超时未收到完整消息则主动断开连接,防御 Slowloris 式半开连接攻击 | 回调签名为 `on_message(sock, addr, text)`: - `sock` — 客户端 socket,可用 `reply()` 回复 @@ -159,68 +161,106 @@ server.start(on_message) # 阻塞当前线程 服务端内部管理客户端列表,透明处理断连和管道破裂,使用 `select` 实现非阻塞 I/O,轮询间隔 100ms。 +**Slowloris 防护**:每个客户端连接在 `recv_timeout`(默认 10 秒)内必须发送完一条完整消息(4 字节头 + 载荷),超时未完成则服务端主动断开该连接,防止慢速发送攻击阻塞主事件循环。 + +**并发模式(v0.2.0)**: + +```python +server = MsgServer("0.0.0.0", 8005, workers=4) +``` + +默认 `workers=0` 为同步模式:回调在 select 事件循环线程内串行执行,慢回调会阻塞所有连接。`workers>0` 时回调提交到 `ThreadPoolExecutor` 并发执行,I/O 密集回调(如 SQL 查询)可获得真实并行度。保证: + +- **同一连接消息严格串行**:回调完成前该连接暂停 select 监听(恢复粒度 ≤100ms,对短连接 RPC 无影响); +- **发送不交错**:`reply()` / `broadcast()` 经每连接锁串行化; +- **故障隔离**:回调抛异常仅移除该连接并打印 traceback,服务整体存活(同步模式下异常会直接使 `start()` 崩溃,两种模式语义不同,请注意)。 + ## 线程安全 - `MsgServer.stop()` 可从任意线程安全调用:设置原子标志并关闭监听 socket,`select` 循环会干净退出。 -- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内(同一线程)调用是安全的。服务端运行期间从其他线程并发调用不支持。 +- `MsgServer.broadcast()` 和 `reply()` 在 `on_message` 回调内调用是安全的(线程池模式下经每连接发送锁串行化)。`broadcast()` 会遍历客户端列表,仅限回调线程调用,不要从无关的第三方线程并发调用。 - `MsgClient.stop()` 可从另一线程调用以中断 `listen()`。 - `send()` 和 `recv()` 内部不做同步 —— 如果多线程共享同一 socket,调用方需自行串行化。 +## JSON RPC 约定层(v0.3.0) + +项目内所有 pymsg 通信都遵循同一 JSON RPC 约定(与 C++/Go 客户端字节级兼容): + +``` +请求: {"_route": "<路由名>", ...业务字段} +响应: {"success": true, "data": ...} | {"success": false, "error": "..."} +``` + +`RpcServer` / `RpcClient` 把这套约定的编解码、路由分发、错误包装收敛为开箱即用的类(`pymsg/rpc.py`),各服务无需再手写 `_dispatch` / `_reply`。 + +### `RpcServer` — JSON RPC 服务端 + +```python +from pymsg import RpcServer + +rpc = RpcServer("127.0.0.1", 8005, workers=4) + +@rpc.route("execute") +def _execute(req): # handler(req: dict) -> data + return {"rows": []} # 返回值自动包成 {"success": true, "data": ...} + # 抛异常自动包成 {"success": false, "error": str(e)} +rpc.start() # 阻塞当前线程 +``` + +| 构造参数 | 含义 | +|----------|------| +| `addr` / `port` | 监听地址与端口 | +| `workers` | 回调并发线程数(透传 `MsgServer`,默认 0) | +| `json_default` | 响应 JSON 序列化兜底函数(如日期转 ISO 8601) | +| `on_error` | 可选钩子 `on_error(route, exc)`,handler 异常时调用(记日志用) | + +### `RpcClient` — JSON RPC 客户端 + +```python +from pymsg import RpcClient, RpcError + +rpc = RpcClient("127.0.0.1", 8005, "dnfs", timeout=10.0) +try: + data = rpc.call("execute", {"action": "select", "table": "Users"}) +except RpcError as e: + ... # 服务端业务错误(success=false) +``` + +`call(route, payload=None, *, timeout=None)`:自动注入 `_route`、JSON 编解码、检查 `success`,返回 `data` 字段;业务错误抛 `RpcError`,网络故障仍抛 `ConnectionRefusedError` / `TimeoutError`(来自 `MsgClient`)。 + ## 实际用例 ### 服务端 — 数据库服务(dbv2) -db 服务通过 pymsg 暴露通用 CRUD 接口。每条请求是带 `_route` 字段的 JSON 对象,服务端按路由分发到对应处理函数,通过同一 socket 回复结果。 +db 服务基于 `RpcServer` 暴露通用 CRUD 接口(路由 `execute`): ```python -import json -from pymsg import MsgServer +from pymsg import RpcServer class DBServer: - def __init__(self): - self._server = MsgServer("127.0.0.1", 8005) - self._routes = {"execute": self._handle_execute} + def __init__(self, addr="127.0.0.1", port=8005, workers=4): + self._server = RpcServer(addr, port, workers=workers, + json_default=_json_default) + self._server.add_route("execute", self._handle_execute) def start(self): - self._server.start(self._dispatch) - - def _dispatch(self, sock, addr, text): - req = json.loads(text) - route = req.pop("_route", "") - handler = self._routes.get(route) - if handler is None: - self._server.reply(sock, - json.dumps({"success": False, "error": "未知路由"})) - return - try: - result = handler(req) - self._server.reply(sock, - json.dumps({"success": True, "data": result})) - except Exception as e: - self._server.reply(sock, - json.dumps({"success": False, "error": str(e)})) + self._server.start() def _handle_execute(self, data): - # ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表 + # ... 通过 SQLAlchemy 执行 SQL,返回 dict 列表;异常自动包装为错误响应 pass ``` ### 客户端 — RPC 调用(dnfs → db) -dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 pymsg 调用 db。 +dnfs 笔记服务自身没有数据库,所有 CRUD 操作均通过 `RpcClient` 调用 db: ```python -import json -from pymsg import MsgClient +from pymsg import RpcClient -def _call_dbv2(payload): - client = MsgClient("127.0.0.1", 8005, "dnfs") - resp = client.call(json.dumps(payload), timeout=10.0) - return json.loads(resp) +_rpc = RpcClient("127.0.0.1", 8005, "dnfs") -# 创建笔记 -result = _call_dbv2({ - "_route": "execute", +data = _rpc.call("execute", { "action": "insert", "table": "Notes", "values": {"title": "会议记录", "content": "..."} @@ -229,24 +269,19 @@ result = _call_dbv2({ ### 客户端 — 异步日志推送(log_client) -各服务通过 pymsg 将日志异步推送到 db。使用 `MsgClient.call()` 逐批插入。 +各服务通过统一 `log_client.py` 将日志异步推送到 db,底层为 `RpcClient`: ```python -import json -from pymsg import MsgClient +from pymsg import RpcClient -def _send(batch): - client = MsgClient("127.0.0.1", 8005, "gateway-log") - for entry in batch: - client.call(json.dumps({ - "_route": "execute", - "action": "insert", - "table": "Logs", - "values": { - "ts": now, "service": entry["service"], - "level": entry["level"], "message": entry["message"], - } - }), timeout=5) +client = RpcClient("127.0.0.1", 8005, "log-client") +for entry in batch: + client.call("execute", { + "action": "insert", + "table": "Logs", + "values": {"ts": now, "service": entry["service"], + "level": entry["level"], "message": entry["message"]}, + }, timeout=5) ``` ## 跨语言互操作 @@ -276,6 +311,7 @@ pymsg 的线格式有意设计得极其简单,使其他语言的服务也能 | `ConnectionError`(内置) | 完整消息接收前 socket 断开 | | `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 | | `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 | +| `pymsg.RpcError` | `RpcClient.call()` 收到 `success=false` 业务错误或非法 RPC 响应 | `MsgServer` 内部会吞掉客户端断连错误(移除该客户端,继续服务)。`broadcast()` 静默跳过已断开的 socket。 diff --git a/pymsg/README.robot.md b/pymsg/README.robot.md index d94d743..0c64f94 100644 --- a/pymsg/README.robot.md +++ b/pymsg/README.robot.md @@ -1,7 +1,7 @@ # pymsg 模块技术手册 > 文档版本:2026-07-30 -> 代码版本:0.1.0 +> 代码版本:0.3.0 > 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成) > 定位:跨语言 TCP 消息帧协议库 @@ -144,7 +144,11 @@ log_client.push(entry) ``` pymsg/ ├── pymsg/ -│ └── __init__.py # 核心实现(~370 行) +│ ├── __init__.py # 核心协议与 MsgClient/MsgServer(~470 行) +│ └── rpc.py # JSON RPC 约定层 RpcClient/RpcServer(~170 行,v0.3.0) +├── tests/ +│ ├── test_threadpool.py # 线程池/同步模式单元测试(纯 stdlib) +│ └── test_rpc.py # RPC 约定层单元测试(纯 stdlib) ├── README.md # 用户文档 ├── pyproject.toml # 项目配置 └── .gitignore @@ -165,23 +169,40 @@ pymsg/ | 函数/类 | 行号 | 功能 | |---------|------|------| -| `send(sock, data, *, timeout=None)` | L48 | 底层发送 | -| `recv(sock, *, timeout=None)` | L72 | 底层接收 | -| `_recv_exact(sock, n)` | L103 | 精确读 n 字节 | -| `_timeout(sock, seconds)` | L34 | 超时 context manager | -| `TimeoutError` | L30 | 超时异常 | -| `MsgClient` | L120 | TCP 客户端 | -| `MsgClient.__init__` | L136 | 构造器 | -| `MsgClient.connect()` | L144 | 建立长连接 | -| `MsgClient.send(msg)` | L150 | 短连接发送(带 name 前缀) | -| `MsgClient.call(msg, *, timeout=None)` | L163 | 短连接 RPC | -| `MsgClient.listen(on_message)` | L193 | 长连接监听 | -| `MsgClient.stop()` | L220 | 停止监听 | -| `MsgServer` | L234 | TCP 服务端 | -| `MsgServer.start(on_message)` | L259 | 启动服务 | -| `MsgServer.broadcast(msg, except_sock)` | L296 | 广播 | -| `MsgServer.reply(sock, msg)` | L321 | 回复 | -| `MsgServer.stop()` | L329 | 停止服务 | +| `send(sock, data, *, timeout=None)` | L51 | 底层发送 | +| `recv(sock, *, timeout=None)` | L75 | 底层接收 | +| `_recv_exact(sock, n)` | L106 | 精确读 n 字节 | +| `_timeout(sock, seconds)` | L38 | 超时 context manager | +| `TimeoutError` | L33 | 超时异常 | +| `MsgClient` | L123 | TCP 客户端 | +| `MsgClient.__init__` | L139 | 构造器 | +| `MsgClient.connect()` | L147 | 建立长连接 | +| `MsgClient.send(msg)` | L153 | 短连接发送(带 name 前缀) | +| `MsgClient.call(msg, *, timeout=None)` | L166 | 短连接 RPC | +| `MsgClient.listen(on_message)` | L196 | 长连接监听 | +| `MsgClient.stop()` | L223 | 停止监听 | +| `MsgServer` | L237 | TCP 服务端 | +| `MsgServer.__init__(addr, port, *, workers=0)` | L262 | 构造器(workers>0 启用线程池) | +| `MsgServer.start(on_message)` | L276 | 启动服务 | +| `MsgServer.broadcast(msg, except_sock)` | L329 | 广播(每连接锁串行化) | +| `MsgServer.reply(sock, msg)` | L355 | 回复(每连接锁串行化) | +| `MsgServer._locked_send(sock, msg)` | L363 | 带每连接锁的发送 | +| `MsgServer.stop()` | L373 | 停止服务 | +| `MsgServer._run_task(...)` | L413 | 工作线程:执行回调、失败打标记 | +| `MsgServer._reap_failed(socketlist)` | L434 | 主循环统一移除失败连接 | + +#### RPC 约定层(rpc.py,v0.3.0) + +| 函数/类 | 行号 | 功能 | +|---------|------|------| +| `RpcError` | L28 | 业务错误异常(success=false / 非法响应) | +| `RpcClient` | L32 | JSON RPC 客户端 | +| `RpcClient.call(route, payload, *, timeout)` | L46 | 注入 _route、编解码、检查 success,返回 data | +| `RpcServer` | L80 | JSON RPC 服务端(基于 MsgServer) | +| `RpcServer.route(name)` / `add_route` | L119/L126 | 注册路由 handler(`fn(req) -> data`) | +| `RpcServer.start()` / `stop()` | L131/L135 | 启动(阻塞)/ 停止 | +| `RpcServer._dispatch` | L140 | 路由分发与异常包装 | +| `RpcServer._reply` | L164 | 响应包装(支持 json_default) | #### C++ 实现(Msg.hpp) @@ -319,12 +340,38 @@ client.listen(on_message) # 阻塞,直到 stop() #### 构造 ```python -server = MsgServer(addr: str, port: int) +server = MsgServer(addr: str, port: int, *, workers: int = 0) ``` **参数**: - `addr`:监听地址(`0.0.0.0` 表示所有网卡) - `port`:监听端口 +- `workers`:工作线程数(v0.2.0+)。默认 `0` 为同步模式(回调在 select 循环线程内串行执行);`>0` 时回调提交到 `ThreadPoolExecutor` 并发执行 + +#### 并发模型(v0.2.0) + +``` +客户端 A ──┐ +客户端 B ──┼──► select 主循环(单线程) +客户端 C ──┘ │ + ├── accept / recv(主线程,快速 I/O) + │ + ├── workers=0:on_message 就地同步执行(串行) + │ + └── workers>0:标记 busy → submit 到 ThreadPoolExecutor + │ + ▼ + worker 线程执行 on_message + ├── reply() 经该连接发送锁串行化 + ├── 异常 → 标记 _failed(主循环下轮统一移除) + └── 完成 → 清除 busy,连接恢复 select 监听 +``` + +保证: +- **同连接串行**:busy 标记使回调完成前该连接暂停 select 监听,同一连接的消息不会并发执行(恢复粒度 ≤100ms,短连接 RPC 无感) +- **发送原子性**:`reply()`/`broadcast()` 经每连接 `Lock` 串行化,多 worker 向同一 socket 写入不会帧交错 +- **故障隔离**:回调抛非连接异常时打印 traceback 并移除该连接,服务整体存活(同步模式下异常会使 `start()` 崩溃,两模式语义不同) +- **fd 安全**:worker 线程不直接 close socket,连接移除统一由主循环执行,避免与 accept/select 产生 fd 复用竞态 #### 方法 @@ -530,11 +577,11 @@ func pymsgCall(payload []byte) ([]byte, error) { | 场景 | 安全性 | 说明 | |------|--------|------| | `MsgServer.stop()` 跨线程调用 | ✅ 安全 | 设置原子标志并关闭监听 socket | -| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 同一线程内调用 | -| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 同一线程内调用 | +| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 经每连接发送锁串行化,worker 线程内同样安全 | +| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 发送经每连接锁;但会遍历客户端列表,仅限回调线程,勿从无关第三方线程并发调用 | | `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` | -| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 调用方需自行串行化 | -| `MsgServer` 并发调用 broadcast/reply | ⚠️ 不支持 | 仅在 `on_message` 回调内(同一线程)安全 | +| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 底层函数无锁,调用方需自行串行化(MsgServer 内部已用每连接锁保护) | +| 同一连接消息并发执行回调 | ✅ 不会 | busy 标记暂停 select 监听,保证同连接串行 | --- @@ -702,7 +749,9 @@ python test_runner.py | 版本 | 日期 | 主要变更 | |------|------|---------| -| 0.1.0 | 当前 | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 | +| 0.1.0 | — | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 | +| 0.2.0 | 2026-07-30 | `MsgServer` 新增 `workers` 参数:回调可提交线程池并发执行;同连接串行(busy 标记)、每连接发送锁防帧交错、回调异常故障隔离;`tests/test_threadpool.py` 单元测试 | +| 0.3.0 | 2026-07-30 | 新增 `rpc.py` JSON RPC 约定层:`RpcClient`(_route 注入 + 编解码 + success 检查 + `RpcError`)、`RpcServer`(路由分发 + 响应包装 + `json_default`/`on_error` 钩子);db/smtp/captcha 服务端与 dnfs/log_client 客户端完成迁移;`tests/test_rpc.py` 单元测试 | --- diff --git a/pymsg/docs/SECURITY_AUDIT_20260801.md b/pymsg/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..3f67dbd --- /dev/null +++ b/pymsg/docs/SECURITY_AUDIT_20260801.md @@ -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 支持选项 +- [ ] 安全事件日志 diff --git a/pymsg/pymsg/__init__.py b/pymsg/pymsg/__init__.py index 4a604ef..48d4e7e 100644 --- a/pymsg/pymsg/__init__.py +++ b/pymsg/pymsg/__init__.py @@ -21,11 +21,18 @@ import contextlib import select import socket as _socket import struct +import threading +import traceback +from concurrent.futures import ThreadPoolExecutor __all__ = ["send", "recv", "TimeoutError", "MsgClient", "MsgServer"] _HEADER = struct.Struct("!I") # 4 bytes, big-endian unsigned int +# 消息大小安全上限(64 MiB),与 C++ 端 Msg.hpp 保持一致。 +# 防止恶意客户端发送超大长度头导致 OOM。 +_MAX_MSG_SIZE = 64 * 1024 * 1024 + class TimeoutError(Exception): """Raised when a send or recv operation exceeds its timeout.""" @@ -59,10 +66,14 @@ def send( timeout: Optional timeout in seconds. Raises TimeoutError on expiry. """ payload = data.encode("utf-8") + if len(payload) > _MAX_MSG_SIZE: + raise ValueError( + f"payload too large: {len(payload)} bytes (max {_MAX_MSG_SIZE})" + ) header = _HEADER.pack(len(payload)) with _timeout(sock, timeout): try: - sock.sendall(header + payload) + _send_all(sock, header, payload) except _socket.timeout: raise TimeoutError( f"send timed out after {timeout}s" @@ -92,6 +103,10 @@ def recv( try: header = _recv_exact(sock, _HEADER.size) (length,) = _HEADER.unpack(header) + if length > _MAX_MSG_SIZE: + raise ConnectionError( + f"message too large: {length} bytes (max {_MAX_MSG_SIZE})" + ) payload = _recv_exact(sock, length) return payload.decode("utf-8") except _socket.timeout: @@ -100,17 +115,42 @@ def recv( ) from None -def _recv_exact(sock: "_socket.socket", n: int) -> bytes: - """Read exactly n bytes from sock, raising on premature close.""" - buf = bytearray() - while len(buf) < n: - chunk = sock.recv(n - len(buf)) - if not chunk: - raise ConnectionError( - f"connection closed: expected {n} bytes, got {len(buf)}" +def _send_all(sock: "_socket.socket", header: bytes, payload: bytes) -> None: + """通过 sendmsg 散射-聚集 I/O 发送 header + payload,避免拼接拷贝。 + + 内核直接从两块独立内存读取数据,无需在用户态拼接 header + payload。 + 处理 partial send:sendmsg 可能只发出部分数据,需循环补发。 + """ + header_len = len(header) + total = header_len + len(payload) + sent = sock.sendmsg([memoryview(header), memoryview(payload)]) + while sent < total: + if sent < header_len: + sent += sock.sendmsg( + [memoryview(header)[sent:], memoryview(payload)] ) - buf.extend(chunk) - return bytes(buf) + else: + offset = sent - header_len + sent += sock.sendmsg([memoryview(payload)[offset:]]) + + +def _recv_exact(sock: "_socket.socket", n: int) -> bytearray: + """Read exactly n bytes from sock, raising on premature close. + + 使用 recv_into 直接写入预分配 buffer,避免 bytearray 逐步增长和 + 最终 bytes() 转换的额外拷贝。 + """ + buf = bytearray(n) + view = memoryview(buf) + got = 0 + while got < n: + chunk = sock.recv_into(view[got:], n - got) + if chunk == 0: + raise ConnectionError( + f"connection closed: expected {n} bytes, got {got}" + ) + got += chunk + return buf # --------------------------------------------------------------------------- @@ -246,14 +286,32 @@ class MsgServer: server.start(on_msg) # 阻塞当前线程 # 在另一线程中调用 server.stop() 可优雅退出 + + 并发模式:: + + server = MsgServer("0.0.0.0", 8003, workers=4) + + ``workers > 0`` 时回调提交到线程池并发执行(I/O 密集回调可获得真实 + 并行度)。同一连接上的消息仍严格串行处理;``reply()`` / ``broadcast()`` + 经每连接发送锁串行化,不会发生帧交错。 """ - def __init__(self, addr: str, port: int) -> None: + _RECV_TIMEOUT = 10.0 # 服务端接收单条消息的超时(秒),防止 Slowloris 式半开连接卡死主线程 + + def __init__(self, addr: str, port: int, *, workers: int = 0, + recv_timeout: float = _RECV_TIMEOUT) -> None: self.addr = addr self.port = port + self._workers = workers + self._recv_timeout = recv_timeout self._running = False self._server_sock: "_socket.socket | None" = None self._clients: "dict" = {} # socket -> (ip, port) + self._send_locks: "dict" = {} # socket -> Lock(防并发发送帧交错) + self._pool: "ThreadPoolExecutor | None" = None + self._lock = threading.Lock() # 保护 _busy / _failed + self._busy: "set" = set() # 回调执行中的 socket(暂停 select 监听) + self._failed: "set" = set() # 回调失败的 socket(主循环统一移除) # ---- 启动(阻塞)---------------------------------------------------- def start(self, on_message: "callable") -> None: @@ -264,6 +322,10 @@ class MsgServer: - sock: 发送消息的客户端套接字 - addr: (ip, port) 元组 - text: 解码后的字符串消息 + + ``workers > 0`` 时回调提交到线程池并发执行;同一连接的消息保持串行 + (回调完成前该连接暂停 select 监听,恢复粒度为一个 select 周期, + 最坏约 100ms——对短连接 RPC 模式无影响)。 """ self._running = True servsock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) @@ -271,26 +333,38 @@ class MsgServer: servsock.bind((self.addr, self.port)) servsock.listen(10) self._server_sock = servsock + if self._workers > 0: + self._pool = ThreadPoolExecutor( + max_workers=self._workers, + thread_name_prefix=f"pymsg-{self.port}", + ) socketlist = [servsock] - while self._running: - try: - readable, _, exception = select.select( - socketlist, [], socketlist, 0.1 - ) - except (ValueError, OSError): - break + try: + while self._running: + self._reap_failed(socketlist) + with self._lock: + watched = [s for s in socketlist if s not in self._busy] + try: + readable, _, exception = select.select( + watched, [], watched, 0.1 + ) + except (ValueError, OSError): + break - for s in exception: - self._remove_client(s, socketlist) + for s in exception: + self._remove_client(s, socketlist) - for s in readable: - if s is servsock: - self._accept_client(servsock, socketlist) - else: - self._handle_client(s, socketlist, on_message) - - self._shutdown(servsock, socketlist) + for s in readable: + if s is servsock: + self._accept_client(servsock, socketlist) + else: + self._handle_client(s, socketlist, on_message) + finally: + if self._pool is not None: + self._pool.shutdown(wait=True) + self._pool = None + self._shutdown(servsock, socketlist) # ---- 广播 ----------------------------------------------------------- def broadcast( @@ -307,11 +381,12 @@ class MsgServer: if client is except_sock: continue try: - send(client, msg) + self._locked_send(client, msg) except (BrokenPipeError, ConnectionResetError, OSError): disconnected.append(client) for client in disconnected: self._clients.pop(client, None) + self._send_locks.pop(client, None) try: client.close() except OSError: @@ -322,8 +397,18 @@ class MsgServer: """向指定客户端套接字发送一条应答消息(pymsg 协议)。 用于请求-应答模式:服务器收到消息后,通过同一 socket 回复处理结果。 + 经每连接发送锁串行化,``workers > 0`` 时可在回调线程安全调用。 """ - send(sock, msg) + self._locked_send(sock, msg) + + def _locked_send(self, sock: "_socket.socket", msg: str) -> None: + """带每连接锁的发送:防止多线程向同一 socket 并发写入导致帧交错。""" + lock = self._send_locks.get(sock) + if lock is None: + send(sock, msg) + return + with lock: + send(sock, msg) # ---- 停止 ----------------------------------------------------------- def stop(self) -> None: @@ -342,18 +427,62 @@ class MsgServer: if client_sock.fileno() != -1: socketlist.append(client_sock) self._clients[client_sock] = client_addr + self._send_locks[client_sock] = threading.Lock() except OSError: pass def _handle_client(self, s, socketlist, on_message): try: - text = recv(s) - on_message(s, self._clients.get(s, ("unknown", 0)), text) + text = recv(s, timeout=self._recv_timeout) + except (ConnectionError, ConnectionResetError, OSError, TimeoutError): + # TimeoutError: 客户端在超时时间内未发送完整消息(Slowloris 防护),关闭连接 + self._remove_client(s, socketlist) + return + addr = self._clients.get(s, ("unknown", 0)) + if self._pool is None: + # 同步模式(默认):回调在主循环线程内执行,行为与旧版一致 + try: + on_message(s, addr, text) + except (ConnectionError, ConnectionResetError, OSError): + self._remove_client(s, socketlist) + return + # 线程池模式:先标记 busy(暂停 select 监听该连接,保证同连接串行), + # 再把回调提交到线程池执行 + with self._lock: + self._busy.add(s) + self._pool.submit(self._run_task, s, addr, text, on_message) + + def _run_task(self, s, addr, text, on_message): + """工作线程:执行用户回调。 + + 出错时只打标记(``_failed``),实际的连接关闭与移除由主循环统一 + 执行,避免 worker 线程与 accept/select 并发操作 fd。 + """ + try: + on_message(s, addr, text) except (ConnectionError, ConnectionResetError, OSError): + with self._lock: + self._failed.add(s) + except Exception: + # 同步模式下此类异常会直接使 start() 崩溃;线程池模式无法传播, + # 打印 traceback 并移除该连接,保证服务整体可用 + traceback.print_exc() + with self._lock: + self._failed.add(s) + finally: + with self._lock: + self._busy.discard(s) + + def _reap_failed(self, socketlist): + """主循环每轮统一移除回调失败的连接。""" + with self._lock: + failed, self._failed = self._failed, set() + for s in failed: self._remove_client(s, socketlist) def _remove_client(self, s, socketlist): self._clients.pop(s, None) + self._send_locks.pop(s, None) if s in socketlist: socketlist.remove(s) try: @@ -368,3 +497,10 @@ class MsgServer: except OSError: pass self._clients.clear() + self._send_locks.clear() + + +# ── JSON RPC 约定层(依赖上面的 MsgClient / MsgServer,故放在末尾导入)── +from .rpc import RpcClient, RpcError, RpcServer # noqa: E402 + +__all__ += ["RpcClient", "RpcServer", "RpcError"] diff --git a/pymsg/pymsg/rpc.py b/pymsg/pymsg/rpc.py new file mode 100644 index 0000000..74b2977 --- /dev/null +++ b/pymsg/pymsg/rpc.py @@ -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)) diff --git a/pymsg/pyproject.toml b/pymsg/pyproject.toml index 4479156..ddb2d78 100644 --- a/pymsg/pyproject.toml +++ b/pymsg/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pymsg" -version = "0.1.0" +version = "0.3.0" description = "Length-prefixed variable-length socket message framing" requires-python = ">=3.9" license = "MIT" diff --git a/pymsg/tests/test_msgsize.py b/pymsg/tests/test_msgsize.py new file mode 100644 index 0000000..0a0bc86 --- /dev/null +++ b/pymsg/tests/test_msgsize.py @@ -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() diff --git a/pymsg/tests/test_rpc.py b/pymsg/tests/test_rpc.py new file mode 100644 index 0000000..6a919d4 --- /dev/null +++ b/pymsg/tests/test_rpc.py @@ -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() diff --git a/pymsg/tests/test_threadpool.py b/pymsg/tests/test_threadpool.py new file mode 100644 index 0000000..a96e813 --- /dev/null +++ b/pymsg/tests/test_threadpool.py @@ -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() diff --git a/smtp/docs/SECURITY_AUDIT_20260801.md b/smtp/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..c448b97 --- /dev/null +++ b/smtp/docs/SECURITY_AUDIT_20260801.md @@ -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 防止误用 + +### 短期 +- [ ] 验证全局熔断配置生效 +- [ ] 验证码存储考虑持久化(可选,当前内存实现适合单实例) diff --git a/smtp/log_client.py b/smtp/log_client.py index e2358a5..0dd516f 100644 --- a/smtp/log_client.py +++ b/smtp/log_client.py @@ -1,182 +1,244 @@ -""" -统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。 - -设计要点: - - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 - - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush - -用法: - from log_client import logs - - logs.info("smtp", "验证码发送成功") - logs.error("smtp", "验证码发送失败", extra={"errno": 2002}) - logs.push("smtp", "WARNING", "登录失败次数超限", - trace_id="req-abc", extra={"ip": "1.2.3.4"}) -""" - -import json -import os as _os -import sys as _sys -import threading -import queue -import time -import atexit -from datetime import datetime, timezone - -_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import MsgClient # noqa: E402 - -# ── 配置 ────────────────────────────────────────────────────────────── - -_DBV2_ADDR = '127.0.0.1' -_DBV2_PORT = 8005 - -_BATCH_SIZE = 50 # 队列满 50 条时 flush -_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush -_TIMEOUT = 5 # socket 超时(秒) - - -class LogClient: - """异步批量日志推送客户端。 - - 通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 - """ - - def __init__(self, service_name="smtp", - batch_size=_BATCH_SIZE, - flush_interval=_FLUSH_INTERVAL): - self._service_name = service_name - self._batch_size = batch_size - self._flush_interval = flush_interval - self._queue = queue.Queue() - self._stop = threading.Event() - self._flush_signal = threading.Event() - self._thread = threading.Thread(target=self._flush_loop, - daemon=True, name='log-client') - self._thread.start() - atexit.register(self._shutdown) - - # ── 公开接口 ──────────────────────────────────────────────────────── - - def push(self, service, level, message, trace_id=None, extra=None): - """推送一条日志(非阻塞,立即返回)。""" - self._queue.put({ - 'service': service, - 'level': level, - 'message': message, - 'trace_id': trace_id, - 'extra': extra, - }) - - def debug(self, service, message, **kw): - self.push(service, 'DEBUG', message, **kw) - - def info(self, service, message, **kw): - self.push(service, 'INFO', message, **kw) - - def warning(self, service, message, **kw): - self.push(service, 'WARNING', message, **kw) - - def error(self, service, message, **kw): - self.push(service, 'ERROR', message, **kw) - - def critical(self, service, message, **kw): - self.push(service, 'CRITICAL', message, **kw) - - def flush(self): - """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" - self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - # 等待后台线程响应(queue.get 超时最长 0.5s) - time.sleep(0.6) - # 排空主队列中尚未被线程取走的条目 - batch = [] - while True: - try: - batch.append(self._queue.get_nowait()) - if len(batch) >= self._batch_size: - self._send(batch) - batch = [] - except queue.Empty: - break - if batch: - self._send(batch) - self._flush_signal.clear() - - # ── 内部实现 ──────────────────────────────────────────────────────── - - def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" - batch = [] - last_flush = time.monotonic() - - while not self._stop.is_set(): - timeout = self._flush_interval - (time.monotonic() - last_flush) - # 限制最长阻塞时间,以便及时响应 flush() 信号 - chk_timeout = max(0.1, min(timeout, 0.5)) - try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass - - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - self._send(batch) - batch = [] - last_flush = time.monotonic() - - # 退出前 flush 剩余 - if batch: - self._send(batch) - - def _send(self, batch): - """通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" - now = datetime.now(timezone.utc).isoformat() - try: - client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - for entry in batch: - extra_val = entry.get("extra") - payload = { - "_route": "execute", - "action": "insert", - "table": "Logs", - "values": { - "ts": now, - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": json.dumps(extra_val, ensure_ascii=False) - if extra_val is not None else None, - }, - } - resp = client.call(json.dumps(payload), timeout=_TIMEOUT) - resp_obj = json.loads(resp) - if not resp_obj.get("success"): - import sys - print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", - file=sys.stderr) - except Exception as e: - import sys - print(f"[log_client] 发送失败: {e}", file=sys.stderr) - - def _shutdown(self): - """进程退出时 flush 剩余日志。""" - self._stop.set() - self.flush() - - -# ── 全局单例 ────────────────────────────────────────────────────────── - -logs = LogClient(service_name="smtp") +"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 + +设计要点: + - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 + - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 + +用法: + from log_client import logs + + logs.info("dnfs", "节点新增成功 id=42") + logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) + logs.push("gateway", "WARNING", "登录失败次数超限", + trace_id="req-abc", extra={"ip": "1.2.3.4"}) +""" + +import json +import os as _os +import sys as _sys +import threading +import queue +import time +import atexit +from datetime import datetime, timezone + +_sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) +from pymsg import RpcClient, RpcError # noqa: E402 + +# ── 配置 ────────────────────────────────────────────────────────────── + +_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') +_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) + +_BATCH_SIZE = 50 # 队列满 50 条时 flush +_FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush +_TIMEOUT = 5 # socket 超时(秒) +_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 + + +def _safe_json(obj): + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + return repr(obj) + + +def _now_iso(): + """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + return datetime.now(timezone.utc).isoformat() + + +class LogClient: + """异步批量日志推送客户端。""" + + def __init__(self, service_name="log", + batch_size=_BATCH_SIZE, + flush_interval=_FLUSH_INTERVAL): + self._service_name = service_name + self._batch_size = batch_size + self._flush_interval = flush_interval + self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) + self._stop = threading.Event() + self._flush_signal = threading.Event() + self._flush_done = threading.Event() + self._thread = threading.Thread(target=self._flush_loop, + daemon=True, name='log-client') + self._thread.start() + atexit.register(self._shutdown) + + # ── 公开接口 ──────────────────────────────────────────────────────── + + def push(self, service, level, message, trace_id=None, extra=None): + """推送一条日志(非阻塞,立即返回)。 + + 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 + """ + entry = { + 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 + 'service': service, + 'level': level, + 'message': message, + 'trace_id': trace_id, + 'extra': extra, + } + try: + self._queue.put_nowait(entry) + except queue.Full: + # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(entry) + except queue.Full: + pass # 极端竞争下仍失败则静默丢弃 + + def debug(self, service, message, **kw): + self.push(service, 'DEBUG', message, **kw) + + def info(self, service, message, **kw): + self.push(service, 'INFO', message, **kw) + + def warning(self, service, message, **kw): + self.push(service, 'WARNING', message, **kw) + + def error(self, service, message, **kw): + self.push(service, 'ERROR', message, **kw) + + def critical(self, service, message, **kw): + self.push(service, 'CRITICAL', message, **kw) + + def flush(self): + """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" + self._flush_done.clear() + self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch + # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) + self._flush_done.wait(timeout=self._flush_interval + 2.0) + # 主线程排空队列中剩余条目 + self._drain_queue_to_db() + + def _drain_queue_to_db(self): + """排空主线程可见的队列中剩余条目,直接发送到 db。""" + batch = [] + while True: + try: + batch.append(self._queue.get_nowait()) + if len(batch) >= self._batch_size: + self._send(batch) + batch = [] + except queue.Empty: + break + if batch: + self._send(batch) + + # ── 内部实现 ──────────────────────────────────────────────────────── + + def _flush_loop(self): + """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 + + 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) + 都不会死亡,避免日志队列无限增长导致 OOM。 + """ + batch = [] + last_flush = time.monotonic() + + while not self._stop.is_set(): + try: + timeout = self._flush_interval - (time.monotonic() - last_flush) + chk_timeout = max(0.1, min(timeout, 0.5)) + try: + item = self._queue.get(timeout=chk_timeout) + batch.append(item) + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + except queue.Empty: + pass + + should_flush = ( + len(batch) >= self._batch_size or + self._flush_signal.is_set() or + (batch and time.monotonic() - last_flush >= self._flush_interval) + ) + if should_flush and batch: + try: + self._send(batch) + except Exception as e: + # _send 内部已有细粒度异常处理,此处是最终安全网 + print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + batch = [] + last_flush = time.monotonic() + if self._flush_signal.is_set(): + self._flush_signal.clear() + self._flush_done.set() + except Exception as e: + # 最外层安全网:任何意外异常都不应杀死日志线程 + print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + time.sleep(0.5) # 防止异常风暴导致 CPU 满载 + + # 退出前 flush 剩余 + if batch: + try: + self._send(batch) + except Exception as e: + print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) + self._flush_done.set() + + def _send(self, batch): + """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 + + 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), + 不抛异常给调用方。 + """ + try: + client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") + except Exception as e: + print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) + return + + for entry in batch: + extra_val = entry.get("extra") + try: + extra_json = _safe_json(extra_val) if extra_val is not None else None + except Exception: + extra_json = None + payload = { + "action": "insert", + "table": "Logs", + "values": { + "ts": entry.get("ts", _now_iso()), + "service": entry.get("service"), + "level": entry.get("level"), + "message": entry.get("message"), + "trace_id": entry.get("trace_id"), + "extra": extra_json, + }, + } + try: + client.call("execute", payload, timeout=_TIMEOUT) + except RpcError as e: + # 业务错误(如表结构不符):打印后继续下一条 + print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) + except Exception as e: + # 网络错误:db 不可用,放弃本批剩余(fail-safe) + print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + break + + def _shutdown(self): + """进程退出时 flush 剩余日志。""" + self._stop.set() + self.flush() + + +# ── 全局单例 ────────────────────────────────────────────────────────── + +logs = LogClient() diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py index de9edde..a9b2f54 100644 --- a/smtp/smtp_v2.py +++ b/smtp/smtp_v2.py @@ -19,7 +19,6 @@ SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD (__PASSWORD 兼容别名 SMTP_PASSWORD)。 """ -import json import os import random import smtplib @@ -60,7 +59,7 @@ from log_client import logs # pymsg 导入 import sys as _sys _sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from pymsg import MsgServer # noqa: E402 +from pymsg import RpcServer # noqa: E402 # ── 配置 ──────────────────────────────────────────────────────────────── @@ -74,6 +73,13 @@ CODE_LEN = 6 CODE_TTL = 600 # 验证码有效期(秒) SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒) MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码) + +# ── 邮件轰炸防护(兜底:gateway 层已有 CAPTCHA + 邮箱级限流,此处为最后防线) ── +# 邮箱级双窗口:与 gateway 对齐,但作为 gateway 被绕过/重启时的兜底 +EMAIL_HOURLY_LIMIT = int(os.environ.get("EMAIL_HOURLY_LIMIT", "3")) # 单邮箱 3 次/小时 +EMAIL_DAILY_LIMIT = int(os.environ.get("EMAIL_DAILY_LIMIT", "10")) # 单邮箱 10 次/天 +# 全局熔断:防止 smtp 账号被服务商封禁;正常注册流量通常 <10/小时,默认值已是 10 倍异常 +SMTP_HOURLY_LIMIT = int(os.environ.get("SMTP_HOURLY_LIMIT", "100")) # 全局 100 封/小时 # 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER; # 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖 MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER @@ -83,6 +89,14 @@ DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int} send_history = {} # email -> 上次成功发送的时间戳(发送冷却用) + +# 邮件轰炸防护:邮箱级长期限流与全局熔断的发送日志。 +# send_log[email] = {'hour': [ts,...], 'day': [ts,...]} 仅记录成功发送的时间戳。 +# global_send_log = [ts,...] 全局成功发送时间戳(小时窗口)。 +# 惰性清理:每次检查时剔除已滑出窗口的时间戳,避免内存无限增长。 +send_log = {} # email -> {'hour': [ts], 'day': [ts]} +global_send_log = [] # [ts] 全局小时窗口发送时间戳 + STORE_LOCK = threading.Lock() @@ -93,17 +107,17 @@ STORE_LOCK = threading.Lock() class SmtpServer: """基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。""" - def __init__(self, addr="127.0.0.1", port=8008): + def __init__(self, addr="127.0.0.1", port=8008, workers=4): self.addr = addr self.port = port - self._server = MsgServer(addr, port) - - # 路由表 - self._routes = { - "send_email": self._handle_send_email, - "verify_email": self._handle_verify_email, - "health": self._handle_health, - } + self._server = RpcServer( + addr, port, workers=workers, + on_error=lambda route, e: logs.error( + "smtp", f"路由 {route} 处理异常: {e}"), + ) + self._server.add_route("send_email", self._handle_send_email) + self._server.add_route("verify_email", self._handle_verify_email) + self._server.add_route("health", self._handle_health) # ── 启动 / 停止 ────────────────────────────────────────────────── def start(self) -> None: @@ -118,42 +132,12 @@ class SmtpServer: print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)") print(f"[smtp] 监听 tcp://{self.addr}:{self.port}") logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}") - self._server.start(self._dispatch) + self._server.start() def stop(self) -> None: """从另一线程停止服务。""" self._server.stop() - # ── 消息调度 ------------------------------------------------------- - def _dispatch(self, sock, addr, text): - """解析 JSON 中的 _route,分发给对应的 handler,并回复结果。""" - try: - req = json.loads(text) - except json.JSONDecodeError as e: - self._reply(sock, False, error=f"JSON 解析失败: {e}") - return - - route = req.pop("_route", "") - handler = self._routes.get(route) - if handler is None: - self._reply(sock, False, error=f"未知路由: {route}") - return - - try: - result = handler(req) - self._reply(sock, True, data=result) - except Exception as e: - logs.error("smtp", f"路由 {route} 处理异常: {e}") - self._reply(sock, False, error=str(e)) - - def _reply(self, sock, success, data=None, error=None): - resp = {"success": success} - if data is not None: - resp["data"] = data - if error is not None: - resp["error"] = error - self._server.reply(sock, json.dumps(resp, ensure_ascii=False)) - # ═══════════════════════════════════════════════════════════════════ # 路由处理 # ═══════════════════════════════════════════════════════════════════ @@ -182,6 +166,37 @@ class SmtpServer: logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝") return {"sent": False, "retry_after": wait, "error": f"发送过于频繁,请 {wait} 秒后重试"} + # 立即占位:防止并发请求同时通过冷却检查导致同邮箱重复发送; + # 发送失败时在下方回滚,"失败不计入冷却"语义不变 + send_history[email] = time.time() + + # 邮件轰炸防护:邮箱级长期限流 + 全局熔断(预占模式,与 60s 冷却一致) + # gateway 层已有 CAPTCHA + 邮箱级限流,此处为 gateway 被绕过/重启时的兜底。 + # 预占:检查时立即记录时间戳;发送失败时回滚,"失败不计入配额"语义不变。 + now = time.time() + with STORE_LOCK: + log = send_log.setdefault(email, {'hour': [], 'day': []}) + # 清理已滑出窗口的时间戳(惰性清理,避免内存无限增长) + log['hour'] = [t for t in log['hour'] if now - t < 3600] + log['day'] = [t for t in log['day'] if now - t < 86400] + global_send_log[:] = [t for t in global_send_log if now - t < 3600] + # 邮箱级配额检查 + if len(log['hour']) >= EMAIL_HOURLY_LIMIT: + return {"sent": False, + "error": f"该邮箱每小时发送上限({EMAIL_HOURLY_LIMIT}次)已达,请稍后重试"} + if len(log['day']) >= EMAIL_DAILY_LIMIT: + return {"sent": False, + "error": f"该邮箱每日发送上限({EMAIL_DAILY_LIMIT}次)已达,请明日重试"} + # 全局熔断:防 smtp 账号被服务商封禁 + if len(global_send_log) >= SMTP_HOURLY_LIMIT: + logs.error("smtp", f"全局熔断:近 1 小时已发送 {len(global_send_log)} 封," + f"达到上限 {SMTP_HOURLY_LIMIT},拒绝发送给 {email}") + return {"sent": False, + "error": "邮件服务发送量已达上限,请稍后重试"} + # 预占(发送失败时在下方回滚) + log['hour'].append(now) + log['day'].append(now) + global_send_log.append(now) code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN)) subject = str(req.get("subject") or DEFAULT_SUBJECT) @@ -211,10 +226,20 @@ class SmtpServer: # 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态 with STORE_LOCK: code_store.pop(email, None) + send_history.pop(email, None) # 回滚冷却占位,允许立即重试 + # 回滚限流预占(移除刚才追加的时间戳) + log = send_log.get(email) + if log: + if log['hour'] and log['hour'][-1] == now: + log['hour'].pop() + if log['day'] and log['day'][-1] == now: + log['day'].pop() + if global_send_log and global_send_log[-1] == now: + global_send_log.pop() logs.error("smtp", f"{email}: 验证码发送失败({err})") return {"sent": False, "error": f"验证码发送失败:{err}"} - # 发送成功:记录冷却起点,并惰性清理已失效的冷却记录 + # 发送成功:刷新冷却起点(检查阶段已占位),并惰性清理已失效的冷却记录 with STORE_LOCK: send_history[email] = time.time() stale = [k for k, v in send_history.items() @@ -280,9 +305,11 @@ if __name__ == "__main__": parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务") parser.add_argument("--addr", default="127.0.0.1", help="监听地址") parser.add_argument("--port", type=int, default=8008, help="监听端口") + parser.add_argument("--workers", type=int, default=4, + help="回调并发线程数(0=同步串行模式)") args = parser.parse_args() - server = SmtpServer(args.addr, args.port) + server = SmtpServer(args.addr, args.port, workers=args.workers) try: server.start() except KeyboardInterrupt: diff --git a/static/README.md b/static/README.md index 003ea13..06ac0d7 100644 --- a/static/README.md +++ b/static/README.md @@ -21,6 +21,7 @@ static :9200 (Flask + CORS) ├── index.html 社区首页 ├── forgot.html 忘记密码 ├── dnfs.html 树状笔记(调用 dnfs:8006) + ├── aibox.html aibox 智能体 Web 客户端(管理员专属,经 gateway WS 反向代理) ├── __view.html 文件浏览器(系统文件,不在列表中显示) ├── allcolor.html 调色板 ├── LICENSE.html 许可证页面 @@ -50,6 +51,7 @@ static :9200 (Flask + CORS) | `POST /api/hash` | SHA256 密码哈希 | | `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) | | `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) | +| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) | ### yhdet OAuth 路由 @@ -68,15 +70,19 @@ static :9200 (Flask + CORS) | `POST /add_note` | 新增笔记(代理至 dnfs:8006) | | `POST /delete_note` | 删除笔记(代理至 dnfs:8006) | | `POST /update_note` | 更新笔记(代理至 dnfs:8006) | -| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) | -以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。 +以上 4 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。 + +> `/whoami` 已上移至 gateway,由 gateway 直接验签 Token 返回用户身份,不再经 static 代理到 dnfs。 ## 功能 - 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计 - 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动 - 深色模式:`css/theme.css` 统一主题,自动适配系统偏好 +- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类 +- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制 +- aibox Web 客户端:`aibox.html` 移动端适配聊天页,经 gateway `[WsProxy]` 反向代理(如 `/ws/aibox → ws://127.0.0.1:8765`)与 aibox 智能体通信,握手由 gateway 强制 `is_admin` 验签;协议与 BotOps/client.py 兼容(chunk 流式 / reasoning 思考区 / task 任务面板 / 指数退避重连) - 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内 - Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定) - 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头 diff --git a/static/docs/SECURITY_AUDIT_20260801.md b/static/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..27138af --- /dev/null +++ b/static/docs/SECURITY_AUDIT_20260801.md @@ -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 服务 +- [ ] 文件服务路径遍历全面审计 diff --git a/static/fsys.py b/static/fsys.py index 3c90403..f2542e2 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -12,6 +12,8 @@ import sys # pymsg 日志客户端(集中式日志 → dbv2 Logs 表) sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +# 附加 ../pymsg 源码路径作为无 venv 时的开发兜底(生产由 venv 提供 pymsg) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "pymsg")) from log_client import logs # noqa: E402 from security_middleware import apply_security_headers # noqa: E402 @@ -41,6 +43,8 @@ app = Flask(__name__) apply_security_headers(app) # ==================== 配置文件加载 ==================== +# 加载顺序:内置默认值 → confs 统一配置服务(static.ini); +# confs 不可用时 fail-safe 回退本地 config.ini CONFIG_FILE = args.config if not os.path.isabs(CONFIG_FILE): CONFIG_FILE = os.path.join(BASE_DIR, CONFIG_FILE) @@ -53,15 +57,42 @@ file_config = { 'cors': {'allowed_origins': 'https://qsont.me,https://yhdet.top'}, } -if os.path.exists(CONFIG_FILE): - config_parser = configparser.ConfigParser() - config_parser.read(CONFIG_FILE, encoding='utf-8') + +def _merge_ini(text): + """把 ini 文本合并进 file_config(仅覆盖已知的 section/key)。""" + ini = configparser.ConfigParser() + ini.read_string(text) for section in ['server', 'directory', 'ssl', 'cors']: - if section in config_parser: - for key, value in config_parser[section].items(): + if ini.has_section(section): + for key, value in ini[section].items(): if key in file_config[section]: file_config[section][key] = value + +def _fetch_from_confs(): + """从 confs 统一配置服务拉取 static.ini 全文,失败返回 None(fail-safe)。""" + try: + from pymsg import RpcClient + host = os.environ.get('CONFS_HOST', '127.0.0.1') + port = int(os.environ.get('CONFS_PORT', '8010')) + data = RpcClient(host, port, 'static', timeout=3).call( + 'get', {'name': 'static.ini'}) + return data.get('content') + except Exception: + return None + + +_confs_text = _fetch_from_confs() +if _confs_text is not None: + _merge_ini(_confs_text) + logger.info("配置来源: confs 统一配置服务 (static.ini)") + logs.info("static", "配置来源: confs 统一配置服务 (static.ini)") +elif os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, encoding='utf-8') as f: + _merge_ini(f.read()) + logger.warning(f"confs 不可用,回退本地配置文件 {CONFIG_FILE}") + logs.warning("static", f"confs 不可用,回退本地配置文件 {CONFIG_FILE}") + # ==================== CORS(配置加载完成后初始化) ==================== _cors_origins = [o.strip() for o in file_config['cors']['allowed_origins'].split(',') if o.strip()] CORS(app, origins=_cors_origins) @@ -474,22 +505,6 @@ def proxy_update_note(): return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"})) -@app.route('/whoami', methods=['POST', 'OPTIONS']) -def proxy_whoami(): - if request.method == "OPTIONS": return _cors(make_response("", 204)) - try: - data = request.get_json(force=True, silent=True) or {} - h = {"Content-Type": "application/json"} - if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"] - elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer - req = _urlreq.Request(f"{DNFS_URL}/whoami", data=_json.dumps(data).encode(), headers=h, method="POST") - with _urlreq.urlopen(req, timeout=15) as r: - return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"})) - except Exception as e: - logs.warning("static", "dnfs whoami 代理失败", extra={"error": str(e)}) - return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"})) - - @app.route('/api/proxy_send_code', methods=['POST']) def proxy_send_code(): from flask import request, jsonify @@ -516,6 +531,75 @@ def proxy_send_code(): +# ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ==================== +# 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。 +# SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。 +import ipaddress +import socket +from urllib.parse import urlparse + +_FAVICON_MAX_BYTES = 512 * 1024 +_FAVICON_TIMEOUT = 5 + + +def _is_private_host(hostname): + """主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。""" + try: + infos = socket.getaddrinfo(hostname, None) + except Exception: + return True # 解析失败一律拒绝 + if not infos: + return True + for info in infos: + try: + ip = ipaddress.ip_address(info[4][0]) + except ValueError: + return True + if (ip.is_private or ip.is_loopback or ip.is_link_local + or ip.is_reserved or ip.is_multicast or ip.is_unspecified): + return True + return False + + +class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler): + """重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。""" + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _is_private_host(urlparse(newurl).hostname or ''): + return None + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +@app.route('/api/favicon', methods=['GET']) +def api_favicon(): + target = (request.args.get('url') or '').strip() + try: + parsed = urlparse(target) + except Exception: + return jsonify({'error': 'bad url'}), 400 + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + return jsonify({'error': 'bad url'}), 400 + if _is_private_host(parsed.hostname): + return jsonify({'error': 'forbidden host'}), 403 + + favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + try: + opener = _urlreq.build_opener(_FaviconRedirectHandler()) + req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'}) + with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp: + data = resp.read(_FAVICON_MAX_BYTES + 1) + if len(data) > _FAVICON_MAX_BYTES: + return jsonify({'error': 'favicon too large'}), 502 + ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip() + if not ctype.startswith('image/'): + ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底 + out = Response(data, 200, mimetype=ctype) + out.headers['Cache-Control'] = 'public, max-age=86400' + return out + except Exception as e: + logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)}) + return jsonify({'error': 'fetch failed'}), 502 + + # ==================== 主路由 ==================== @app.route('/') def serve_view(): diff --git a/static/log_client.py b/static/log_client.py index f70e750..0dd516f 100644 --- a/static/log_client.py +++ b/static/log_client.py @@ -1,17 +1,19 @@ -""" -统一日志客户端 — 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表。 +"""统一日志客户端 — 各服务共用(dnfs / smtp / static / confs 四份副本内容一致,改动请同步)。 设计要点: - - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 + - 通过 pymsg RPC 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表 - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务 - - fail-safe:db 服务不可用时静默丢弃,绝不影响业务 + - fail-safe:db 服务不可用时静默丢弃,绝不影响业务;后台线程任何异常都不会崩溃 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush + - 时间戳:日志入队时即记录 ts,反映事件真实发生时间 用法: from log_client import logs - logs.info("static", "服务启动成功") - logs.error("static", "文件系统未启用", extra={"path": "/data"}) + logs.info("dnfs", "节点新增成功 id=42") + logs.error("dnfs", "数据库连接失败", extra={"errno": 2002}) + logs.push("gateway", "WARNING", "登录失败次数超限", + trace_id="req-abc", extra={"ip": "1.2.3.4"}) """ import json @@ -24,33 +26,45 @@ import atexit from datetime import datetime, timezone _sys.path.insert(0, _os.path.join(_os.path.dirname(__file__), "..")) -from pymsg import MsgClient # noqa: E402 +from pymsg import RpcClient, RpcError # noqa: E402 # ── 配置 ────────────────────────────────────────────────────────────── -_DBV2_ADDR = '127.0.0.1' -_DBV2_PORT = 8005 +_DBV2_ADDR = _os.environ.get('DB_HOST', '127.0.0.1') +_DBV2_PORT = int(_os.environ.get('DB_PORT', '8005')) _BATCH_SIZE = 50 # 队列满 50 条时 flush _FLUSH_INTERVAL = 2.0 # 超时 2 秒强制 flush _TIMEOUT = 5 # socket 超时(秒) +_QUEUE_MAXSIZE = 10000 # 队列上限,防止后台线程异常时内存无限增长;满则丢弃最旧条目 + + +def _safe_json(obj): + """安全序列化 extra 字段,不可序列化对象转为 repr 字符串,保证不抛异常。""" + try: + return json.dumps(obj, ensure_ascii=False, default=str) + except Exception: + return repr(obj) + + +def _now_iso(): + """生成当前 UTC 时间 ISO 字符串(在入队时调用,保证时间戳准确)。""" + return datetime.now(timezone.utc).isoformat() class LogClient: - """异步批量日志推送客户端。 + """异步批量日志推送客户端。""" - 通过 MsgClient(pymsg TCP 协议)逐条 INSERT 到 dbv2 的 Logs 表。 - """ - - def __init__(self, service_name="static", + def __init__(self, service_name="log", batch_size=_BATCH_SIZE, flush_interval=_FLUSH_INTERVAL): self._service_name = service_name self._batch_size = batch_size self._flush_interval = flush_interval - self._queue = queue.Queue() + self._queue = queue.Queue(maxsize=_QUEUE_MAXSIZE) self._stop = threading.Event() self._flush_signal = threading.Event() + self._flush_done = threading.Event() self._thread = threading.Thread(target=self._flush_loop, daemon=True, name='log-client') self._thread.start() @@ -59,14 +73,30 @@ class LogClient: # ── 公开接口 ──────────────────────────────────────────────────────── def push(self, service, level, message, trace_id=None, extra=None): - """推送一条日志(非阻塞,立即返回)。""" - self._queue.put({ + """推送一条日志(非阻塞,立即返回)。 + + 队列满时静默丢弃最旧条目再放入新条目,保证不阻塞业务线程、不导致 OOM。 + """ + entry = { + 'ts': _now_iso(), # 入队时即生成时间戳,反映真实事件时间 'service': service, 'level': level, 'message': message, 'trace_id': trace_id, 'extra': extra, - }) + } + try: + self._queue.put_nowait(entry) + except queue.Full: + # 队列满:丢弃最旧的一条,再放入新条目(drop-oldest 策略) + try: + self._queue.get_nowait() + except queue.Empty: + pass + try: + self._queue.put_nowait(entry) + except queue.Full: + pass # 极端竞争下仍失败则静默丢弃 def debug(self, service, message, **kw): self.push(service, 'DEBUG', message, **kw) @@ -85,8 +115,15 @@ class LogClient: def flush(self): """手动 flush 队列中剩余的日志(含后台线程持有的局部 batch)。""" + self._flush_done.clear() self._flush_signal.set() # 通知后台线程立即 flush 其局部 batch - time.sleep(0.6) # 等待后台线程响应 + # 等待后台线程完成 flush(最多等待 flush_interval + 2 秒余量) + self._flush_done.wait(timeout=self._flush_interval + 2.0) + # 主线程排空队列中剩余条目 + self._drain_queue_to_db() + + def _drain_queue_to_db(self): + """排空主线程可见的队列中剩余条目,直接发送到 db。""" batch = [] while True: try: @@ -98,73 +135,103 @@ class LogClient: break if batch: self._send(batch) - self._flush_signal.clear() # ── 内部实现 ──────────────────────────────────────────────────────── def _flush_loop(self): - """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。""" + """后台线程:积攒到 batch_size 或超时 flush_interval 时发送。 + + 外层捕获所有异常,保证后台线程在任何情况下(网络故障、序列化错误等) + 都不会死亡,避免日志队列无限增长导致 OOM。 + """ batch = [] last_flush = time.monotonic() while not self._stop.is_set(): - timeout = self._flush_interval - (time.monotonic() - last_flush) - chk_timeout = max(0.1, min(timeout, 0.5)) try: - item = self._queue.get(timeout=chk_timeout) - batch.append(item) - while len(batch) < self._batch_size: - try: - batch.append(self._queue.get_nowait()) - except queue.Empty: - break - except queue.Empty: - pass + timeout = self._flush_interval - (time.monotonic() - last_flush) + chk_timeout = max(0.1, min(timeout, 0.5)) + try: + item = self._queue.get(timeout=chk_timeout) + batch.append(item) + while len(batch) < self._batch_size: + try: + batch.append(self._queue.get_nowait()) + except queue.Empty: + break + except queue.Empty: + pass - should_flush = ( - len(batch) >= self._batch_size or - self._flush_signal.is_set() or - (batch and time.monotonic() - last_flush >= self._flush_interval) - ) - if should_flush and batch: - self._send(batch) - batch = [] - last_flush = time.monotonic() + should_flush = ( + len(batch) >= self._batch_size or + self._flush_signal.is_set() or + (batch and time.monotonic() - last_flush >= self._flush_interval) + ) + if should_flush and batch: + try: + self._send(batch) + except Exception as e: + # _send 内部已有细粒度异常处理,此处是最终安全网 + print(f"[log_client] _send 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + batch = [] + last_flush = time.monotonic() + if self._flush_signal.is_set(): + self._flush_signal.clear() + self._flush_done.set() + except Exception as e: + # 最外层安全网:任何意外异常都不应杀死日志线程 + print(f"[log_client] _flush_loop 异常(已捕获,线程继续运行): {e}", + file=_sys.stderr) + time.sleep(0.5) # 防止异常风暴导致 CPU 满载 # 退出前 flush 剩余 if batch: - self._send(batch) + try: + self._send(batch) + except Exception as e: + print(f"[log_client] 退出时 flush 异常: {e}", file=_sys.stderr) + self._flush_done.set() def _send(self, batch): - """通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表。""" - now = datetime.now(timezone.utc).isoformat() + """通过 pymsg RPC(execute 路由)批量发送到 dbv2 Logs 表。 + + 整个 batch 复用一个 TCP 连接;网络错误时放弃本批剩余条目(fail-safe), + 不抛异常给调用方。 + """ try: - client = MsgClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") - for entry in batch: - extra_val = entry.get("extra") - payload = { - "_route": "execute", - "action": "insert", - "table": "Logs", - "values": { - "ts": now, - "service": entry.get("service"), - "level": entry.get("level"), - "message": entry.get("message"), - "trace_id": entry.get("trace_id"), - "extra": json.dumps(extra_val, ensure_ascii=False) - if extra_val is not None else None, - }, - } - resp = client.call(json.dumps(payload), timeout=_TIMEOUT) - resp_obj = json.loads(resp) - if not resp_obj.get("success"): - import sys - print(f"[log_client] INSERT 失败: {resp_obj.get('error')}", - file=sys.stderr) + client = RpcClient(_DBV2_ADDR, _DBV2_PORT, self._service_name + "-log") except Exception as e: - import sys - print(f"[log_client] 发送失败: {e}", file=sys.stderr) + print(f"[log_client] 创建 RpcClient 失败: {e}", file=_sys.stderr) + return + + for entry in batch: + extra_val = entry.get("extra") + try: + extra_json = _safe_json(extra_val) if extra_val is not None else None + except Exception: + extra_json = None + payload = { + "action": "insert", + "table": "Logs", + "values": { + "ts": entry.get("ts", _now_iso()), + "service": entry.get("service"), + "level": entry.get("level"), + "message": entry.get("message"), + "trace_id": entry.get("trace_id"), + "extra": extra_json, + }, + } + try: + client.call("execute", payload, timeout=_TIMEOUT) + except RpcError as e: + # 业务错误(如表结构不符):打印后继续下一条 + print(f"[log_client] INSERT 失败: {e}", file=_sys.stderr) + except Exception as e: + # 网络错误:db 不可用,放弃本批剩余(fail-safe) + print(f"[log_client] 发送失败: {e}", file=_sys.stderr) + break def _shutdown(self): """进程退出时 flush 剩余日志。""" @@ -174,4 +241,4 @@ class LogClient: # ── 全局单例 ────────────────────────────────────────────────────────── -logs = LogClient(service_name="static") +logs = LogClient() diff --git a/static/public/aibox.html b/static/public/aibox.html new file mode 100644 index 0000000..89fb608 --- /dev/null +++ b/static/public/aibox.html @@ -0,0 +1,35 @@ + + + + + + +aibox 智能体 + + + + +
+
+ 🤖 aibox 智能体 + 未连接 + + +
+ +
+
+ + + +
+
+ +
+ + + + diff --git a/static/public/css/aibox.css b/static/public/css/aibox.css new file mode 100644 index 0000000..36f43fd --- /dev/null +++ b/static/public/css/aibox.css @@ -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; } +} diff --git a/static/public/css/theme.css b/static/public/css/theme.css index b19d08a..2e7aa2d 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -119,6 +119,21 @@ textarea:focus { input::placeholder, textarea::placeholder { color: var(--muted); } textarea { min-height: 80px; resize: vertical; } +select { + display: block; + width: 100%; + padding: 0.6rem 0.9rem; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + background: var(--bg); + color: var(--fg); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } + /* ===== Buttons ===== */ button, input[type="submit"], input[type="reset"], input[type="button"] { padding: 0.55rem 1.3rem; @@ -190,6 +205,18 @@ input[type="button"]:disabled { } .code-box input[type="text"] { width: auto; flex: 1; min-width: 100px; display: inline-block; } .code-box input[type="button"] { white-space: nowrap; } +/* 图形验证码图片(register 图形/邮箱验证码同排布局) */ +.code-box .captcha-img { + width: 120px; + height: 38px; + flex: none; + cursor: pointer; + border: 1.5px solid var(--border); + border-radius: 10px; + box-sizing: border-box; + object-fit: cover; + background: var(--bg); +} /* ===== Validation Tips ===== */ .tip-red { color: var(--error); font-size: 0.78rem; } @@ -480,6 +507,30 @@ input[type="button"]:disabled { border-color: var(--accent); } +/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */ +.sp-link { display: flex; align-items: center; gap: 0.5rem; } +.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; } +.sp-anchor { color: var(--accent); word-break: break-all; } +.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; } +.sp-code { + max-height: 320px; + overflow: auto; + margin-top: 0.5rem; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} +.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); } +.sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; } +#sp-panel { margin-top: 0.4rem; } +#sp-panel select { margin-bottom: 0.4rem; } +#sp-file-tip { font-size: 0.78rem; } + /* ===== Node Form (dnfs) ===== */ .node-form { margin-top: 1rem; } .node-form .form-actions { margin-top: 0.75rem; } @@ -491,7 +542,10 @@ input[type="button"]:disabled { padding: 0.9rem 1rem; font-size: 0.95rem; min-height: 120px; + max-height: 400px; line-height: 1.7; + overflow-y: hidden; + resize: none; } /* ===== Markdown Content ===== */ diff --git a/static/public/dnfs.html b/static/public/dnfs.html index bc4c659..b9a8a99 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -15,8 +15,21 @@

节点操作

- - + + + +
diff --git a/static/public/js/aibox.js b/static/public/js/aibox.js new file mode 100644 index 0000000..056676a --- /dev/null +++ b/static/public/js/aibox.js @@ -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(); +})(); diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index b5e4e91..0ac94ca 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -105,6 +105,7 @@ async function addNode(pid, title, content) { input_note_title.value = ""; input_note_content.value = ""; + cancelEdit(); /* 退出编辑模式并重置高度 */ getNode(stack[stack.length - 1]).then(load_notes); getNode(stack[stack.length - 1]).then(loadMap) @@ -417,6 +418,151 @@ function renderMarkdown(element, content) { element.innerHTML = md.render(content, {html: false}); } +/* ===== by qing — 特殊节点(管理员专属)===== + * 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。 + * 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */ + +/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */ +var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024; + +function parseSp(content) { + if (typeof content !== 'string') return null; + var s = content.trim(); + if (s.indexOf('{"_sp"') !== 0) return null; + try { + var obj = JSON.parse(s); + } catch (e) { + return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) + } + if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; + return null; +} + +/* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */ +function renderContent(container, item) { + var sp = parseSp(item.content); + if (!sp) { + renderMarkdown(container, item.content); + return; + } + function appendNote() { + if (typeof sp.note === 'string' && sp.note.trim()) { + var note = document.createElement('div'); + note.className = 'sp-note'; + note.textContent = sp.note; + container.appendChild(note); + } + } + if (sp._sp === 'link') renderSpLink(container, sp, appendNote); + else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote); + else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); + else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); +} + +/* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */ +function renderSpLink(container, sp, appendNote) { + var box = document.createElement('div'); + box.className = 'sp-link'; + var icon = document.createElement('img'); + icon.className = 'sp-favicon'; + icon.alt = ''; + icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url); + icon.addEventListener('error', function() { icon.style.display = 'none'; }); + var anchor = document.createElement('a'); + anchor.className = 'sp-anchor'; + anchor.href = sp.url; + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + anchor.textContent = sp.url; + anchor.addEventListener('click', function(e) { e.stopPropagation(); }); + box.appendChild(icon); + box.appendChild(anchor); + container.appendChild(box); + appendNote(); +} + +/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */ +function renderSpHtml(container, sp, appendNote) { + var bar = document.createElement('div'); + bar.className = 'sp-actions'; + var audit_btn = document.createElement('button'); + audit_btn.className = 'act-btn act-edit'; + audit_btn.textContent = '审计源码'; + var run_btn = document.createElement('button'); + run_btn.className = 'act-btn act-move'; + run_btn.textContent = '运行'; + var pre = document.createElement('pre'); + pre.className = 'sp-code'; + pre.style.display = 'none'; + pre.textContent = sp.code; + audit_btn.addEventListener('click', function(e) { + e.stopPropagation(); + var hidden = (pre.style.display === 'none'); + pre.style.display = hidden ? 'block' : 'none'; + audit_btn.textContent = hidden ? '收起源码' : '审计源码'; + }); + run_btn.addEventListener('click', function(e) { + e.stopPropagation(); + var win = window.open('', '_blank'); + if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; } + win.document.open(); + win.document.write(sp.code); + win.document.close(); + }); + bar.appendChild(audit_btn); + bar.appendChild(run_btn); + container.appendChild(bar); + container.appendChild(pre); + appendNote(); +} + +/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */ +function renderSpImage(container, sp, appendNote) { + var img = document.createElement('img'); + img.className = 'sp-image'; + img.src = sp.data; + img.alt = sp.name || '图片'; + container.appendChild(img); + appendNote(); +} + +/* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */ +function renderSpFile(container, sp, appendNote) { + var bar = document.createElement('div'); + bar.className = 'sp-actions'; + var btn = document.createElement('button'); + btn.className = 'act-btn act-edit'; + btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')'; + btn.addEventListener('click', function(e) { + e.stopPropagation(); + try { + var bin = atob(sp.data); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = sp.name || 'download'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000); + } catch (err) { + alert('文件解码失败:' + err.message); + } + }); + bar.appendChild(btn); + container.appendChild(bar); + appendNote(); +} + +function formatBytes(n) { + var units = ['B', 'KB', 'MB', 'GB']; + var i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return n.toFixed(1) + units[i]; +} + /* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */ const ICON_EYE = ''; const ICON_HEART = ''; @@ -449,16 +595,96 @@ var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var movebar = document.getElementById("movebar"); +/* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */ +var sp_panel = document.getElementById('sp-panel'); +var sp_type = document.getElementById('sp-type'); +var sp_file = document.getElementById('sp-file'); +var sp_file_tip = document.getElementById('sp-file-tip'); +var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */ /* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */ var PANEL_EXPAND_MIN_WIDTH = 768; var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */ var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */ +/* ===== 输入框自动高度调整 ===== */ +function autoResizeTextarea(el) { + el.style.height = 'auto'; + el.style.height = Math.min(el.scrollHeight, 400) + 'px'; + el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden'; +} +input_note_content.addEventListener('input', function() { + autoResizeTextarea(input_note_content); +}); +function resetTextareaHeight() { + input_note_content.style.height = '120px'; + input_note_content.style.overflowY = 'hidden'; +} +autoResizeTextarea(input_note_content); /* 初始化高度 */ + +/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */ +sp_type.addEventListener('change', function() { + var t = sp_type.value; + var is_file = (t === 'image' || t === 'file'); + sp_file.style.display = is_file ? '' : 'none'; + input_note_content.style.display = is_file ? 'none' : ''; + sp_file_tip.textContent = ''; + sp_file_tip.className = is_file ? 'tip-yellow' : ''; + if (!is_file) { + sp_file.value = ''; + sp_edit_original = null; + } + if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)'; + else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码'; + else input_note_content.placeholder = ''; +}); + +function readFileAsDataURL(f) { + return new Promise(function(resolve, reject) { + var r = new FileReader(); + r.onload = function() { resolve(r.result); }; + r.onerror = function() { reject(new Error('文件读取失败')); }; + r.readAsDataURL(f); + }); +} + +/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。 + * 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */ +async function buildSubmitContent() { + var t = (current_user && current_user.is_admin) ? sp_type.value : 'text'; + if (t === 'text') return input_note_content.value; + if (t === 'link') { + var url = input_note_content.value.trim(); + if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; } + return JSON.stringify({ _sp: 'link', url: url }); + } + if (t === 'html') { + var code = input_note_content.value; + if (!code.trim()) { alert('请输入 HTML 代码'); return null; } + return JSON.stringify({ _sp: 'html', code: code }); + } + /* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */ + if (sp_file.files.length === 0) { + if (sp_edit_original) return sp_edit_original; + alert('请选择要上传的' + (t === 'image' ? '图片' : '文件')); + return null; + } + var f = sp_file.files[0]; + if (f.size > SP_MAX_FILE_BYTES) { + alert('文件过大(上限约 2.5MB):' + f.name); + return null; + } + var dataUrl = await readFileAsDataURL(f); + if (t === 'image') { + if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; } + return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl }); + } + return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] }); +} + input_note_submit.addEventListener('click', function(event) { if (input_note_submit.disabled) return; // 防重复点击 var title = input_note_title.value - var content = input_note_content.value var pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; //alert(stack.length) //alert(pid) @@ -466,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) { input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; - // 编辑模式下保存修改,否则新增节点 - var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); - Promise.resolve(req) + // 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件) + buildSubmitContent() + .then(function(content) { + if (content == null) return null; // 校验失败,已弹提示 + return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); + }) + .catch(function(err) { + alert(err.message || '内容处理失败'); + console.error('构造提交内容异常:', err); + }) .finally(() => { input_note_submit.disabled = false; input_note_clear.disabled = false; @@ -497,12 +730,30 @@ delete_note.addEventListener('click', (event) => { } }) -/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */ +/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。 + * 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */ function startEdit(item) { editing_id = item.id; input_note_title.value = item.title; - input_note_content.value = item.content; + var sp = parseSp(item.content); + sp_edit_original = null; + if (sp && (sp._sp === 'link' || sp._sp === 'html')) { + sp_type.value = sp._sp; + sp_type.dispatchEvent(new Event('change')); + input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || ''); + } else if (sp) { + sp_type.value = sp._sp; + sp_type.dispatchEvent(new Event('change')); + sp_edit_original = item.content; + sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件'); + input_note_content.value = ''; + } else { + sp_type.value = 'text'; + sp_type.dispatchEvent(new Event('change')); + input_note_content.value = item.content; + } input_note_submit.textContent = "保存修改"; + autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */ input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); input_note_title.focus(); } @@ -510,7 +761,15 @@ function startEdit(item) { /* by qing — 退出编辑模式,表单恢复为新增状态 */ function cancelEdit() { editing_id = null; + sp_edit_original = null; + sp_file.value = ''; + sp_file_tip.textContent = ''; + if (sp_type.value !== 'text') { + sp_type.value = 'text'; + sp_type.dispatchEvent(new Event('change')); + } input_note_submit.textContent = "提交"; + resetTextareaHeight(); /* 恢复初始高度 */ } function load_notes(main_node) { @@ -579,7 +838,7 @@ function load_notes(main_node) { ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); - renderMarkdown(ctt, item.content); + renderContent(ctt, item); card.appendChild(ctt) if (item.can_operate === true) { let act_div = document.createElement('div'); @@ -681,6 +940,10 @@ async function loadUserPanel() { }); if (!res.ok) return; current_user = await res.json(); + /* 管理员显示特殊节点面板(普通用户始终普通文本) */ + if (current_user.login && current_user.is_admin) { + sp_panel.style.display = ''; + } renderUserPanel(); } catch (e) { console.error('获取用户信息失败:', e); diff --git a/static/public/js/register.js b/static/public/js/register.js index 298a93c..f022257 100644 --- a/static/public/js/register.js +++ b/static/public/js/register.js @@ -7,11 +7,45 @@ const repeatPasswordInput = document.getElementById('repwd'); const emlTip = document.getElementById('emlTip'); const pwdTip = document.getElementById('pwdTip'); const codeTip = document.getElementById('codeTip'); +const captchaImg = document.getElementById('captchaImg'); +const captchaInput = document.getElementById('captchaAnswer'); +const captchaTip = document.getElementById('captchaTip'); const submitButton = document.getElementById('formsub'); let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手) let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐) +/* ── 图形验证码(gateway 代理 captcha 服务:GET /captcha/image) ── */ +let captchaToken = ''; +let captchaLoading = false; // 防重复点击:加载中忽略再次触发 + +async function refreshCaptcha() { + if (captchaLoading) return; + captchaLoading = true; + captchaToken = ''; + try { + const res = await fetch('/captcha/image', { cache: 'no-store' }); + if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试 + captchaImg.removeAttribute('src'); + captchaImg.alt = '验证码加载被安全规则拦截,点击重试'; + return; + } + if (!res.ok) throw new Error('captcha load failed'); + captchaToken = res.headers.get('X-Captcha-Token') || ''; + const blob = await res.blob(); + if (captchaImg.src && captchaImg.src.startsWith('blob:')) URL.revokeObjectURL(captchaImg.src); + captchaImg.src = URL.createObjectURL(blob); + captchaImg.alt = '点击刷新'; + } catch (e) { + captchaImg.removeAttribute('src'); + captchaImg.alt = '加载失败,点击重试'; + } finally { + captchaLoading = false; + } +} +captchaImg.addEventListener('click', refreshCaptcha); +refreshCaptcha(); + // 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却 function startResendCooldown(seconds) { clearInterval(cooldownTimer); @@ -34,16 +68,20 @@ function startResendCooldown(seconds) { form.addEventListener('reset', function () { clearTipStyle(); codeTarget = ''; + captchaInput.value = ''; + refreshCaptcha(); }); function clearTipStyle() { emlTip.innerHTML = ''; pwdTip.innerHTML = ''; codeTip.innerHTML = ''; + captchaTip.innerHTML = ''; emlTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); pwdTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); } clearTipStyle(); @@ -101,6 +139,23 @@ function checkCode() { return code; } +function checkCaptcha() { + captchaTip.innerHTML = ''; + captchaTip.classList.remove('tip-red', 'tip-yellow', 'tip-green'); + const v = captchaInput.value.trim(); + if (!v) { + captchaTip.textContent = '请输入图形验证码!'; + captchaTip.classList.add('tip-red'); + return false; + } + if (!captchaToken) { + captchaTip.textContent = '图形验证码未加载,请点击图片刷新'; + captchaTip.classList.add('tip-red'); + return false; + } + return v; +} + passwordInput.addEventListener('focus', checkPassword); repeatPasswordInput.addEventListener('focus', checkPassword); @@ -126,9 +181,9 @@ codeInput.addEventListener('blur', checkCode); getCodeButton.addEventListener('click', async function () { if (getCodeButton.disabled) return; // 防重复点击 const email = checkEmail(); - if (!email) { - return; - } + if (!email) return; + const captchaAnswer = checkCaptcha(); + if (!captchaAnswer) return; getCodeButton.disabled = true; codeTip.textContent = '验证码发送中…'; @@ -139,7 +194,11 @@ getCodeButton.addEventListener('click', async function () { const res = await fetch('/register/send_code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }) + body: JSON.stringify({ + email, + captcha_token: captchaToken, + captcha_answer: captchaAnswer + }) }); httpStatus = res.status; const responseText = await res.text(); @@ -160,6 +219,9 @@ getCodeButton.addEventListener('click', async function () { codeTip.classList.add('tip-red'); if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐 } finally { + // CAPTCHA Token 一次性消费,无论成败都刷新 + captchaInput.value = ''; + refreshCaptcha(); if (!cooldownTimer) getCodeButton.disabled = false; } }); diff --git a/static/public/register.html b/static/public/register.html index a9c0241..472ad5d 100644 --- a/static/public/register.html +++ b/static/public/register.html @@ -13,12 +13,17 @@
-
验证码: + +
图形验证码: + + 加载中… + +
+
邮箱验证码:
- @@ -36,6 +41,6 @@ 蜀ICP备2024107064号-2 - + diff --git a/waf/README.md b/waf/README.md index 803dcd0..8a0c9e2 100644 --- a/waf/README.md +++ b/waf/README.md @@ -248,29 +248,94 @@ kill -HUP $(pgrep waf) ## 请求处理流程 -[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`: +[proxy.go:81-172](internal/proxy/proxy.go#L81) `ServeHTTP`: 1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host) 2. `ProcessRequestHeaders` → 命中即 `block` 返回 403 -3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block +3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block 4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway -5. 捕获响应头 → `ProcessResponseHeaders` +5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截) 6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头) +7. defer 中确保 `r.Body` 耗尽并关闭,保证 TCP 连接复用 + +### 请求体大小限制 + +WAF 在代码层面对请求体实施 **10MB 硬限制**([proxy.go#L19-L22](internal/proxy/proxy.go#L19-L22) `maxRequestBodySize`),与 `waf.conf` 中的 `SecRequestBodyLimit 10485760` 保持一致: + +- 使用 `io.LimitReader` + 多读 1 字节检测超限 +- 超限直接返回 `413 Request Entity Too Large`,不经过 Coraza 规则引擎 +- 读取错误(客户端断开)记录日志后直接返回,不继续处理 +- 从根本上防止超大请求体导致 OOM 崩溃 + +### HTTP 服务器超时防护 + +[main.go#L111-L122](main.go#L111) 为 `http.Server` 配置了多层超时,防御 Slowloris 等慢连接 DoS 攻击: + +| 参数 | 值 | 作用 | +|------|-----|------| +| `ReadHeaderTimeout` | 10s | 读取请求头超时,**直接防御 Slowloris** | +| `ReadTimeout` | 15s | 读取整个请求(含 body)超时,防慢 POST | +| `WriteTimeout` | 30s | 写入响应超时 | +| `IdleTimeout` | 120s | keep-alive 空闲连接超时 | +| `MaxHeaderBytes` | 1MB | 请求头大小限制 | Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。 ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。 +## WebSocket 代理支持 + +WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。实现要点: + +- `responseRecorder` 实现了 `http.Hijacker` 接口,将 Hijack 调用委托给底层 `http.ResponseWriter` +- WebSocket 握手阶段(HTTP Upgrade 请求/响应)仍然经过 WAF 规则检测(请求头 phase:1) +- 握手成功(101 Switching Protocols)后,连接被 Hijack,由 `httputil.ReverseProxy` 自动进行双向字节流转发 +- 升级后连接转为二进制帧协议,跳过 HTTP 响应阶段的 WAF 检测,记录 Info 日志 +- WebSocket 帧内容不做 WAF 检测(帧是二进制双向流,Coraza 规则引擎基于 HTTP 请求/响应模型) +- `http.Server` 的超时设置不影响 Hijack 后的 WebSocket 长连接(Hijack 后连接脱离 Server 超时管理) +- 安全响应头(CSP/HSTS 等)不注入 WebSocket 升级响应(101 响应由协议协商决定,注入非标准头可能导致客户端兼容问题) + +配置要求: +- 无特殊配置,WebSocket 连接(`Upgrade: websocket`)自动识别并代理 +- 需要上游服务正确处理 WebSocket 升级(如 aibox 监听 ws:// 端口) + ## 已知限制 -- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。 -- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 +- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。 +- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。 +- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。 - 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。 -- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。 - TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。 -- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。 +- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。 - CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。 - HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。 +- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。 +- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。 +- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。 +- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。 + +## 版本日志 + +### v1.2(2026-08-01)— WebSocket 支持 + 修复 + +- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口 +- WebSocket 握手阶段仍经过 WAF 请求头检测,升级后自动双向转发 +- WebSocket 升级成功记录 Info 日志,便于追踪 +- **fix(P2-3)**: `ProcessResponseHeaders` 协议版本从硬编码 `"HTTP/1.1"` 改为 `r.Proto`,支持 HTTP/2 +- **fix(P2-4)**: responseRecorder 添加 Write 方法确保 WriteHeader 在首次 Write 前调用 +- 补充 `bufio`/`net` import 用于 Hijacker 实现 + +### v1.1(2026-08-01)— 安全加固 + +- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413 +- **P0 修复:Slowloris DoS 防护**:`http.Server` 添加 ReadHeaderTimeout(10s)/ReadTimeout(15s)/WriteTimeout(30s)/IdleTimeout(120s)/MaxHeaderBytes(1MB) +- **P1 修复:请求体读取错误处理**:`io.ReadAll` 失败时记录日志并返回,不再传递出错 body 给 ReverseProxy;`ReadRequestBodyFrom`/`ProcessRequestBody` 错误均记录日志 +- **P2 修复:r.Body 连接复用**:defer 中耗尽并关闭 r.Body,确保 TCP 连接可复用 +- 413 响应同样注入安全响应头 + +### v1.0 + +- 初始版本:TLS 终止 + Coraza WAF 规则匹配 + 反向代理 + SIGHUP 热加载 + 异步日志推送 + 安全响应头注入 ## 依赖 diff --git a/waf/docs/SECURITY_AUDIT_20260801.md b/waf/docs/SECURITY_AUDIT_20260801.md new file mode 100644 index 0000000..f465221 --- /dev/null +++ b/waf/docs/SECURITY_AUDIT_20260801.md @@ -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 配置化等 diff --git a/waf/internal/logclient/logclient.go b/waf/internal/logclient/logclient.go index 3dcb844..87854bf 100644 --- a/waf/internal/logclient/logclient.go +++ b/waf/internal/logclient/logclient.go @@ -25,6 +25,8 @@ const ( batchSize = 50 flushInterval = 2 * time.Second socketTimeout = 5 * time.Second + // 消息大小安全上限(64 MiB),与 Python / C++ 端保持一致。 + maxMsgSize = 64 * 1024 * 1024 ) // Entry 单条日志。 @@ -247,6 +249,9 @@ func pymsgCall(payload []byte) ([]byte, error) { return nil, err } respLen := binary.BigEndian.Uint32(header) + if respLen > maxMsgSize { + return nil, fmt.Errorf("response too large: %d bytes (max %d)", respLen, maxMsgSize) + } resp := make([]byte, respLen) if _, err := io.ReadFull(conn, resp); err != nil { return nil, err diff --git a/waf/internal/proxy/proxy.go b/waf/internal/proxy/proxy.go index bacd0a4..eb49bf6 100644 --- a/waf/internal/proxy/proxy.go +++ b/waf/internal/proxy/proxy.go @@ -2,9 +2,11 @@ package proxy import ( + "bufio" "bytes" "fmt" "io" + "net" "net/http" "net/http/httputil" "net/url" @@ -16,6 +18,10 @@ import ( "github.com/micro_servers/waf/internal/logclient" ) +// maxRequestBodySize 请求体最大大小(10MB),与 rules/waf.conf 中 SecRequestBodyLimit 保持一致。 +// 超过此限制的请求直接返回 413 Request Entity Too Large,防止 OOM。 +const maxRequestBodySize = 10 * 1024 * 1024 + // Proxy 持有 ReverseProxy 与 WAF 引擎引用。 type Proxy struct { rp *httputil.ReverseProxy @@ -84,6 +90,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { defer func() { tx.ProcessLogging() _ = tx.Close() + // 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用 + if r.Body != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024)) + _ = r.Body.Close() + } }() // 解析 RemoteAddr 中的 IP 与端口 @@ -111,14 +122,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发 + // 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM if r.Body != nil && r.Body != http.NoBody { - bodyBytes, err := io.ReadAll(r.Body) - if err == nil { - _, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)) - r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + // 限制读取大小:多读 1 字节,若能读到说明超限 + limitReader := io.LimitReader(r.Body, maxRequestBodySize+1) + bodyBytes, err := io.ReadAll(limitReader) + if err != nil { + // 客户端中途断开或读取错误,直接返回无需继续处理 + logclient.Info("waf", fmt.Sprintf("body_read_error path=%s err=%v", r.URL.Path, err), + map[string]any{"ip": r.RemoteAddr}) + return } - if it, _ := tx.ProcessRequestBody(); it != nil { + if len(bodyBytes) > maxRequestBodySize { + // 请求体超限,返回 413 + logclient.Warning("waf", fmt.Sprintf("body_too_large %s %s size=%d", r.Method, r.URL.Path, len(bodyBytes)), + map[string]any{ + "ip": r.RemoteAddr, + "method": r.Method, + "path": r.URL.Path, + "size": len(bodyBytes), + "limit": maxRequestBodySize, + "cost_ms": time.Since(start).Milliseconds(), + }) + setSecurityHeaders(w.Header()) + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write([]byte("request body too large\n")) + return + } + // 喂给 coraza 进行请求体检测 + if _, _, err := tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)); err != nil { + logclient.Warning("waf", fmt.Sprintf("body_parse_error path=%s err=%v", r.URL.Path, err), + map[string]any{"ip": r.RemoteAddr}) + } + // 恢复 r.Body 供 ReverseProxy 转发 + r.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + it, err := tx.ProcessRequestBody() + if err != nil { + logclient.Warning("waf", fmt.Sprintf("body_process_error path=%s err=%v", r.URL.Path, err), + map[string]any{"ip": r.RemoteAddr}) + } + if it != nil { p.block(w, r, it.RuleID, start) return } @@ -128,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { rw := &responseRecorder{ResponseWriter: w, status: 200} p.rp.ServeHTTP(rw, r) + // WebSocket 升级成功后连接已被 Hijack,转为二进制帧协议,无需执行 HTTP 响应阶段检测 + if rw.hijacked { + logclient.Info("waf", fmt.Sprintf("websocket_upgraded %s %s cost_ms=%d", + r.Method, r.URL.Path, time.Since(start).Milliseconds()), + map[string]any{ + "ip": r.RemoteAddr, + "method": r.Method, + "path": r.URL.Path, + "cost_ms": time.Since(start).Milliseconds(), + }) + return + } + // 响应阶段:把 upstream 的 header 喂给 WAF for k, vs := range rw.headers { for _, v := range vs { tx.AddResponseHeader(k, v) } } - tx.ProcessResponseHeaders(rw.status, "HTTP/1.1") + tx.ProcessResponseHeaders(rw.status, r.Proto) } // block 命中规则时统一返回 403,并将关键输入推送到日志库。 @@ -155,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start } // responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。 +// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。 type responseRecorder struct { http.ResponseWriter - status int - headers http.Header + status int + statusSent bool + headers http.Header + hijacked bool } func (r *responseRecorder) WriteHeader(code int) { + if r.hijacked { + return + } r.status = code + r.statusSent = true if r.headers == nil { r.headers = http.Header{} } - // 拷贝上游响应头 + // 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定) for k, vs := range r.Header() { r.headers[k] = vs } r.ResponseWriter.WriteHeader(code) } +func (r *responseRecorder) Write(b []byte) (int, error) { + if !r.statusSent { + r.WriteHeader(http.StatusOK) + } + return r.ResponseWriter.Write(b) +} + +// Flush 实现 http.Flusher,支持 SSE/流式响应。 func (r *responseRecorder) Flush() { if f, ok := r.ResponseWriter.(http.Flusher); ok { f.Flush() } } + +// Hijack 实现 http.Hijacker,支持 WebSocket 升级。 +// 委托给底层 ResponseWriter 的 Hijack 方法,并标记连接已被劫持。 +func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { + hijacker, ok := r.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijacker") + } + conn, rw, err := hijacker.Hijack() + if err == nil { + r.hijacked = true + // WebSocket 升级响应是 101 Switching Protocols + r.status = http.StatusSwitchingProtocols + r.statusSent = true + } + return conn, rw, err +} diff --git a/waf/main.go b/waf/main.go index 6919d08..9e7fea7 100644 --- a/waf/main.go +++ b/waf/main.go @@ -22,6 +22,7 @@ import ( "os" "os/signal" "syscall" + "time" "gopkg.in/yaml.v3" @@ -108,8 +109,13 @@ func main() { }() server := &http.Server{ - Addr: cfg.Listen, - Handler: p, + Addr: cfg.Listen, + Handler: p, + ReadTimeout: 15 * time.Second, + ReadHeaderTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + MaxHeaderBytes: 1 << 20, // 1MB TLSConfig: &tls.Config{ MinVersion: tls.VersionTLS12, },