Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e007990594 | ||
|
|
b79044d631 | ||
|
|
81ae84851d | ||
|
|
4e190953c0 | ||
|
|
b4436b192a |
No files matched your search
+4
-2
@@ -60,7 +60,9 @@ BotOps/
|
|||||||
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
|
- 发送:`{"type":"message","content":...}` / `{"type":"command","command":"/..."}` / `{"type":"interrupt",...}`
|
||||||
- 接收:
|
- 接收:
|
||||||
- `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源
|
- `chunk`(**增量片段**):服务端 `chunk_buffer` 发送后即清零,前端直接 `append_ai` 拼接,为内容权威来源
|
||||||
|
- `reasoning_start`(**思考开始**):创建灰色斜体思考区域,实时展示 AI 思考过程
|
||||||
|
- `reasoning`(**思考流**):思考内容增量片段,追加到思考区域(超长自动折叠显示最后 2000 字符)
|
||||||
|
- `reasoning_end`(**思考结束**):思考结束,切换到正式回答
|
||||||
- `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染)
|
- `complete`(**收尾信号**):chunk 流式已完整渲染,complete 仅用于收尾;仅在完全丢帧(未收到任何 chunk)时用 full_text 补建消息,**不再做全文替换对齐**(避免服务端递归调用工具后 complete 只含最后一轮内容导致重复渲染)
|
||||||
- `line`(过程行):🔧 工具调用提示、工具输出、💬 助手前缀,路由到任务侧栏;助手前缀/思考提示在聊天区已由 AI 消息框承载,不重复展示
|
- `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`
|
- `dialogue_ended` / `dialogue_reset` / `task_added` / `task_process` / `task_status` / `task_error` / `interrupt_ack` / `error` / `info` / `command_result` / `connection_ack` / `notification` / `pong`
|
||||||
+20
-7
@@ -58,6 +58,13 @@ class BotOpsApp(App):
|
|||||||
color: yellow;
|
color: yellow;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
}
|
}
|
||||||
|
.msg-reasoning {
|
||||||
|
color: $text-muted;
|
||||||
|
background: $surface;
|
||||||
|
padding: 0 1;
|
||||||
|
margin: 0 1;
|
||||||
|
text-style: italic;
|
||||||
|
}
|
||||||
#status {
|
#status {
|
||||||
height: 1;
|
height: 1;
|
||||||
}
|
}
|
||||||
@@ -163,21 +170,27 @@ class BotOpsApp(App):
|
|||||||
if t == "chunk":
|
if t == "chunk":
|
||||||
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
# 服务端 chunk 为增量片段(发送后即清零),直接拼接
|
||||||
await self.chat.append_ai(content)
|
await self.chat.append_ai(content)
|
||||||
|
elif t == "reasoning_start":
|
||||||
|
# 开始深度思考,创建思考区域
|
||||||
|
await self.chat.start_reasoning(content or "🤔 深度思考中...")
|
||||||
|
elif t == "reasoning":
|
||||||
|
# 思考内容增量
|
||||||
|
stripped = content
|
||||||
|
if stripped:
|
||||||
|
await self.chat.append_reasoning(stripped)
|
||||||
|
elif t == "reasoning_end":
|
||||||
|
# 思考结束
|
||||||
|
await self.chat.end_reasoning()
|
||||||
elif t == "line":
|
elif t == "line":
|
||||||
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
# 过程行(服务端 output_callback 原样透传):工具调用提示 / 工具输出 / 前缀
|
||||||
stripped = content.strip()
|
stripped = content.strip()
|
||||||
if not stripped:
|
if not stripped:
|
||||||
pass
|
pass
|
||||||
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking")):
|
elif stripped.startswith(("💬 助手", "💬 Assistant", "🤔 思考中", "🤔 Thinking", "🤔 深度思考中")):
|
||||||
pass # 助手前缀 / 思考提示:聊天区已有 AI 消息框,无需展示
|
pass # 助手前缀 / 思考提示:聊天区已有对应区域,无需展示
|
||||||
else:
|
else:
|
||||||
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
|
# 🔧 调用工具 / 工具输出 / 🤔 分析工具结果 等 → 任务侧栏
|
||||||
self.tasks.write(stripped)
|
self.tasks.write(stripped)
|
||||||
elif t == "reasoning":
|
|
||||||
# 思考流(服务端 debug_level>=3 才发送),记入任务侧栏
|
|
||||||
stripped = content.strip()
|
|
||||||
if stripped:
|
|
||||||
self.tasks.write(f"💭 {stripped[-200:]}")
|
|
||||||
elif t == "complete":
|
elif t == "complete":
|
||||||
# complete 携带完整全文,用于对齐已显示内容
|
# complete 携带完整全文,用于对齐已显示内容
|
||||||
await self.chat.finish_ai(content)
|
await self.chat.finish_ai(content)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
|
"""聊天视图:用户/AI/系统消息 + AI 回复流式 Markdown 渲染。
|
||||||
|
|
||||||
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
|
服务端协议(aibox/aibox.py output_callback 原样透传 msg_type):
|
||||||
- chunk 增量片段(服务端 chunk_buffer 发送后即清零),直接 append
|
- chunk 增量片段(正式回答内容),直接 append 到 AI 消息
|
||||||
|
- reasoning_start 思考开始信号,创建思考区域
|
||||||
|
- reasoning 思考内容增量片段,追加到思考区域
|
||||||
|
- reasoning_end 思考结束信号
|
||||||
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
|
- complete 收尾信号:chunk 流式是内容权威来源,complete 仅用于收尾;
|
||||||
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
|
仅当流式完全丢帧(未建 widget 且无内容)时,用 full_text 补建
|
||||||
"""
|
"""
|
||||||
@@ -20,7 +23,9 @@ class ChatView(VerticalScroll):
|
|||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self._ai_widget: Markdown | None = None
|
self._ai_widget: Markdown | None = None
|
||||||
|
self._reasoning_widget: Static | None = None
|
||||||
self._ai_text = ""
|
self._ai_text = ""
|
||||||
|
self._reasoning_text = ""
|
||||||
self._ai_finalized = True # 初始无活跃 AI 消息
|
self._ai_finalized = True # 初始无活跃 AI 消息
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -49,6 +54,39 @@ class ChatView(VerticalScroll):
|
|||||||
await self.mount(self._ai_widget)
|
await self.mount(self._ai_widget)
|
||||||
self._anchor_bottom()
|
self._anchor_bottom()
|
||||||
|
|
||||||
|
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
|
||||||
|
# 限制思考内容显示长度(最多显示最后 2000 字符,避免思考过长占满屏幕)
|
||||||
|
display = self._reasoning_text
|
||||||
|
if len(display) > 2000:
|
||||||
|
display = "...(前略)\n" + display[-2000:]
|
||||||
|
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) > 2000:
|
||||||
|
display = "...(前略)\n" + display[-2000:]
|
||||||
|
self._reasoning_widget.update(f"💭 思考过程({len(self._reasoning_text)}字符)\n{display}\n{'─' * 30}")
|
||||||
|
# 思考区域保留显示,不删除
|
||||||
|
|
||||||
async def append_ai(self, delta: str):
|
async def append_ai(self, delta: str):
|
||||||
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
|
"""追加增量片段(服务端 chunk 为增量语义,直接拼接)。"""
|
||||||
if not delta:
|
if not delta:
|
||||||
@@ -97,12 +135,16 @@ class ChatView(VerticalScroll):
|
|||||||
self._anchor_bottom()
|
self._anchor_bottom()
|
||||||
|
|
||||||
self._ai_text = ""
|
self._ai_text = ""
|
||||||
|
self._reasoning_widget = None
|
||||||
|
self._reasoning_text = ""
|
||||||
self._ai_finalized = True
|
self._ai_finalized = True
|
||||||
self.call_after_refresh(self.scroll_end, animate=False)
|
self.call_after_refresh(self.scroll_end, animate=False)
|
||||||
|
|
||||||
async def reset(self):
|
async def reset(self):
|
||||||
"""清空聊天区(/clear)。"""
|
"""清空聊天区(/clear)。"""
|
||||||
self._ai_widget = None
|
self._ai_widget = None
|
||||||
|
self._reasoning_widget = None
|
||||||
self._ai_text = ""
|
self._ai_text = ""
|
||||||
|
self._reasoning_text = ""
|
||||||
self._ai_finalized = True
|
self._ai_finalized = True
|
||||||
await self.remove_children()
|
await self.remove_children()
|
||||||
+19
-4
@@ -215,9 +215,11 @@ const ws = new WebSocket('ws://localhost:8765');
|
|||||||
| 类型 | 说明 |
|
| 类型 | 说明 |
|
||||||
|-----|------|
|
|-----|------|
|
||||||
| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
|
| `chunk` | AI 响应的**增量片段**(流式输出,chunk_buffer 发送后即清零,为内容权威来源) |
|
||||||
|
| `reasoning_start` | AI 开始思考信号(思考模式,创建思考区域展示) |
|
||||||
|
| `reasoning` | AI 思考过程增量片段(流式输出,灰色/斜体样式) |
|
||||||
|
| `reasoning_end` | AI 思考结束信号(切换到正式回答) |
|
||||||
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
|
| `complete` | AI 响应**收尾信号**(chunk 流式是内容权威来源,complete 仅用于收尾;完全丢帧时可用 full_text 补建) |
|
||||||
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀、思考提示等,供任务侧栏/终端显示) |
|
| `line` | 过程行(🔧 工具调用提示、工具输出、💬 助手前缀等,供任务侧栏/终端显示) |
|
||||||
| `reasoning` | AI 思考过程(思考模式,仅 debug_level≥3 发送) |
|
|
||||||
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
|
| `error` | 错误信息(API 请求失败、解析失败等;服务端自动回滚本轮消息防止对话历史被污染) |
|
||||||
| `command_result` | 命令执行结果 |
|
| `command_result` | 命令执行结果 |
|
||||||
| `task_status` | 任务状态更新(executing/complete/failed) |
|
| `task_status` | 任务状态更新(executing/complete/failed) |
|
||||||
@@ -427,8 +429,9 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
|||||||
"api": {
|
"api": {
|
||||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||||
"deepseek_model": "deepseek-v4-flash",
|
"deepseek_model": "deepseek-v4-flash",
|
||||||
|
"reasoning_enabled": false,
|
||||||
"api_key_env": "DEEPSEEK_API_KEY",
|
"api_key_env": "DEEPSEEK_API_KEY",
|
||||||
"timeout": 30,
|
"timeout": 120,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
},
|
},
|
||||||
"context": {
|
"context": {
|
||||||
@@ -492,7 +495,9 @@ 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` 文件)。
|
||||||
|
|
||||||
## 🛠️ 命令行参数
|
## 🛠️ 命令行参数
|
||||||
|
|
||||||
@@ -701,6 +706,16 @@ elif command == "/mycommand":
|
|||||||
|
|
||||||
## 📄 更新日志
|
## 📄 更新日志
|
||||||
|
|
||||||
|
### 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
|
### v2.4
|
||||||
- ✅ **新增专用工作目录规范**:
|
- ✅ **新增专用工作目录规范**:
|
||||||
- AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录
|
- AI 主动创建的临时文件、脚本、数据文件默认写入 `~/.aibox/workspace/`,避免污染系统其他目录
|
||||||
|
|||||||
+39
-19
@@ -1168,8 +1168,9 @@ class ConfigManager:
|
|||||||
"api": {
|
"api": {
|
||||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||||
"deepseek_model": "deepseek-v4-flash",
|
"deepseek_model": "deepseek-v4-flash",
|
||||||
|
"reasoning_enabled": false,
|
||||||
"api_key_env": "DEEPSEEK_API_KEY",
|
"api_key_env": "DEEPSEEK_API_KEY",
|
||||||
"timeout": 30,
|
"timeout": 120,
|
||||||
"temperature": 0.7
|
"temperature": 0.7
|
||||||
},
|
},
|
||||||
"context": {
|
"context": {
|
||||||
@@ -1356,7 +1357,11 @@ class Constants:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def DEEPSEEK_MODEL(cls):
|
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
|
@property
|
||||||
def DEFAULT_TIMEOUT(cls):
|
def DEFAULT_TIMEOUT(cls):
|
||||||
@@ -7169,11 +7174,13 @@ class DeepSeekChat:
|
|||||||
"stream": True
|
"stream": True
|
||||||
}
|
}
|
||||||
|
|
||||||
# 可选:添加 thinking 模式配置(如果有)
|
# 思考模式:启用 thinking 参数
|
||||||
|
if constants.REASONING_ENABLED:
|
||||||
payload["thinking"] = {
|
payload["thinking"] = {
|
||||||
"type": "enabled"
|
"type": "enabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 工具调用(v4-flash 支持思考+工具调用)
|
||||||
tools = self.tool_registry.get_tools_schemas()
|
tools = self.tool_registry.get_tools_schemas()
|
||||||
if tools:
|
if tools:
|
||||||
payload["tools"] = tools
|
payload["tools"] = tools
|
||||||
@@ -7182,10 +7189,16 @@ class DeepSeekChat:
|
|||||||
current_time = datetime.now().strftime("%H:%M:%S")
|
current_time = datetime.now().strftime("%H:%M:%S")
|
||||||
if output_callback:
|
if output_callback:
|
||||||
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
|
output_callback("line", f"\n{i18n.get('thinking', current_time)}\n")
|
||||||
|
if constants.REASONING_ENABLED:
|
||||||
|
output_callback("reasoning_start", "🤔 思考中...")
|
||||||
|
else:
|
||||||
|
# 普通模式先显示助手前缀
|
||||||
output_callback("line", i18n.get('assistant_prefix'))
|
output_callback("line", i18n.get('assistant_prefix'))
|
||||||
|
|
||||||
full_response = ""
|
full_response = ""
|
||||||
full_reasoning = "" # 存储思考内容
|
full_reasoning = "" # 存储思考内容
|
||||||
|
reasoning_started = False
|
||||||
|
reasoning_ended = False
|
||||||
tool_calls_buffer = []
|
tool_calls_buffer = []
|
||||||
current_tool_calls = {}
|
current_tool_calls = {}
|
||||||
|
|
||||||
@@ -7236,10 +7249,25 @@ class DeepSeekChat:
|
|||||||
if 'reasoning_content' in delta and delta['reasoning_content']:
|
if 'reasoning_content' in delta and delta['reasoning_content']:
|
||||||
reasoning = delta['reasoning_content']
|
reasoning = delta['reasoning_content']
|
||||||
full_reasoning += reasoning
|
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)
|
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']:
|
if 'content' in delta and delta['content']:
|
||||||
content = delta['content']
|
content = delta['content']
|
||||||
@@ -7290,7 +7318,12 @@ class DeepSeekChat:
|
|||||||
# 处理工具调用
|
# 处理工具调用
|
||||||
if current_tool_calls:
|
if current_tool_calls:
|
||||||
tool_calls_buffer = list(current_tool_calls.values())
|
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")
|
output_callback("line", i18n.get('calling_tools') + "\n")
|
||||||
|
|
||||||
# 【关键修复1】先添加助手消息,包含 tool_calls
|
# 【关键修复1】先添加助手消息,包含 tool_calls
|
||||||
@@ -7388,19 +7421,6 @@ class DeepSeekChat:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"消息回滚失败: {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):
|
def reset_conversation(self, reload_prompts: bool = True):
|
||||||
"""
|
"""
|
||||||
重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词
|
重置对话状态 - 清除所有对话记忆,可选择是否重新加载系统提示词
|
||||||
|
|||||||
+9
-2
@@ -236,6 +236,7 @@ except RpcError as e:
|
|||||||
| 服务 | 配置文件 | 接入方式 |
|
| 服务 | 配置文件 | 接入方式 |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| [static](../static/) | [static.ini](configs/static.ini) | 三层 fail-safe:内置默认值 → confs(优先)→ 本地 `config.ini` 回退 |
|
| [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):
|
static 的接入实现见 [static/fsys.py](../static/fsys.py):
|
||||||
- `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时
|
- `_fetch_from_confs()` 通过 `RpcClient` 调 `get` 路由取 `static.ini`,3 秒超时
|
||||||
@@ -243,7 +244,12 @@ static 的接入实现见 [static/fsys.py](../static/fsys.py):
|
|||||||
- `_merge_ini()` 仅覆盖已知的 section/key(白名单合并,不引入未预期字段)
|
- `_merge_ini()` 仅覆盖已知的 section/key(白名单合并,不引入未预期字段)
|
||||||
- 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层
|
- 配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层
|
||||||
|
|
||||||
其他服务(gateway / waf / ai_review / smtp / dnfs / db)尚未接入,仍读各自本地配置。
|
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)尚未接入,仍读各自本地配置。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
@@ -253,7 +259,8 @@ confs/
|
|||||||
├── log_client.py # 统一日志客户端 SDK(与其他服务一致的副本)
|
├── log_client.py # 统一日志客户端 SDK(与其他服务一致的副本)
|
||||||
├── configs/ # 配置文件存放目录(被分发对象)
|
├── configs/ # 配置文件存放目录(被分发对象)
|
||||||
│ ├── example.ini # 示例配置
|
│ ├── example.ini # 示例配置
|
||||||
│ └── static.ini # static 服务配置(已接入分发)
|
│ ├── static.ini # static 服务配置(已接入分发)
|
||||||
|
│ └── dnfs.ini # dnfs 服务配置(已接入分发)
|
||||||
├── docs/
|
├── docs/
|
||||||
│ └── SECURITY_AUDIT_20260801.md # 安全审计报告
|
│ └── SECURITY_AUDIT_20260801.md # 安全审计报告
|
||||||
├── _set.sh # 初始化脚本(建 venv + 安装依赖)
|
├── _set.sh # 初始化脚本(建 venv + 安装依赖)
|
||||||
|
|||||||
@@ -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
|
||||||
+54
-9
@@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点
|
|||||||
|
|
||||||
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
|
监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。
|
||||||
|
|
||||||
|
支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -44,9 +46,26 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
|
|||||||
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||||
- `delete_note` 与 `update_note` 校验所有权:
|
- `delete_note` 与 `update_note` 校验所有权:
|
||||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||||
- **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。
|
- **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。
|
||||||
- `get_notes` 不需要鉴权。
|
- `get_notes` 不需要鉴权。
|
||||||
|
|
||||||
|
## 特殊节点(管理员专属)
|
||||||
|
|
||||||
|
`content` 为以下信封 JSON 时即为特殊节点,前端 `dnfs.js` 按 `_sp` 类型差异化渲染;普通 Markdown 内容不受影响:
|
||||||
|
|
||||||
|
| `_sp` | 信封字段 | 前端表现 |
|
||||||
|
|-------|---------|---------|
|
||||||
|
| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 |
|
||||||
|
| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 |
|
||||||
|
| `image` | `name`、`data`(`data:image/` DataURL,≤3.5M 字符)、`note?` | 图片直接显示 |
|
||||||
|
| `file` | `name`、`mime`、`data`(Base64,≤3.5M 字符)、`note?` | 下载按钮(Base64→Blob→下载) |
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- **仅管理员可发布/更新**(`add_note` / `update_note` 中按 `is_admin` 强制,非管理员返回 403);前端发布面板也仅对管理员显示。
|
||||||
|
- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`html` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。
|
||||||
|
- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。
|
||||||
|
|
||||||
## 接口文档
|
## 接口文档
|
||||||
|
|
||||||
所有接口均为 `POST`,`Content-Type: application/json`。
|
所有接口均为 `POST`,`Content-Type: application/json`。
|
||||||
@@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \
|
|||||||
|
|
||||||
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。
|
||||||
|
|
||||||
仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。
|
仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。
|
||||||
|
|
||||||
**Request**
|
**Request**
|
||||||
|
|
||||||
@@ -196,15 +215,41 @@ curl -X POST http://localhost:8006/update_note \
|
|||||||
|
|
||||||
## 配置
|
## 配置
|
||||||
|
|
||||||
服务地址与上游依赖硬编码在 [dnfs.py](dnfs.py):
|
dnfs 已接入 confs 统一配置服务,采用**三层 fail-safe** 加载模式(与 static 服务一致):
|
||||||
|
|
||||||
| 常量 | 值 | 说明 |
|
**加载顺序**:内置默认值 → confs 统一配置服务(dnfs.ini,优先)→ 环境变量覆盖(最高优先级)
|
||||||
|------|----|------|
|
|
||||||
| `ADDR` / `PORT` | `127.0.0.1` / `8006` | 监听地址 |
|
|
||||||
| `DB_SERVER_URL` | `http://127.0.0.1:8005/execute` | db 服务 |
|
|
||||||
| `REVIEW_SERVER_URL` | `http://127.0.0.1:8007/review` | ai_review 服务 |
|
|
||||||
|
|
||||||
调用超时均为 10 秒。
|
### 配置文件
|
||||||
|
|
||||||
|
集中托管在 [confs/configs/dnfs.ini](../confs/configs/dnfs.ini),由 confs 服务分发:
|
||||||
|
|
||||||
|
| Section | Key | 默认值 | 说明 |
|
||||||
|
|---------|-----|--------|------|
|
||||||
|
| `[server]` | `host` | `127.0.0.1` | 监听地址 |
|
||||||
|
| `[server]` | `port` | `8006` | 监听端口 |
|
||||||
|
| `[database]` | `host` | `127.0.0.1` | db 服务地址 |
|
||||||
|
| `[database]` | `port` | `8005` | db 服务端口 |
|
||||||
|
| `[database]` | `timeout` | `10` | RPC 调用超时(秒) |
|
||||||
|
| `[ai_review]` | `url` | `http://127.0.0.1:8007/review` | AI 审核接口地址 |
|
||||||
|
| `[ai_review]` | `timeout` | `10` | AI 审核请求超时(秒) |
|
||||||
|
| `[confs]` | `host` | `127.0.0.1` | confs 服务地址 |
|
||||||
|
| `[confs]` | `port` | `8010` | confs 服务端口 |
|
||||||
|
|
||||||
|
### 环境变量(Docker 环境必需,优先级最高)
|
||||||
|
|
||||||
|
环境变量覆盖配置文件值,容器化部署时通过 docker-compose.yml 传入服务名:
|
||||||
|
|
||||||
|
| 变量 | 容器内值 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `LISTEN_ADDR` | `0.0.0.0` | 监听地址(容器内需绑定所有网卡) |
|
||||||
|
| `LISTEN_PORT` | `8006` | 监听端口 |
|
||||||
|
| `DB_HOST` | `db` | db 服务主机名(docker-compose 服务名) |
|
||||||
|
| `DB_PORT` | `8005` | db 服务端口 |
|
||||||
|
| `REVIEW_URL` | `http://ai_review:8007/review` | ai_review 服务地址 |
|
||||||
|
| `CONFS_HOST` | - | 可选,confs 容器化后使用 |
|
||||||
|
| `CONFS_PORT` | - | 可选,confs 容器化后使用 |
|
||||||
|
|
||||||
|
配置来源会记录到日志(`logs.info/warning`),便于排查实际用了哪一层。本地裸机运行时 confs 不可用会自动回退到内置默认值 + 环境变量。
|
||||||
|
|
||||||
## 前端
|
## 前端
|
||||||
|
|
||||||
|
|||||||
+179
-25
@@ -1,9 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
|
||||||
Dendrachy Network File-System (dnfs)
|
Dendrachy Network File-System (dnfs)
|
||||||
@@ -21,8 +17,10 @@ from flask_cors import CORS
|
|||||||
#from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信
|
#from flask_sqlalchemy import SQLAlchemy # 使用pymsg模块进行通信
|
||||||
from waitress import serve
|
from waitress import serve
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import json
|
import json
|
||||||
import base64
|
import base64
|
||||||
|
import configparser
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
@@ -32,14 +30,75 @@ from log_client import logs
|
|||||||
# pymsg 导入
|
# pymsg 导入
|
||||||
# 我们有在依赖列表中加入pymsg
|
# 我们有在依赖列表中加入pymsg
|
||||||
# 这个可以省略也许
|
# 这个可以省略也许
|
||||||
import sys as _sys
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||||
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
||||||
from pymsg import RpcClient, RpcError # noqa: E402
|
from pymsg import RpcClient, RpcError # noqa: E402
|
||||||
|
|
||||||
# 从环境变量中获取参数
|
# ==================== 配置加载(三层 fail-safe) ====================
|
||||||
# 随后我们专门做一个统一配置微服务
|
# 加载顺序:内置默认值 → confs 统一配置服务(dnfs.ini) → 环境变量覆盖
|
||||||
_DBV2_ADDR = os.environ.get('DB_HOST', '127.0.0.1')
|
# 环境变量优先级最高(Docker 环境使用服务名 db/ai_review 而非 127.0.0.1)
|
||||||
_DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
|
|
||||||
|
_dnfs_config = {
|
||||||
|
'server': {'host': '127.0.0.1', 'port': '8006'},
|
||||||
|
'database': {'host': '127.0.0.1', 'port': '8005', 'timeout': '10'},
|
||||||
|
'ai_review': {'url': 'http://127.0.0.1:8007/review', 'timeout': '10'},
|
||||||
|
'confs': {'host': '127.0.0.1', 'port': '8010'},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_ini(text):
|
||||||
|
"""把 ini 文本合并进 _dnfs_config(仅覆盖已知的 section/key,白名单合并)。"""
|
||||||
|
ini = configparser.ConfigParser(interpolation=None)
|
||||||
|
ini.read_string(text)
|
||||||
|
for section in _dnfs_config:
|
||||||
|
if ini.has_section(section):
|
||||||
|
for key, value in ini[section].items():
|
||||||
|
if key in _dnfs_config[section]:
|
||||||
|
_dnfs_config[section][key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_from_confs():
|
||||||
|
"""从 confs 统一配置服务拉取 dnfs.ini 全文,失败返回 None(fail-safe)。"""
|
||||||
|
try:
|
||||||
|
host = os.environ.get('CONFS_HOST', _dnfs_config['confs']['host'])
|
||||||
|
port = int(os.environ.get('CONFS_PORT', _dnfs_config['confs']['port']))
|
||||||
|
data = RpcClient(host, port, 'dnfs', timeout=3).call(
|
||||||
|
'get', {'name': 'dnfs.ini'})
|
||||||
|
return data.get('content')
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
_confs_text = _fetch_from_confs()
|
||||||
|
if _confs_text is not None:
|
||||||
|
_merge_ini(_confs_text)
|
||||||
|
logs.info("dnfs", "配置来源: confs 统一配置服务 (dnfs.ini)")
|
||||||
|
else:
|
||||||
|
logs.warning("dnfs", "confs 不可用,使用内置默认值 + 环境变量")
|
||||||
|
|
||||||
|
# 环境变量覆盖(优先级最高,Docker 环境需要)
|
||||||
|
if os.environ.get('LISTEN_ADDR'):
|
||||||
|
_dnfs_config['server']['host'] = os.environ['LISTEN_ADDR']
|
||||||
|
if os.environ.get('LISTEN_PORT'):
|
||||||
|
_dnfs_config['server']['port'] = os.environ['LISTEN_PORT']
|
||||||
|
if os.environ.get('DB_HOST'):
|
||||||
|
_dnfs_config['database']['host'] = os.environ['DB_HOST']
|
||||||
|
if os.environ.get('DB_PORT'):
|
||||||
|
_dnfs_config['database']['port'] = os.environ['DB_PORT']
|
||||||
|
if os.environ.get('REVIEW_URL'):
|
||||||
|
_dnfs_config['ai_review']['url'] = os.environ['REVIEW_URL']
|
||||||
|
if os.environ.get('CONFS_HOST'):
|
||||||
|
_dnfs_config['confs']['host'] = os.environ['CONFS_HOST']
|
||||||
|
if os.environ.get('CONFS_PORT'):
|
||||||
|
_dnfs_config['confs']['port'] = os.environ['CONFS_PORT']
|
||||||
|
|
||||||
|
# 提取配置常量
|
||||||
|
_DBV2_ADDR = _dnfs_config['database']['host']
|
||||||
|
_DBV2_PORT = int(_dnfs_config['database']['port'])
|
||||||
|
_DB_TIMEOUT = int(_dnfs_config['database']['timeout'])
|
||||||
|
_REVIEW_SERVER_URL = _dnfs_config['ai_review']['url']
|
||||||
|
_REVIEW_TIMEOUT = int(_dnfs_config['ai_review']['timeout'])
|
||||||
|
_ADDR = _dnfs_config['server']['host']
|
||||||
|
_PORT = int(_dnfs_config['server']['port'])
|
||||||
|
|
||||||
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
|
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
|
||||||
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
|
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
|
||||||
@@ -48,13 +107,15 @@ _DBV2_PORT = int(os.environ.get('DB_PORT', '8005'))
|
|||||||
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
_rpc = RpcClient(_DBV2_ADDR, _DBV2_PORT, "dnfs")
|
||||||
|
|
||||||
|
|
||||||
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
def _call_dbv2(payload: dict, timeout: int = None) -> dict:
|
||||||
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
|
"""通过 pymsg RPC 调用 dbv2 数据库服务(自动注入 ``_route: "execute"``)。
|
||||||
|
|
||||||
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
|
返回完整响应 dict(``{"success": ..., "data"/"error": ...}``),
|
||||||
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
|
与历史行为一致,调用方无需改动。服务端业务错误返回 success=False;
|
||||||
网络层故障抛 ConnectionError。
|
网络层故障抛 ConnectionError。
|
||||||
"""
|
"""
|
||||||
|
if timeout is None:
|
||||||
|
timeout = _DB_TIMEOUT
|
||||||
try:
|
try:
|
||||||
data = _rpc.call("execute", payload, timeout=timeout)
|
data = _rpc.call("execute", payload, timeout=timeout)
|
||||||
return {"success": True, "data": data}
|
return {"success": True, "data": data}
|
||||||
@@ -65,15 +126,14 @@ def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
|
|||||||
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
|
f"dbv2 调用失败 ({_DBV2_ADDR}:{_DBV2_PORT}): {e}"
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
# AI审核服务地址,从环境变量获取
|
# AI审核服务地址
|
||||||
REVIEW_SERVER_URL = os.environ.get('REVIEW_URL', 'http://127.0.0.1:8007/review')
|
def review_content(content: str, timeout: int = None) -> Dict[str, Any]:
|
||||||
def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
|
||||||
"""
|
"""
|
||||||
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
|
调用 AI 内容审核服务的 /review 接口,对给定文本进行审核。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: 待审核的文本内容,不可为空。
|
content: 待审核的文本内容,不可为空。
|
||||||
timeout: 请求超时时间(秒),默认 10 秒。
|
timeout: 请求超时时间(秒),默认使用配置值。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
字典,包含接口返回的字段,例如:
|
字典,包含接口返回的字段,例如:
|
||||||
@@ -83,7 +143,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
|
|||||||
- 请求参数错误时:{"error": "content is required"} 等
|
- 请求参数错误时:{"error": "content is required"} 等
|
||||||
- 网络或异常时:{"error": "request_failed", "detail": "..."}
|
- 网络或异常时:{"error": "request_failed", "detail": "..."}
|
||||||
"""
|
"""
|
||||||
url = REVIEW_SERVER_URL
|
if timeout is None:
|
||||||
|
timeout = _REVIEW_TIMEOUT
|
||||||
|
url = _REVIEW_SERVER_URL
|
||||||
payload = {"content": content}
|
payload = {"content": content}
|
||||||
json_data = json.dumps(payload).encode("utf-8")
|
json_data = json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
@@ -121,6 +183,82 @@ class DnfsExp(Exception):
|
|||||||
def get_status(self) -> int:
|
def get_status(self) -> int:
|
||||||
return self.status
|
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 节点送审的代码前缀长度
|
||||||
|
|
||||||
|
|
||||||
|
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类
|
# 这个也许不用这么复杂 大不了每个异常点我们抛出DnfsExp类
|
||||||
#class InvalidRequest(DnfsExp):
|
#class InvalidRequest(DnfsExp):
|
||||||
# '''非法参数异常'''
|
# '''非法参数异常'''
|
||||||
@@ -191,8 +329,15 @@ def add_note():
|
|||||||
raise DnfsExp('父节点不存在', 400)
|
raise DnfsExp('父节点不存在', 400)
|
||||||
|
|
||||||
|
|
||||||
# 内容审查
|
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
|
||||||
review = review_content(title + '\n' + content, 10)
|
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)
|
if True != review: raise DnfsExp('内容审核不通过,用户'+info.get('user')+',从'+request.remote_addr, 400)
|
||||||
|
|
||||||
# 执行插入
|
# 执行插入
|
||||||
@@ -411,8 +556,19 @@ def update_note():
|
|||||||
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
|
||||||
return jsonify({"error": 'It\'s not your note'}), 403
|
return jsonify({"error": 'It\'s not your note'}), 403
|
||||||
|
|
||||||
# 内容审查
|
# 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验
|
||||||
review = review_content(title + '\n' + content, 10)
|
sp = _parse_sp(content)
|
||||||
|
if sp is not None:
|
||||||
|
if not info.get('is_admin'):
|
||||||
|
logs.warning('dnfs', f'update_note 特殊节点非管理员拒绝 id={id}', extra={'user': info['user']})
|
||||||
|
return jsonify({"error": '特殊节点仅管理员可发布'}), 403
|
||||||
|
try:
|
||||||
|
_validate_sp(sp)
|
||||||
|
except DnfsExp as e:
|
||||||
|
return jsonify({"error": str(e)}), e.get_status()
|
||||||
|
|
||||||
|
# 内容审查(特殊节点剔除 base64 负载)
|
||||||
|
review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content)
|
||||||
if True != review:
|
if True != review:
|
||||||
#return jsonify({'error': 'review failed', 'data': review}), 400
|
#return jsonify({'error': 'review failed', 'data': review}), 400
|
||||||
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
|
logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']})
|
||||||
@@ -502,9 +658,7 @@ def get_note():
|
|||||||
# """返回应用主页"""
|
# """返回应用主页"""
|
||||||
# return render_template('index.html')
|
# return render_template('index.html')
|
||||||
|
|
||||||
ADDR = os.environ.get('LISTEN_ADDR', '0.0.0.0')
|
|
||||||
PORT = int(os.environ.get('LISTEN_PORT', '8006'))
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logs.info('dnfs', f'服务启动,监听 http://{ADDR}:{PORT}')
|
logs.info('dnfs', f'服务启动,监听 http://{_ADDR}:{_PORT}(db={_DBV2_ADDR}:{_DBV2_PORT}, ai_review={_REVIEW_SERVER_URL})')
|
||||||
print(f'Listen at http://{ADDR}:{PORT}')
|
print(f'Listen at http://{_ADDR}:{_PORT}')
|
||||||
serve(app, host=ADDR, port=PORT)
|
serve(app, host=_ADDR, port=_PORT)
|
||||||
@@ -50,6 +50,7 @@ static :9200 (Flask + CORS)
|
|||||||
| `POST /api/hash` | SHA256 密码哈希 |
|
| `POST /api/hash` | SHA256 密码哈希 |
|
||||||
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
||||||
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
||||||
|
| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) |
|
||||||
|
|
||||||
### yhdet OAuth 路由
|
### yhdet OAuth 路由
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ static :9200 (Flask + CORS)
|
|||||||
- 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动
|
- 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动
|
||||||
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
|
||||||
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
|
- 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类
|
||||||
|
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制
|
||||||
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
|
||||||
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
|
||||||
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
|
||||||
|
|||||||
@@ -531,6 +531,75 @@ def proxy_send_code():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ====================
|
||||||
|
# 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。
|
||||||
|
# SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_FAVICON_MAX_BYTES = 512 * 1024
|
||||||
|
_FAVICON_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _is_private_host(hostname):
|
||||||
|
"""主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。"""
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(hostname, None)
|
||||||
|
except Exception:
|
||||||
|
return True # 解析失败一律拒绝
|
||||||
|
if not infos:
|
||||||
|
return True
|
||||||
|
for info in infos:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(info[4][0])
|
||||||
|
except ValueError:
|
||||||
|
return True
|
||||||
|
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||||
|
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler):
|
||||||
|
"""重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。"""
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
if _is_private_host(urlparse(newurl).hostname or ''):
|
||||||
|
return None
|
||||||
|
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/favicon', methods=['GET'])
|
||||||
|
def api_favicon():
|
||||||
|
target = (request.args.get('url') or '').strip()
|
||||||
|
try:
|
||||||
|
parsed = urlparse(target)
|
||||||
|
except Exception:
|
||||||
|
return jsonify({'error': 'bad url'}), 400
|
||||||
|
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
|
||||||
|
return jsonify({'error': 'bad url'}), 400
|
||||||
|
if _is_private_host(parsed.hostname):
|
||||||
|
return jsonify({'error': 'forbidden host'}), 403
|
||||||
|
|
||||||
|
favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico"
|
||||||
|
try:
|
||||||
|
opener = _urlreq.build_opener(_FaviconRedirectHandler())
|
||||||
|
req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'})
|
||||||
|
with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp:
|
||||||
|
data = resp.read(_FAVICON_MAX_BYTES + 1)
|
||||||
|
if len(data) > _FAVICON_MAX_BYTES:
|
||||||
|
return jsonify({'error': 'favicon too large'}), 502
|
||||||
|
ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip()
|
||||||
|
if not ctype.startswith('image/'):
|
||||||
|
ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底
|
||||||
|
out = Response(data, 200, mimetype=ctype)
|
||||||
|
out.headers['Cache-Control'] = 'public, max-age=86400'
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)})
|
||||||
|
return jsonify({'error': 'fetch failed'}), 502
|
||||||
|
|
||||||
|
|
||||||
# ==================== 主路由 ====================
|
# ==================== 主路由 ====================
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def serve_view():
|
def serve_view():
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ textarea:focus {
|
|||||||
input::placeholder, textarea::placeholder { color: var(--muted); }
|
input::placeholder, textarea::placeholder { color: var(--muted); }
|
||||||
textarea { min-height: 80px; resize: vertical; }
|
textarea { min-height: 80px; resize: vertical; }
|
||||||
|
|
||||||
|
select {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
border: 1.5px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: inherit;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); }
|
||||||
|
|
||||||
/* ===== Buttons ===== */
|
/* ===== Buttons ===== */
|
||||||
button, input[type="submit"], input[type="reset"], input[type="button"] {
|
button, input[type="submit"], input[type="reset"], input[type="button"] {
|
||||||
padding: 0.55rem 1.3rem;
|
padding: 0.55rem 1.3rem;
|
||||||
@@ -492,6 +507,30 @@ input[type="button"]:disabled {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
|
||||||
|
.sp-link { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
|
.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; }
|
||||||
|
.sp-anchor { color: var(--accent); word-break: break-all; }
|
||||||
|
.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
||||||
|
.sp-code {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); }
|
||||||
|
.sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; }
|
||||||
|
#sp-panel { margin-top: 0.4rem; }
|
||||||
|
#sp-panel select { margin-bottom: 0.4rem; }
|
||||||
|
#sp-file-tip { font-size: 0.78rem; }
|
||||||
|
|
||||||
/* ===== Node Form (dnfs) ===== */
|
/* ===== Node Form (dnfs) ===== */
|
||||||
.node-form { margin-top: 1rem; }
|
.node-form { margin-top: 1rem; }
|
||||||
.node-form .form-actions { margin-top: 0.75rem; }
|
.node-form .form-actions { margin-top: 0.75rem; }
|
||||||
|
|||||||
@@ -16,6 +16,19 @@
|
|||||||
<div class="card node-form">
|
<div class="card node-form">
|
||||||
<h3>节点操作</h3>
|
<h3>节点操作</h3>
|
||||||
<label>新增节点</label><input id="input-note-title">
|
<label>新增节点</label><input id="input-note-title">
|
||||||
|
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
|
||||||
|
<div id="sp-panel" style="display:none">
|
||||||
|
<label>节点类型</label>
|
||||||
|
<select id="sp-type">
|
||||||
|
<option value="text">普通文本</option>
|
||||||
|
<option value="link">链接(自动显示站点图标)</option>
|
||||||
|
<option value="html">网页代码(可审计/可运行)</option>
|
||||||
|
<option value="image">图片(自动显示)</option>
|
||||||
|
<option value="file">文件(可下载)</option>
|
||||||
|
</select>
|
||||||
|
<input type="file" id="sp-file" style="display:none">
|
||||||
|
<span id="sp-file-tip"></span>
|
||||||
|
</div>
|
||||||
<label>节点内容</label><textarea id="input-note-content"></textarea>
|
<label>节点内容</label><textarea id="input-note-content"></textarea>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button id="input-note-submit">提交</button>
|
<button id="input-note-submit">提交</button>
|
||||||
|
|||||||
+251
-6
@@ -418,6 +418,151 @@ function renderMarkdown(element, content) {
|
|||||||
element.innerHTML = md.render(content, {html: false});
|
element.innerHTML = md.render(content, {html: false});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== by qing — 特殊节点(管理员专属)=====
|
||||||
|
* 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。
|
||||||
|
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
|
||||||
|
|
||||||
|
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
|
||||||
|
var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024;
|
||||||
|
|
||||||
|
function parseSp(content) {
|
||||||
|
if (typeof content !== 'string') return null;
|
||||||
|
var s = content.trim();
|
||||||
|
if (s.indexOf('{"_sp"') !== 0) return null;
|
||||||
|
try {
|
||||||
|
var obj = JSON.parse(s);
|
||||||
|
} catch (e) {
|
||||||
|
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
|
||||||
|
}
|
||||||
|
if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */
|
||||||
|
function renderContent(container, item) {
|
||||||
|
var sp = parseSp(item.content);
|
||||||
|
if (!sp) {
|
||||||
|
renderMarkdown(container, item.content);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
function appendNote() {
|
||||||
|
if (typeof sp.note === 'string' && sp.note.trim()) {
|
||||||
|
var note = document.createElement('div');
|
||||||
|
note.className = 'sp-note';
|
||||||
|
note.textContent = sp.note;
|
||||||
|
container.appendChild(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
|
||||||
|
else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote);
|
||||||
|
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
|
||||||
|
else if (sp._sp === 'file') renderSpFile(container, sp, appendNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */
|
||||||
|
function renderSpLink(container, sp, appendNote) {
|
||||||
|
var box = document.createElement('div');
|
||||||
|
box.className = 'sp-link';
|
||||||
|
var icon = document.createElement('img');
|
||||||
|
icon.className = 'sp-favicon';
|
||||||
|
icon.alt = '';
|
||||||
|
icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url);
|
||||||
|
icon.addEventListener('error', function() { icon.style.display = 'none'; });
|
||||||
|
var anchor = document.createElement('a');
|
||||||
|
anchor.className = 'sp-anchor';
|
||||||
|
anchor.href = sp.url;
|
||||||
|
anchor.target = '_blank';
|
||||||
|
anchor.rel = 'noopener noreferrer';
|
||||||
|
anchor.textContent = sp.url;
|
||||||
|
anchor.addEventListener('click', function(e) { e.stopPropagation(); });
|
||||||
|
box.appendChild(icon);
|
||||||
|
box.appendChild(anchor);
|
||||||
|
container.appendChild(box);
|
||||||
|
appendNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */
|
||||||
|
function renderSpHtml(container, sp, appendNote) {
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'sp-actions';
|
||||||
|
var audit_btn = document.createElement('button');
|
||||||
|
audit_btn.className = 'act-btn act-edit';
|
||||||
|
audit_btn.textContent = '审计源码';
|
||||||
|
var run_btn = document.createElement('button');
|
||||||
|
run_btn.className = 'act-btn act-move';
|
||||||
|
run_btn.textContent = '运行';
|
||||||
|
var pre = document.createElement('pre');
|
||||||
|
pre.className = 'sp-code';
|
||||||
|
pre.style.display = 'none';
|
||||||
|
pre.textContent = sp.code;
|
||||||
|
audit_btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var hidden = (pre.style.display === 'none');
|
||||||
|
pre.style.display = hidden ? 'block' : 'none';
|
||||||
|
audit_btn.textContent = hidden ? '收起源码' : '审计源码';
|
||||||
|
});
|
||||||
|
run_btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
var win = window.open('', '_blank');
|
||||||
|
if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; }
|
||||||
|
win.document.open();
|
||||||
|
win.document.write(sp.code);
|
||||||
|
win.document.close();
|
||||||
|
});
|
||||||
|
bar.appendChild(audit_btn);
|
||||||
|
bar.appendChild(run_btn);
|
||||||
|
container.appendChild(bar);
|
||||||
|
container.appendChild(pre);
|
||||||
|
appendNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */
|
||||||
|
function renderSpImage(container, sp, appendNote) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.className = 'sp-image';
|
||||||
|
img.src = sp.data;
|
||||||
|
img.alt = sp.name || '图片';
|
||||||
|
container.appendChild(img);
|
||||||
|
appendNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */
|
||||||
|
function renderSpFile(container, sp, appendNote) {
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'sp-actions';
|
||||||
|
var btn = document.createElement('button');
|
||||||
|
btn.className = 'act-btn act-edit';
|
||||||
|
btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')';
|
||||||
|
btn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
try {
|
||||||
|
var bin = atob(sp.data);
|
||||||
|
var bytes = new Uint8Array(bin.length);
|
||||||
|
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' });
|
||||||
|
var a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = sp.name || 'download';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000);
|
||||||
|
} catch (err) {
|
||||||
|
alert('文件解码失败:' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bar.appendChild(btn);
|
||||||
|
container.appendChild(bar);
|
||||||
|
appendNote();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n) {
|
||||||
|
var units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
var i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||||
|
return n.toFixed(1) + units[i];
|
||||||
|
}
|
||||||
|
|
||||||
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
|
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
|
||||||
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||||
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
|
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
|
||||||
@@ -450,6 +595,12 @@ var recommend_list = document.getElementById('recommend-list');
|
|||||||
var delete_note = document.getElementById('delete-note');
|
var delete_note = document.getElementById('delete-note');
|
||||||
var minimap = document.getElementById("minimap");
|
var minimap = document.getElementById("minimap");
|
||||||
var movebar = document.getElementById("movebar");
|
var movebar = document.getElementById("movebar");
|
||||||
|
/* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */
|
||||||
|
var sp_panel = document.getElementById('sp-panel');
|
||||||
|
var sp_type = document.getElementById('sp-type');
|
||||||
|
var sp_file = document.getElementById('sp-file');
|
||||||
|
var sp_file_tip = document.getElementById('sp-file-tip');
|
||||||
|
var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
|
||||||
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
|
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
|
||||||
var PANEL_EXPAND_MIN_WIDTH = 768;
|
var PANEL_EXPAND_MIN_WIDTH = 768;
|
||||||
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
|
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
|
||||||
@@ -471,10 +622,69 @@ function resetTextareaHeight() {
|
|||||||
}
|
}
|
||||||
autoResizeTextarea(input_note_content); /* 初始化高度 */
|
autoResizeTextarea(input_note_content); /* 初始化高度 */
|
||||||
|
|
||||||
|
/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */
|
||||||
|
sp_type.addEventListener('change', function() {
|
||||||
|
var t = sp_type.value;
|
||||||
|
var is_file = (t === 'image' || t === 'file');
|
||||||
|
sp_file.style.display = is_file ? '' : 'none';
|
||||||
|
input_note_content.style.display = is_file ? 'none' : '';
|
||||||
|
sp_file_tip.textContent = '';
|
||||||
|
sp_file_tip.className = is_file ? 'tip-yellow' : '';
|
||||||
|
if (!is_file) {
|
||||||
|
sp_file.value = '';
|
||||||
|
sp_edit_original = null;
|
||||||
|
}
|
||||||
|
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
|
||||||
|
else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码';
|
||||||
|
else input_note_content.placeholder = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function readFileAsDataURL(f) {
|
||||||
|
return new Promise(function(resolve, reject) {
|
||||||
|
var r = new FileReader();
|
||||||
|
r.onload = function() { resolve(r.result); };
|
||||||
|
r.onerror = function() { reject(new Error('文件读取失败')); };
|
||||||
|
r.readAsDataURL(f);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。
|
||||||
|
* 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */
|
||||||
|
async function buildSubmitContent() {
|
||||||
|
var t = (current_user && current_user.is_admin) ? sp_type.value : 'text';
|
||||||
|
if (t === 'text') return input_note_content.value;
|
||||||
|
if (t === 'link') {
|
||||||
|
var url = input_note_content.value.trim();
|
||||||
|
if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; }
|
||||||
|
return JSON.stringify({ _sp: 'link', url: url });
|
||||||
|
}
|
||||||
|
if (t === 'html') {
|
||||||
|
var code = input_note_content.value;
|
||||||
|
if (!code.trim()) { alert('请输入 HTML 代码'); return null; }
|
||||||
|
return JSON.stringify({ _sp: 'html', code: code });
|
||||||
|
}
|
||||||
|
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
|
||||||
|
if (sp_file.files.length === 0) {
|
||||||
|
if (sp_edit_original) return sp_edit_original;
|
||||||
|
alert('请选择要上传的' + (t === 'image' ? '图片' : '文件'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var f = sp_file.files[0];
|
||||||
|
if (f.size > SP_MAX_FILE_BYTES) {
|
||||||
|
alert('文件过大(上限约 2.5MB):' + f.name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var dataUrl = await readFileAsDataURL(f);
|
||||||
|
if (t === 'image') {
|
||||||
|
if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; }
|
||||||
|
return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl });
|
||||||
|
}
|
||||||
|
return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] });
|
||||||
|
}
|
||||||
|
|
||||||
input_note_submit.addEventListener('click', function(event) {
|
input_note_submit.addEventListener('click', function(event) {
|
||||||
if (input_note_submit.disabled) return; // 防重复点击
|
if (input_note_submit.disabled) return; // 防重复点击
|
||||||
var title = input_note_title.value
|
var title = input_note_title.value
|
||||||
var content = input_note_content.value
|
|
||||||
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
var pid = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||||
//alert(stack.length)
|
//alert(stack.length)
|
||||||
//alert(pid)
|
//alert(pid)
|
||||||
@@ -482,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) {
|
|||||||
input_note_submit.disabled = true;
|
input_note_submit.disabled = true;
|
||||||
input_note_clear.disabled = true;
|
input_note_clear.disabled = true;
|
||||||
delete_note.disabled = true;
|
delete_note.disabled = true;
|
||||||
// 编辑模式下保存修改,否则新增节点
|
// 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件)
|
||||||
var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
|
buildSubmitContent()
|
||||||
Promise.resolve(req)
|
.then(function(content) {
|
||||||
|
if (content == null) return null; // 校验失败,已弹提示
|
||||||
|
return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
alert(err.message || '内容处理失败');
|
||||||
|
console.error('构造提交内容异常:', err);
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
input_note_submit.disabled = false;
|
input_note_submit.disabled = false;
|
||||||
input_note_clear.disabled = false;
|
input_note_clear.disabled = false;
|
||||||
@@ -513,11 +730,28 @@ delete_note.addEventListener('click', (event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */
|
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
|
||||||
|
* 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
|
||||||
function startEdit(item) {
|
function startEdit(item) {
|
||||||
editing_id = item.id;
|
editing_id = item.id;
|
||||||
input_note_title.value = item.title;
|
input_note_title.value = item.title;
|
||||||
|
var sp = parseSp(item.content);
|
||||||
|
sp_edit_original = null;
|
||||||
|
if (sp && (sp._sp === 'link' || sp._sp === 'html')) {
|
||||||
|
sp_type.value = sp._sp;
|
||||||
|
sp_type.dispatchEvent(new Event('change'));
|
||||||
|
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
|
||||||
|
} else if (sp) {
|
||||||
|
sp_type.value = sp._sp;
|
||||||
|
sp_type.dispatchEvent(new Event('change'));
|
||||||
|
sp_edit_original = item.content;
|
||||||
|
sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件');
|
||||||
|
input_note_content.value = '';
|
||||||
|
} else {
|
||||||
|
sp_type.value = 'text';
|
||||||
|
sp_type.dispatchEvent(new Event('change'));
|
||||||
input_note_content.value = item.content;
|
input_note_content.value = item.content;
|
||||||
|
}
|
||||||
input_note_submit.textContent = "保存修改";
|
input_note_submit.textContent = "保存修改";
|
||||||
autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */
|
autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */
|
||||||
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
@@ -527,6 +761,13 @@ function startEdit(item) {
|
|||||||
/* by qing — 退出编辑模式,表单恢复为新增状态 */
|
/* by qing — 退出编辑模式,表单恢复为新增状态 */
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editing_id = null;
|
editing_id = null;
|
||||||
|
sp_edit_original = null;
|
||||||
|
sp_file.value = '';
|
||||||
|
sp_file_tip.textContent = '';
|
||||||
|
if (sp_type.value !== 'text') {
|
||||||
|
sp_type.value = 'text';
|
||||||
|
sp_type.dispatchEvent(new Event('change'));
|
||||||
|
}
|
||||||
input_note_submit.textContent = "提交";
|
input_note_submit.textContent = "提交";
|
||||||
resetTextareaHeight(); /* 恢复初始高度 */
|
resetTextareaHeight(); /* 恢复初始高度 */
|
||||||
}
|
}
|
||||||
@@ -597,7 +838,7 @@ function load_notes(main_node) {
|
|||||||
ttl.appendChild(wrt_div);
|
ttl.appendChild(wrt_div);
|
||||||
ttl.appendChild(sts_div);
|
ttl.appendChild(sts_div);
|
||||||
let ctt = document.createElement('p');
|
let ctt = document.createElement('p');
|
||||||
renderMarkdown(ctt, item.content);
|
renderContent(ctt, item);
|
||||||
card.appendChild(ctt)
|
card.appendChild(ctt)
|
||||||
if (item.can_operate === true) {
|
if (item.can_operate === true) {
|
||||||
let act_div = document.createElement('div');
|
let act_div = document.createElement('div');
|
||||||
@@ -699,6 +940,10 @@ async function loadUserPanel() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
current_user = await res.json();
|
current_user = await res.json();
|
||||||
|
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
|
||||||
|
if (current_user.login && current_user.is_admin) {
|
||||||
|
sp_panel.style.display = '';
|
||||||
|
}
|
||||||
renderUserPanel();
|
renderUserPanel();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取用户信息失败:', e);
|
console.error('获取用户信息失败:', e);
|
||||||
|
|||||||
+72
-7
@@ -248,29 +248,94 @@ kill -HUP $(pgrep waf)
|
|||||||
|
|
||||||
## 请求处理流程
|
## 请求处理流程
|
||||||
|
|
||||||
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
[proxy.go:81-172](internal/proxy/proxy.go#L81) `ServeHTTP`:
|
||||||
|
|
||||||
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
|
||||||
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
|
||||||
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
3. 读取请求体(**10MB 硬限制**,超限返回 413)→ `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
|
||||||
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
|
||||||
5. 捕获响应头 → `ProcessResponseHeaders`
|
5. 捕获响应头 → `ProcessResponseHeaders`(仅审计日志,不拦截)
|
||||||
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
|
||||||
|
7. defer 中确保 `r.Body` 耗尽并关闭,保证 TCP 连接复用
|
||||||
|
|
||||||
|
### 请求体大小限制
|
||||||
|
|
||||||
|
WAF 在代码层面对请求体实施 **10MB 硬限制**([proxy.go#L19-L22](internal/proxy/proxy.go#L19-L22) `maxRequestBodySize`),与 `waf.conf` 中的 `SecRequestBodyLimit 10485760` 保持一致:
|
||||||
|
|
||||||
|
- 使用 `io.LimitReader` + 多读 1 字节检测超限
|
||||||
|
- 超限直接返回 `413 Request Entity Too Large`,不经过 Coraza 规则引擎
|
||||||
|
- 读取错误(客户端断开)记录日志后直接返回,不继续处理
|
||||||
|
- 从根本上防止超大请求体导致 OOM 崩溃
|
||||||
|
|
||||||
|
### HTTP 服务器超时防护
|
||||||
|
|
||||||
|
[main.go#L111-L122](main.go#L111) 为 `http.Server` 配置了多层超时,防御 Slowloris 等慢连接 DoS 攻击:
|
||||||
|
|
||||||
|
| 参数 | 值 | 作用 |
|
||||||
|
|------|-----|------|
|
||||||
|
| `ReadHeaderTimeout` | 10s | 读取请求头超时,**直接防御 Slowloris** |
|
||||||
|
| `ReadTimeout` | 15s | 读取整个请求(含 body)超时,防慢 POST |
|
||||||
|
| `WriteTimeout` | 30s | 写入响应超时 |
|
||||||
|
| `IdleTimeout` | 120s | keep-alive 空闲连接超时 |
|
||||||
|
| `MaxHeaderBytes` | 1MB | 请求头大小限制 |
|
||||||
|
|
||||||
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
|
||||||
|
|
||||||
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
|
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
|
||||||
|
|
||||||
|
## WebSocket 代理支持
|
||||||
|
|
||||||
|
WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。实现要点:
|
||||||
|
|
||||||
|
- `responseRecorder` 实现了 `http.Hijacker` 接口,将 Hijack 调用委托给底层 `http.ResponseWriter`
|
||||||
|
- WebSocket 握手阶段(HTTP Upgrade 请求/响应)仍然经过 WAF 规则检测(请求头 phase:1)
|
||||||
|
- 握手成功(101 Switching Protocols)后,连接被 Hijack,由 `httputil.ReverseProxy` 自动进行双向字节流转发
|
||||||
|
- 升级后连接转为二进制帧协议,跳过 HTTP 响应阶段的 WAF 检测,记录 Info 日志
|
||||||
|
- WebSocket 帧内容不做 WAF 检测(帧是二进制双向流,Coraza 规则引擎基于 HTTP 请求/响应模型)
|
||||||
|
- `http.Server` 的超时设置不影响 Hijack 后的 WebSocket 长连接(Hijack 后连接脱离 Server 超时管理)
|
||||||
|
- 安全响应头(CSP/HSTS 等)不注入 WebSocket 升级响应(101 响应由协议协商决定,注入非标准头可能导致客户端兼容问题)
|
||||||
|
|
||||||
|
配置要求:
|
||||||
|
- 无特殊配置,WebSocket 连接(`Upgrade: websocket`)自动识别并代理
|
||||||
|
- 需要上游服务正确处理 WebSocket 升级(如 aibox 监听 ws:// 端口)
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
|
- 响应头检测(`ProcessResponseHeaders`)当前仅做审计日志,命中后不执行拦截(响应头已发送无法撤销状态码)。如需响应头拦截需引入 buffered response writer。
|
||||||
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
- **响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
|
||||||
|
- **WebSocket 帧内容不检测**:升级后二进制帧直接转发,不经过 WAF 规则引擎(适用于内部可信服务如 aibox)。
|
||||||
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
|
||||||
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
|
|
||||||
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
|
||||||
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
|
- `X-Forwarded-For` 直接覆盖原值(`Header.Set`),未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。注意:IPv6 地址在 X-Forwarded-For 中可能带方括号(待修复,使用 `LastIndexByte` 无法正确处理 IPv6 格式)。
|
||||||
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
|
||||||
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
|
||||||
|
- logclient 日志推送每次新建 TCP 短连接,高并发拦截场景可能产生较多 TIME_WAIT;db 地址硬编码 `127.0.0.1:8005`,不支持环境变量配置。
|
||||||
|
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。
|
||||||
|
- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。
|
||||||
|
- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。
|
||||||
|
|
||||||
|
## 版本日志
|
||||||
|
|
||||||
|
### v1.2(2026-08-01)— WebSocket 支持 + 修复
|
||||||
|
|
||||||
|
- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口
|
||||||
|
- WebSocket 握手阶段仍经过 WAF 请求头检测,升级后自动双向转发
|
||||||
|
- WebSocket 升级成功记录 Info 日志,便于追踪
|
||||||
|
- **fix(P2-3)**: `ProcessResponseHeaders` 协议版本从硬编码 `"HTTP/1.1"` 改为 `r.Proto`,支持 HTTP/2
|
||||||
|
- **fix(P2-4)**: responseRecorder 添加 Write 方法确保 WriteHeader 在首次 Write 前调用
|
||||||
|
- 补充 `bufio`/`net` import 用于 Hijacker 实现
|
||||||
|
|
||||||
|
### v1.1(2026-08-01)— 安全加固
|
||||||
|
|
||||||
|
- **P0 修复:请求体 OOM 防护**:添加 10MB 硬限制,`io.LimitReader` + 多读1字节检测超限,超限返回 413
|
||||||
|
- **P0 修复:Slowloris DoS 防护**:`http.Server` 添加 ReadHeaderTimeout(10s)/ReadTimeout(15s)/WriteTimeout(30s)/IdleTimeout(120s)/MaxHeaderBytes(1MB)
|
||||||
|
- **P1 修复:请求体读取错误处理**:`io.ReadAll` 失败时记录日志并返回,不再传递出错 body 给 ReverseProxy;`ReadRequestBodyFrom`/`ProcessRequestBody` 错误均记录日志
|
||||||
|
- **P2 修复:r.Body 连接复用**:defer 中耗尽并关闭 r.Body,确保 TCP 连接可复用
|
||||||
|
- 413 响应同样注入安全响应头
|
||||||
|
|
||||||
|
### v1.0
|
||||||
|
|
||||||
|
- 初始版本:TLS 终止 + Coraza WAF 规则匹配 + 反向代理 + SIGHUP 热加载 + 异步日志推送 + 安全响应头注入
|
||||||
|
|
||||||
## 依赖
|
## 依赖
|
||||||
|
|
||||||
|
|||||||
@@ -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 配置化等
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -16,6 +18,10 @@ import (
|
|||||||
"github.com/micro_servers/waf/internal/logclient"
|
"github.com/micro_servers/waf/internal/logclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxRequestBodySize 请求体最大大小(10MB),与 rules/waf.conf 中 SecRequestBodyLimit 保持一致。
|
||||||
|
// 超过此限制的请求直接返回 413 Request Entity Too Large,防止 OOM。
|
||||||
|
const maxRequestBodySize = 10 * 1024 * 1024
|
||||||
|
|
||||||
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
// Proxy 持有 ReverseProxy 与 WAF 引擎引用。
|
||||||
type Proxy struct {
|
type Proxy struct {
|
||||||
rp *httputil.ReverseProxy
|
rp *httputil.ReverseProxy
|
||||||
@@ -84,6 +90,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer func() {
|
defer func() {
|
||||||
tx.ProcessLogging()
|
tx.ProcessLogging()
|
||||||
_ = tx.Close()
|
_ = tx.Close()
|
||||||
|
// 确保 r.Body 被关闭并耗尽,保证 TCP 连接可复用
|
||||||
|
if r.Body != nil {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(r.Body, 1024))
|
||||||
|
_ = r.Body.Close()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// 解析 RemoteAddr 中的 IP 与端口
|
// 解析 RemoteAddr 中的 IP 与端口
|
||||||
@@ -111,14 +122,47 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求体:读全量喂给 coraza,再用 buffer 恢复 r.Body 给 proxy 转发
|
// 请求体:读取时限制大小(maxRequestBodySize + 1 字节用于判断超限),防止 OOM
|
||||||
if r.Body != nil && r.Body != http.NoBody {
|
if r.Body != nil && r.Body != http.NoBody {
|
||||||
bodyBytes, err := io.ReadAll(r.Body)
|
// 限制读取大小:多读 1 字节,若能读到说明超限
|
||||||
if err == nil {
|
limitReader := io.LimitReader(r.Body, maxRequestBodySize+1)
|
||||||
_, _, _ = tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes))
|
bodyBytes, err := io.ReadAll(limitReader)
|
||||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
if err != nil {
|
||||||
|
// 客户端中途断开或读取错误,直接返回无需继续处理
|
||||||
|
logclient.Info("waf", fmt.Sprintf("body_read_error path=%s err=%v", r.URL.Path, err),
|
||||||
|
map[string]any{"ip": r.RemoteAddr})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if it, _ := tx.ProcessRequestBody(); it != nil {
|
if len(bodyBytes) > maxRequestBodySize {
|
||||||
|
// 请求体超限,返回 413
|
||||||
|
logclient.Warning("waf", fmt.Sprintf("body_too_large %s %s size=%d", r.Method, r.URL.Path, len(bodyBytes)),
|
||||||
|
map[string]any{
|
||||||
|
"ip": r.RemoteAddr,
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"size": len(bodyBytes),
|
||||||
|
"limit": maxRequestBodySize,
|
||||||
|
"cost_ms": time.Since(start).Milliseconds(),
|
||||||
|
})
|
||||||
|
setSecurityHeaders(w.Header())
|
||||||
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||||
|
_, _ = w.Write([]byte("request body too large\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 喂给 coraza 进行请求体检测
|
||||||
|
if _, _, err := tx.ReadRequestBodyFrom(bytes.NewReader(bodyBytes)); err != nil {
|
||||||
|
logclient.Warning("waf", fmt.Sprintf("body_parse_error path=%s err=%v", r.URL.Path, err),
|
||||||
|
map[string]any{"ip": r.RemoteAddr})
|
||||||
|
}
|
||||||
|
// 恢复 r.Body 供 ReverseProxy 转发
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
|
it, err := tx.ProcessRequestBody()
|
||||||
|
if err != nil {
|
||||||
|
logclient.Warning("waf", fmt.Sprintf("body_process_error path=%s err=%v", r.URL.Path, err),
|
||||||
|
map[string]any{"ip": r.RemoteAddr})
|
||||||
|
}
|
||||||
|
if it != nil {
|
||||||
p.block(w, r, it.RuleID, start)
|
p.block(w, r, it.RuleID, start)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -128,13 +172,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
rw := &responseRecorder{ResponseWriter: w, status: 200}
|
rw := &responseRecorder{ResponseWriter: w, status: 200}
|
||||||
p.rp.ServeHTTP(rw, r)
|
p.rp.ServeHTTP(rw, r)
|
||||||
|
|
||||||
|
// WebSocket 升级成功后连接已被 Hijack,转为二进制帧协议,无需执行 HTTP 响应阶段检测
|
||||||
|
if rw.hijacked {
|
||||||
|
logclient.Info("waf", fmt.Sprintf("websocket_upgraded %s %s cost_ms=%d",
|
||||||
|
r.Method, r.URL.Path, time.Since(start).Milliseconds()),
|
||||||
|
map[string]any{
|
||||||
|
"ip": r.RemoteAddr,
|
||||||
|
"method": r.Method,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"cost_ms": time.Since(start).Milliseconds(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 响应阶段:把 upstream 的 header 喂给 WAF
|
// 响应阶段:把 upstream 的 header 喂给 WAF
|
||||||
for k, vs := range rw.headers {
|
for k, vs := range rw.headers {
|
||||||
for _, v := range vs {
|
for _, v := range vs {
|
||||||
tx.AddResponseHeader(k, v)
|
tx.AddResponseHeader(k, v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tx.ProcessResponseHeaders(rw.status, "HTTP/1.1")
|
tx.ProcessResponseHeaders(rw.status, r.Proto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
|
// block 命中规则时统一返回 403,并将关键输入推送到日志库。
|
||||||
@@ -155,26 +212,58 @@ func (p *Proxy) block(w http.ResponseWriter, r *http.Request, ruleID int, start
|
|||||||
}
|
}
|
||||||
|
|
||||||
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
|
// responseRecorder 记录状态码与响应头,供响应阶段 WAF 检测使用。
|
||||||
|
// 同时实现 http.Hijacker 以支持 WebSocket 升级(ReverseProxy 在 WebSocket 握手时需要劫持底层连接)。
|
||||||
type responseRecorder struct {
|
type responseRecorder struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
|
statusSent bool
|
||||||
headers http.Header
|
headers http.Header
|
||||||
|
hijacked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *responseRecorder) WriteHeader(code int) {
|
func (r *responseRecorder) WriteHeader(code int) {
|
||||||
|
if r.hijacked {
|
||||||
|
return
|
||||||
|
}
|
||||||
r.status = code
|
r.status = code
|
||||||
|
r.statusSent = true
|
||||||
if r.headers == nil {
|
if r.headers == nil {
|
||||||
r.headers = http.Header{}
|
r.headers = http.Header{}
|
||||||
}
|
}
|
||||||
// 拷贝上游响应头
|
// 拷贝上游响应头(在 WriteHeader 调用时响应头已最终确定)
|
||||||
for k, vs := range r.Header() {
|
for k, vs := range r.Header() {
|
||||||
r.headers[k] = vs
|
r.headers[k] = vs
|
||||||
}
|
}
|
||||||
r.ResponseWriter.WriteHeader(code)
|
r.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||||
|
if !r.statusSent {
|
||||||
|
r.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
return r.ResponseWriter.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush 实现 http.Flusher,支持 SSE/流式响应。
|
||||||
func (r *responseRecorder) Flush() {
|
func (r *responseRecorder) Flush() {
|
||||||
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
if f, ok := r.ResponseWriter.(http.Flusher); ok {
|
||||||
f.Flush()
|
f.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Hijack 实现 http.Hijacker,支持 WebSocket 升级。
|
||||||
|
// 委托给底层 ResponseWriter 的 Hijack 方法,并标记连接已被劫持。
|
||||||
|
func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
hijacker, ok := r.ResponseWriter.(http.Hijacker)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("underlying ResponseWriter does not support Hijacker")
|
||||||
|
}
|
||||||
|
conn, rw, err := hijacker.Hijack()
|
||||||
|
if err == nil {
|
||||||
|
r.hijacked = true
|
||||||
|
// WebSocket 升级响应是 101 Switching Protocols
|
||||||
|
r.status = http.StatusSwitchingProtocols
|
||||||
|
r.statusSent = true
|
||||||
|
}
|
||||||
|
return conn, rw, err
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
@@ -110,6 +111,11 @@ func main() {
|
|||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: cfg.Listen,
|
Addr: cfg.Listen,
|
||||||
Handler: p,
|
Handler: p,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 120 * time.Second,
|
||||||
|
MaxHeaderBytes: 1 << 20, // 1MB
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
MinVersion: tls.VersionTLS12,
|
MinVersion: tls.VersionTLS12,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user