Compare commits
70
Commits
417fda5479
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81ba942b8e | ||
|
|
bbb88a96f8 | ||
|
|
7b0b49fde1 | ||
|
|
5a8ac61502 | ||
|
|
8b3863f3ae | ||
|
|
6483719551 | ||
|
|
9c73d70582 | ||
|
|
0348b959bb | ||
|
|
3ebf402efd | ||
|
|
63b5243faf | ||
|
|
1b6a767f62 | ||
|
|
7f6f077469 | ||
|
|
60db2c40d8 | ||
|
|
08aff2656d | ||
|
|
c059589e94 | ||
|
|
d1e4b5b01a | ||
|
|
897e76fd8f | ||
|
|
ac2677abc7 | ||
|
|
d680dcd058 | ||
|
|
84a1a5e3be | ||
|
|
058eb11f5f | ||
|
|
85c9699758 | ||
|
|
9dd16e0eef | ||
|
|
28894e48dc | ||
|
|
924c718005 | ||
|
|
d71f526af4 | ||
|
|
09556c9ef4 | ||
|
|
cc671a9fce | ||
|
|
f7ed1f785b | ||
|
|
2ff7908bc7 | ||
|
|
745ce06f85 | ||
|
|
f17a6a8233 | ||
|
|
30360cb106 | ||
|
|
ec3df34b43 | ||
|
|
c7441c1c6f | ||
|
|
fed2d42472 | ||
|
|
2918f2c83c | ||
|
|
87538e7729 | ||
|
|
fd088f5959 | ||
|
|
15f8c9552d | ||
|
|
43c5821d3d | ||
|
|
1d7641cc89 | ||
|
|
6a68b3393d | ||
|
|
0700491300 | ||
|
|
d036af935f | ||
|
|
34cd295005 | ||
|
|
c8812dd27c | ||
|
|
3460a157d0 | ||
|
|
fe618be1fa | ||
|
|
4d9bb5d940 | ||
|
|
37c6d401d6 | ||
|
|
507054c4b2 | ||
|
|
85e0d0740b | ||
|
|
dc52ec13ad | ||
|
|
5ff1dfa4d2 | ||
|
|
2eb6d3c310 | ||
|
|
d921f84b83 | ||
|
|
69a8eee192 | ||
|
|
fa9679b7a6 | ||
|
|
53ddcb364f | ||
|
|
fb485ebac4 | ||
|
|
da4957c52d | ||
|
|
b37bf255d9 | ||
|
|
2f5fe8baaa | ||
|
|
2e3f07bf4f | ||
|
|
028e0e0806 | ||
|
|
37c50ed517 | ||
|
|
02904f711e | ||
|
|
d24066369b | ||
|
|
40b9b9c92f |
No files matched your search
@@ -168,7 +168,7 @@
|
||||
| 库 | 说明 |
|
||||
|----|------|
|
||||
| [libth](libth/) | 线程库:`Thread`、`Cv_wait`(条件变量等待)、`Fsm`、`ITask` 任务接口 |
|
||||
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`、`HttpClient`、`WebSocketServer/Client`(基于 Boost.Beast) |
|
||||
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`(含 WebSocket 服务/代理)、`HttpClient`(含 WebSocket 客户端,基于 Boost.Beast) |
|
||||
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
|
||||
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
|
||||
| [libtry](libtry/) | 嵌入式脚本解释器(~741 行 C),手写递归下降词法/语法/执行引擎,支持变量/数组/函数/控制流,宽字符(中文标识符),用于嵌入式操作界面 shell |
|
||||
|
||||
@@ -28,6 +28,7 @@ inst_module() {
|
||||
inst_module "libth"
|
||||
inst_module "libhttp"
|
||||
inst_module "libcrypt"
|
||||
inst_module "libmsg"
|
||||
inst_module "libsqlite3"
|
||||
inst_module "gateway"
|
||||
inst_module "waf" # waf 在 gateway 之前编译,部署时由 core 先拉起 waf 再拉起 gateway
|
||||
|
||||
+21
-2
@@ -15,6 +15,7 @@
|
||||
- 🔧 **扩展技能**:动态加载 `~/.aibox/skills/` 下的可执行脚本或 Markdown 技能文件
|
||||
- 🌍 **多语言支持**:自动检测系统语言,支持中英文
|
||||
- 🧬 **智能体自我进化**:通过 `update_soul` / `update_identity` 工具动态更新角色定位与用户画像
|
||||
- 🤖 **子代理**:通过 `create_subagent` 工具派生出拥有独立上下文/角色设定的轻量子代理,主代理可分解长任务、隔离上下文,子代理可选用不同模型
|
||||
- 🔄 **技能热重载**:无需重启即可添加、修改或删除扩展技能
|
||||
- 🔐 **SOCKS5 代理**:通过 `set_proxy` / `get_proxy` 工具为网页访问与 API 调用配置代理,可持久化
|
||||
- 💾 **对话管理**:历史对话归档、开始新对话(重置 AI 记忆并重新加载提示词)
|
||||
@@ -146,6 +147,8 @@ AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,
|
||||
| `calculator` | 数学计算 |
|
||||
| `set_proxy` | 设置 SOCKS5 代理(host/port/可选账号密码,可持久化,即时生效) |
|
||||
| `get_proxy` | 查看当前 SOCKS5 代理配置 |
|
||||
| `express_emotion` | 表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能(如 angry/coquettish/tsundere)派生携带该人格的情绪子代理,保证人格独立、不污染主对话上下文 |
|
||||
| `create_subagent` | 派生子代理:独立上下文/角色设定,轻量无工具、可选独立模型,用于长任务分解、上下文隔离、独立角色专注处理 |
|
||||
|
||||
## 💬 WebSocket API
|
||||
|
||||
@@ -204,14 +207,13 @@ const ws = new WebSocket('ws://localhost:8765');
|
||||
|
||||
### 支持的命令
|
||||
|
||||
> 注意:记忆搜索通过 `memory` 工具(在对话中由 AI 调用)完成,本系统**不存在** `/search` 斜杠命令。
|
||||
|
||||
| 命令 | 功能 |
|
||||
|-----|------|
|
||||
| `/exit` | 退出连接 |
|
||||
| `/new` | 开始新对话(保存当前,重置 AI 记忆,重新加载提示词) |
|
||||
| `/list` | 列出所有历史对话 |
|
||||
| `/memories` | 查看最近记忆(支持分页) |
|
||||
| `/search <关键词>` | 按关键词搜索记忆 |
|
||||
| `/memos` | 查看待办任务 |
|
||||
| `/config` | 查看配置 |
|
||||
| `/reload` | 重载配置 |
|
||||
@@ -236,6 +238,8 @@ const ws = new WebSocket('ws://localhost:8765');
|
||||
| `task_process` | 任务执行过程中的 AI 输出 |
|
||||
| `task_error` | 任务执行错误 |
|
||||
| `task_added` | 任务添加成功 |
|
||||
| `task_list` | 待办任务列表(`task` action=list 的返回) |
|
||||
| `task_completed` | 任务完成通知 |
|
||||
| `dialogue_ended` | 对话结束通知 |
|
||||
| `dialogue_reset` | 对话重置完成 |
|
||||
| `notification` | 通知消息 |
|
||||
@@ -437,6 +441,7 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
"broadcast_enabled": true
|
||||
},
|
||||
"api": {
|
||||
"provider": "deepseek",
|
||||
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
|
||||
"deepseek_model": "deepseek-v4-flash",
|
||||
"reasoning_enabled": false,
|
||||
@@ -508,6 +513,8 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
|
||||
> 默认模型为 `deepseek-v4-flash`(通过 `api.deepseek_model` 修改)。设置 `api.reasoning_enabled: true` 可启用思考模式,此时会在请求中添加 `thinking: {type: "enabled"}` 参数,模型会先进行思考再回答,实时展示思考过程(灰色斜体区域),回答质量更高且**支持工具调用**(文件操作、命令执行、记忆搜索等工具均可在思考模式下正常使用)。
|
||||
>
|
||||
> API Key 从环境变量 `DEEPSEEK_API_KEY` 读取(亦支持 `~/.cert/deepseek.key` 文件)。
|
||||
>
|
||||
> **多厂商切换**:大模型调用已通过 `aibox/llm_client.py` 抽象层解耦,业务层不再直接依赖具体厂商。配置 `api.provider`(默认 `deepseek`)即可切换厂商,当前内置 `DeepSeekClient`(兼容 OpenAI Chat Completions 协议,Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。新增厂商只需在此文件新增一个 `LLMClient` 子类并在 `create_llm_client` 工厂中注册,无需改动业务代码,为后续子代理/多模型共发预留了扩展点。
|
||||
|
||||
## 🛠️ 命令行参数
|
||||
|
||||
@@ -716,6 +723,18 @@ elif command == "/mycommand":
|
||||
|
||||
## 📄 更新日志
|
||||
|
||||
### v2.8
|
||||
- ✅ **新增 `express_emotion` 工具**:表达情绪并记录当前情绪状态(anger/joy/sadness/surprise),可指定 `persona` 从 `~/.aibox/skills/` 加载人格技能文件(如 angry/coquettish/tsundere),自动派生携带该人格的情绪子代理生成回应,保证人格独立、不污染主对话上下文
|
||||
- ✅ **新增 `/search <关键词>` 命令**:在 WebSocket 会话中按关键词搜索记忆,返回匹配的记忆条目
|
||||
|
||||
### v2.7
|
||||
- ✅ **新增子代理功能(`create_subagent` 工具)**:
|
||||
- 主代理可派生出拥有**独立上下文窗口**和**独立角色设定**的轻量子代理,不污染主对话上下文
|
||||
- 轻量子代理:无工具,通过一次 LLM 补全返回结果,适合长任务分解、上下文隔离、独立角色专注处理(如代码审查、文本总结、资料研究)
|
||||
- 子代理可选用**不同模型**(复用 `llm_client` 抽象层,`create_llm_client` 新增 `model` 参数)
|
||||
- 参数:`role`(角色/系统提示词,必填)、`task`(任务,必填)、`model`/`temperature`/`max_tokens`(可选)
|
||||
- `max_tokens` 已钳制到 `[1, 主配置 DEFAULT_MAX_TOKENS]`,防止意外超限消耗
|
||||
|
||||
### v2.6
|
||||
- ✅ **修复 Ctrl+C 中断无法停止服务器的问题**:
|
||||
- 新增 `interrupt` WebSocket 消息类型处理(BotOps 前端 Ctrl+C 发送此消息),立即回复 `interrupt_ack` 确认
|
||||
|
||||
@@ -507,6 +507,7 @@ CREATE TABLE memos (
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `provider` | str | `deepseek` | 模型厂商,用于 `llm_client` 工厂路由 |
|
||||
| `deepseek_api_url` | str | `https://api.deepseek.com/chat/completions` | API URL |
|
||||
| `deepseek_model` | str | `deepseek-v4-flash` | 模型名 |
|
||||
| `api_key_env` | str | `DEEPSEEK_API_KEY` | API Key 环境变量名 |
|
||||
|
||||
+913
-491
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
大模型客户端抽象层
|
||||
|
||||
为 aibox 提供统一的 LLM 调用接口,将「调用哪个厂商 / 哪个模型」与业务逻辑解耦。
|
||||
业务层只依赖 `LLMClient` 抽象方法与统一的流式事件格式,切换厂商只需:
|
||||
1. 新增一个 `LLMClient` 子类实现;
|
||||
2. 在 `create_llm_client` 工厂里按 `api.provider` 进行路由。
|
||||
|
||||
当前内置实现:
|
||||
- `DeepSeekClient`:兼容 OpenAI Chat Completions 协议(DeepSeek / Kimi / Qwen / GLM / Doubao 等绝大多数厂商均可复用)。
|
||||
|
||||
统一流式事件格式(`stream_chat` 逐条 yield 的 dict):
|
||||
{"type": "content", "text": str} # 增量正文
|
||||
{"type": "reasoning", "text": str} # 增量思考内容(可选,厂商支持时)
|
||||
{"type": "tool_call", "index": int, "id": str, "name": str, "arguments": str} # 工具调用增量
|
||||
{"type": "done"} # 流结束
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Iterator, List, Optional
|
||||
|
||||
import json
|
||||
import os
|
||||
import requests
|
||||
|
||||
|
||||
class LLMClient(ABC):
|
||||
"""大模型客户端抽象基类。子类需实现流式/非流式两种调用,并归一化为统一事件格式。"""
|
||||
|
||||
def __init__(self, api_key: str, model: str, base_url: str,
|
||||
timeout: int, config=None, logger=None):
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.timeout = timeout
|
||||
self.config = config # 可选,用于读取代理等动态配置
|
||||
self.logger = logger # 可选,用于日志输出
|
||||
# 当前活动的 HTTP 响应引用,供 cancel() 强制关闭底层连接以中断阻塞读取
|
||||
self._current_response = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""厂商/模型展示名,业务层可用于上下文(如 history 里的 assistant 名)。"""
|
||||
return "LLM"
|
||||
|
||||
# ---------- 工具方法 ----------
|
||||
|
||||
def _resolve_proxies(self) -> Optional[Dict[str, str]]:
|
||||
"""从 config 解析 requests 可用的 proxies 字典(动态代理)。"""
|
||||
if self.config is not None and hasattr(self.config, "get_proxy"):
|
||||
return self.config.get_proxy()
|
||||
return None
|
||||
|
||||
def _log(self, level: str, msg: str):
|
||||
if self.logger is not None and hasattr(self.logger, level):
|
||||
try:
|
||||
getattr(self.logger, level)(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cancel(self):
|
||||
"""中断当前流式请求。线程安全,可从任意线程调用。"""
|
||||
resp = self._current_response
|
||||
self._current_response = None
|
||||
if resp is not None:
|
||||
try:
|
||||
# 先关闭底层 raw socket,强制阻塞的 recv() 立即抛出异常
|
||||
if getattr(resp, "raw", None) is not None:
|
||||
resp.raw.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
resp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ---------- 抽象接口 ----------
|
||||
|
||||
@abstractmethod
|
||||
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 1024, **kwargs) -> str:
|
||||
"""非流式对话,返回完整文本。用于对话压缩等离线摘要场景。"""
|
||||
|
||||
@abstractmethod
|
||||
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||
"""流式对话,逐条 yield 统一事件格式的 dict。业务层据此驱动输出/工具调用。"""
|
||||
|
||||
|
||||
class DeepSeekClient(LLMClient):
|
||||
"""DeepSeek 客户端(OpenAI Chat Completions 协议兼容)。
|
||||
|
||||
思考模式通过 `thinking: {"type": "enabled"}` 参数控制,思考内容字段为
|
||||
`delta.reasoning_content`。二者均为 DeepSeek 特色,其他 OpenAI 兼容厂商
|
||||
若字段不同,可在子类中覆写 `_parse_delta` 归一化。
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str, base_url: str,
|
||||
timeout: int = 30, config=None, logger=None):
|
||||
super().__init__(api_key, model, base_url, timeout, config, logger)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "DeepSeek"
|
||||
|
||||
def _headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
def chat_completion(self, messages: List[Dict], temperature: float = 0.3,
|
||||
max_tokens: int = 1024, **kwargs) -> str:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
payload.update(kwargs)
|
||||
try:
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=30,
|
||||
proxies=self._resolve_proxies(),
|
||||
)
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
return result["choices"][0]["message"]["content"].strip()
|
||||
self._log("error", f"LLM 压缩请求失败: HTTP {response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self._log("error", f"LLM 压缩请求异常: {e}")
|
||||
return None
|
||||
|
||||
def stream_chat(self, messages: List[Dict], temperature: float = 0.7,
|
||||
max_tokens: int = 8192, tools: Optional[List[Dict]] = None,
|
||||
tool_choice: Optional[str] = None,
|
||||
reasoning_enabled: bool = False, **extra) -> Iterator[Dict]:
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
# 思考模式
|
||||
if reasoning_enabled:
|
||||
payload["thinking"] = {"type": "enabled"}
|
||||
# 工具调用
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = tool_choice or "auto"
|
||||
payload.update(extra)
|
||||
|
||||
proxies = self._resolve_proxies()
|
||||
response = requests.post(
|
||||
self.base_url,
|
||||
headers=self._headers(),
|
||||
json=payload,
|
||||
timeout=self.timeout,
|
||||
stream=True,
|
||||
proxies=proxies,
|
||||
)
|
||||
self._current_response = response
|
||||
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(
|
||||
f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
try:
|
||||
for line in response.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line_text = line.decode("utf-8")
|
||||
if not line_text.startswith("data: "):
|
||||
continue
|
||||
data = line_text[6:]
|
||||
if data == "[DONE]":
|
||||
yield {"type": "done"}
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError as e:
|
||||
self._log("error", f"JSON 解析错误: {e}, 数据: {data}")
|
||||
continue
|
||||
choices = chunk.get("choices", [])
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta", {})
|
||||
for event in self._parse_delta(delta, reasoning_enabled):
|
||||
yield event
|
||||
finally:
|
||||
self._current_response = None
|
||||
|
||||
def _parse_delta(self, delta: Dict, reasoning_enabled: bool) -> Iterator[Dict]:
|
||||
"""将单条 delta 归一化为统一事件。子类覆写以适配不同厂商字段。"""
|
||||
# 思考内容
|
||||
if reasoning_enabled and delta.get("reasoning_content"):
|
||||
yield {"type": "reasoning", "text": delta["reasoning_content"]}
|
||||
# 正文
|
||||
if delta.get("content"):
|
||||
yield {"type": "content", "text": delta["content"]}
|
||||
# 工具调用(增量)
|
||||
for tc in delta.get("tool_calls", []):
|
||||
index = tc.get("index", 0)
|
||||
function = tc.get("function", {})
|
||||
yield {
|
||||
"type": "tool_call",
|
||||
"index": index,
|
||||
"id": tc.get("id", ""),
|
||||
"name": function.get("name", ""),
|
||||
"arguments": function.get("arguments", ""),
|
||||
}
|
||||
|
||||
|
||||
def resolve_api_key(api_key: Optional[str], config=None) -> str:
|
||||
"""解析 API Key:优先显式传入,其次环境变量,最后 ~/.cert/deepseek.key。"""
|
||||
if api_key:
|
||||
return api_key
|
||||
env_name = None
|
||||
if config is not None and hasattr(config, "get"):
|
||||
env_name = config.get("api.api_key_env")
|
||||
env_name = env_name or "DEEPSEEK_API_KEY"
|
||||
env_key = os.environ.get(env_name)
|
||||
if env_key:
|
||||
return env_key
|
||||
try:
|
||||
home = os.environ.get("HOME", os.environ["USERPROFILE"])
|
||||
key_path = os.path.join(home, ".cert", "deepseek.key")
|
||||
with open(key_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
raise RuntimeError(
|
||||
f"请设置环境变量 {env_name} 或在配置文件中设置 API Key")
|
||||
|
||||
|
||||
def create_llm_client(config, api_key: Optional[str] = None,
|
||||
logger=None, model: Optional[str] = None) -> LLMClient:
|
||||
"""根据配置创建对应厂商的 LLM 客户端。可通过 `api.provider` 切换厂商。
|
||||
|
||||
Args:
|
||||
config: 配置对象(需提供 get 方法)。
|
||||
api_key: 显式传入的 API Key;为 None 时按 resolve_api_key 解析。
|
||||
logger: 可选日志对象。
|
||||
model: 可选模型名覆盖;为 None 时使用配置中的 `api.deepseek_model`。
|
||||
子代理等场景可通过此参数显式指定与主代理不同的模型。
|
||||
"""
|
||||
provider = (config.get("api.provider", "deepseek") or "deepseek").lower()
|
||||
base_url = config.get("api.deepseek_api_url")
|
||||
model = model or config.get("api.deepseek_model", "deepseek-v4-flash")
|
||||
timeout = config.get("api.timeout", 30)
|
||||
|
||||
resolved_key = resolve_api_key(api_key, config)
|
||||
|
||||
if provider in ("deepseek", "deepseek-chat"):
|
||||
return DeepSeekClient(
|
||||
api_key=resolved_key,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
config=config,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# 其他 OpenAI 兼容厂商:可直接复用 DeepSeekClient(继承其协议解析),
|
||||
# 或在此接入专有实现。新增厂商时在此扩展即可。
|
||||
raise ValueError(f"未知的模型提供商: {provider}")
|
||||
@@ -0,0 +1,113 @@
|
||||
# sysinfo - 获取系统运行时间、CPU、内存、磁盘、网络、负载、温度、电池等状态信息的技能
|
||||
|
||||
## 功能概述
|
||||
本技能用于查询 Linux 服务器/本机的系统状态信息。它通过 `psutil` 库和系统命令采集数据,支持按需获取单项或组合信息,支持文本和 JSON 两种输出格式。
|
||||
|
||||
**依赖要求**:需要 Python3 环境,并安装 `psutil` 库(`pip install psutil`)。若未安装,可改用 `/proc`、`free`、`df`、`uptime`、`top` 等系统命令替代(见下文备选方案)。
|
||||
|
||||
## 信息类型及对应获取方法
|
||||
|
||||
### 1. 系统基本信息 (system)
|
||||
- 系统名称、主机名、内核版本、硬件架构、处理器型号、Python 版本
|
||||
- 命令:`uname -a`、`hostname`、`python3 --version`
|
||||
|
||||
### 2. 运行时间 (uptime)
|
||||
- 开机时间、已运行时长(天/时/分/秒)
|
||||
- 命令:`uptime -s`(开机时间)、`uptime`(运行时长)
|
||||
|
||||
### 3. CPU 信息 (cpu)
|
||||
- 物理核心数、逻辑核心数、实时使用率、当前频率
|
||||
- 命令:`lscpu`、`nproc`、`top -bn1 | head -5`、`cat /proc/cpuinfo`
|
||||
|
||||
### 4. 内存信息 (memory)
|
||||
- 总内存、可用内存、已用内存、内存使用率、交换分区大小及使用率
|
||||
- 命令:`free -h`
|
||||
|
||||
### 5. 指定磁盘信息 (disk)
|
||||
- 指定路径(默认 `/`)的总空间、已用、可用、使用率
|
||||
- 命令:`df -h /路径`
|
||||
|
||||
### 6. 所有磁盘分区 (disks)
|
||||
- 所有挂载点的文件系统、总空间、已用、可用、使用率
|
||||
- 命令:`df -hT`
|
||||
|
||||
### 7. 进程信息 (process)
|
||||
- 进程总数、运行中进程数、睡眠进程数
|
||||
- 命令:`ps -e | wc -l`、`ps -eo stat | grep -c '^R'`、`ps -eo stat | grep -c '^S'`
|
||||
|
||||
### 8. 网络信息 (network)
|
||||
- 累计发送/接收的数据量(MB)、发送/接收包数
|
||||
- 命令:`cat /proc/net/dev`、`ip -s link`
|
||||
|
||||
### 9. 系统负载 (load)
|
||||
- 1/5/15 分钟负载及每核心负载
|
||||
- 命令:`cat /proc/loadavg`、`uptime`
|
||||
|
||||
### 10. 温度信息 (temperature)
|
||||
- CPU/主板等传感器温度(仅支持部分硬件)
|
||||
- 命令:`sensors`(需安装 lm-sensors)、`cat /sys/class/thermal/thermal_zone*/temp`
|
||||
|
||||
### 11. 电池信息 (battery)
|
||||
- 电量百分比、是否充电中、剩余时间(仅笔记本)
|
||||
- 命令:`cat /sys/class/power_supply/BAT0/capacity`、`cat /sys/class/power_supply/BAT0/status`
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 方式一:直接运行原版 Python 脚本(若旧版技能仍可用)
|
||||
```bash
|
||||
# 获取系统基本信息
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info system
|
||||
|
||||
# 获取 CPU + 内存 + 磁盘信息
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info cpu --info memory --info disk
|
||||
|
||||
# 指定磁盘路径
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info disk --disk-path /home
|
||||
|
||||
# 获取所有信息(JSON 格式,便于程序解析)
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info all --format json
|
||||
|
||||
# 获取负载 + 温度 + 电池
|
||||
python3 /opt/micro_servers/aibox/skills/info.py --info load --info temperature --info battery
|
||||
```
|
||||
|
||||
### 方式二:使用系统命令(无 psutil 时的备选方案)
|
||||
```bash
|
||||
# 综合系统信息
|
||||
uname -a && hostname && uptime && free -h && df -hT && nproc
|
||||
```
|
||||
|
||||
## 参数说明(原版脚本)
|
||||
| 参数 | 简写 | 说明 |
|
||||
|------|------|------|
|
||||
| `--info` / `-i` | 可多次 | 信息类型:system/uptime/cpu/memory/disk/disks/process/network/load/temperature/battery/all |
|
||||
| `--disk-path` / `-d` | 默认 `/` | 磁盘检查路径 |
|
||||
| `--format` / `-f` | 默认 text | 输出格式:text 或 json |
|
||||
| `--help` / `-h` | - | 显示帮助与示例 |
|
||||
|
||||
## 输出示例(文本格式)
|
||||
```
|
||||
系统信息:
|
||||
系统: Linux
|
||||
主机名: my-server
|
||||
架构: x86_64
|
||||
CPU信息:
|
||||
物理核心数: 4
|
||||
逻辑核心数: 8
|
||||
使用率: 12.5%
|
||||
内存信息:
|
||||
总内存: 15.58 GB
|
||||
可用内存: 8.21 GB
|
||||
内存使用率: 45.2%
|
||||
```
|
||||
|
||||
## 典型使用场景
|
||||
1. **运维巡检**:配合定时任务,每日采集系统资源快照,监控负载与内存趋势
|
||||
2. **故障排查**:磁盘空间告警时,快速定位使用率最高的挂载点
|
||||
3. **性能评估**:获取 CPU 核心数、内存大小、网络吞吐等硬件基线数据
|
||||
4. **上报集成**:使用 `--format json` 输出结构化数据,便于脚本解析和上报
|
||||
|
||||
## 注意事项
|
||||
- 温度/电池信息仅在支持对应硬件的系统上可用,服务器通常无电池
|
||||
- CPU 使用率为瞬时采样(间隔 1 秒),如需平均值建议多次采样
|
||||
- 网络数据为累计值,对比时需记录时间差
|
||||
@@ -0,0 +1,97 @@
|
||||
# tech-manual - 生成Linux命令中文技术手册的技能(输入命令名,输出完整Markdown手册)
|
||||
|
||||
## 用途
|
||||
|
||||
当用户需要了解某个 Linux 命令的用法时,本技能指导智能体生成该命令的**完整中文技术手册**(Markdown 格式),涵盖语法、全部常用选项、环境变量、退出状态、实战示例和相关命令,内容基于 GNU coreutils / man pages 官方文档,准确实用。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 用户问:"帮我看看 xx 命令怎么用"
|
||||
- 用户说:"生成 xx 命令的技术手册"
|
||||
- 用户问:"xx 命令有哪些选项/参数"
|
||||
- 用户想深入学习某个命令(ls、find、grep、tar、awk、sed、du、df、ps、top、netstat、ss 等)
|
||||
|
||||
## 执行步骤
|
||||
|
||||
1. **确认命令名**:从用户输入中提取目标命令名(如 `ls`、`find`)。若用户未指定,可用 ask_user 询问。
|
||||
2. **收集资料**(可选):若对命令不熟悉,可运行 `man <命令> | col -b | head -200` 或 `info <命令>` 获取权威信息;也可用 `run_command` 执行 `命令 --help` 查看选项。
|
||||
3. **生成手册内容**:按以下固定结构编写 Markdown 手册:
|
||||
|
||||
### 手册结构(8 个章节)
|
||||
|
||||
```
|
||||
# <命令> 命令技术手册
|
||||
|
||||
## 1. 名称(NAME)
|
||||
<命令> — 一句话功能说明
|
||||
|
||||
## 2. 语法(SYNOPSIS)
|
||||
```bash
|
||||
<命令> [选项]... [参数]...
|
||||
```
|
||||
给出标准调用形式,说明缺省参数时的行为。
|
||||
|
||||
## 3. 描述(DESCRIPTION)
|
||||
命令的功能、工作原理、关键特性(3-6 条要点,可用列表)。
|
||||
|
||||
## 4. 选项(OPTIONS)
|
||||
分类列出所有常用与关键选项(至少 20 个),每个选项格式:
|
||||
#### `-x, --long-option`
|
||||
作用说明(一句话)。
|
||||
|
||||
```bash
|
||||
<命令> -x
|
||||
```
|
||||
可选:典型输出示例。
|
||||
|
||||
分类建议(按命令特点调整):
|
||||
- 基础选项
|
||||
- 排序/过滤选项
|
||||
- 输出格式选项
|
||||
- 时间/状态选项
|
||||
- 长格式字段说明(如 ls -l、ps -ef 的列含义)
|
||||
- 权限/特殊位解析(如适用)
|
||||
|
||||
## 5. 环境变量(ENVIRONMENT)
|
||||
影响该命令行为的环境变量,如 LS_COLORS、COLUMNS、TZ、LANG/LC_* 等。
|
||||
|
||||
## 6. 退出状态(EXIT STATUS)
|
||||
| 状态码 | 含义 |
|
||||
|--------|------|
|
||||
| 0 | 成功 |
|
||||
| 1 | 轻微错误(如文件不存在) |
|
||||
| 2 | 严重错误(如参数错误) |
|
||||
|
||||
## 7. 示例(EXAMPLES)
|
||||
至少 15 个真实场景示例,按类别分组(日常使用/排序查找/递归/时间/格式/管道脚本/实用技巧),每个示例用 ```bash 代码块并附注释。
|
||||
|
||||
## 8. 相关命令(SEE ALSO)
|
||||
以表格列出相关命令及其用途。
|
||||
|
||||
---
|
||||
|
||||
*本文档基于 GNU coreutils/man pages 编写,适用于大多数 Linux 发行版。BSD/macOS 部分选项有差异,以 man 手册为准。*
|
||||
```
|
||||
|
||||
4. **写入文件**:将手册写入工作区文件 `~/.aibox/workspace/<命令名>_manual.md`。
|
||||
- 内容较长时**分多次追加写入**(每次 200-400 行),避免 heredoc 超限。
|
||||
- 写入后校验:`wc -l <文件>` 和 `grep -E "^#{1,3} " <文件>` 检查行数和章节完整性。
|
||||
5. **汇报结果**:告知用户文件路径、总行数、章节概览,并询问是否需要进一步操作(如安装为技能、补充其他命令)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 内容必须**准确**,基于 GNU coreutils / 官方 man pages,不要臆造选项。
|
||||
- 代码示例统一用 ` ```bash ` 代码块。
|
||||
- 若命令是 shell 内建(如 cd、echo)或非 coreutils 命令,结构可适当调整,但保持 8 章节框架。
|
||||
- 写入工作区使用 `~/.aibox/workspace/` 目录,遵守工作目录规范。
|
||||
|
||||
## 典型示例
|
||||
|
||||
用户:"帮我生成 find 命令的技术手册"
|
||||
→ 执行本技能,输出 `~/.aibox/workspace/find_manual.md`
|
||||
|
||||
用户:"grep 有哪些常用选项?"
|
||||
→ 执行本技能,生成 grep 手册并重点展示选项章节
|
||||
|
||||
用户:"看看 tar 命令怎么打包解压"
|
||||
→ 执行本技能,生成 tar 手册并重点展示示例章节
|
||||
@@ -50,7 +50,7 @@ _confs_port = int(os.environ.get('CONFS_PORT', '8010'))
|
||||
# 白名单结构:_merge_ini 仅覆盖此处列出的 section/key,防止意外字段
|
||||
_captcha_config = {
|
||||
'server': {'host': '', 'port': '', 'workers': ''},
|
||||
'captcha': {'len': '', 'ttl': '', 'font_path': ''},
|
||||
'captcha': {'len': '', 'ttl': '', 'font_path': '', 'max_store': ''},
|
||||
}
|
||||
|
||||
|
||||
@@ -98,12 +98,15 @@ if os.environ.get('WORKERS'):
|
||||
_captcha_config['server']['workers'] = os.environ['WORKERS']
|
||||
if os.environ.get('CAPTCHA_FONT_PATH'):
|
||||
_captcha_config['captcha']['font_path'] = os.environ['CAPTCHA_FONT_PATH']
|
||||
if os.environ.get('CAPTCHA_MAX_STORE'):
|
||||
_captcha_config['captcha']['max_store'] = os.environ['CAPTCHA_MAX_STORE']
|
||||
|
||||
# 配置完整性校验
|
||||
_required = {
|
||||
'server': ['host', 'port', 'workers'],
|
||||
'captcha': ['len', 'ttl'],
|
||||
}
|
||||
# max_store 是可选配置(非必填)
|
||||
for section, keys in _required.items():
|
||||
for key in keys:
|
||||
if not _captcha_config[section][key]:
|
||||
@@ -115,6 +118,14 @@ for section, keys in _required.items():
|
||||
CAPTCHA_LEN = int(_captcha_config['captcha']['len'])
|
||||
CAPTCHA_TTL = int(_captcha_config['captcha']['ttl'])
|
||||
|
||||
# 验证码存储容量上限(可选配置,默认 10000 个),防止内存耗尽(审计 P1-1)
|
||||
_default_max_store = '10000'
|
||||
MAX_STORE_SIZE = int(_captcha_config['captcha']['max_store'] or _default_max_store)
|
||||
if MAX_STORE_SIZE <= 0:
|
||||
print('[captcha] 致命错误: 配置项 [captcha] max_store 必须为正整数',
|
||||
file=_sys.stderr)
|
||||
_sys.exit(1)
|
||||
|
||||
# 字体路径:可选配置项(缺失或用空值时不致命,回退到代码默认值;
|
||||
# 即使默认值文件不存在,PIL 在 truetype() 失败时会回退到 load_ Default())。
|
||||
DEFAULT_FONT_PATH = '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf'
|
||||
@@ -207,13 +218,21 @@ class CaptchaServer:
|
||||
token = secrets.token_hex(16)
|
||||
|
||||
with STORE_LOCK:
|
||||
captcha_store[token] = {'answer': text.lower(), 'created_at': time.time()}
|
||||
# 惰性清理过期项
|
||||
now = time.time()
|
||||
expired = [k for k, v in captcha_store.items() if now - v['created_at'] > CAPTCHA_TTL]
|
||||
for k in expired:
|
||||
del captcha_store[k]
|
||||
|
||||
# 容量上限保护(P1-1):清理过期后仍超限则拒绝新生成,防止内存耗尽
|
||||
if len(captcha_store) >= MAX_STORE_SIZE:
|
||||
logs.warning("captcha",
|
||||
f"验证码存储已满({len(captcha_store)}/{MAX_STORE_SIZE}),拒绝生成")
|
||||
return {"success": False,
|
||||
"error": "too many pending captchas, please try again later"}
|
||||
|
||||
captcha_store[token] = {'answer': text.lower(), 'created_at': now}
|
||||
|
||||
png_bytes = generate_image(text)
|
||||
logs.info("captcha", f"验证码已生成 token={token[:8]}...")
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ tls = off
|
||||
[Proxy]
|
||||
default = http://127.0.0.1:9200
|
||||
closed = http://127.0.0.1:8005;http://127.0.0.1:8007;
|
||||
opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
|
||||
opened = http://127.0.0.1:9200;http://127.0.0.1:8006;http://127.0.0.1:5200;
|
||||
|
||||
[Token]
|
||||
# Token 有效期(秒),默认 3600 = 1 小时
|
||||
@@ -23,7 +23,7 @@ max_refresh = 10
|
||||
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
|
||||
# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。
|
||||
# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。
|
||||
users = qing@qsont.me;shufeng@qsont.me
|
||||
users = 1776177662@qq.com;277665039@qq.com;
|
||||
|
||||
[CORS]
|
||||
# 允许跨域的 Origin 白名单(分号分隔)
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ CPP:= g++
|
||||
|
||||
INCS:= -I include/
|
||||
|
||||
MY_LIBS := -L/usr/lib -lus_th -lus_http
|
||||
MY_LIBS := -L/usr/lib -lus_th -lus_http -lus_httpclient -lcurl
|
||||
LIBS := -lpthread -lssl -lcrypto -lboost_program_options -lboost_json -lboost_thread -lboost_filesystem
|
||||
|
||||
CPPFLAGS := --std=c++20 -O3 -pipe #-Wall -Werror
|
||||
|
||||
+13
-13
@@ -280,20 +280,20 @@ try {
|
||||
}
|
||||
|
||||
/* 20260727(qing): 在身份验证之前禁止操作 */
|
||||
//else if (typ == "kill") {
|
||||
// int trg = jv.at("kill").as_int64();
|
||||
// auto flag = pmc_mtd::kill(trg);
|
||||
// std::string msg = flag ? "OK" : "Nope";
|
||||
// pipe.writePipe(msg);
|
||||
// return;
|
||||
//}
|
||||
else if (typ == "kill") {
|
||||
int trg = jv.at("kill").as_int64();
|
||||
auto flag = pmc_mtd::kill(trg);
|
||||
std::string msg = flag ? "OK" : "Nope";
|
||||
pipe.writePipe(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
//else if (typ == "exec"){
|
||||
// std::string cmd = jv.at("exec").as_string().c_str();
|
||||
// std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
|
||||
// pipe.writePipe(msg);
|
||||
// return;
|
||||
//}
|
||||
else if (typ == "exec"){
|
||||
std::string cmd = jv.at("exec").as_string().c_str();
|
||||
std::string msg = (pmc_mtd::exec(cmd)) ? "OK" : "ERR";
|
||||
pipe.writePipe(msg);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* 捕获异常后,仅仅只是打印出来? */
|
||||
|
||||
+102
-1
@@ -1,6 +1,9 @@
|
||||
#include <iostream>
|
||||
#include <cstdlib>
|
||||
#include <boost/property_tree/ptree.hpp>
|
||||
#include <boost/property_tree/json_parser.hpp>
|
||||
#include <boost/json.hpp>
|
||||
#include <us/HttpClient.hpp>
|
||||
#include "main/HttpTask.hh"
|
||||
#include "main/pmc_mtd.hh"
|
||||
#include "main/subsys_call.hh"
|
||||
@@ -50,7 +53,9 @@ namespace qing {
|
||||
|
||||
/* start callback 唤醒事件 */
|
||||
this->th->set_wake( [this](Thread& th) -> void {
|
||||
this->server = std::make_unique<pmc::net::HttpServer> ("127.0.0.1", port, "server.crt", "server.key", 4);
|
||||
// core 的 HTTP 服务仅供 gateway 内部代理转发(WAF 已终止 TLS)
|
||||
// 使用无证书的纯 HTTP 构造,不启用 TLS
|
||||
this->server = std::make_unique<pmc::net::HttpServer> ("127.0.0.1", port, 4);
|
||||
handler(*this->server);
|
||||
th.run();
|
||||
|
||||
@@ -126,6 +131,78 @@ static std::string url_decode(const std::string& str) {
|
||||
|
||||
namespace process_http_server {
|
||||
|
||||
/* 从 Cookie 头中解析指定名称的值(找不到返回空串) */
|
||||
static std::string get_cookie_value(const std::string& cookie_header, const std::string& name) {
|
||||
size_t pos = 0;
|
||||
while (pos <= cookie_header.size()) {
|
||||
size_t end = cookie_header.find(';', pos);
|
||||
if (end == std::string::npos) end = cookie_header.size();
|
||||
std::string pair = cookie_header.substr(pos, end - pos);
|
||||
size_t eq = pair.find('=');
|
||||
if (eq != std::string::npos) {
|
||||
std::string k = pair.substr(0, eq);
|
||||
size_t b = k.find_first_not_of(" \t");
|
||||
size_t e = k.find_last_not_of(" \t");
|
||||
if (b != std::string::npos) k = k.substr(b, e - b + 1);
|
||||
if (k == name) return pair.substr(eq + 1);
|
||||
}
|
||||
if (end == cookie_header.size()) break;
|
||||
pos = end + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/* 管理员验证:调用 gateway 的 /admin/verify 验签端点,验证请求携带的 Token 是否为有效管理员。
|
||||
* gateway 是 Token 签发与验证的唯一权威(RSA 验签 + is_admin + 过期检查)。
|
||||
* gateway 地址由环境变量 GATEWAY_HOST/GATEWAY_PORT 配置,默认 127.0.0.1:8443。
|
||||
* gateway 不可用或响应异常一律视为未授权(fail-closed)。 */
|
||||
static bool verify_admin_token(const http::request<http::string_body>& req) {
|
||||
try {
|
||||
/* 提取 Token:Authorization 头优先,回退 Cookie */
|
||||
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;
|
||||
|
||||
/* gateway 地址 */
|
||||
std::string host = "127.0.0.1";
|
||||
int port = 8443;
|
||||
if (const char* h = std::getenv("GATEWAY_HOST")) host = h;
|
||||
if (const char* p = std::getenv("GATEWAY_PORT")) {
|
||||
int v = std::atoi(p);
|
||||
if (v > 0) port = v;
|
||||
}
|
||||
|
||||
std::string url = "http://" + host + ":" + std::to_string(port) + "/admin/verify";
|
||||
|
||||
pmc::net::HttpClient client;
|
||||
client.set_header("Authorization: " + raw);
|
||||
std::string body;
|
||||
client.post_stream_sync(url, "", [&](const std::string& chunk) { body += chunk; });
|
||||
|
||||
auto jv = boost::json::parse(body);
|
||||
if (!jv.is_object()) return false;
|
||||
auto& obj = jv.as_object();
|
||||
auto it_success = obj.find("success");
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_success == obj.end() || it_admin == obj.end()) return false;
|
||||
if (!it_success->value().is_bool() || !it_admin->value().is_bool()) return false;
|
||||
return it_success->value().as_bool() && it_admin->value().as_bool();
|
||||
}
|
||||
catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 注册POST路由 ---- */
|
||||
void registerRoutes(pmc::net::HttpServer& server) {
|
||||
server.post("/list", [](const auto& req, const auto& params) {
|
||||
@@ -199,6 +276,14 @@ namespace process_http_server {
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "application/json");
|
||||
|
||||
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
|
||||
if (!verify_admin_token(req)) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
std::string target = getTarget(params);
|
||||
|
||||
@@ -226,6 +311,14 @@ namespace process_http_server {
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "application/json");
|
||||
|
||||
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
|
||||
if (!verify_admin_token(req)) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
std::string target = getTarget(params);
|
||||
std::string command = getExecCommand(params);
|
||||
@@ -271,6 +364,14 @@ namespace process_http_server {
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "application/json");
|
||||
|
||||
/* 管理员认证:调用 gateway 验签,未授权直接拒绝 */
|
||||
if (!verify_admin_token(req)) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = buildJsonResponse(false, "Unauthorized: admin token required");
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
try {
|
||||
std::string target = getTarget(params);
|
||||
int pid = getPid(params);
|
||||
|
||||
@@ -34,7 +34,8 @@ auto list() -> std::string {
|
||||
processes.push_back({ /* 向json中推入对象 */
|
||||
{"index", i},
|
||||
{"pid", ppool[i].pid()},
|
||||
{"status", ppool[i].check()}
|
||||
{"running", ppool[i].isRunning()},
|
||||
{"command", ppool[i].check()}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ def print_rows(cols, rows):
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="查看 database.db")
|
||||
parser.add_argument("table", nargs="?", help="要查看的表名 (不指定则列出所有表)")
|
||||
parser.add_argument("-n", "--limit", type=int, default=50, help="最多显示行数 (默认 50)")
|
||||
parser.add_argument("-n", "--limit", type=int, default=50, help="显示最后 N 行 (默认 50)")
|
||||
parser.add_argument("-w", "--where", help="WHERE 条件, 如: \"level='ERROR'\"")
|
||||
parser.add_argument("-s", "--sql", help="自由 SQL 查询 (忽略 table/where/limit)")
|
||||
args = parser.parse_args()
|
||||
@@ -65,15 +65,17 @@ def main():
|
||||
conn.close()
|
||||
return
|
||||
|
||||
# 查看指定表
|
||||
# 查看指定表:取最后 N 行(按 rowid 倒序取,再正序展示)
|
||||
# 用子查询包装:内层倒序取末尾 N 行,外层按 rowid 正序还原展示顺序
|
||||
if args.table not in tables:
|
||||
print(f"表 '{args.table}' 不存在, 可用: {', '.join(tables)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
sql = f"SELECT * FROM [{args.table}]"
|
||||
inner = f"SELECT * FROM [{args.table}]"
|
||||
if args.where:
|
||||
sql += f" WHERE {args.where}"
|
||||
sql += f" LIMIT {args.limit}"
|
||||
inner += f" WHERE {args.where}"
|
||||
inner += f" ORDER BY rowid DESC LIMIT {args.limit}"
|
||||
sql = f"SELECT * FROM ({inner}) ORDER BY rowid ASC"
|
||||
|
||||
cur.execute(sql)
|
||||
cols = [d[0] for d in cur.description]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
fix_orphan_notes — 修复 Notes 表中"父节点不存在"的记录。
|
||||
|
||||
Notes 是父子结构(pid 指向父节点 id,0 表示无父/根)。当父节点被删除后,
|
||||
子节点的 pid 会悬空(指向一个不存在的 id)。本脚本把这些 pid 悬空的记录的
|
||||
pid 改为 0,使其成为根节点。
|
||||
|
||||
通过 db 服务的 pymsg execute 路由执行(不直接连库),与业务访问方式一致。
|
||||
|
||||
用法:
|
||||
python fix_orphan_notes.py # 连接 db 服务(默认 127.0.0.1:8005)
|
||||
DB_HOST=127.0.0.1 DB_PORT=8005 python fix_orphan_notes.py
|
||||
|
||||
说明:
|
||||
- 默认只打印将修复的记录,不实际修改;加 --apply 才真正执行修改。
|
||||
- --id 显式指定需要修复的节点 id(逗号分隔),否则自动检测所有 pid 悬空节点。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import argparse
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from pymsg import RpcClient # noqa: E402
|
||||
|
||||
DB_HOST = os.environ.get('DB_HOST', '127.0.0.1')
|
||||
DB_PORT = int(os.environ.get('DB_PORT', '8005'))
|
||||
|
||||
# SQLite 默认 SQLITE_MAX_VARIABLE_NUMBER=999,in 子句分批大小取一个安全值
|
||||
_IN_BATCH = 500
|
||||
|
||||
|
||||
def _rpc():
|
||||
return RpcClient(DB_HOST, DB_PORT, 'fix_orphan_notes', timeout=10)
|
||||
|
||||
|
||||
def _select(client, columns, where=None):
|
||||
payload = {'action': 'select', 'table': 'Notes', 'columns': columns}
|
||||
if where:
|
||||
payload['where'] = where
|
||||
return client.call('execute', payload)
|
||||
|
||||
|
||||
def _find_orphan_ids(client):
|
||||
"""返回所有 pid 指向不存在节点的记录 id 列表。"""
|
||||
all_ids = {row['id'] for row in _select(client, ['id'])}
|
||||
refs = _select(client, ['id', 'pid'], {'pid': {'ne': 0}})
|
||||
orphan = [row['id'] for row in refs if row['pid'] not in all_ids]
|
||||
return orphan
|
||||
|
||||
|
||||
def _fix(client, ids, apply):
|
||||
"""把指定 id 的记录的 pid 改为 0。返回实际修改行数。"""
|
||||
total = 0
|
||||
for i in range(0, len(ids), _IN_BATCH):
|
||||
batch = ids[i:i + _IN_BATCH]
|
||||
if not apply:
|
||||
print(f" [dry-run] 节点 {batch} 的 pid 将改为 0")
|
||||
continue
|
||||
resp = client.call('execute', {
|
||||
'action': 'update',
|
||||
'table': 'Notes',
|
||||
'values': {'pid': 0},
|
||||
'where': {'id': {'in': batch}},
|
||||
})
|
||||
total += resp[0]['rowcount'] if resp else 0
|
||||
return total
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='修复 Notes 表中 pid 悬空的记录')
|
||||
parser.add_argument('--apply', action='store_true',
|
||||
help='真正执行修改(默认 dry-run 仅打印)')
|
||||
parser.add_argument('--id', help='指定需要修复的节点 id(逗号分隔),否则自动检测')
|
||||
args = parser.parse_args()
|
||||
|
||||
client = _rpc()
|
||||
|
||||
if args.id:
|
||||
target = [int(x) for x in args.id.split(',') if x.strip()]
|
||||
else:
|
||||
target = _find_orphan_ids(client)
|
||||
|
||||
if not target:
|
||||
print("没有发现需要修复的节点(所有 pid 均指向存在的父节点)。")
|
||||
return
|
||||
|
||||
print(f"待修复 {len(target)} 个节点: {target}")
|
||||
if not args.apply:
|
||||
print("dry-run 模式:未做任何修改。加 --apply 真正执行。")
|
||||
_fix(client, target, apply=False)
|
||||
return
|
||||
|
||||
fixed = _fix(client, target, apply=True)
|
||||
print(f"完成:已修复 {fixed}/{len(target)} 个节点 pid -> 0。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+26
-32
@@ -45,7 +45,7 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点
|
||||
Authorization: Bearer <base64_payload>_<rsa_sign>
|
||||
```
|
||||
|
||||
- Token 由 gateway 在 `/login` 签发,dnfs 通过 [dnfs.py:304](dnfs.py#L304) `decode_token` 仅解 Base64 取 payload,**当前代码不验签**(代码中标注 `# FIXME: 验证数字签名`,依赖部署上经 gateway 转发的网络隔离)。
|
||||
- Token 由 gateway 在 `/login` 签发;dnfs 通过 [dnfs.py:312](dnfs.py#L312) `decode_token` 将 Token 转发至 gateway `/whoami` 接口,由 gateway 完成 **RSA 签名验证 + 过期检查**(不再本地解码验签);gateway 不可达时按 **fail-closed** 返回 503,验签失败返回 None。
|
||||
- payload 中取 `user` 字段作为操作者,写入 `owner`。
|
||||
- `delete_note` 与 `update_note` 校验所有权:
|
||||
- 节点 `owner` 必须等于当前 `user`,否则 `403`;
|
||||
@@ -59,14 +59,14 @@ Authorization: Bearer <base64_payload>_<rsa_sign>
|
||||
| `_sp` | 信封字段 | 前端表现 |
|
||||
|-------|---------|---------|
|
||||
| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 |
|
||||
| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 |
|
||||
| `js` | `code`(完整 HTML/CSS/JS 文档,≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(解析完整 HTML 文档注入当前页面:style 注入 head、script 在全局作用域执行可加特效、body 渲染到节点下方;console 输出捕获到下方)按钮 |
|
||||
| `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`。
|
||||
- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`js` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。
|
||||
- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。
|
||||
|
||||
## 接口文档
|
||||
@@ -214,7 +214,7 @@ curl -X POST http://localhost:8006/update_note \
|
||||
- **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: SAMEORIGIN`、`X-XSS-Protection`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`、`Permissions-Policy`(禁用摄像头/麦克风/地理位置)、CSP(`default-src 'self'` 等),并移除 `Server` 头。
|
||||
- **Token 鉴权**:写操作强制校验,payload 取 `user` 字段。
|
||||
- **所有权隔离**:普通用户只能改/删自己的节点。
|
||||
- **特殊节点管控**:link/html/image/file 类型有格式校验与大小限制(URL 前缀、文件名长度、内容大小上限)。
|
||||
- **特殊节点管控**:link/js/image/file 类型有格式校验与大小限制(URL 前缀、文件名长度、内容大小上限)。
|
||||
- **内容审核**:写入与更新前调用 `ai_review`,非法内容会被拒绝(fail-closed 设计,见已知问题)。
|
||||
- **异步日志**:通过 pymsg `AsyncRpcQueue` 异步批量写日志,fail-safe 不阻塞业务线程。
|
||||
- **非 root 容器化运行**:Dockerfile 中使用 appuser 非特权用户运行服务。
|
||||
@@ -306,45 +306,39 @@ bash _run.sh # 启动服务(waitress)
|
||||
|
||||
## 已知问题与风险
|
||||
|
||||
审计日期: 2026-08-01
|
||||
> 状态基准:2026-08-01 审计 + 2026-08-03 逐条代码验证。
|
||||
|
||||
### 🔴 P0 — 严重问题(需立即修复)
|
||||
### 🔴 P0 — 严重问题
|
||||
|
||||
1. **Token 无 RSA 签名验证,可伪造任意用户身份(含管理员)** — [dnfs.py:304-311](dnfs.py#L304-L311)
|
||||
- `decode_token()` 直接 base64 解码 payload,完全跳过 RSA 签名验证(代码中有 `# FIXME: 验证数字签名` 注释)
|
||||
- 若 dnfs 被直接访问(绕过 gateway),攻击者可构造 `{"user": "任意邮箱", "is_admin": true}` 的 payload,base64 编码后拼接下划线+任意字符串即可伪造管理员身份,绕过所有权限校验
|
||||
- **参考实现**: gateway `verifyWsAdminToken` 展示了正确流程:分割 `_` → base64 解码 → RSA 公钥验签 → 检查过期时间
|
||||
- **修复建议**: 实现 RSA 签名验证(持有 gateway 公钥)+ Token 过期校验(`iat`/`created_at` + `keep_time`)
|
||||
1. **Token 无 RSA 签名验证,可伪造任意用户身份(含管理员)** — ✅ 已修复
|
||||
- 原 `decode_token()` 本地 base64 解码 payload、跳过 RSA 验签(`# FIXME: 验证数字签名`)。
|
||||
- 现已重写为转发 gateway `/whoami` 完成验签 + 过期检查([dnfs.py:312-351](dnfs.py#L312-L351)),gateway 不可达时 fail-closed 返回 503。
|
||||
|
||||
2. **`review_content()` 错误处理断裂,fail-closed 失效** — [dnfs.py:149-195](dnfs.py#L149-L195)
|
||||
- 成功路径返回 `bool`,但 HTTPError/URLError/其他异常直接 `raise e`,没有返回 `False` 实现 fail-closed
|
||||
- 内层 `except json.JSONDecodeError as e` 变量名遮蔽外层 `HTTPError as e`
|
||||
- ai_review 不可用时异常向上传播导致 500,而非拒绝内容(违反 fail-closed 设计意图)
|
||||
- **修复建议**: 所有异常路径返回 `False` 或抛 DnfsExp 拒绝写入
|
||||
2. **`review_content()` 错误处理 / fail-closed** — ⚠️ 基本修复
|
||||
- 已实现 fail-closed:HTTPError/URLError/JSONDecodeError 等异常路径均拒绝请求(写入/更新被拒、负载不送审)。
|
||||
- 遗留:函数返回类型不一致(成功返回 `bool`,而 `_validate_sp` 等期望 dict),建议代码清理(审计 P2-1)。
|
||||
|
||||
3. **`delete_note` 和 `update_note` 缺少 `@_handle_exp` 装饰器** — [dnfs.py:428](dnfs.py#L428), [dnfs.py:531](dnfs.py#L531)
|
||||
- `add_note`/`get_notes` 使用了 `@_handle_exp` 统一异常处理,但 `delete_note`/`update_note` 没有
|
||||
- 抛出 `DnfsExp` 或其他异常时返回 Flask 默认 500 HTML 错误页,与其他接口 JSON 响应不一致
|
||||
3. **`delete_note` / `update_note` 缺少 `@_handle_exp` 装饰器** — ✅ 已修复
|
||||
- 现两函数均已加 `@_handle_exp`([dnfs.py:469](dnfs.py#L469)、[dnfs.py:572](dnfs.py#L572)),异常统一返回结构化 JSON,不再出 Flask 默认 500 页。
|
||||
|
||||
4. **有子节点的父节点可能被删除(树结构完整性保护缺陷)**
|
||||
- **数据库层缺失外键约束**: 虽然 dbv2 启用了 `PRAGMA foreign_keys=ON`,但 Notes 表建表 SQL([db/create_sqlite3.sql:13-19](../db/create_sqlite3.sql#L13-L19))中 `pid` 列只是普通 INTEGER,没有定义 `FOREIGN KEY (pid) REFERENCES Notes(id) ON DELETE RESTRICT`
|
||||
- **应用层 TOCTOU 竞态**: 子节点查询(SELECT)和删除(DELETE)是两个独立 RPC 调用,无事务保护,检查与删除之间可插入子节点导致父节点被删后子节点成为孤儿
|
||||
- **错误处理缺陷**: `_call_dbv2` 返回 `success=False` 时响应中无 `data` 键,直接访问 `notes['data']` 会抛 KeyError 而非正确处理
|
||||
- **修复建议**: (a) 添加数据库外键约束; (b) 应用层检查返回值 `success` 状态; (c) 使用数据库事务将检查+删除原子化
|
||||
4. **有子节点的父节点可能被删除(树结构完整性保护缺陷)** — ❌ 未修复
|
||||
- **数据库层缺失外键约束**: Notes 表 `pid` 列未定义 `FOREIGN KEY ... ON DELETE RESTRICT`([db/create_sqlite3.sql](../db/create_sqlite3.sql))。
|
||||
- **应用层 TOCTOU 竞态**: 子节点查询(SELECT)与删除(DELETE)是两个独立 RPC,无事务保护。
|
||||
- **错误处理缺陷**: 多处直接访问 `notes['data']` 而未先确认 `success`(如 [dnfs.py:387](dnfs.py#L387)、[L519](dnfs.py#L519)),`success=False` 时可能抛 KeyError。
|
||||
|
||||
### 🟠 P1 — 高危问题
|
||||
|
||||
5. **Token 无过期校验** — payload 中包含 `iat`/`created_at`/`keep_time` 字段,但 `decode_token()` 完全不检查,泄露的 Token 可永久使用
|
||||
6. **请求体大小无限制** — Waitress 未配置 `max_request_body_size`,可能被大请求体 DoS;建议设置 10MB 上限与 WAF 对齐
|
||||
7. **`log_client.py` 配置与主模块不一致** — [log_client.py:32-33](log_client.py#L32-L33) 直接读 `DB_HOST`/`DB_PORT` 环境变量,不从 confs 拉取配置,可能连错地址
|
||||
8. **`update_note` 修改 pid 时未验证目标父节点存在** — `add_note` 会检查 pid≠0 时父节点是否存在,但 `update_note` 直接更新不检查,可将节点移动到不存在的父节点下
|
||||
9. **db RPC 客户端未启用启动等待** — `_rpc = RpcClient(...)` 未设置 `retry_on_refused=True`,db 未就绪时首次请求失败(虽然 confs 连接启用了重试)
|
||||
10. **`delete_note`/`update_note` 中 `decode_token` 无 try-except** — `get_notes` 中包裹了异常处理,但这两个函数没有,base64 解码/JSON 解析错误直接抛 500
|
||||
11. **`note_id` 缺少强制类型转换** — `note_id: int = data.get('id')` 只是类型提示,不会实际转换;传入字符串 `"5"` 依赖 SQLite 类型亲和性隐式匹配
|
||||
5. **Token 无过期校验** — ✅ 已修复(gateway `/whoami` 基于 `created_at` + `keep_time` 统一校验,dnfs 不再自行处理)
|
||||
6. **请求体大小无限制** — ❌ 未修复([dnfs.py:723](dnfs.py#L723) `serve(app, ...)` 未配置 `max_request_body_size`,建议设 10MB 与 WAF 对齐)
|
||||
7. **`log_client.py` 配置与主模块不一致** — ❌ 未修复([log_client.py:32-33](log_client.py#L32-L33) 直接读 `DB_HOST`/`DB_PORT` 环境变量,不拉取 confs)
|
||||
8. **`update_note` 修改 pid 时未验证目标父节点存在** — ❌ 未修复([update_note](dnfs.py#L573) 直接更新,不检查 pid≠0 时父节点是否存在)
|
||||
9. **db RPC 客户端未启用启动等待** — ❌ 未修复([dnfs.py:133](dnfs.py#L133) `_rpc = RpcClient(...)` 无 `retry_on_refused=True`)
|
||||
10. **`delete_note`/`update_note` 中 `decode_token` 无 try-except** — ✅ 基本解决(两函数均由 `@_handle_exp` 统一捕获异常,不再裸抛 500)
|
||||
11. **`note_id` 缺少强制类型转换** — ❌ 未修复([dnfs.py:474](dnfs.py#L474)、[L577](dnfs.py#L577) 仅类型提示,依赖 SQLite 类型亲和性隐式匹配)
|
||||
|
||||
### 🟡 P2 — 中危问题
|
||||
|
||||
12. **错误响应泄露内部信息** — 多处错误消息拼接 `request.remote_addr` 和用户名返回给客户端(如"未授权访问,从xxx"),应仅记录到服务端日志
|
||||
12. **错误响应泄露内部信息** — 多处错误消息拼接 `request.remote_addr` 和用户名返回给客户端,应仅记录到服务端日志
|
||||
13. **CORS 配置过于宽松** — `CORS(app)` 默认允许所有来源(`*`),建议限制为实际前端域名
|
||||
14. **`_call_dbv2` 返回值访问前未统一检查 success** — 多处直接访问 `notes['data']` 而不先确认 `success==True`
|
||||
15. **`pid` 参数判断逻辑不严谨** — `if not pid: pid = 0` 会将空字符串/None 等非法输入默默转为 0,掩盖参数错误
|
||||
|
||||
+8
-8
@@ -210,13 +210,13 @@ class DnfsExp(Exception):
|
||||
return self.status
|
||||
|
||||
# ==================== 特殊节点(管理员专属) ====================
|
||||
# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染;
|
||||
# 信封格式:content 为 JSON {"_sp": "link|js|image|file", ...},前端识别后差异化渲染;
|
||||
# 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。
|
||||
_SP_TYPES = ('link', 'html', 'image', 'file')
|
||||
_SP_TYPES = ('link', 'js', 'image', 'file')
|
||||
_SP_MAX_URL = 2048 # link 节点 url 长度上限
|
||||
_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符)
|
||||
_SP_MAX_CODE = 512 * 1024 # js 节点代码长度上限(字符)
|
||||
_SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据)
|
||||
_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度
|
||||
_SP_REVIEW_CODE_PREFIX = 4000 # js 节点送审的代码前缀长度
|
||||
|
||||
|
||||
def _parse_sp(content):
|
||||
@@ -245,12 +245,12 @@ def _validate_sp(sp):
|
||||
raise DnfsExp('链接节点必须是 http/https URL', 400)
|
||||
if len(url) > _SP_MAX_URL:
|
||||
raise DnfsExp('链接过长', 400)
|
||||
elif t == 'html':
|
||||
elif t == 'js':
|
||||
code = sp.get('code', '')
|
||||
if not isinstance(code, str) or not code.strip():
|
||||
raise DnfsExp('网页代码不能为空', 400)
|
||||
raise DnfsExp('前端代码不能为空', 400)
|
||||
if len(code) > _SP_MAX_CODE:
|
||||
raise DnfsExp('网页代码过大(上限 512KB)', 400)
|
||||
raise DnfsExp('前端代码过大(上限 512KB)', 400)
|
||||
elif t == 'image':
|
||||
data = sp.get('data', '')
|
||||
if not isinstance(data, str) or not data.startswith('data:image/'):
|
||||
@@ -279,7 +279,7 @@ def _sp_review_text(title, sp):
|
||||
t = sp['_sp']
|
||||
if t == 'link':
|
||||
parts.append(str(sp.get('url', '')))
|
||||
elif t == 'html':
|
||||
elif t == 'js':
|
||||
parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX])
|
||||
else: # image / file
|
||||
parts.append(str(sp.get('name', '')))
|
||||
|
||||
+1
-3
@@ -7,7 +7,6 @@
|
||||
```
|
||||
docs/
|
||||
├── INDEX.md # 本文件(文档总索引)
|
||||
├── SECURITY_AUDIT_20260801.md # 全局安全审计总览
|
||||
├── security/ # 安全审计与加固相关
|
||||
├── reviews/ # 代码审查日志
|
||||
└── reports/ # 其他分析报告
|
||||
@@ -29,7 +28,6 @@ docs/
|
||||
|
||||
| 文档 | 说明 |
|
||||
|------|------|
|
||||
| [SECURITY_AUDIT_20260801.md](security/SECURITY_AUDIT_20260801.md) | **全局总览** — 全模块安全审计汇总(问题等级定义、各模块问题汇总、修复跟踪) |
|
||||
| [DEPLOY_SECURITY_AUDIT_20260801.md](security/DEPLOY_SECURITY_AUDIT_20260801.md) | 部署配置安全审计(docker-compose、systemd、防火墙、TLS 等) |
|
||||
| [DEPS_SECURITY_AUDIT_20260801.md](security/DEPS_SECURITY_AUDIT_20260801.md) | 依赖安全审计(第三方库版本、已知漏洞、许可证) |
|
||||
| [SECURITY_HARDENING_PLAN_20260803.md](security/SECURITY_HARDENING_PLAN_20260803.md) | 安全加固计划(内网通信安全加固开发计划) |
|
||||
@@ -49,8 +47,8 @@ docs/
|
||||
| smtp | [smtp_SECURITY_AUDIT_20260801.md](security/smtp_SECURITY_AUDIT_20260801.md) | Python 邮件服务(验证码发送/校验) |
|
||||
| captcha | [captcha_SECURITY_AUDIT_20260801.md](security/captcha_SECURITY_AUDIT_20260801.md) | Python 验证码服务 |
|
||||
| confs | [confs_SECURITY_AUDIT_20260801.md](security/confs_SECURITY_AUDIT_20260801.md) | Python 统一配置服务 |
|
||||
| libhttp | [libhttp_SECURITY_AUDIT_20260803.md](security/libhttp_SECURITY_AUDIT_20260803.md) | C++ HTTP/WebSocket 库(请求/响应/WS/静态文件) |
|
||||
| aibox | [aibox_SECURITY_AUDIT_20260801.md](security/aibox_SECURITY_AUDIT_20260801.md) | Python AI 智能体系统(WebSocket) |
|
||||
| aibox (详版) | [aibox_SECURITY_AUDIT_full_20260801.md](security/aibox_SECURITY_AUDIT_full_20260801.md) | aibox 详细安全审计(含具体漏洞位置与修复建议) |
|
||||
| BotOps | [BotOps_SECURITY_AUDIT_20260801.md](security/BotOps_SECURITY_AUDIT_20260801.md) | BotOps 运维控制面板 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,427 +0,0 @@
|
||||
# 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
|
||||
@@ -0,0 +1,194 @@
|
||||
# aibox 开放版开发计划
|
||||
|
||||
## 背景
|
||||
|
||||
当前 aibox 主分支完整功能包含**命令执行工具**(`run_command`),该功能存在 **P0-1 严重命令注入漏洞**([aibox_SECURITY_AUDIT_20260801.md](../security/aibox_SECURITY_AUDIT_20260801.md)):
|
||||
|
||||
- 使用 `shell=True` + 用户输入拼接,可执行任意系统命令
|
||||
- P0-2 WebSocket 无认证,结合可导致远程 RCE
|
||||
- 不适合在公开网络环境下开放使用
|
||||
|
||||
**目标**:创建一个**阉割版 aibox**,移除命令执行、文件操作等危险功能,保留核心对话、技能系统、记忆管理、任务调度,可安全开放使用。
|
||||
|
||||
## 版本分支
|
||||
|
||||
- `main`:保持现有完整功能,供本地可信环境使用
|
||||
- `open`:开放阉割版,移除所有危险功能,安全可公开访问
|
||||
|
||||
## 功能裁剪方案
|
||||
|
||||
| 功能 | 主分支完整版 | 开放阉割版 | 备注 |
|
||||
|------|-------------|------------|------|
|
||||
| AI 对话(流式输出) | ✅ | ✅ | 保留核心能力 |
|
||||
| WebSocket 前端交互 | ✅ | ✅ | 需要认证 |
|
||||
| DeepSeek API 调用 | ✅ | ✅ | 保留 |
|
||||
| 思考模式(Reasoning) | ✅ | ✅ | 保留 |
|
||||
| 子代理 | ✅ | ✅ | 保留(无工具调用) |
|
||||
| 情绪表达(情绪化技能) | ✅ | ✅ | 保留 |
|
||||
| 记忆搜索/保存 | ✅ | ✅ | 保留 |
|
||||
| 备忘录/任务调度 | ✅ | ✅ | 保留 |
|
||||
| 扩展技能系统 | ✅ | ✅ | 只保留 Markdown 技能,移除可执行技能 |
|
||||
| 提示词系统 | ✅ | ✅ | 保留(identity/soul 更新) |
|
||||
| 对话压缩 | ✅ | ✅ | 保留 |
|
||||
| SOCKS5 代理 | ✅ | ✅ | 保留(API/网页访问) |
|
||||
| --- | --- | --- | --- |
|
||||
| `read_file` 读文件 | ✅ | ❌ | 移除,防止任意文件读取(路径遍历 P1-1) |
|
||||
| `write_file` 写文件 | ✅ | ❌ | 移除,防止任意文件写入 |
|
||||
| `list_files` 列目录 | ✅ | ❌ | 移除 |
|
||||
| `run_command` 命令执行 | ✅ | ❌ | **完全移除**,P0-1 根源 |
|
||||
| `simple_web` 网页访问 | ✅ | ✅ | 需要 SSRF 防护(内网 IP 过滤) |
|
||||
| `create_skill` 创建技能 | ✅ | ❌ | 移除(会创建文件) |
|
||||
| `install_skill` 安装技能 | ✅ | ❌ | 移除(会安装可执行) |
|
||||
| `update_identity` / `update_soul` | ✅ | ✅ | 保留,仅修改提示词文件 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
## 需要添加的安全措施
|
||||
|
||||
### 1. WebSocket 认证(P0-2 修复)
|
||||
- 绑定地址强制改为 `127.0.0.1`,通过 gateway/WAF 反向代理接入
|
||||
- 握手阶段校验 `token` 查询参数或 `Authorization: Bearer <token>`
|
||||
- token 从配置文件读取,环境变量可覆盖
|
||||
- 不匹配立即关闭连接,不处理任何消息
|
||||
|
||||
### 2. SSRF 防护(P1-2 修复)
|
||||
- `SimpleWebCrawlerTool` 访问网页前解析主机 IP
|
||||
- 禁止访问内网 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`(链路本地)
|
||||
- `0.0.0.0/8`
|
||||
- `100.64.0.0/10`(共享地址)
|
||||
- 禁止非 HTTP/HTTPS 协议(仅允许 `http://`/`https://`)
|
||||
- 跟随重定向时重新校验目标地址
|
||||
|
||||
### 3. 可执行技能完全禁止
|
||||
- `SkillManager` 只加载 `.md` Markdown 技能
|
||||
- 跳过目录中所有可执行文件(无扩展名或 `.py`/`.sh`)
|
||||
- `search_skills` 只返回 Markdown 技能
|
||||
- 移除 `create_skill`/`install_skill` 工具
|
||||
|
||||
### 4. 文件操作限制
|
||||
- **完全移除** `read_file`/`write_file`/`list_files`
|
||||
- `update_identity`/`update_soul` 只允许修改 `~/.aibox/prompts/` 下固定文件,不允许路径遍历
|
||||
- `check_path_safety` 严格校验文件名,确保在允许目录内
|
||||
|
||||
### 5. 频率限制(P2-1 修复)
|
||||
- 每个连接/每个 token 每分钟限制消息轮数(默认 30 轮/分钟)
|
||||
- 超限返回 `error: rate limit exceeded`
|
||||
|
||||
### 6. 其他加固
|
||||
- 启动时强制校验 `DEEPSEEK_API_KEY` 配置,无效直接退出(P1-4 修复)
|
||||
- 错误信息脱敏,不泄露路径/栈追踪(P1-3 修复)
|
||||
- 日志脱敏,不记录 API Key、token 等敏感信息(P2-6 修复)
|
||||
|
||||
## 代码重构步骤
|
||||
|
||||
### 第一步:工具层移除危险工具
|
||||
1. 在 `DeepSeekChat._register_tools()` 中:
|
||||
- 删除/注释 `ReadFileTool`
|
||||
- 删除/注释 `WriteFileTool`
|
||||
- 删除/注释 `ListFilesTool`
|
||||
- 删除/注释 `CommandLineTool`
|
||||
- 删除/注释 `CreateSkillTool`
|
||||
- 删除/注释 `InstallSkillTool`
|
||||
- 保留:`SearchSkillsTool`、`ReloadSkillsTool`、`AskUserTool`、`ExpressEmotionTool`、`UpdateIdentityTool`、`UpdateSoulTool`、`SaveMemoryAndEndConversationTool`、`MemoTool`、`MemoryTool`、`SimpleWebCrawlerTool`、`GetCurrentTimeTool`、`CalculatorTool`、`SubAgentTool`、`SetProxyTool`、`GetProxyTool`
|
||||
|
||||
### 第二步:修改 SkillManager 只加载 Markdown 技能
|
||||
1. `SkillManager._load_skills()` 中只加载 `.md` 文件
|
||||
2. 跳过所有其他扩展名(包括无扩展名)
|
||||
3. `search_skills` 只返回 Markdown 技能
|
||||
4. 移除执行相关代码路径
|
||||
|
||||
### 第三步:添加 WebSocket 认证
|
||||
1. 在 `handle_connection` 握手时检查 token:
|
||||
- 从 `query` 中获取 `token` 参数,或从 `Authorization: Bearer` 获取
|
||||
- 与配置文件中 `auth.token` 比对
|
||||
- 不匹配:关闭连接,返回 1008 错误
|
||||
2. 配置新增 `auth.token` 项,必填
|
||||
3. 默认监听地址改为 `127.0.0.1`(原 `localhost`)
|
||||
|
||||
### 第四步:SimpleWebCrawler 添加 SSRF 防护
|
||||
1. 新增 `_is_private_ip(ip)` 函数判断是否内网
|
||||
2. 爬取前:
|
||||
- 解析 URL 获取主机名
|
||||
- DNS 解析得到 IP 地址列表
|
||||
- 逐个检查,任一命中内网则拒绝
|
||||
- 检查协议,只允许 http/https
|
||||
3. 跟随重定向时重复检查
|
||||
|
||||
### 第五步:频率限制实现
|
||||
1. `WebSocketHandler` 维护 `rate_limit: Dict[connection_id, (last_time, count)]`
|
||||
2. 每次处理消息前检查计数,超限拒绝
|
||||
|
||||
### 第六步:update_identity/update_soul 路径加固
|
||||
1. 只允许写 `00_IDENTITY.md` 和 `99_SOUL.md`
|
||||
2. 文件名硬编码,不允许用户指定路径
|
||||
3. 写入前 `realpath` 校验,确保在 `prompts_dir` 内
|
||||
|
||||
### 第七步:其他细节
|
||||
1. 启动时强制校验 API Key,不存在立即退出
|
||||
2. 错误信息脱敏,服务端记录详细错误,客户端只返回通用错误
|
||||
3. 日志脱敏,不打印 API Key/token
|
||||
|
||||
## 配置文件变化(`config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
// ... 原有配置不变 ...
|
||||
"auth": {
|
||||
"token": "your-secret-token-here", // 新增 必填
|
||||
"enabled": true // 新增 默认 true
|
||||
},
|
||||
"rate_limit": {
|
||||
"enabled": true, // 新增 默认 true
|
||||
"max_requests_per_minute": 30 // 新增 默认 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 部署说明
|
||||
|
||||
开放版必须通过 gateway/WAF 反向代理接入:
|
||||
```
|
||||
Client → WAF (TLS 终止) → gateway (认证) → aibox (127.0.0.1:8765)
|
||||
```
|
||||
- aibox 只监听 `127.0.0.1`,不直接暴露
|
||||
- gateway 完成 token/JWT 认证
|
||||
- WAF 处理 TLS 和规则过滤
|
||||
|
||||
## 安全验证点
|
||||
|
||||
发布前必须验证:
|
||||
|
||||
- [ ] `run_command` 工具不存在,无法调用
|
||||
- [ ] `read_file`/`write_file`/`list_files` 不存在
|
||||
- [ ] `create_skill`/`install_skill` 不存在
|
||||
- [ ] 无 token 连接被拒绝
|
||||
- [ ] 错误 token 连接被拒绝
|
||||
- [ ] 正确 token 可连接
|
||||
- [ ] 内网 URL 爬取被拒绝(SSRF 防护)
|
||||
- [ ] 超过频率限制被拒绝
|
||||
- [ ] 启动时无 API Key 直接退出
|
||||
- [ ] `update_identity`/`update_soul` 只能写固定文件,无法遍历
|
||||
- [ ] 只有 `.md` 技能被加载
|
||||
|
||||
## 发布路线图
|
||||
|
||||
| 阶段 | 任务 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段 1 | 工具裁剪、SkillManager 修改 | ⏸ 待开始 |
|
||||
| 阶段 2 | WebSocket 认证实现 | ⏸ 待开始 |
|
||||
| 阶段 3 | SSRF 防护(SimpleWebCrawler) | ⏸ 待开始 |
|
||||
| 阶段 4 | 频率限制、启动校验、错误脱敏 | ⏸ 待开始 |
|
||||
| 阶段 5 | 路径加固(update_identity/update_soul) | ⏸ 待开始 |
|
||||
| 阶段 6 | 安全验证测试 | ⏸ 待开始 |
|
||||
| 阶段 7 | 更新 README、更新文档 | ⏸ 待开始 |
|
||||
|
||||
## 风险与约束
|
||||
|
||||
- 阉割后功能减少,但安全性大大提高,可公开部署使用
|
||||
- 仍保留 AI 对话、记忆、任务调度、子代理、扩展 Markdown 技能等核心特性
|
||||
- 用户仍可通过 AI 能力总结、分析、创作,只是无法直接操作服务器文件和执行命令
|
||||
- 若需要文件操作,建议通过 gateway 提供受限 API,不开放给 aibox 直接操作
|
||||
@@ -16,15 +16,15 @@
|
||||
| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 |
|
||||
|------|------|--------|----------|--------|--------|
|
||||
| P0 | 0 | 0 | 0 | 0 | - |
|
||||
| P1 | 1 | 0 | 0 | 1 | 0% |
|
||||
| P1 | 1 | 1 | 0 | 0 | 100% |
|
||||
| P2 | 1 | 0 | 0 | 1 | 0% |
|
||||
| **合计** | **2** | **0** | **0** | **2** | **0%** |
|
||||
| **合计** | **2** | **1** | **0** | **1** | **50%** |
|
||||
|
||||
### 逐条验证
|
||||
|
||||
| 编号 | 状态 | 代码位置 | 验证说明 |
|
||||
|------|------|----------|----------|
|
||||
| P1-1 | ❌ 未修复 | `captcha_service.py` L128-129, L204-222 | `captcha_store` dict 无容量上限,`_handle_image` 无生成速率限制,大量请求可耗尽内存 |
|
||||
| P1-1 | ✅ 已修复 | `captcha_service.py` L227-232 | 新增 `MAX_STORE_SIZE`(默认 10000,可配置 `max_store`/`CAPTCHA_MAX_STORE`),`_handle_image` 在加锁后先清理过期项,再检查容量,超限拒绝生成并返回错误,防止内存耗尽 |
|
||||
| P2-1 | ❌ 未修复 | `captcha_service.py` L128 | 仍为进程内存 dict,未迁移至 Redis;当前单实例可接受 |
|
||||
|
||||
---
|
||||
@@ -34,7 +34,7 @@
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 0 | 0 | 0 |
|
||||
| P1 | 1 | 0 | 1 |
|
||||
| P1 | 1 | 1 | 0 |
|
||||
| P2 | 1 | 0 | 1 |
|
||||
|
||||
---
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
| # | 问题 | 说明 |
|
||||
|---|------|------|
|
||||
| P1-1 | 验证码存储无容量限制,大量生成可耗尽内存 | 无验证码数量上限,攻击者可高频调用 `/captcha/image` 生成大量验证码填满内存 |
|
||||
| P1-1 | ~~验证码存储无容量限制,大量生成可耗尽内存~~ | ✅ 已修复(2026-08-04):新增 `MAX_STORE_SIZE`(默认 10000),`_handle_image` 清理过期后容量仍满则拒绝生成并返回 `too many pending captchas`,防止内存耗尽 |
|
||||
|
||||
---
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
## 修复路线图
|
||||
|
||||
### 短期
|
||||
- [ ] 添加验证码存储容量上限(如最多 10000 个,超出时清理最早的)
|
||||
- [x] 添加验证码存储容量上限(默认 10000,超出时拒绝新生成)✅ 已修复(2026-08-04)
|
||||
- [ ] 添加生成速率限制(单 IP 每分钟最多生成 N 个)
|
||||
- [ ] 将 `generate_text()` 中的 `random.choices` 替换为 `secrets` 模块
|
||||
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
| 优先级 | 总数 | 已修复 | 部分修复 | 未修复 |
|
||||
|--------|------|--------|----------|--------|
|
||||
| P0 | 1 | 0 | 1 | 0 |
|
||||
| P1 | 5 | 0 | 1 | 4 |
|
||||
| P2 | 2 | 1 | 0 | 1 |
|
||||
| **合计** | **8** | **1** | **2** | **5** |
|
||||
| P1 | 6 | 0 | 1 | 5 |
|
||||
| P2 | 4 | 1 | 0 | 3 |
|
||||
| **合计** | **11** | **1** | **2** | **8** |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: RSA 私钥存储在仓库中/硬编码
|
||||
- **位置**: `main.cpp` L669 `_KEYGEN = std::make_unique<MyRSA::Generator>();`
|
||||
- **位置**: `main.cpp` L681 `_KEYGEN = std::make_unique<MyRSA::Generator>();`
|
||||
- **当前状态**: ⚠️ 部分修复
|
||||
- 密钥由 `MyRSA::Generator` 在内存中运行时生成,**未硬编码在源码中**,**未随仓库分发**
|
||||
- 每个进程实例独立生成密钥对,满足"每个部署独立密钥"要求
|
||||
@@ -37,11 +37,12 @@
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P1-1 | Token 吊销机制缺失 | ❌ 未修复 | `handleLogout` (L1160) 仅清除 Cookie,无服务端黑名单/吊销列表;修改密码/注销后旧 Token 仍有效直到过期 |
|
||||
| P1-2 | 登录失败次数限制 | ⚠️ 部分修复 | IP 级限流已实现 (`_LOGIN_RATE`, L998, 5次/300秒),但**无账户级锁定**(同一邮箱多次失败不触发账户锁定) |
|
||||
| P1-3 | 上游代理超时配置 | ❌ 未修复 | `handleProxy` (L1712) 中 `HttpClient` 未显式设置超时,依赖 libhttp 默认值 |
|
||||
| P1-1 | Token 吊销机制缺失 | ❌ 未修复 | `handleLogout` (L1184) 仅清除 Cookie,无服务端黑名单/吊销列表;修改密码/注销后旧 Token 仍有效直到过期 |
|
||||
| P1-2 | 登录失败次数限制 | ⚠️ 部分修复 | IP 级限流已实现 (`_LOGIN_RATE` L371,`check_and_record(ip,5,300)` L1022),但**无账户级锁定**(同一邮箱多次失败不触发账户锁定) |
|
||||
| P1-3 | 上游代理超时配置 | ❌ 未修复 | `handleProxy` (L1947) 中 `HttpClient` 未显式设置超时,依赖 libhttp 默认值 |
|
||||
| P1-4 | C++ HTTP 解析器缓冲区溢出审计 | ❌ 未修复 | libhttp 库(`src/shared/`)需全面 fuzz 测试 |
|
||||
| P1-5 | 请求体大小限制 | ❌ 未修复 | 未配置显式请求体大小上限(如 10MB),依赖 Boost.Beast 默认行为 |
|
||||
| P1-6 | 反向代理响应无大小限制 | ❌ 未修复 | `handleProxy` 回调 L2069-2074 拼接下游响应 body 无上限(`res.body() += ret`,带 `FIXME: 增加长度限制`),下游(static/dnfs)返回超大响应可导致 OOM(全局审计 P1-1) |
|
||||
|
||||
---
|
||||
|
||||
@@ -49,8 +50,10 @@
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P2-1 | 错误页面泄露版本号/堆栈信息 | ✅ 已修复 | `wrapperHttp` (L919-940) 异常捕获返回统一 `"Internal Server Error"`,详细错误仅输出到 stderr,不向客户端暴露 |
|
||||
| P2-2 | HTTPS TLS 版本和密码套件安全 | ❌ 未修复 | `main.cpp` L664-668 TLS 模式下仅指定证书文件,未显式限定 TLS 版本和 CipherSuites |
|
||||
| P2-1 | 错误页面泄露版本号/堆栈信息 | ✅ 已修复 | `wrapperHttp` (L909-940) 异常捕获返回统一 `"Internal Server Error"`;`ExceptionBase::build_message()` 中文件/行号/函数名已注释(`ExpBase.hpp` L173-197),`what()` 仅含业务消息,不向客户端暴露源码位置 |
|
||||
| P2-2 | HTTPS TLS 版本和密码套件安全 | ❌ 未修复 | `main.cpp` L677 TLS 模式下仅指定证书文件,未显式限定 TLS 版本和 CipherSuites |
|
||||
| P2-3 | CORS 通配(反射任意 Origin) | ❌ 未修复 | **根因**:`_CORS_ORIGINS` 白名单 (L593) 已读取但**从未用于响应头**——OPTIONS 预检由 libhttp 库处理(`HttpServer_Request.cpp` L73-74),直接反射请求 Origin,libhttp 不感知 gateway 的 `_CORS_ORIGINS` 状态,等于允许任意来源(全局审计 P2)。修复需在 libhttp 层接入白名单或由 gateway 注册显式 OPTIONS 路由 |
|
||||
| P2-4 | HTTP 服务器无超时配置 | ❌ 未修复 | 未设置 Read/Write/Idle 超时,`HttpServer(addr,port,threads)` 构造函数无超时参数,依赖 libhttp 默认行为,存在 Slowloris/连接耗尽风险(全局审计 P2) |
|
||||
|
||||
---
|
||||
|
||||
@@ -60,15 +63,15 @@
|
||||
2. ✅ **RSA Token 签名**: Token 使用 RSA 私钥签名,防伪造 (`main.cpp` L1112-1130)
|
||||
3. ✅ **反向代理架构**: 统一入口,隐藏后端服务拓扑
|
||||
4. ✅ **速率限制**: 多层防护(IP 级 + 邮箱级双窗口限流、60秒冷却、失败锁定)
|
||||
5. ✅ **防用户名枚举**: 用户不存在时执行 dummy PBKDF2 校验拉平响应时序 (`main.cpp` L1078-1083)
|
||||
5. ✅ **防用户名枚举**: 用户不存在时执行 dummy PBKDF2 校验拉平响应时序 (`main.cpp` L1102-1108)
|
||||
6. ✅ **WAF 前置防护**: Go WAF 处理 SQLi/XSS/CC/协议解析
|
||||
7. ✅ **C++ 高性能实现**: 避免 Python GIL 瓶颈,抗并发能力强
|
||||
8. ✅ **依赖审计**: 使用项目自带的 libcrypt 加密库(PBKDF2、RSA)
|
||||
9. ✅ **路径遍历防护**: `is_safe_target()` 校验请求路径,拒绝 `..`、空字节、URL 编码遍历 (`main.cpp` L1926-1952)
|
||||
10. ✅ **X-Forwarded-For 可信注入**: 剥离客户端伪造的 IP 头后由 gateway 主动设置 (`main.cpp` L1853-1863)
|
||||
11. ✅ **优雅关闭**: SIGINT/SIGTERM 信号处理 (`main.cpp` L465-479)
|
||||
12. ✅ **Token 刷新机制**: 支持宽限期刷新 + 次数上限 + 限流 (`main.cpp` L1302-1453)
|
||||
13. ✅ **CAPTCHA 强制校验**: 登录/注册/发送验证码均需 CAPTCHA,对接 captcha 服务 (`main.cpp` L426-442)
|
||||
9. ✅ **路径遍历防护**: `is_safe_target()` 校验请求路径,拒绝 `..`、空字节、URL 编码遍历 (`main.cpp` L2161-2186)
|
||||
10. ✅ **X-Forwarded-For 可信注入**: 剥离客户端伪造的 IP 头后由 gateway 主动设置 (`main.cpp` L2088-2098, `is_special_header` L2126-2138)
|
||||
11. ✅ **优雅关闭**: SIGINT/SIGTERM 信号处理 (`main.cpp` L484-490)
|
||||
12. ✅ **Token 刷新机制**: 支持宽限期刷新 + 次数上限 + 限流 (`main.cpp` L1339-1450)
|
||||
13. ✅ **CAPTCHA 强制校验**: 登录/注册/发送验证码均需 CAPTCHA,对接 captcha 服务 (`main.cpp` L438, 调用点 L1044/1671/1784/1905)
|
||||
14. ✅ **异步日志**: 批量推送 + fail-safe,db 不可用时静默丢弃 (`LogC.hpp`)
|
||||
|
||||
---
|
||||
@@ -77,6 +80,7 @@
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 增加请求体大小限制(参考 waf 10MB 配置)
|
||||
- [ ] 反向代理响应体大小限制(P1-6,防 OOM)
|
||||
- [ ] 代理上游 HttpClient 增加显式超时配置
|
||||
|
||||
### 短期(72小时)
|
||||
@@ -88,6 +92,8 @@
|
||||
### 中期
|
||||
- [x] 错误页面脱敏 → ✅ 已完成
|
||||
- [ ] TLS 安全配置(限定 TLS 版本和 CipherSuites)
|
||||
- [ ] CORS 白名单生效(`_CORS_ORIGINS` 接入响应头,替代反射任意 Origin)
|
||||
- [ ] HTTP 服务器超时配置(Read/Write/Idle)
|
||||
- [ ] 管理操作审计日志
|
||||
- [ ] RSA 密钥持久化(避免重启后 Token 全部失效)
|
||||
|
||||
@@ -103,31 +109,34 @@
|
||||
| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 |
|
||||
|------|------|--------|----------|--------|--------|
|
||||
| P0 | 1 | 0 | 1 | 0 | 50% (部分) |
|
||||
| P1 | 5 | 0 | 1 | 4 | 10% (部分) |
|
||||
| P2 | 2 | 1 | 0 | 1 | 50% |
|
||||
| **合计** | **8** | **1** | **2** | **5** | **25%** |
|
||||
| P1 | 6 | 0 | 1 | 5 | 8% (部分) |
|
||||
| P2 | 4 | 1 | 0 | 3 | 25% |
|
||||
| **合计** | **11** | **1** | **2** | **8** | **18%** |
|
||||
|
||||
### 逐条验证
|
||||
|
||||
| 编号 | 状态 | 代码位置 | 验证说明 |
|
||||
|------|------|----------|----------|
|
||||
| P0-1 | ⚠️ 部分 | `main.cpp` L669 | `MyRSA::Generator()` 运行时生成,非硬编码;但无持久化,重启后 Token 全部失效 |
|
||||
| P1-1 | ❌ 未修复 | `main.cpp` L1160-1174 | `handleLogout` 仅清除 Cookie,无 Token 黑名单/吊销机制 |
|
||||
| P1-2 | ⚠️ 部分 | `main.cpp` L998 | IP 限流已实现 (5次/300s),无账户级锁定 |
|
||||
| P1-3 | ❌ 未修复 | `main.cpp` L1712-1889 | `handleProxy` 中 `HttpClient` 未显式设置超时 |
|
||||
| P0-1 | ⚠️ 部分 | `main.cpp` L681 | `MyRSA::Generator()` 运行时生成,非硬编码;但无持久化,重启后 Token 全部失效 |
|
||||
| P1-1 | ❌ 未修复 | `main.cpp` L1184-1198 | `handleLogout` 仅清除 Cookie,无 Token 黑名单/吊销机制 |
|
||||
| P1-2 | ⚠️ 部分 | `main.cpp` L371, L1022 | IP 限流已实现 (5次/300s),无账户级锁定 |
|
||||
| P1-3 | ❌ 未修复 | `main.cpp` L1947-2124 | `handleProxy` 中 `HttpClient` 未显式设置超时 |
|
||||
| P1-4 | ❌ 未修复 | `src/shared/` | libhttp 库未进行 fuzz 测试 |
|
||||
| P1-5 | ❌ 未修复 | `main.cpp` | 未设置请求体大小上限 |
|
||||
| P2-1 | ✅ 已修复 | `main.cpp` L919-940 | 异常统一返回 `"Internal Server Error"`,不泄露类型/堆栈 |
|
||||
| P2-2 | ❌ 未修复 | `main.cpp` L664-668 | TLS 模式仅指定证书,未限定版本和密码套件 |
|
||||
| P1-6 | ❌ 未修复 | `main.cpp` L2069-2074 | `handleProxy` 回调 `res.body() += ret` 拼接下游响应 body 无上限,超大响应可 OOM |
|
||||
| P2-1 | ✅ 已修复 | `main.cpp` L909-940, `ExpBase.hpp` L173-197 | 异常统一返回 `"Internal Server Error"`;`build_message()` 已注释文件/行号/函数名,不泄露源码位置 |
|
||||
| P2-2 | ❌ 未修复 | `main.cpp` L677 | TLS 模式仅指定证书,未限定版本和密码套件 |
|
||||
| P2-3 | ❌ 未修复 | `main.cpp` L593, libhttp `HttpServer_Request.cpp` L73-74 | `_CORS_ORIGINS` 白名单读取但未用于响应头(OPTIONS 由 libhttp 处理,不感知 gateway 状态);OPTIONS 预检反射任意 Origin |
|
||||
| P2-4 | ❌ 未修复 | `main.cpp` L677-679, libhttp `HttpServer` | `HttpServer(addr,port,threads)` 无超时参数,未设置 HTTP 服务器 Read/Write/Idle 超时 |
|
||||
|
||||
### 新增安全机制(审计报告未覆盖)
|
||||
|
||||
| 机制 | 代码位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| 路径遍历防护 | `main.cpp` L1926-1952 | `is_safe_target()` 拒绝 `..`、空字节、URL 编码遍历 |
|
||||
| XFF 可信注入 | `main.cpp` L1853-1863 | 剥离客户端伪造 IP 头后主动设置可信 XFF |
|
||||
| 优雅关闭 | `main.cpp` L465-479 | SIGINT/SIGTERM 信号处理 |
|
||||
| 防用户枚举 | `main.cpp` L1078-1083 | 用户不存在时执行 dummy PBKDF2 拉平响应时序 |
|
||||
| 密码强度校验 | `main.cpp` L148-181 | 8-128字符,至少3种字符类别 |
|
||||
| 邮箱双窗口限流 | `main.cpp` L1652-1660 | 3次/小时 + 10次/天/邮箱 |
|
||||
| 路径遍历防护 | `main.cpp` L2161-2186 | `is_safe_target()` 拒绝 `..`、空字节、URL 编码遍历 |
|
||||
| XFF 可信注入 | `main.cpp` L2088-2098, L2126-2138 | `is_special_header` 剥离客户端伪造 IP 头后主动设置可信 XFF |
|
||||
| 优雅关闭 | `main.cpp` L484-490 | SIGINT/SIGTERM 信号处理 |
|
||||
| 防用户枚举 | `main.cpp` L1102-1108 | 用户不存在时执行 dummy PBKDF2 拉平响应时序 |
|
||||
| 密码强度校验 | `main.cpp` L146-176 | 8-128字符,至少3种字符类别 |
|
||||
| 邮箱双窗口限流 | `main.cpp` L1677-1681 | 3次/小时 + 10次/天/邮箱 |
|
||||
| 异步日志 fail-safe | `LogC.hpp` L210-213 | db 不可用时静默丢弃,绝不影响业务 |
|
||||
@@ -0,0 +1,109 @@
|
||||
# libhttp 安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-03
|
||||
**审计范围**: `libhttp/inc/us/`、`libhttp/src/`(HttpServer 6 个拆分 TU + HttpClient + 遗留 WebSocketServer/WebSocketClient)
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 部分修复 | 未修复 |
|
||||
|--------|------|--------|----------|--------|
|
||||
| P0 | 0 | 0 | 0 | 0 |
|
||||
| P1 | 6 | 6 | 0 | 0 |
|
||||
| P2 | 4 | 0 | 0 | 4 |
|
||||
| **合计** | **10** | **8** | **0** | **2** |
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P1-1 | 请求体大小限制不可配置 + 超限无 413 | ✅ 已修复 | `Session::doRead`(`HttpServer_Request.cpp` L167-186)改用 `http::request_parser` + `body_limit()`(默认 10MB,与 WAF 对齐);`HttpServer` 新增 `setMaxBodySize()`/`getMaxBodySize()`(`HttpServer.hpp` L118-120)。超限时 `handleRead` 返回 **413 Payload Too Large** 并关闭连接(L256-265),而非静默断连 |
|
||||
| P1-2 | HttpClient 响应无大小限制 | ✅ 已修复 | `HttpClient.cpp` 新增 `WriteCtx`(流式回调 + 累计字节 + 上限),`write_callback` 超限返回 0 触发 `CURLE_WRITE_ERROR`,`request_stream_sync` 抛异常;`request_stream_sync` 改为**真流式**(callback 非空时每块即回调,不二次累积),`HttpClient` 新增 `set_max_response_size()`/`get_max_response_size()`(默认 10MB,对齐 WAF,0 表示不限制) |
|
||||
| P1-3 | WebSocket 消息无大小限制 | ✅ 已修复 | `HttpServer` 新增 `max_ws_message_size_`(默认 16MB,与 Beast 默认一致)与 `setMaxWsMessageSize()`/`getMaxWsMessageSize()`。`doWsUpgrade`/`doWssUpgrade` 及代理流 `onProxyConnect`/`onProxySslHandshake` 创建后调用 `read_message_max()`,超限消息由 Beast 在分配内存前拒绝并关闭连接,防 OOM |
|
||||
| P1-4 | 服务器无读写超时(Slowloris) | ✅ 已修复 | 入站 `stream_` 现设置超时:`doSSLHandshake` 设头超时(覆盖握手),`doRead` 两阶段读(`async_read_header` 头超时 → `async_read` 体超时),`doWsRead` 每个 WS 读取设空闲超时。`HttpServer` 新增 `setHeaderTimeout()`(默认 10s)/`setReadTimeout()`(默认 30s),对齐 WAF ReadHeaderTimeout/ReadTimeout。慢速/空闲连接被清理,防 Slowloris/连接耗尽 |
|
||||
| P1-5 | 静态文件目录路径遍历 | ✅ 已修复 | 新增 `isSafeStaticPath()`(`HttpServer_Request.cpp` L139-156),静态文件服务前校验 target_path(拒绝 `..`、空字节、非 `/` 开头、URL 编码 `%2e`/`%2f`/`%5c`),逻辑与 gateway `is_safe_target()` 对齐(`HttpServer.cpp` L94) |
|
||||
| P1-6 | 发送队列无界(慢消费者 OOM) | ✅ 已修复 | `HttpServer` 新增 `setMaxWsQueueSize()`(默认 1000),`wsSend` / `onProxyReadClient` / `onProxyReadBackend` 入队前检查,超过上限判定慢消费者/慢后端,关闭连接并清空队列,防止内存耗尽 |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P2-1 | CORS 反射任意 Origin | ❌ 未修复 | OPTIONS 预检(`HttpServer_Request.cpp` L73-74)把 `Access-Control-Allow-Origin` 直接设为请求的 `Origin` 值,任意来源均被放行,无白名单校验(对应全局审计 P2-3 根因) |
|
||||
| P2-2 | TLS 客户端一律不校验证书 | ❌ 未修复 | `HttpClient.cpp` L131 与 WS 代理 `HttpServer_WsSession.cpp` L243 均 `set_verify_mode(asio::ssl::verify_none)`。对内部后端可接受,但库内不留逃生口,无法在需要校验时启用 |
|
||||
| P2-3 | WebSocket 握手无 Origin/CSWSH 校验 | ❌ 未修复 | libhttp 对任意 `ws`/`ws_proxy` upgrade 请求直接握手,不校验 `Origin`/`Sec-WebSocket-Protocol`。若后端依赖 Cookie 鉴权,存在跨站 WebSocket 劫持(CSWSH)风险(gateway 的 `ws_proxy` 依赖握手回调鉴权,属应用层缓解) |
|
||||
| P2-4 | HttpClient `ws_recv` 忽略 timeout 参数 | ❌ 未修复 | `HttpClient.cpp` L220-221 `(void)timeout_ms;` 丢弃超时参数,`ws_recv` 会**无限阻塞**。后端挂起时调用线程永久卡死 |
|
||||
| P2-5 | 反向代理后端连接建立后无超时 | ❌ 未修复 | 代理后端握手成功后 `proxy_tcp_stream_->expires_never()`(`HttpServer_WsSession.cpp` L262/L287),双向读写无超时,慢后端可长期占用连接 |
|
||||
| P2-6 | 无连接数限制 | ❌ 未修复 | `Listener::doAccept`(`HttpServer_Listener.cpp` L23-48)无限接受连接,每连接一个 Session,无最大连接数上限,可被连接耗尽 |
|
||||
|
||||
---
|
||||
|
||||
## 🔵 P3 — 低危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P3-1 | 遗留 WebSocketServer/WebSocketClient 未构建 | ✅ 已修复 | 已删除 `WebSocketServer.cpp`/`WebSocketClient.cpp`/`WebSocketServer.hpp`/`WebSocketClient.hpp`/`WebSocketClientBase.hpp`(未构建、与 `HttpClient` 别名同名冲突、有缺陷),并清理 Makefile/README 引用。WebSocket 能力由 `HttpServer` + `HttpClient` 提供 |
|
||||
| P3-2 | CMakeLists.txt 过时 | ✅ 已修复 | CMakeLists 已重写对齐 Makefile:`libus_http.so`(6 个服务端 TU)+ `libus_httpclient.so`(HttpClient),移除遗留文件引用与错误库名 |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **Beast 默认头/体限制**: `http::async_read` 使用 Beast 默认 `header_limit`(8KB) + `body_limit`(1MB),超限拒绝暴力请求
|
||||
2. ✅ **TLS 服务端密码套件受限**: `initSSLContext`(`HttpServer.cpp` L30-62)禁用 `!aNULL:!MD5:!DSS`,仅允许强套件
|
||||
3. ✅ **TLS 服务端禁用 SSLv2/v3**: `no_sslv2 | no_sslv3`(`HttpServer.cpp` L36-37)
|
||||
4. ✅ **会话生命周期管理**: `enable_shared_from_this` + `close()` 析构,连接关闭时清理 WS 代理队列
|
||||
5. ✅ **代理后端连接超时**: 建立连接阶段设 `expires_after(10s)`(`HttpServer_WsSession.cpp` L225/L253)
|
||||
6. ✅ **WS 消息顺序保持**: 写队列串行化,避免并发写交错
|
||||
7. ✅ **WS 代理握手鉴权回调**: `ws_proxy` 支持 `WsProxyHandshakeHandler` 在 upgrade 阶段授权(gateway 用于 admin-only 控制)
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [x] P1-1 请求体大小限制:`doRead` 改用 `http::request_parser` + `body_limit()`,`HttpServer` 增加 `setMaxBodySize()`(对齐 WAF 10MB),超限返回 413 而非断开 → ✅ 已实现(默认 10MB,`setMaxBodySize()` 可覆盖)
|
||||
- [x] P1-2 HttpClient 响应上限:`write_callback` 累计超过上限即失败,返回错误 → ✅ 已实现(`WriteCtx` 流式回调 + 上限,默认 10MB,`set_max_response_size()` 可覆盖)
|
||||
- [x] P1-4 服务器读超时:入站 `stream_` 设置 `expires_after`(首字节/头+体超时),防 Slowloris → ✅ 已实现(`setHeaderTimeout()` 默认 10s / `setReadTimeout()` 默认 30s,两阶段读 + WS 空闲超时)
|
||||
|
||||
### 短期(72小时)
|
||||
- [x] P1-3 WS 消息大小限制:设置 `read_message_max`(可配置)→ ✅ 已实现(`setMaxWsMessageSize()`,默认 16MB,服务端与代理流均生效)
|
||||
- [x] P1-5 静态文件路径遍历:新增 `isSafeStaticPath()` 拼接前校验 `target_path`,拒绝 `..`/空字节/编码遍历 → ✅ 已实现(`HttpServer_Request.cpp` L139-157)
|
||||
- [x] P1-6 发送队列上限:`ws_send_queue_`/代理队列设最大值,超限断开慢消费者 → ✅ 已实现(`setMaxWsQueueSize()`,默认 1000,入队前检查超限即关闭)
|
||||
- [ ] P2-1 CORS 白名单:origin 白名单校验,替代反射任意 Origin
|
||||
|
||||
### 中期
|
||||
- [ ] P2-3 WS 握手 Origin 校验(防 CSWSH)
|
||||
- [ ] P2-4 `ws_recv` 实现超时(用 `io_context` + timer 或 `async_read` + 私有超时)
|
||||
- [ ] P2-5 代理后端读写超时 + 空闲保活
|
||||
- [ ] P2-6 最大连接数限制
|
||||
- [ ] P2-2 TLS 校验可配置(至少提供 `verify_peer` 选项)
|
||||
- [x] P3-1 删除或维护遗留 WebSocketServer/WebSocketClient → ✅ 已删除
|
||||
- [x] P3-2 同步 CMakeLists.txt 与 Makefile → ✅ 已重写对齐
|
||||
|
||||
---
|
||||
|
||||
## 代码位置索引
|
||||
|
||||
| 功能 | 文件 | 行号 |
|
||||
|------|------|------|
|
||||
| 请求读入(body_limit 可配置) | `src/HttpServer_Request.cpp` | L167-186 |
|
||||
| 请求体超限返回 413 | `src/HttpServer_Request.cpp` | L256-265 |
|
||||
| setMaxBodySize/getMaxBodySize | `inc/us/HttpServer.hpp` | L118-120 |
|
||||
| 请求处理/静态文件 | `src/HttpServer_Request.cpp` | L7-137, L94-119 |
|
||||
| CORS 反射 Origin | `src/HttpServer_Request.cpp` | L73-74 |
|
||||
| WS 读消息(无大小限制) | `src/HttpServer_WsSession.cpp` | L88-111 |
|
||||
| WS 发送队列/代理队列 | `src/HttpServer_WsSession.cpp` | L8-16, L362-367, L438-443 |
|
||||
| 代理后端超时 | `src/HttpServer_WsSession.cpp` | L225, L253, L262, L287 |
|
||||
| 代理 TLS verify_none | `src/HttpServer_WsSession.cpp` | L243 |
|
||||
| 连接接受(无限制) | `src/HttpServer_Listener.cpp` | L23-48 |
|
||||
| TLS 服务端配置 | `src/HttpServer.cpp` | L30-62 |
|
||||
| HttpClient 响应无上限 | `src/HttpClient.cpp` | L63-81, L329-334 |
|
||||
| HttpClient TLS verify_none | `src/HttpClient.cpp` | L131 |
|
||||
| HttpClient ws_recv 忽略超时 | `src/HttpClient.cpp` | L220-221 |
|
||||
| 遗留 WS 实现(未构建) | `src/WebSocketServer.cpp`、`src/WebSocketClient.cpp` | 全篇 |
|
||||
@@ -0,0 +1,92 @@
|
||||
# libth 安全审计报告
|
||||
|
||||
**审计日期**: 2026-08-04
|
||||
**审计范围**: `libth/inc/us/`(`Thread.hpp`、`Fsm.hpp`、`ITask.hpp`、`Cv_wait.hpp`)、`libth/src/Thread.cpp`
|
||||
**代码版本**: dev_qing 分支
|
||||
|
||||
---
|
||||
|
||||
## 修复进度
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 部分修复 | 未修复 |
|
||||
|--------|------|--------|----------|--------|
|
||||
| P0 | 0 | 0 | 0 | 0 |
|
||||
| P1 | 1 | 0 | 0 | 1 |
|
||||
| P2 | 4 | 0 | 0 | 4 |
|
||||
| P3 | 2 | 0 | 0 | 2 |
|
||||
| **合计** | **7** | **0** | **0** | **7** |
|
||||
|
||||
> 说明:libth 为底层基础线程库,无对外网络攻击面,风险集中在**同步原语缺失超时导致的潜在永久阻塞(DoS)**与**内存/生命周期健壮性**。以下问题均为设计层面缺陷,源码中已有 FIXME/README 标注,尚未修复。
|
||||
|
||||
---
|
||||
|
||||
## 🟠 P1 — 高危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P1-1 | `Cv_wait` 无超时机制,可永久阻塞 | ❌ 未修复 | `Cv_wait.hpp` L21-25 `Wait()` 依赖 `cv.wait(lock, [this]{return flag;})`,无超时与超时中断。若 `WakeCv()` 永不触发(如等待的信号/事件丢失),调用线程将**永久阻塞**。被 `core` 的 `main.cpp` 直接使用,一旦误用即造成服务线程卡死(DoS)。源码 FIXME 已标注"仅支持单次等待-唤醒、可能导致永久堵塞,应改用 `std::future`/`std::promise`" |
|
||||
|
||||
---
|
||||
|
||||
## 🟡 P2 — 中危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P2-1 | `Thread::suspend()` 无超时 | ❌ 未修复 | `Thread.cpp` L33-35 透传 `Fsm::suspend()`(`Fsm.hpp` L61-65),阻塞等待状态变更却无超时。若其他线程永不调用 `set()`/`run()`/`stop()`/`shut()`,当前线程永久挂起,造成线程资源耗尽 |
|
||||
| P2-2 | 异常回退后未释放资源,可能内存泄漏 | ❌ 未修复 | `Thread.cpp` L132-135 `catch(std::exception)` 后仅 `stop()` 回退 STOP,不触发清理。若 `LoopEvent` 在循环中反复抛异常,会反复进入 STOP→START→RUNNING 循环,每次泄漏部分资源,长期运行内存持续增长。README 已注明"catch 后继续运行可能造成内存泄漏" |
|
||||
| P2-3 | `wake()`/`WaitStart()` 死锁约束脆弱 | ❌ 未修复 | `Thread.cpp` L87-92 `WaitStart()` 先持有 `mtx_th` 再调 `wake()`,而 `wake()`(L38-45)内部禁止再次加锁。该约束依赖调用方自觉遵守,一旦在 `WakeEvent` 回调中误加锁即死锁,缺少防御性校验(如 `std::recursive_mutex` 或断言) |
|
||||
| P2-4 | `WaitStart()` 存在潜在死锁/异常风险 | ❌ 未修复 | `Thread.hpp` L113 注释"据说该函数有问题可能导致异常"。`WaitStart()` 在 `wake()` 后置 `ready=false` 再 `cv.wait`,若 `WakeEvent` 中未调用 `run()`/`stop()`/`shut()`,`ready` 永不置位,调用线程永久阻塞 |
|
||||
|
||||
---
|
||||
|
||||
## 🔵 P3 — 低危问题
|
||||
|
||||
| # | 问题 | 状态 | 说明 |
|
||||
|---|------|------|------|
|
||||
| P3-1 | `Activate()` 重复调用静默返回 | ❌ 未修复 | `Thread.cpp` L80 若 `th` 已存在直接 `return`,不报错也不提示。调用方误以为重复激活有效,实际第二个线程未创建,状态不一致且无任何反馈 |
|
||||
| P3-2 | `Cv_wait` 复用后行为异常 | ❌ 未修复 | `Cv_wait.hpp` L19 `flag` 非原子且复用后残留 `true`,导致后续 `Wait()` 立即返回而不阻塞。README 已注明"仅支持单次使用,复用导致 `Wait()` 不阻塞" |
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已有安全机制
|
||||
|
||||
1. ✅ **线程安全状态机**: `Fsm` 用 `std::mutex` 保护状态读写(`Fsm.hpp` L31-34、L50-57),避免并发设置状态的数据竞争
|
||||
2. ✅ **两阶段构造防竞态**: `Activate()` 与构造函数分离(`Thread.hpp` L110),避免 `std::thread` 在对象构造完成前访问未初始化成员
|
||||
3. ✅ **异常捕获防进程崩溃**: `main()` 循环捕获 `std::exception` 回退 STOP(`Thread.cpp` L108-135),单线程异常不会拖垮整个进程
|
||||
4. ✅ **RAII 资源管理**: 使用 `std::unique_ptr<std::thread>`、`std::unique_ptr<f_t>`(`Thread.hpp` L128-146),避免裸指针管理
|
||||
5. ✅ **复制构造禁用**: `Thread(const Thread&) = delete`(`Thread.hpp` L41),防止线程对象被误拷贝造成双重管理
|
||||
6. ✅ **回调组合优于继承**: 持 `std::function` 回调(`Thread.hpp` L24),接口收敛、避免子类化的状态泄漏
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图
|
||||
|
||||
### 短期(72小时)
|
||||
- [ ] P1-1 `Cv_wait` 增加超时等待(`wait_for` + 超时返回),或按源码 FIXME 改用 `std::future`/`std::promise`
|
||||
- [ ] P2-1 `Fsm::suspend()`/`Thread::suspend()` 增加超时参数,避免永久挂起
|
||||
|
||||
### 中期
|
||||
- [ ] P2-2 异常回退时统一触发 `ClearEvent` 清理,避免长期运行内存泄漏
|
||||
- [ ] P2-3 将 `wake()` 的锁约束改为可防御形式(如 `std::recursive_mutex` 或加锁断言诊断)
|
||||
- [ ] P2-4 `WaitStart()` 增加超时与状态校验,避免 `WakeEvent` 未推进状态时永久阻塞
|
||||
|
||||
### 长期
|
||||
- [ ] P3-1 `Activate()` 重复调用改为抛异常或返回错误码,避免静默失败
|
||||
- [ ] P3-2 `Cv_wait` 的 `flag` 改为原子变量并支持复用,或明确废弃并替换
|
||||
|
||||
---
|
||||
|
||||
## 代码位置索引
|
||||
|
||||
| 功能 | 文件 | 行号 |
|
||||
|------|------|------|
|
||||
| `Cv_wait::Wait()` 无超时 | `inc/us/Cv_wait.hpp` | L21-25 |
|
||||
| `Cv_wait::flag` 非原子/复用残留 | `inc/us/Cv_wait.hpp` | L18-19 |
|
||||
| `Fsm::suspend()` 无超时 | `inc/us/Fsm.hpp` | L61-65 |
|
||||
| `Thread::suspend()` 透传 | `src/Thread.cpp` | L33-35 |
|
||||
| `Thread::WaitStart()` 唤醒等待 | `src/Thread.cpp` | L87-92 |
|
||||
| `Thread::wake()` 禁止加锁 | `src/Thread.cpp` | L38-45 |
|
||||
| `Thread::Activate()` 重复调用静默返回 | `src/Thread.cpp` | L76-85 |
|
||||
| `Thread::main()` 异常回退 | `src/Thread.cpp` | L103-138 |
|
||||
| `Thread` 复制构造禁用 | `inc/us/Thread.hpp` | L41 |
|
||||
| `Thread` 两阶段构造 | `inc/us/Thread.hpp` | L110 |
|
||||
@@ -15,15 +15,15 @@
|
||||
|
||||
| 等级 | 总数 | 已修复 | 部分修复 | 未修复 | 修复率 |
|
||||
|------|------|--------|----------|--------|--------|
|
||||
| P0 | 2 | 0 | 1 | 1 | 25% (部分) |
|
||||
| P0 | 2 | 1 | 0 | 1 | 50% |
|
||||
| P1 | 2 | 1 | 0 | 1 | 50% |
|
||||
| **合计** | **4** | **1** | **1** | **2** | **37.5%** (部分) |
|
||||
| **合计** | **4** | **2** | **0** | **2** | **50%** |
|
||||
|
||||
### 逐条验证
|
||||
|
||||
| 编号 | 状态 | 代码位置 | 验证说明 |
|
||||
|------|------|----------|----------|
|
||||
| P0-1 | ⚠️ 部分 | `_run.sh` L6, `smtp.py` | `_run.sh` 已确认使用 `smtp_v2.py`,但旧版 `smtp.py` 仍存在于代码库中,可被误启动 |
|
||||
| P0-1 | ✅ 已修复 | ~~`smtp.py`~~ | 旧版 `smtp.py` 已删除下线(2026-08-04),杜绝误启动;`_run.sh` 使用 `smtp_v2.py` |
|
||||
| P0-2 | ❌ 未修复 | `smtp_v2.py` L113-117 | 使用 pymsg RpcServer,无认证机制(与 pymsg P0-3 同源),依赖 127.0.0.1 网络隔离 |
|
||||
| P1-1 | ❌ 未修复 | `smtp_v2.py` L90 | 验证码存储在进程内存 `code_store` dict,服务重启后全部失效 |
|
||||
| P1-2 | ✅ 已修复 | `smtp_v2.py` L82, L191 | `SMTP_HOURLY_LIMIT` 从环境变量正确加载(默认 100),全局熔断逻辑正确生效 |
|
||||
@@ -34,15 +34,15 @@
|
||||
|
||||
| 优先级 | 总数 | 已修复 | 待修复 |
|
||||
|--------|------|--------|--------|
|
||||
| P0 | 2 | 1(v2已修复) | 1(旧版需下线) |
|
||||
| P0 | 2 | 2 | 0 |
|
||||
| P1 | 2 | 1 | 1 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 P0 — 严重问题
|
||||
|
||||
### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线) ⚠️ 部分修复
|
||||
- **位置**: `smtp.py`、`_run.sh`
|
||||
### P0-1: 旧版 smtp.py 存在大量致命漏洞(需立即下线) ✅ 已修复
|
||||
- **位置**: ~~`smtp.py`~~、`_run.sh`
|
||||
- **问题**: 旧版 smtp.py 缺乏关键防护:
|
||||
1. 无发送速率限制(可被用于邮件炸弹)
|
||||
2. 无同邮箱 60 秒发送冷却
|
||||
@@ -50,10 +50,7 @@
|
||||
4. 无连续失败锁定机制
|
||||
5. 存在 `NameError` 风险:`server.quit()` 在 finally 块中,若 SMTP 连接失败导致 `server` 未赋值则崩溃
|
||||
- **已确认**: `_run.sh` L6 使用 `exec python3 smtp_v2.py`,生产环境已切换至新版
|
||||
- **仍缺失**: 旧版 `smtp.py` 仍存在于代码库中,可被误启动
|
||||
- **修复建议**:
|
||||
1. 删除或重命名旧版 `smtp.py`(如 `smtp.py.deprecated`)防止误用
|
||||
2. 在代码注释中明确标记废弃
|
||||
- **修复 (2026-08-04)**: 旧版 `smtp.py` 已**删除下线**,杜绝误启动;README 中相关描述已同步更新
|
||||
|
||||
### P0-2: 依赖 pymsg 无认证(缓解:绑定 127.0.0.1)
|
||||
- **说明**: 与其他 pymsg 服务相同,当前依赖网络层隔离
|
||||
@@ -85,8 +82,8 @@
|
||||
## 修复路线图
|
||||
|
||||
### 立即(24小时)
|
||||
- [ ] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线
|
||||
- [ ] 删除或隔离 smtp.py 防止误用
|
||||
- [x] 确认 smtp 服务运行的是 smtp_v2.py,旧版下线 ✅ 已确认(`_run.sh` 使用 `smtp_v2.py`)
|
||||
- [x] 删除或隔离 smtp.py 防止误用 ✅ 已删除(2026-08-04)
|
||||
|
||||
### 短期
|
||||
- [ ] 验证全局熔断配置生效
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
VERSION := 0.0.1
|
||||
VERSION := 0.8.0
|
||||
APP_NAME := gateway
|
||||
INSTL_DIR := /usr/local/bin
|
||||
CPP := g++
|
||||
INCS := -I inc/
|
||||
MY_LIBS := -L/usr/lib -lus_http -lus_httpclient -lus_crypt
|
||||
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options
|
||||
INCS := -I inc/ -I ../libmsg/inc
|
||||
MY_LIBS := -L/usr/lib -L../libmsg/build -lus_http -lus_httpclient -lus_crypt -lus_msg
|
||||
LIBS := -pthread -lcurl -lssl -lsqlite3 -lz -lcrypto -lboost_json -lboost_program_options -Wl,-rpath,$(shell cd ../libmsg/build && pwd)
|
||||
CPPFLAGS := --std=c++20 -pipe -pthread # -Wall -Werror
|
||||
BUILD_DIR = build
|
||||
$(shell mkdir -p $(BUILD_DIR))
|
||||
@@ -13,7 +13,7 @@ $(shell mkdir -p $(BUILD_DIR))
|
||||
all: $(BUILD_DIR)/$(APP_NAME).out
|
||||
$(BUILD_DIR)/$(APP_NAME).out: $(BUILD_DIR)/main.o
|
||||
$(CPP) $^ $(LIBS) $(MY_LIBS) -o $@
|
||||
$(BUILD_DIR)/main.o: src/main.cpp
|
||||
$(CPP) $(CPPFLAGS) -c $^ $(INCS) -o $@
|
||||
$(BUILD_DIR)/main.o: src/main.cpp inc/DbC.hpp inc/LogC.hpp inc/Conf.hpp inc/ExpBase.hpp inc/LocalTime.hpp
|
||||
$(CPP) $(CPPFLAGS) -c $< $(INCS) -o $@
|
||||
clear:
|
||||
@rm -rvf build/
|
||||
+4
-4
@@ -13,7 +13,7 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
|
||||
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟(IP 级)+ 3 次/小时/10 次/天(邮箱级),按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
|
||||
- **邮件轰炸防护**:`/register/send_code` 强制 CAPTCHA 校验 + 邮箱级双窗口限流(3 次/小时 + 10 次/天),smtp 层兜底同样限流 + 全局熔断(默认 100 封/小时,可配)。
|
||||
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
|
||||
- **OPTIONS 预检**:由 libhttp 处理,`Access-Control-Allow-Origin` 反射请求的 Origin 头(任意来源均被放行);`CORS.allowed_origins` 白名单已读取但尚未用于预检响应(见审计 P2-3)。
|
||||
|
||||
## 安全特性
|
||||
|
||||
@@ -22,7 +22,7 @@ UniServe Framework 的应用层网关,C++20 实现。
|
||||
| 密码哈希 | PBKDF2-HMAC-SHA256,100000 轮,16 字节随机盐,32 字节哈希 |
|
||||
| 密码强度校验 | 至少 8 字符;大写/小写/数字/特殊字符中至少 3 类 |
|
||||
| 邮箱格式校验 | 正则匹配 `^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$` |
|
||||
| 旧哈希自动迁移 | 登录时若发现 SHA256 旧哈希,验证通过后自动迁移为 PBKDF2 |
|
||||
| 旧哈希自动迁移 | 登录时若发现 SHA256 旧哈希,验证通过后自动迁移为 PBKDF2(⚠️ 当前源码中该迁移逻辑已被注释禁用,见 `main.cpp` L1102-1109,未生效) |
|
||||
| Token 机制 | `Base64(payload) + "_" + RSA-Sign(payload)`,验签 + 过期校验(默认 1 小时) |
|
||||
| 常数时间比较 | PBKDF2 验证用 `diff |= hash[i] ^ expected[i]` 防 timing attack |
|
||||
| CAPTCHA | 一次性、5 分钟过期,登录与注册均可选启用 |
|
||||
@@ -147,7 +147,7 @@ Target-Url: http://127.0.0.1:9200 // 可选,缺省取 Proxy.default
|
||||
|
||||
### `OPTIONS *`
|
||||
|
||||
返回 CORS 预检响应:`Access-Control-Allow-Origin: *`、`Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。
|
||||
返回 CORS 预检响应:`Access-Control-Allow-Origin` 反射请求 Origin 头(等价于允许任意来源,见审计 P2-3)、`Access-Control-Allow-Methods: GET, POST, OPTIONS`、`Max-Age: 86400`。
|
||||
|
||||
### WebSocket 代理(`[WsProxy]` 配置的路径)
|
||||
|
||||
@@ -202,7 +202,7 @@ gateway 配置采用**两层加载模式**,与其他 Python 服务(dnfs/db/s
|
||||
- `Proxy.closed`:仅文档记录用途,实际靠"不在 opened 中即拒绝"实现隔离。
|
||||
- `Token.*`:Token 有效期(`keep_time`)、刷新宽限期倍数(`refresh_grace_multiplier`)、最大刷新次数(`max_refresh`)。
|
||||
- `Admin.users`:管理员白名单(分号分隔邮箱),登录命中时 Token payload 写入 `is_admin=true`。
|
||||
- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。
|
||||
- `CORS.allowed_origins`:允许跨域的 Origin 白名单(分号分隔)。⚠️ 当前已读取但**尚未用于预检响应**——OPTIONS 预检由 libhttp 反射请求 Origin,白名单暂不生效(见审计 P2-3)。
|
||||
- `WsProxy.routes`:WebSocket 代理路由,格式 `/path=ws://host:port/target`,分号分隔。
|
||||
- `Downstream.*`:下游 pymsg 服务地址(db/captcha/smtp),可被 `DB_HOST`/`DB_PORT`、`CAPTCHA_HOST`/`CAPTCHA_PORT`、`SMTP_HOST`/`SMTP_PORT` 环境变量覆盖。
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
#include <us/HttpClient.hpp>
|
||||
#include "Msg.hpp"
|
||||
#include <us/Msg.hpp>
|
||||
#include <boost/json.hpp>
|
||||
namespace qing {
|
||||
namespace json = boost::json;
|
||||
|
||||
+39
-82
@@ -4,9 +4,10 @@
|
||||
*
|
||||
* 设计要点(与 Python 版对齐):
|
||||
* - 通过 pymsg TCP 调用 dbv2 execute 路由,逐条 INSERT 到 Logs 表
|
||||
* - 异步非阻塞:内存队列 + 后台线程批量推送,不拖慢业务
|
||||
* - 异步非阻塞:基于 pmc::net::AsyncQueue 内存队列 + 后台线程批量推送,不拖慢业务
|
||||
* - fail-safe:db 服务不可用时静默丢弃(仅 stderr 提示),绝不影响业务
|
||||
* - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
|
||||
* - 背压保护:队列上限 10000 条,满时丢最旧日志,防止内存无限增长
|
||||
*
|
||||
* 用法:
|
||||
* #include "LogC.hpp"
|
||||
@@ -21,20 +22,17 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
#include "Msg.hpp"
|
||||
#include <us/AsyncQueue.hpp>
|
||||
#include <us/Msg.hpp>
|
||||
|
||||
namespace qing {
|
||||
|
||||
@@ -62,12 +60,8 @@ public:
|
||||
const std::string& message,
|
||||
std::optional<std::string> trace_id = std::nullopt,
|
||||
std::optional<std::string> extra = std::nullopt) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
_queue.push_back(Entry{service, level, message,
|
||||
std::move(trace_id), std::move(extra)});
|
||||
}
|
||||
_cv.notify_one();
|
||||
_queue.push(Entry{service, level, message,
|
||||
std::move(trace_id), std::move(extra)});
|
||||
}
|
||||
|
||||
void debug(const std::string& service, const std::string& message,
|
||||
@@ -96,88 +90,53 @@ public:
|
||||
push(service, "CRITICAL", message, std::move(trace_id), std::move(extra));
|
||||
}
|
||||
|
||||
// 手动 flush:通知后台线程立即发送当前积攒的批次,并短暂等待其完成
|
||||
// 手动 flush:通知后台线程立即发送当前积攒的批次,并等待其完成
|
||||
void flush() {
|
||||
_force_flush.store(true);
|
||||
_cv.notify_all();
|
||||
for (int i = 0; i < 200 && _force_flush.load(); ++i) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||
}
|
||||
_queue.flush();
|
||||
}
|
||||
|
||||
// 停止后台线程并 flush 剩余日志(进程退出前调用,幂等)
|
||||
void shutdown() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_stop) return;
|
||||
_stop = true;
|
||||
}
|
||||
_cv.notify_all();
|
||||
if (_thread.joinable()) _thread.join();
|
||||
_queue.shutdown();
|
||||
}
|
||||
|
||||
// ---- 配置(构造后、首次推送前可改)------------------------------------
|
||||
void set_endpoint(const std::string& addr, int port) {
|
||||
std::lock_guard<std::mutex> lock(_endpoint_mtx);
|
||||
_db_addr = addr;
|
||||
_db_port = port;
|
||||
}
|
||||
|
||||
private:
|
||||
LogC() : _thread(&LogC::_flush_loop, this) {}
|
||||
LogC()
|
||||
: _queue(
|
||||
[this](std::vector<Entry>&& batch) { this->_send_batch(std::move(batch)); },
|
||||
{_BATCH_SIZE, _FLUSH_INTERVAL, _MAX_QUEUE_SIZE, 0.5},
|
||||
[](const std::exception& e, std::vector<Entry>&&) {
|
||||
std::cerr << "[LogC] 发送失败: " << e.what() << std::endl;
|
||||
})
|
||||
{
|
||||
_queue.start();
|
||||
}
|
||||
|
||||
~LogC() { shutdown(); }
|
||||
|
||||
LogC(const LogC&) = delete;
|
||||
LogC& operator=(const LogC&) = delete;
|
||||
|
||||
// 后台线程:积攒到 batch_size 或超时 flush_interval 时发送
|
||||
void _flush_loop() {
|
||||
std::vector<Entry> batch;
|
||||
auto last_flush = std::chrono::steady_clock::now();
|
||||
|
||||
for (;;) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mtx);
|
||||
// 最多 0.5s 轮询一次(对齐 Python 版 chk_timeout 上限)
|
||||
_cv.wait_for(lock, std::chrono::milliseconds(500),
|
||||
[&] { return _stop || !_queue.empty(); });
|
||||
while (!_queue.empty() && batch.size() < _BATCH_SIZE) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool due = std::chrono::duration<double>(now - last_flush).count()
|
||||
>= _FLUSH_INTERVAL;
|
||||
if (!batch.empty() && (batch.size() >= _BATCH_SIZE || due
|
||||
|| _stop || _force_flush.load())) {
|
||||
_send(batch);
|
||||
batch.clear();
|
||||
last_flush = now;
|
||||
_force_flush.store(false);
|
||||
}
|
||||
|
||||
if (_stop) {
|
||||
// 退出前 flush 队列剩余
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mtx);
|
||||
while (!_queue.empty()) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
if (!batch.empty()) _send(batch);
|
||||
_force_flush.store(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通过 pymsg 使用 execute 路由逐条 INSERT 到 dbv2 Logs 表
|
||||
void _send(const std::vector<Entry>& batch) {
|
||||
void _send_batch(const std::vector<Entry>& batch) {
|
||||
std::string addr;
|
||||
int port;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_endpoint_mtx);
|
||||
addr = _db_addr;
|
||||
port = _db_port;
|
||||
}
|
||||
|
||||
const std::string now = _utc_now_iso(); // 批次共享同一时间戳(对齐 Python 版)
|
||||
try {
|
||||
pmc::net::MsgClient client(_db_addr, _db_port, "gateway-log");
|
||||
pmc::net::MsgClient client(addr, port, "gateway-log");
|
||||
for (const auto& e : batch) {
|
||||
json::object values;
|
||||
values["ts"] = now;
|
||||
@@ -233,17 +192,15 @@ private:
|
||||
// ---- 配置项 -----------------------------------------------------------
|
||||
std::string _db_addr = "127.0.0.1";
|
||||
int _db_port = 8005;
|
||||
static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush
|
||||
static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush
|
||||
static constexpr double _TIMEOUT = 5.0; // socket 超时(秒)
|
||||
std::mutex _endpoint_mtx; // 保护 _db_addr/_db_port 读写(set_endpoint 与后台线程并发)
|
||||
|
||||
// ---- 状态 -------------------------------------------------------------
|
||||
std::deque<Entry> _queue;
|
||||
std::mutex _mtx;
|
||||
std::condition_variable _cv;
|
||||
bool _stop = false;
|
||||
std::atomic<bool> _force_flush{false};
|
||||
std::thread _thread;
|
||||
static constexpr size_t _BATCH_SIZE = 50; // 队列满 50 条时 flush
|
||||
static constexpr double _FLUSH_INTERVAL = 2.0; // 超时 2 秒强制 flush
|
||||
static constexpr size_t _MAX_QUEUE_SIZE = 10000; // 队列上限,满时丢最旧
|
||||
static constexpr double _TIMEOUT = 5.0; // socket 超时(秒)
|
||||
|
||||
// ---- 核心队列 ---------------------------------------------------------
|
||||
pmc::net::AsyncQueue<Entry> _queue;
|
||||
};
|
||||
|
||||
} // namespace qing
|
||||
@@ -1,393 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
/** pymsg C++ 实现 — Length-prefixed variable-length socket message framing.
|
||||
*
|
||||
* Protocol: [4-byte big-endian uint32_t length][UTF-8 payload]
|
||||
*
|
||||
* 对应 Python 版 pymsg/pymsg/__init__.py,提供:
|
||||
* - pmsg_send() / pmsg_recv() 底层协议收发函数
|
||||
* - MsgClient 短连接 send / call / 长连接 listen 客户端
|
||||
*
|
||||
* Usage:
|
||||
* #include "Msg.hpp"
|
||||
*
|
||||
* pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
* std::string resp = client.call(R"({"sql":"SELECT 1"})", 5.0);
|
||||
* client.send("hello");
|
||||
*/
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// macOS 没有 MSG_NOSIGNAL,用 0 替代(内核不会发 SIGPIPE)
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
// ============================================================================
|
||||
// Exceptions
|
||||
// ============================================================================
|
||||
|
||||
class MsgException : public std::runtime_error {
|
||||
public:
|
||||
explicit MsgException(const std::string &msg) : std::runtime_error(msg) {}
|
||||
};
|
||||
|
||||
class MsgTimeoutException : public MsgException {
|
||||
public:
|
||||
explicit MsgTimeoutException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
class MsgConnectionException : public MsgException {
|
||||
public:
|
||||
explicit MsgConnectionException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
/// 目标服务未启动(connect 返回 ECONNREFUSED),用于 retry_on_refused 重试机制
|
||||
class MsgConnectionRefusedException : public MsgConnectionException {
|
||||
public:
|
||||
explicit MsgConnectionRefusedException(const std::string &msg)
|
||||
: MsgConnectionException(msg) {}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Low-level protocol: 4-byte big-endian length prefix + UTF-8 payload
|
||||
// ============================================================================
|
||||
|
||||
namespace detail {
|
||||
|
||||
constexpr size_t kHeaderSize = 4;
|
||||
|
||||
// ---- 4-byte big-endian 编码 / 解码 ----------------------------------------
|
||||
|
||||
inline void writeUint32BE(char *buf, uint32_t val) noexcept {
|
||||
buf[0] = static_cast<char>((val >> 24) & 0xFFu);
|
||||
buf[1] = static_cast<char>((val >> 16) & 0xFFu);
|
||||
buf[2] = static_cast<char>((val >> 8) & 0xFFu);
|
||||
buf[3] = static_cast<char>(val & 0xFFu);
|
||||
}
|
||||
|
||||
inline uint32_t readUint32BE(const char *buf) noexcept {
|
||||
return (static_cast<uint32_t>(static_cast<unsigned char>(buf[0])) << 24) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[1])) << 16) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[2])) << 8) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[3])));
|
||||
}
|
||||
|
||||
// ---- socket 超时设置 ------------------------------------------------------
|
||||
|
||||
inline void setSockTimeout(int sockfd, double seconds) {
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = static_cast<time_t>(seconds);
|
||||
tv.tv_usec =
|
||||
static_cast<long>((seconds - static_cast<double>(tv.tv_sec)) * 1'000'000.0);
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
// ---- 精确读取 n 字节 ------------------------------------------------------
|
||||
|
||||
inline void recvExact(int sockfd, char *buf, size_t n) {
|
||||
size_t received = 0;
|
||||
while (received < n) {
|
||||
ssize_t r = ::recv(sockfd, buf + received, n - received, 0);
|
||||
if (r == 0) {
|
||||
throw MsgConnectionException(
|
||||
"connection closed: expected " + std::to_string(n) +
|
||||
" bytes, got " + std::to_string(received));
|
||||
}
|
||||
if (r < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("recv timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"recv error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
received += static_cast<size_t>(r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 完整发送 -------------------------------------------------------------
|
||||
|
||||
inline void sendAll(int sockfd, const char *data, size_t len) {
|
||||
size_t sent = 0;
|
||||
while (sent < len) {
|
||||
ssize_t w =
|
||||
::send(sockfd, data + sent, len - sent, MSG_NOSIGNAL);
|
||||
if (w < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("send timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"send error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
sent += static_cast<size_t>(w);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 短连接 helper:connect → action → close ------------------------------
|
||||
|
||||
inline int openConnection(const std::string& addr, int port, double timeout) {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
throw MsgConnectionException(
|
||||
"socket() failed: " + std::string(::strerror(errno)));
|
||||
}
|
||||
|
||||
struct sockaddr_in serv_addr {};
|
||||
serv_addr.sin_family = AF_INET;
|
||||
serv_addr.sin_port = htons(static_cast<uint16_t>(port));
|
||||
if (::inet_pton(AF_INET, addr.c_str(), &serv_addr.sin_addr) <= 0) {
|
||||
::close(fd);
|
||||
throw MsgConnectionException("invalid address: " + addr);
|
||||
}
|
||||
|
||||
setSockTimeout(fd, timeout);
|
||||
|
||||
if (::connect(fd, reinterpret_cast<struct sockaddr *>(&serv_addr),
|
||||
sizeof(serv_addr)) < 0) {
|
||||
int saved_errno = errno;
|
||||
::close(fd);
|
||||
std::string err_msg = "could not connect to " + addr + ":" +
|
||||
std::to_string(port) + " - " + ::strerror(saved_errno);
|
||||
if (saved_errno == ECONNREFUSED) {
|
||||
throw MsgConnectionRefusedException(err_msg);
|
||||
}
|
||||
throw MsgConnectionException(err_msg);
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ============================================================================
|
||||
// 底层协议函数:pmsg_send / pmsg_recv
|
||||
// ============================================================================
|
||||
|
||||
/// 以 pymsg 协议发送一条字符串消息(长度前缀 + UTF-8 载荷)。
|
||||
inline void pmsg_send(int sockfd, const std::string &data) {
|
||||
size_t payload_size = data.size();
|
||||
if (payload_size > 0xFFFFFFFFu) {
|
||||
throw MsgException("payload too large: " + std::to_string(payload_size));
|
||||
}
|
||||
char header[detail::kHeaderSize];
|
||||
detail::writeUint32BE(header, static_cast<uint32_t>(payload_size));
|
||||
detail::sendAll(sockfd, header, detail::kHeaderSize);
|
||||
detail::sendAll(sockfd, data.c_str(), payload_size);
|
||||
}
|
||||
|
||||
/// 以 pymsg 协议接收一条字符串消息。
|
||||
/// @throws MsgConnectionException 连接在收全消息前断开。
|
||||
/// @throws MsgTimeoutException 等待超时。
|
||||
inline std::string pmsg_recv(int sockfd) {
|
||||
char header[detail::kHeaderSize];
|
||||
detail::recvExact(sockfd, header, detail::kHeaderSize);
|
||||
uint32_t length = detail::readUint32BE(header);
|
||||
if (length > 64 * 1024 * 1024) { // 安全上限 64 MiB
|
||||
throw MsgException("message too large: " + std::to_string(length));
|
||||
}
|
||||
std::string payload(static_cast<size_t>(length), '\0');
|
||||
detail::recvExact(sockfd, payload.data(), length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgClient
|
||||
// ============================================================================
|
||||
|
||||
/// 基于 pymsg 协议的 TCP 客户端。
|
||||
///
|
||||
/// 支持三种操作模式:
|
||||
/// - send() 短连接发送一条消息(不等待回复),消息带 name 前缀。
|
||||
/// - call() 短连接发送 + 阻塞等待回复(RPC 模式),原始消息不添加前缀。
|
||||
/// - listen() 基于 connect() 维持的长连接,持续接收消息并回调。
|
||||
class MsgClient {
|
||||
public:
|
||||
MsgClient(const std::string &addr, int port, const std::string &name)
|
||||
: _addr(addr), _port(port), _name(name), _sockfd(-1), _running(false) {}
|
||||
|
||||
~MsgClient() { stop(); }
|
||||
|
||||
// ---- 连接 ---------------------------------------------------------------
|
||||
|
||||
/// 创建 TCP 套接字并连接到服务器(长连接,供 listen() 使用)。
|
||||
void connect() {
|
||||
_sockfd = detail::openConnection(_addr, _port, /*timeout=*/10.0);
|
||||
}
|
||||
|
||||
// ---- 发送单条消息(短连接,带名字前缀)----------------------------------
|
||||
|
||||
/// 短连接发送一条消息到服务器,消息格式为 ``"name: msg"``。
|
||||
/// 不等待回复。短连接(每次创建新 socket,发完即关)。
|
||||
void send(const std::string &msg) {
|
||||
int fd = detail::openConnection(_addr, _port, /*timeout=*/3.0);
|
||||
try {
|
||||
pmsg_send(fd, _name + ": " + msg);
|
||||
::close(fd);
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 请求-应答(短连接,RPC 模式)---------------------------------------
|
||||
|
||||
/// 短连接发送一条消息,阻塞等待并返回一条回复。
|
||||
///
|
||||
/// 不会给消息添加 ``name:`` 前缀(适用于 RPC/API 调用)。
|
||||
///
|
||||
/// @param msg 要发送的原始字符串。
|
||||
/// @param timeout 超时秒数,默认 10.0。
|
||||
/// @param retry_on_refused 目标服务未启动(ECONNREFUSED)时是否阻塞重试,
|
||||
/// 直到连接成功。适用于启动顺序不确定的服务发现场景。
|
||||
/// 默认 false(连接拒绝立即抛异常)。
|
||||
/// @param retry_interval 重试间隔秒数,默认 1.0。
|
||||
/// @returns 服务器返回的字符串。
|
||||
std::string call(const std::string &msg, double timeout = 10.0,
|
||||
bool retry_on_refused = false,
|
||||
double retry_interval = 1.0) {
|
||||
if (!retry_on_refused) {
|
||||
// 快速路径:不重试,直接执行一次,保持原有行为
|
||||
int fd = detail::openConnection(_addr, _port, timeout);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
std::string resp = pmsg_recv(fd);
|
||||
::close(fd);
|
||||
return resp;
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 重试路径:仅对 ECONNREFUSED 重试,其他错误立即抛出
|
||||
constexpr double kLogEvery = 5.0; // 重试期间打印等待提示的间隔(秒)
|
||||
int attempt = 0;
|
||||
auto last_log = std::chrono::steady_clock::now() -
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(kLogEvery));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
int fd = detail::openConnection(_addr, _port, timeout);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
std::string resp = pmsg_recv(fd);
|
||||
::close(fd);
|
||||
return resp;
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
} catch (const MsgConnectionRefusedException &) {
|
||||
++attempt;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
double elapsed = std::chrono::duration<double>(now - last_log).count();
|
||||
if (elapsed >= kLogEvery || attempt == 1) {
|
||||
std::fprintf(stderr,
|
||||
"[pymsg] 等待服务 %s:%d 就绪... (已重试 %d 次)\n",
|
||||
_addr.c_str(), _port, attempt);
|
||||
std::fflush(stderr);
|
||||
last_log = now;
|
||||
}
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::duration<double>(retry_interval));
|
||||
}
|
||||
// 其他异常(MsgTimeoutException、普通MsgConnectionException等)不捕获,直接向上抛出
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 持续监听(长连接,阻塞回调模式)-----------------------------------
|
||||
|
||||
/// 在长连接上持续接收消息并通过回调处理。
|
||||
///
|
||||
/// 阻塞当前线程直到 stop() 被调用或连接断开。
|
||||
/// 必须先调用 connect()。
|
||||
///
|
||||
/// @param on_message 回调 ``void(const std::string &msg)``。
|
||||
void listen(std::function<void(const std::string &)> on_message) {
|
||||
if (_sockfd < 0) {
|
||||
throw MsgException("must call connect() before listen()");
|
||||
}
|
||||
|
||||
_running = true;
|
||||
while (_running) {
|
||||
fd_set readfds;
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(_sockfd, &readfds);
|
||||
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = 100'000; // 100 ms 轮询间隔
|
||||
|
||||
int ready =
|
||||
::select(_sockfd + 1, &readfds, nullptr, nullptr, &tv);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break; // EBADF or other fatal error
|
||||
}
|
||||
if (ready == 0) continue; // timeout, loop around
|
||||
|
||||
try {
|
||||
std::string text = pmsg_recv(_sockfd);
|
||||
on_message(std::move(text));
|
||||
} catch (const MsgConnectionException &) {
|
||||
break; // 连接断开,退出循环
|
||||
} catch (const MsgTimeoutException &) {
|
||||
continue; // 读超时但连接仍在,继续等待
|
||||
}
|
||||
}
|
||||
|
||||
if (_sockfd >= 0) {
|
||||
::close(_sockfd);
|
||||
_sockfd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 停止 ---------------------------------------------------------------
|
||||
|
||||
/// 设置停止标志并关闭套接字。可从任意线程调用。
|
||||
void stop() noexcept {
|
||||
_running = false;
|
||||
if (_sockfd >= 0) {
|
||||
::close(_sockfd);
|
||||
_sockfd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 访问器 -------------------------------------------------------------
|
||||
|
||||
const std::string &addr() const noexcept { return _addr; }
|
||||
int port() const noexcept { return _port; }
|
||||
const std::string &name() const noexcept { return _name; }
|
||||
|
||||
private:
|
||||
std::string _addr;
|
||||
int _port;
|
||||
std::string _name;
|
||||
int _sockfd;
|
||||
bool _running;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
+487
-256
@@ -28,7 +28,7 @@ using namespace qing;
|
||||
namespace json = boost::json;
|
||||
namespace po = boost::program_options;
|
||||
|
||||
constexpr const char *_VER = "0.7.0";
|
||||
constexpr const char *_VER = "0.8.0";
|
||||
|
||||
//////////////////////////////////
|
||||
#include <sstream>
|
||||
@@ -321,6 +321,11 @@ http::response<http::string_body> handleWhoami(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 内部服务间管理员验签视图:供 core 等内部服务调用,验证 Token 是否为有效管理员 */
|
||||
http::response<http::string_body> handleAdminVerify(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params);
|
||||
|
||||
/* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */
|
||||
http::response<http::string_body> handleCaptchaImage(
|
||||
const http::request<http::string_body>& req,
|
||||
@@ -336,6 +341,43 @@ bool is_special_header(const std::string &header);
|
||||
/* 请求路径安全性校验(防路径遍历/协议注入/空字节) */
|
||||
bool is_safe_target(const std::string& target);
|
||||
|
||||
/* ===== Token 吊销机制 ===== */
|
||||
// Token 验签结果结构体
|
||||
struct TokenInfo {
|
||||
bool valid = false;
|
||||
std::string username;
|
||||
std::string jti; // Token 唯一标识(用于单 Token 吊销)
|
||||
int ver = 1; // 用户 Token 版本号(用于全设备吊销)
|
||||
bool is_admin = false;
|
||||
int keep_time = 3600;
|
||||
int refresh_count = 0;
|
||||
int max_refresh = 10;
|
||||
std::string iat;
|
||||
std::string created_at;
|
||||
int http_status = 401; // 验签失败时的推荐 HTTP 状态码
|
||||
};
|
||||
|
||||
// 生成 128-bit 随机 JTI(hex 字符串,32 字符)
|
||||
std::string generate_jti();
|
||||
|
||||
// 获取用户当前 Token 版本号(不存在则初始化为 1)
|
||||
int get_user_ver(const std::string& username);
|
||||
|
||||
// 吊销单个 Token(将 jti 加入黑名单直到其过期)
|
||||
void revoke_jti(const std::string& jti, int keep_time);
|
||||
|
||||
// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效)
|
||||
void revoke_all_user_tokens(const std::string& username);
|
||||
|
||||
// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token)
|
||||
// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串
|
||||
// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入)
|
||||
std::string extract_token_from_request(const http::request<http::string_body>& req, std::string& out_cookie_token);
|
||||
|
||||
// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查
|
||||
// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀)
|
||||
TokenInfo verify_token(const std::string& raw_token);
|
||||
|
||||
/* configure items */
|
||||
static std::string _ADDR;
|
||||
static int _PORT;
|
||||
@@ -398,6 +440,26 @@ struct CaptchaEntry {
|
||||
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
|
||||
static std::mutex _CAPTCHA_MTX;
|
||||
|
||||
/* Token 吊销机制(P1:Token 吊销缺失修复)
|
||||
*
|
||||
* 双轨纯内存方案:
|
||||
* 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理
|
||||
* 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取
|
||||
*
|
||||
* 设计优势:
|
||||
* - 零 DB 改动:黑名单和版本号全在 gateway 进程内存中
|
||||
* - 重启自动清空:gateway 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败
|
||||
* - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响
|
||||
* - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理
|
||||
*/
|
||||
struct JtiDenylistEntry {
|
||||
std::chrono::steady_clock::time_point expire_at;
|
||||
};
|
||||
static std::unordered_map<std::string, JtiDenylistEntry> _JTI_DENYLIST;
|
||||
static std::unordered_map<std::string, int> _USER_VER_MAP;
|
||||
static std::mutex _TOKEN_REVOKE_MTX;
|
||||
static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理
|
||||
|
||||
/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */
|
||||
static inline LogC& logs() { return LogC::instance(); }
|
||||
|
||||
@@ -473,6 +535,230 @@ bool verify_email_code_remote(const std::string& email, const std::string& code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Token 吊销机制实现 ===== */
|
||||
|
||||
std::string generate_jti() {
|
||||
unsigned char jti_bytes[16];
|
||||
RAND_bytes(jti_bytes, 16);
|
||||
std::string jti;
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
jti.reserve(32);
|
||||
for (int i = 0; i < 16; i++) {
|
||||
jti += hex[jti_bytes[i] >> 4];
|
||||
jti += hex[jti_bytes[i] & 0x0F];
|
||||
}
|
||||
return jti;
|
||||
}
|
||||
|
||||
int get_user_ver(const std::string& username) {
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
auto it = _USER_VER_MAP.find(username);
|
||||
if (it == _USER_VER_MAP.end()) {
|
||||
_USER_VER_MAP[username] = 1;
|
||||
return 1;
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
void revoke_jti(const std::string& jti, int keep_time) {
|
||||
if (jti.empty()) return;
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time);
|
||||
_JTI_DENYLIST[jti] = {expire};
|
||||
|
||||
// 超过阈值时批量清理过期条目
|
||||
if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) {
|
||||
if (now > it->second.expire_at)
|
||||
it = _JTI_DENYLIST.erase(it);
|
||||
else
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void revoke_all_user_tokens(const std::string& username) {
|
||||
if (username.empty()) return;
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
auto it = _USER_VER_MAP.find(username);
|
||||
if (it == _USER_VER_MAP.end()) {
|
||||
_USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2
|
||||
} else {
|
||||
it->second++;
|
||||
}
|
||||
logs().info("gateway", "用户所有Token已吊销: " + username, std::nullopt,
|
||||
json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}}));
|
||||
}
|
||||
|
||||
std::string extract_token_from_request(const http::request<http::string_body>& req, std::string& out_cookie_token) {
|
||||
out_cookie_token.clear();
|
||||
std::string raw;
|
||||
|
||||
// 1. 优先从 Authorization: Bearer 头获取
|
||||
auto it_auth = req.base().find("Authorization");
|
||||
if (it_auth != req.base().end()) {
|
||||
raw = std::string(it_auth->value());
|
||||
}
|
||||
|
||||
// 2. 回退从 Cookie q4_token 获取
|
||||
if (raw.empty()) {
|
||||
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;
|
||||
out_cookie_token = cookie_token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去掉 "Bearer " 前缀
|
||||
if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") {
|
||||
return raw.substr(7);
|
||||
}
|
||||
return raw; // 可能为空或不含前缀,由调用方处理
|
||||
}
|
||||
|
||||
// 清理 denylist 中指定 jti(如果已过期则删除)
|
||||
static void cleanup_jti_if_expired(const std::string& jti) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto it = _JTI_DENYLIST.find(jti);
|
||||
if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) {
|
||||
_JTI_DENYLIST.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
TokenInfo verify_token(const std::string& raw_token) {
|
||||
TokenInfo info;
|
||||
info.valid = false;
|
||||
|
||||
if (raw_token.empty()) {
|
||||
info.http_status = 401;
|
||||
return info;
|
||||
}
|
||||
|
||||
// 解析 base64data_signature
|
||||
size_t idx = raw_token.find("_");
|
||||
if (idx == std::string::npos) {
|
||||
info.http_status = 400;
|
||||
return info;
|
||||
}
|
||||
|
||||
std::string data = raw_token.substr(0, idx);
|
||||
std::string sign = raw_token.substr(idx + 1);
|
||||
std::string auth;
|
||||
try {
|
||||
auth = Base64::base64_decode_to_string(data);
|
||||
} catch (const std::exception&) {
|
||||
info.http_status = 400;
|
||||
return info;
|
||||
}
|
||||
|
||||
// RSA 验签
|
||||
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
if (!publ.Verify(auth, sign)) {
|
||||
info.http_status = 401;
|
||||
return info;
|
||||
}
|
||||
|
||||
// 解析 payload
|
||||
json::value jv_t;
|
||||
try {
|
||||
jv_t = json::parse(auth);
|
||||
} catch (const std::exception&) {
|
||||
info.http_status = 400;
|
||||
return info;
|
||||
}
|
||||
if (!jv_t.is_object()) {
|
||||
info.http_status = 400;
|
||||
return info;
|
||||
}
|
||||
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
// 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况)
|
||||
try {
|
||||
info.username = json::value_to<std::string>(obj.at("user"));
|
||||
} catch (...) {
|
||||
info.http_status = 400;
|
||||
return info;
|
||||
}
|
||||
|
||||
// 过期检查
|
||||
auto it_created = obj.find("created_at");
|
||||
if (it_created != obj.end() && it_created->value().is_string()) {
|
||||
info.created_at = json::value_to<std::string>(it_created->value());
|
||||
}
|
||||
auto it_keep = obj.find("keep_time");
|
||||
if (it_keep != obj.end() && it_keep->value().is_int64()) {
|
||||
info.keep_time = static_cast<int>(it_keep->value().as_int64());
|
||||
}
|
||||
if (!info.created_at.empty()) {
|
||||
auto created_tp = LocalTime::parse(info.created_at);
|
||||
if (created_tp) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time);
|
||||
if (now > expire_tp) {
|
||||
info.http_status = 401;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// is_admin
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_admin != obj.end() && it_admin->value().is_bool()) {
|
||||
info.is_admin = it_admin->value().as_bool();
|
||||
}
|
||||
|
||||
// refresh_count / max_refresh / iat
|
||||
auto it_rc = obj.find("refresh_count");
|
||||
if (it_rc != obj.end() && it_rc->value().is_int64())
|
||||
info.refresh_count = static_cast<int>(it_rc->value().as_int64());
|
||||
auto it_mr = obj.find("max_refresh");
|
||||
if (it_mr != obj.end() && it_mr->value().is_int64())
|
||||
info.max_refresh = static_cast<int>(it_mr->value().as_int64());
|
||||
auto it_iat = obj.find("iat");
|
||||
if (it_iat != obj.end() && it_iat->value().is_string())
|
||||
info.iat = json::value_to<std::string>(it_iat->value());
|
||||
|
||||
// jti 黑名单检查(单 Token 吊销)
|
||||
auto it_jti = obj.find("jti");
|
||||
if (it_jti != obj.end() && it_jti->value().is_string()) {
|
||||
info.jti = json::value_to<std::string>(it_jti->value());
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
cleanup_jti_if_expired(info.jti); // 惰性清理
|
||||
if (_JTI_DENYLIST.count(info.jti)) {
|
||||
info.http_status = 401;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
// 版本号检查(全设备吊销:改密后版本号递增)
|
||||
// 兼容旧 Token 无 ver 字段:视为 ver=1
|
||||
auto it_ver = obj.find("ver");
|
||||
int token_ver = 1;
|
||||
if (it_ver != obj.end() && it_ver->value().is_int64()) {
|
||||
token_ver = static_cast<int>(it_ver->value().as_int64());
|
||||
}
|
||||
info.ver = token_ver;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
auto it_uv = _USER_VER_MAP.find(info.username);
|
||||
int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1;
|
||||
if (token_ver < current_ver) {
|
||||
info.http_status = 401;
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
// 全部通过
|
||||
info.valid = true;
|
||||
info.http_status = 200;
|
||||
return info;
|
||||
}
|
||||
|
||||
/* -------- 信号处理回调函数 -------- */
|
||||
void signalHandler(int signum) {
|
||||
|
||||
@@ -752,66 +1038,23 @@ static bool verifyWsAdminToken(const http::request<http::string_body>& req,
|
||||
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;
|
||||
/* 提取 Token(Authorization 优先,回退 Cookie) */
|
||||
std::string cookie_token;
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
if (raw_token.empty()) return false;
|
||||
|
||||
raw = raw.substr(7);
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos) return false;
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth = Base64::base64_decode_to_string(data);
|
||||
|
||||
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
if (!publ.Verify(auth, sign)) return false;
|
||||
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (...) { return false; }
|
||||
if (!jv_t.is_object()) return false;
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
auto t = json::value_to<std::string>(obj["created_at"]);
|
||||
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
|
||||
auto created_tp = LocalTime::parse(t);
|
||||
if (created_tp) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
|
||||
if (now > expire_tp) return false;
|
||||
}
|
||||
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
|
||||
TokenInfo ti = verify_token(raw_token);
|
||||
if (!ti.valid) 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) {
|
||||
if (!ti.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) {
|
||||
@@ -871,6 +1114,12 @@ void registerRoutes(HttpServer &server) {
|
||||
[](const auto& req, const auto& params) { return handleWhoami(req, params); });
|
||||
});
|
||||
|
||||
/* 内部服务间管理员验签视图(供 core 等调用,须在 post("*") 通配符之前注册) */
|
||||
server.post("/admin/verify", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
[](const auto& req, const auto& params) { return handleAdminVerify(req, params); });
|
||||
});
|
||||
|
||||
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
|
||||
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
|
||||
return wrapperHttp(req, params,
|
||||
@@ -1139,8 +1388,14 @@ http::response<http::string_body> handleLogin(
|
||||
* 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */
|
||||
bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end();
|
||||
|
||||
/* Token 吊销支持:签发新 jti + 当前版本号 */
|
||||
std::string jti = generate_jti();
|
||||
int user_ver = get_user_ver(username);
|
||||
|
||||
json::value jv_t = {
|
||||
{"user", username},
|
||||
{"jti", jti},
|
||||
{"ver", user_ver},
|
||||
{"iat", LocalTime::getCurrent()},
|
||||
{"created_at", LocalTime::getCurrent()},
|
||||
{"keep_time", _TOKEN_KEEP_TIME},
|
||||
@@ -1180,7 +1435,7 @@ http::response<http::string_body> handleLogin(
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 退出登录 /logout:清除 q4_token Cookie */
|
||||
/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */
|
||||
http::response<http::string_body> handleLogout(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
@@ -1188,6 +1443,18 @@ http::response<http::string_body> handleLogout(
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
/* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */
|
||||
std::string cookie_token;
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
if (!raw_token.empty()) {
|
||||
TokenInfo ti = verify_token(raw_token);
|
||||
if (ti.valid && !ti.jti.empty()) {
|
||||
revoke_jti(ti.jti, ti.keep_time);
|
||||
logs().info("gateway", "Token 已吊销: " + ti.username, std::nullopt,
|
||||
json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}}));
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Set-Cookie",
|
||||
"q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
|
||||
res.set("Connection", "close");
|
||||
@@ -1209,114 +1476,91 @@ http::response<http::string_body> handleWhoami(
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
|
||||
/* 获取 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(Authorization 优先,回退 Cookie) */
|
||||
std::string cookie_token;
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
|
||||
/* 无 Token 或格式错误:返回未登录 */
|
||||
if (raw.empty() || raw.substr(0, 7) != "Bearer ") {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
if (raw_token.empty()) {
|
||||
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");
|
||||
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
|
||||
TokenInfo ti = verify_token(raw_token);
|
||||
if (!ti.valid) {
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth;
|
||||
try { auth = Base64::base64_decode_to_string(data); }
|
||||
catch (const std::exception&) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* RSA 验签:失败按未登录处理 */
|
||||
if (!_PUBL.Verify(auth, sign)) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 解析 Token payload */
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const std::exception&) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
if (!jv_t.is_object()) {
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
res.result(http::status::ok);
|
||||
res.body() = R"({"login": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
json::object& obj = jv_t.as_object();
|
||||
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||
|
||||
/* is_admin:旧 Token 未携带时默认为 false */
|
||||
bool is_admin = false;
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||
is_admin = it_admin->value().as_bool();
|
||||
|
||||
json::value jv_r = {
|
||||
{"login", true},
|
||||
{"user", username},
|
||||
{"is_admin", is_admin}
|
||||
{"user", ti.username},
|
||||
{"is_admin", ti.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();
|
||||
if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat;
|
||||
if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at;
|
||||
if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time;
|
||||
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 内部服务间管理员验签视图:POST /admin/verify
|
||||
*
|
||||
* 供 core 等内部服务调用,验证请求携带的 Token 是否为有效且未过期的管理员。
|
||||
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token。
|
||||
* gateway 是 Token 签发与验证的唯一权威,core 通过本接口完成鉴权,避免密钥共享。
|
||||
*
|
||||
* 响应:{ success, is_admin, user }
|
||||
* - success=true, is_admin=true → 有效管理员(放行)
|
||||
* - success=true, is_admin=false → 非管理员(拒绝)
|
||||
* - success=false → 无 Token / 验签失败 / 过期(拒绝)
|
||||
* 验签失败一律返回 HTTP 401(fail-closed),供内部服务快速判拒绝。 */
|
||||
http::response<http::string_body> handleAdminVerify(
|
||||
const http::request<http::string_body>& req,
|
||||
const std::unordered_map<std::string, std::string>& params)
|
||||
{
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
res.set(http::field::content_type, "application/json");
|
||||
res.set("Connection", "close");
|
||||
|
||||
/* 提取 Token(Authorization 优先,回退 Cookie) */
|
||||
std::string cookie_token;
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
|
||||
if (raw_token.empty()) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = R"({"success": false, "is_admin": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
|
||||
TokenInfo ti = verify_token(raw_token);
|
||||
if (!ti.valid) {
|
||||
res.result(http::status::unauthorized);
|
||||
res.body() = R"({"success": false, "is_admin": false})";
|
||||
res.prepare_payload();
|
||||
return res;
|
||||
}
|
||||
|
||||
json::value jv_r = {
|
||||
{"success", true},
|
||||
{"is_admin", ti.is_admin},
|
||||
{"user", ti.username}
|
||||
};
|
||||
res.result(http::status::ok);
|
||||
res.body() = json::serialize(jv_r);
|
||||
res.prepare_payload();
|
||||
@@ -1352,59 +1596,84 @@ http::response<http::string_body> handleRefreshToken(
|
||||
THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.",
|
||||
429, "/refresh_token");
|
||||
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
/* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */
|
||||
std::string cookie_token;
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
if (raw_token.empty())
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
|
||||
|
||||
/* 获取 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;
|
||||
/* 正常过期(非吊销/版本错误)才允许宽限期刷新:
|
||||
* - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝
|
||||
* - 版本号落后(改密后旧token)→ 401 拒绝
|
||||
* 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。
|
||||
* 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。
|
||||
* 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */
|
||||
|
||||
// 重新做一次不含过期检查的验签(判断是否被吊销)
|
||||
bool token_revoked = false;
|
||||
std::string old_jti;
|
||||
std::string username;
|
||||
std::string iat_str;
|
||||
int keep_time = _TOKEN_KEEP_TIME;
|
||||
int refresh_count = 0;
|
||||
int max_refresh = _TOKEN_MAX_REFRESH;
|
||||
bool is_admin = false;
|
||||
int user_ver = 1;
|
||||
{
|
||||
if (raw_token.find("_") == std::string::npos)
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
size_t idx2 = raw_token.find("_");
|
||||
std::string data = raw_token.substr(0, idx2);
|
||||
std::string sign = raw_token.substr(idx2 + 1);
|
||||
std::string auth;
|
||||
try { auth = Base64::base64_decode_to_string(data); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
}
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
if (!_PUBL.Verify(auth, sign))
|
||||
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
}
|
||||
if (!jv_t.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
json::object& obj = jv_t.as_object();
|
||||
username = json::value_to<std::string>(obj.at("user"));
|
||||
iat_str = json::value_to<std::string>(obj.at("iat"));
|
||||
keep_time = json::value_to<int>(obj.at("keep_time"));
|
||||
refresh_count = json::value_to<int>(obj.at("refresh_count"));
|
||||
max_refresh = json::value_to<int>(obj.at("max_refresh"));
|
||||
auto it_admin = obj.find("is_admin");
|
||||
if (it_admin != obj.end() && it_admin->value().is_bool())
|
||||
is_admin = it_admin->value().as_bool();
|
||||
// 检查 jti 黑名单
|
||||
auto it_jti = obj.find("jti");
|
||||
if (it_jti != obj.end() && it_jti->value().is_string()) {
|
||||
old_jti = json::value_to<std::string>(it_jti->value());
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
if (_JTI_DENYLIST.count(old_jti))
|
||||
token_revoked = true;
|
||||
}
|
||||
// 检查版本号
|
||||
auto it_ver = obj.find("ver");
|
||||
int token_ver = 1;
|
||||
if (it_ver != obj.end() && it_ver->value().is_int64())
|
||||
token_ver = static_cast<int>(it_ver->value().as_int64());
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
|
||||
auto it_uv = _USER_VER_MAP.find(username);
|
||||
user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1;
|
||||
if (token_ver < user_ver)
|
||||
token_revoked = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (raw.empty())
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
|
||||
|
||||
/* 解析 Bearer <base64>_<signature> */
|
||||
if (raw.substr(0, 7) != "Bearer ")
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
raw = raw.substr(7);
|
||||
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos)
|
||||
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
|
||||
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx + 1);
|
||||
std::string auth = Base64::base64_decode_to_string(data);
|
||||
|
||||
/* RSA 验签 */
|
||||
if (!_PUBL.Verify(auth, sign))
|
||||
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
|
||||
|
||||
/* 解析 Token payload */
|
||||
json::value jv_t;
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const std::exception&) {
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
}
|
||||
if (!jv_t.is_object())
|
||||
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
|
||||
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
/* 提取关键字段 */
|
||||
std::string username = json::value_to<std::string>(obj.at("user"));
|
||||
std::string iat_str = json::value_to<std::string>(obj.at("iat"));
|
||||
int keep_time = json::value_to<int>(obj.at("keep_time"));
|
||||
int refresh_count = json::value_to<int>(obj.at("refresh_count"));
|
||||
int max_refresh = json::value_to<int>(obj.at("max_refresh"));
|
||||
if (token_revoked)
|
||||
THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.",
|
||||
401, "/refresh_token");
|
||||
|
||||
/* 刷新次数检查 */
|
||||
if (refresh_count >= max_refresh)
|
||||
@@ -1422,16 +1691,18 @@ http::response<http::string_body> handleRefreshToken(
|
||||
THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.",
|
||||
401, "/refresh_token");
|
||||
|
||||
/* 保留 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();
|
||||
/* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */
|
||||
if (!old_jti.empty()) {
|
||||
revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
|
||||
}
|
||||
|
||||
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */
|
||||
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */
|
||||
std::string new_jti = generate_jti();
|
||||
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
|
||||
json::value jv_new = {
|
||||
{"user", username},
|
||||
{"jti", new_jti},
|
||||
{"ver", user_ver},
|
||||
{"iat", iat_str},
|
||||
{"created_at", LocalTime::getCurrent()},
|
||||
{"keep_time", keep_time},
|
||||
@@ -1933,6 +2204,9 @@ http::response<http::string_body> handleResetPassword(
|
||||
if (!obj_r["success"].as_bool())
|
||||
THROW_EX_CUSTOM(HttpException, 2202, "Reset Failed", 500, "/reset_password");
|
||||
|
||||
/* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */
|
||||
revoke_all_user_tokens(username);
|
||||
|
||||
// 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册
|
||||
logs().info("gateway", "重置密码成功: " + username, std::nullopt,
|
||||
json::serialize(json::value{{"ip", client_ip}}));
|
||||
@@ -1952,18 +2226,11 @@ http::response<http::string_body> handleProxy(
|
||||
http::response<http::string_body> res;
|
||||
res.version(req.version());
|
||||
|
||||
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
|
||||
|
||||
std::string t_url = "";
|
||||
std::string url = "";
|
||||
std::string auth = "";
|
||||
std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */
|
||||
json::value jv_t;
|
||||
bool is_public_target = false;
|
||||
{
|
||||
auto flag = false;
|
||||
|
||||
|
||||
|
||||
/* 大写的Target-Url */
|
||||
auto it = req.base().find("Target-Url");
|
||||
if (it != req.base().end()) {
|
||||
@@ -1979,7 +2246,7 @@ http::response<http::string_body> handleProxy(
|
||||
/* 如果请求没设置Target-Url头,就自动设置为默认的 */
|
||||
if (t_url == "") {
|
||||
t_url = _DEFT_URL;
|
||||
flag = true;
|
||||
is_public_target = true;
|
||||
}
|
||||
|
||||
// 路径规范化校验(P1-1):防止路径遍历、协议注入、空字节等
|
||||
@@ -1989,58 +2256,25 @@ http::response<http::string_body> handleProxy(
|
||||
THROW_EX_CUSTOM(HttpException, 2300, "Invalid request path", 400, "*");
|
||||
|
||||
url = t_url;
|
||||
url += "/";
|
||||
/* 避免 target_path 以 "/" 开头时产生双斜杠(如 http://host:port//list),
|
||||
* 下游服务(core 等)使用精确路径匹配,双斜杠会导致 404 */
|
||||
if (target_path.empty() || target_path[0] != '/')
|
||||
url += "/";
|
||||
url += target_path;
|
||||
|
||||
std::string raw = "";
|
||||
it = req.base().find("Authorization");
|
||||
if (it != req.base().end()) {
|
||||
raw = std::string(it->value());
|
||||
} else {
|
||||
/* 回退:从 HttpOnly Cookie 读取 q4_token(浏览器同源请求自动携带) */
|
||||
auto cit = req.base().find("Cookie");
|
||||
if (cit != req.base().end()) {
|
||||
cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
|
||||
if (!cookie_token.empty())
|
||||
raw = "Bearer " + cookie_token;
|
||||
}
|
||||
}
|
||||
if (raw.empty() && !flag)
|
||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
||||
/* 提取 Token(Authorization 优先,回退 Cookie) */
|
||||
std::string raw_token = extract_token_from_request(req, cookie_token);
|
||||
|
||||
if (!flag) {
|
||||
/* 仅在需要鉴权的目标上校验凭证。
|
||||
* 默认目标(公开静态资源)必须跳过校验:
|
||||
* 浏览器会给每个请求自动携带 q4_token Cookie,
|
||||
* gateway 重启(内存 RSA 密钥对轮换)或 Token 过期后,
|
||||
* 旧 Cookie 验签失败会让公开页面也误返回 401 */
|
||||
if (raw.substr(0, 7) == "Bearer ") {
|
||||
raw = raw.substr(7, raw.size() - 7);
|
||||
size_t idx = raw.find("_");
|
||||
if (idx == std::string::npos)
|
||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 400, "*");
|
||||
std::string data = raw.substr(0, idx);
|
||||
std::string sign = raw.substr(idx+1, raw.size()-idx-1);
|
||||
auth = Base64::base64_decode_to_string(data);
|
||||
if (!_PUBL.Verify(auth, sign))
|
||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
||||
}
|
||||
if (!is_public_target) {
|
||||
/* 需要鉴权的目标:统一验签(RSA + 过期 + jti黑名单 + 版本号) */
|
||||
if (raw_token.empty())
|
||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
||||
|
||||
/* 是否过期 */
|
||||
try { jv_t = json::parse(auth); }
|
||||
catch (const boost::wrapexcept<boost::system::system_error>& e) {
|
||||
THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
|
||||
}
|
||||
if (!jv_t.is_object()) THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
|
||||
json::object& obj = jv_t.as_object();
|
||||
|
||||
auto t = json::value_to<std::string>(obj["created_at"]);
|
||||
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
|
||||
auto created_tp = LocalTime::parse(t);
|
||||
if (created_tp) {
|
||||
auto now = std::chrono::system_clock::now();
|
||||
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
|
||||
if (now > expire_tp) THROW_EX_CUSTOM(HttpException, 2000, "Authorization Expired", 401, "*");
|
||||
TokenInfo ti = verify_token(raw_token);
|
||||
if (!ti.valid) {
|
||||
if (ti.http_status == 400)
|
||||
THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
|
||||
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
|
||||
}
|
||||
} else {
|
||||
/* 公开目标不做凭证校验,也不向下游注入 Authorization */
|
||||
@@ -2064,11 +2298,8 @@ http::response<http::string_body> handleProxy(
|
||||
}
|
||||
|
||||
/*
|
||||
* 这是用于读数据的HTTP客户端回调函数
|
||||
*
|
||||
* FIXME: 增加长度限制
|
||||
* FIXME: 试试流式输出
|
||||
* */
|
||||
* HTTP客户端回调函数
|
||||
* 响应体大小限制由 HttpClient 默认 10MB 上限保证(libhttp P1-2 已修复) */
|
||||
auto callback = [&res](const auto& ret) {
|
||||
res.body() += ret;
|
||||
};
|
||||
|
||||
+45
-70
@@ -13,10 +13,9 @@ else()
|
||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -pipe -Wall")
|
||||
endif()
|
||||
|
||||
# Set library names
|
||||
set(SHARED_LIB_NAME "us_http")
|
||||
set(STATIC_LIB_NAME "us_http_static")
|
||||
set(FRAMEWORK_NAME "us")
|
||||
# Library names (与 Makefile 保持一致)
|
||||
set(SERVER_LIB_NAME "us_http")
|
||||
set(CLIENT_LIB_NAME "us_httpclient")
|
||||
|
||||
# ===== Boost Library Configuration (Header-Only) =====
|
||||
if(DEFINED ENV{BOOST_ROOT})
|
||||
@@ -41,86 +40,66 @@ include_directories(${Boost_INCLUDE_DIR})
|
||||
set(INCLUDE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/inc")
|
||||
include_directories(${INCLUDE_DIR})
|
||||
|
||||
# Source files
|
||||
set(SOURCES
|
||||
# Lowest-path OpenSSL/libcurl (macOS homebrew) — 可被环境覆盖
|
||||
set(EXTERNAL_LIB_DIR "/opt/homebrew/lib")
|
||||
set(EXTERNAL_INC_DIR "/opt/homebrew/include")
|
||||
include_directories(${EXTERNAL_INC_DIR})
|
||||
|
||||
# Server sources (HttpServer 拆分后的 6 个 TU)
|
||||
set(SERVER_SOURCES
|
||||
src/HttpServer.cpp
|
||||
src/WebSocketServer.cpp
|
||||
src/WebSocketClient.cpp
|
||||
src/HttpServer_Listener.cpp
|
||||
src/HttpServer_Routes.cpp
|
||||
src/HttpServer_Request.cpp
|
||||
src/HttpServer_Ws.cpp
|
||||
src/HttpServer_WsSession.cpp
|
||||
)
|
||||
|
||||
# Header files list
|
||||
set(HEADERS
|
||||
inc/${FRAMEWORK_NAME}/HttpServer.hpp
|
||||
inc/${FRAMEWORK_NAME}/HttpClient.hpp
|
||||
inc/${FRAMEWORK_NAME}/WebSocketServer.hpp
|
||||
inc/${FRAMEWORK_NAME}/WebSocketClient.hpp
|
||||
# Client sources
|
||||
set(CLIENT_SOURCES
|
||||
src/HttpClient.cpp
|
||||
)
|
||||
|
||||
# Create shared library (DLL)
|
||||
add_library(${SHARED_LIB_NAME} SHARED ${SOURCES})
|
||||
# Server library: libus_http.so (HTTP + WebSocket server + WS proxy, 不依赖 curl)
|
||||
add_library(${SERVER_LIB_NAME} SHARED ${SERVER_SOURCES})
|
||||
target_link_libraries(${SERVER_LIB_NAME}
|
||||
${EXTERNAL_LIB_DIR}/libssl.a
|
||||
${EXTERNAL_LIB_DIR}/libcrypto.a
|
||||
pthread
|
||||
)
|
||||
|
||||
# Create static library (LIB)
|
||||
add_library(${STATIC_LIB_NAME} STATIC ${SOURCES})
|
||||
|
||||
# Set properties for shared library
|
||||
if(WIN32)
|
||||
target_link_libraries(${SHARED_LIB_NAME}
|
||||
ws2_32
|
||||
)
|
||||
else()
|
||||
target_link_libraries(${SHARED_LIB_NAME}
|
||||
pthread
|
||||
)
|
||||
endif()
|
||||
# Client library: libus_httpclient.so (HttpClient, 依赖 curl)
|
||||
add_library(${CLIENT_LIB_NAME} SHARED ${CLIENT_SOURCES})
|
||||
target_link_libraries(${CLIENT_LIB_NAME}
|
||||
${EXTERNAL_LIB_DIR}/libssl.a
|
||||
${EXTERNAL_LIB_DIR}/libcrypto.a
|
||||
${EXTERNAL_LIB_DIR}/libcurl.a
|
||||
pthread
|
||||
)
|
||||
|
||||
# Set output directories
|
||||
set_target_properties(${SHARED_LIB_NAME} ${STATIC_LIB_NAME} PROPERTIES
|
||||
set_target_properties(${SERVER_LIB_NAME} ${CLIENT_LIB_NAME} PROPERTIES
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/build"
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/build"
|
||||
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/build"
|
||||
)
|
||||
|
||||
# Additional properties for Windows
|
||||
if(WIN32)
|
||||
set_target_properties(${SHARED_LIB_NAME} PROPERTIES
|
||||
PREFIX ""
|
||||
SUFFIX ".dll"
|
||||
)
|
||||
set_target_properties(${STATIC_LIB_NAME} PROPERTIES
|
||||
PREFIX ""
|
||||
SUFFIX ".lib"
|
||||
)
|
||||
else()
|
||||
set_target_properties(${SHARED_LIB_NAME} PROPERTIES
|
||||
PREFIX "lib"
|
||||
SUFFIX ".so"
|
||||
)
|
||||
set_target_properties(${STATIC_LIB_NAME} PROPERTIES
|
||||
PREFIX "lib"
|
||||
SUFFIX ".a"
|
||||
)
|
||||
endif()
|
||||
|
||||
# Position independent code for static library on Unix
|
||||
if(UNIX AND NOT APPLE)
|
||||
set_target_properties(${STATIC_LIB_NAME} PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON
|
||||
)
|
||||
set_target_properties(${SHARED_LIB_NAME} PROPERTIES
|
||||
POSITION_INDEPENDENT_CODE ON
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install targets
|
||||
install(TARGETS ${SHARED_LIB_NAME} ${STATIC_LIB_NAME}
|
||||
install(TARGETS ${SERVER_LIB_NAME} ${CLIENT_LIB_NAME}
|
||||
LIBRARY DESTINATION lib
|
||||
RUNTIME DESTINATION bin
|
||||
ARCHIVE DESTINATION lib
|
||||
)
|
||||
|
||||
# Install headers
|
||||
install(FILES ${HEADERS}
|
||||
DESTINATION include/${FRAMEWORK_NAME}
|
||||
install(FILES
|
||||
inc/us/HttpServer.hpp
|
||||
inc/us/HttpClient.hpp
|
||||
DESTINATION include/us
|
||||
)
|
||||
install(FILES
|
||||
inc/us/detail/HttpServerImpl.hpp
|
||||
DESTINATION include/us/detail
|
||||
)
|
||||
|
||||
# Clean target
|
||||
@@ -133,13 +112,9 @@ add_custom_target(clear
|
||||
message(STATUS "========================================")
|
||||
message(STATUS "Project: ${PROJECT_NAME}")
|
||||
message(STATUS "Version: ${PROJECT_VERSION}")
|
||||
message(STATUS "Shared library: ${SHARED_LIB_NAME}")
|
||||
message(STATUS "Static library: ${STATIC_LIB_NAME}")
|
||||
message(STATUS "Server library: ${SERVER_LIB_NAME}")
|
||||
message(STATUS "Client library: ${CLIENT_LIB_NAME}")
|
||||
message(STATUS "Build type: ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS "Install prefix: ${CMAKE_INSTALL_PREFIX}")
|
||||
message(STATUS "Compiler: ${CMAKE_CXX_COMPILER_ID}")
|
||||
if(MSVC)
|
||||
message(STATUS "MSVC version: ${MSVC_VERSION}")
|
||||
endif()
|
||||
message(STATUS "BOOST_ROOT: ${BOOST_ROOT}")
|
||||
message(STATUS "========================================")
|
||||
@@ -62,9 +62,6 @@ install: all
|
||||
@mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail
|
||||
@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 $(INCLU_SDIR)/$(FRAMEWORK_NAME)/WebSocketClientBase.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/detail/HttpServerImpl.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail/
|
||||
@cp -vf $(BUILD_TDIR)/$(LIB_NAME).so $(INSTL_TDIR)/
|
||||
@cp -vf $(BUILD_TDIR)/$(LIBC_NAME).so $(INSTL_TDIR)/
|
||||
@@ -74,9 +71,6 @@ uninstall:
|
||||
@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 $(INCLU_TDIR)/$(FRAMEWORK_NAME)/WebSocketClientBase.hpp
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/detail/HttpServerImpl.hpp
|
||||
@rm -vf $(INSTL_TDIR)/$(LIB_NAME).so
|
||||
@rm -vf $(INSTL_TDIR)/$(LIBC_NAME).so
|
||||
|
||||
@@ -13,9 +13,14 @@ C++20 network library for the UniServe microservice framework, providing unified
|
||||
- **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
|
||||
- **WS Message Size Limit**: `setMaxWsMessageSize()` caps single WebSocket message (default 16MB, aligned with Beast; applies to server and proxy streams) to prevent OOM
|
||||
- **Read Timeouts (Slowloris Protection)**: `setHeaderTimeout()` (default 10s) / `setReadTimeout()` (default 30s) enforce two-stage header/body read timeouts plus WS idle timeout, aligned with WAF
|
||||
- **WS Queue Size Limit**: `setMaxWsQueueSize()` (default 1000) caps WS send / proxy queues; slow consumers get disconnected to prevent unbounded-memory OOM
|
||||
|
||||
### HttpClient
|
||||
- **HTTP Client**: Synchronous HTTP requests via libcurl (GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS)
|
||||
- **Streaming Response**: `request_stream_sync` streams response body chunk-by-chunk to the callback (no full-body buffering)
|
||||
- **Response Size Limit**: `set_max_response_size()` caps downstream response body (default 10MB, aligned with WAF; 0 = unlimited) to prevent OOM on oversized responses
|
||||
- **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
|
||||
@@ -165,6 +170,20 @@ void use(MiddlewareHandler middleware);
|
||||
void setStaticDirectory(const std::string& path);
|
||||
```
|
||||
|
||||
// WebSocket 单条消息大小上限(字节)。默认 16MB,与 Beast 默认一致;超限由 Beast 拒绝并关闭连接(防 OOM)。
|
||||
void setMaxWsMessageSize(std::size_t bytes);
|
||||
std::size_t getMaxWsMessageSize() const;
|
||||
|
||||
// 入站读超时(防 Slowloris)。默认 10s(头)/ 30s(体),对齐 WAF。
|
||||
void setHeaderTimeout(std::chrono::seconds t);
|
||||
std::chrono::seconds getHeaderTimeout() const;
|
||||
void setReadTimeout(std::chrono::seconds t);
|
||||
std::chrono::seconds getReadTimeout() const;
|
||||
|
||||
// WS 发送/代理队列上限(条数)。默认 1000;超限判定慢消费者并关闭连接(防 OOM)。
|
||||
void setMaxWsQueueSize(std::size_t n);
|
||||
std::size_t getMaxWsQueueSize() const;
|
||||
|
||||
**WebSocket Routes**
|
||||
```cpp
|
||||
void ws(const std::string& path, WebSocketMessageHandler handler);
|
||||
@@ -221,6 +240,10 @@ void put_stream_sync(const std::string& url, const std::string& data, ResponseCa
|
||||
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);
|
||||
|
||||
// Response size limit (防 OOM). Default 10MB (aligned with WAF); 0 = unlimited.
|
||||
void set_max_response_size(size_t bytes);
|
||||
size_t get_max_response_size() const;
|
||||
```
|
||||
|
||||
**WebSocket Methods**
|
||||
@@ -277,6 +300,10 @@ using WebSocketClientBase = HttpClient;
|
||||
|
||||
## Version History
|
||||
|
||||
- **v3.4.3**: WebSocket 发送/代理队列上限(P1-6)。新增 `setMaxWsQueueSize()`(默认 1000);`wsSend` / `onProxyReadClient` / `onProxyReadBackend` 入队前检查,超限判定慢消费者并关闭连接,防止无界队列内存耗尽。
|
||||
- **v3.4.2**: 服务器读超时防 Slowloris(P1-4)。新增 `setHeaderTimeout()`(默认 10s)/`setReadTimeout()`(默认 30s);`doRead` 两阶段读(`async_read_header` 头超时 → `async_read` 体超时),`doSSLHandshake` 覆盖握手超时,`doWsRead` 设 WS 空闲超时,对齐 WAF ReadHeaderTimeout/ReadTimeout。
|
||||
- **v3.4.1**: WebSocket 单条消息大小限制(P1-3)。新增 `setMaxWsMessageSize()`/`getMaxWsMessageSize()`(默认 16MB,与 Beast 默认一致),服务端 `doWsUpgrade`/`doWssUpgrade` 与代理 `onProxyConnect`/`onProxySslHandshake` 创建流后调用 `read_message_max()`,超限消息由 Beast 在分配内存前拒绝并关闭连接,防 OOM。
|
||||
- **v3.4.0**: HttpClient 响应大小限制(防 OOM)。`request_stream_sync` 改为真流式(按块回调,不二次缓冲);新增 `set_max_response_size()`/`get_max_response_size()`(默认 10MB,对齐 WAF,0 表示不限制),超限时 `write_callback` 返回 0 中止传输并抛异常。
|
||||
- **v3.3.0**: Split HttpServer into 6 modular translation units by functional area (core/lifecycle, listener, routes, request dispatch, WS management, WS session/proxy) for dramatically faster incremental builds. Added internal detail header `inc/us/detail/HttpServerImpl.hpp` for shared definitions across TU boundaries.
|
||||
- **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.
|
||||
|
||||
+34
-171
@@ -30,13 +30,15 @@ libhttp/
|
||||
├── inc/us/
|
||||
│ ├── HttpClient.hpp # HTTP 客户端 (header-only)
|
||||
│ ├── HttpServer.hpp # HTTP/HTTPS 服务器声明
|
||||
│ ├── WebSocketClient.hpp # WebSocket 客户端声明
|
||||
│ ├── WebSocketClientBase.hpp # WebSocket 客户端抽象基类
|
||||
│ └── WebSocketServer.hpp # WebSocket 服务器声明
|
||||
│ └── detail/
|
||||
│ └── HttpServerImpl.hpp # Session/Listener 内部共享定义
|
||||
└── src/
|
||||
├── HttpServer.cpp # HTTP/HTTPS 服务器实现
|
||||
├── WebSocketClient.cpp # WebSocket 客户端实现
|
||||
└── WebSocketServer.cpp # WebSocket 服务器实现
|
||||
├── HttpServer.cpp # 核心生命周期/SSL 初始化
|
||||
├── HttpServer_Listener.cpp # TCP 连接接受器
|
||||
├── HttpServer_Routes.cpp # 路由注册/匹配/通配符
|
||||
├── HttpServer_Request.cpp # 请求分发/静态文件/CORS/HTTP 读写
|
||||
├── HttpServer_Ws.cpp # WebSocket 路由注册/客户端管理
|
||||
└── HttpServer_WsSession.cpp# WebSocket 会话/代理桥接
|
||||
```
|
||||
|
||||
---
|
||||
@@ -71,7 +73,7 @@ make install
|
||||
make uninstall
|
||||
```
|
||||
|
||||
> **注意**:Makefile 第 17 行默认只链接 `HttpServer.o`。如需 WebSocket 支持,需取消注释 `WebSocketServer.o` 和 `WebSocketClient.o`。
|
||||
> **注意**:HttpServer 拆分为 6 个 TU,统一编译进 `libus_http.so`;WebSocket 能力已内置于 HttpServer(单端口同时服务 HTTP + WebSocket),客户端通过 `HttpClient` 提供,无独立 WebSocketServer/WebSocketClient 类。
|
||||
|
||||
### 4.2 CMake
|
||||
|
||||
@@ -82,7 +84,7 @@ cmake --build .
|
||||
cmake --install . --prefix /usr/local
|
||||
```
|
||||
|
||||
CMake 会同时产出 `libus_http.so` (动态) 和 `libus_http_static.a` (静态)。
|
||||
CMake 会同时产出 `libus_http.so` (服务端) 和 `libus_httpclient.so` (客户端)。
|
||||
|
||||
### 4.3 一键脚本
|
||||
|
||||
@@ -257,130 +259,9 @@ void delete_stream_sync(const std::string& url, std::function<void(const std::st
|
||||
|
||||
---
|
||||
|
||||
### 5.3 WebSocketServer — WebSocket 服务器
|
||||
### 5.3 WebSocket 支持
|
||||
|
||||
**头文件**: `<us/WebSocketServer.hpp>`
|
||||
**实现**: `src/WebSocketServer.cpp`
|
||||
|
||||
#### 5.3.1 回调类型
|
||||
|
||||
```cpp
|
||||
using WebSocketMessageHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> sendCallback)>;
|
||||
|
||||
using WebSocketConnectHandler = std::function<void(
|
||||
const std::string& clientId, const std::string& remoteAddress)>;
|
||||
|
||||
using WebSocketDisconnectHandler = std::function<void(
|
||||
const std::string& clientId)>;
|
||||
```
|
||||
|
||||
#### 5.3.2 公共接口
|
||||
|
||||
```cpp
|
||||
WebSocketServer(unsigned short port, unsigned int threads = 2);
|
||||
~WebSocketServer();
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
bool isRunning() const;
|
||||
unsigned short getPort() const;
|
||||
|
||||
// 事件注册
|
||||
void onMessage(const std::string& path, WebSocketMessageHandler handler);
|
||||
void onConnect(WebSocketConnectHandler handler);
|
||||
void onDisconnect(WebSocketDisconnectHandler handler);
|
||||
|
||||
// 消息/客户端管理
|
||||
bool sendToClient(const std::string& clientId, const std::string& message);
|
||||
size_t broadcast(const std::string& message);
|
||||
void disconnectClient(const std::string& clientId);
|
||||
size_t getClientCount() const;
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- PIMPL 模式:内部实现封装于 `Impl` 结构体
|
||||
- 每个 `Session` 包含一个发送队列 (`std::deque<std::string>`),保证消息顺序
|
||||
- 客户端 ID 由全局原子计数器自动生成,格式 `"client_N"`
|
||||
- 线程安全:客户端列表受 `clientsMutex_` 保护
|
||||
- 消息处理器支持路径路由(按 `path` 键索引)
|
||||
|
||||
**内部架构**:
|
||||
|
||||
```
|
||||
WebSocketServer
|
||||
├── Impl
|
||||
│ ├── io_context
|
||||
│ ├── Listener (内部类) → tcp::acceptor
|
||||
│ └── threads_
|
||||
└── Session (内部类,enable_shared_from_this)
|
||||
├── websocket::stream<beast::tcp_stream>
|
||||
├── sendQueue_ (std::deque)
|
||||
└── 读取循环 → onMessage 回调 → sendCallback 写回
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 WebSocketClient — WebSocket 客户端
|
||||
|
||||
**头文件**: `<us/WebSocketClient.hpp>`, `<us/WebSocketClientBase.hpp>`
|
||||
**实现**: `src/WebSocketClient.cpp`
|
||||
|
||||
#### 5.4.1 抽象基类 `WebSocketClientBase`
|
||||
|
||||
定义统一接口:
|
||||
|
||||
```cpp
|
||||
using MessageCallback = std::function<void(const std::string&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
using ConnectCallback = std::function<void(bool)>;
|
||||
using CloseCallback = std::function<void()>;
|
||||
|
||||
// 回调设置
|
||||
virtual void setMessageCallback(MessageCallback) = 0;
|
||||
virtual void setErrorCallback(ErrorCallback) = 0;
|
||||
virtual void setConnectCallback(ConnectCallback) = 0;
|
||||
virtual void setCloseCallback(CloseCallback) = 0;
|
||||
|
||||
// 连接管理
|
||||
virtual bool connect(int timeout_ms = 5000) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool isConnected() const = 0;
|
||||
|
||||
// 消息发送
|
||||
virtual bool send(const std::string& message) = 0;
|
||||
virtual bool sendBinary(const void* data, size_t size) = 0;
|
||||
|
||||
// 配置
|
||||
virtual void setAutoReconnect(bool enable, int interval_ms = 3000) = 0;
|
||||
virtual void setHeartbeatInterval(int interval_ms = 30000) = 0;
|
||||
|
||||
// SSL 相关(纯虚函数,非 SSL 版本忽略参数)
|
||||
virtual bool isSecure() const = 0;
|
||||
virtual void setVerifyCertificate(bool verify) = 0;
|
||||
virtual void setCertificateFile(const std::string& cert_file) = 0;
|
||||
virtual void setPrivateKeyFile(const std::string& key_file) = 0;
|
||||
virtual void setCertificateAuthorityFile(const std::string& ca_file) = 0;
|
||||
```
|
||||
|
||||
#### 5.4.2 公共接口
|
||||
|
||||
```cpp
|
||||
WebSocketClient(const std::string& host, const std::string& port,
|
||||
const std::string& path = "/");
|
||||
~WebSocketClient();
|
||||
```
|
||||
|
||||
**特点**:
|
||||
- PIMPL 模式:`Impl` 结构体封装所有 Boost 对象和状态
|
||||
- 构造时自动启动 IO 线程
|
||||
- 异步连接流程:`resolve → connect → WebSocket handshake`
|
||||
- 发送队列受 `std::mutex` 和 `std::condition_variable` 保护
|
||||
- 自动重连:`onError` 或 `onClose` 中若 `auto_reconnect=true` 则自动发起重连
|
||||
- 非 SSL 版本:`isSecure()` 返回 `false`,SSL 配置方法为空实现
|
||||
- `sendBinary` 将二进制数据包装为 `std::string` 后调用 `send`
|
||||
WebSocket 能力已内置于 `HttpServer`(单端口同时服务 HTTP + WebSocket),通过 `ws()`/`ws_proxy()` 注册路由;客户端通过 `HttpClient` 的 `ws_*` 方法提供(`using WebSocketClient = HttpClient`)。原独立 `WebSocketServer`/`WebSocketClient` 类已删除(未构建的历史遗留,见安全审计 P3-1)。
|
||||
|
||||
---
|
||||
|
||||
@@ -476,45 +357,36 @@ auto& headers = client.get_response_headers();
|
||||
### 6.5 WebSocket 服务器
|
||||
|
||||
```cpp
|
||||
pmc::net::WebSocketServer ws_server(9000, 2);
|
||||
pmc::net::HttpServer server("0.0.0.0", 9000, 4);
|
||||
|
||||
ws_server.onConnect([](const std::string& clientId, const std::string& addr) {
|
||||
server.onWsConnect([](const std::string& clientId, const std::string& addr) {
|
||||
std::cout << "Client connected: " << clientId << " from " << addr << std::endl;
|
||||
});
|
||||
|
||||
ws_server.onMessage("/chat", [&ws_server](const std::string& clientId,
|
||||
const std::string& message,
|
||||
auto sendCallback) {
|
||||
server.ws("/chat", [](const std::string& clientId,
|
||||
const std::string& message,
|
||||
auto sendCallback) {
|
||||
std::cout << "[" << clientId << "]: " << message << std::endl;
|
||||
ws_server.broadcast(clientId + ": " + message);
|
||||
sendCallback("echo: " + message);
|
||||
});
|
||||
|
||||
ws_server.onDisconnect([](const std::string& clientId) {
|
||||
server.onWsDisconnect([](const std::string& clientId) {
|
||||
std::cout << "Client disconnected: " << clientId << std::endl;
|
||||
});
|
||||
|
||||
ws_server.start();
|
||||
server.start();
|
||||
```
|
||||
|
||||
### 6.6 WebSocket 客户端
|
||||
|
||||
```cpp
|
||||
pmc::net::WebSocketClient ws_client("localhost", "9000", "/chat");
|
||||
pmc::net::HttpClient ws_client;
|
||||
|
||||
ws_client.setConnectCallback([](bool success) {
|
||||
std::cout << (success ? "Connected!" : "Failed!") << std::endl;
|
||||
});
|
||||
|
||||
ws_client.setMessageCallback([](const std::string& msg) {
|
||||
std::cout << "Received: " << msg << std::endl;
|
||||
});
|
||||
|
||||
ws_client.setAutoReconnect(true, 3000);
|
||||
ws_client.connect();
|
||||
|
||||
// 阻塞主线程保持连接
|
||||
while (ws_client.isConnected()) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (ws_client.ws_connect("ws://localhost:9000/chat")) {
|
||||
ws_client.ws_send("hello");
|
||||
std::string reply = ws_client.ws_recv();
|
||||
std::cout << "Received: " << reply << std::endl;
|
||||
ws_client.ws_close();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -548,22 +420,15 @@ while (ws_client.isConnected()) {
|
||||
### 8.1 线程模型
|
||||
|
||||
- **HttpServer**: 每个工作线程独立运行 `io_context::run()`,所有异步操作通过 `strand` 序列化,同一 Session 的读写不会并发冲突
|
||||
- **WebSocketServer**: 同 HttpServer,工作线程池跑 `io_context`
|
||||
- **WebSocketClient**: 单 IO 线程,所有异步回调在该线程中执行
|
||||
- **HttpClient**: 同步阻塞,无额外线程
|
||||
- **HttpClient**: 同步阻塞,无额外线程(HTTP 经 libcurl;WebSocket 经 Beast)
|
||||
|
||||
### 8.2 PIMPL 模式
|
||||
|
||||
`WebSocketClient` 和 `WebSocketServer` 使用 PIMPL 隐藏 Boost 依赖,保持头文件轻量。
|
||||
|
||||
### 8.3 会话管理
|
||||
### 8.2 会话管理
|
||||
|
||||
- `HttpServer` 通过 `weak_ptr<Session>` 跟踪所有活跃会话,`stop()` 时遍历清理
|
||||
- `WebSocketServer` 通过 `unordered_map<string, shared_ptr<Session>>` + mutex 管理
|
||||
|
||||
### 8.4 发送队列
|
||||
### 8.3 发送队列
|
||||
|
||||
`WebSocketServer::Session` 和 `WebSocketClient::Impl` 均采用 `std::deque` 消息队列,确保消息按序发送且不会因并发写入冲突。
|
||||
`HttpServer::Session` 的 `ws_send_queue_` 与代理队列 (`proxy_to_backend_queue_`/`proxy_to_client_queue_`) 采用 `std::deque` 消息队列,确保消息按序发送且不会因并发写入冲突。
|
||||
|
||||
---
|
||||
|
||||
@@ -596,11 +461,9 @@ micro_servers/
|
||||
|
||||
## 11. 已知限制
|
||||
|
||||
1. **Makefile 默认不编译 WebSocket**:第 17 行仅链接 `HttpServer.o`,需手动启用
|
||||
2. **WebSocketClient 无 SSL 实现**:当前仅有非加密版本;SSL 接口已声明但为空实现
|
||||
3. **无 HTTP/2 支持**:仅 HTTP/1.1
|
||||
4. **静态文件 MIME 有限**:仅支持 6 种常见类型
|
||||
5. **无请求体大小限制**:依赖 Boost.Beast 默认配置
|
||||
6. **HttpClient 仅同步**:无异步 HTTP 客户端实现
|
||||
1. **无 HTTP/2 支持**:仅 HTTP/1.1
|
||||
2. **静态文件 MIME 有限**:仅支持 6 种常见类型
|
||||
3. **无请求体大小限制**:依赖 Boost.Beast 默认配置(见安全审计 P1-1)
|
||||
4. **HttpClient 仅同步**:无异步 HTTP 客户端实现
|
||||
```
|
||||
|
||||
@@ -59,6 +59,12 @@ public:
|
||||
void put_stream_sync(const std::string& url, const std::string& data, std::function<void(const std::string&)> callback);
|
||||
void delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
|
||||
|
||||
// ===== 响应体大小限制(防 OOM)=====
|
||||
// 设置下游响应体大小上限(字节)。0 表示不限制(默认 10MB,与 WAF 对齐)。
|
||||
// 超限时 write_callback 返回 0 中止传输,request_stream_sync 抛异常。
|
||||
void set_max_response_size(size_t bytes);
|
||||
size_t get_max_response_size() const;
|
||||
|
||||
// ===== 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,
|
||||
@@ -77,6 +83,7 @@ private:
|
||||
struct curl_slist* headers_ = nullptr;
|
||||
std::map<std::string, std::string> response_headers_;
|
||||
long response_code_ = 0;
|
||||
size_t max_response_size_ = 10 * 1024 * 1024; // 默认 10MB,对齐 WAF;0 表示不限制
|
||||
|
||||
struct WsState {
|
||||
asio::io_context ioc;
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <mutex>
|
||||
#include <regex>
|
||||
#include <deque>
|
||||
#include <chrono>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
@@ -115,6 +116,27 @@ public:
|
||||
|
||||
void setPrivateKeyPassword(const std::string& password);
|
||||
|
||||
// 请求体大小上限(字节)。默认 10MB,与 WAF 对齐;超限返回 413。
|
||||
void setMaxBodySize(std::size_t bytes) { max_body_size_ = bytes; }
|
||||
std::size_t getMaxBodySize() const { return max_body_size_; }
|
||||
|
||||
// WebSocket 单条消息大小上限(字节)。默认 16MB,与 Beast 默认一致(防 OOM)。
|
||||
// 超限时 Beast 拒绝该消息并关闭连接(P1-3)。
|
||||
void setMaxWsMessageSize(std::size_t bytes) { max_ws_message_size_ = bytes; }
|
||||
std::size_t getMaxWsMessageSize() const { return max_ws_message_size_; }
|
||||
|
||||
// 入站读超时(防 Slowloris,P1-4)。默认与 WAF 对齐:头 10s、体 30s。
|
||||
// 头超时覆盖 TCP 连接建立 + SSL 握手 + 请求头读取;体超时覆盖请求体读取。
|
||||
void setHeaderTimeout(std::chrono::seconds t) { header_timeout_ = t; }
|
||||
std::chrono::seconds getHeaderTimeout() const { return header_timeout_; }
|
||||
void setReadTimeout(std::chrono::seconds t) { read_timeout_ = t; }
|
||||
std::chrono::seconds getReadTimeout() const { return read_timeout_; }
|
||||
|
||||
// WS 发送/代理队列上限(条数)。默认 1000。防止慢消费者导致无界队列内存耗尽(P1-6)。
|
||||
// 超限时判定为慢消费者,关闭对应连接。
|
||||
void setMaxWsQueueSize(std::size_t n) { max_ws_queue_size_ = n; }
|
||||
std::size_t getMaxWsQueueSize() const { return max_ws_queue_size_; }
|
||||
|
||||
class debug {
|
||||
public:
|
||||
static void print_all_header_fields(const http::request<http::string_body>& req);
|
||||
@@ -129,6 +151,12 @@ private:
|
||||
unsigned int threads_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
std::size_t max_body_size_ = 10 * 1024 * 1024; // 默认 10MB,与 WAF 对齐
|
||||
std::size_t max_ws_message_size_ = 16 * 1024 * 1024; // 默认 16MB,与 Beast 默认一致
|
||||
std::chrono::seconds header_timeout_{10}; // 头/握手超时,防 Slowloris(对齐 WAF ReadHeaderTimeout)
|
||||
std::chrono::seconds read_timeout_{30}; // 体超时(对齐 WAF ReadTimeout)
|
||||
std::size_t max_ws_queue_size_ = 1000; // WS 发送/代理队列上限,防慢消费者 OOM(P1-6)
|
||||
|
||||
bool ssl_enabled_{false};
|
||||
std::string cert_file_;
|
||||
std::string key_file_;
|
||||
@@ -161,6 +189,7 @@ private:
|
||||
std::mutex sessions_mutex_;
|
||||
|
||||
std::unordered_map<std::string, std::string> parseQueryParams(const std::string& query);
|
||||
bool isSafeStaticPath(const std::string& path) const;
|
||||
std::regex wildcardToRegex(const std::string& pattern);
|
||||
bool matchPath(const std::string& path, const std::regex& pattern);
|
||||
http::response<http::string_body> handleRequest(const http::request<http::string_body>& req);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#ifndef PMC_NET_WEBSOCKET_CLIENT_HPP
|
||||
#define PMC_NET_WEBSOCKET_CLIENT_HPP
|
||||
|
||||
#include "WebSocketClientBase.hpp"
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
/**
|
||||
* @brief Asynchronous WebSocket client based on Boost.Beast (non-SSL version)
|
||||
*/
|
||||
class WebSocketClient : public WebSocketClientBase {
|
||||
public:
|
||||
/**
|
||||
* @brief Constructor
|
||||
* @param host Server hostname or IP address
|
||||
* @param port Server port number
|
||||
* @param path WebSocket path (default "/")
|
||||
*/
|
||||
WebSocketClient(const std::string& host, const std::string& port, const std::string& path = "/");
|
||||
|
||||
/**
|
||||
* @brief Destructor
|
||||
*/
|
||||
~WebSocketClient();
|
||||
|
||||
// Implement WebSocketClientBase interface
|
||||
void setMessageCallback(MessageCallback callback) override;
|
||||
void setErrorCallback(ErrorCallback callback) override;
|
||||
void setConnectCallback(ConnectCallback callback) override;
|
||||
void setCloseCallback(CloseCallback callback) override;
|
||||
|
||||
bool connect(int timeout_ms = 5000) override;
|
||||
void disconnect() override;
|
||||
bool isConnected() const override;
|
||||
|
||||
bool send(const std::string& message) override;
|
||||
bool sendBinary(const void* data, size_t size) override;
|
||||
|
||||
void setAutoReconnect(bool enable, int interval_ms = 3000) override;
|
||||
void setHeartbeatInterval(int interval_ms = 30000) override;
|
||||
|
||||
std::string getHost() const override;
|
||||
std::string getPort() const override;
|
||||
std::string getPath() const override;
|
||||
|
||||
// SSL/TLS related (non-SSL version returns default values)
|
||||
bool isSecure() const override { return false; }
|
||||
void setVerifyCertificate(bool verify) override { (void)verify;/* Ignored */ }
|
||||
void setCertificateFile(const std::string& cert_file) override { (void)cert_file; /* Ignored */ }
|
||||
void setPrivateKeyFile(const std::string& key_file) override { (void)key_file;/* Ignored */ }
|
||||
void setCertificateAuthorityFile(const std::string& ca_file) override { (void)ca_file;/* Ignored */ }
|
||||
|
||||
private:
|
||||
// Forward declaration of implementation class
|
||||
class Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
|
||||
#endif // PMC_NET_WEBSOCKET_CLIENT_HPP
|
||||
@@ -1,61 +0,0 @@
|
||||
#ifndef PMC_NET_WEBSOCKET_CLIENT_BASE_HPP
|
||||
#define PMC_NET_WEBSOCKET_CLIENT_BASE_HPP
|
||||
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
/**
|
||||
* @brief WebSocket client base class
|
||||
*
|
||||
* Defines the common interface for all WebSocket clients
|
||||
*/
|
||||
class WebSocketClientBase {
|
||||
public:
|
||||
// Callback function types
|
||||
using MessageCallback = std::function<void(const std::string&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
using ConnectCallback = std::function<void(bool)>;
|
||||
using CloseCallback = std::function<void()>;
|
||||
|
||||
virtual ~WebSocketClientBase() = default;
|
||||
|
||||
// Callback setters
|
||||
virtual void setMessageCallback(MessageCallback callback) = 0;
|
||||
virtual void setErrorCallback(ErrorCallback callback) = 0;
|
||||
virtual void setConnectCallback(ConnectCallback callback) = 0;
|
||||
virtual void setCloseCallback(CloseCallback callback) = 0;
|
||||
|
||||
// Connection management
|
||||
virtual bool connect(int timeout_ms = 5000) = 0;
|
||||
virtual void disconnect() = 0;
|
||||
virtual bool isConnected() const = 0;
|
||||
|
||||
// Message sending
|
||||
virtual bool send(const std::string& message) = 0;
|
||||
virtual bool sendBinary(const void* data, size_t size) = 0;
|
||||
|
||||
// Configuration options
|
||||
virtual void setAutoReconnect(bool enable, int interval_ms = 3000) = 0;
|
||||
virtual void setHeartbeatInterval(int interval_ms = 30000) = 0;
|
||||
|
||||
// Information retrieval
|
||||
virtual std::string getHost() const = 0;
|
||||
virtual std::string getPort() const = 0;
|
||||
virtual std::string getPath() const = 0;
|
||||
|
||||
// SSL/TLS related
|
||||
virtual bool isSecure() const = 0;
|
||||
virtual void setVerifyCertificate(bool verify) = 0;
|
||||
virtual void setCertificateFile(const std::string& cert_file) = 0;
|
||||
virtual void setPrivateKeyFile(const std::string& key_file) = 0;
|
||||
virtual void setCertificateAuthorityFile(const std::string& ca_file) = 0;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
|
||||
#endif // PMC_NET_WEBSOCKET_CLIENT_BASE_HPP
|
||||
@@ -1,176 +0,0 @@
|
||||
// WebSocketServer.hpp - WebSocket server header file
|
||||
// Based on Boost.Beast and Boost.Asio
|
||||
|
||||
#ifndef PMC_NET_WEBSOCKETSERVER_HPP
|
||||
#define PMC_NET_WEBSOCKETSERVER_HPP
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include <atomic>
|
||||
#include <vector>
|
||||
#include <thread>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
|
||||
namespace pmc::net {
|
||||
|
||||
/**
|
||||
* @brief WebSocket connection handler type definition
|
||||
*
|
||||
* Handles message sending and receiving after WebSocket connection is established
|
||||
*/
|
||||
using WebSocketMessageHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& message,
|
||||
std::function<void(const std::string&)> sendCallback)>;
|
||||
|
||||
/**
|
||||
* @brief WebSocket connection callback type definition
|
||||
*/
|
||||
using WebSocketConnectHandler = std::function<void(
|
||||
const std::string& clientId,
|
||||
const std::string& remoteAddress)>;
|
||||
|
||||
/**
|
||||
* @brief WebSocket disconnection callback type definition
|
||||
*/
|
||||
using WebSocketDisconnectHandler = std::function<void(
|
||||
const std::string& clientId)>;
|
||||
|
||||
/**
|
||||
* @brief WebSocket server class
|
||||
*
|
||||
* WebSocket server implemented based on Boost.Beast and Boost.Asio
|
||||
*/
|
||||
class WebSocketServer {
|
||||
public:
|
||||
/**
|
||||
* @brief Constructor
|
||||
* @param port Listening port
|
||||
* @param threads Number of threads (default 2)
|
||||
*/
|
||||
WebSocketServer(unsigned short port, unsigned int threads = 2);
|
||||
|
||||
/**
|
||||
* @brief Destructor
|
||||
*/
|
||||
~WebSocketServer();
|
||||
|
||||
/**
|
||||
* @brief Start the server
|
||||
*/
|
||||
void start();
|
||||
|
||||
/**
|
||||
* @brief Stop the server
|
||||
*/
|
||||
void stop();
|
||||
|
||||
/**
|
||||
* @brief Register message handler
|
||||
* @param path Path (for routing)
|
||||
* @param handler Message handler
|
||||
*/
|
||||
void onMessage(const std::string& path, WebSocketMessageHandler handler);
|
||||
|
||||
/**
|
||||
* @brief Register connection handler
|
||||
* @param handler Connection handler
|
||||
*/
|
||||
void onConnect(WebSocketConnectHandler handler);
|
||||
|
||||
/**
|
||||
* @brief Register disconnection handler
|
||||
* @param handler Disconnection handler
|
||||
*/
|
||||
void onDisconnect(WebSocketDisconnectHandler handler);
|
||||
|
||||
/**
|
||||
* @brief Send message to a specific client
|
||||
* @param clientId Client ID
|
||||
* @param message Message content
|
||||
* @return Whether sending was successful
|
||||
*/
|
||||
bool sendToClient(const std::string& clientId, const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Broadcast message to all clients
|
||||
* @param message Message content
|
||||
* @return Number of clients that successfully received the message
|
||||
*/
|
||||
size_t broadcast(const std::string& message);
|
||||
|
||||
/**
|
||||
* @brief Disconnect a specific client
|
||||
* @param clientId Client ID
|
||||
*/
|
||||
void disconnectClient(const std::string& clientId);
|
||||
|
||||
/**
|
||||
* @brief Get the number of currently connected clients
|
||||
* @return Number of clients
|
||||
*/
|
||||
size_t getClientCount() const;
|
||||
|
||||
/**
|
||||
* @brief Check if the server is running
|
||||
* @return Running status
|
||||
*/
|
||||
bool isRunning() const { return running_; }
|
||||
|
||||
/**
|
||||
* @brief Get the server listening port
|
||||
* @return Port number
|
||||
*/
|
||||
unsigned short getPort() const { return port_; }
|
||||
|
||||
private:
|
||||
// Internal Session class
|
||||
class Session;
|
||||
|
||||
// Internal Listener class
|
||||
class Listener;
|
||||
|
||||
// Internal implementation structure
|
||||
struct Impl {
|
||||
boost::asio::io_context ioc_;
|
||||
std::shared_ptr<Listener> listener_;
|
||||
std::vector<std::thread> threads_;
|
||||
};
|
||||
|
||||
// Server configuration
|
||||
unsigned short port_;
|
||||
unsigned int threads_;
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// Internal implementation
|
||||
std::unique_ptr<Impl> impl_;
|
||||
|
||||
// Generate client ID
|
||||
std::string generateClientId() const;
|
||||
|
||||
// Add client
|
||||
void addClient(const std::string& clientId, std::shared_ptr<Session> session);
|
||||
|
||||
// Remove client
|
||||
void removeClient(const std::string& clientId);
|
||||
|
||||
// Get client session
|
||||
std::shared_ptr<Session> getClient(const std::string& clientId);
|
||||
|
||||
// Callback functions
|
||||
std::unordered_map<std::string, WebSocketMessageHandler> messageHandlers_;
|
||||
WebSocketConnectHandler connectHandler_;
|
||||
WebSocketDisconnectHandler disconnectHandler_;
|
||||
|
||||
// Client management
|
||||
mutable std::mutex clientsMutex_;
|
||||
std::unordered_map<std::string, std::shared_ptr<Session>> clients_;
|
||||
};
|
||||
|
||||
} // namespace pmc::net
|
||||
|
||||
#endif // PMC_NET_WEBSOCKETSERVER_HPP
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <optional>
|
||||
#include <openssl/err.h>
|
||||
#include <openssl/ssl.h>
|
||||
#include <boost/beast/websocket/ssl.hpp>
|
||||
@@ -66,6 +67,7 @@ private:
|
||||
void close();
|
||||
void doSSLHandshake();
|
||||
void doRead();
|
||||
void doReadBody();
|
||||
void handleRead(beast::error_code ec);
|
||||
bool isWebSocketUpgrade(const http::request<http::string_body>& req);
|
||||
void doWsUpgrade();
|
||||
@@ -112,6 +114,7 @@ private:
|
||||
beast::flat_buffer buffer_;
|
||||
beast::flat_buffer ws_buffer_;
|
||||
http::request<http::string_body> request_;
|
||||
std::optional<http::request_parser<http::string_body>> parser_;
|
||||
|
||||
HttpServer* server_;
|
||||
bool ssl_enabled_;
|
||||
|
||||
@@ -4,6 +4,19 @@
|
||||
|
||||
namespace pmc::net {
|
||||
|
||||
namespace {
|
||||
|
||||
// write_callback 的用户态上下文:流式回调 + 响应大小上限
|
||||
struct WriteCtx {
|
||||
std::function<void(const std::string&)>* callback = nullptr; // 流式回调(非空时每块回调)
|
||||
std::string* full_response = nullptr; // 退化累积(callback 为空时)
|
||||
size_t received = 0; // 已接收字节数
|
||||
size_t max = 0; // 响应上限(0 表示不限制)
|
||||
bool overflow = false; // 是否超限
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
HttpClient::HttpClient() {
|
||||
curl_ = curl_easy_init();
|
||||
if (!curl_) {
|
||||
@@ -21,6 +34,14 @@ HttpClient::~HttpClient() {
|
||||
}
|
||||
}
|
||||
|
||||
void HttpClient::set_max_response_size(size_t bytes) {
|
||||
max_response_size_ = bytes;
|
||||
}
|
||||
|
||||
size_t HttpClient::get_max_response_size() const {
|
||||
return max_response_size_;
|
||||
}
|
||||
|
||||
void HttpClient::set_header(const std::string& header) {
|
||||
headers_ = curl_slist_append(headers_, header.c_str());
|
||||
}
|
||||
@@ -61,8 +82,12 @@ void HttpClient::request_stream_sync(
|
||||
curl_easy_setopt(curl_, CURLOPT_HEADERDATA, this);
|
||||
|
||||
std::string full_response;
|
||||
WriteCtx write_ctx;
|
||||
write_ctx.callback = &callback;
|
||||
write_ctx.full_response = &full_response;
|
||||
write_ctx.max = max_response_size_;
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEFUNCTION, write_callback);
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &full_response);
|
||||
curl_easy_setopt(curl_, CURLOPT_WRITEDATA, &write_ctx);
|
||||
|
||||
curl_easy_setopt(curl_, CURLOPT_TIMEOUT, 30L);
|
||||
curl_easy_setopt(curl_, CURLOPT_CONNECTTIMEOUT, 10L);
|
||||
@@ -70,11 +95,16 @@ void HttpClient::request_stream_sync(
|
||||
CURLcode res = curl_easy_perform(curl_);
|
||||
|
||||
if (res != CURLE_OK) {
|
||||
if (res == CURLE_WRITE_ERROR && write_ctx.overflow) {
|
||||
throw std::runtime_error("HTTP response exceeded max size ("
|
||||
+ std::to_string(max_response_size_) + " bytes)");
|
||||
}
|
||||
throw std::runtime_error(std::string("CURL error: ") + curl_easy_strerror(res));
|
||||
}
|
||||
|
||||
curl_easy_getinfo(curl_, CURLINFO_RESPONSE_CODE, &response_code_);
|
||||
|
||||
// 退化路径:调用方未传 callback 时,整块累积后一次性回调
|
||||
if (!full_response.empty()) {
|
||||
callback(full_response);
|
||||
}
|
||||
@@ -328,8 +358,20 @@ size_t HttpClient::header_callback(char* buffer, size_t size, size_t nitems, voi
|
||||
|
||||
size_t HttpClient::write_callback(char* ptr, size_t size, size_t nmemb, void* userdata) {
|
||||
size_t total_size = size * nmemb;
|
||||
std::string* response = static_cast<std::string*>(userdata);
|
||||
response->append(ptr, total_size);
|
||||
auto* ctx = static_cast<WriteCtx*>(userdata);
|
||||
// 响应上限:超限返回 0,让 curl 以 CURLE_WRITE_ERROR 中止传输
|
||||
if (ctx->max > 0 && ctx->received + total_size > ctx->max) {
|
||||
ctx->overflow = true;
|
||||
return 0;
|
||||
}
|
||||
ctx->received += total_size;
|
||||
// 流式:callback 非空时每块即回调(不累积,避免二次内存)
|
||||
if (ctx->callback && *ctx->callback) {
|
||||
(*ctx->callback)(std::string(ptr, total_size));
|
||||
} else if (ctx->full_response) {
|
||||
// 退化:callback 为空时整块累积,供一次性回调
|
||||
ctx->full_response->append(ptr, total_size);
|
||||
}
|
||||
return total_size;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "us/detail/HttpServerImpl.hpp"
|
||||
#include <cctype>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
@@ -91,7 +92,7 @@ http::response<http::string_body> HttpServer::handleRequest(const http::request<
|
||||
|
||||
auto handler = findRoute(method, target_path);
|
||||
|
||||
if (!handler && !static_directory_.empty()) {
|
||||
if (!handler && !static_directory_.empty() && isSafeStaticPath(target_path)) {
|
||||
std::string file_path = static_directory_ + target_path;
|
||||
std::ifstream file(file_path, std::ios::binary);
|
||||
if (file.is_open()) {
|
||||
@@ -136,6 +137,25 @@ http::response<http::string_body> HttpServer::handleRequest(const http::request<
|
||||
return res;
|
||||
}
|
||||
|
||||
// ---- 静态文件路径安全校验(P1-5)----
|
||||
bool HttpServer::isSafeStaticPath(const std::string& path) const {
|
||||
// 防御:路径遍历、空字节、绝对 URI 形式、URL 编码遍历
|
||||
if (path.empty()) return false;
|
||||
if (path[0] != '/') return false; // 拒绝 "http://..."/相对形式
|
||||
if (path.find('\0') != std::string::npos) return false; // 拒绝空字节截断
|
||||
if (path.find("..") != std::string::npos) return false; // 拒绝明文 ".."
|
||||
|
||||
// 拒绝 URL 编码遍历/斜杠变体(%2e/./%2f/%5c)
|
||||
std::string lower;
|
||||
lower.reserve(path.size());
|
||||
for (char c : path)
|
||||
lower.push_back(static_cast<char>(std::tolower(static_cast<unsigned char>(c))));
|
||||
if (lower.find("%2e") != std::string::npos) return false;
|
||||
if (lower.find("%2f") != std::string::npos) return false;
|
||||
if (lower.find("%5c") != std::string::npos) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- Session HTTP 处理 ----
|
||||
void HttpServer::Session::setRemoteInfo(const std::string& ip, unsigned short port) {
|
||||
remote_ip_ = ip;
|
||||
@@ -147,6 +167,13 @@ void HttpServer::Session::doSSLHandshake() {
|
||||
std::cout << "[SSL] Starting handshake for "
|
||||
<< remote_ip_ << ":" << remote_port_ << std::endl;
|
||||
|
||||
// P1-4:设置头超时(TCP 连接完成后,覆盖 SSL 握手 + 请求头读取)
|
||||
if (ssl_enabled_ && ssl_stream_) {
|
||||
ssl_stream_->next_layer().expires_after(server_->getHeaderTimeout());
|
||||
} else {
|
||||
stream_.expires_after(server_->getHeaderTimeout());
|
||||
}
|
||||
|
||||
ssl_stream_->async_handshake(
|
||||
asio::ssl::stream_base::server,
|
||||
[self](beast::error_code ec) {
|
||||
@@ -169,16 +196,50 @@ void HttpServer::Session::doRead() {
|
||||
request_ = {};
|
||||
buffer_.consume(buffer_.size());
|
||||
|
||||
// 使用 request_parser 并设置可配置的请求体上限(默认 10MB,超限返回 413)
|
||||
parser_.emplace();
|
||||
parser_->body_limit(server_->max_body_size_);
|
||||
|
||||
// P1-4:阶段1 读请求头(头超时,防 Slowloris)
|
||||
if (ssl_enabled_ && ssl_stream_) {
|
||||
http::async_read(*ssl_stream_, buffer_, request_,
|
||||
[self](beast::error_code ec, std::size_t) {
|
||||
self->handleRead(ec);
|
||||
});
|
||||
ssl_stream_->next_layer().expires_after(server_->getHeaderTimeout());
|
||||
} else {
|
||||
http::async_read(stream_, buffer_, request_,
|
||||
[self](beast::error_code ec, std::size_t) {
|
||||
self->handleRead(ec);
|
||||
});
|
||||
stream_.expires_after(server_->getHeaderTimeout());
|
||||
}
|
||||
|
||||
auto on_header = [self](beast::error_code ec, std::size_t) {
|
||||
if (ec) {
|
||||
self->handleRead(ec);
|
||||
return;
|
||||
}
|
||||
// 阶段2 读请求体:切换到体超时
|
||||
if (self->ssl_enabled_ && self->ssl_stream_) {
|
||||
self->ssl_stream_->next_layer().expires_after(self->server_->getReadTimeout());
|
||||
} else {
|
||||
self->stream_.expires_after(self->server_->getReadTimeout());
|
||||
}
|
||||
self->doReadBody();
|
||||
};
|
||||
|
||||
if (ssl_enabled_ && ssl_stream_) {
|
||||
http::async_read_header(*ssl_stream_, buffer_, *parser_, on_header);
|
||||
} else {
|
||||
http::async_read_header(stream_, buffer_, *parser_, on_header);
|
||||
}
|
||||
}
|
||||
|
||||
void HttpServer::Session::doReadBody() {
|
||||
auto self = shared_from_this();
|
||||
|
||||
auto on_read = [self](beast::error_code ec, std::size_t) {
|
||||
if (!ec) self->request_ = self->parser_->release();
|
||||
self->handleRead(ec);
|
||||
};
|
||||
|
||||
if (ssl_enabled_ && ssl_stream_) {
|
||||
http::async_read(*ssl_stream_, buffer_, *parser_, on_read);
|
||||
} else {
|
||||
http::async_read(stream_, buffer_, *parser_, on_read);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +311,16 @@ void HttpServer::Session::handleRead(beast::error_code ec) {
|
||||
|
||||
auto req_copy = std::move(request_);
|
||||
processRequest(std::move(req_copy));
|
||||
} else if (ec == http::error::body_limit) {
|
||||
// 请求体超过上限 → 返回 413 并关闭连接
|
||||
auto resp = std::make_shared<http::response<http::string_body>>();
|
||||
resp->version(11); // request_ 在超限时未成功解析,固定 HTTP/1.1
|
||||
resp->result(http::status::payload_too_large);
|
||||
resp->set(http::field::content_type, "text/plain");
|
||||
resp->set(http::field::connection, "close");
|
||||
resp->body() = "413 Payload Too Large\n";
|
||||
resp->prepare_payload();
|
||||
doWrite(resp);
|
||||
} else if (ec != http::error::end_of_stream) {
|
||||
std::cerr << "[Read] Error from " << remote_ip_ << ":" << remote_port_
|
||||
<< ": " << ec.message() << std::endl;
|
||||
|
||||
@@ -8,6 +8,14 @@ namespace net {
|
||||
void HttpServer::Session::wsSend(const std::string& message) {
|
||||
auto exec = stream_.get_executor();
|
||||
asio::post(exec, [self = shared_from_this(), message]() {
|
||||
// P1-6:超过队列上限,判定慢消费者,关闭连接防内存耗尽
|
||||
if (self->ws_send_queue_.size() >= self->server_->getMaxWsQueueSize()) {
|
||||
std::cerr << "[WS] ws_send_queue_ overflow (" << self->ws_send_queue_.size()
|
||||
<< " >= " << self->server_->getMaxWsQueueSize()
|
||||
<< ") from " << self->getRemoteAddress() << ", closing" << std::endl;
|
||||
self->close();
|
||||
return;
|
||||
}
|
||||
self->ws_send_queue_.push_back(message);
|
||||
if (self->ws_send_queue_.size() == 1) {
|
||||
self->doWsWrite();
|
||||
@@ -19,6 +27,8 @@ void HttpServer::Session::doWsUpgrade() {
|
||||
auto self = shared_from_this();
|
||||
ws_stream_ = std::make_unique<ws_stream>(stream_);
|
||||
ws_stream_->text(true);
|
||||
// P1-3:限制单条 WS 消息大小,超限由 Beast 拒绝并关闭连接(防 OOM)
|
||||
ws_stream_->read_message_max(server_->getMaxWsMessageSize());
|
||||
|
||||
ws_stream_->async_accept(
|
||||
request_,
|
||||
@@ -31,6 +41,8 @@ void HttpServer::Session::doWssUpgrade() {
|
||||
auto self = shared_from_this();
|
||||
wss_stream_ = std::make_unique<wss_stream>(*ssl_stream_);
|
||||
wss_stream_->text(true);
|
||||
// P1-3:限制单条 WS 消息大小,超限由 Beast 拒绝并关闭连接(防 OOM)
|
||||
wss_stream_->read_message_max(server_->getMaxWsMessageSize());
|
||||
|
||||
wss_stream_->async_accept(
|
||||
request_,
|
||||
@@ -70,6 +82,13 @@ void HttpServer::Session::doWsRead() {
|
||||
auto self = shared_from_this();
|
||||
ws_buffer_.consume(ws_buffer_.size());
|
||||
|
||||
// P1-4:WS 读取设置空闲超时,清理长期不发消息的连接。
|
||||
// WebSocket 代理模式(如 aibox)不设超时:后端 AI 处理可能长时间无数据,
|
||||
// 由前端心跳保活 + 后端自身超时机制管理连接生命周期。
|
||||
if (!is_ws_proxy_) {
|
||||
stream_.expires_after(server_->getReadTimeout());
|
||||
}
|
||||
|
||||
if (ssl_enabled_ && wss_stream_) {
|
||||
wss_stream_->async_read(
|
||||
ws_buffer_,
|
||||
@@ -261,6 +280,9 @@ void HttpServer::Session::onProxyConnect(beast::error_code ec, tcp::endpoint /*e
|
||||
proxy_ws_stream_ = std::make_unique<proxy_ws_stream>(proxy_tcp_stream_->socket());
|
||||
proxy_tcp_stream_->expires_never();
|
||||
|
||||
// P1-3:限制代理方向单条 WS 消息大小(防 OOM)
|
||||
proxy_ws_stream_->read_message_max(server_->getMaxWsMessageSize());
|
||||
|
||||
proxy_ws_stream_->set_option(websocket::stream_base::decorator(
|
||||
[](websocket::request_type& req) {
|
||||
req.set(http::field::user_agent, "UniServe-WsProxy");
|
||||
@@ -286,6 +308,9 @@ void HttpServer::Session::onProxySslHandshake(beast::error_code ec) {
|
||||
proxy_wss_stream_ = std::make_unique<proxy_wss_stream>(*proxy_ssl_stream_);
|
||||
proxy_tcp_stream_->expires_never();
|
||||
|
||||
// P1-3:限制代理方向单条 WS 消息大小(防 OOM)
|
||||
proxy_wss_stream_->read_message_max(server_->getMaxWsMessageSize());
|
||||
|
||||
proxy_wss_stream_->set_option(websocket::stream_base::decorator(
|
||||
[](websocket::request_type& req) {
|
||||
req.set(http::field::user_agent, "UniServe-WsProxy");
|
||||
@@ -308,6 +333,12 @@ void HttpServer::Session::onProxyHandshake(beast::error_code ec) {
|
||||
|
||||
proxy_connected_ = true;
|
||||
proxy_closed_ = false;
|
||||
|
||||
// 清除客户端侧流在 HTTP 升级阶段继承的读 deadline(body 阶段设置的 30s)。
|
||||
// 否则代理连接会在握手后约 30s 被强制关闭,导致长连接 WebSocket 断线。
|
||||
// 注意:后端侧 proxy_tcp_stream_ 已在 onProxyConnect 中 expires_never()。
|
||||
stream_.expires_never();
|
||||
|
||||
std::cout << "[WS-Proxy] Connected to backend " << proxy_host_ << ":" << proxy_port_
|
||||
<< proxy_path_ << std::endl;
|
||||
|
||||
@@ -360,6 +391,13 @@ void HttpServer::Session::onProxyReadClient(beast::error_code ec, std::size_t by
|
||||
// Queue to backend
|
||||
auto exec = stream_.get_executor();
|
||||
asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() {
|
||||
// P1-6:超过队列上限,判定慢后端,关闭代理连接防内存耗尽
|
||||
if (self->proxy_to_backend_queue_.size() >= self->server_->getMaxWsQueueSize()) {
|
||||
std::cerr << "[WS-Proxy] proxy_to_backend_queue_ overflow ("
|
||||
<< self->proxy_to_backend_queue_.size() << ") closing" << std::endl;
|
||||
self->closeProxy(websocket::close_code::internal_error);
|
||||
return;
|
||||
}
|
||||
self->proxy_to_backend_queue_.emplace_back(std::move(data), is_binary);
|
||||
if (self->proxy_to_backend_queue_.size() == 1) {
|
||||
self->doProxyWriteBackend();
|
||||
@@ -436,6 +474,13 @@ void HttpServer::Session::onProxyReadBackend(beast::error_code ec, std::size_t b
|
||||
// Queue to client
|
||||
auto exec = stream_.get_executor();
|
||||
asio::post(exec, [self = shared_from_this(), data = std::move(data), is_binary]() {
|
||||
// P1-6:超过队列上限,判定慢客户端,关闭代理连接防内存耗尽
|
||||
if (self->proxy_to_client_queue_.size() >= self->server_->getMaxWsQueueSize()) {
|
||||
std::cerr << "[WS-Proxy] proxy_to_client_queue_ overflow ("
|
||||
<< self->proxy_to_client_queue_.size() << ") closing" << std::endl;
|
||||
self->closeProxy(websocket::close_code::internal_error);
|
||||
return;
|
||||
}
|
||||
self->proxy_to_client_queue_.emplace_back(std::move(data), is_binary);
|
||||
if (self->proxy_to_client_queue_.size() == 1) {
|
||||
self->doProxyWriteClient();
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
#include "us/WebSocketClient.hpp"
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/asio/strand.hpp>
|
||||
#include <boost/asio/dispatch.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/connect.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <condition_variable>
|
||||
#include <atomic>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace asio = boost::asio;
|
||||
using tcp = asio::ip::tcp;
|
||||
|
||||
// Internal implementation structure
|
||||
struct WebSocketClient::Impl {
|
||||
// Configuration parameters
|
||||
std::string host;
|
||||
std::string port;
|
||||
std::string path;
|
||||
|
||||
// Boost objects
|
||||
asio::io_context ioc;
|
||||
tcp::resolver resolver;
|
||||
beast::websocket::stream<beast::tcp_stream> ws;
|
||||
|
||||
// Thread management
|
||||
std::thread io_thread;
|
||||
std::atomic<bool> running{false};
|
||||
|
||||
// Callback functions
|
||||
WebSocketClientBase::MessageCallback message_callback;
|
||||
WebSocketClientBase::ErrorCallback error_callback;
|
||||
WebSocketClientBase::ConnectCallback connect_callback;
|
||||
WebSocketClientBase::CloseCallback close_callback;
|
||||
|
||||
// Message queue
|
||||
std::queue<std::string> send_queue;
|
||||
std::mutex queue_mutex;
|
||||
std::condition_variable queue_cv;
|
||||
std::atomic<bool> sending{false};
|
||||
|
||||
// Status flags
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<bool> connecting{false};
|
||||
std::atomic<bool> stopping{false};
|
||||
|
||||
// Configuration options
|
||||
bool auto_reconnect{false};
|
||||
int reconnect_interval_ms{3000};
|
||||
int heartbeat_interval_ms{30000};
|
||||
|
||||
// Constructor
|
||||
Impl(const std::string& host, const std::string& port, const std::string& path)
|
||||
: host(host)
|
||||
, port(port)
|
||||
, path(path)
|
||||
, resolver(asio::make_strand(ioc))
|
||||
, ws(asio::make_strand(ioc))
|
||||
{
|
||||
}
|
||||
|
||||
// Destructor
|
||||
~Impl() {
|
||||
stop();
|
||||
}
|
||||
|
||||
// Start IO thread
|
||||
void start() {
|
||||
if (!running) {
|
||||
running = true;
|
||||
io_thread = std::thread([this]() {
|
||||
while (running) {
|
||||
try {
|
||||
ioc.run();
|
||||
break;
|
||||
} catch (const std::exception& e) {
|
||||
std::cerr << "IO context exception: " << e.what() << std::endl;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stop IO thread
|
||||
void stop() {
|
||||
running = false;
|
||||
stopping = true;
|
||||
|
||||
if (io_thread.joinable()) {
|
||||
ioc.stop();
|
||||
io_thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
// Perform connection
|
||||
void doConnect() {
|
||||
if (connecting || connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
connecting = true;
|
||||
connected = false;
|
||||
|
||||
// Resolve hostname
|
||||
resolver.async_resolve(host, port,
|
||||
[this](beast::error_code ec, tcp::resolver::results_type results) {
|
||||
onResolve(ec, results);
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve completion callback
|
||||
void onResolve(beast::error_code ec, tcp::resolver::results_type results) {
|
||||
if (ec) {
|
||||
onError("Resolve failed: " + ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to resolved endpoints
|
||||
beast::get_lowest_layer(ws).async_connect(results,
|
||||
[this](beast::error_code ec, tcp::resolver::results_type::endpoint_type) {
|
||||
onConnect(ec);
|
||||
});
|
||||
}
|
||||
|
||||
// Connection completion callback
|
||||
void onConnect(beast::error_code ec) {
|
||||
if (ec) {
|
||||
onError("Connection failed: " + ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
// Set WebSocket options
|
||||
ws.set_option(beast::websocket::stream_base::timeout::suggested(
|
||||
beast::role_type::client));
|
||||
|
||||
// Perform WebSocket handshake
|
||||
ws.async_handshake(host, path,
|
||||
[this](beast::error_code ec) {
|
||||
onHandshake(ec);
|
||||
});
|
||||
}
|
||||
|
||||
// Handshake completion callback
|
||||
void onHandshake(beast::error_code ec) {
|
||||
connecting = false;
|
||||
|
||||
if (ec) {
|
||||
onError("Handshake failed: " + ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
connected = true;
|
||||
|
||||
// Call connection success callback
|
||||
if (connect_callback) {
|
||||
connect_callback(true);
|
||||
}
|
||||
|
||||
// Start reading messages
|
||||
doRead();
|
||||
|
||||
// Start sending queued messages
|
||||
doSend();
|
||||
}
|
||||
|
||||
// Read messages
|
||||
void doRead() {
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.async_read(buffer,
|
||||
[this](beast::error_code ec, std::size_t bytes_transferred) {
|
||||
onRead(ec, bytes_transferred);
|
||||
});
|
||||
}
|
||||
|
||||
// Read completion callback
|
||||
void onRead(beast::error_code ec, std::size_t bytes_transferred) {
|
||||
if (ec) {
|
||||
if (ec == beast::websocket::error::closed) {
|
||||
// Normal close
|
||||
onClose();
|
||||
} else {
|
||||
onError("Read failed: " + ec.message());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process received message
|
||||
std::string message = beast::buffers_to_string(buffer.data());
|
||||
buffer.consume(bytes_transferred);
|
||||
|
||||
if (message_callback) {
|
||||
message_callback(message);
|
||||
}
|
||||
|
||||
// Continue reading
|
||||
doRead();
|
||||
}
|
||||
|
||||
// Send messages
|
||||
void doSend() {
|
||||
if (!connected || sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(queue_mutex);
|
||||
if (send_queue.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
sending = true;
|
||||
std::string message = std::move(send_queue.front());
|
||||
send_queue.pop();
|
||||
lock.unlock();
|
||||
|
||||
ws.async_write(asio::buffer(message),
|
||||
[this](beast::error_code ec, std::size_t bytes_transferred) {
|
||||
onWrite(ec, bytes_transferred);
|
||||
});
|
||||
}
|
||||
|
||||
// Write completion callback
|
||||
void onWrite(beast::error_code ec, std::size_t bytes_transferred) {
|
||||
sending = false;
|
||||
|
||||
if (ec) {
|
||||
onError("Write failed: " + ec.message());
|
||||
return;
|
||||
}
|
||||
|
||||
// Continue sending queued messages
|
||||
doSend();
|
||||
}
|
||||
|
||||
// Error handling
|
||||
void onError(const std::string& error) {
|
||||
connecting = false;
|
||||
connected = false;
|
||||
|
||||
std::cerr << "WebSocket error: " << error << std::endl;
|
||||
|
||||
if (error_callback) {
|
||||
error_callback(error);
|
||||
}
|
||||
|
||||
// Auto reconnect
|
||||
if (auto_reconnect && !stopping) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(reconnect_interval_ms));
|
||||
asio::post(ioc, [this]() { doConnect(); });
|
||||
}
|
||||
}
|
||||
|
||||
// Connection close
|
||||
void onClose() {
|
||||
connected = false;
|
||||
|
||||
if (close_callback) {
|
||||
close_callback();
|
||||
}
|
||||
|
||||
// Auto reconnect
|
||||
if (auto_reconnect && !stopping) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(reconnect_interval_ms));
|
||||
asio::post(ioc, [this]() { doConnect(); });
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
beast::flat_buffer buffer;
|
||||
};
|
||||
|
||||
// WebSocketClient implementation
|
||||
WebSocketClient::WebSocketClient(const std::string& host, const std::string& port, const std::string& path)
|
||||
: impl_(std::make_unique<Impl>(host, port, path))
|
||||
{
|
||||
impl_->start();
|
||||
}
|
||||
|
||||
WebSocketClient::~WebSocketClient() {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
void WebSocketClient::setMessageCallback(MessageCallback callback) {
|
||||
impl_->message_callback = callback;
|
||||
}
|
||||
|
||||
void WebSocketClient::setErrorCallback(ErrorCallback callback) {
|
||||
impl_->error_callback = callback;
|
||||
}
|
||||
|
||||
void WebSocketClient::setConnectCallback(ConnectCallback callback) {
|
||||
impl_->connect_callback = callback;
|
||||
}
|
||||
|
||||
void WebSocketClient::setCloseCallback(CloseCallback callback) {
|
||||
impl_->close_callback = callback;
|
||||
}
|
||||
|
||||
bool WebSocketClient::connect(int timeout_ms) {
|
||||
if (impl_->connecting || impl_->connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
impl_->doConnect();
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebSocketClient::disconnect() {
|
||||
if (!impl_->connected && !impl_->connecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
impl_->stopping = true;
|
||||
|
||||
asio::post(impl_->ioc, [this]() {
|
||||
beast::error_code ec;
|
||||
impl_->ws.close(beast::websocket::close_code::normal, ec);
|
||||
impl_->connected = false;
|
||||
impl_->connecting = false;
|
||||
});
|
||||
}
|
||||
|
||||
bool WebSocketClient::isConnected() const {
|
||||
return impl_->connected;
|
||||
}
|
||||
|
||||
bool WebSocketClient::send(const std::string& message) {
|
||||
if (!impl_->connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(impl_->queue_mutex);
|
||||
impl_->send_queue.push(message);
|
||||
}
|
||||
|
||||
impl_->queue_cv.notify_one();
|
||||
asio::post(impl_->ioc, [this]() { impl_->doSend(); });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebSocketClient::sendBinary(const void* data, size_t size) {
|
||||
if (!impl_->connected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string message(static_cast<const char*>(data), size);
|
||||
return send(message);
|
||||
}
|
||||
|
||||
void WebSocketClient::setAutoReconnect(bool enable, int interval_ms) {
|
||||
impl_->auto_reconnect = enable;
|
||||
impl_->reconnect_interval_ms = interval_ms;
|
||||
}
|
||||
|
||||
void WebSocketClient::setHeartbeatInterval(int interval_ms) {
|
||||
impl_->heartbeat_interval_ms = interval_ms;
|
||||
}
|
||||
|
||||
std::string WebSocketClient::getHost() const {
|
||||
return impl_->host;
|
||||
}
|
||||
|
||||
std::string WebSocketClient::getPort() const {
|
||||
return impl_->port;
|
||||
}
|
||||
|
||||
std::string WebSocketClient::getPath() const {
|
||||
return impl_->path;
|
||||
}
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -1,383 +0,0 @@
|
||||
// WebSocketServer.cpp - WebSocket server implementation
|
||||
// Based on Boost.Beast and Boost.Asio
|
||||
|
||||
#include "us/WebSocketServer.hpp"
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include <deque>
|
||||
#include <boost/beast/core.hpp>
|
||||
#include <boost/beast/websocket.hpp>
|
||||
#include <boost/asio/strand.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
|
||||
namespace beast = boost::beast;
|
||||
namespace websocket = beast::websocket;
|
||||
namespace asio = boost::asio;
|
||||
using tcp = asio::ip::tcp;
|
||||
|
||||
namespace pmc::net {
|
||||
|
||||
// Internal Session class implementation
|
||||
class WebSocketServer::Session : public std::enable_shared_from_this<Session> {
|
||||
public:
|
||||
Session(tcp::socket socket, WebSocketServer* server)
|
||||
: ws_(std::move(socket))
|
||||
, server_(server) {
|
||||
|
||||
// Get remote address
|
||||
auto endpoint = ws_.next_layer().socket().remote_endpoint();
|
||||
remoteAddress_ = endpoint.address().to_string() + ":" + std::to_string(endpoint.port());
|
||||
}
|
||||
|
||||
void start() {
|
||||
// Generate client ID
|
||||
if (server_) {
|
||||
clientId_ = server_->generateClientId();
|
||||
} else {
|
||||
// If server is null, use default ID
|
||||
static std::atomic<uint64_t> counter{0};
|
||||
clientId_ = "client_" + std::to_string(++counter);
|
||||
}
|
||||
|
||||
// Asynchronously accept WebSocket handshake
|
||||
ws_.async_accept(
|
||||
beast::bind_front_handler(
|
||||
&Session::onAccept,
|
||||
shared_from_this()));
|
||||
}
|
||||
|
||||
void send(const std::string& message) {
|
||||
// Add message to send queue
|
||||
bool write_in_progress = !sendQueue_.empty();
|
||||
sendQueue_.push_back(message);
|
||||
|
||||
if (!write_in_progress) {
|
||||
// Start asynchronous write
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
|
||||
const std::string& getClientId() const { return clientId_; }
|
||||
const std::string& getRemoteAddress() const { return remoteAddress_; }
|
||||
|
||||
private:
|
||||
websocket::stream<beast::tcp_stream> ws_;
|
||||
beast::flat_buffer buffer_;
|
||||
std::deque<std::string> sendQueue_;
|
||||
WebSocketServer* server_;
|
||||
std::string clientId_;
|
||||
std::string remoteAddress_;
|
||||
|
||||
void onAccept(beast::error_code ec) {
|
||||
if (ec) {
|
||||
std::cerr << "WebSocket accept error: " << ec.message() << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to server client list
|
||||
if (server_) {
|
||||
server_->addClient(clientId_, shared_from_this());
|
||||
|
||||
// Call connection handler
|
||||
if (server_->connectHandler_) {
|
||||
server_->connectHandler_(clientId_, remoteAddress_);
|
||||
}
|
||||
}
|
||||
|
||||
// Start reading messages
|
||||
doRead();
|
||||
}
|
||||
|
||||
void doRead() {
|
||||
// Asynchronously read message
|
||||
ws_.async_read(
|
||||
buffer_,
|
||||
beast::bind_front_handler(
|
||||
&Session::onRead,
|
||||
shared_from_this()));
|
||||
}
|
||||
|
||||
void onRead(beast::error_code ec, std::size_t bytes_transferred) {
|
||||
if (ec == websocket::error::closed) {
|
||||
// Normal close
|
||||
if (server_) {
|
||||
server_->removeClient(clientId_);
|
||||
if (server_->disconnectHandler_) {
|
||||
server_->disconnectHandler_(clientId_);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ec) {
|
||||
std::cerr << "WebSocket read error: " << ec.message() << std::endl;
|
||||
if (server_) {
|
||||
server_->removeClient(clientId_);
|
||||
if (server_->disconnectHandler_) {
|
||||
server_->disconnectHandler_(clientId_);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process received message
|
||||
std::string message = beast::buffers_to_string(buffer_.data());
|
||||
buffer_.consume(bytes_transferred);
|
||||
|
||||
if (server_) {
|
||||
// Find message handler
|
||||
for (const auto& handler : server_->messageHandlers_) {
|
||||
// Path matching logic can be added here
|
||||
handler.second(clientId_, message, [this](const std::string& response) {
|
||||
send(response);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Continue reading
|
||||
doRead();
|
||||
}
|
||||
|
||||
void doWrite() {
|
||||
if (sendQueue_.empty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get first message from queue
|
||||
auto message = sendQueue_.front();
|
||||
|
||||
// Asynchronously write message
|
||||
ws_.async_write(
|
||||
asio::buffer(message),
|
||||
beast::bind_front_handler(
|
||||
&Session::onWrite,
|
||||
shared_from_this()));
|
||||
}
|
||||
|
||||
void onWrite(beast::error_code ec, std::size_t bytes_transferred) {
|
||||
(void)bytes_transferred;
|
||||
if (ec) {
|
||||
std::cerr << "WebSocket write error: " << ec.message() << std::endl;
|
||||
if (server_) {
|
||||
server_->removeClient(clientId_);
|
||||
if (server_->disconnectHandler_) {
|
||||
server_->disconnectHandler_(clientId_);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove sent message
|
||||
sendQueue_.pop_front();
|
||||
|
||||
// Continue sending next message in queue
|
||||
if (!sendQueue_.empty()) {
|
||||
doWrite();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Internal Listener class implementation
|
||||
class WebSocketServer::Listener : public std::enable_shared_from_this<Listener> {
|
||||
public:
|
||||
static std::shared_ptr<Listener> create(asio::io_context& ioc, tcp::endpoint endpoint, WebSocketServer* server) {
|
||||
return std::shared_ptr<Listener>(new Listener(ioc, endpoint, server));
|
||||
}
|
||||
|
||||
void run() {
|
||||
if (!acceptor_.is_open()) {
|
||||
throw std::runtime_error("Acceptor is not open");
|
||||
}
|
||||
|
||||
doAccept();
|
||||
}
|
||||
|
||||
private:
|
||||
Listener(asio::io_context& ioc, tcp::endpoint endpoint, WebSocketServer* server)
|
||||
: ioc_(ioc)
|
||||
, acceptor_(asio::make_strand(ioc))
|
||||
, server_(server) {
|
||||
|
||||
beast::error_code ec;
|
||||
|
||||
// Open acceptor
|
||||
acceptor_.open(endpoint.protocol(), ec);
|
||||
if (ec) {
|
||||
std::cerr << "Failed to open acceptor: " << ec.message() << std::endl;
|
||||
throw std::runtime_error("Failed to open acceptor: " + ec.message());
|
||||
}
|
||||
|
||||
// Set address reuse
|
||||
acceptor_.set_option(asio::socket_base::reuse_address(true), ec);
|
||||
if (ec) {
|
||||
std::cerr << "Failed to set reuse address: " << ec.message() << std::endl;
|
||||
throw std::runtime_error("Failed to set reuse address: " + ec.message());
|
||||
}
|
||||
|
||||
// Bind to endpoint
|
||||
acceptor_.bind(endpoint, ec);
|
||||
if (ec) {
|
||||
std::cerr << "Failed to bind to endpoint: " << ec.message() << std::endl;
|
||||
throw std::runtime_error("Failed to bind to endpoint: " + ec.message());
|
||||
}
|
||||
|
||||
// Start listening
|
||||
acceptor_.listen(asio::socket_base::max_listen_connections, ec);
|
||||
if (ec) {
|
||||
std::cerr << "Failed to listen: " << ec.message() << std::endl;
|
||||
throw std::runtime_error("Failed to listen: " + ec.message());
|
||||
}
|
||||
}
|
||||
|
||||
void doAccept() {
|
||||
acceptor_.async_accept(
|
||||
asio::make_strand(ioc_),
|
||||
beast::bind_front_handler(
|
||||
&Listener::onAccept,
|
||||
shared_from_this()));
|
||||
}
|
||||
|
||||
void onAccept(beast::error_code ec, tcp::socket socket) {
|
||||
if (ec) {
|
||||
std::cerr << "Accept error: " << ec.message() << std::endl;
|
||||
} else {
|
||||
// Create new Session to handle connection
|
||||
std::make_shared<Session>(std::move(socket), server_)->start();
|
||||
}
|
||||
|
||||
// Continue accepting new connections
|
||||
doAccept();
|
||||
}
|
||||
|
||||
asio::io_context& ioc_;
|
||||
tcp::acceptor acceptor_;
|
||||
WebSocketServer* server_;
|
||||
};
|
||||
|
||||
WebSocketServer::WebSocketServer(unsigned short port, unsigned int threads)
|
||||
: port_(port)
|
||||
, threads_(threads)
|
||||
, impl_(std::make_unique<Impl>()) {}
|
||||
|
||||
WebSocketServer::~WebSocketServer() {
|
||||
stop();
|
||||
}
|
||||
|
||||
void WebSocketServer::start() {
|
||||
if (running_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create listener
|
||||
tcp::endpoint endpoint(tcp::v4(), port_);
|
||||
impl_->listener_ = Listener::create(impl_->ioc_, endpoint, this);
|
||||
|
||||
// Start listener
|
||||
impl_->listener_->run();
|
||||
|
||||
// Start worker threads
|
||||
for (unsigned int i = 0; i < threads_; ++i) {
|
||||
impl_->threads_.emplace_back([this]() {
|
||||
impl_->ioc_.run();
|
||||
});
|
||||
}
|
||||
|
||||
running_ = true;
|
||||
std::cout << "WebSocketServer started on port " << port_ << " with " << threads_ << " threads" << std::endl;
|
||||
}
|
||||
|
||||
void WebSocketServer::stop() {
|
||||
if (!running_) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop io_context
|
||||
impl_->ioc_.stop();
|
||||
|
||||
// Wait for all threads to finish
|
||||
for (auto& thread : impl_->threads_) {
|
||||
if (thread.joinable()) {
|
||||
thread.join();
|
||||
}
|
||||
}
|
||||
|
||||
impl_->threads_.clear();
|
||||
running_ = false;
|
||||
std::cout << "WebSocketServer stopped" << std::endl;
|
||||
}
|
||||
|
||||
void WebSocketServer::onMessage(const std::string& path, WebSocketMessageHandler handler) {
|
||||
messageHandlers_[path] = std::move(handler);
|
||||
}
|
||||
|
||||
void WebSocketServer::onConnect(WebSocketConnectHandler handler) {
|
||||
connectHandler_ = std::move(handler);
|
||||
}
|
||||
|
||||
void WebSocketServer::onDisconnect(WebSocketDisconnectHandler handler) {
|
||||
disconnectHandler_ = std::move(handler);
|
||||
}
|
||||
|
||||
bool WebSocketServer::sendToClient(const std::string& clientId, const std::string& message) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
auto it = clients_.find(clientId);
|
||||
if (it != clients_.end()) {
|
||||
it->second->send(message);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
size_t WebSocketServer::broadcast(const std::string& message) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
size_t count = 0;
|
||||
for (auto& pair : clients_) {
|
||||
pair.second->send(message);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
void WebSocketServer::disconnectClient(const std::string& clientId) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
auto it = clients_.find(clientId);
|
||||
if (it != clients_.end()) {
|
||||
// Should close WebSocket connection here
|
||||
// Currently only removing from list
|
||||
clients_.erase(it);
|
||||
}
|
||||
}
|
||||
|
||||
size_t WebSocketServer::getClientCount() const {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
return clients_.size();
|
||||
}
|
||||
|
||||
std::string WebSocketServer::generateClientId() const {
|
||||
static std::atomic<uint64_t> counter{0};
|
||||
return "client_" + std::to_string(++counter);
|
||||
}
|
||||
|
||||
void WebSocketServer::addClient(const std::string& clientId, std::shared_ptr<Session> session) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
clients_[clientId] = session;
|
||||
}
|
||||
|
||||
void WebSocketServer::removeClient(const std::string& clientId) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
clients_.erase(clientId);
|
||||
}
|
||||
|
||||
std::shared_ptr<WebSocketServer::Session> WebSocketServer::getClient(const std::string& clientId) {
|
||||
std::lock_guard<std::mutex> lock(clientsMutex_);
|
||||
auto it = clients_.find(clientId);
|
||||
if (it != clients_.end()) {
|
||||
return it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace pmc::net
|
||||
@@ -0,0 +1,69 @@
|
||||
# Prerequisites
|
||||
*.d
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Linker files
|
||||
*.ilk
|
||||
|
||||
# Debugger Files
|
||||
*.pdb
|
||||
|
||||
# Compiled Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
*.so.*
|
||||
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
|
||||
# Build directories
|
||||
build/
|
||||
Build/
|
||||
build-*/
|
||||
|
||||
# CMake generated files
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
cmake_install.cmake
|
||||
#Makefile
|
||||
install_manifest.txt
|
||||
compile_commands.json
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.log
|
||||
*.bak
|
||||
*.swp
|
||||
|
||||
# vcpkg
|
||||
vcpkg_installed/
|
||||
|
||||
# debug information files
|
||||
*.dwo
|
||||
|
||||
# test output & cache
|
||||
Testing/
|
||||
.cache/
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 建昌风物大顺脚本
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,39 @@
|
||||
VERSION := 0.0.1
|
||||
FRAMEWORK_NAME := us
|
||||
LIB_NAME := libus_msg
|
||||
INSTL_TDIR := /lib
|
||||
INCLU_TDIR := /usr/include
|
||||
INCLU_SDIR := inc
|
||||
CPP := g++
|
||||
INCS := -I inc
|
||||
CPPFLAGS := --std=c++20 -O3 -pipe -Wall -Werror
|
||||
LIBS :=
|
||||
BUILD_TDIR := build
|
||||
$(shell mkdir -p $(BUILD_TDIR))
|
||||
|
||||
#--------
|
||||
all: $(BUILD_TDIR)/$(LIB_NAME).so
|
||||
|
||||
$(BUILD_TDIR)/$(LIB_NAME).so: $(BUILD_TDIR)/Msg_Basic.o
|
||||
$(CPP) -shared $^ $(LIBS) -o $@
|
||||
|
||||
$(BUILD_TDIR)/Msg_Basic.o: src/Msg_Basic.cpp
|
||||
$(CPP) $(CPPFLAGS) -fPIC -c $^ $(INCS) -o $@
|
||||
|
||||
clear:
|
||||
@rm -rvf $(BUILD_TDIR)
|
||||
|
||||
install:
|
||||
@echo "install UniServe_libmsg..."
|
||||
@mkdir -vp $(INCLU_TDIR)/$(FRAMEWORK_NAME)
|
||||
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/Msg_Basic.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
@cp -vf $(INCLU_SDIR)/$(FRAMEWORK_NAME)/Msg.hpp $(INCLU_TDIR)/$(FRAMEWORK_NAME)/
|
||||
@cp -vf $(BUILD_TDIR)/$(LIB_NAME).so $(INSTL_TDIR)/
|
||||
@echo "done."
|
||||
|
||||
uninstall:
|
||||
@echo "uninstall UniServe_libmsg..."
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/Msg_Basic.hpp
|
||||
@rm -vf $(INCLU_TDIR)/$(FRAMEWORK_NAME)/Msg.hpp
|
||||
@rm -vf $(INSTL_TDIR)/$(LIB_NAME).so
|
||||
@echo "done."
|
||||
@@ -0,0 +1,545 @@
|
||||
# libmsg — UniServe 消息通信库
|
||||
|
||||
C++20 轻量级 TCP 消息帧通信库,实现 pymsg 协议(4字节大端长度前缀 + UTF-8载荷),与 Python 版 pymsg 字节级兼容。提供底层帧收发函数、MsgClient 客户端、MsgServer 多客户端服务端,以及通用异步批量队列 AsyncQueue。
|
||||
|
||||
命名空间 `pmc::net`,库文件 `libus_msg.so`,头文件安装至 `/usr/include/us/`。被 `gateway`、`db`、`dnfs`、`smtp`、`captcha`、`confs` 等所有使用 pymsg 协议的 C++ 服务依赖。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
pmc::net::Msg_Basic (基类: 版本信息 / 异常层次 / MSG_CHECK 宏)
|
||||
└── MsgClient TCP 客户端(短连接 send/call + 长连接 listen)
|
||||
|
||||
pmc::net::MsgServer TCP 服务端(select 事件循环 + 可选线程池 + reply/broadcast)
|
||||
pmc::net::AsyncQueue<T> 通用异步批量发送队列(对应 Python AsyncRpcQueue)
|
||||
|
||||
pmc::net::RpcClient JSON RPC 客户端(_route 注入 + success/error 自动解包)—— 需 boost::json
|
||||
pmc::net::RpcServer JSON RPC 服务端(路由分发 + 响应包装 + 异常脱敏)—— 需 boost::json
|
||||
|
||||
pmc::net 命名空间下的自由函数:
|
||||
├── pmsg_send(fd, data) 底层协议发送
|
||||
├── pmsg_recv(fd) 底层协议接收(阻塞模式,依赖 SO_RCVTIMEO)
|
||||
└── pmsg_recv(fd, timeout_sec) 底层协议接收(deadline 模式,总超时精确控制)
|
||||
|
||||
pmc::net::msg_constants 协议常量(kHeaderSize=4, kMaxMsgSize=64MiB 等)
|
||||
|
||||
异常层次:
|
||||
├── MsgException 基础异常
|
||||
│ ├── MsgTimeoutException 收发超时
|
||||
│ ├── MsgConnectionException 连接异常
|
||||
│ │ └── MsgConnectionRefusedException ECONNREFUSED(用于重试)
|
||||
│ └── RpcError JSON RPC 业务错误(success=false 或响应非法)
|
||||
```
|
||||
|
||||
所有收发函数均为 header-only(inline 实现),`AsyncQueue<T>` 和 `MsgServer` 也是 header-only,无第三方依赖(仅 POSIX socket + 标准库线程)。`version()` 编译进 `libus_msg.so`。
|
||||
|
||||
`RpcClient` 和 `RpcServer` 是可选的 JSON RPC 约定层,基于 `boost::json` 提供路由分发和响应自动包装,对应 Python 版 `pymsg.rpc`。使用时需额外链接 `-lboost_json`,核心库不引入此依赖。
|
||||
|
||||
## 线协议
|
||||
|
||||
```
|
||||
+--------+---------------------------+
|
||||
| 4 B BE | N B UTF-8 |
|
||||
| length | payload |
|
||||
+--------+---------------------------+
|
||||
```
|
||||
|
||||
- 长度字段:32 位无符号整数,大端序(network byte order)
|
||||
- 载荷:UTF-8 编码文本(通常为 JSON)
|
||||
- 最大消息长度:64 MiB(防 OOM 攻击)
|
||||
- 传输层:TCP,无 TLS(信任本机网络边界,默认绑定 127.0.0.1)
|
||||
|
||||
## 类与 API
|
||||
|
||||
### Msg_Basic — 基类
|
||||
|
||||
提供版本查询和异常类型,`MsgClient` 继承此类。
|
||||
|
||||
| 成员 | 说明 |
|
||||
|------|------|
|
||||
| `version()` (static protected) | 返回版本字符串(编译进 .so) |
|
||||
| `MsgExp` | 异常类,继承 `std::runtime_error` |
|
||||
| `MSG_CHECK(cond, msg)` | 宏,条件为 false 时抛出 `std::runtime_error` |
|
||||
|
||||
### 协议常量(`msg_constants` 命名空间)
|
||||
|
||||
| 常量 | 值 | 说明 |
|
||||
|------|----|------|
|
||||
| `kHeaderSize` | 4 | 帧头长度(字节) |
|
||||
| `kMaxMsgSize` | 64 MiB | 消息最大长度(防 OOM) |
|
||||
| `kDefaultTimeout` | 10 秒 | call()/connect() 默认超时 |
|
||||
| `kSendTimeout` | 3 秒 | send() 默认超时 |
|
||||
| `kListenPollMs` | 100000 微秒 | listen() select 轮询间隔 |
|
||||
| `kRetryLogEvery` | 5.0 秒 | 重试等待日志打印间隔 |
|
||||
|
||||
### 底层协议函数
|
||||
|
||||
```cpp
|
||||
// 发送一条消息(长度前缀 + payload)
|
||||
void pmsg_send(int sockfd, const std::string& data);
|
||||
|
||||
// 接收一条消息(阻塞模式,依赖 socket 已设置的 SO_RCVTIMEO)
|
||||
// 抛出 MsgConnectionException(连接断开)或 MsgTimeoutException(超时)
|
||||
std::string pmsg_recv(int sockfd);
|
||||
|
||||
// 接收一条消息(deadline 模式,总超时不超过 timeout_sec)
|
||||
// header+payload 共享同一个 deadline,使用 select() 实现精确超时控制
|
||||
// 适用于客户端 call() 和服务端,防止总等待时间翻倍
|
||||
std::string pmsg_recv(int sockfd, double timeout_sec);
|
||||
```
|
||||
|
||||
### MsgClient — TCP 客户端
|
||||
|
||||
```cpp
|
||||
#include <us/Msg.hpp>
|
||||
|
||||
// 构造:指定服务地址、端口、客户端名称(用于 send() 前缀)
|
||||
pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `connect()` | 创建长连接(供 listen() 使用) |
|
||||
| `send(msg)` | 短连接发送 ``"name: msg"`` 格式消息,不等待回复 |
|
||||
| `call(msg, timeout=10, retry_on_refused=false, retry_interval=1.0)` | 短连接 RPC:发送原始消息,阻塞等待回复 |
|
||||
| `listen(on_message)` | 长连接持续接收,回调签名 `void(const std::string&)` |
|
||||
| `stop()` | 设置停止标志并关闭套接字,可跨线程调用 |
|
||||
| `addr()` / `port()` / `name()` | 访问器 |
|
||||
|
||||
**call() 的 retry_on_refused 机制**:
|
||||
|
||||
设为 `true` 时,仅对 `ECONNREFUSED`(目标服务未启动)以 1 秒间隔阻塞重试,每 5 秒打印一次等待日志。适用于微服务启动顺序不确定的场景:服务先启动的一方会自动等待依赖方就绪,无需额外服务发现组件。其他异常(超时、连接重置等)仍立即抛出。
|
||||
|
||||
### MsgServer — TCP 服务端
|
||||
|
||||
```cpp
|
||||
#include <us/MsgServer.hpp>
|
||||
|
||||
// 构造:绑定地址和端口,可配置线程池大小
|
||||
pmc::net::MsgServer server("0.0.0.0", 8003, {.workers=4});
|
||||
```
|
||||
|
||||
对应 Python 版 `pymsg.MsgServer`,提供基于 select 的多客户端 TCP 服务端。
|
||||
|
||||
**配置结构**:
|
||||
|
||||
```cpp
|
||||
struct MsgServerConfig {
|
||||
int workers = 0; // 线程池大小;0=同步模式(回调在主线程执行)
|
||||
double recv_timeout = 10.0; // 接收单条消息的总超时(秒),防 Slowloris
|
||||
int max_connections = 1000; // 最大并发连接数,超过则拒绝新连接
|
||||
int max_queue_size = 1000; // 线程池任务队列上限(workers>0 时),满时断开连接
|
||||
int listen_backlog = 10; // listen() backlog
|
||||
double poll_timeout = 0.1; // select 轮询超时(秒),影响关闭响应速度
|
||||
};
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `start(on_message)` | 启动服务(阻塞当前线程);回调签名 `void(int fd, const string& addr, const string& msg)` |
|
||||
| `stop()` | 停止服务(可从任意线程调用,包括回调内部) |
|
||||
| `reply(fd, msg)` | 向指定客户端回复消息(线程安全,经每连接锁串行化) |
|
||||
| `broadcast(msg, except_fd=-1)` | 向所有客户端广播(线程安全),可排除指定 fd |
|
||||
| `client_count()` | 当前已连接客户端数量 |
|
||||
|
||||
**设计特性**:
|
||||
|
||||
- **两种并发模式**:`workers=0` 同步模式(简单高效),`workers>0` 线程池并发模式
|
||||
- **同连接串行保证**:同一连接的消息严格串行处理(回调执行期间暂停 select 监听该 fd)
|
||||
- **线程安全发送**:`reply()`/`broadcast()` 经每连接 mutex 串行化,防止帧交错
|
||||
- **连接数限制**:`max_connections` 防止连接耗尽攻击
|
||||
- **有界任务队列**:`max_queue_size` 防止慢 handler 导致 OOM,队列满时断开连接
|
||||
- **Slowloris 防护**:使用 deadline 模式 recv(select + 剩余时间),总接收时间不超过 `recv_timeout`
|
||||
- **优雅关闭**:`stop()` 可从任意线程调用,事件循环退出前清理所有连接
|
||||
- **故障隔离**:worker 线程异常仅标记 failed fd,主循环统一清理,避免 fd 并发操作
|
||||
- **TCP_NODELAY**:新连接自动禁用 Nagle 算法,降低小包延迟
|
||||
|
||||
### AsyncQueue\<T\> — 通用异步批量发送队列
|
||||
|
||||
```cpp
|
||||
#include <us/AsyncQueue.hpp>
|
||||
```
|
||||
|
||||
对应 Python 版 `pymsg.AsyncRpcQueue`,提供内存队列 + 后台线程的异步批量处理机制,适用于日志上报、指标采集、事件通知等非关键路径场景。业务线程 `push()` 非阻塞立即返回,后台线程攒批处理(批量阈值或超时),失败时通过错误回调通知,绝不阻塞主业务流程。
|
||||
|
||||
**配置结构**:
|
||||
|
||||
```cpp
|
||||
struct AsyncQueueConfig {
|
||||
size_t batch_size = 100; // 批量发送阈值,达到此数量立即 flush
|
||||
double flush_interval = 2.0; // 定时 flush 间隔(秒)
|
||||
size_t max_queue_size = 10000; // 队列最大长度,0=无上限;超过时丢最旧数据
|
||||
double poll_timeout = 0.1; // 后台线程轮询超时(秒)
|
||||
};
|
||||
```
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `AsyncQueue(handler, config={}, on_error={})` | 构造队列;`handler` 签名 `void(vector<T>&& batch)`,`on_error` 签名 `void(const exception&, vector<T>&&)` |
|
||||
| `start()` | 启动后台发送线程(幂等) |
|
||||
| `push(item)` | 非阻塞入队;返回 false 表示队列已关闭 |
|
||||
| `flush()` | 在调用线程同步 flush 当前队列中的所有数据,阻塞直到处理完成 |
|
||||
| `shutdown()` | 优雅关闭:flush 剩余数据后停止线程(幂等,析构自动调用) |
|
||||
| `pending()` | 当前队列中待处理的数据量 |
|
||||
| `dropped()` | 因队列满而丢弃的数据总数 |
|
||||
| `is_running()` | 是否已 start 且未 shutdown |
|
||||
|
||||
**设计特性**:
|
||||
|
||||
- **非阻塞入队**:`push()` 立即返回,不等待网络 I/O
|
||||
- **批量发送**:攒到 `batch_size` 条或超过 `flush_interval` 秒自动 flush
|
||||
- **背压保护**:`max_queue_size` 限制内存队列长度,满时丢最旧数据并递增 `dropped()` 计数
|
||||
- **优雅关闭**:`shutdown()` flush 所有剩余数据后停止后台线程
|
||||
- **RAII**:析构自动调用 `shutdown()`
|
||||
- **可插拔**:Handler 回调可自定义(RPC 调用、写文件、发消息等)
|
||||
- **fail-safe**:Handler 抛异常时触发 `on_error` 回调,绝不传播到业务线程
|
||||
|
||||
### RpcClient — JSON RPC 客户端
|
||||
|
||||
```cpp
|
||||
#include <us/RpcClient.hpp> // 需链接 -lboost_json
|
||||
|
||||
// 构造:指定服务地址、端口、客户端名称
|
||||
pmc::net::RpcClient rpc("127.0.0.1", 8005, "gateway");
|
||||
|
||||
// 启动等待模式(db 未启动时阻塞重试)
|
||||
pmc::net::RpcClient rpc("127.0.0.1", 8005, "gateway", 10.0, true);
|
||||
```
|
||||
|
||||
对应 Python 版 `pymsg.rpc.RpcClient`,在 `MsgClient` 之上封装 JSON RPC 约定:
|
||||
- 自动注入 `_route` 字段
|
||||
- 自动解析响应 `{"success":true,"data":...}` / `{"success":false,"error":"..."}`
|
||||
- 业务错误抛 `RpcError`,网络错误仍抛原有网络异常
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `RpcClient(addr, port, name, timeout=10, retry_on_refused=false, retry_interval=1.0)` | 构造 RPC 客户端 |
|
||||
| `call(route, params={}, timeout=nullopt, retry=nullopt, retry_interval=nullopt)` | 调用路由,返回 `data` 字段值;业务错误抛 `RpcError` |
|
||||
| `call_raw(route, params={}, timeout=nullopt, retry=nullopt, retry_interval=nullopt)` | 调用路由,返回完整响应 `object`(含 success/data/error),不抛 `RpcError` |
|
||||
| `addr()` / `port()` / `name()` | 访问器 |
|
||||
|
||||
**RPC 约定**:
|
||||
- 请求格式:`{"_route": "<路由名>", ...业务字段}`
|
||||
- 成功响应:`{"success": true, "data": ...}`
|
||||
- 错误响应:`{"success": false, "error": "<错误信息>"}`
|
||||
- 非 JSON 响应或缺 success 字段抛 `RpcError("响应不是合法 JSON: ...")`
|
||||
|
||||
### RpcServer — JSON RPC 服务端
|
||||
|
||||
```cpp
|
||||
#include <us/RpcServer.hpp> // 需链接 -lboost_json
|
||||
|
||||
// 构造:绑定地址和端口,可透传 MsgServerConfig
|
||||
pmc::net::RpcServer rpc("127.0.0.1", 8005, {.workers=4});
|
||||
|
||||
// 注册路由
|
||||
rpc.add_route("ping", [](const json::object&) -> json::value {
|
||||
return "pong";
|
||||
});
|
||||
|
||||
rpc.start(); // 阻塞当前线程
|
||||
```
|
||||
|
||||
对应 Python 版 `pymsg.rpc.RpcServer`,在 `MsgServer` 之上封装路由分发和响应自动包装。
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `RpcServer(addr, port, config={}, on_error={})` | 构造 RPC 服务端;`on_error` 签名 `void(const string& route, const exception& e)` |
|
||||
| `add_route(name, handler)` | 注册路由;`handler` 签名 `json::value(const json::object& req)` |
|
||||
| `start()` | 启动服务(阻塞当前线程) |
|
||||
| `stop()` | 从另一线程停止服务 |
|
||||
| `server()` | 获取底层 `MsgServer&` 引用(可用于 broadcast 等) |
|
||||
|
||||
**Handler 约定**:
|
||||
- 返回值自动包装为 `{"success": true, "data": <返回值>}`
|
||||
- 抛出 `std::exception` 自动包装为 `{"success": false, "error": "<脱敏后错误>"}`
|
||||
- 返回 `nullptr` 表示无数据(客户端收到 `data=null`)
|
||||
- 业务参数中 `_route` 字段已被移除,不会透传给 handler
|
||||
|
||||
**安全特性**:
|
||||
- JSON 解析限制嵌套深度 32 层,防止栈溢出攻击
|
||||
- 错误信息自动脱敏:绝对路径替换为 `<redacted>`,不泄露内部文件路径
|
||||
- `on_error` 钩子可记录完整异常到服务端日志(脱敏仅对客户端响应生效)
|
||||
- Handler 异常不导致服务崩溃
|
||||
|
||||
### 异常层次
|
||||
|
||||
| 异常类 | 含义 | 来源 |
|
||||
|--------|------|------|
|
||||
| `MsgException` | 所有消息库异常的基类 | — |
|
||||
| `MsgTimeoutException` | send/recv 超时 | SO_RCVTIMEO/SO_SNDTIMEO 或 deadline 到期触发 |
|
||||
| `MsgConnectionException` | 连接断开/重置/地址错误 | recv 返回0/错误、connect 失败 |
|
||||
| `MsgConnectionRefusedException` | connect 返回 ECONNREFUSED | 仅用于 retry_on_refused 判定 |
|
||||
| `RpcError` | JSON RPC 业务错误 | 远端返回 success=false、响应非 JSON、缺少 success 字段 |
|
||||
|
||||
## 构建与安装
|
||||
|
||||
**前置依赖**:g++(支持 C++20)、POSIX 系统(Linux/macOS),无第三方库依赖。
|
||||
|
||||
```bash
|
||||
# 一键构建 + 安装(需 sudo)
|
||||
bash _set.sh
|
||||
|
||||
# 或手动
|
||||
make # 编译 libus_msg.so
|
||||
make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/
|
||||
make clear # 清理构建产物
|
||||
make uninstall # 卸载
|
||||
```
|
||||
|
||||
使用:
|
||||
|
||||
```cpp
|
||||
#include <us/Msg_Basic.hpp>
|
||||
#include <us/Msg.hpp>
|
||||
#include <us/MsgServer.hpp> // 如需服务端
|
||||
#include <us/AsyncQueue.hpp> // 如需异步队列
|
||||
#include <us/RpcClient.hpp> // 如需 JSON RPC 客户端(需 boost::json)
|
||||
#include <us/RpcServer.hpp> // 如需 JSON RPC 服务端(需 boost::json)
|
||||
```
|
||||
|
||||
链接:
|
||||
|
||||
```bash
|
||||
# 仅使用核心库(Msg/MsgServer/AsyncQueue)
|
||||
g++ -std=c++20 ... -lus_msg
|
||||
|
||||
# 使用 JSON RPC 层(RpcClient/RpcServer)
|
||||
g++ -std=c++20 ... -lus_msg -lboost_json
|
||||
```
|
||||
|
||||
> 与 `libus_crypt` 不同,`libus_msg.so` 不依赖 OpenSSL,无需额外链接 `-lssl -lcrypto`。所有通信函数、MsgServer 和 AsyncQueue 均为 header-only 内联实现,链接 `-lus_msg` 仅引入版本符号。
|
||||
|
||||
## 使用示例
|
||||
|
||||
### RPC 请求-应答(短连接)
|
||||
|
||||
```cpp
|
||||
#include <us/Msg.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
pmc::net::MsgClient client("127.0.0.1", 8005, "demo");
|
||||
|
||||
// 简单调用(不重试)
|
||||
try {
|
||||
std::string resp = client.call(R"({"_route":"execute","sql":"SELECT 1"})", 5.0);
|
||||
std::cout << "Response: " << resp << std::endl;
|
||||
} catch (const pmc::net::MsgConnectionRefusedException& e) {
|
||||
std::cerr << "服务未启动: " << e.what() << std::endl;
|
||||
}
|
||||
|
||||
// 启动等待模式(阻塞直到 db 就绪)
|
||||
std::string resp = client.call(
|
||||
R"({"_route":"execute","sql":"SELECT 1"})",
|
||||
5.0,
|
||||
/*retry_on_refused=*/true
|
||||
);
|
||||
std::cout << "Response after wait: " << resp << std::endl;
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 发送即忘(短连接通知)
|
||||
|
||||
```cpp
|
||||
pmc::net::MsgClient log_client("127.0.0.1", 8005, "log");
|
||||
log_client.send("application started"); // 发送 "log: application started"
|
||||
```
|
||||
|
||||
### 长连接监听
|
||||
|
||||
```cpp
|
||||
#include <us/Msg.hpp>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
pmc::net::MsgClient listener("127.0.0.1", 9000, "subscriber");
|
||||
listener.connect();
|
||||
|
||||
listener.listen([](const std::string& msg) {
|
||||
std::cout << "Received: " << msg << std::endl;
|
||||
});
|
||||
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### Echo 服务端(同步模式)
|
||||
|
||||
```cpp
|
||||
#include <us/MsgServer.hpp>
|
||||
#include <iostream>
|
||||
#include <thread>
|
||||
|
||||
int main() {
|
||||
pmc::net::MsgServer server("127.0.0.1", 8003);
|
||||
std::thread t([&] {
|
||||
server.start([&](int fd, const std::string& addr, const std::string& msg) {
|
||||
server.reply(fd, "echo: " + msg);
|
||||
});
|
||||
});
|
||||
|
||||
// ... 服务运行 ...
|
||||
// 另一线程调用 server.stop() 可优雅关闭
|
||||
|
||||
server.stop();
|
||||
t.join();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 广播聊天服务端(线程池模式)
|
||||
|
||||
```cpp
|
||||
#include <us/MsgServer.hpp>
|
||||
#include <thread>
|
||||
|
||||
int main() {
|
||||
pmc::net::MsgServer server("0.0.0.0", 8003, {
|
||||
.workers = 4,
|
||||
.recv_timeout = 10.0,
|
||||
.max_connections = 1000,
|
||||
.max_queue_size = 1000
|
||||
});
|
||||
|
||||
server.start([&](int fd, const std::string& addr, const std::string& msg) {
|
||||
// 转发给所有人(排除发送者自己)
|
||||
server.broadcast("[" + addr + "] " + msg, /*except_fd=*/fd);
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### 异步批量队列(日志上报)
|
||||
|
||||
```cpp
|
||||
#include <us/AsyncQueue.hpp>
|
||||
#include <us/Msg.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <iostream>
|
||||
|
||||
struct LogEntry {
|
||||
std::string service;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
int main() {
|
||||
pmc::net::AsyncQueue<LogEntry> queue(
|
||||
[](std::vector<LogEntry>&& batch) {
|
||||
pmc::net::MsgClient client("127.0.0.1", 8005, "metrics");
|
||||
for (auto& e : batch) {
|
||||
// 发送到日志服务...
|
||||
}
|
||||
},
|
||||
{50, 2.0, 10000, 0.5},
|
||||
[](const std::exception& e, std::vector<LogEntry>&&) {
|
||||
std::cerr << "[log] send failed: " << e.what() << std::endl;
|
||||
}
|
||||
);
|
||||
queue.start();
|
||||
|
||||
queue.push(LogEntry{"gateway", "request handled"});
|
||||
queue.push(LogEntry{"gateway", "db connected"});
|
||||
|
||||
// ... 应用运行 ...
|
||||
|
||||
queue.shutdown();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
### JSON RPC 服务端 + 客户端
|
||||
|
||||
```cpp
|
||||
#include <us/RpcServer.hpp>
|
||||
#include <us/RpcClient.hpp>
|
||||
#include <thread>
|
||||
#include <iostream>
|
||||
|
||||
int main() {
|
||||
// 服务端
|
||||
pmc::net::RpcServer rpc("127.0.0.1", 8005, {.workers=4},
|
||||
[](const std::string& route, const std::exception& e) {
|
||||
std::cerr << "[rpc][" << route << "] error: " << e.what() << std::endl;
|
||||
});
|
||||
|
||||
rpc.add_route("ping", [](const json::object&) -> json::value {
|
||||
return "pong";
|
||||
});
|
||||
|
||||
rpc.add_route("add", [](const json::object& req) -> json::value {
|
||||
return req.at("a").to_number<int>() + req.at("b").to_number<int>();
|
||||
});
|
||||
|
||||
std::thread t([&]{ rpc.start(); });
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
// 客户端
|
||||
pmc::net::RpcClient client("127.0.0.1", 8005, "demo");
|
||||
|
||||
json::object params;
|
||||
params["a"] = 3;
|
||||
params["b"] = 4;
|
||||
json::value result = client.call("add", params);
|
||||
std::cout << "3 + 4 = " << result.as_int64() << std::endl; // 7
|
||||
|
||||
try {
|
||||
client.call("nonexistent");
|
||||
} catch (const pmc::net::RpcError& e) {
|
||||
std::cout << "error: " << e.what() << std::endl; // unknown route: nonexistent
|
||||
}
|
||||
|
||||
rpc.stop();
|
||||
t.join();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
libmsg/
|
||||
├── inc/us/
|
||||
│ ├── Msg_Basic.hpp # 基类:异常层次、常量、版本声明
|
||||
│ ├── Msg.hpp # 核心协议:pmsg_send/pmsg_recv + MsgClient(header-only)
|
||||
│ ├── MsgServer.hpp # TCP 服务端:多客户端 select 事件循环 + 线程池(header-only)
|
||||
│ ├── AsyncQueue.hpp # 通用异步批量队列(header-only 模板)
|
||||
│ ├── RpcClient.hpp # JSON RPC 客户端(header-only,需 boost::json)
|
||||
│ └── RpcServer.hpp # JSON RPC 服务端(header-only,需 boost::json)
|
||||
├── src/
|
||||
│ └── Msg_Basic.cpp # version() 实现
|
||||
├── Makefile
|
||||
├── _set.sh
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **无外部依赖**:libmsg 核心(Msg/MsgServer/AsyncQueue)仅使用 POSIX socket API(socket/connect/recv/send/select)和 C++ 标准库(thread/mutex/condition_variable/deque/functional/unordered_map),不依赖 OpenSSL、Boost 或任何第三方库。
|
||||
- **JSON RPC 层依赖**:`RpcClient`/`RpcServer` 是基于 `boost::json` 的可选约定层,使用时需安装 Boost 并链接 `-lboost_json`。核心 `libus_msg.so` 不依赖 Boost。
|
||||
- **线程安全**:
|
||||
- `MsgClient` 的 `stop()` 方法使用 `std::atomic` 保护标志位和 fd,可跨线程安全调用;`connect()` 会安全关闭旧连接。同一 `MsgClient` 实例的 `call()`/`send()`/`listen()` 不应并发调用(每个线程应创建独立实例)。`listen()` 中使用局部 fd 副本防止 stop() 关闭后 FD_SET(-1) 导致未定义行为。
|
||||
- `MsgServer` 的 `start()` 阻塞调用线程;`stop()`/`reply()`/`broadcast()`/`client_count()` 可在任意线程(包括消息回调内部)安全调用。消息回调在同步模式下运行于主线程,在线程池模式下运行于 worker 线程。reply()/broadcast() 经每连接 mutex 串行化防止帧交错。
|
||||
- `AsyncQueue<T>` 的 `push()`/`flush()`/`shutdown()`/`pending()`/`dropped()` 可在任意线程安全调用;Handler 回调在后台线程中执行。`push()` 使用 mutex + condition_variable 唤醒后台线程,无锁竞争热点。
|
||||
- `RpcClient` 每次 `call()` 创建独立短连接,实例可在多线程中安全使用(但建议每个线程独立实例以避免连接复用问题)。
|
||||
- `RpcServer` 的 `add_route()` 应在 `start()` 之前调用;`stop()`/`server()` 可跨线程安全调用。Handler 在 MsgServer 回调线程中执行(同步模式=主线程,线程池模式=worker线程)。
|
||||
- **macOS 兼容**:自动处理 `MSG_NOSIGNAL` 缺失问题(macOS 内核不会在 send 到已关闭连接时发 SIGPIPE)。
|
||||
- **超时行为**:
|
||||
- `MsgClient::call()` 使用 deadline 模式 recv(select + 剩余时间递减),header+payload 共享同一个总超时,不会出现"header 等 t 秒 + payload 再等 t 秒"的双倍超时问题。
|
||||
- `MsgClient::listen()` 使用 SO_RCVTIMEO 做 100ms 轮询超时,结合 `_running` 原子标志实现快速停止响应。
|
||||
- `MsgServer` 使用 deadline 模式(select + 剩余时间递减),单条消息总接收时间不超过 `recv_timeout`,有效防御 Slowloris 攻击。
|
||||
- `AsyncQueue::flush()` 在调用线程同步执行 `_send_batch()`,阻塞直到 handler 处理完成;`shutdown()` 永久等待后台线程结束(handler 中 socket 均有超时,不会永久阻塞)。
|
||||
- **消息大小限制**:收发两端均检查 64 MiB 上限(`kMaxMsgSize`),防止超大消息导致 OOM。
|
||||
- **retry_on_refused 仅对 ECONNREFUSED 重试**:连接超时、连接重置、路由不可达等错误不重试,立即抛出异常。
|
||||
- **MsgServer 同步 vs 线程池**:
|
||||
- `workers=0`(默认):回调在主线程执行,适合 CPU 轻量、无阻塞 I/O 的场景(如简单 echo、内存状态查询)。同步模式下回调异常会被捕获、记日志并断开该客户端,不会导致服务崩溃。
|
||||
- `workers>0`:回调提交到线程池,适合有 I/O 阻塞的场景;同一连接消息仍严格串行,不同连接可并行处理。`stop()` 会等待所有已排队任务处理完成(优雅关闭),不丢弃请求。
|
||||
- **AsyncQueue 背压策略**:队列满时丢最旧数据(drop-oldest),而非阻塞 push 或丢最新数据,保证最新数据不丢失同时防止内存无限增长。`batch_size` 是触发阈值而非批量上限,flush 时取走队列中所有数据一次处理。
|
||||
- **跨语言兼容**:C++ 实现与 Python 版 `pymsg`、Go 版(waf 内)字节级完全兼容,可无缝互操作。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd "$(dirname "$0")" || exit
|
||||
make clear
|
||||
make
|
||||
make install
|
||||
@@ -0,0 +1,289 @@
|
||||
#pragma once
|
||||
|
||||
/** AsyncQueue — 通用异步批量发送队列(C++ 版,对应 Python 版 pymsg/pymsg/async_queue.py)
|
||||
*
|
||||
* 提供内存队列 + 后台线程的异步批量处理机制,适用于日志上报、指标采集、事件通知等
|
||||
* 非关键路径场景。业务线程 push() 非阻塞立即返回,后台线程攒批处理(批量阈值或超时),
|
||||
* 失败时通过错误回调通知,绝不阻塞或影响主业务流程。
|
||||
*
|
||||
* 设计要点(与 Python AsyncRpcQueue 对齐):
|
||||
* - 非阻塞入队:push() 立即返回,不等待网络 I/O
|
||||
* - 批量发送:攒到 batch_size 条或超过 flush_interval 秒自动 flush
|
||||
* - 背压保护:max_queue_size 限制内存队列长度,满时丢最旧数据并计数
|
||||
* - 优雅关闭:shutdown() flush 剩余数据后停止后台线程
|
||||
* - RAII:析构自动 shutdown()
|
||||
* - 可插拔:通过 Handler 回调自定义批量处理逻辑(RPC 调用、写文件、发消息等)
|
||||
*
|
||||
* Usage:
|
||||
* #include <us/AsyncQueue.hpp>
|
||||
*
|
||||
* struct LogEntry { std::string service; std::string msg; };
|
||||
*
|
||||
* pmc::net::AsyncQueue<LogEntry> queue(
|
||||
* [](std::vector<LogEntry>&& batch) {
|
||||
* for (auto& e : batch) { send_to_server(e); }
|
||||
* },
|
||||
* {50, 2.0, 10000, 0.5},
|
||||
* [](const std::exception& e, std::vector<LogEntry>&&) {
|
||||
* std::cerr << "[queue] send failed: " << e.what() << std::endl;
|
||||
* }
|
||||
* );
|
||||
* queue.start();
|
||||
* queue.push(LogEntry{"gateway", "started"});
|
||||
* queue.shutdown();
|
||||
*/
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <exception>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
// ============================================================================
|
||||
// AsyncQueue 配置
|
||||
// ============================================================================
|
||||
|
||||
struct AsyncQueueConfig {
|
||||
size_t batch_size = 100; ///< 批量发送阈值,达到此数量立即 flush
|
||||
double flush_interval = 2.0; ///< 定时 flush 间隔(秒)
|
||||
size_t max_queue_size = 10000; ///< 队列最大长度,0 表示无上限;超过时丢最旧数据
|
||||
double poll_timeout = 0.1; ///< 后台线程轮询超时(秒),越小关闭响应越快但CPU占用略高
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AsyncQueue<T>
|
||||
// ============================================================================
|
||||
|
||||
/** 通用异步批量发送队列。
|
||||
*
|
||||
* @tparam T 队列元素类型,需支持移动构造(push 时会被 std::move 到 batch 中)。
|
||||
*/
|
||||
template <typename T>
|
||||
class AsyncQueue {
|
||||
public:
|
||||
/// 批量处理回调:接收一个元素批次的右值引用,处理完后 batch 被销毁。
|
||||
using Handler = std::function<void(std::vector<T>&& batch)>;
|
||||
|
||||
/// 错误回调:接收异常和失败的批次(可用于重试或记录)。
|
||||
using ErrorHandler = std::function<void(const std::exception& e, std::vector<T>&& batch)>;
|
||||
|
||||
// ---- 构造 / 析构 -------------------------------------------------------
|
||||
|
||||
explicit AsyncQueue(Handler handler,
|
||||
AsyncQueueConfig config = {},
|
||||
ErrorHandler on_error = {})
|
||||
: _handler(std::move(handler)),
|
||||
_config(config),
|
||||
_on_error(std::move(on_error)),
|
||||
_running(false),
|
||||
_stop(false),
|
||||
_wakeup(false),
|
||||
_dropped(0) {}
|
||||
|
||||
/// 析构函数:自动 shutdown(),保证队列中剩余数据被 flush。
|
||||
~AsyncQueue() { shutdown(); }
|
||||
|
||||
AsyncQueue(const AsyncQueue&) = delete;
|
||||
AsyncQueue& operator=(const AsyncQueue&) = delete;
|
||||
|
||||
// ---- 生命周期 ----------------------------------------------------------
|
||||
|
||||
/// 启动后台发送线程。幂等:已启动时无操作。
|
||||
void start() {
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_running) return;
|
||||
_stop = false;
|
||||
_wakeup = false;
|
||||
_running = true;
|
||||
_thread = std::thread(&AsyncQueue::_run, this);
|
||||
}
|
||||
|
||||
/// 优雅关闭:flush 剩余数据后停止后台线程。幂等。
|
||||
void shutdown() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (!_running) return;
|
||||
_stop = true;
|
||||
_wakeup = true;
|
||||
}
|
||||
_cv.notify_all();
|
||||
if (_thread.joinable()) {
|
||||
_thread.join();
|
||||
}
|
||||
// 线程结束后,flush 队列中剩余的所有数据
|
||||
_flush_all();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 入队 --------------------------------------------------------------
|
||||
|
||||
/** 非阻塞入队一条数据。
|
||||
*
|
||||
* 若队列已关闭(shutdown 后),则丢弃数据并返回 false。
|
||||
* 若队列长度达到 max_queue_size,则丢弃最旧的数据并递增 dropped 计数。
|
||||
*
|
||||
* @return true 入队成功;false 队列已关闭。
|
||||
*/
|
||||
bool push(T item) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_stop) return false;
|
||||
if (_config.max_queue_size > 0 && _queue.size() >= _config.max_queue_size) {
|
||||
_queue.pop_front();
|
||||
++_dropped;
|
||||
}
|
||||
_queue.push_back(std::move(item));
|
||||
_wakeup = true;
|
||||
}
|
||||
_cv.notify_one();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- 手动 flush --------------------------------------------------------
|
||||
|
||||
/// 立即触发一次 flush,在调用线程同步处理当前队列中的所有数据。
|
||||
/// 对齐 Python:flush() 直接在调用线程执行 _send_batch(),阻塞直到处理完成。
|
||||
void flush() {
|
||||
_send_batch();
|
||||
}
|
||||
|
||||
// ---- 状态查询 ----------------------------------------------------------
|
||||
|
||||
/// 当前队列中待处理的数据量。
|
||||
size_t pending() const {
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
return _queue.size();
|
||||
}
|
||||
|
||||
/// 因队列满而丢弃的数据总数。
|
||||
size_t dropped() const {
|
||||
return _dropped.load();
|
||||
}
|
||||
|
||||
/// 队列是否正在运行(已 start 且未 shutdown)。
|
||||
bool is_running() const {
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
return _running;
|
||||
}
|
||||
|
||||
private:
|
||||
// ---- 后台线程主循环(对齐 Python AsyncRpcQueue._run)--------------------
|
||||
|
||||
void _run() {
|
||||
auto last_flush = std::chrono::steady_clock::now();
|
||||
|
||||
while (true) {
|
||||
bool should_stop = false;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_mtx);
|
||||
_cv.wait_for(lock,
|
||||
std::chrono::duration<double>(_config.poll_timeout),
|
||||
[this] { return _stop || _wakeup; });
|
||||
_wakeup = false; // 消费唤醒标志
|
||||
should_stop = _stop;
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool due = std::chrono::duration<double>(now - last_flush).count()
|
||||
>= _config.flush_interval;
|
||||
|
||||
size_t qsize = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
qsize = _queue.size();
|
||||
}
|
||||
|
||||
// flush 条件:达到批量阈值 / 超时且有数据 / 停止时还有数据
|
||||
if (qsize >= _config.batch_size || (due && qsize > 0) || (should_stop && qsize > 0)) {
|
||||
_send_batch();
|
||||
last_flush = std::chrono::steady_clock::now();
|
||||
}
|
||||
|
||||
if (should_stop) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 取所有待处理数据并发送(对齐 Python:batch_size 是触发阈值,不是上限)-
|
||||
|
||||
void _send_batch() {
|
||||
std::vector<T> batch;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_queue.empty()) return;
|
||||
batch.reserve(_queue.size());
|
||||
while (!_queue.empty()) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
_handler(std::move(batch));
|
||||
} catch (const std::exception& e) {
|
||||
if (_on_error) {
|
||||
try {
|
||||
_on_error(e, std::move(batch));
|
||||
} catch (...) {
|
||||
// 错误回调本身不应传播异常
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 关闭时 flush 所有剩余数据 ----------------------------------------
|
||||
|
||||
void _flush_all() {
|
||||
while (true) {
|
||||
std::vector<T> batch;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_mtx);
|
||||
if (_queue.empty()) break;
|
||||
batch.reserve(_queue.size());
|
||||
while (!_queue.empty()) {
|
||||
batch.push_back(std::move(_queue.front()));
|
||||
_queue.pop_front();
|
||||
}
|
||||
}
|
||||
try {
|
||||
_handler(std::move(batch));
|
||||
} catch (const std::exception& e) {
|
||||
if (_on_error) {
|
||||
try {
|
||||
_on_error(e, std::move(batch));
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 成员 --------------------------------------------------------------
|
||||
|
||||
Handler _handler;
|
||||
AsyncQueueConfig _config;
|
||||
ErrorHandler _on_error;
|
||||
|
||||
mutable std::mutex _mtx;
|
||||
std::condition_variable _cv;
|
||||
std::deque<T> _queue;
|
||||
std::thread _thread;
|
||||
bool _running;
|
||||
bool _stop;
|
||||
bool _wakeup; ///< 唤醒标志(push/shutdown 置 true,后台线程消费后置 false)
|
||||
|
||||
std::atomic<size_t> _dropped;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,462 @@
|
||||
#pragma once
|
||||
|
||||
/** pymsg C++ 实现 — Length-prefixed variable-length socket message framing.
|
||||
*
|
||||
* Protocol: [4-byte big-endian uint32_t length][UTF-8 payload]
|
||||
*
|
||||
* 对应 Python 版 pymsg/pymsg/__init__.py,提供:
|
||||
* - pmsg_send() / pmsg_recv() 底层协议收发函数
|
||||
* - MsgClient 短连接 send / call / 长连接 listen 客户端
|
||||
*
|
||||
* Usage:
|
||||
* #include <us/Msg.hpp>
|
||||
*
|
||||
* pmc::net::MsgClient client("127.0.0.1", 8005, "gateway");
|
||||
* std::string resp = client.call(R"({"sql":"SELECT 1"})", 5.0);
|
||||
* client.send("hello");
|
||||
*/
|
||||
|
||||
#include <us/Msg_Basic.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
// macOS 没有 MSG_NOSIGNAL,用 0 替代(内核不会发 SIGPIPE)
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
// ============================================================================
|
||||
// Low-level protocol: 4-byte big-endian length prefix + UTF-8 payload
|
||||
// ============================================================================
|
||||
|
||||
namespace detail {
|
||||
|
||||
using namespace msg_constants;
|
||||
|
||||
// ---- 4-byte big-endian 编码 / 解码 ----------------------------------------
|
||||
|
||||
inline void writeUint32BE(char *buf, uint32_t val) noexcept {
|
||||
buf[0] = static_cast<char>((val >> 24) & 0xFFu);
|
||||
buf[1] = static_cast<char>((val >> 16) & 0xFFu);
|
||||
buf[2] = static_cast<char>((val >> 8) & 0xFFu);
|
||||
buf[3] = static_cast<char>(val & 0xFFu);
|
||||
}
|
||||
|
||||
inline uint32_t readUint32BE(const char *buf) noexcept {
|
||||
return (static_cast<uint32_t>(static_cast<unsigned char>(buf[0])) << 24) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[1])) << 16) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[2])) << 8) |
|
||||
(static_cast<uint32_t>(static_cast<unsigned char>(buf[3])));
|
||||
}
|
||||
|
||||
// ---- socket 超时设置 ------------------------------------------------------
|
||||
|
||||
inline void setSockTimeout(int sockfd, double seconds) {
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = static_cast<time_t>(seconds);
|
||||
tv.tv_usec =
|
||||
static_cast<long>((seconds - static_cast<double>(tv.tv_sec)) * 1'000'000.0);
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
|
||||
::setsockopt(sockfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
|
||||
}
|
||||
|
||||
// ---- 精确读取 n 字节 ------------------------------------------------------
|
||||
|
||||
inline void recvExact(int sockfd, char *buf, size_t n) {
|
||||
size_t received = 0;
|
||||
while (received < n) {
|
||||
ssize_t r = ::recv(sockfd, buf + received, n - received, 0);
|
||||
if (r == 0) {
|
||||
throw MsgConnectionException(
|
||||
"connection closed: expected " + std::to_string(n) +
|
||||
" bytes, got " + std::to_string(received));
|
||||
}
|
||||
if (r < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("recv timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"recv error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
received += static_cast<size_t>(r);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- deadline 模式精确读取 n 字节(总超时,防 Slowloris)-------------------
|
||||
// 与 Python 版 _recv_exact 对齐:每次 select 前按剩余时间收紧超时,
|
||||
// header+payload 共享同一个 deadline,总接收时间不超过 timeout_sec。
|
||||
|
||||
inline void recvExactDeadline(int sockfd, char *buf, size_t n,
|
||||
const std::chrono::steady_clock::time_point& deadline) {
|
||||
size_t received = 0;
|
||||
while (received < n) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
if (now >= deadline) {
|
||||
throw MsgTimeoutException("recv timed out");
|
||||
}
|
||||
auto remaining_dur = std::chrono::duration_cast<std::chrono::microseconds>(deadline - now);
|
||||
double remaining = static_cast<double>(remaining_dur.count()) / 1'000'000.0;
|
||||
if (remaining < 0.001) remaining = 0.001; // 最小 1ms
|
||||
|
||||
fd_set rfds;
|
||||
FD_ZERO(&rfds);
|
||||
FD_SET(sockfd, &rfds);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = static_cast<time_t>(remaining);
|
||||
tv.tv_usec = static_cast<long>((remaining - tv.tv_sec) * 1'000'000.0);
|
||||
|
||||
int ready = ::select(sockfd + 1, &rfds, nullptr, nullptr, &tv);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw MsgConnectionException(std::string("select error: ") + ::strerror(errno));
|
||||
}
|
||||
if (ready == 0) {
|
||||
throw MsgTimeoutException("recv timed out");
|
||||
}
|
||||
|
||||
ssize_t r = ::recv(sockfd, buf + received, n - received, 0);
|
||||
if (r == 0) {
|
||||
throw MsgConnectionException(
|
||||
"connection closed: expected " + std::to_string(n) +
|
||||
" bytes, got " + std::to_string(received));
|
||||
}
|
||||
if (r < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) continue;
|
||||
throw MsgConnectionException(
|
||||
"recv error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
received += static_cast<size_t>(r);
|
||||
}
|
||||
}
|
||||
|
||||
// deadline 模式接收完整 pymsg 帧(header + payload)
|
||||
inline std::string recvFrameDeadline(int sockfd, double timeout_sec) {
|
||||
const auto deadline = std::chrono::steady_clock::now()
|
||||
+ std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(timeout_sec));
|
||||
|
||||
char header[kHeaderSize];
|
||||
recvExactDeadline(sockfd, header, kHeaderSize, deadline);
|
||||
uint32_t length = readUint32BE(header);
|
||||
if (length > kMaxMsgSize) {
|
||||
throw MsgException("message too large: " + std::to_string(length));
|
||||
}
|
||||
|
||||
std::string payload(static_cast<size_t>(length), '\0');
|
||||
if (length > 0) {
|
||||
recvExactDeadline(sockfd, payload.data(), length, deadline);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ---- 完整发送 -------------------------------------------------------------
|
||||
|
||||
inline void sendAll(int sockfd, const char *data, size_t len) {
|
||||
size_t sent = 0;
|
||||
while (sent < len) {
|
||||
ssize_t w =
|
||||
::send(sockfd, data + sent, len - sent, MSG_NOSIGNAL);
|
||||
if (w < 0) {
|
||||
if (errno == EAGAIN || errno == EWOULDBLOCK) {
|
||||
throw MsgTimeoutException("send timed out");
|
||||
}
|
||||
throw MsgConnectionException(
|
||||
"send error: " + std::string(::strerror(errno)));
|
||||
}
|
||||
sent += static_cast<size_t>(w);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 短连接 helper:connect → action → close ------------------------------
|
||||
|
||||
inline int openConnection(const std::string& addr, int port, double timeout) {
|
||||
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) {
|
||||
throw MsgConnectionException(
|
||||
"socket() failed: " + std::string(::strerror(errno)));
|
||||
}
|
||||
|
||||
struct sockaddr_in serv_addr {};
|
||||
serv_addr.sin_family = AF_INET;
|
||||
serv_addr.sin_port = htons(static_cast<uint16_t>(port));
|
||||
if (::inet_pton(AF_INET, addr.c_str(), &serv_addr.sin_addr) <= 0) {
|
||||
::close(fd);
|
||||
throw MsgConnectionException("invalid address: " + addr);
|
||||
}
|
||||
|
||||
setSockTimeout(fd, timeout);
|
||||
|
||||
if (::connect(fd, reinterpret_cast<struct sockaddr *>(&serv_addr),
|
||||
sizeof(serv_addr)) < 0) {
|
||||
int saved_errno = errno;
|
||||
::close(fd);
|
||||
std::string err_msg = "could not connect to " + addr + ":" +
|
||||
std::to_string(port) + " - " + ::strerror(saved_errno);
|
||||
if (saved_errno == ECONNREFUSED) {
|
||||
throw MsgConnectionRefusedException(err_msg);
|
||||
}
|
||||
throw MsgConnectionException(err_msg);
|
||||
}
|
||||
|
||||
return fd;
|
||||
}
|
||||
|
||||
} // namespace detail
|
||||
|
||||
// ============================================================================
|
||||
// 底层协议函数:pmsg_send / pmsg_recv
|
||||
// ============================================================================
|
||||
|
||||
/// 以 pymsg 协议发送一条字符串消息(长度前缀 + UTF-8 载荷)。
|
||||
inline void pmsg_send(int sockfd, const std::string &data) {
|
||||
size_t payload_size = data.size();
|
||||
if (payload_size > msg_constants::kMaxMsgSize) {
|
||||
throw MsgException(
|
||||
"payload too large: " + std::to_string(payload_size) +
|
||||
" bytes (max " + std::to_string(msg_constants::kMaxMsgSize) + ")");
|
||||
}
|
||||
char header[detail::kHeaderSize];
|
||||
detail::writeUint32BE(header, static_cast<uint32_t>(payload_size));
|
||||
detail::sendAll(sockfd, header, detail::kHeaderSize);
|
||||
detail::sendAll(sockfd, data.c_str(), payload_size);
|
||||
}
|
||||
|
||||
/// 以 pymsg 协议接收一条字符串消息(阻塞模式,依赖 socket 已设置的超时)。
|
||||
/// @throws MsgConnectionException 连接在收全消息前断开。
|
||||
/// @throws MsgTimeoutException 等待超时。
|
||||
inline std::string pmsg_recv(int sockfd) {
|
||||
char header[detail::kHeaderSize];
|
||||
detail::recvExact(sockfd, header, detail::kHeaderSize);
|
||||
uint32_t length = detail::readUint32BE(header);
|
||||
if (length > msg_constants::kMaxMsgSize) { // 安全上限 64 MiB
|
||||
throw MsgException("message too large: " + std::to_string(length));
|
||||
}
|
||||
std::string payload(static_cast<size_t>(length), '\0');
|
||||
detail::recvExact(sockfd, payload.data(), length);
|
||||
return payload;
|
||||
}
|
||||
|
||||
/// 以 pymsg 协议接收一条字符串消息(deadline 模式,总超时不超过 timeout_sec)。
|
||||
/// header 和 payload 共享同一个 deadline,防止 Slowloris 攻击。
|
||||
/// 不依赖 socket 的 SO_RCVTIMEO,使用 select() 实现精确超时控制。
|
||||
/// @param timeout_sec 总超时秒数
|
||||
/// @throws MsgConnectionException 连接在收全消息前断开。
|
||||
/// @throws MsgTimeoutException 总超时到期。
|
||||
inline std::string pmsg_recv(int sockfd, double timeout_sec) {
|
||||
return detail::recvFrameDeadline(sockfd, timeout_sec);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MsgClient
|
||||
// ============================================================================
|
||||
|
||||
/// 基于 pymsg 协议的 TCP 客户端。
|
||||
///
|
||||
/// 支持三种操作模式:
|
||||
/// - send() 短连接发送一条消息(不等待回复),消息带 name 前缀。
|
||||
/// - call() 短连接发送 + 阻塞等待回复(RPC 模式),原始消息不添加前缀。
|
||||
/// - listen() 基于 connect() 维持的长连接,持续接收消息并回调。
|
||||
class MsgClient : public Msg_Basic {
|
||||
public:
|
||||
MsgClient(const std::string &addr, int port, const std::string &name)
|
||||
: _addr(addr), _port(port), _name(name), _sockfd(-1), _running(false) {}
|
||||
|
||||
~MsgClient() { stop(); }
|
||||
|
||||
// ---- 连接 ---------------------------------------------------------------
|
||||
|
||||
/// 创建 TCP 套接字并连接到服务器(长连接,供 listen() 使用)。
|
||||
void connect() {
|
||||
int fd = detail::openConnection(_addr, _port, /*timeout=*/static_cast<double>(msg_constants::kDefaultTimeout));
|
||||
// 如果已有旧连接,先关闭
|
||||
int old = _sockfd.exchange(fd);
|
||||
if (old >= 0) {
|
||||
::shutdown(old, SHUT_RDWR);
|
||||
::close(old);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 发送单条消息(短连接,带名字前缀)----------------------------------
|
||||
|
||||
/// 短连接发送一条消息到服务器,消息格式为 ``"name: msg"``。
|
||||
/// 不等待回复。短连接(每次创建新 socket,发完即关)。
|
||||
void send(const std::string &msg) {
|
||||
int fd = detail::openConnection(_addr, _port, /*timeout=*/static_cast<double>(msg_constants::kSendTimeout));
|
||||
try {
|
||||
pmsg_send(fd, _name + ": " + msg);
|
||||
::close(fd);
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 请求-应答(短连接,RPC 模式)---------------------------------------
|
||||
|
||||
/// 短连接发送一条消息,阻塞等待并返回一条回复。
|
||||
///
|
||||
/// 不会给消息添加 ``name:`` 前缀(适用于 RPC/API 调用)。
|
||||
///
|
||||
/// @param msg 要发送的原始字符串。
|
||||
/// @param timeout 超时秒数,默认 10.0。
|
||||
/// @param retry_on_refused 目标服务未启动(ECONNREFUSED)时是否阻塞重试,
|
||||
/// 直到连接成功。适用于启动顺序不确定的服务发现场景。
|
||||
/// 默认 false(连接拒绝立即抛异常)。
|
||||
/// @param retry_interval 重试间隔秒数,默认 1.0。
|
||||
/// @returns 服务器返回的字符串。
|
||||
std::string call(const std::string &msg, double timeout = static_cast<double>(msg_constants::kDefaultTimeout),
|
||||
bool retry_on_refused = false,
|
||||
double retry_interval = 1.0) {
|
||||
if (!retry_on_refused) {
|
||||
// 快速路径:不重试,直接执行一次
|
||||
int fd = detail::openConnection(_addr, _port, timeout);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
// deadline 模式 recv:header+payload 共享总超时,防止总等待时间翻倍
|
||||
std::string resp = pmsg_recv(fd, timeout);
|
||||
::close(fd);
|
||||
return resp;
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// 重试路径:仅对 ECONNREFUSED 重试,其他错误立即抛出
|
||||
int attempt = 0;
|
||||
auto last_log = std::chrono::steady_clock::now() -
|
||||
std::chrono::duration_cast<std::chrono::steady_clock::duration>(
|
||||
std::chrono::duration<double>(msg_constants::kRetryLogEvery));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
int fd = detail::openConnection(_addr, _port, timeout);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
std::string resp = pmsg_recv(fd, timeout);
|
||||
::close(fd);
|
||||
return resp;
|
||||
} catch (...) {
|
||||
::close(fd);
|
||||
throw;
|
||||
}
|
||||
} catch (const MsgConnectionRefusedException &) {
|
||||
++attempt;
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
double elapsed = std::chrono::duration<double>(now - last_log).count();
|
||||
if (elapsed >= msg_constants::kRetryLogEvery || attempt == 1) {
|
||||
std::fprintf(stderr,
|
||||
"[pymsg] 等待服务 %s:%d 就绪... (已重试 %d 次)\n",
|
||||
_addr.c_str(), _port, attempt);
|
||||
std::fflush(stderr);
|
||||
last_log = now;
|
||||
}
|
||||
std::this_thread::sleep_for(
|
||||
std::chrono::duration<double>(retry_interval));
|
||||
}
|
||||
// 其他异常(MsgTimeoutException、普通MsgConnectionException等)不捕获,直接向上抛出
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 持续监听(长连接,阻塞回调模式)-----------------------------------
|
||||
|
||||
/// 在长连接上持续接收消息并通过回调处理。
|
||||
///
|
||||
/// 阻塞当前线程直到 stop() 被调用或连接断开。
|
||||
/// 必须先调用 connect()。
|
||||
///
|
||||
/// @param on_message 回调 ``void(const std::string &msg)``。
|
||||
void listen(std::function<void(const std::string &)> on_message) {
|
||||
int fd = _sockfd.load();
|
||||
if (fd < 0) {
|
||||
throw MsgException("must call connect() before listen()");
|
||||
}
|
||||
|
||||
_running = true;
|
||||
while (_running.load()) {
|
||||
// 使用局部 fd,避免与 stop() 竞争导致 FD_SET(-1) UB
|
||||
// 若 stop() 已将 _sockfd 置为 -1,下次循环 _running=false 退出
|
||||
int cur_fd = _sockfd.load();
|
||||
if (cur_fd < 0) break;
|
||||
|
||||
fd_set readfds;
|
||||
FD_ZERO(&readfds);
|
||||
FD_SET(cur_fd, &readfds);
|
||||
|
||||
struct timeval tv {};
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = msg_constants::kListenPollMs; // 100 ms 轮询间隔
|
||||
|
||||
int ready =
|
||||
::select(cur_fd + 1, &readfds, nullptr, nullptr, &tv);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break; // EBADF (socket 被 stop() 关闭) 或其他致命错误
|
||||
}
|
||||
if (ready == 0) continue; // timeout, loop around
|
||||
|
||||
try {
|
||||
std::string text = pmsg_recv(cur_fd);
|
||||
on_message(std::move(text));
|
||||
} catch (const MsgConnectionException &) {
|
||||
break; // 连接断开,退出循环
|
||||
} catch (const MsgTimeoutException &) {
|
||||
continue; // 读超时但连接仍在,继续等待
|
||||
}
|
||||
}
|
||||
|
||||
// 原子地认领 fd 并关闭,防止与 stop() double-close
|
||||
int close_fd = _sockfd.exchange(-1);
|
||||
if (close_fd >= 0) {
|
||||
::shutdown(close_fd, SHUT_RDWR);
|
||||
::close(close_fd);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 停止 ---------------------------------------------------------------
|
||||
|
||||
/// 设置停止标志并关闭套接字。可从任意线程调用。
|
||||
void stop() noexcept {
|
||||
_running = false;
|
||||
// 原子地认领 fd 并关闭,防止与 listen() double-close
|
||||
int fd = _sockfd.exchange(-1);
|
||||
if (fd >= 0) {
|
||||
::shutdown(fd, SHUT_RDWR); // 唤醒阻塞在 recv/select 上的线程
|
||||
::close(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 访问器 -------------------------------------------------------------
|
||||
|
||||
const std::string &addr() const noexcept { return _addr; }
|
||||
int port() const noexcept { return _port; }
|
||||
const std::string &name() const noexcept { return _name; }
|
||||
|
||||
private:
|
||||
std::string _addr;
|
||||
int _port;
|
||||
std::string _name;
|
||||
std::atomic<int> _sockfd;
|
||||
std::atomic<bool> _running;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,528 @@
|
||||
#pragma once
|
||||
|
||||
/** MsgServer — 基于 pymsg 协议的多客户端 TCP 服务器(C++ 版,对应 Python 版 pymsg.MsgServer)
|
||||
*
|
||||
* 提供 select-based 事件循环 + 可选线程池并发处理,支持 reply()/broadcast() 线程安全发送。
|
||||
*
|
||||
* 设计要点(与 Python 版对齐):
|
||||
* - select 事件循环,支持多客户端并发连接
|
||||
* - workers=0 同步模式(回调在主线程执行),workers>0 线程池并发模式
|
||||
* - 同一连接消息严格串行:回调执行期间暂停该连接 select 监听
|
||||
* - reply()/broadcast() 经每连接发送锁串行化,防止帧交错
|
||||
* - max_connections 限制活跃连接数,防止连接耗尽攻击
|
||||
* - max_queue_size 限制线程池任务队列深度,慢 handler 时断开连接防 OOM
|
||||
* - deadline 模式 recv 超时,防 Slowloris 攻击
|
||||
* - stop() 可从任意线程(包括回调内部)安全调用,优雅关闭
|
||||
* - worker 线程出错仅标记 failed,主循环统一清理连接,避免 fd 并发操作
|
||||
*
|
||||
* Usage:
|
||||
* #include <us/MsgServer.hpp>
|
||||
*
|
||||
* pmc::net::MsgServer server("0.0.0.0", 8003, {.workers=4});
|
||||
* server.start([&](int fd, const std::string& addr, const std::string& msg) {
|
||||
* server.reply(fd, "echo: " + msg); // 回复发送者
|
||||
* server.broadcast(addr + ": " + msg, fd); // 广播给其他人
|
||||
* });
|
||||
*/
|
||||
|
||||
#include <us/Msg_Basic.hpp>
|
||||
#include <us/Msg.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
// ============================================================================
|
||||
// MsgServer 配置
|
||||
// ============================================================================
|
||||
|
||||
struct MsgServerConfig {
|
||||
int workers = 0; ///< 线程池大小;0 = 同步模式(回调在主线程执行)
|
||||
double recv_timeout = 10.0; ///< 接收单条消息的总超时(秒),防 Slowloris
|
||||
int max_connections = 1000; ///< 最大并发连接数,超过则拒绝新连接
|
||||
int max_queue_size = 1000; ///< 线程池任务队列上限(workers>0 时),满时断开连接
|
||||
int listen_backlog = 10; ///< listen() backlog
|
||||
double poll_timeout = 0.1; ///< select 轮询超时(秒),影响关闭响应速度
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MsgServer
|
||||
// ============================================================================
|
||||
|
||||
/// 基于 pymsg 长度前缀协议的多客户端 TCP 服务器。
|
||||
class MsgServer {
|
||||
public:
|
||||
/// 消息回调签名:(客户端 fd, 客户端地址 "ip:port", 消息内容)
|
||||
using MessageCallback = std::function<void(int sockfd, const std::string& addr, const std::string& message)>;
|
||||
|
||||
explicit MsgServer(const std::string& addr, int port, MsgServerConfig config = {})
|
||||
: _addr(addr), _port(port), _config(config), _server_fd(-1), _running(false) {}
|
||||
|
||||
~MsgServer() { stop(); }
|
||||
|
||||
MsgServer(const MsgServer&) = delete;
|
||||
MsgServer& operator=(const MsgServer&) = delete;
|
||||
|
||||
// ---- 启动(阻塞当前线程)------------------------------------------------
|
||||
|
||||
/** 启动服务器,阻塞当前线程直到 stop() 被调用或发生致命错误。
|
||||
*
|
||||
* @param on_message 消息回调,签名 void(int fd, const string& addr, const string& msg)。
|
||||
* - 同步模式(workers=0):回调在主线程执行,同一连接消息天然串行。
|
||||
* - 线程池模式(workers>0):回调提交到线程池并发执行;同一连接
|
||||
* 上的消息仍严格串行(回调完成前该连接暂停 select 监听)。
|
||||
* 回调内可安全调用 reply()/broadcast()/stop()。
|
||||
*/
|
||||
void start(MessageCallback on_message) {
|
||||
_on_message = std::move(on_message);
|
||||
_running = true;
|
||||
|
||||
// 创建监听 socket
|
||||
_server_fd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (_server_fd < 0) {
|
||||
throw MsgConnectionException(std::string("socket() failed: ") + ::strerror(errno));
|
||||
}
|
||||
|
||||
int optval = 1;
|
||||
::setsockopt(_server_fd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));
|
||||
|
||||
struct sockaddr_in serv_addr {};
|
||||
serv_addr.sin_family = AF_INET;
|
||||
serv_addr.sin_port = htons(static_cast<uint16_t>(_port));
|
||||
if (::inet_pton(AF_INET, _addr.c_str(), &serv_addr.sin_addr) <= 0) {
|
||||
::close(_server_fd);
|
||||
_server_fd = -1;
|
||||
throw MsgConnectionException("invalid bind address: " + _addr);
|
||||
}
|
||||
|
||||
if (::bind(_server_fd, reinterpret_cast<struct sockaddr*>(&serv_addr), sizeof(serv_addr)) < 0) {
|
||||
int saved_errno = errno;
|
||||
::close(_server_fd);
|
||||
_server_fd = -1;
|
||||
throw MsgConnectionException(std::string("bind() failed: ") + ::strerror(saved_errno));
|
||||
}
|
||||
|
||||
if (::listen(_server_fd, _config.listen_backlog) < 0) {
|
||||
int saved_errno = errno;
|
||||
::close(_server_fd);
|
||||
_server_fd = -1;
|
||||
throw MsgConnectionException(std::string("listen() failed: ") + ::strerror(saved_errno));
|
||||
}
|
||||
|
||||
// 启动线程池(如果配置了 workers)
|
||||
std::vector<std::thread> pool_threads;
|
||||
if (_config.workers > 0) {
|
||||
_pool_running = true;
|
||||
pool_threads.reserve(_config.workers);
|
||||
for (int i = 0; i < _config.workers; ++i) {
|
||||
pool_threads.emplace_back(&MsgServer::_worker_loop, this);
|
||||
}
|
||||
}
|
||||
|
||||
// 主事件循环
|
||||
try {
|
||||
_event_loop();
|
||||
} catch (...) {
|
||||
// 确保线程池和连接被清理
|
||||
_running = false;
|
||||
_shutdown_pool(pool_threads);
|
||||
_close_all_clients();
|
||||
if (_server_fd >= 0) { ::close(_server_fd); _server_fd = -1; }
|
||||
throw;
|
||||
}
|
||||
|
||||
_shutdown_pool(pool_threads);
|
||||
_close_all_clients();
|
||||
if (_server_fd >= 0) { ::close(_server_fd); _server_fd = -1; }
|
||||
}
|
||||
|
||||
// ---- 停止 --------------------------------------------------------------
|
||||
|
||||
/// 停止服务器(可从任意线程调用,包括回调内部)。
|
||||
/// 关闭监听 socket 以解除 select 阻塞,事件循环退出前清理所有连接。
|
||||
void stop() noexcept {
|
||||
bool expected = true;
|
||||
if (!_running.compare_exchange_strong(expected, false)) return;
|
||||
if (_server_fd >= 0) {
|
||||
::shutdown(_server_fd, SHUT_RDWR);
|
||||
::close(_server_fd);
|
||||
_server_fd = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 发送(线程安全)----------------------------------------------------
|
||||
|
||||
/** 向指定客户端发送一条应答消息(线程安全)。
|
||||
*
|
||||
* 经每连接发送锁串行化,workers>0 时可在回调线程安全调用。
|
||||
* 如果连接已断开则静默忽略(fail-safe,不抛异常)。
|
||||
*/
|
||||
void reply(int sockfd, const std::string& msg) {
|
||||
std::shared_ptr<std::mutex> send_lock;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
auto it = _send_locks.find(sockfd);
|
||||
if (it == _send_locks.end()) return; // 连接已断开
|
||||
send_lock = it->second;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(*send_lock);
|
||||
try {
|
||||
pmsg_send(sockfd, msg);
|
||||
} catch (const MsgException&) {
|
||||
// 连接可能已断开,静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 向所有已连接客户端广播消息(线程安全)。
|
||||
*
|
||||
* @param msg 要广播的消息。
|
||||
* @param except_fd 排除的 fd(通常是发送者自己),默认 -1 表示不排除。
|
||||
*/
|
||||
void broadcast(const std::string& msg, int except_fd = -1) {
|
||||
// 先拷贝一份 fd 列表,避免持锁期间发送
|
||||
std::vector<std::pair<int, std::shared_ptr<std::mutex>>> targets;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
targets.reserve(_clients.size());
|
||||
for (auto& [fd, addr] : _clients) {
|
||||
if (fd == except_fd) continue;
|
||||
auto it = _send_locks.find(fd);
|
||||
if (it != _send_locks.end()) {
|
||||
targets.emplace_back(fd, it->second);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (auto& [fd, send_lock] : targets) {
|
||||
std::lock_guard<std::mutex> slock(*send_lock);
|
||||
try {
|
||||
pmsg_send(fd, msg);
|
||||
} catch (const MsgException&) {
|
||||
// 发送失败,标记为 failed 由主循环清理
|
||||
std::lock_guard<std::mutex> flk(_failed_mtx);
|
||||
_failed.insert(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 状态查询 ----------------------------------------------------------
|
||||
|
||||
/// 当前已连接客户端数量。
|
||||
size_t client_count() const {
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
return _clients.size();
|
||||
}
|
||||
|
||||
private:
|
||||
// ---- 内部:线程池 -------------------------------------------------------
|
||||
|
||||
struct Task {
|
||||
int fd;
|
||||
std::string addr;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
void _worker_loop() {
|
||||
while (true) {
|
||||
Task task;
|
||||
bool has_task = false;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(_pool_mtx);
|
||||
_pool_cv.wait(lock, [this] { return !_pool_running || !_pool_queue.empty(); });
|
||||
if (!_pool_queue.empty()) {
|
||||
task = std::move(_pool_queue.front());
|
||||
_pool_queue.pop();
|
||||
has_task = true;
|
||||
} else if (!_pool_running) {
|
||||
return; // 队列空且已停止,退出
|
||||
}
|
||||
}
|
||||
if (!has_task) continue;
|
||||
|
||||
// 执行回调
|
||||
bool failed = false;
|
||||
try {
|
||||
_on_message(task.fd, task.addr, std::move(task.message));
|
||||
} catch (const MsgConnectionException&) {
|
||||
failed = true;
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "[MsgServer] callback error on fd=%d: %s\n",
|
||||
task.fd, e.what());
|
||||
std::fflush(stderr);
|
||||
failed = true;
|
||||
} catch (...) {
|
||||
failed = true;
|
||||
}
|
||||
if (failed) {
|
||||
std::lock_guard<std::mutex> flk(_failed_mtx);
|
||||
_failed.insert(task.fd);
|
||||
}
|
||||
// 清除 busy 标记,恢复 select 监听
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_busy_mtx);
|
||||
_busy.erase(task.fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _shutdown_pool(std::vector<std::thread>& pool_threads) {
|
||||
// 停止接收新任务,唤醒所有 worker 排空队列后退出
|
||||
_pool_running = false;
|
||||
_pool_cv.notify_all();
|
||||
for (auto& t : pool_threads) {
|
||||
if (t.joinable()) t.join();
|
||||
}
|
||||
pool_threads.clear();
|
||||
// worker 已排空队列,此时 busy 集合中的任务都已完成
|
||||
// 任何仍标记为 failed 的连接由 _close_all_clients 统一清理
|
||||
}
|
||||
|
||||
// ---- 内部:主事件循环 ---------------------------------------------------
|
||||
|
||||
void _event_loop() {
|
||||
while (_running) {
|
||||
// 清理 failed 连接
|
||||
_reap_failed();
|
||||
|
||||
// 构建 fd_set:server_fd + 非 busy 的客户端 fd
|
||||
fd_set readfds, exceptfds;
|
||||
FD_ZERO(&readfds);
|
||||
FD_ZERO(&exceptfds);
|
||||
int maxfd = -1;
|
||||
|
||||
if (_server_fd >= 0) {
|
||||
FD_SET(_server_fd, &readfds);
|
||||
maxfd = _server_fd;
|
||||
}
|
||||
|
||||
std::vector<int> watched_clients;
|
||||
{
|
||||
std::lock_guard<std::mutex> clk(_clients_mtx);
|
||||
std::lock_guard<std::mutex> blk(_busy_mtx);
|
||||
watched_clients.reserve(_clients.size());
|
||||
for (auto& [fd, addr] : _clients) {
|
||||
if (_busy.count(fd)) continue; // 回调处理中,暂停监听
|
||||
FD_SET(fd, &readfds);
|
||||
FD_SET(fd, &exceptfds);
|
||||
if (fd > maxfd) maxfd = fd;
|
||||
watched_clients.push_back(fd);
|
||||
}
|
||||
}
|
||||
|
||||
if (maxfd < 0) {
|
||||
// 没有任何 fd 可监听(server_fd 已关闭且无客户端),退出
|
||||
break;
|
||||
}
|
||||
|
||||
struct timeval tv;
|
||||
tv.tv_sec = 0;
|
||||
tv.tv_usec = static_cast<long>(_config.poll_timeout * 1'000'000.0);
|
||||
|
||||
int ready = ::select(maxfd + 1, &readfds, nullptr, &exceptfds, &tv);
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
break; // EBADF 等致命错误
|
||||
}
|
||||
if (ready == 0) continue;
|
||||
|
||||
// 处理异常 fd
|
||||
for (int fd : watched_clients) {
|
||||
if (FD_ISSET(fd, &exceptfds)) {
|
||||
_remove_client(fd);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理新连接
|
||||
if (_server_fd >= 0 && FD_ISSET(_server_fd, &readfds)) {
|
||||
_accept_client();
|
||||
}
|
||||
|
||||
// 处理客户端消息
|
||||
for (int fd : watched_clients) {
|
||||
if (FD_ISSET(fd, &readfds)) {
|
||||
// 检查 fd 是否仍在客户端列表中(可能已被异常处理移除)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
if (!_clients.count(fd)) continue;
|
||||
}
|
||||
_handle_client(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _accept_client() {
|
||||
struct sockaddr_in client_addr {};
|
||||
socklen_t addrlen = sizeof(client_addr);
|
||||
int client_fd = ::accept(_server_fd, reinterpret_cast<struct sockaddr*>(&client_addr), &addrlen);
|
||||
if (client_fd < 0) {
|
||||
// EINVAL/EBADF: server_fd 已被 stop() 关闭;其他错误忽略
|
||||
return;
|
||||
}
|
||||
if (client_fd >= FD_SETSIZE) { ::close(client_fd); return; }
|
||||
|
||||
// 检查连接数上限
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
if (static_cast<int>(_clients.size()) >= _config.max_connections) {
|
||||
::close(client_fd);
|
||||
return;
|
||||
}
|
||||
char ip_str[INET_ADDRSTRLEN];
|
||||
::inet_ntop(AF_INET, &client_addr.sin_addr, ip_str, sizeof(ip_str));
|
||||
std::string addr = std::string(ip_str) + ":" + std::to_string(ntohs(client_addr.sin_port));
|
||||
_clients[client_fd] = addr;
|
||||
_send_locks[client_fd] = std::make_shared<std::mutex>();
|
||||
}
|
||||
|
||||
// 禁用 Nagle 算法,降低小包延迟
|
||||
int flag = 1;
|
||||
::setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));
|
||||
}
|
||||
|
||||
void _handle_client(int fd) {
|
||||
std::string addr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(_clients_mtx);
|
||||
auto it = _clients.find(fd);
|
||||
if (it == _clients.end()) return;
|
||||
addr = it->second;
|
||||
}
|
||||
|
||||
std::string message;
|
||||
try {
|
||||
// deadline 模式:header+payload 共享总超时,防 Slowloris
|
||||
message = pmsg_recv(fd, _config.recv_timeout);
|
||||
} catch (const MsgException&) {
|
||||
_remove_client(fd);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_config.workers == 0) {
|
||||
// 同步模式:在主线程执行回调
|
||||
try {
|
||||
_on_message(fd, addr, std::move(message));
|
||||
} catch (const MsgConnectionException&) {
|
||||
_remove_client(fd);
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "[MsgServer] callback error on fd=%d: %s\n", fd, e.what());
|
||||
std::fflush(stderr);
|
||||
_remove_client(fd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 线程池模式:标记 busy,提交任务
|
||||
{
|
||||
std::lock_guard<std::mutex> blk(_busy_mtx);
|
||||
_busy.insert(fd);
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> plk(_pool_mtx);
|
||||
if (static_cast<int>(_pool_queue.size()) >= _config.max_queue_size) {
|
||||
// 队列已满:拒绝并断开连接(防 OOM)
|
||||
std::lock_guard<std::mutex> blk(_busy_mtx);
|
||||
_busy.erase(fd);
|
||||
_remove_client(fd);
|
||||
return;
|
||||
}
|
||||
_pool_queue.push(Task{fd, std::move(addr), std::move(message)});
|
||||
}
|
||||
_pool_cv.notify_one();
|
||||
}
|
||||
|
||||
void _reap_failed() {
|
||||
std::unordered_set<int> failed;
|
||||
{
|
||||
std::lock_guard<std::mutex> flk(_failed_mtx);
|
||||
failed.swap(_failed);
|
||||
}
|
||||
for (int fd : failed) {
|
||||
{
|
||||
std::lock_guard<std::mutex> blk(_busy_mtx);
|
||||
_busy.erase(fd);
|
||||
}
|
||||
_remove_client(fd);
|
||||
}
|
||||
}
|
||||
|
||||
void _remove_client(int fd) {
|
||||
std::lock_guard<std::mutex> clk(_clients_mtx);
|
||||
if (_clients.erase(fd) == 0) return; // 已被移除,防止 double-close
|
||||
_send_locks.erase(fd);
|
||||
::close(fd);
|
||||
}
|
||||
|
||||
void _close_all_clients() {
|
||||
std::lock_guard<std::mutex> clk(_clients_mtx);
|
||||
for (auto& [fd, addr] : _clients) {
|
||||
::close(fd);
|
||||
}
|
||||
_clients.clear();
|
||||
_send_locks.clear();
|
||||
std::lock_guard<std::mutex> blk(_busy_mtx);
|
||||
_busy.clear();
|
||||
std::lock_guard<std::mutex> flk(_failed_mtx);
|
||||
_failed.clear();
|
||||
}
|
||||
|
||||
// ---- 成员 --------------------------------------------------------------
|
||||
|
||||
std::string _addr;
|
||||
int _port;
|
||||
MsgServerConfig _config;
|
||||
int _server_fd;
|
||||
std::atomic<bool> _running;
|
||||
MessageCallback _on_message;
|
||||
|
||||
// 客户端管理
|
||||
mutable std::mutex _clients_mtx;
|
||||
std::unordered_map<int, std::string> _clients; ///< fd -> "ip:port"
|
||||
std::unordered_map<int, std::shared_ptr<std::mutex>> _send_locks; ///< fd -> 发送锁
|
||||
|
||||
// 同连接串行
|
||||
std::mutex _busy_mtx;
|
||||
std::unordered_set<int> _busy; ///< 回调执行中的 fd
|
||||
|
||||
// 失败连接(worker 线程标记,主循环清理)
|
||||
std::mutex _failed_mtx;
|
||||
std::unordered_set<int> _failed;
|
||||
|
||||
// 线程池
|
||||
std::atomic<bool> _pool_running{false};
|
||||
std::mutex _pool_mtx;
|
||||
std::condition_variable _pool_cv;
|
||||
std::queue<Task> _pool_queue;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
/** libmsg — UniServe 消息通信基础库
|
||||
*
|
||||
* 提供 pymsg 协议的 C++ 实现:4字节大端长度前缀 + UTF-8 载荷的 TCP 消息帧协议。
|
||||
* 对应 Python 版 pymsg/pymsg/__init__.py,字节级兼容。
|
||||
*
|
||||
* 命名空间 pmc::net,库文件 libus_msg.so,头文件安装至 /usr/include/us/。
|
||||
*/
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#define MSG_CHECK(condition, message) \
|
||||
if (!(condition)) { \
|
||||
throw std::runtime_error(message); \
|
||||
}
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
/// 协议常量
|
||||
namespace msg_constants {
|
||||
constexpr size_t kHeaderSize = 4; ///< 帧头长度(字节)
|
||||
constexpr uint32_t kMaxMsgSize = 64u * 1024u * 1024u; ///< 消息最大长度 64 MiB
|
||||
constexpr int kDefaultTimeout = 10; ///< 默认超时(秒)
|
||||
constexpr int kSendTimeout = 3; ///< send() 默认超时(秒)
|
||||
constexpr int kListenPollMs = 100'000; ///< listen() select 轮询间隔(微秒)
|
||||
constexpr double kRetryLogEvery = 5.0; ///< 重试等待日志间隔(秒)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 异常层次
|
||||
// ============================================================================
|
||||
|
||||
/// 消息库基础异常
|
||||
class MsgException : public std::runtime_error {
|
||||
public:
|
||||
explicit MsgException(const std::string &msg) : std::runtime_error(msg) {}
|
||||
};
|
||||
|
||||
/// 消息收发超时
|
||||
class MsgTimeoutException : public MsgException {
|
||||
public:
|
||||
explicit MsgTimeoutException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
/// 连接异常(断开、重置、地址错误等)
|
||||
class MsgConnectionException : public MsgException {
|
||||
public:
|
||||
explicit MsgConnectionException(const std::string &msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
/// 目标服务未启动(ECONNREFUSED),用于 retry_on_refused 重试机制
|
||||
class MsgConnectionRefusedException : public MsgConnectionException {
|
||||
public:
|
||||
explicit MsgConnectionRefusedException(const std::string &msg)
|
||||
: MsgConnectionException(msg) {}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 基类(供 MsgClient 等内部工具方法继承使用)
|
||||
// ============================================================================
|
||||
|
||||
class Msg_Basic {
|
||||
public:
|
||||
class MsgExp : public std::runtime_error {
|
||||
public:
|
||||
MsgExp(const std::string& msg) : std::runtime_error(msg) {}
|
||||
};
|
||||
protected:
|
||||
/// 版本字符串(编译进 .so)
|
||||
static const char* version();
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,181 @@
|
||||
#pragma once
|
||||
|
||||
/** RpcClient — pymsg 线协议之上的 JSON RPC 客户端(C++ 版,对应 Python 版 pymsg.rpc.RpcClient)
|
||||
*
|
||||
* 约定(与 Python/Go 客户端字节级兼容):
|
||||
* 请求: {"_route": "<路由名>", ...业务字段}
|
||||
* 响应: {"success": true, "data": ...}
|
||||
* {"success": false, "error": "..."}
|
||||
*
|
||||
* 基于 boost::json 进行 JSON 序列化/反序列化,依赖 MsgClient 进行 TCP 通信。
|
||||
*
|
||||
* 注意:此头文件依赖 boost::json,核心 libmsg 库(Msg.hpp/MsgServer.hpp/AsyncQueue.hpp)
|
||||
* 保持零外部依赖不变。使用 RpcClient 时需要链接 -lboost_json。
|
||||
*
|
||||
* Usage:
|
||||
* #include <us/RpcClient.hpp>
|
||||
*
|
||||
* pmc::net::RpcClient rpc("127.0.0.1", 8005, "gateway");
|
||||
* json::object params;
|
||||
* params["action"] = "select";
|
||||
* params["table"] = "Users";
|
||||
* json::value data = rpc.call("execute", params); // 返回 data 字段
|
||||
*
|
||||
* // 启动等待模式(db 未启动时阻塞重试)
|
||||
* pmc::net::RpcClient rpc("127.0.0.1", 8005, "gateway", 10.0, true);
|
||||
*/
|
||||
|
||||
#include <us/Msg.hpp>
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
namespace json = boost::json;
|
||||
|
||||
// ============================================================================
|
||||
// RpcError — RPC 业务异常
|
||||
// ============================================================================
|
||||
|
||||
/// 远端返回 success=false 的业务错误,或响应不符合 RPC 约定(非 JSON / 缺少 success 字段)。
|
||||
/// 网络层异常(超时、连接拒绝等)仍抛 MsgTimeoutException / MsgConnectionRefusedException 等。
|
||||
class RpcError : public MsgException {
|
||||
public:
|
||||
explicit RpcError(const std::string& msg) : MsgException(msg) {}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// RpcClient
|
||||
// ============================================================================
|
||||
|
||||
/// JSON RPC 客户端(短连接,每次调用独立 TCP 连接)。
|
||||
class RpcClient {
|
||||
public:
|
||||
/** 构造 RPC 客户端。
|
||||
*
|
||||
* @param addr 服务地址(通常 127.0.0.1)
|
||||
* @param port 服务端口
|
||||
* @param name 客户端名称(用于调试/日志)
|
||||
* @param timeout 默认超时秒数
|
||||
* @param retry_on_refused 默认是否在 ECONNREFUSED 时阻塞重试
|
||||
* @param retry_interval 重试间隔秒数
|
||||
*/
|
||||
explicit RpcClient(const std::string& addr, int port,
|
||||
const std::string& name = "rpc",
|
||||
double timeout = 10.0,
|
||||
bool retry_on_refused = false,
|
||||
double retry_interval = 1.0)
|
||||
: _addr(addr), _port(port), _name(name),
|
||||
_timeout(timeout),
|
||||
_retry_on_refused(retry_on_refused),
|
||||
_retry_interval(retry_interval) {}
|
||||
|
||||
// ---- RPC 调用 ----------------------------------------------------------
|
||||
|
||||
/** 调用指定路由,返回响应中的 data 字段。
|
||||
*
|
||||
* @param route 路由名(自动注入为 _route 字段)
|
||||
* @param params 业务参数(_route 会被覆盖)
|
||||
* @param timeout 本次调用超时(nullopt = 使用构造时默认值)
|
||||
* @param retry_on_refused 本次调用是否重试连接拒绝(nullopt = 使用构造时默认值)
|
||||
* @return 服务端返回的 data 字段值(可能为 null)
|
||||
* @throws RpcError 服务端返回 success=false,或响应非法
|
||||
* @throws MsgTimeoutException 网络超时
|
||||
* @throws MsgConnectionRefusedException 目标不可达且未重试
|
||||
* @throws MsgConnectionException 连接中断
|
||||
*/
|
||||
json::value call(const std::string& route,
|
||||
const json::object& params = {},
|
||||
std::optional<double> timeout = std::nullopt,
|
||||
std::optional<bool> retry_on_refused = std::nullopt,
|
||||
std::optional<double> retry_interval = std::nullopt) {
|
||||
json::object req = params;
|
||||
req["_route"] = route;
|
||||
|
||||
double t = timeout.value_or(_timeout);
|
||||
bool retry = retry_on_refused.value_or(_retry_on_refused);
|
||||
double interval = retry_interval.value_or(_retry_interval);
|
||||
|
||||
MsgClient client(_addr, _port, _name);
|
||||
std::string resp_str = client.call(json::serialize(req), t, retry, interval);
|
||||
|
||||
json::value resp;
|
||||
try {
|
||||
resp = json::parse(resp_str);
|
||||
} catch (const std::exception& e) {
|
||||
throw RpcError("响应不是合法 JSON: " + std::string(e.what()));
|
||||
}
|
||||
|
||||
if (!resp.is_object()) {
|
||||
throw RpcError("非法 RPC 响应(不是 object): " + resp_str.substr(0, 200));
|
||||
}
|
||||
const json::object& obj = resp.as_object();
|
||||
if (!obj.contains("success")) {
|
||||
throw RpcError("非法 RPC 响应(缺少 success 字段): " + resp_str.substr(0, 200));
|
||||
}
|
||||
if (!obj.at("success").as_bool()) {
|
||||
std::string err = "未知错误";
|
||||
if (auto it = obj.find("error"); it != obj.end() && it->value().is_string()) {
|
||||
err = it->value().as_string().c_str();
|
||||
}
|
||||
throw RpcError(err);
|
||||
}
|
||||
if (auto it = obj.find("data"); it != obj.end()) {
|
||||
return it->value();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
/** 调用指定路由,返回完整响应对象(包含 success/data/error),不抛 RpcError。
|
||||
* 适用于需要自行检查 success 字段或访问额外字段的场景。
|
||||
*/
|
||||
json::object call_raw(const std::string& route,
|
||||
const json::object& params = {},
|
||||
std::optional<double> timeout = std::nullopt,
|
||||
std::optional<bool> retry_on_refused = std::nullopt,
|
||||
std::optional<double> retry_interval = std::nullopt) {
|
||||
json::object req = params;
|
||||
req["_route"] = route;
|
||||
|
||||
double t = timeout.value_or(_timeout);
|
||||
bool retry = retry_on_refused.value_or(_retry_on_refused);
|
||||
double interval = retry_interval.value_or(_retry_interval);
|
||||
|
||||
MsgClient client(_addr, _port, _name);
|
||||
std::string resp_str = client.call(json::serialize(req), t, retry, interval);
|
||||
|
||||
json::value resp;
|
||||
try {
|
||||
resp = json::parse(resp_str);
|
||||
} catch (const std::exception& e) {
|
||||
throw RpcError("响应不是合法 JSON: " + std::string(e.what()));
|
||||
}
|
||||
if (!resp.is_object()) {
|
||||
throw RpcError("非法 RPC 响应(不是 object): " + resp_str.substr(0, 200));
|
||||
}
|
||||
return resp.as_object();
|
||||
}
|
||||
|
||||
// ---- 访问器 ------------------------------------------------------------
|
||||
|
||||
const std::string& addr() const noexcept { return _addr; }
|
||||
int port() const noexcept { return _port; }
|
||||
const std::string& name() const noexcept { return _name; }
|
||||
|
||||
private:
|
||||
std::string _addr;
|
||||
int _port;
|
||||
std::string _name;
|
||||
double _timeout;
|
||||
bool _retry_on_refused;
|
||||
double _retry_interval;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,199 @@
|
||||
#pragma once
|
||||
|
||||
/** RpcServer — pymsg 线协议之上的 JSON RPC 服务端(C++ 版,对应 Python 版 pymsg.rpc.RpcServer)
|
||||
*
|
||||
* 约定(与 Python/Go 客户端字节级兼容):
|
||||
* 请求: {"_route": "<路由名>", ...业务字段}
|
||||
* 响应: {"success": true, "data": ...}
|
||||
* {"success": false, "error": "..."}
|
||||
*
|
||||
* 基于 MsgServer 提供路由分发、JSON 解析、响应自动包装、异常脱敏等功能。
|
||||
* handler 签名为 ``json::value(const json::object& req)``:
|
||||
* - 返回值自动包装为 {"success": true, "data": ...}
|
||||
* - 抛出的异常自动包装为 {"success": false, "error": "<脱敏后错误信息>"}
|
||||
* - 服务不会因 handler 异常而崩溃
|
||||
*
|
||||
* 注意:此头文件依赖 boost::json,核心 libmsg 库保持零外部依赖不变。
|
||||
* 使用 RpcServer 时需要链接 -lboost_json。
|
||||
*
|
||||
* Usage:
|
||||
* #include <us/RpcServer.hpp>
|
||||
*
|
||||
* pmc::net::RpcServer rpc("127.0.0.1", 8005, {.workers=4});
|
||||
*
|
||||
* rpc.add_route("ping", [](const json::object&) -> json::value {
|
||||
* return "pong";
|
||||
* });
|
||||
*
|
||||
* rpc.add_route("execute", [](const json::object& req) -> json::value {
|
||||
* // 处理业务逻辑...
|
||||
* return json::object{{"rows", json::array{}}};
|
||||
* });
|
||||
*
|
||||
* rpc.start(); // 阻塞当前线程
|
||||
*/
|
||||
|
||||
#include <us/MsgServer.hpp>
|
||||
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <regex>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
namespace json = boost::json;
|
||||
|
||||
// ============================================================================
|
||||
// RpcServer
|
||||
// ============================================================================
|
||||
|
||||
/// JSON RPC 服务端:基于 MsgServer,自带路由分发与响应包装。
|
||||
class RpcServer {
|
||||
public:
|
||||
/// 路由处理函数签名:接收请求参数 object,返回 data 值(任意 JSON 类型)。
|
||||
using Handler = std::function<json::value(const json::object& req)>;
|
||||
|
||||
/// 错误回调:void(route_name, exception),handler 抛异常时调用(用于记日志)。
|
||||
using ErrorCallback = std::function<void(const std::string& route, const std::exception& e)>;
|
||||
|
||||
/** 构造 RPC 服务端。
|
||||
*
|
||||
* @param addr 监听地址
|
||||
* @param port 监听端口
|
||||
* @param config MsgServer 配置(线程池/超时/连接数限制等)
|
||||
* @param on_error 可选错误回调,handler 抛异常时被调用
|
||||
*/
|
||||
explicit RpcServer(const std::string& addr, int port,
|
||||
MsgServerConfig config = {},
|
||||
ErrorCallback on_error = {})
|
||||
: _server(addr, port, config), _on_error(std::move(on_error)) {}
|
||||
|
||||
// ---- 路由注册 ----------------------------------------------------------
|
||||
|
||||
/** 注册路由处理函数。
|
||||
*
|
||||
* @param name 路由名(对应请求中的 _route 字段)
|
||||
* @param handler 处理函数,签名 json::value(const json::object& req)
|
||||
*/
|
||||
void add_route(const std::string& name, Handler handler) {
|
||||
_routes[name] = std::move(handler);
|
||||
}
|
||||
|
||||
// ---- 启动 / 停止 -------------------------------------------------------
|
||||
|
||||
/// 启动服务(阻塞当前线程)。
|
||||
void start() {
|
||||
_server.start([this](int fd, const std::string& addr, const std::string& msg) {
|
||||
this->_dispatch(fd, addr, msg);
|
||||
});
|
||||
}
|
||||
|
||||
/// 从另一线程停止服务。
|
||||
void stop() {
|
||||
_server.stop();
|
||||
}
|
||||
|
||||
/// 获取底层 MsgServer 引用(可用于 broadcast 等高级操作)。
|
||||
MsgServer& server() noexcept { return _server; }
|
||||
|
||||
private:
|
||||
// ---- 分发与响应包装 ----------------------------------------------------
|
||||
|
||||
void _dispatch(int fd, const std::string& /*addr*/, const std::string& text) {
|
||||
// 解析 JSON(限制嵌套深度防止栈溢出)
|
||||
json::parse_options opts;
|
||||
opts.max_depth = 32; // 与 Python 版一致
|
||||
|
||||
boost::system::error_code ec;
|
||||
json::value req_val = json::parse(text, ec, {}, opts);
|
||||
if (ec || !req_val.is_object()) {
|
||||
_reply_error(fd, "invalid JSON request");
|
||||
return;
|
||||
}
|
||||
|
||||
json::object req = req_val.as_object();
|
||||
|
||||
// 提取 _route
|
||||
std::string route;
|
||||
if (auto it = req.find("_route"); it != req.end() && it->value().is_string()) {
|
||||
route = it->value().as_string().c_str();
|
||||
req.erase(it); // 从业务参数中移除 _route
|
||||
} else {
|
||||
_reply_error(fd, "missing _route");
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找 handler
|
||||
auto handler_it = _routes.find(route);
|
||||
if (handler_it == _routes.end()) {
|
||||
_reply_error(fd, "unknown route: " + route);
|
||||
return;
|
||||
}
|
||||
|
||||
// 执行 handler
|
||||
try {
|
||||
json::value result = handler_it->second(req);
|
||||
_reply_success(fd, result);
|
||||
} catch (const std::exception& e) {
|
||||
if (_on_error) {
|
||||
try {
|
||||
_on_error(route, e);
|
||||
} catch (...) {}
|
||||
}
|
||||
// 脱敏后返回:保留业务错误语义,过滤绝对路径等内部细节
|
||||
try {
|
||||
_reply_error(fd, _sanitize_error(e.what()));
|
||||
} catch (...) {
|
||||
// _sanitize_error 或序列化失败时,使用安全的固定文案
|
||||
try { _reply_error(fd, "internal error"); } catch (...) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _reply_success(int fd, const json::value& data) {
|
||||
json::object resp;
|
||||
resp["success"] = true;
|
||||
resp["data"] = data;
|
||||
_server.reply(fd, json::serialize(resp));
|
||||
}
|
||||
|
||||
void _reply_error(int fd, const std::string& error) {
|
||||
json::object resp;
|
||||
resp["success"] = false;
|
||||
resp["error"] = error;
|
||||
_server.reply(fd, json::serialize(resp));
|
||||
}
|
||||
|
||||
// ---- 错误信息脱敏 ------------------------------------------------------
|
||||
// 匹配绝对路径(如 /opt/micro_servers/db/xxx.py 或 /usr/lib/...),替换为 <redacted>
|
||||
// 不返回函数堆栈、SQL、文件路径等内部细节给客户端;完整异常由 on_error 钩子记录到服务端日志。
|
||||
|
||||
static std::string _sanitize_error(const std::string& msg) {
|
||||
if (msg.empty()) return msg;
|
||||
try {
|
||||
// 匹配以 / 开头的绝对路径段,替换为 <redacted>
|
||||
// 不返回函数堆栈、SQL、文件路径等内部细节给客户端
|
||||
static const std::regex path_re(R"(/[A-Za-z0-9_.\-]+(?:/[A-Za-z0-9_.\-]+)*)");
|
||||
return std::regex_replace(msg, path_re, "<redacted>");
|
||||
} catch (...) {
|
||||
return msg; // 正则异常时返回原文
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 成员 --------------------------------------------------------------
|
||||
|
||||
MsgServer _server;
|
||||
std::unordered_map<std::string, Handler> _routes;
|
||||
ErrorCallback _on_error;
|
||||
};
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -0,0 +1,11 @@
|
||||
#include "us/Msg_Basic.hpp"
|
||||
|
||||
namespace pmc {
|
||||
namespace net {
|
||||
|
||||
const char* Msg_Basic::version() {
|
||||
return "libus_msg 0.0.1";
|
||||
}
|
||||
|
||||
} // namespace net
|
||||
} // namespace pmc
|
||||
@@ -194,6 +194,10 @@ int main() {
|
||||
- `Activate()` 不可重复调用(内部有空指针检查),但不会报错,静默返回。
|
||||
- 析构函数为虚函数,允许安全的多态删除。
|
||||
|
||||
## 安全审计
|
||||
|
||||
本模块安全审计文档见 `../docs/security/libth_SECURITY_AUDIT_20260804.md`,包含已知设计缺陷与修复路线图。
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
+1
-2
@@ -144,7 +144,6 @@ python3 smtp_v2.py --addr 127.0.0.1 --port 8008
|
||||
```
|
||||
smtp/
|
||||
├── smtp_v2.py # 服务主程序(pymsg 协议,当前有效版本)
|
||||
├── smtp.py # 旧版 Flask HTTP 实现(已废弃,仅作参考)
|
||||
├── log_client.py # 集中日志推送 SDK
|
||||
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
|
||||
├── _run.sh # 启动脚本(exec python3 smtp_v2.py)
|
||||
@@ -156,7 +155,7 @@ smtp/
|
||||
|
||||
- 验证码存储在进程内存中,服务重启后全部失效;不支持多实例水平扩展。
|
||||
- 默认仅监听 `127.0.0.1`,仅供本机调用;浏览器侧不直接访问本服务。
|
||||
- 旧版 `smtp.py`(Flask HTTP 实现)已废弃,`_run.sh` 当前启动的是 `smtp_v2.py`。旧版存在无冷却、无失败回滚、无防枚举等安全问题,且 `verify_email` 在 email 不存在时有 KeyError 风险,不应在生产环境使用。
|
||||
- 旧版 `smtp.py`(Flask HTTP 实现)因存在无冷却、无失败回滚、无防枚举、`verify_email` 在 email 不存在时 KeyError、`server.quit()` NameError 等安全问题,已**删除下线**。`_run.sh` 当前启动的是 `smtp_v2.py`。
|
||||
- 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,`MAIL_FROM` 仅当服务商允许自定义发件身份时才起作用。
|
||||
- 验证码为纯 6 位数字,有效期 600 秒。
|
||||
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from email.mime.text import MIMEText
|
||||
from email import header
|
||||
from log_client import logs
|
||||
from dotenv import load_dotenv
|
||||
import waitress
|
||||
import smtplib
|
||||
import random
|
||||
import time
|
||||
import sys, os
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
|
||||
app = Flask(__name__)
|
||||
captchas: dict[str, list[str, str]] = {}
|
||||
|
||||
@app.route("/send_email", methods=["POST"])
|
||||
|
||||
def send_email():
|
||||
def build_captcha() -> str:
|
||||
captcha: str = ""
|
||||
for i in range(0,6):
|
||||
digit: int = random.randint(0,9)
|
||||
captcha += str(digit)
|
||||
return captcha
|
||||
|
||||
data = request.get_json()
|
||||
|
||||
recipient: str = data.get("email")
|
||||
subject: str = "DNFS 邮箱验证"
|
||||
captcha: str = build_captcha()
|
||||
content: str = f"DNFS: 您的邮箱验证码为 {captcha},一分钟内有效。"
|
||||
|
||||
captchas[recipient] = ["", ""]
|
||||
captchas[recipient][0] = captcha
|
||||
captchas[recipient][1] = str(int(time.time())+60)
|
||||
|
||||
message = MIMEText(content, "plain", "UTF-8")
|
||||
message["From"] = "dnfs@qsont.me"
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.login(SMTP_USER, __PASSWORD)
|
||||
server.sendmail(SMTP_USER, [recipient], message.as_string())
|
||||
logs.info("smtp", f"{recipient}: 验证码发送成功({captcha})")
|
||||
return jsonify({"success": "验证码发送成功"}), 200
|
||||
|
||||
except Exception as err:
|
||||
logs.error("smtp", f"{recipient}: 验证码发送失败({err})")
|
||||
return jsonify({"error": f"验证码发送失败:{err}"}), 400
|
||||
|
||||
finally:
|
||||
server.quit()
|
||||
|
||||
|
||||
@app.route("/verify_email", methods=["POST"])
|
||||
|
||||
def verify_email():
|
||||
data = request.get_json()
|
||||
|
||||
for i in list(captchas.keys()):
|
||||
if int(captchas[i][1]) < int(time.time()):
|
||||
del captchas[i]
|
||||
|
||||
email: str = data.get("email")
|
||||
input_captcha: str = data.get("input_captcha")
|
||||
|
||||
if captchas[email][0] == input_captcha:
|
||||
logs.info("smtp", f"{email}: 验证通过")
|
||||
return jsonify({"success": "验证通过"}), 200
|
||||
else:
|
||||
logs.info("smtp", f"{email}: 验证失败")
|
||||
return jsonify({"error": "验证失败"}), 400
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logs.info("smtp", "开始监听http://127.0.0.1:8008")
|
||||
print("Listen at http://127.0.0.1:8008")
|
||||
waitress.serve(app, host="127.0.0.1", port=8008)
|
||||
+7
-11
@@ -49,18 +49,12 @@ static :9200 (Flask + CORS)
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/hash` | SHA256 密码哈希 |
|
||||
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
|
||||
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
|
||||
| `POST /api/check_user` | 检查用户是否已注册(经 db:8005 服务 pymsg 调用查询) |
|
||||
| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) |
|
||||
|
||||
### yhdet OAuth 路由
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `GET/POST /yhdet_callback` | yhdet.top OAuth 回调,自动注册/登录并跳转 dnfs 页 |
|
||||
| `POST /api/yhdet_login` | yhdet OAuth 用户名密码登录 |
|
||||
| `POST /api/yhdet_verify` | yhdet Token 验证 |
|
||||
| `POST /api/proxy_send_code` | yhdet 验证码代理转发 |
|
||||
> 以下路由已在安全审计修复中**禁用**(源码以 `#` 注释,不再对外暴露):
|
||||
> - `POST /api/reset_password` — 无认证可接管任意账户(原 P0-1)
|
||||
> - `GET/POST /yhdet_callback`、`POST /api/yhdet_login`、`POST /api/yhdet_verify`、`POST /api/proxy_send_code` — yhdet OAuth 系列(原 P0-2/3/4、NEW-1/2)
|
||||
|
||||
### dnfs 代理路由
|
||||
|
||||
@@ -73,6 +67,8 @@ static :9200 (Flask + CORS)
|
||||
|
||||
以上 4 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
|
||||
|
||||
> **CORS 例外**:全局 CORS 使用 `[cors] allowed_origins` 白名单;但上述 4 个 dnfs 代理路由的 OPTIONS 预检由 `_cors()` 处理,返回 `Access-Control-Allow-Origin: *`(通配)。若需收紧,应按白名单改造 `_cors()`(安全审计 NEW-3 未修复)。
|
||||
|
||||
> `/whoami` 已上移至 gateway,由 gateway 直接验签 Token 返回用户身份,不再经 static 代理到 dnfs。
|
||||
|
||||
## 功能
|
||||
@@ -81,7 +77,7 @@ static :9200 (Flask + CORS)
|
||||
- 流媒体:视频(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` 强制
|
||||
- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 前端代码(完整 HTML/CSS/JS,可审计/可运行,注入当前页面可加特效)/ 图片(直显)/ 文件(下载);发布权限由 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 锚定)
|
||||
|
||||
+147
-40
@@ -25,8 +25,8 @@ body {
|
||||
transition: opacity 0.15s, transform 0.1s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.btn:active { transform: scale(0.96); }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-dark); opacity: 1; }
|
||||
.btn-primary { background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(232, 133, 58, 0.28); }
|
||||
.btn-primary:hover { background: var(--accent-dark); opacity: 1; box-shadow: 0 4px 14px rgba(232, 133, 58, 0.36); }
|
||||
.btn-ghost { background: var(--accent-soft); color: var(--accent-dark); }
|
||||
.btn-ghost:hover { border-color: var(--accent); opacity: 1; }
|
||||
.btn-lg { padding: 0.7rem 1.9rem; font-size: 1rem; border-radius: var(--radius-md); }
|
||||
@@ -61,7 +61,11 @@ body {
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s;
|
||||
@@ -95,7 +99,7 @@ body {
|
||||
.hero {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 4rem 1rem 3rem;
|
||||
padding: 6rem 1rem 5rem;
|
||||
}
|
||||
.hero::before {
|
||||
content: "";
|
||||
@@ -104,98 +108,154 @@ body {
|
||||
background: radial-gradient(ellipse 60% 50% at 50% 30%, var(--accent-glow), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero > * { position: relative; }
|
||||
.hero::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 52%;
|
||||
width: 640px;
|
||||
height: 640px;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(circle, var(--accent-glow), transparent 62%);
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero > * { position: relative; z-index: 1; }
|
||||
.hero-title {
|
||||
font-size: clamp(1.8rem, 5vw, 2.6rem);
|
||||
font-size: clamp(2rem, 6vw, 3rem);
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
line-height: 1.28;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.accent-text {
|
||||
background: linear-gradient(120deg, var(--accent) 20%, var(--accent-dark) 80%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.accent-text { color: var(--accent); }
|
||||
.hero-sub {
|
||||
font-size: 1.05rem;
|
||||
font-size: 1.08rem;
|
||||
color: var(--fg-secondary);
|
||||
max-width: 30rem;
|
||||
margin: 0 auto;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.75rem;
|
||||
gap: 0.85rem;
|
||||
margin-top: 2.1rem;
|
||||
}
|
||||
|
||||
/* ===== 特性卡片 ===== */
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
margin-top: 4rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.section-title::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 44px;
|
||||
height: 3px;
|
||||
margin: 0.6rem auto 0;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
gap: 0.9rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 1.1rem;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
margin: 1.75rem auto 0;
|
||||
}
|
||||
.feature-card {
|
||||
position: relative;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.4rem 1.25rem;
|
||||
padding: 1.7rem 1.4rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: transform 0.18s, box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
.feature-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.feature-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--border);
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--accent);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.feature-card:hover::before { opacity: 0.9; }
|
||||
.feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin-bottom: 0.9rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent-soft);
|
||||
background: linear-gradient(135deg, var(--accent-soft), rgba(232, 133, 58, 0.16));
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.feature-icon svg { width: 20px; height: 20px; }
|
||||
.feature-card h3 { margin-bottom: 0.35rem; }
|
||||
.feature-icon svg { width: 22px; height: 22px; }
|
||||
.feature-card h3 { margin-bottom: 0.4rem; font-size: 1.05rem; }
|
||||
.feature-card p {
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.65;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.7;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
|
||||
/* ===== 关于本站 ===== */
|
||||
.about {
|
||||
text-align: center;
|
||||
padding-bottom: 2rem;
|
||||
padding: 1rem 1rem 3rem;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.about .site-intro {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 1.75rem 1.9rem;
|
||||
}
|
||||
.site-intro {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.9;
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.95;
|
||||
color: var(--fg-secondary);
|
||||
}
|
||||
.quick-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.25rem;
|
||||
margin-top: 1.5rem;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
.quick-links a {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
padding-bottom: 1px;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.quick-links a:hover { color: var(--accent); }
|
||||
.quick-links a:hover { color: var(--accent); border-bottom-color: var(--accent); }
|
||||
|
||||
/* ===== 入场动画 ===== */
|
||||
@keyframes fade-up {
|
||||
@@ -212,12 +272,59 @@ body {
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
/* 触屏设备:加大可点目标 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn { min-height: 44px; }
|
||||
.quick-links a { padding: 0.5rem 0.25rem; }
|
||||
}
|
||||
|
||||
/* 平板及以下:收敛首屏光晕与留白 */
|
||||
@media (max-width: 768px) {
|
||||
body { padding: 1.25rem 0.75rem; }
|
||||
.hero { padding: 4rem 1.25rem 3.5rem; }
|
||||
.hero::after { width: 420px; height: 420px; opacity: 0.4; }
|
||||
.hero-title { letter-spacing: 0.01em; }
|
||||
.features { gap: 0.9rem; }
|
||||
.feature-card { padding: 1.4rem 1.2rem; }
|
||||
}
|
||||
|
||||
/* 手机:全宽布局、紧凑排版 */
|
||||
@media (max-width: 480px) {
|
||||
.header { top: 0.5rem; padding: 0.45rem 0.75rem; }
|
||||
body { padding: 1rem 0.75rem; }
|
||||
.header {
|
||||
top: 0.5rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.header h1 { font-size: 1rem; }
|
||||
.header h1 svg { display: none; } /* 小屏隐藏 logo 图标,避免挤压 */
|
||||
.right-group { gap: 0.35rem; }
|
||||
.right-group .btn { padding: 0.5rem 0.95rem; font-size: 0.82rem; }
|
||||
#name { max-width: 72px; }
|
||||
|
||||
.hero { padding: 3rem 0.75rem 2.5rem; }
|
||||
.hero::after { width: 300px; height: 300px; opacity: 0.32; }
|
||||
.hero-title { margin-bottom: 0.75rem; }
|
||||
.hero-sub { font-size: 0.95rem; }
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
.hero-actions .btn-lg { width: 100%; padding: 0.85rem 1.5rem; }
|
||||
|
||||
.section-title { margin-top: 2.5rem; font-size: 1.3rem; }
|
||||
.features { margin-top: 1.25rem; grid-template-columns: 1fr; }
|
||||
.feature-card:hover { transform: none; } /* 触屏取消悬浮位移 */
|
||||
|
||||
.about { padding: 0.5rem 0.5rem 2.5rem; }
|
||||
.about .site-intro { padding: 1.4rem 1.3rem; text-align: left; }
|
||||
.quick-links { gap: 1rem 1.5rem; margin-top: 1.5rem; }
|
||||
}
|
||||
|
||||
/* 超小屏 */
|
||||
@media (max-width: 360px) {
|
||||
.hero { padding: 2.5rem 0.5rem 2rem; }
|
||||
.hero-sub { font-size: 0.95rem; }
|
||||
.hero-actions { flex-direction: column; align-items: stretch; }
|
||||
.section-title { margin-top: 2rem; }
|
||||
.header h1 { font-size: 0.95rem; }
|
||||
.right-group .btn { padding: 0.45rem 0.8rem; font-size: 0.8rem; }
|
||||
}
|
||||
@@ -238,6 +238,69 @@ input[type="button"]:disabled {
|
||||
.article-stats span { font-weight: 600; color: var(--fg-secondary); }
|
||||
#article-content { font-size: 0.95rem; line-height: 1.7; margin-bottom: 1.25rem; }
|
||||
|
||||
/* ===== Search Box (dnfs 顶部搜索) ===== */
|
||||
.searchbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.55rem 0.45rem 0.9rem;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 1rem;
|
||||
transition: border-color 0.2s, box-shadow 0.2s, background 0.2s;
|
||||
}
|
||||
.searchbox:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow), var(--shadow-md);
|
||||
}
|
||||
.searchbox-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.searchbox:focus-within .searchbox-icon {
|
||||
color: var(--accent);
|
||||
}
|
||||
.searchbox-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0.45rem 0.25rem !important;
|
||||
font-size: 0.92rem !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
.searchbox-input:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.searchbox-btn {
|
||||
flex: none;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.85rem !important;
|
||||
font-weight: 600;
|
||||
background: var(--accent) !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, transform 0.1s, background 0.2s;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.searchbox-btn:hover { opacity: 0.88; }
|
||||
.searchbox-btn:active { transform: scale(0.96); }
|
||||
@media (max-width: 480px) {
|
||||
.searchbox { padding: 0.35rem 0.4rem 0.35rem 0.75rem; gap: 0.35rem; }
|
||||
.searchbox-icon { width: 16px; height: 16px; }
|
||||
.searchbox-input { font-size: 0.88rem !important; }
|
||||
.searchbox-btn { padding: 0.45rem 0.75rem !important; font-size: 0.82rem !important; }
|
||||
}
|
||||
|
||||
/* ===== Recommend List (dnfs) ===== */
|
||||
/* 卡片尺寸随内容自适应:宽度包裹内容(fit-content),上限为容器宽度;高度天然随内容伸缩 */
|
||||
.recommend-list {
|
||||
@@ -261,14 +324,15 @@ input[type="button"]:disabled {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.recommend-list center {
|
||||
cursor: pointer;
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.recommend-list center:hover { background: var(--accent-soft); }
|
||||
.recommend-list center:hover h3 { color: var(--accent); }
|
||||
.recommend-list .card h3 { transition: color 0.15s; }
|
||||
/* 仅标题可点击进入子节点:手型光标 + hover 高亮集中在标题 h3 上 */
|
||||
.recommend-list .card h3 {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.recommend-list .card h3:hover { color: var(--accent); }
|
||||
.recommend-list .card p { font-size: 0.9rem; line-height: 1.6; color: var(--fg-secondary); }
|
||||
|
||||
/* ===== Owner Chip (dnfs 发帖用户) ===== */
|
||||
@@ -396,7 +460,7 @@ input[type="button"]:disabled {
|
||||
.userpanel {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
top: 4.5rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -526,6 +590,26 @@ input[type="button"]:disabled {
|
||||
word-break: break-all;
|
||||
}
|
||||
.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); }
|
||||
.sp-run {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.sp-log {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
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-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; }
|
||||
|
||||
@@ -10,9 +10,13 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div id="searchbox">
|
||||
<input type="text" placeholder="输入你想搜索的内容......" id="searchtext" style="display: inline-block; width: 89%;">
|
||||
<input type="submit" value="搜索" id="searchsubmit" style="display: inline-block;">
|
||||
<div id="searchbox" class="searchbox">
|
||||
<svg class="searchbox-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="在当前节点内搜索..." id="searchtext" class="searchbox-input" autocomplete="off">
|
||||
<button type="button" id="searchsubmit" class="searchbox-btn" aria-label="搜索">搜索</button>
|
||||
</div>
|
||||
<div id="userpanel" class="userpanel"></div>
|
||||
<div id="minimap" class="minimap"></div>
|
||||
@@ -27,7 +31,7 @@
|
||||
<select id="sp-type">
|
||||
<option value="text">普通文本</option>
|
||||
<option value="link">链接(自动显示站点图标)</option>
|
||||
<option value="html">网页代码(可审计/可运行)</option>
|
||||
<option value="js">前端代码(可审计/可运行)</option>
|
||||
<option value="image">图片(自动显示)</option>
|
||||
<option value="file">文件(可下载)</option>
|
||||
</select>
|
||||
|
||||
@@ -63,11 +63,11 @@ let cooldownTimer = null; // 重发倒计时(与服务端 smtp 同邮箱 60s
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha() {
|
||||
async function refreshCaptcha(preserveInput) {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
captchaInput.value = ''; // 开始刷新时清空输入,避免用户误用旧验证码
|
||||
if (!preserveInput) captchaInput.value = ''; // 默认清空输入;preserveInput=true 时保留(获取验证码后)
|
||||
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
@@ -267,8 +267,9 @@ getCodeButton.addEventListener('click', async function () {
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新
|
||||
refreshCaptcha();
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新图形验证码;
|
||||
// 刷新时保留已输入的图形验证码答案,避免误清空用户输入
|
||||
refreshCaptcha(true);
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<h1 id="logo">DNFS 树状笔记</h1>
|
||||
<h1 id="logo">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px;color:var(--accent)"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
|
||||
DNFS 树状笔记
|
||||
</h1>
|
||||
<nav class="right-group" aria-label="用户操作">
|
||||
<img src="favicon.ico" alt="用户头像" id="avatar" style="display:none">
|
||||
<b id="name" style="display:none"></b>
|
||||
|
||||
@@ -44,6 +44,21 @@ let ws = null;
|
||||
let manualClose = false; /* 手动断开则抑制自动重连 */
|
||||
let reconnectDelay = RECONNECT_MIN;
|
||||
let reconnectTimer = null;
|
||||
let heartbeatTimer = null; /* 心跳定时器,25s 发送 ping 保持 WS 连接 */
|
||||
const HEARTBEAT_INTERVAL = 25000; /* 心跳间隔(< gateway 30s 空闲超时) */
|
||||
|
||||
function startHeartbeat() {
|
||||
stopHeartbeat();
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL);
|
||||
}
|
||||
|
||||
function stopHeartbeat() {
|
||||
if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; }
|
||||
}
|
||||
|
||||
/* 流式状态:不预创建空气泡,第一个 chunk 到来才挂载(保证思考区必然在正式回答之前) */
|
||||
let aiBubble = null, aiText = '', aiActive = false;
|
||||
@@ -204,6 +219,7 @@ function connect() {
|
||||
setStatus('connected', '已连接');
|
||||
connBtn.textContent = '断开';
|
||||
connBtn.classList.add('on');
|
||||
startHeartbeat();
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let data;
|
||||
@@ -212,6 +228,7 @@ function connect() {
|
||||
routeMessage(data);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
stopHeartbeat();
|
||||
setStatus('disconnected', '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
connBtn.classList.remove('on');
|
||||
@@ -235,6 +252,7 @@ function scheduleReconnect() {
|
||||
function disconnect(manual) {
|
||||
manualClose = manual !== false;
|
||||
clearTimeout(reconnectTimer);
|
||||
stopHeartbeat();
|
||||
if (ws) { ws.close(); ws = null; }
|
||||
setStatus('disconnected', manualClose ? '已手动断开' : '已断开');
|
||||
connBtn.textContent = '连接';
|
||||
|
||||
+181
-29
@@ -430,7 +430,7 @@ function renderMarkdown(element, content) {
|
||||
}
|
||||
|
||||
/* ===== by qing — 特殊节点(管理员专属)=====
|
||||
* 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。
|
||||
* 信封:content 为 {"_sp":"link|js|image|file", ...} JSON,普通内容走 Markdown。
|
||||
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
|
||||
|
||||
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
|
||||
@@ -445,7 +445,7 @@ function parseSp(content) {
|
||||
} catch (e) {
|
||||
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
|
||||
}
|
||||
if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
|
||||
if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ function renderContent(container, item) {
|
||||
}
|
||||
}
|
||||
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
|
||||
else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote);
|
||||
else if (sp._sp === 'js') renderSpJs(container, sp, appendNote);
|
||||
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
|
||||
else if (sp._sp === 'file') renderSpFile(container, sp, appendNote);
|
||||
}
|
||||
@@ -492,39 +492,83 @@ function renderSpLink(container, sp, appendNote) {
|
||||
appendNote();
|
||||
}
|
||||
|
||||
/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */
|
||||
function renderSpHtml(container, sp, appendNote) {
|
||||
/* 前端代码节点:含前端内容的节点在渲染到当前页面时自动执行(隔离 iframe 沙箱)。
|
||||
* 采用 iframe 沙箱而非注入父页面全局作用域,原因:
|
||||
* - 特效脚本运行在独立 iframe 内,无法访问父页面 DOM/JS,任何报错、死循环、变量覆盖
|
||||
* 都不会污染父页面,从而保证"运行特效后卡片点击/节点导航依然正常";
|
||||
* - iframe 元素挂载在节点容器内部(DOM 归属该节点),但 keep 全屏 fixed 定位,视觉特效悬浮覆盖整个页面;
|
||||
* pointer-events:none 不拦截父页面点击,不影响卡片点击/节点导航;
|
||||
* - 不使用 new Function/eval(CSP 未开 unsafe-eval),srcdoc 内联脚本由 CSP 'unsafe-inline' 放行。
|
||||
* 仅管理员可发布(后端强制)。 */
|
||||
/* 无特效模式:true 为关闭自动执行(用户面板开关控制,localStorage 持久化) */
|
||||
var sp_effects_disabled = (function() {
|
||||
try { return localStorage.getItem('sp_effects_disabled') === '1'; } catch (e) { return false; }
|
||||
})();
|
||||
/* 清除当前页面已运行的全部特效沙箱(进入新节点 / 关闭特效时调用) */
|
||||
function clearAllEffects() {
|
||||
document.querySelectorAll('.sp-effect-frame').forEach(function(f) { f.remove(); });
|
||||
}
|
||||
function renderSpJs(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 toggle_btn = document.createElement('button');
|
||||
toggle_btn.className = 'act-btn act-move';
|
||||
toggle_btn.textContent = '停止特效';
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'sp-code';
|
||||
pre.style.display = 'none';
|
||||
pre.textContent = sp.code;
|
||||
var out = document.createElement('div');
|
||||
out.className = 'sp-run';
|
||||
out.style.display = 'none';
|
||||
var frame = null; /* 当前特效 iframe 沙箱实例,null 表示未运行 */
|
||||
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) {
|
||||
/* 启动特效:创建隔离 iframe 沙箱,完整前端代码(含 <style>/<script>/<body>)由 srcdoc 承载,
|
||||
* 脚本在独立上下文执行,无法影响父页面导航。
|
||||
* 两种模式:
|
||||
* - 代码含 "<!-- in-node -->" 标记 → 渲染在节点内部(relative 定位、可交互、随节点滚动);
|
||||
* - 否则 → 全屏 fixed 定位,视觉特效悬浮覆盖整个页面(pointer-events:none 不拦截父页面点击)。
|
||||
* iframe 元素始终挂载在节点容器内部(DOM 归属该节点)。 */
|
||||
function startEffect() {
|
||||
if (frame) return;
|
||||
frame = document.createElement('iframe');
|
||||
frame.className = 'sp-effect-frame';
|
||||
frame.setAttribute('sandbox', 'allow-scripts');
|
||||
frame.srcdoc = sp.code;
|
||||
var inNode = /<!--\s*in-node\s*-->/.test(sp.code || '');
|
||||
frame.style.cssText = inNode
|
||||
? 'position:relative;display:block;width:100%;height:420px;border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden;background:transparent;'
|
||||
: 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;pointer-events:none;z-index:9999;';
|
||||
container.appendChild(frame);
|
||||
toggle_btn.textContent = '停止特效';
|
||||
out.style.display = 'block';
|
||||
out.textContent = inNode ? '已载入(运行于隔离沙箱)' : '特效已启动(运行于隔离沙箱,不影响页面导航)';
|
||||
}
|
||||
function stopEffect() {
|
||||
if (frame) { frame.remove(); frame = null; }
|
||||
toggle_btn.textContent = '重新运行';
|
||||
out.style.display = 'none';
|
||||
}
|
||||
toggle_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();
|
||||
if (frame) stopEffect(); else startEffect();
|
||||
});
|
||||
bar.appendChild(audit_btn);
|
||||
bar.appendChild(run_btn);
|
||||
bar.appendChild(toggle_btn);
|
||||
container.appendChild(bar);
|
||||
container.appendChild(pre);
|
||||
container.appendChild(out);
|
||||
appendNote();
|
||||
/* 含前端内容的节点渲染到当前页面时自动执行(无特效模式下跳过) */
|
||||
if (!sp_effects_disabled) startEffect();
|
||||
}
|
||||
|
||||
/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */
|
||||
@@ -612,6 +656,10 @@ 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; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
|
||||
/* 搜索框:当前层卡片标题/内容关键词过滤 */
|
||||
var searchtext = document.getElementById('searchtext');
|
||||
var searchsubmit = document.getElementById('searchsubmit');
|
||||
var current_children = []; /* 当前层子节点列表(load_notes 时更新,搜索据此过滤) */
|
||||
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
|
||||
var PANEL_EXPAND_MIN_WIDTH = 768;
|
||||
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
|
||||
@@ -646,7 +694,7 @@ sp_type.addEventListener('change', function() {
|
||||
sp_edit_original = null;
|
||||
}
|
||||
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
|
||||
else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码';
|
||||
else if (t === 'js') input_note_content.placeholder = '请输入完整前端代码(可含 HTML/CSS/JS)';
|
||||
else input_note_content.placeholder = '';
|
||||
});
|
||||
|
||||
@@ -669,10 +717,10 @@ async function buildSubmitContent() {
|
||||
if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; }
|
||||
return JSON.stringify({ _sp: 'link', url: url });
|
||||
}
|
||||
if (t === 'html') {
|
||||
if (t === 'js') {
|
||||
var code = input_note_content.value;
|
||||
if (!code.trim()) { alert('请输入 HTML 代码'); return null; }
|
||||
return JSON.stringify({ _sp: 'html', code: code });
|
||||
if (!code.trim()) { alert('请输入前端代码'); return null; }
|
||||
return JSON.stringify({ _sp: 'js', code: code });
|
||||
}
|
||||
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
|
||||
if (sp_file.files.length === 0) {
|
||||
@@ -742,13 +790,13 @@ delete_note.addEventListener('click', (event) => {
|
||||
})
|
||||
|
||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
|
||||
* 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
|
||||
* 特殊节点:link/js 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
|
||||
function startEdit(item) {
|
||||
editing_id = item.id;
|
||||
input_note_title.value = item.title;
|
||||
var sp = parseSp(item.content);
|
||||
sp_edit_original = null;
|
||||
if (sp && (sp._sp === 'link' || sp._sp === 'html')) {
|
||||
if (sp && (sp._sp === 'link' || sp._sp === 'js')) {
|
||||
sp_type.value = sp._sp;
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
|
||||
@@ -817,24 +865,34 @@ function load_notes(main_node) {
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
/* 切换到新节点:清空搜索关键词,清除上一节点特效,再渲染当前层 */
|
||||
current_children = Array.isArray(main_node) ? main_node : [];
|
||||
if (searchtext) searchtext.value = "";
|
||||
clearAllEffects();
|
||||
renderCards(current_children);
|
||||
};
|
||||
|
||||
/* 把卡片列表渲染到 recommend_list(从 load_notes/搜索过滤复用) */
|
||||
function renderCards(list) {
|
||||
recommend_list.replaceChildren();
|
||||
for (let i in main_node) {
|
||||
let item = main_node[i]
|
||||
for (let i in list) {
|
||||
let item = list[i]
|
||||
console.log(item)
|
||||
let card = document.createElement('div');
|
||||
card.className = "card";
|
||||
card.addEventListener('click', function(event) {
|
||||
card.__item = item; /* 供搜索过滤时读取原始数据 */
|
||||
let ttl = document.createElement('center');
|
||||
let ttl1 =document.createElement('h3');
|
||||
ttl1.textContent = item.title;
|
||||
card.id = item.title;
|
||||
/* 仅标题可点击进入子节点(内容/统计/操作区不触发跳转) */
|
||||
ttl1.addEventListener('click', function(event) {
|
||||
if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不跳转,stack 保持原样 */
|
||||
var id = Number(item.id)
|
||||
stack.push(id)
|
||||
getNode(id).then(load_notes)
|
||||
getNode(id).then(loadMap)
|
||||
});
|
||||
let ttl = document.createElement('center');
|
||||
let ttl1 =document.createElement('h3');
|
||||
ttl1.textContent = item.title;
|
||||
card.id = item.title;
|
||||
ttl.appendChild(ttl1);
|
||||
card.appendChild(ttl);
|
||||
let wrt_div = document.createElement('p');
|
||||
@@ -898,6 +956,60 @@ function load_notes(main_node) {
|
||||
}
|
||||
};
|
||||
|
||||
/* ===== 当前层搜索:从 item 中提取可搜索文本(title + 内容文本;信封类排除 base64 数据) ===== */
|
||||
function itemSearchText(item) {
|
||||
var parts = [];
|
||||
if (item.title) parts.push(item.title);
|
||||
if (item.owner) parts.push(item.owner);
|
||||
var c = item.content;
|
||||
if (typeof c === 'string') {
|
||||
var sp = parseSp(c);
|
||||
if (!sp) {
|
||||
parts.push(c);
|
||||
} else if (sp._sp === 'link') {
|
||||
if (sp.url) parts.push(sp.url);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else if (sp._sp === 'js') {
|
||||
if (sp.code) parts.push(sp.code);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else { /* image / file:不把 base64 数据纳入搜索 */
|
||||
if (sp.name) parts.push(sp.name);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
}
|
||||
}
|
||||
return parts.join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
function applySearch() {
|
||||
var kw = (searchtext && searchtext.value || '').trim().toLowerCase();
|
||||
var cards = recommend_list.querySelectorAll('.card');
|
||||
var anyMatch = false;
|
||||
for (var i = 0; i < cards.length; i++) {
|
||||
var card = cards[i];
|
||||
var item = card.__item;
|
||||
var matched = !kw || (item && itemSearchText(item).indexOf(kw) !== -1);
|
||||
card.style.display = matched ? '' : 'none';
|
||||
if (matched) anyMatch = true;
|
||||
}
|
||||
/* 无匹配结果提示(避免空列表看不出是已过滤) */
|
||||
var noMatchTip = document.getElementById('search-no-match');
|
||||
if (kw && !anyMatch) {
|
||||
if (!noMatchTip) {
|
||||
noMatchTip = document.createElement('div');
|
||||
noMatchTip.id = 'search-no-match';
|
||||
noMatchTip.className = 'card';
|
||||
noMatchTip.style.textAlign = 'center';
|
||||
noMatchTip.style.color = 'var(--fg-secondary)';
|
||||
noMatchTip.style.cursor = 'default';
|
||||
recommend_list.appendChild(noMatchTip);
|
||||
}
|
||||
noMatchTip.textContent = '未找到匹配「' + searchtext.value + '」的节点';
|
||||
noMatchTip.style.display = '';
|
||||
} else if (noMatchTip) {
|
||||
noMatchTip.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function loadMap(children_json) {
|
||||
let need_return = false;
|
||||
minimap.replaceChildren();
|
||||
@@ -984,7 +1096,7 @@ function renderUserPanel() {
|
||||
if (userpanel_expand) {
|
||||
fold.textContent = "v 折叠";
|
||||
} else {
|
||||
fold.textContent = "> " + (current_user.login ? current_user.user : "未登录");
|
||||
fold.textContent = "> 展开";
|
||||
}
|
||||
fold.addEventListener("click", () => {
|
||||
userpanel_expand = !userpanel_expand;
|
||||
@@ -1011,6 +1123,34 @@ function renderUserPanel() {
|
||||
role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户";
|
||||
userpanel.appendChild(role_tag);
|
||||
|
||||
/* 无特效模式开关(所有用户):关闭后特效节点不再自动执行 */
|
||||
let effect_btn = document.createElement("div");
|
||||
effect_btn.className = "tag";
|
||||
effect_btn.textContent = sp_effects_disabled ? "无特效模式" : "特效模式";
|
||||
effect_btn.addEventListener("click", () => {
|
||||
sp_effects_disabled = !sp_effects_disabled;
|
||||
try { localStorage.setItem('sp_effects_disabled', sp_effects_disabled ? '1' : '0'); } catch (e) {}
|
||||
effect_btn.textContent = sp_effects_disabled ? "无特效模式" : "特效模式";
|
||||
/* 关闭时移除当前已运行的全部特效沙箱 */
|
||||
if (sp_effects_disabled) clearAllEffects();
|
||||
});
|
||||
userpanel.appendChild(effect_btn);
|
||||
|
||||
/* 管理员专属入口:运维机器人 / 进程管理器 */
|
||||
if (current_user.is_admin) {
|
||||
let ops_btn = document.createElement("div");
|
||||
ops_btn.className = "tag";
|
||||
ops_btn.textContent = "运维机器人";
|
||||
ops_btn.addEventListener("click", () => { window.location.href = "/aibox.html"; });
|
||||
userpanel.appendChild(ops_btn);
|
||||
|
||||
let mgmt_btn = document.createElement("div");
|
||||
mgmt_btn.className = "tag";
|
||||
mgmt_btn.textContent = "进程管理器";
|
||||
mgmt_btn.addEventListener("click", () => { window.location.href = "/mgmt/mgmt.html"; });
|
||||
userpanel.appendChild(mgmt_btn);
|
||||
}
|
||||
|
||||
let logout_tag = document.createElement("div");
|
||||
logout_tag.className = "tag back";
|
||||
logout_tag.textContent = "退出登录";
|
||||
@@ -1024,3 +1164,15 @@ function renderUserPanel() {
|
||||
getNode(0).then(load_notes);
|
||||
getNode(0).then(loadMap)
|
||||
loadUserPanel();
|
||||
|
||||
/* ===== 搜索框事件绑定:实时过滤当前层卡片;ESC 清空;回车不提交 ===== */
|
||||
if (searchtext) {
|
||||
searchtext.addEventListener('input', applySearch);
|
||||
searchtext.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); applySearch(); }
|
||||
if (e.key === 'Escape') { searchtext.value = ''; applySearch(); }
|
||||
});
|
||||
}
|
||||
if (searchsubmit) {
|
||||
searchsubmit.addEventListener('click', function(e) { e.preventDefault(); applySearch(); });
|
||||
}
|
||||
@@ -19,11 +19,11 @@ let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送
|
||||
let captchaToken = '';
|
||||
let captchaLoading = false; // 防重复点击:加载中忽略再次触发
|
||||
|
||||
async function refreshCaptcha() {
|
||||
async function refreshCaptcha(preserveInput) {
|
||||
if (captchaLoading) return;
|
||||
captchaLoading = true;
|
||||
captchaToken = '';
|
||||
captchaInput.value = ''; // 开始刷新时清空输入,避免用户误用旧验证码
|
||||
if (!preserveInput) captchaInput.value = ''; // 默认清空输入;preserveInput=true 时保留(获取验证码后)
|
||||
captchaImg.style.opacity = '0.5'; // 视觉提示:正在刷新
|
||||
try {
|
||||
const res = await fetch('/captcha/image', { cache: 'no-store' });
|
||||
@@ -222,8 +222,9 @@ getCodeButton.addEventListener('click', async function () {
|
||||
codeTip.classList.add('tip-red');
|
||||
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
|
||||
} finally {
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新
|
||||
refreshCaptcha();
|
||||
// CAPTCHA Token 一次性消费,无论成败都刷新图形验证码;
|
||||
// 刷新时保留已输入的图形验证码答案,避免误清空用户输入
|
||||
refreshCaptcha(true);
|
||||
if (!cooldownTimer) getCodeButton.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
</head>
|
||||
<body>
|
||||
log
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
.top-bar-con {
|
||||
width: 230px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.newBtn,
|
||||
.flushBtn,
|
||||
.removeBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
background-color: #ff00007d;
|
||||
color: #000000b8;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="module.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-bar-con">
|
||||
<button class="newBtn">新建</button>
|
||||
<button class="flushBtn">刷新</button>
|
||||
</div>
|
||||
|
||||
<p>模块列表需 mgmt Flask 后端 API 提供,静态页面不展示。</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,74 @@
|
||||
.newBtn,
|
||||
.flushBtn,
|
||||
.stopBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
margin-left: 60px;
|
||||
}
|
||||
|
||||
.stopBtn {
|
||||
background-color: #ff00007d;
|
||||
}
|
||||
|
||||
.text,
|
||||
.title {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#inProgressText {
|
||||
font-size: 20px;
|
||||
margin-top: 5px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
/* modal window style */
|
||||
.modal-con {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(68, 94, 65, 0.77);
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
display: none;
|
||||
justify-content: center;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-con .window {
|
||||
width: 200px;
|
||||
height: 120px;
|
||||
background-color: #5cff007d;
|
||||
padding: 0px 10px;
|
||||
}
|
||||
|
||||
.modal-con .window .top-bar {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.modal-con .window .top-bar .close-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-con .window .create-btn,
|
||||
.modal-con .window .input .input-area {
|
||||
margin-top: 5px;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="process.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- stop, new flush -->
|
||||
<button class="newBtn" onclick="newBtn()">新建</button>
|
||||
<button class="flushBtn" onclick="flushBtn()">刷新</button>
|
||||
<table border="1">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="title">PID</th>
|
||||
<th class="title">状态</th>
|
||||
<th class="title">命令</th>
|
||||
<th class="title">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tBody"></tbody>
|
||||
</table>
|
||||
<div id="inProgressText"></div>
|
||||
|
||||
<div class="modal-con" id="modal-con">
|
||||
<div class="window">
|
||||
<div class="top-bar">
|
||||
<div class="close-btn" onclick="closeModal()">X</div>
|
||||
</div>
|
||||
|
||||
<div class="input">
|
||||
<div class="prompt">输入命令:</div>
|
||||
<input id="input-area" class="input-area" type="text">
|
||||
</div>
|
||||
<button class="create-btn" onclick="createProcess()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// core 进程管理接口经 gateway 代理(Target-Url 指向 core:5200)
|
||||
const CORE_TARGET = "http://127.0.0.1:5200";
|
||||
|
||||
function handleUnauthorized(res) {
|
||||
if (res.status === 401) {
|
||||
alert("未授权或管理员 Token 已失效,请重新登录");
|
||||
setTimeout(() => { window.location.href = "../index.html"; }, 800);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function api(path, body) {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Target-Url': CORE_TARGET
|
||||
},
|
||||
body: body ? JSON.stringify(body) : '{}'
|
||||
});
|
||||
if (handleUnauthorized(res)) return null;
|
||||
const text = await res.text();
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (e) {
|
||||
console.error('接口返回非 JSON:', text);
|
||||
throw new Error('服务器响应格式异常 (HTTP ' + res.status + ')');
|
||||
}
|
||||
if (!res.ok || !result.success) {
|
||||
throw new Error(result.message || '请求失败 (HTTP ' + res.status + ')');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 列表接口返回的 data,可能是数组,也可能是 ptree 序列化后的
|
||||
// {"": {...}, "": {...}} 对象(boost property_tree 对数组的表示)
|
||||
function toProcessArray(data) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === "object") {
|
||||
return Object.values(data);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadProcesses() {
|
||||
const tbody = document.getElementById("tBody");
|
||||
const progress = document.getElementById("inProgressText");
|
||||
tbody.innerHTML = "";
|
||||
progress.innerText = "加载中...";
|
||||
try {
|
||||
const result = await api('/list');
|
||||
const list = toProcessArray(result.data);
|
||||
tbody.innerHTML = "";
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const p = list[i];
|
||||
const tr = document.createElement("tr");
|
||||
const pid = p.pid;
|
||||
const status = p.running === true ? "运行中" : (p.running === false ? "已停止" : "未知");
|
||||
tr.innerHTML = `
|
||||
<td>${pid}</td>
|
||||
<td>${status}</td>
|
||||
<td>${p.command || "-"}</td>
|
||||
<td><button class="stopBtn" onclick="stopBtn(${pid})">停止</button></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
progress.innerText = "";
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
tbody.innerHTML = "";
|
||||
alert(err.message || '加载进程列表失败');
|
||||
}
|
||||
}
|
||||
|
||||
function newBtn() {
|
||||
const modal = document.getElementById("modal-con")
|
||||
modal.style.display = "flex"
|
||||
}
|
||||
|
||||
async function createProcess() {
|
||||
const moduleName = document.getElementById("input-area").value.trim()
|
||||
if (!moduleName) {
|
||||
alert("请输入要执行的命令");
|
||||
return;
|
||||
}
|
||||
const progress = document.getElementById("inProgressText");
|
||||
progress.innerText = "启动中...";
|
||||
try {
|
||||
await api('/exec', { command: moduleName });
|
||||
alert("启动成功");
|
||||
closeModal();
|
||||
await loadProcesses();
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
alert(err.message || '启动失败');
|
||||
}
|
||||
}
|
||||
|
||||
function flushBtn() {
|
||||
loadProcesses();
|
||||
}
|
||||
|
||||
async function stopBtn(pid) {
|
||||
if (!confirm("确认停止该服务?")) {
|
||||
return
|
||||
}
|
||||
const progress = document.getElementById("inProgressText");
|
||||
progress.innerText = "停止中...";
|
||||
try {
|
||||
await api('/kill', { pid: pid });
|
||||
alert("已停止");
|
||||
await loadProcesses();
|
||||
} catch (err) {
|
||||
progress.innerText = "";
|
||||
alert(err.message || '停止失败');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const modal = document.getElementById("modal-con")
|
||||
const input = document.getElementById("input-area")
|
||||
|
||||
input.value = ""
|
||||
modal.style.display = "none"
|
||||
}
|
||||
|
||||
// 页面加载时自动拉取进程列表
|
||||
window.onload = () => {
|
||||
loadProcesses();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
.top-bar-con {
|
||||
width: 230px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
td {
|
||||
width: 180px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.addUserBtn,
|
||||
.flushBtn,
|
||||
.changePasswdBtn,
|
||||
.deleteUserBtn {
|
||||
width: 84px;
|
||||
height: 42px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addUserBtn {
|
||||
background-color: #5cff007d;
|
||||
}
|
||||
|
||||
.flushBtn {
|
||||
background-color: #5757577d;
|
||||
}
|
||||
|
||||
.changePasswdBtn {
|
||||
background-color: #e6db407d;
|
||||
color: #fb0202b0;
|
||||
}
|
||||
|
||||
.deleteUserBtn {
|
||||
background-color: #ff00007d;
|
||||
color: #000000b8;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="user.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-bar-con">
|
||||
<button class="addUserBtn" onclick="addUser()">添加用户</button>
|
||||
<button class="flushBtn" onclick="flush()">刷新</button>
|
||||
</div>
|
||||
|
||||
<p>用户列表需 mgmt Flask 后端 API 提供,静态页面不展示。</p>
|
||||
|
||||
<script>
|
||||
function addUser() {
|
||||
alert("注意:这是静态页面 - 添加用户需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function changePasswd(id) {
|
||||
alert("注意:这是静态页面 - 修改密码需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function deleteUser(id) {
|
||||
alert("注意:这是静态页面 - 删除用户需要后端 API 支持,请通过 mgmt Flask 后端访问");
|
||||
}
|
||||
|
||||
function flush() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: rgb(233, 233, 233);
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
.main .title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.main .name-con,
|
||||
.main .passwd-con {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main .name-con {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.main .passwd-con {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.main .name-con .user-name-text,
|
||||
.main .passwd-con .user-passwd-text {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.main .login-con {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>pmc management</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="index.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="main">
|
||||
<div class="title">Welcome to pmc management system</div>
|
||||
<div class="name-con">
|
||||
<div class="user-name-text">Name: </div>
|
||||
<input class="user-name-input" id="user-name" type="text">
|
||||
</div>
|
||||
<div class="passwd-con">
|
||||
<div class="user-passwd-text">Password: </div>
|
||||
<input class="user-passwd-input" id="user-passwd" type="password">
|
||||
</div>
|
||||
<div class="login-con">
|
||||
<button onclick="login()">login</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function login() {
|
||||
const name = document.getElementById("user-name").value
|
||||
const passwd = document.getElementById("user-passwd").value
|
||||
const request = new XMLHttpRequest()
|
||||
const REQUEST_URL = "/api/login"
|
||||
|
||||
// if user not input any information
|
||||
if (name === "" || passwd === "") {
|
||||
alert("please input name or passwd")
|
||||
return
|
||||
}
|
||||
|
||||
// request api
|
||||
let requestData = new URLSearchParams()
|
||||
requestData.append("user-name", name)
|
||||
requestData.append("user-passwd", passwd)
|
||||
request.open("post", REQUEST_URL, true)
|
||||
request.send(requestData)
|
||||
|
||||
request.onload = () => {
|
||||
if (request.status === 401) {
|
||||
alert(request.responseText)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 404) {
|
||||
alert(request.responseText)
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 500) {
|
||||
alert("服务端错误")
|
||||
return
|
||||
}
|
||||
|
||||
if (request.status === 200 && request.readyState === 4) {
|
||||
const token = JSON.parse(request.responseText).token
|
||||
let date = new Date()
|
||||
date.setTime(date.getTime() + (3 * 24 * 60 * 60 * 1000))
|
||||
const UTCTime = date.toUTCString()
|
||||
document.cookie = `token=${token}; path=/; expires=${UTCTime}`
|
||||
window.location = JSON.parse(request.responseText).redirect;
|
||||
return
|
||||
} else {
|
||||
alert(`意外错误 (${request.status})`)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key != "Enter") {
|
||||
return
|
||||
}
|
||||
login()
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
:root {
|
||||
--listMargin: 15%;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
.top-con {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.top-con >*{
|
||||
margin-left: 25px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.top-con div button {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.main {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
width: 150px;
|
||||
height: 100%;
|
||||
background-color: #FFFFCC;
|
||||
padding: 0px 10px 0px 10px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.feature-list .item {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-top: var(--listMargin);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pages {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>management page</title>
|
||||
<link rel="icon" href="data:;base64,=">
|
||||
<link rel="stylesheet" href="mgmt.css">
|
||||
|
||||
<script>
|
||||
async function loadUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/whoami', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.login && data.user) {
|
||||
document.getElementById("current-user").innerText = "当前用户: " + data.user;
|
||||
document.getElementById("current-role").innerText = data.is_admin ? "当前角色: 管理员" : "当前角色: 普通用户";
|
||||
if (!data.is_admin) {
|
||||
alert("需要管理员权限才能访问此页面");
|
||||
}
|
||||
} else {
|
||||
document.getElementById("current-user").innerText = "当前用户: 未登录";
|
||||
document.getElementById("current-role").innerText = "当前角色: 未知";
|
||||
setTimeout(() => {
|
||||
window.location = "index.html";
|
||||
}, 1000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取用户信息失败:', err);
|
||||
document.getElementById("current-user").innerText = "当前用户: 未知";
|
||||
document.getElementById("current-role").innerText = "当前角色: 未知";
|
||||
}
|
||||
}
|
||||
|
||||
window.onload = () => {
|
||||
loadUserInfo();
|
||||
|
||||
const featureList = [
|
||||
{
|
||||
name: "进程",
|
||||
url: "features/process.html"
|
||||
},
|
||||
{
|
||||
name: "模块",
|
||||
url: "features/module.html"
|
||||
},
|
||||
// {
|
||||
// name: "日志",
|
||||
// url: "features/log.html"
|
||||
// },
|
||||
{
|
||||
name: "用户",
|
||||
url: "features/user.html"
|
||||
}
|
||||
]
|
||||
|
||||
const featureListEle = document.getElementById("feature-list")
|
||||
for (let i = 0; i < featureList.length; i++) {
|
||||
// get name and url
|
||||
const name = featureList[i].name
|
||||
const url = featureList[i].url
|
||||
|
||||
// Create a item
|
||||
let listItemEle = document.createElement("div")
|
||||
listItemEle.setAttribute("class", "item")
|
||||
listItemEle.innerText = name
|
||||
listItemEle.setAttribute("onclick", `switchPage('${url}')`)
|
||||
|
||||
// append element into feature list
|
||||
featureListEle.append(listItemEle)
|
||||
}
|
||||
|
||||
// set default page in iframe
|
||||
const DEFAULT_PAGE = "<!DOCTYPE html><h1>请在左侧选择功能</h1>"
|
||||
document.getElementById("page").contentDocument.write(DEFAULT_PAGE)
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="top-con">
|
||||
<div id="current-user">当前用户: 未登录</div>
|
||||
<div id="current-role">当前角色: 未知</div>
|
||||
<div class="button-con">
|
||||
<button onclick="logout()">退出登陆</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="feature-list" id="feature-list"></div>
|
||||
<div class="pages">
|
||||
<iframe id="page" src="" frameborder="1" width="100%" height="100%"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// change the iframe's url
|
||||
function switchPage(url) {
|
||||
const page = document.getElementById("page")
|
||||
page.setAttribute("src", url)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
if (!confirm("确认退出?")) {
|
||||
return
|
||||
}
|
||||
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
window.location = "index.html"
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+44
-3
@@ -31,7 +31,8 @@ rules/
|
||||
├── waf.conf # 主入口:基础配置(SecRuleEngine / SecRequestBodyLimit 等)
|
||||
├── local/ # 本地自定义规则(ID 段 100000-199999)
|
||||
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
|
||||
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
||||
│ ├── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
|
||||
│ └── tuning.conf # CRS 调优:偏执等级 + 异常评分阈值(自动拦截的"旋钮")
|
||||
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取,可选)
|
||||
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
|
||||
├── LICENSE # Apache-2.0
|
||||
@@ -71,6 +72,36 @@ SecRule REQUEST_URI|ARGS "@rx (?i)union[\s/*]+select" \
|
||||
- `deny,status:403` 命中后动作
|
||||
- `msg` 命中日志描述
|
||||
|
||||
## 异常评分制自动拦截
|
||||
|
||||
启用 OWASP CRS 后,WAF 采用**异常评分制**自动拦截,而非单条硬规则直接 deny——无需人工干预,按命中严重度自动分级:
|
||||
|
||||
- 每条规则命中时按严重度累加异常分:`CRITICAL=5`、`ERROR=4`、`WARNING=3`、`NOTICE=2`
|
||||
- 当 `tx.anomaly_score >= tx.inbound_anomaly_score_threshold` 时,规则 **949110** 自动 `deny`
|
||||
- **低分请求放行、高分请求拦截**,全自动
|
||||
|
||||
### 引擎模式([waf.conf](rules/waf.conf))
|
||||
|
||||
| 模式 | 行为 |
|
||||
|------|------|
|
||||
| `On` | 自动拦截:异常评分达到阈值即 deny(当前配置) |
|
||||
| `DetectionOnly` | 只记录异常评分,不执行 deny(用于观察误报) |
|
||||
| `Off` | 完全关闭 |
|
||||
|
||||
> 上线建议:先 `DetectionOnly` 跑一段时间,确认异常评分无误报后,再切回 `On` 开启自动拦截。
|
||||
|
||||
### 调参旋钮([rules/local/tuning.conf](rules/local/tuning.conf))
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `tx.blocking_paranoia_level` | `1` | 偏执等级,1 最保守(误报最少);2/3/4 递增更严、误报更多 |
|
||||
| `tx.inbound_anomaly_score_threshold` | `5` | 请求侧拦截阈值,越小越敏感、误报越多;越大越宽松、漏报越多 |
|
||||
| `tx.outbound_anomaly_score_threshold` | `4` | 响应侧拦截阈值 |
|
||||
|
||||
调参后 `kill -HUP <pid>` 热加载即可,无需重启。
|
||||
|
||||
> 说明:`tuning.conf` 独立于自动生成的 `crs/coraza.conf`,重跑 `_fetch_crs.sh` 不会覆盖调优配置。
|
||||
|
||||
## 部署形态
|
||||
|
||||
WAF 必须终止 TLS 才能看到明文 body,因此证书需从 gateway 迁到 WAF:
|
||||
@@ -120,7 +151,8 @@ waf/
|
||||
│ ├── waf.conf
|
||||
│ └── local/
|
||||
│ ├── custom.conf
|
||||
│ └── exceptions.conf
|
||||
│ ├── exceptions.conf
|
||||
│ └── tuning.conf # CRS 调优(偏执等级 + 异常评分阈值)
|
||||
├── _set.sh # 编译(含 go mod tidy + download + build)
|
||||
├── _run.sh # 运行
|
||||
├── _goproxy.sh # 设置 GOPROXY 国内镜像(由 _set.sh 调用)
|
||||
@@ -310,12 +342,21 @@ WAF 支持透明代理 WebSocket 连接(如 aibox 的 WebSocket 接口)。
|
||||
- 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 提示。
|
||||
- **默认规则全部注释**:`rules/local/custom.conf` 中自定义规则默认注释,需手动取消注释或拉取 OWASP CRS 后才有实际拦截效果。启动时无 warning 提示。**注意:CRS 规则目录不入库,需先在部署机执行 `_fetch_crs.sh`;未拉取 CRS 时 WAF 开箱无拦截能力。**
|
||||
- 缺少 SIGINT/SIGTERM 优雅关闭,进程终止时进行中的请求被暴力中断,logclient 队列中未发送的日志可能丢失。
|
||||
- 标准日志(`log.SetOutput`)被静默到 `io.Discard`,net/http 的 panic 堆栈和 TLS 错误不可见。
|
||||
|
||||
## 版本日志
|
||||
|
||||
### v1.3(2026-08-04)— 启用 OWASP CRS 异常评分制自动拦截
|
||||
|
||||
- **feat**: 启用 OWASP CRS v4.25.0 异常评分制,`SecRuleEngine On` 自动拦截评分达阈值的请求(规则 949110)
|
||||
- **feat**: 新增 `rules/local/tuning.conf` CRS 调优文件,可配置偏执等级(默认 1)与异常评分阈值(inbound 5 / outbound 4)
|
||||
- **feat**: `tuning.conf` 独立于自动生成的 `crs/coraza.conf`,重跑 `_fetch_crs.sh` 不会覆盖调优配置
|
||||
- 校验:SQLi / XSS / 路径遍历 / 命令注入均命中 949110 拦截,正常请求放行
|
||||
|
||||
> **⚠️ 生效前提**:CRS 规则目录 `rules/crs/` 被 `.gitignore` 排除,不入库。**必须先在某台机器上执行 `bash _fetch_crs.sh` 拉取 CRS 规则**,`crs/coraza.conf` 存在后 engine 才会自动加载并启用自动拦截。若未拉取 CRS,实际生效的仅有 `custom.conf`(默认全注释)+ `tuning.conf`(仅 setvar),**WAF 开箱无拦截能力**。部署时请务必先拉取 CRS 或启用 `custom.conf` 中的兜底规则。
|
||||
|
||||
### v1.2(2026-08-01)— WebSocket 支持 + 修复
|
||||
|
||||
- **feat**: WebSocket 透明代理支持,`responseRecorder` 实现 `http.Hijacker` 接口
|
||||
|
||||
+3
-17
@@ -114,23 +114,9 @@ SecAction \\
|
||||
nolog,\\
|
||||
setvar:tx.crs_setup_version=${SETUP_VER}"
|
||||
|
||||
# ===== 可选调优(取消注释以启用) =====
|
||||
# 异常评分阈值(默认 inbound=5, outbound=4)
|
||||
# SecAction \\
|
||||
# "id:900110,\\
|
||||
# phase:1,\\
|
||||
# pass,\\
|
||||
# nolog,\\
|
||||
# setvar:tx.inbound_anomaly_score_threshold=5,\\
|
||||
# setvar:tx.outbound_anomaly_score_threshold=4"
|
||||
|
||||
# 偏执等级(默认 1;等级越高规则越严、误报越多)
|
||||
# SecAction \\
|
||||
# "id:900000,\\
|
||||
# phase:1,\\
|
||||
# pass,\\
|
||||
# nolog,\\
|
||||
# setvar:tx.blocking_paranoia_level=1"
|
||||
# ===== 可选调优(由 local/tuning.conf 统一管理,重跑本脚本不会覆盖) =====
|
||||
# 异常评分阈值 / 偏执等级等 CRS 调优,已移至 rules/local/tuning.conf。
|
||||
# 注意:本文件由 _fetch_crs.sh 自动生成,重跑脚本会覆盖,请不要在此处修改调优。
|
||||
CONFEOF
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -260,6 +260,10 @@ func (r *responseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
}
|
||||
conn, rw, err := hijacker.Hijack()
|
||||
if err == nil {
|
||||
// 清除 http.Server 在劫持前设置的读写 deadline(ReadTimeout=15s / WriteTimeout=30s)。
|
||||
// 这些绝对 deadline 在劫持后仍会生效,会导致长连接 WebSocket 在空闲或 30s 后被强制断开。
|
||||
// 底层 WebSocket 桥接由 ReverseProxy 的 io.Copy 双向转发,长时间无人读写时应保持连接。
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
r.hijacked = true
|
||||
// WebSocket 升级响应是 101 Switching Protocols
|
||||
r.status = http.StatusSwitchingProtocols
|
||||
|
||||
@@ -4,3 +4,17 @@
|
||||
# 示例:关闭 100003 对 /login 路径的检查
|
||||
# SecRule REQUEST_URI "@beginsWith /login" \
|
||||
# "id:100100,phase:1,pass,nolog,ctl:ruleRemoveById=100003"
|
||||
|
||||
# ============================================================
|
||||
# 100100: 对笔记相关接口豁免 CRS 误报规则
|
||||
# 背景:嵌入式/技术笔记含 C 代码(if() 条件、select、sbit 等),
|
||||
# 被 CRS 的 SQLi/命令注入规则误判为攻击(942170/942230/932xxx/934xxx)。
|
||||
# 覆盖接口:/add_note、/update_note、/edit_note、/delete_note 等笔记写操作。
|
||||
# 方案:仅移除误报规则本身,其余 CRS 规则仍生效,兼顾正常提交与安全检测。
|
||||
# 若后续文章仍被误拦,把日志中新增的 rule_id 追加到 ctl:ruleRemoveById 列表。
|
||||
# ============================================================
|
||||
SecRule REQUEST_URI "@rx ^/(?:add|update|edit|delete)_note" \
|
||||
"id:100100,phase:1,pass,nolog,\
|
||||
ctl:ruleRemoveById=942170,ctl:ruleRemoveById=942230,\
|
||||
ctl:ruleRemoveById=932120,ctl:ruleRemoveById=932125,ctl:ruleRemoveById=932140,ctl:ruleRemoveById=932235,ctl:ruleRemoveById=932260,\
|
||||
ctl:ruleRemoveById=934110,ctl:ruleRemoveById=934160"
|
||||
@@ -0,0 +1,33 @@
|
||||
# tuning.conf —— OWASP CRS 调优(异常评分制)
|
||||
# 本文件独立于 crs/coraza.conf(后者由 _fetch_crs.sh 自动生成,重跑会被覆盖)。
|
||||
# engine 加载顺序:local/*.conf 在 crs/*.conf 之前,故此处 setvar 先于 CRS 规则生效。
|
||||
#
|
||||
# 自动拦截机制(waf.conf 已设为 On):
|
||||
# 每条规则命中按严重度累加异常分(CRITICAL=5 / ERROR=4 / WARNING=3 / NOTICE=2),
|
||||
# 当 tx.anomaly_score >= tx.inbound_anomaly_score_threshold 时,规则 949110 自动 deny。
|
||||
# 低分请求放行、高分请求拦截,全自动,无需人工干预。
|
||||
#
|
||||
# 调参旋钮:
|
||||
# - 偏执等级 1(paranoia_level=1):只用最基础规则集,误报最少
|
||||
# - 异常评分阈值(inbound 5 / outbound 4):
|
||||
# · 数值越小越敏感、误报越多;越大越宽松、漏报越多
|
||||
# · 若线上误报偏多,调大阈值(如 7/8);漏报偏多则调小
|
||||
#
|
||||
# 调参后执行:kill -HUP <pid> 热加载生效。
|
||||
|
||||
# 偏执等级(1 最保守;2/3/4 递增更严、误报更多)
|
||||
SecAction \
|
||||
"id:900000,\
|
||||
phase:1,\
|
||||
pass,\
|
||||
nolog,\
|
||||
setvar:tx.blocking_paranoia_level=1"
|
||||
|
||||
# 异常评分阈值
|
||||
SecAction \
|
||||
"id:900110,\
|
||||
phase:1,\
|
||||
pass,\
|
||||
nolog,\
|
||||
setvar:tx.inbound_anomaly_score_threshold=5,\
|
||||
setvar:tx.outbound_anomaly_score_threshold=4"
|
||||
@@ -19,6 +19,13 @@
|
||||
# 易出错)。engine.Reload 会先加载本文件,再扫描 local/*.conf 逐个加载。
|
||||
|
||||
# ===== 基础配置(SecLang) =====
|
||||
# 引擎模式:
|
||||
# On —— 自动拦截:异常评分达到阈值(tx.inbound_anomaly_score_threshold)即 deny
|
||||
# DetectionOnly —— 只记录异常评分,不执行 deny(用于观察误报)
|
||||
# Off —— 完全关闭
|
||||
#
|
||||
# 当前为 On(自动拦截):CRS 对每个请求累积异常分,达到阈值即拦截,低分请求全自动放行。
|
||||
# 阈值在 rules/local/tuning.conf 中配置(默认 5,越小越敏感、误报越多)。
|
||||
SecRuleEngine On
|
||||
SecRequestBodyAccess On
|
||||
SecResponseBodyAccess On
|
||||
|
||||
Reference in new issue
Block a user