Merge pull request 'Dev qing' (#14) from dev_qing into main

Reviewed-on: qing/micro_servers#14
This commit is contained in:
qing committed 2026-07-30 19:47:21 +08:00
commit edb53d079c
42 files changed
+5649 -370

No files matched your search

+19 -4
View File
@@ -16,10 +16,20 @@
```bash
cd BotOps
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
python app.py # 默认 ws://localhost:8765
python app.py ws://your-host:8765 # 指定 aibox 地址
# 一键初始化(创建 venv + 安装依赖)
bash _set.sh
# 启动
bash _run.sh # 默认 ws://localhost:8765
bash _run.sh ws://your-host:8765 # 指定 aibox 地址
```
### Nuitka 编译为独立二进制
```bash
bash _inst.sh # 产出 ./botops.bin(单文件,无需 Python 环境)
./botops.bin [ws://host:port]
```
## 命令
@@ -34,7 +44,12 @@ BotOps/
├── app.py # Textual App:布局 + 消息路由 + 快捷键
├── ws_client.py # WebSocket 协议层(自动重连)
├── commands.py # 斜杠命令注册表
├── _run.sh # 启动脚本
├── _set.sh # 初始化脚本(venv + 依赖安装)
├── _inst.sh # Nuitka 编译脚本(产出 botops.bin)
├── requirements.txt # Python 依赖
└── widgets/
├── __init__.py
├── chat_view.py # 聊天区:AI 回复 Markdown 增量流式渲染
└── status_bar.py # 底部状态栏(连接状态)
```
+32 -7
View File
@@ -1,7 +1,7 @@
# micro_servers
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python 与 Go 混合实现。
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。
一套 DIY 的轻量级微服务架构(UniServe Framework),由 C++、Python、Go 与仓颉(Cangjie) 混合实现。
`core` 作为进程托管服务负责拉起并守护各业务子进程,`waf` 作为前置 WAF 网关终止 TLS 并做规则匹配,`gateway` 作为应用层网关统一鉴权与反向代理,`db` 提供通用数据库访问与集中式日志收集,`ai_review` 提供 AI 内容审核,`static` 与 `dnfs` 作为业务服务对外提供能力。此外包含独立的机器人硬件控制模块 `bot4cj` 和嵌入式脚本引擎 `libtry`。
## 架构总览
@@ -51,6 +51,7 @@
| [captcha](captcha/) | Python(pymsg) | 8009 | 验证码服务(pymsg/TCP),生成图形验证码(5 分钟过期、一次性),经 gateway 的 `GET /captcha/image` 代理给浏览器 |
| [smtp](smtp/) | Python(pymsg) | 8008 | 邮件服务,发送/校验邮箱验证码(pymsg 路由 send_email/verify_email/health),注册流程的邮件验证码后端,SMTP 账号配置在 .env |
| [aibox](aibox/) | Python | 8765 | 独立 AI 智能体系统(DeepSeek + WebSocket),自带技能系统/记忆/任务调度,由 core 拉起但与主干微服务无调用关系 |
| [bot4cj](bot4cj/) | 仓颉(Cangjie) | — | 机器人硬件脚本解析器,解析 .mt 矩阵脚本通过 I2C 驱动 PCA9685 舵机 / MPU6050 陀螺仪,面向树莓派 Dashun 蜘蛛机器人平台(独立硬件项目) |
### core(核心模块 · 进程托管服务)
@@ -68,7 +69,7 @@
- 极客操作:`kill -HUP <pid>` 热加载规则;`waf -list` 列规则;`waf -test <file>` 校验语法;`waf -check "<input>"` 测试单条输入。
- 配置见 [waf/config.yaml](waf/config.yaml),详见 [waf/README.md](waf/README.md)。
### gateway(应用层网关 · v0.5.2)
### gateway(应用层网关 · v0.5.3)
- 内部 HTTP 服务,默认监听 127.0.0.1:8443,不再承载 TLS(TLS 已由 WAF 终止)。
- 通过 `Server.tls = on/off` 配置切换 HTTPS/HTTP 模式([example.ini](gateway/example.ini)),独立部署时仍可启用 TLS。
@@ -136,7 +137,7 @@
### smtp(邮件服务)
- 注册流程的邮箱验证码后端,基于 pymsg(MsgServer,TCP 长度前缀帧 + JSON),监听 127.0.0.1:8008。
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,60 秒有效,发送失败自动作废)、`verify_email`(校验,通过即一次性消费防重放,兼容旧字段 `input_captcha`)、`health`。
- pymsg 路由:`send_email`(生成 6 位验证码并经 SMTP_SSL 发送,10 分钟有效,发送失败自动作废;同一邮箱 60 秒发送冷却,冷却期拒绝并返回 `retry_after`,不覆盖已发出的有效验证码,发送失败不计入冷却)、`verify_email`(校验,通过即一次性消费防重放,连续失败 5 次自动作废并返回 `invalidated`,兼容旧字段 `input_captcha`)、`health`。
- 验证码存于进程内存(锁保护 + 惰性过期清理),SMTP 账号通过 `.env` 配置(`SMTP_SERVER` / `SMTP_PORT` / `SMTP_USER` / `__PASSWORD`,`__PASSWORD` 兼容别名 `SMTP_PASSWORD`)。
### aibox(独立 AI 智能体系统)
@@ -145,6 +146,14 @@
- 自带技能系统(`skills/`)、任务调度器、记忆管理、前端页面与语音客户端(`voice_client.py`)。
- 默认模型 `deepseek-v4-flash`,配置在 `config.json`;详见 [aibox/README.md](aibox/README.md)。
### bot4cj(机器人硬件脚本解析器)
- 基于仓颉(Cangjie)编程语言和 Linux I2C 接口的机器人动作脚本解析器,面向树莓派 Dashun 蜘蛛机器人平台(10 舵机 + 2 云台)。
- 解析 `.mt` 矩阵脚本(行列式表格语法,支持 `@Servo` / `@Action` 等块),编译为舵机角度序列执行。
- 硬件层通过 I2C 驱动 PCA9685(PWM 舵机控制,50Hz,脉宽 0.5–2.5ms)、MPU6050(陀螺仪/加速度计,10ms 采样 + 卡尔曼滤波)、Speaker(正弦波发声)。
- 附带数学库(矩阵运算、NN/CNN、Adam 优化器)、CUDA GPU 加速 kernel 和 Lua 硬件模块(ADS1115 / ADS1256 / SPI)。
- 与主干微服务完全独立,需部署在树莓派上独立运行。
## 共享基础库
| 库 | 说明 |
@@ -153,6 +162,7 @@
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`、`HttpClient`、`WebSocketServer/Client`(基于 Boost.Beast) |
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
| [libtry](libtry/) | 嵌入式脚本解释器(~741 行 C),手写递归下降词法/语法/执行引擎,支持变量/数组/函数/控制流,宽字符(中文标识符),用于嵌入式操作界面 shell |
### Python 共享组件
@@ -176,6 +186,21 @@
| 8009 | captcha | 127.0.0.1 | 验证码服务(pymsg/TCP,经 gateway 代理) |
| 8008 | smtp | 127.0.0.1 | 邮件服务(仅内部调用,注册验证码邮件后端) |
| 8765 | aibox | 127.0.0.1 | AI 智能体(WebSocket,独立产品) |
| — | bot4cj | — (树莓派 I2C) | 机器人硬件脚本解析器(独立硬件项目,不联网) |
## 测试
[tests/](tests/) 包含一套黑盒测试套件,基于子进程调用与 HTTP 请求验证各微服务功能正确性:
| 测试文件 | 被测模块 | 说明 |
|----------|----------|------|
| `test_db_*.py` | db | SQL-over-TCP 的 select/insert/update/delete、日期哨兵、边界条件 |
| `test_gateway_*.py` | gateway | 用户注册/登录/Token 校验、速率限制、CAPTCHA 集成 |
| `test_waf_*.py` | waf | coraza 规则匹配与拦截、白名单放行 |
| `test_dnfs_*.py` | dnfs | 节点树 CRUD、AI 审核集成 |
- 默认使用 `config.py` 中的生产地址(127.0.0.1),每个测试函数前后自动备份/恢复 db 数据,保证不污染线上数据。
- 测试依赖 `pymsg`、`Flask`、`flask-cors`、`requests`、`Pillow`,详见 [tests/requirements-test.txt](tests/requirements-test.txt)。
## 依赖
@@ -193,7 +218,7 @@ bash _setup.deb.sh # Debian/Ubuntu
# 或
bash _setup.rhel.sh # RHEL/CentOS/Rocky/Fedora
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> core)
# 2. 依次安装各模块(libth -> libhttp -> libcrypt -> libsqlite3 -> gateway -> waf -> static -> dnfs -> db -> smtp -> core)
bash _inst.sh
```
@@ -206,7 +231,7 @@ bash _run.sh # 运行
部署路径约定为 `/opt/micro_servers/`,`core` 的 `init.txt` 即从该路径拉起其余服务。
> **注意**:`ai_review` 未包含在 `_inst.sh` 中,需单独进入 `ai_review/` 目录执行 `_set.sh` 安装,并配置 `.env` 文件(DeepSeek API 密钥等)。
> **注意**:`ai_review` 与 `captcha` 未包含在 `_inst.sh` 中,需分别进入 `ai_review/`、`captcha/` 目录执行 `_set.sh` 安装。其中 `ai_review` 需额外配置 `.env` 文件(DeepSeek API 密钥等)。
> **注意**:waf 首次编译需联网下载 Go 依赖,[waf/_set.sh](waf/_set.sh) 会自动调用 [waf/_goproxy.sh](waf/_goproxy.sh) 设置 GOPROXY 国内镜像。如需手动设置:`go env -w GOPROXY=https://goproxy.cn,direct`。
@@ -254,7 +279,7 @@ gateway 改为内部 HTTP 模式([example.ini](gateway/example.ini) 中 `tls =
- **服务按业务能力拆分**:WAF 网关、应用网关、前端托管、节点树、数据库、内容审核各自独立、独立端口、独立数据存储。
- **API 网关模式**:`waf` 作为边缘网关统一入口,`gateway` 承担鉴权与反向代理,内部服务通过 `closed` 列表隔离。
- **进程编排/守护**:`core` 作为服务管理器拉起并守护子进程,类似简易版 supervisor。
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)混合,对外 HTTP、内部数据访问走 pymsg/TCP。
- **多语言异构**:C++(core / gateway)、Python(static / dnfs / db / ai_review)、Go(waf)、仓颉/Cangjie(bot4cj)混合,对外 HTTP、内部数据访问走 pymsg/TCP。
- **数据访问层解耦**:`db` 服务将数据库操作收敛为统一的 pymsg/TCP 接口,业务服务无需直连数据库。
- **AI 能力集成**:`ai_review` 以 RAG 增强的 LLM 审核为业务层提供内容安全能力,支持人工反馈闭环。
- **集中式可观测性**:`db` 内置日志表与自动清理,各服务通过 `log_client.py` SDK 异步推送日志(pymsg),实现全链路汇聚,无需引入额外中间件。
+65 -12
View File
@@ -115,6 +115,7 @@ AI 智能体可以调用以下工具来完成任务(由 `Tool` 基类派生,
| `reload_skills` | 热重载扩展技能 |
| `create_skill` | 创建新技能(AI 自主编写 Python 脚本) |
| `install_skill` | 技能安装指引 |
| `ask_user` | 向用户提问(AI 主动交互) |
| `update_identity` | 更新用户身份信息(`00_IDENTITY.md`) |
| `update_soul` | 更新 AI 自我定义(`99_SOUL.md`,智能体进化) |
| `save_memory_and_end_conversation` | 保存记忆并结束对话 |
@@ -374,36 +375,88 @@ AI 可以通过 `update_soul` 工具动态更新 `99_SOUL.md`,通过 `update_i
{
"system": {
"save_dir": "~/.aibox",
"datetime_format": "%Y-%m-%d %H:%M:%S",
"date_format": "%Y%m%d_%H%M%S",
"debug_level": 1,
"load_history_by_default": true,
"log_file": "~/.aibox/ai.log",
"log_max_size": 10485760,
"log_backup_count": 5,
"websocket_host": "localhost",
"websocket_port": 8765,
"debug_level": 1,
"log_file": "~/.aibox/ai.log",
"skills_dir": "~/.aibox/skills",
"prompts_dir": "~/.aibox/prompts"
},
"scheduler": {
"enabled": true,
"check_interval": 1.0,
"max_tasks_per_run": 10,
"broadcast_enabled": true
},
"api": {
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash",
"api_key_env": "DEEPSEEK_API_KEY",
"timeout": 30,
"temperature": 0.7
},
"context": {
"max_history_messages": 500,
"max_context_messages": 120,
"max_tokens": 8192,
"max_history_conversations": 5,
"preserve_system_prompts": true,
"compress_enabled": true,
"compress_message_threshold": 200,
"compress_token_threshold": 120000,
"compress_ratio": 0.2,
"compress_keep_recent": 8
},
"scheduler": {
"enabled": true,
"check_interval": 1.0,
"max_tasks_per_run": 10
"memo": {
"default_reminder_minutes": 60,
"max_memos_per_check": 10,
"allow_user_tasks": true
},
"api": {
"deepseek_api_url": "https://api.deepseek.com/chat/completions",
"deepseek_model": "deepseek-v4-flash",
"api_key_env": "DEEPSEEK_API_KEY"
"files": {
"max_file_size": 10485710
},
"commands": {
"enabled": true
},
"web": {
"timeout": 10,
"delay": 0,
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"respect_robots": false,
"max_download_size": 104857600
},
"memory": {
"max_search_results": 100
"db_name": "memories.db",
"max_search_results": 100,
"default_importance": 2
},
"conversation": {
"current_file": "current_conversation.json",
"history_file": "conversation_history.json",
"archive_dir": "archive"
},
"tools": {
"max_result_length": 10000
"max_result_length": 10000,
"save_result_to_file": false,
"tool_results_dir": "tool_results"
},
"compressor": {
"min_messages_to_compress": 5,
"weight_decay_factor": 0.01,
"max_input_chars": 6000,
"llm_max_tokens": 800
},
"proxy": {
"type": "",
"host": "",
"port": 0,
"username": "",
"password": ""
}
}
```
+823
View File
@@ -0,0 +1,823 @@
# AIBOX 模块技术手册
> 文档版本:2026-07-30
> 代码版本:v2.0
> 语言:Python 3.8+
> 定位:单 AI 智能体系统(独立于微服务体系)
---
## 一、模块概述
### 1.1 定位
**AIBOX** 是一个基于 DeepSeek API 的单 AI 智能体系统,通过 WebSocket 与客户端双向通信。所有任务(文件操作、网页访问、记忆管理、任务调度等)都由 AI 对话驱动执行。
### 1.2 与微服务体系的关系
| 属性 | 说明 |
|------|------|
| 启动方式 | `core`(PMC) 通过 `init.txt` 拉起 |
| 监听端口 | 8765(WebSocket) |
| 与主干关系 | **完全独立**,不调用 waf/gateway/dnfs 等服务 |
| 数据存储 | 本地 `~/.aibox/`(不使用 db:8005) |
| 前端 | 独立 HTML 页面 |
### 1.3 核心特性
| 特性 | 说明 |
|------|------|
| AI 对话 | DeepSeek API + 思考模式(reasoning 流式输出) |
| 实时通信 | WebSocket 双向通信 + 流式输出 |
| 工具系统 | 19 个内置工具(文件/网页/记忆/任务/命令等) |
| 技能扩展 | 热重载 `.py` 脚本和 `.md` 技能 |
| 记忆管理 | 自动保存/检索对话记忆 |
| 任务调度 | 一次性/重复/即时任务 |
| 自我进化 | AI 动态更新角色与用户画像 |
| 对话压缩 | 自动压缩长对话历史 |
| 多语言 | 中英自动检测 |
---
## 二、技术架构
### 2.1 技术栈
| 层 | 技术 | 用途 |
|----|------|------|
| 语言 | Python 3.8+ | 主程序 |
| 通信 | websockets | WebSocket 服务器 |
| AI | DeepSeek API | 智能对话、工具调用、流式输出 |
| 存储 | SQLite | 记忆数据库、备忘录任务 |
| HTTP | requests | 网页访问、API 调用 |
| HTML 解析 | beautifulsoup4 | 网页内容提取 |
| SSH | paramiko | SSH 远程执行(技能) |
| 加密 | cryptography | AES 加密(技能) |
### 2.2 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ WebSocket 客户端 │
│ (entry.html / frontend.html / client.py / voice_client.py) │
└────────────────────────┬────────────────────────────────────┘
│ ws://localhost:8765
┌────────────────────────▼────────────────────────────────────┐
│ WebSocket 服务器 (main.py) │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 消息处理器 │ │ 命令处理器 │ │ 任务调度器 │ │
│ │ (handle_msg) │ │ (handle_cmd) │ │ (MemoScheduler) │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
└─────────┼─────────────────┼───────────────────┼─────────────┘
│ │ │
┌─────────▼─────────────────▼───────────────────▼─────────────┐
│ AI 对话引擎 (DeepSeekChat) │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 提示词管理器 │ │ 工具注册器 │ │ 对话压缩器 │ │
│ │ (PromptMgr) │ │ (ToolRegistry)│ │ (ConversationComp)│ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
└─────────┼─────────────────┼───────────────────┼─────────────┘
│ │ │
┌─────────▼─────────────────▼───────────────────▼─────────────┐
│ 数据持久层 │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 记忆数据库 │ │ 备忘录 DB │ │ 对话历史管理器 │ │
│ │ (memories.db) │ │ (memos.db) │ │ (JSON files) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 核心数据流
```
客户端请求 → WebSocketHandler.handle_message()
↓
┌─── 消息类型判断 ───┬─── 命令类型判断 ───┬─── 任务操作判断 ───┐
│ type="message" │ type="command" │ type="task" │
└─────────┬─────────┴─────────┬─────────┴─────────┬─────────┘
│ │ │
▼ ▼ ▼
AI 对话引擎 命令处理器 任务管理器
(DeepSeekChat) (handle_command) (handle_task)
│ │ │
▼ ▼ ▼
工具调用循环 系统命令执行 任务 CRUD 操作
(ToolRegistry) (/new, /list...) (add, complete...)
│ │ │
▼ ▼ ▼
流式响应生成 命令结果返回 任务状态广播
(chunk → complete) (command_result) (task_status)
```
---
## 三、代码结构
### 3.1 文件结构
```
aibox/
├── main.py # 核心(~8000 行)
│ ├── WebSocketHandler # WebSocket 消息处理
│ ├── DeepSeekChat # AI 对话引擎
│ ├── PromptManager # 提示词管理
│ ├── Tool + 19 个工具类 # 工具系统
│ ├── MemoScheduler # 任务调度器
│ ├── MemoryManager # 记忆管理
│ └── ConfigManager # 配置管理
├── client.py # CLI WebSocket 客户端
├── voice_client.py # 语音客户端(语音输入/输出)
├── entry.html # Web 前端入口
├── frontend.html # Web 主页面
├── skills/ # 内置扩展技能
│ ├── hello-world.py # 示例技能
│ ├── bing_search.py # 必应搜索
│ ├── ssh-exec.py # SSH 远程执行
│ ├── sftp-transfer.py # SFTP 传输
│ ├── facerec.py # 人脸识别
│ ├── aes-crypto.py # AES 加密
│ ├── gitclient.py # Git 操作
│ └── *.md # Markdown 技能(prompt)
├── tasks/ITIL.md # ITIL 任务模板
├── SKILLS_FORMAT.md # 技能格式规范
├── requirements.txt
├── _run.sh # core 拉起的启动脚本
├── _runcli.sh # CLI 启动
├── _set.sh # 依赖安装
├── _setenv.sh # 环境变量设置
└── README.md # 用户文档
```
### 3.2 main.py 核心类
| 类名 | 行号 | 功能 |
|------|------|------|
| `Tool` | L3642 | 工具基类(抽象方法:get_name/execute) |
| `WebSocketHandler` | ~L1900 | WebSocket 消息处理 |
| `DeepSeekChat` | ~L2400 | AI 对话引擎 |
| `PromptManager` | ~L1050 | 提示词文件管理 |
| `ConfigManager` | ~L1080 | 配置加载/合并 |
| `MemoryManager` | ~L5200 | 记忆 CRUD |
| `MemoScheduler` | ~L4700 | 任务调度器 |
| `ConversationCompressor` | ~L6480 | 对话压缩 |
### 3.3 运行期数据
```
~/.aibox/
├── config.json # 配置(自动生成默认值)
├── prompts/
│ ├── 00_IDENTITY.md # 用户身份(AI 可更新)
│ ├── 1_*.md # 基础技能提示词
│ ├── 99_SOUL.md # AI 灵魂定义(AI 可更新)
│ └── 100_STATUS.md # AI 状态记录
├── skills/ # 用户扩展技能
├── memories.db # 记忆库(SQLite)
├── memos.db # 备忘录任务(SQLite)
├── current_conversation.json # 当前对话上下文
├── conversation_history.json # 对话索引
├── archive/ # 历史对话归档
├── tool_results/ # 工具执行结果缓存
└── ai.log # 日志(自动轮转)
```
---
## 四、工具系统
### 4.1 工具基类
```python
class Tool:
"""工具基类,所有工具必须继承此类"""
def get_name(self) -> str:
"""返回工具名称(抽象方法,必须实现)"""
pass
def get_description(self) -> str:
"""返回工具描述"""
return ""
def get_parameters(self) -> Dict:
"""返回工具参数(JSON Schema 格式)"""
return {"type": "object", "properties": {}}
def execute(self, **kwargs) -> str:
"""执行工具(抽象方法,必须实现)"""
pass
def execute_streaming(self, **kwargs):
"""流式执行(可选实现)"""
return None
```
### 4.2 内置工具一览
| 工具名 | 类名 | 行号 | 功能 |
|--------|------|------|------|
| `read_file` | `ReadFileTool` | L5762 | 读取文件(支持 head/tail/paging) |
| `write_file` | `WriteFileTool` | L5884 | 写入/追加文件 |
| `list_files` | `ListFilesTool` | L5961 | 目录列表 |
| `web` | `SimpleWebCrawlerTool` | L5637 | 网页抓取(标题/正文/链接) |
| `memory` | `MemoryTool` | L5280 | 记忆 CRUD(search/save/delete) |
| `memo` | `MemoTool` | L4753 | 任务 CRUD(add/complete/list/search) |
| `run_command` | `CommandLineTool` | L6150 | 系统命令执行(支持超时) |
| `search_skills` | `SearchSkillsTool` | L4076 | 搜索扩展技能 |
| `reload_skills` | `ReloadSkillsTool` | L4199 | 热重载技能 |
| `create_skill` | `CreateSkillTool` | L4409 | AI 自主创建技能 |
| `install_skill` | `InstallSkillTool` | L4306 | 技能安装指引 |
| `ask_user` | `AskUserTool` | L4275 | 向用户提问 |
| `update_identity` | `UpdateIdentityTool` | L4518 | 更新用户身份 |
| `update_soul` | `UpdateSoulTool` | L4582 | 更新 AI 灵魂 |
| `save_memory_and_end_conversation` | `SaveMemoryAndEndConversationTool` | L4646 | 保存记忆并结束对话 |
| `get_current_time` | `GetCurrentTimeTool` | L6055 | 获取当前时间 |
| `calculator` | `CalculatorTool` | L6095 | 数学计算 |
| `set_proxy` | `SetProxyTool` | L6415 | 设置 SOCKS5 代理 |
| `get_proxy` | `GetProxyTool` | L6453 | 查看代理配置 |
### 4.3 工具执行流程
```
AI 决定调用工具 → 生成 JSON 格式工具调用
↓
DeepSeekChat.process_tool_calls()
↓
┌─── 解析工具名 ───┬─── 解析参数 ───┬─── 注册工具 ───┐
└─────────────────┴───────────────┴─────────────────┘
↓
ToolRegistry.get_tool(name)
↓
tool.execute(**params)
↓
┌─── 结果处理 ───┬─── 错误处理 ───┬─── 长结果截断 ───┐
└─────────────────┴───────────────┴─────────────────┘
↓
返回结果给 AI → AI 继续生成响应
```
---
## 五、WebSocket 协议
### 5.1 客户端 → 服务器
| 类型 | 字段 | 说明 |
|------|------|------|
| `message` | `content` | 普通对话文本 |
| `command` | `command` | 斜杠命令(如 `/new`, `/help`) |
| `task` | `action`, `title`, ... | 任务操作(add/complete/list) |
| `ping` | — | 心跳检测 |
### 5.2 服务器 → 客户端
| 类型 | 字段 | 说明 |
|------|------|------|
| `chunk` | `content` | AI 响应片段(流式) |
| `complete` | `content` | AI 响应完成 |
| `reasoning` | `content` | AI 思考过程 |
| `error` | `content` | 错误信息 |
| `command_result` | `content` | 命令执行结果 |
| `task_status` | `task_id`, `status` | 任务状态更新 |
| `task_process` | `task_id`, `content` | 任务执行过程 |
| `dialogue_ended` | — | 对话结束 |
| `dialogue_reset` | — | 对话重置完成 |
| `info` | `content` | 系统信息 |
| `pong` | — | Ping 响应 |
### 5.3 消息流示例
```
// 普通对话
Client: {type: "message", content: "你好"}
Server: {type: "chunk", content: "你好!"}
Server: {type: "complete", content: "你好!有什么可以帮你的吗?"}
// 工具调用
Server: {type: "tool_call", tool: "web", args: {url: "..."}}
Server: {type: "chunk", content: "网页内容:..."}
Server: {type: "complete", content: "网页已获取"}
// 任务执行
Client: {type: "task", action: "add", title: "提醒", delay: 60}
Server: {type: "command_result", content: "任务已添加"}
// 60 秒后
Server: {type: "task_status", task_id: 1, status: "executing"}
Server: {type: "task_process", task_id: "1", content: "处理中..."}
Server: {type: "task_status", task_id: 1, status: "complete"}
```
---
## 六、任务系统
### 6.1 任务类型
| 类型 | 字段 | 说明 |
|------|------|------|
| 一次性 | `reminder_time` | 指定执行时间 |
| 重复(daily) | `repeat_type: "daily"` | 每天执行 |
| 重复(weekly) | `repeat_type: "weekly"`, `weekday` | 每周指定日 |
| 重复(monthly) | `repeat_type: "monthly"`, `day` | 每月指定日 |
| 重复(custom) | `repeat_type: "custom"`, `interval`, `unit` | 自定义间隔 |
| 即时 | `is_immediate: true` | 立即执行 |
### 6.2 任务调度流程
```
MemoScheduler._run() # 后台线程
↓
检查所有未完成任务
↓
┌─── 到达时间? ───┬─── 未完成? ───┬─── 允许用户任务? ───┐
└─────────────────┴───────────────┴─────────────────┘
↓
创建新的 AI 对话(将任务内容作为用户输入)
↓
AI 处理任务(可能调用多个工具)
↓
标记任务已触发
↓
广播结果到所有客户端
↓
计算下一次执行时间(重复任务)
```
### 6.3 数据库表结构
```sql
-- memories.db
CREATE TABLE memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT,
content TEXT,
importance INTEGER DEFAULT 2,
created_at TEXT,
updated_at TEXT,
embedding BLOB -- 预留向量嵌入
);
-- memos.db
CREATE TABLE memos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT,
content TEXT,
reminder_time TEXT,
repeat_type TEXT,
repeat_interval INTEGER,
repeat_interval_unit TEXT,
is_immediate INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending',
is_triggered INTEGER DEFAULT 0,
created_at TEXT,
user_id TEXT
);
```
---
## 七、记忆系统
### 7.1 记忆操作
| 操作 | AI 调用 | 说明 |
|------|---------|------|
| 保存 | `memory(action="save", content=...)` | AI 主动保存重要信息 |
| 搜索 | `memory(action="search", query=...)` | 按关键词搜索记忆 |
| 列表 | `memory(action="list", page=...)` | 分页浏览所有记忆 |
| 删除 | `memory(action="delete", id=...)` | 删除指定记忆 |
### 7.2 自动记忆
- AI 在对话结束时(`/exit`)自动保存关键信息
- 可配置 `system.auto_save_memory` 开关
- 重要度(importance)1-5 级
---
## 八、对话压缩
### 8.1 压缩触发条件
| 条件 | 阈值(默认) | 配置项 |
|------|-------------|--------|
| 消息数超过 | 200 条 | `compress_message_threshold` |
| Token 数超过 | 120000 | `compress_token_threshold` |
### 8.2 压缩流程
```
检测到阈值触发
↓
保留最近 8 条消息(compress_keep_recent)
↓
对早期消息按时间衰减加权(weight_decay_factor=0.01)
↓
调用 AI 生成摘要(max_input_chars=6000)
↓
将摘要作为系统消息插入
↓
压缩后继续对话
```
### 8.3 压缩配置
```json
{
"compressor": {
"min_messages_to_compress": 5,
"weight_decay_factor": 0.01,
"max_input_chars": 6000,
"llm_max_tokens": 800
}
}
```
---
## 九、扩展技能系统
### 9.1 技能类型
| 类型 | 格式 | 存储位置 |
|------|------|---------|
| 可执行脚本 | Python/Shell,支持 `--help` | `~/.aibox/skills/` |
| Markdown 技能 | `.md` 文件,首行为简介 | `~/.aibox/skills/` |
### 9.2 内置技能
| 技能名 | 文件 | 功能 |
|--------|------|------|
| hello-world | `skills/hello-world.py` | 示例技能 |
| bing_search | `skills/bing_search.py` | 必应搜索 |
| ssh-exec | `skills/ssh-exec.py` | SSH 远程执行 |
| sftp-transfer | `skills/sftp-transfer.py` | SFTP 文件传输 |
| facerec | `skills/facerec.py` | 人脸识别 |
| aes-crypto | `skills/aes-crypto.py` | AES 加密/解密 |
| gitclient | `skills/gitclient.py` | Git 操作封装 |
### 9.3 技能热重载
```python
# 新技能放置到 ~/.aibox/skills/
# 调用 reload_skills 工具
# AI 下次对话时即可使用
```
### 9.4 技能格式规范
详见 [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
---
## 十、配置参考
### 10.1 完整配置项
#### system 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `save_dir` | str | `~/.aibox` | 数据存储目录 |
| `datetime_format` | str | `%Y-%m-%d %H:%M:%S` | 日期时间格式 |
| `date_format` | str | `%Y%m%d_%H%M%S` | 文件名日期格式 |
| `debug_level` | int | `1` | 调试级别 0-3 |
| `load_history_by_default` | bool | `true` | 启动时加载历史对话 |
| `log_file` | str | `~/.aibox/ai.log` | 日志文件路径 |
| `log_max_size` | int | `10485760` | 日志最大字节数(10MB) |
| `log_backup_count` | int | `5` | 日志备份数量 |
| `websocket_host` | str | `localhost` | WebSocket 监听地址 |
| `websocket_port` | int | `8765` | WebSocket 监听端口 |
| `skills_dir` | str | `~/.aibox/skills` | 技能目录 |
| `prompts_dir` | str | `~/.aibox/prompts` | 提示词目录 |
#### scheduler 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | bool | `true` | 启用任务调度器 |
| `check_interval` | float | `1.0` | 检查间隔(秒) |
| `max_tasks_per_run` | int | `10` | 单次最大触发任务数 |
| `broadcast_enabled` | bool | `true` | 广播任务执行结果 |
#### api 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `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 环境变量名 |
| `timeout` | int | `30` | 请求超时(秒) |
| `temperature` | float | `0.7` | 生成温度 0-1 |
#### context 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `max_history_messages` | int | `500` | 历史消息上限 |
| `max_context_messages` | int | `120` | 上下文窗口 |
| `max_tokens` | int | `8192` | 最大生成 Token |
| `max_history_conversations` | int | `5` | 保留历史对话数 |
| `preserve_system_prompts` | bool | `true` | 保留系统提示词 |
| `compress_enabled` | bool | `true` | 启用对话压缩 |
| `compress_message_threshold` | int | `200` | 压缩消息阈值 |
| `compress_token_threshold` | int | `120000` | 压缩 Token 阈值 |
| `compress_ratio` | float | `0.2` | 压缩保留比例 |
| `compress_keep_recent` | int | `8` | 保留最近消息数 |
#### memo 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `default_reminder_minutes` | int | `60` | 默认提醒分钟数 |
| `max_memos_per_check` | int | `10` | 单次最大触发任务数 |
| `allow_user_tasks` | bool | `true` | 允许用户创建任务 |
#### proxy 段
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `type` | str | `""` | 代理类型(socks5/http) |
| `host` | str | `""` | 代理主机 |
| `port` | int | `0` | 代理端口 |
| `username` | str | `""` | 代理用户名 |
| `password` | str | `""` | 代理密码 |
### 10.2 环境变量
| 变量 | 说明 |
|------|------|
| `DEEPSEEK_API_KEY` | DeepSeek API Key(必填) |
| `AI_CONFIG_PATH` | 自定义配置文件路径 |
---
## 十一、命令行参数
```bash
python main.py [选项]
```
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `--verbose`, `-v` | flag | `false` | 显示详细日志 |
| `--debug` | int | `None` | 调试级别 0-3 |
| `--lang` | str | `None` | 强制语言 zh/en |
| `--host` | str | `None` | 覆盖 WebSocket 主机 |
| `--port` | int | `None` | 覆盖 WebSocket 端口 |
| `--no-scheduler` | flag | `false` | 禁用任务调度器 |
| `--scheduler-interval` | float | `1.0` | 调度器检查间隔 |
| `--config`, `-c` | str | `None` | 指定配置文件 |
| `--check-memo` | flag | `false` | 检查到期任务并退出 |
| `--memo-add` | str | `None` | 添加任务(CLI) |
| `--memo-list` | flag | `false` | 列出任务 |
| `--memo-complete` | int | `None` | 完成指定任务 |
### 使用示例
```bash
# 启动服务器
python main.py
# 详细日志 + 指定端口
python main.py --verbose --port 9000
# 检查并执行到期任务(cron 场景)
python main.py --check-memo
# 添加任务
python main.py --memo-add "每日备份|执行数据库备份|+1h"
# 列出所有任务
python main.py --memo-list
```
---
## 十二、前端集成
### 12.1 前端文件
| 文件 | 说明 |
|------|------|
| `entry.html` | 简单入口页(重定向到 frontend.html) |
| `frontend.html` | 完整前端界面(对话/任务/设置) |
### 12.2 WebSocket 连接
```javascript
const ws = new WebSocket('ws://localhost:8765');
// 发送消息
ws.send(JSON.stringify({
type: 'message',
content: '你好'
}));
// 接收消息
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'chunk':
appendToChat(data.content);
break;
case 'complete':
finishResponse();
break;
case 'error':
showError(data.content);
break;
}
};
```
---
## 十三、部署
### 13.1 目录结构
```
/opt/micro_servers/aibox/
├── main.py
├── client.py
├── skills/
├── _run.sh # core 拉起入口
├── _set.sh # 首次安装
└── requirements.txt
```
### 13.2 core init.txt 配置
```bash
# aibox 由 core 拉起
bash /opt/micro_servers/aibox/_run.sh
```
### 13.3 systemd 单元(可选)
```ini
[Unit]
Description=AIBOX AI Agent
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/micro_servers/aibox
ExecStart=/usr/bin/python3 main.py
Restart=always
RestartSec=5
Environment=DEEPSEEK_API_KEY=xxx
[Install]
WantedBy=multi-user.target
```
---
## 十四、已知限制
| ID | 描述 | 严重等级 |
|----|------|---------|
| L1 | 单实例运行,不支持多进程 | 🟡 Medium |
| L2 | 记忆库无向量搜索,仅关键词匹配 | 🟡 Medium |
| L3 | 对话压缩可能丢失早期重要信息 | 🟢 Low |
| L4 | 工具执行无超时控制(部分工具) | 🟡 Medium |
| L5 | 日志无远程推送,仅本地文件 | 🟢 Low |
| L6 | 无用户认证,WebSocket 连接可被滥用 | 🔴 High |
| L7 | API Key 以明文存储在环境变量或文件中 | 🟡 Medium |
### L6 详细说明
WebSocket 服务器**无任何认证**,任何能连接到 `ws://localhost:8765` 的客户端都可以:
- 与 AI 对话(消耗 API 额度)
- 执行系统命令(`run_command`)
- 操作文件系统(`read_file`/`write_file`)
- 创建/删除任务
**建议**:
1. 仅绑定 `localhost`(默认配置已这样做)
2. 添加 API Token 认证
3. 限制 `run_command` 可执行的命令范围
---
## 十五、开发指南
### 15.1 添加新工具
1. 继承 `Tool` 基类
2. 实现 `get_name()`、`get_description()`、`get_parameters()`、`execute()`
3. 在 `DeepSeekChat._register_tools()` 中注册
4. 更新 README 文档
```python
class MyTool(Tool):
def get_name(self) -> str:
return "my_tool"
def get_description(self) -> str:
return "我的自定义工具"
def get_parameters(self) -> Dict:
return {
"type": "object",
"properties": {
"param": {"type": "string", "description": "参数说明"}
},
"required": ["param"]
}
def execute(self, param: str, **kwargs) -> str:
return f"执行结果: {param}"
```
### 15.2 添加新命令
在 `WebSocketHandler.handle_command()` 中添加:
```python
elif command == "/mycommand":
result = "命令执行结果"
await self.send_message(websocket, {
"type": "command_result",
"content": result
})
```
### 15.3 添加配置项
1. 在 `ConfigManager._get_default_config()` 中添加默认值
2. 在 README 文档中添加说明
3. 确保 `_merge_configs()` 能正确处理新增字段
---
## 十六、故障排查
### 16.1 常见问题
| 问题 | 排查步骤 |
|------|---------|
| API Key 无效 | 检查 `echo $DEEPSEEK_API_KEY` 或 `~/.cert/deepseek.key` |
| 技能不生效 | 确认技能文件在 `~/.aibox/skills/` 且有执行权限 |
| 任务不执行 | 检查调度器状态:`/tasks`;检查时间设置 |
| 对话压缩丢失信息 | 调整 `compress_message_threshold` 和 `compress_keep_recent` |
| WebSocket 断开 | 检查服务器进程:`ps aux | grep main.py`;查看日志 |
### 16.2 日志位置
```bash
# 查看实时日志
tail -f ~/.aibox/ai.log
# 查看最近错误
grep "ERROR" ~/.aibox/ai.log | tail -20
```
### 16.3 数据库检查
```bash
# 检查记忆库
sqlite3 ~/.aibox/memories.db "SELECT * FROM memories LIMIT 10;"
# 检查任务库
sqlite3 ~/.aibox/memos.db "SELECT * FROM memos WHERE status='pending';"
```
---
## 十七、版本历史
| 版本 | 日期 | 主要变更 |
|------|------|---------|
| v2.0 | 当前 | 新增 `ask_user`、`update_soul`、`create_skill`、`install_skill`、`set_proxy`/`get_proxy` 等工具;支持 Markdown 技能;对话压缩支持 Token 阈值 |
| v1.0 | — | 初始版本:WebSocket 服务器、文件/网页/记忆操作、任务调度、扩展技能、多语言 |
---
## 附录
### A. 配置文件示例
完整默认配置见 [main.py:1100-1180](file:///Users/qingsong/micro_servers/aibox/main.py#L1100-L1180)
### B. 数据库 Schema
完整建表语句见:
- 记忆库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoryManager._init_db()`
- 任务库:[main.py](file:///Users/qingsong/micro_servers/aibox/main.py) 中 `MemoScheduler._init_db()`
### C. 参考文档
- [DeepSeek API 文档](https://platform.deepseek.com/docs)
- [websockets 库文档](https://websockets.readthedocs.io/)
- [SKILLS_FORMAT.md](file:///Users/qingsong/micro_servers/aibox/SKILLS_FORMAT.md)
---
*本文档由代码审查工具生成,供开发团队参考。*
+1 -1
View File
@@ -3,6 +3,6 @@ cd "$(dirname "$0")" || exit
source ./_setenv.sh
source ./venv/bin/activate
export PATH=${PATH}:~/.aibox/skills
exec python main.py $@
exec python aibox.py $@
+58 -3
View File
@@ -871,7 +871,8 @@ update_soul content="我是...(新的自我定义)"
},
{
'filename': '9_language_instruction.md',
'content': """# 语言说明
'content': """
# 语言说明
## 语言选择
- 根据用户的输入语言来选择回复语言
@@ -885,11 +886,23 @@ update_soul content="我是...(新的自我定义)"
- 重要信息可以加粗或标记
记住:用用户习惯的语言交流!
---
## 提问行为(正常对话时)
当你在处理用户问题过程中遇到以下情况时,**应主动使用 `ask_user` 工具向用户提问**:
- 信息不足,无法准确回答
- 任务有多种可行方案,需要用户选择
- 用户需求模糊,需要进一步澄清
- 执行操作前需要用户确认或提供额外参数
**注意**:此提问行为仅适用于**用户主动发起的对话**,不适用于定时任务触发的自动对话(定时任务中禁止提问)。
"""
},
{
'filename': '10_task_handling.md',
'content': """# 任务处理规则
'content': """
# 任务处理规则
## 定时任务
当收到定时任务触发时:
@@ -897,6 +910,7 @@ update_soul content="我是...(新的自我定义)"
2. 不要偏离主题
3. 使用相应工具执行操作
4. 完成后使用 `save_memory_and_end_conversation` 结束对话
5. **不要在任务执行过程中创建新的定时任务(除非任务明确要求),也不要向用户提问(因为此对话是自动触发的)**
## 即时任务
当任务需要立即执行时:
@@ -909,6 +923,7 @@ update_soul content="我是...(新的自我定义)"
- 每次触发时专注当前执行
- 不要重复之前已完成的工作
- 系统会自动更新下一次执行时间
"""
}
]
@@ -2894,9 +2909,12 @@ class TaskScheduler:
2. 根据任务内容进行适当的操作和回答
3. 任务处理完成后,如果不需要继续对话,可以结束对话
4. 系统已经自动更新了任务状态(更新了执行时间和次数),你不需要手动处理任务完成逻辑
5. 不要在任务执行过程中创建新的定时任务(除非任务明确要求),也不要向用户提问(因为此对话是自动触发的)
请现在开始处理这个任务。
"""
user_input = focus_prompt + "\n\n" + user_input
result = "任务已触发AI对话"
@@ -4268,6 +4286,36 @@ class ReloadSkillsTool(Tool):
output_queue.put(("complete", result))
# ==================== 询问工具 ========================
class AskUserTool(Tool):
"""智能体遇到了麻烦,主动向用户问一个问题"""
def get_name(self) -> str:
return "ask_user"
def get_description(self) -> str:
return "你遇到了一个问题,想向用户询问一个问题的工具。"
def get_parameters(self) -> Dict:
''' TODO: 调用子代理生成提示词 '''
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": (
"你现在遇到一个问题,你想询问用户,现在提供你的问题。"
)
}
},
"required": []
}
def execute(self, question: str, **kwargs) -> str:
return (
"你遇到了想搞清楚的事情: " + question + "\n你现在可以询问用户。"
)
# ==================== 安装技能工具 ====================
class InstallSkillTool(Tool):
@@ -6547,6 +6595,9 @@ class DeepSeekChat:
# 增强版保存记忆并结束对话工具
if exit_callback and self.memory_db:
builtin_tools.append(SaveMemoryAndEndConversationTool(exit_callback, self.memory_db, self.prompt_manager, self.logger))
# 添加询问用户的工具
builtin_tools.append(AskUserTool(self.logger))
# 添加技能热重载工具 & 技能安装指引工具 & 创建技能工具
builtin_tools.append(ReloadSkillsTool(self.tool_registry, self.logger))
@@ -6819,7 +6870,11 @@ class DeepSeekChat:
## 第二步:搜索相关记忆
在每次回答用户问题前,**必须**使用 `memory search` 工具搜索记忆库中与当前话题相关的记忆,确保回答的连贯性和准确性。
## 第三步:执行任务或回答问题
## 第三步:积极向用户提问
如果你有任何无法确定的事情,那么你需要用户提供额外信息,
请务必使用 `ask_user` 工具向用户提问,以便获得更准确的指引。
## 第四步:执行任务或回答问题
根据搜索结果,选择合适的工具或直接回答。
【重要提醒】
+384
View File
@@ -0,0 +1,384 @@
# bot4cj — 机器人硬件脚本解析器
基于**仓颉(Cangjie)** 编程语言和 Linux I2C 接口的机器人动作脚本解析器。用一种矩阵式脚本来描述机器人的硬件构成与动作序列,解析后驱动真实硬件执行。
## 架构分层
```
┌──────────────────────────────────────────────────────────────┐
│ .mt 矩阵脚本(硬件定义 + 动作序列) │
├──────────────────────────────────────────────────────────────┤
│ MatrixParser(P.cj) — FSM 逐字符解析 → MatrixObject 链表 │
├──────────────────────────────────────────────────────────────┤
│ RobotStruct(r.cj) — 从矩阵对象构建硬件变量(createHardVars) │
│ MatrixVars_Hardware 基类 + 闭包回调: │
│ · build() → rise 回调(打开硬件、获取资源) │
│ · play() → things 回调(执行动作,参数/返回值均为 JsonValue) │
│ · free() → free 回调(释放资源) │
├──────────────────────────────────────────────────────────────┤
│ 硬件实现:hd/*.cj │
│ PCA9685(I2C 0x40, 50Hz PWM, 角度→脉宽 0.5~2.5ms) │
│ Servo(舵机:max/min/offset 校准 + 角度翻转) │
│ MPU6050(陀螺+加速度,±500°/s ±8g,后台 10ms 采样线程+卡尔曼) │
│ Speaker(正弦波发声,5ms 淡入淡出) │
│ fb.cj(外部硬件扩展口) │
├──────────────────────────────────────────────────────────────┤
│ 系统库:cpp/ + cuda/ │
│ libi2c4cj.so — I2C glibc 封装 + 20 槽锁数组 │
│ libmt4cj.so — FP32 矩阵运算(CPU / CUDA 二选一) │
│ libfft4cj.so — 基-2 Cooley-Tukey FFT │
│ libcnn4cj.so — Conv/Pool/GA-Pool/Flatten + Adam/Momentum │
│ libsnd4cj.so — WaveAPI / ALSA 音频播放 │
├──────────────────────────────────────────────────────────────┤
│ 数学/NN 库:math/*.cj │
│ Matrix1(二维矩阵)+ Tensor + 6 种激活函数 + Xavier 初始化 │
│ Nn1 全连接层(前向/反向/权重更新) │
│ Af(Sigmoid/Tanh/ReLU/Leaky_ReLU/Swish/Softmax) │
└──────────────────────────────────────────────────────────────┘
```
## 技术栈
| 层 | 技术 | 说明 |
|----|------|------|
| 应用层 | **仓颉 Cangjie** | 主程序、解析器、机器人结构、数学库、HTTP 服务 |
| 系统层 | C / C++ | I2C 驱动、矩阵运算、FFT、CNN、音频 |
| 加速层 | CUDA(可选) | `MODE=cpu/cuda` 切换 |
| 脚本层 | `.mt` 矩阵脚本 | 硬件定义 + 动作序列 |
| 依赖包 | ctype4cj / pthread4cj / spi4cj / lua4cj | 仓颉官方扩展包 |
| 构建 | Makefile(C/C++ 共享库)+ cjpm.toml(仓颉包管理) | — |
## 目录结构
```
bot4cj/
├── src/
│ ├── main.cj # 入口:CLI 参数解析(-i/-o/--test)+ stdin 管道执行
│ ├── P.cj # 矩阵脚本解析器(FSM 逐字符解析)
│ ├── r.cj # RobotStruct + MatrixVars_Hardware 基类
│ ├── testCNN.cj # CNN 测试入口
│ ├── testCNN1.cj # 几何 CNN 测试
│ ├── hd/
│ │ ├── I2C.cj # I2C 封装 + 20 槽锁数组
│ │ ├── PCA9685.cj # PCA9685 舵机控制板(已标记由 Lua 替代)
│ │ ├── Servo.cj # 舵机:校准 + 角度翻转
│ │ ├── MPU6050.cj # MPU6050 陀螺+加速度+卡尔曼滤波
│ │ ├── snd.cj # 扬声器
│ │ ├── fb.cj # 外部硬件扩展
│ │ └── extern/
│ │ └── macro_ExternHardware.cj
│ ├── math/
│ │ ├── Matrix.cj # 二维矩阵(+运算符重载)
│ │ ├── Tensor.cj # 张量
│ │ ├── Af.cj # 6 种激活函数
│ │ ├── Ini.cj # Xavier/Kaiming 初始化
│ │ ├── nn.cj # 全连接层 Nn1
│ │ ├── nns.cj # 旧版神经网络(XNOR 门测试)
│ │ ├── cnn.cj # CNN 仓颉绑定
│ │ ├── pooling.cj # 池化
│ │ ├── flatten.cj # Flatten
│ │ ├── fft.cj # FFT 仓颉绑定
│ │ ├── kalman.cj # 卡尔曼滤波
│ │ └── Adam.cj # Adam 优化器
│ ├── cpp/ # C/C++ 系统库源
│ │ ├── I2C.c # I2C glibc 封装
│ │ ├── Matrix.cpp # CPU 矩阵运算
│ │ ├── fft1.c # FFT 单实序列
│ │ ├── fft.cpp # FFT 双实序列 + 基-2 Cooley-Tukey
│ │ ├── kalman.c # 卡尔曼滤波
│ │ ├── snd.cpp # 音频播放(WaveAPI/ALSA)
│ │ └── cnn/ # 纯 C++ CNN(Conv/Pool/GA-Pool/Flatten)
│ └── cuda/
│ ├── matrix.cu # GPU 矩阵运算 kernel
│ └── error.cuh # CUDA 错误检查宏
├── modules/ # Lua 硬件模块(ADS1115/ADS1256/PCA9685/SPI)
├── scripts/ # .mt 动作脚本(std/jump/lie-down/tilt-* 等)
├── test/ # C/Lua 测试脚本
├── docs/Reports/ # 深度分析报告
├── cjpm.toml # 仓颉包配置
├── Makefile # C/C++ 共享库构建
├── _set.sh # 编译(cjpm update + build)
├── _run.sh # 运行(stdin 管道)
├── compile.ps1 # Windows 编译脚本
├── run.ps1 # Windows 运行脚本
├── README.md # 旧版说明
└── README.robot.md # 本文件
```
## 快速开始
```bash
# 1. 编译 C/C++ 共享库(一次性)
make MODE=cpu # CPU 模式(默认)
# 或
make MODE=cuda # GPU 模式(需要 nvcc)
# 2. 编译仓颉程序
bash _set.sh
# 3. 运行动作脚本(stdin 管道)
cat scripts/std.mt | bash _run.sh
```
> **硬件要求**:树莓派(aarch64)或其他 Linux I2C 设备。x86_64 开发板需自行验证 I2C 参数。Windows 暂不支持(依赖 glibc)。
## CLI 参数
```
./bot # 从 stdin 读取 .mt 脚本执行
./bot --help # 打印帮助
./bot --test nn # 运行神经网络与门测试
./bot --test cnn # 运行 CNN 测试
./bot --test cnn1 # 运行几何 CNN 测试
./bot --test matrix_o # 解析 ss/test.mt 并保存为 ss/output.mt
./bot --test matrix_2 # 解析 ss/output.mt → 执行 → 再次保存
./bot -i model.json -o out.json # 加载/保存神经网络模型(DRL 预留)
```
## .mt 脚本语法
行列式表格,每列以空格或制表符分隔,每行以分号 `;` 结尾。
### 块结构
```
@BlockName
column1 column2 column3;
value1 value2 value3;
@
```
每个 `@` 块由 `@Name` 行开始,`@` 行结束。支持的块类型:
| 块 | 说明 |
|----|------|
| `@Alias` | 列名别名(如 `A` → `angle`、`F` → `frequency`) |
| `@I2C` | 定义 I2C 设备(name + path) |
| `@PCA9685` | 定义 PCA9685 舵机控制板(name + I2C 引用 + 地址) |
| `@MPU6050` | 定义 MPU6050 传感器(name + I2C 引用 + 地址) |
| `@Speaker` | 定义扬声器(channels + sample_rate + volume + max_f + min_f) |
| `@Servo` | 定义舵机(name + max + min + offset + controller + channel) |
| `@Action` | **动作矩阵**:列头为硬件变量的属性,行为每帧的参数 |
### 占位符
`-` 表示"沿用上一行同列的值",当前行该列不产生调用。
### 完整示例
```mt
@Alias
name alias;
A angle;
F frequency;
@
@I2C
name path;
i2c-0 /dev/i2c-0;
i2c-1 /dev/i2c-1;
@
@PCA9685
name I2C address;
pca9685-0 i2c-1 0x40;
@
@Speaker
name channels sample_rate volumn max_f min_f;
spk 2 48000 0.6 5000 -;
@
@Servo
name max min offset controller channel;
s1 90 - - pca9685-0 0;
s2 180 - - - 1;
s3 -90 - - - 2;
s4 -180 - - - 3;
@
@Action
s1.A s2.A s3.A s4.A spk.F spk.delay;
45 - 45 - 10 200; # 站立
90 - - - - 20; # 左转
@
```
## 执行流程
```
stdin.read()
↓
P.MatrixParser (FSM 逐字符解析)
↓
LinkedList<MatrixObject> 链表
↓
RobotStruct.createHardVars() # 按 @I2C/@PCA9685/@Servo/@Speaker/@MPU6050 构建硬件变量
↓
robot.active() # build():逐个硬件调用 rise 回调(打开 I2C、初始化 PWM 等)
↓
robot.run() # 逐行遍历 @Action 矩阵,每列调用对应硬件的 play() 回调
↓
robot.deactive() # free():倒序释放资源
```
`play()` 的输入输出均为 `JsonValue`,实现了硬件与脚本之间的统一通信协议。
## 已支持的硬件
### PCA9685(I2C 舵机控制板)
- 地址:默认 `0x40`
- PWM 频率:50Hz(舵机标准 20ms 周期)
- 舵机脉宽映射:0°→0.5ms,180°→2.5ms
- 角度映射到 12 位 PWM 值(0-4095)
- **注意**:PCA9685.cj 代码已标记"由 Lua 脚本取代",保留作备用
### Servo(舵机)
- 支持 `max` / `min` 角度限制与 `offset` 漂移校准
- `max < 0` 时自动翻转角度(反向安装的舵机)
- 通过闭包回调与实际硬件(PCA9685/Lua 模块)解耦
### MPU6050(6 轴传感器)
- 陀螺仪:±500°/s 量程
- 加速度计:±8g 量程
- 后台 10ms 采样线程(pthread_create + pthread_cancel)
- 卡尔曼滤波融合数据
- **已知问题**:`ADDR_0` 与 `ADDR_1` 常量均为 `0x68`([MPU6050.cj:14-15](src/hd/MPU6050.cj#L14-L15)),第二个地址应改为 `0x69`
### Speaker(扬声器)
- 支持正弦波发声(指定频率 `F` + 延时 `delay`)
- 5ms 淡入淡出,避免"咔哒"声
- 通道数/采样率/音量可在 `.mt` 中配置
- **已知问题**:`play()` 仅 sleep 指定延时,真实发声依赖底层 C++ 实现
## 机器人型号适配
### Dashun 蜘蛛机器人(默认)
10 舵机 + 2 云台 + 扬声器,建模文件见 [gopsc/Dashun](https://github.com/gopsc/Dashun):
| 舵机 | 关节 | 舵机 | 关节 |
|------|------|------|------|
| s1 | 左下水平 (rb0) | s6 | 右上垂直 (lf1) |
| s2 | 左下垂直 (rb1) | s7 | 左上水平 (rf0) |
| s3 | 右下水平 (lb0) | s8 | 左上垂直 (rf1) |
| s4 | 右下垂直 (lb1) | s9 | 云台1 (cam0) |
| s5 | 右上水平 (lf0) | s10 | 云台2 (cam1) |
### 可用脚本
`scripts/` 目录下预写了多种动作脚本:
- `std.mt` — 站立姿态
- `jump.mt` — 跳跃
- `lie-down.mt` — 趴下
- `left-hand.mt` / `rght-hand.mt` — 单手动作
- `left-turn.mt` / `tilt-f.mt` / `tilt-b.mt` / `tilt-l.mt` / `tilt-r.mt` — 转向与倾斜
## 构建
### 依赖
```bash
# 仓颉 1.1.3
# 仓颉官方扩展包:ctype4cj / pthread4cj / spi4cj / lua4cj
# libc / libpthread
# liblua-5.4(可选,Lua 硬件模块支持)
sudo ln -s /usr/lib/aarch64-linux-gnu/liblua5.4.so /usr/lib/aarch64-linux-gnu/liblua.so
sudo ln -s /usr/lib/x86_64-linux-gnu/liblua5.4.so /usr/lib/x86_64-linux-gnu/liblua.so
```
### 构建步骤
```bash
# 1. 构建 C/C++ 共享库
make MODE=cpu
# 产出:libi2c4cj.so libmt4cj.so libfft4cj.so libsnd4cj.so
# 2. (可选)构建 CNN
make libcnn4cj.so
# 3. (可选)CUDA 模式需 NVIDIA CUDA Toolkit
make MODE=cuda
# 4. 仓颉主程序
bash _set.sh
```
[_set.sh](_set.sh) 执行 `cjpm update` + `cjpm build`,自动解析 cjpm.toml 依赖。
### 运行
```bash
bash _run.sh scripts/std.mt
# 或
cat scripts/std.mt | bash _run.sh
```
[_run.sh](_run.sh) 以 `cjStackSize=64MB` / `cjHeapSize=64MB` 启动。
## 神经网络支持
项目内置了全连接层 Nn1([math/nn.cj](src/math/nn.cj))与 CNN([math/cnn.cj](src/math/cnn.cj) + [cpp/cnn/](src/cpp/cnn/)):
- **Nn1**:多层感知机,支持前向传播、反向传播、权重更新
- **CNN**:Conv → ReLU → Pool → GA-Pool → Flatten → FC
- **优化器**:Adam / Momentum
- **激活函数**:Sigmoid / Tanh / ReLU / Leaky_ReLU / Swish / Softmax
- **初始化**:Xavier / Kaiming
### 测试
```bash
./bot --test nn # 与门训练(2 输入 → 1 输出)
./bot --test cnn # CNN 前向/反向测试
./bot --test cnn1 # 几何分类 CNN 测试
```
### DRL 预留(未完成)
main.cj 中预留了 DRL 流程(注释状态):加载预训练权重 → 执行动作 → 保存微调模型。通过 `-i` / `-o` 参数指定输入/输出路径。
## 安全与限制
### I2C 总线保护
- [hd/I2C.cj](src/hd/I2C.cj) 内置 **20 槽锁数组**,多硬件共享 I2C 总线时自动互斥
- 每个 I2C fd 独立加锁,避免并发写入冲突
### 硬件关闭时机
- `MatrixVars_Hardware` 析构依赖仓颉 GC,**不保证**在确定性时机调用 `free()`
- 机器人使用 `try/finally` 在 [main.cj:80-87](src/main.cj#L80) 中显式调用 `deactive()`,确保硬件资源正确释放
### 线程安全
- MPU6050 采样通过 `pthread_create` 启动后台线程
- `pthread_cancel` 依赖系统定义的取消点(sleep / 系统调用),可能在非安全点中断
### 平台支持
- ✅ Linux aarch64(树莓派)/ x86_64
- ❌ Windows(依赖 glibc)
## 已知问题
| ID | 描述 | 影响 |
|----|------|------|
| D1 | 仓颉 GC 不保证析构器调用,I2C fd 可能泄漏 | 长时间运行后 I2C 资源耗尽 |
| D2 | Speaker `play()` 仅 sleep,真实发声未接入 C++ | 扬声器无实际声音 |
| D3 | MPU6050 `pthread_cancel` 可能在非安全点中断 | 传感器数据不一致 |
| D4 | PCA9685 初始化被标记为"由 Lua 取代",但仍保留主路径 | 代码存在双实现 |
| D5 | `_set.sh` 会删除 `a.out` | 误删用户产物 |
| D6 | Softmax 雅可比矩阵实现错误(math/Af.cj) | NN 训练收敛异常 |
| D7 | Tensor 乘法仅支持 2D,3D+ 会 panic | 限制模型复杂度 |
| D8 | Tanh 导数实现错误(使用 `1 - y^2` 而非 `1 - tanh^2(x)`) | NN 训练收敛异常 |
| D9 | CNN `testGeometryCNN` 使用随机数据 | 测试无实际意义 |
| D10 | MPU6050 `ADDR_0` 与 `ADDR_1` 均为 `0x68` | 无法同时使用两个 MPU6050 |
| D11 | `@Action` 与 `@Action_Auto` 并存未合并 | 脚本语法冗余 |
## 相关资源
- [Dashun 蜘蛛机器人建模](https://github.com/gopsc/Dashun)
- [深度分析报告](docs/Reports/analysis_20260525_00.md)(28 章节,1280 行)
- [仓颉编程语言](https://cangjie-lang.org/)
- [PCA9685 数据手册](https://www.nxp.com/docs/en/data-sheet/PCA9685.pdf)
- [MPU6050 数据手册](https://invensense.tdk.com/wp-content/uploads/2015/02/MPU-6000-Datasheet1.pdf)
## 许可证
MIT License
+6
View File
@@ -23,6 +23,10 @@
## 安装
```bash
# 自动创建 venv 并安装依赖(推荐)
bash _set.sh
# 或手动安装
pip install Pillow
```
@@ -138,7 +142,9 @@ png_b64 = resp["data"]["image"]
```
captcha/
├── captcha_service.py # 服务主程序(pymsg 服务 + 验证码生成与校验)
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
├── _run.sh # 启动脚本(cd 后 exec python3 captcha_service.py)
├── requirements.txt # Python 依赖(pymsg、Pillow)
└── README.md
```
+381
View File
@@ -0,0 +1,381 @@
# Core 模块代码审查日志
> 审查日期:2026-07-30 14:30:00 (Asia/Shanghai)
> 审查人:Automated Review
> 审查范围:`core/` 全模块(C++20,PMC/Nidus 进程托管服务)
---
## 一、模块概述
| 属性 | 值 |
|------|-----|
| 定位 | 微服务进程托管控制器(Process Machine Controller) |
| 语言 | C++20 |
| 构建 | Makefile(GCC)+ systemd 部署 |
| 入口 | `nidus.out` |
| 端口 | HTTP API: 5200(仅本机 `127.0.0.1`) |
| 启动列表 | `init.txt`(9 个子服务) |
### 依赖项
| 库 | 用途 | 版本要求 |
|----|------|---------|
| Boost.Process | 子进程创建/管理 | — |
| Boost.JSON | JSON 解析与序列化 | — |
| Boost.Interprocess | POSIX 消息队列 | — |
| Boost.Program.Options | CLI 参数解析 | — |
| UniServe_libth | 自定义线程/HTTP 服务器封装 | 自研 |
| OpenSSL | TLS(HTTP 服务器证书) | — |
### 目录结构
```
core/
├── include/
│ ├── cn/ # 中文代码库(自研)
│ │ ├── 中文化.hpp # 保留字映射 + 中文字符串/IO 类
│ │ ├── 链串.hpp # 分块链表
│ │ ├── 顺序表.hpp # 数组封装
│ │ ├── 向量.hpp # 动态数组
│ │ └── 异常.hpp # 异常层次
│ ├── logs/
│ │ └── Logger.hpp # 单例日志(控制台输出)
│ ├── main/
│ │ ├── HttpTask.hh # HTTP 服务器任务
│ │ ├── PPool.hh # 进程池
│ │ ├── ProcessTask.hh # 子进程任务
│ │ ├── Tttask.hh # 通信任务(消息队列监听)
│ │ ├── pmc_mtd.hh # 进程管理方法集
│ │ └── subsys_call.hh # 子系统调用封装
│ └── tt/
│ ├── Mq.h # POSIX 消息队列封装
│ └── Pipe.h # FIFO 管道封装
├── src/
│ ├── main.cpp # 主入口
│ └── main/ # 实现文件
├── init.txt # 自启动列表
├── Makefile # 构建脚本
├── Nidus.service # systemd 单元
├── README.md
├── _set.sh # 编译+安装
└── _run.sh # 运行脚本
```
---
## 二、审查发现
### 🔴 严重问题 (P0)
#### [CORE-S01] 命令注入漏洞
- **文件**: `src/main/HttpTask.cpp:230-247`
- **时间**: 2026-07-30 14:32:15
- **描述**: `exec` 路由直接将 URL 查询参数 `command` 传递给 `boost::process::child`,无任何过滤或校验。攻击者可构造恶意请求执行任意系统命令。
```cpp
// 危险代码
std::string result = execCommand(command, target);
// command 直接来自 HTTP 请求参数
```
- **风险**: 远程代码执行(RCE)
- **严重等级**: 🔴 Critical
- **修复建议**:
1. 实现命令白名单机制
2. 使用 `execvp` 而非 `system`/shell 模式
3. 限制可执行命令的路径范围
4. 添加参数长度和字符集校验
#### [CORE-S02] HTTP API 无任何鉴权
- **文件**: `src/main/HttpTask.cpp:130-340`
- **时间**: 2026-07-30 14:33:00
- **描述**: HTTP API (`/list`, `/exec`, `/kill`) 完全开放,无认证、无授权、无访问控制。任何人(本地)都可以:
- 列出所有子进程
- 启动任意进程
- 终止任意进程
- **风险**: 权限绕过、未授权操作
- **严重等级**: 🔴 Critical
- **修复建议**:
1. 添加 API Token 认证(请求头 `Authorization: Bearer <token>`)
2. 实现 IP 白名单(仅允许 `127.0.0.1`)
3. 添加请求速率限制
4. 记录所有管理操作审计日志
#### [CORE-S03] ProcessTask 空指针解引用
- **文件**: `include/main/ProcessTask.hh:19,36`
- **时间**: 2026-07-30 14:35:22
- **描述**: `isRunning()` 和 `pid()` 方法未检查 `p` 是否为空。如果在 `start()` 之前调用这些方法,会导致空指针解引用(段错误)。
```cpp
bool isRunning() override { return p && p->running(); } // 应加 p 非空检查
pid_t pid() { return p->id(); } // 危险!p 可能为 nullptr
```
- **风险**: 程序崩溃
- **严重等级**: 🔴 Critical
- **修复建议**:
```cpp
bool isRunning() override { return p != nullptr && p->running(); }
pid_t pid() { return p ? p->id() : -1; }
```
#### [CORE-S04] exec 路由路径穿越
- **文件**: `src/main/HttpTask.cpp:232-236`
- **时间**: 2026-07-30 14:37:10
- **描述**: `getExecCommand` 函数从 URL 参数提取命令,允许包含 `..` 路径穿越符,可能执行预期外的脚本。
- **风险**: 路径遍历
- **严重等级**: 🔴 High
- **修复建议**:
- 白名单校验,仅允许执行 `init.txt` 中列出的命令
- 规范化路径后检查是否在允许目录内
### 🟡 中等问题 (P1)
#### [CORE-S05] 进程池无线程安全保护
- **文件**: `src/main/PPool.cpp`
- **时间**: 2026-07-30 14:40:05
- **描述**: `PPool` 类使用 `std::list<std::shared_ptr<ProcessTask>>` 作为内部存储,但无互斥锁保护。`HttpTask` 和 `Tttask` 可能并发调用 `crtp()`、`kill()`、`clr()` 等方法,导致数据竞争。
- **风险**: 数据竞争、未定义行为
- **严重等级**: 🟡 Medium
- **修复建议**: 添加 `std::mutex` 保护所有公共方法
#### [CORE-S06] 消息队列命名冲突
- **文件**: `src/main/Tttask.cpp:41-42`
- **时间**: 2026-07-30 14:42:30
- **描述**: 使用 `getpid()` 作为消息队列名。同一台机器上启动多个 Nidus 实例时,可能发生队列名冲突。
```cpp
name = std::to_string(getpid()); // 仅用 PID,无唯一化
```
- **风险**: 多实例冲突
- **严重等级**: 🟡 Medium
- **修复建议**: 使用 `PID + 时间戳` 或 `PID + 随机后缀` 组合命名
#### [CORE-S07] 管道文件残留
- **文件**: `src/main/subsys_call.cpp`
- **时间**: 2026-07-30 14:44:18
- **描述**: 在异常路径中,`Mq` 和 `Pipe` 对象的析构器可能未正确执行(依赖栈展开),导致 `/tmp/pst-*` 文件残留,长期运行会占满 `/tmp` 空间。
- **风险**: 磁盘空间耗尽
- **严重等级**: 🟡 Medium
- **修复建议**:
1. 在 `catch` 块中显式清理管道文件
2. 添加定期清理脚本
3. 使用 `std::filesystem::remove` 在构造器前清理旧文件
#### [CORE-S08] kill/exec IPC 路径已废弃
- **文件**: `src/main.cpp:282-296`
- **时间**: 2026-07-30 14:46:00
- **描述**: `parse_data_from_mq()` 中 `kill` 和 `exec` 的 IPC 处理路径被注释,仅 `list` 可用。HTTP 路由 `/exec` 和 `/kill` 虽然存在,但远端调用时会失败。
```cpp
// 已注释的代码
//else if (typ == "kill") {
// int trg = jv.at("kill").as_int64();
// auto flag = pmc_mtd::kill(trg);
// ...
//}
```
- **风险**: 功能缺失
- **严重等级**: 🟡 Medium
- **修复建议**: 恢复被注释的代码,或在文档中明确说明限制
#### [CORE-S09] PPool::clr() 边界条件错误
- **文件**: `src/main/PPool.cpp:20-32`
- **时间**: 2026-07-30 14:48:22
- **描述**: `clr()` 方法从尾部反向遍历删除已终止的进程,但边界条件处理复杂:
- 当 `pool.size() == 1` 时,循环条件 `it != pool.begin()` 直接跳过
- 最后的 `if (pool.size() > 0 && !(*it)->isRunning())` 可能导致双删或漏删
```cpp
auto it = --pool.end();
for (; it != pool.begin(); --it)
if (!(*it)->isRunning()) { /* erase */ }
if (pool.size() > 0 && !(*it)->isRunning()) // 可能越界
pool.erase(it);
```
- **风险**: 迭代器失效、崩溃
- **严重等级**: 🟡 Medium
- **修复建议**: 简化为正向前遍历,或使用 `std::list::remove_if`
#### [CORE-S10] 日志仅输出到控制台
- **文件**: `include/logs/Logger.hpp`
- **时间**: 2026-07-30 14:50:00
- **描述**: `Logger` 类仅支持控制台输出,日志不持久化。服务重启后日志丢失,无法进行事后审计或问题追溯。
- **风险**: 可观测性缺失
- **严重等级**: 🟡 Medium
- **修复建议**:
1. 实现日志文件轮转
2. 添加远程日志推送(syslog、Elasticsearch)
3. 集成到微服务体系的集中日志(db:8005 Logs 表)
### 🟢 轻微问题 (P2)
#### [CORE-Q01] 版本号不一致
- **文件**: `src/main.cpp:42` vs `Makefile:1`
- **时间**: 2026-07-30 14:52:10
- **描述**: `PMC_VERSION = "0.0.10"` 与 `Makefile` 中 `VERSION := 0.0.4` 不一致。
- **严重等级**: 🟢 Low
- **修复建议**: 统一版本管理,或在 Makefile 中定义并编译时注入
#### [CORE-Q02] parse_data_from_mq 语法存疑
- **文件**: `src/main.cpp:267-304`
- **时间**: 2026-07-30 14:53:45
- **描述**: 函数声明缺少 `try` 关键字(`void parse_data_from_mq(std::string& json_str) try {`),但代码能编译通过,可能使用了 C++11 的 `function-try-block` 语法。代码可读性较差,建议显式标注。
- **严重等级**: 🟢 Low
- **修复建议**: 添加注释说明使用的是函数 try-catch 语法
#### [CORE-Q03] PPool::kill 参数类型不匹配
- **文件**: `include/main/PPool.hh:35` vs `src/main/pmc_mtd.cpp:53`
- **时间**: 2026-07-30 14:55:00
- **描述**: `PPool::kill(int idx)` 接受下标参数,但 `pmc_mtd::kill(pid)` 传入的是 PID。需要遍历查找,O(n) 复杂度。
- **严重等级**: 🟢 Low
- **修复建议**:
1. 改为 `killByPid(pid_t pid)`
2. 或维护 PID → 迭代器 的映射表
#### [CORE-Q04] 缺少配置加载能力
- **文件**: 全局
- **时间**: 2026-07-30 14:56:30
- **描述**: 当前无 `config.yaml` 或类似配置文件支持,所有参数硬编码:
- HTTP 端口 5200
- 消息队列大小 2048/100
- 证书路径 `server.crt/server.key`
- **严重等级**: 🟢 Low
- **修复建议**: 引入 `config.yaml` 支持,至少分离端口和证书路径配置
#### [CORE-Q05] 中文代码库未被实际使用
- **文件**: `include/cn/`
- **时间**: 2026-07-30 14:58:00
- **描述**: `中文化.hpp`、`链串.hpp`、`顺序表.hpp`、`向量.hpp`、`异常.hpp` 构成了一套完整的中文 API,但在 `src/` 代码中未被引用。这些文件更像是演示项目,而非生产代码。
- **严重等级**: 🟢 Low
- **修复建议**:
1. 删除未使用的中文代码库
2. 或在 README 中明确标注为"实验性功能"
3. 迁移到独立仓库
#### [CORE-Q06] 缺少单元测试
- **文件**: 全局
- **时间**: 2026-07-30 14:59:30
- **描述**: 项目无任何测试代码。核心功能(进程管理、消息队列、管道通信)完全依赖手工测试。
- **严重等级**: 🟢 Low
- **修复建议**: 引入 Google Test,覆盖:
- PPool 的增删查改
- Pipe 的读写超时
- Mq 的发送接收
- 中文代码库的基础功能
---
## 三、统计汇总
| 等级 | 数量 | 问题 ID |
|------|------|---------|
| 🔴 Critical | 4 | S01, S02, S03, S04 |
| 🟡 Medium | 6 | S05, S06, S07, S08, S09, S10 |
| 🟢 Low | 6 | Q01, Q02, Q03, Q04, Q05, Q06 |
| **合计** | **16** | |
### 按类别统计
| 类别 | 问题数 | 占比 |
|------|--------|------|
| 安全(注入、鉴权) | 3 | 19% |
| 稳定性(崩溃、竞态) | 4 | 25% |
| 功能缺失 | 2 | 13% |
| 代码质量 | 5 | 31% |
| 可观测性 | 2 | 12% |
---
## 四、修复优先级建议
### 第一阶段(紧急,预计 1 天)
1. **CORE-S03**: 修复 `ProcessTask` 空指针检查(最简单,影响启动)
2. **CORE-S01**: 实现命令白名单 + 路径校验(核心安全)
3. **CORE-S02**: 添加 API Token 鉴权(核心安全)
### 第二阶段(重要,预计 3 天)
4. **CORE-S05**: 添加 PPool 互斥锁(并发安全)
5. **CORE-S08**: 恢复 kill/exec IPC 路径(功能补全)
6. **CORE-S09**: 简化 PPool::clr() 实现(稳定性)
7. **CORE-S10**: 添加日志文件持久化(可观测性)
### 第三阶段(优化,预计 1 周)
8. **CORE-S04**: 完善路径穿越防护
9. **CORE-S06**: 改进消息队列命名
10. **CORE-S07**: 添加管道清理机制
11. **CORE-Q01 ~ Q06**: 代码质量优化
---
## 五、亮点
尽管问题较多,项目仍有值得肯定之处:
| 亮点 | 说明 |
|------|------|
| 清晰的模块划分 | 进程池、线程、通信、HTTP 各司其职 |
| 双轨 IPC 设计 | 消息队列 + 管道的请求-响应模式简洁高效 |
| 中文代码实验 | 对中文编程的探索有教育意义 |
| systemd 集成 | 标准部署方式,便于运维 |
| 最小依赖 | 仅依赖 Boost,无过度设计 |
---
## 六、附录
### 审查工具
- 人工静态分析
- grep 模式匹配
- 代码路径追踪
### 参考文档
- [Boost.Process 文档](https://www.boost.org/doc/libs/release/libs/process/)
- [Boost.Interprocess 文档](https://www.boost.org/doc/libs/release/libs/interprocess/)
- [POSIX 消息队列](https://man7.org/linux/man-pages/man7/mq_overview.7.html)
- [FIFO 管道](https://man7.org/linux/man-pages/man7/fifo.7.html)
### 审查人
- 自动化审查助手
- 审查时间:2026-07-30
---
*本日志由自动化代码审查工具生成,供开发团队参考。修复建议需结合实际场景评估。*
+2 -1
View File
@@ -201,9 +201,10 @@ logs.push("gateway", "WARNING", "登录失败次数超限",
```
客户端特性:
- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送
- 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送
- fail-safe:db 不可用时静默丢弃,绝不影响业务
- 缓冲批量:队列满 50 条或超时 2 秒时自动 flush
- 长度保护:`message` 上限 8192 字节、`extra` 上限 4096 字节,超长自动截断
**db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。
+1 -1
View File
@@ -3,4 +3,4 @@ cd "$(dirname "$0")" || exit
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
#nuitka --standalone main.py
#nuitka --standalone dbv2.py
+55 -9
View File
@@ -37,8 +37,9 @@ from pymsg import MsgClient # noqa: E402
_DBV2_ADDR = '127.0.0.1'
_DBV2_PORT = 8005
# 管理员账号:删除/更新节点时豁免所有权校验(保留旧的 'admin' 以兼容历史数据)
ADMIN_USERS = ('admin@qsont.me', 'qing@qsont.me')
# 管理员判定已上移至 gateway:登录时命中 [Admin] users 白名单即在 Token payload
# 写入 is_admin=true,本服务通过 info.get('is_admin') 读取,不再本地维护名单。
# 旧 Token 无此字段,按非管理员处理(安全方向)。
def _call_dbv2(payload: dict, timeout: int = 10) -> dict:
"""通过 pymsg 短连接调用 dbv2 数据库服务。
@@ -85,11 +86,9 @@ def review_content(content: str, timeout: int = 10) -> Dict[str, Any]:
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
resp_data = resp.read().decode("utf-8")
data = json.loads(resp_data)
invalid: bool = data['invalid']
return invalid == False
#invalid = data
#return invalid
data = json.loads(resp_data)
# 兼容布尔/字符串两种返回类型:仅明确判定安全才放行,其余一律拦截(fail-closed)
return data.get('invalid') in (False, 'false')
except urllib.error.HTTPError as e:
# 服务端返回 4xx/5xx 错误时,尝试解析错误正文
@@ -253,10 +252,57 @@ def get_notes():
result = notes['data']
# 解析当前用户(可选):为每个节点附带操作权标记 can_operate,
# 前端据此显隐编辑/删除按钮。口径与 delete_note / update_note 一致:
# 管理员(is_admin)或节点 owner 有权。
# get_notes 保持公开:未携带 Token 或 Token 解析失败时,所有节点一律无权。
info = None
auth: str = request.headers.get('Authorization', '')
if auth.startswith('Bearer '):
try:
info = decode_token(auth[7:])
except Exception:
info = None
for note in result:
note['can_operate'] = bool(
info and (info.get('is_admin') or note.get('owner') == info.get('user'))
)
logs.info('dnfs', f'get_notes 查询 pid={pid} 返回{len(result)}条', extra={'ip': request.remote_addr})
return jsonify(result), 200
# 当前用户身份查询:配合前端用户面板,返回 user / is_admin。
# 与 get_notes 同款可选解析:无 Token 或畸形 Token 按未登录处理(不 500)。
@app.route('/whoami', methods=["POST"])
def whoami():
info = None
auth: str = request.headers.get('Authorization', '')
if auth.startswith('Bearer '):
try:
info = decode_token(auth[7:])
except Exception:
info = None
if info is None:
return jsonify({'login': False}), 200
resp = {
'login': True,
'user': info.get('user'),
'is_admin': bool(info.get('is_admin'))
}
# 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间
if info.get('iat'):
resp['iat'] = info['iat']
if info.get('created_at'):
resp['created_at'] = info['created_at']
if info.get('keep_time'):
resp['keep_time'] = info['keep_time']
return jsonify(resp), 200
# eg: {"id": 1}
@app.route('/delete_note', methods=['POST'])
@@ -328,7 +374,7 @@ def delete_note():
data = _call_dbv2(select_payload)
notes = data['data']
if info['user'] not in ADMIN_USERS:
if not info.get('is_admin'):
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'delete_note 所有权拒绝 id={note_id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
@@ -403,7 +449,7 @@ def update_note():
data = _call_dbv2(select_payload)
notes = data['data']
if info['user'] not in ADMIN_USERS:
if not info.get('is_admin'):
if not len(notes) == 1 or not notes[0]['owner'] == info['user']:
logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']})
return jsonify({"error": 'It\'s not your note'}), 403
+7 -6
View File
@@ -9,7 +9,7 @@ UniServe Framework 的应用层网关,C++20 实现。
- **账号鉴权**:`/register` 注册(需邮箱验证码,经 pymsg 调 smtp:8008 校验)、`/login` 登录,签发 RSA 签名的 Token。
- **反向代理**:携带 Token 的请求校验后转发到 `Proxy.opened` 中放开的内部服务。
- **CAPTCHA 接口**:`/captcha` 生成验证码 token + 文本(内存存储,5 分钟过期,一次性使用)。
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(60 秒有效);注册时校验,一次性消费。
- **邮箱验证码**:`/register/send_code` 发送 6 位验证码邮件(10 分钟有效);注册时校验,一次性消费。
- **速率限制**:登录 5 次/5 分钟、注册 3 次/10 分钟、发验证码 5 次/10 分钟,按 `X-Forwarded-For` 客户端 IP 计数(由 WAF 注入),超限一律返回 429。
- **OPTIONS 预检**:通配 `*` 返回 CORS 头,允许跨域。
@@ -78,19 +78,19 @@ UniServe Framework 的应用层网关,C++20 实现。
{ "username": "user@example.com", "password": "...", "email_code": "123456" }
```
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,fail-closed),全部通过后写入 `Users` 表。
校验顺序:密码强度 → 邮箱格式 → CAPTCHA(可选)→ **邮箱验证码**(经 pymsg 调 smtp:8008 `verify_email`,一次性消费,连续失败 5 次自动作废,fail-closed),全部通过后写入 `Users` 表。
错误码:`400`(密码/邮箱/CAPTCHA/邮箱验证码不合规)、`429`(速率超限)、`500`(数据库异常)。
### `POST /register/send_code`
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,60 秒有效)。
向目标邮箱发送注册验证码(经 pymsg 调 smtp:8008 `send_email`,10 分钟有效)。
```json
{ "email": "user@example.com" }
```
限流 5 次/10 分钟/IP。错误码:`400`(邮箱格式错误)、`429`(速率超限)、`502`(smtp 服务不可用)、`500`(发送失败)。
限流 5 次/10 分钟/IP;同一邮箱 60 秒发送冷却(由 smtp 服务执行,冷却期拒绝时返回 `retry_after` 剩余秒数与提示文案,gateway 按 429 透传;已发出且在有效期内的验证码不受影响,发送失败不计入冷却)。错误码:`400`(邮箱格式错误)、`429`(IP 速率超限或同邮箱发送冷却)、`502`(smtp 服务不可用)、`500`(发送失败)。
### `GET/POST *`(反向代理)
@@ -226,14 +226,15 @@ gateway/
## 版本
`0.5.2`(见 [src/main.cpp:29](src/main.cpp#L29))。启动时打印:
`0.5.3`(见 [src/main.cpp:30](src/main.cpp#L30))。启动时打印:
```
Gateway 0.5.2 started
Gateway 0.5.3 started
```
近期变更:
- `0.5.3`:配合 smtp 同邮箱 60 秒发送冷却——`/register/send_code` 在 smtp 返回 `retry_after` 时按 429 透传其提示文案(其余发送失败仍用通用 500 文案,SMTP 内部错误细节不向外暴露)。
- `0.5.2`:限流错误码统一为 429(登录原 400、注册/发码原 403,并修正 "attampts" 拼写);登录时用户不存在不再兜底 500——空结果统一走 401,并执行 dummy PBKDF2 校验拉平时序防用户名枚举;db 查询失败消息由误名的 "Login (Update Step) Failed" 改为 "Login (Query Step) Failed";pymsg 下游调用超时常量化(`DbC::DEFAULT_TIMEOUT`、`_TIMEOUT_CAPTCHA/_SMTP_VERIFY/_SMTP_SEND`)。
- `0.5.1`:登录成功后通过 db 更新 `Users.last_login`(`"$now"` 哨兵,db 日期适配层转为服务端当前 UTC 时间;更新失败经 THROW_EX_CUSTOM 抛 500,与其他步骤一致)。
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/bash
cd "$(dirname "$0")" || exit
sleep 1
sleep 2
exec ./build/gateway.out -i example.ini #1>/dev/null
+10
View File
@@ -14,6 +14,16 @@ opened = http://127.0.0.1:9200;http://127.0.0.1:8006;
[Token]
# Token 有效期(秒),默认 3600 = 1 小时
keep_time = 3600
# Token 刷新宽限期倍数:过期后 iat + keep_time * 倍数 窗口内仍可刷新,默认 2
refresh_grace_multiplier = 2
# Token 生命周期内最大刷新次数,超过后需重新登录,默认 10
max_refresh = 10
[Admin]
# 管理员账号白名单(分号分隔)。登录时命中即在 Token payload 写入 is_admin=true,
# 下游服务(dnfs 等)凭该字段豁免所有权校验,无需各自维护名单。
# 修改后需重启 gateway 生效;旧 Token 无此字段,按非管理员处理。
users = qing@qsont.me
[CORS]
# 允许跨域的 Origin 白名单(分号分隔)
+202 -5
View File
@@ -5,6 +5,7 @@
#include <us/SHA256.hpp>
#include <us/Sqlite3.hpp>
#include <iostream>
#include <algorithm>
#include <cctype>
#include <typeinfo>
#include <memory>
@@ -27,7 +28,7 @@ using namespace qing;
namespace json = boost::json;
namespace po = boost::program_options;
constexpr const char *_VER = "0.5.2";
constexpr const char *_VER = "0.5.3";
//////////////////////////////////
#include <sstream>
@@ -301,6 +302,10 @@ http::response<http::string_body> handleLogout(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params);
http::response<http::string_body> handleRefreshToken(
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,
@@ -324,7 +329,10 @@ static std::string _DEFT_URL;
static std::vector<std::string> _OPEN_URL;
static std::string _DB_PATH;
static int _TOKEN_KEEP_TIME = 3600; // Token 有效期(秒),可由 [Token] keep_time 配置
static int _TOKEN_REFRESH_GRACE_MULTIPLIER = 2; // Token 刷新宽限期倍数,过期后 iat + keep_time*倍数 窗口内仍可刷新
static int _TOKEN_MAX_REFRESH = 10; // Token 生命周期内最大刷新次数,可由 [Token] max_refresh 配置
static std::vector<std::string> _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置
static std::vector<std::string> _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin
/* instance items */
static std::unique_ptr<HttpServer> _SERVE;
@@ -333,6 +341,7 @@ static std::unique_ptr<MyRSA::Generator> _KEYGEN;
static RateLimiter _LOGIN_RATE;
static RateLimiter _REGISTER_RATE;
static RateLimiter _EMAIL_CODE_RATE; // 发送邮箱验证码限流
static RateLimiter _REFRESH_TOKEN_RATE; // Token 刷新限流
/* pymsg 下游调用超时(秒):
* captcha / smtp verify_email 均为本机内存操作,5s 足够;
@@ -497,7 +506,10 @@ int main(int argc, char **argv) {
_OPEN_URL = split(cf.get<std::string>("Proxy.opened", ""), ';');
_DB_PATH = cf.get<std::string>("Database.path", "");
_TOKEN_KEEP_TIME = cf.get<int>("Token.keep_time", 3600);
_TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get<int>("Token.refresh_grace_multiplier", 2);
_TOKEN_MAX_REFRESH = cf.get<int>("Token.max_refresh", 10);
_CORS_ORIGINS = split(cf.get<std::string>("CORS.allowed_origins", ""), ';');
_ADMIN_USERS = split(cf.get<std::string>("Admin.users", ""), ';');
}
@@ -618,6 +630,11 @@ void registerRoutes(HttpServer &server) {
[](const auto& req, const auto& params) { return handleLogout(req, params); });
});
server.post("/refresh_token", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
[](const auto& req, const auto& params) { return handleRefreshToken(req, params); });
});
/* 代理 captcha 图形验证码(须在 get("*") 通配符之前注册) */
server.get("/captcha/image", [](const auto& req, const auto& params) -> http::response<http::string_body> {
return wrapperHttp(req, params,
@@ -867,10 +884,18 @@ http::response<http::string_body> handleLogin(
/* 生成令牌 */
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
/* 管理员判定:账号命中 [Admin] users 白名单即在 payload 中签发 is_admin,
* 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */
bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end();
json::value jv_t = {
{"user", username},
{"iat", LocalTime::getCurrent()},
{"created_at", LocalTime::getCurrent()},
{"keep_time", _TOKEN_KEEP_TIME}
{"keep_time", _TOKEN_KEEP_TIME},
{"is_admin", is_admin},
{"refresh_count", 0},
{"max_refresh", _TOKEN_MAX_REFRESH}
};
std::string data = json::serialize(jv_t);
std::string auth = Base64::base64_encode(data);
@@ -921,6 +946,159 @@ http::response<http::string_body> handleLogout(
return res;
}
/* 刷新 Token:POST /refresh_token
*
* 接受当前有效 Token(即使已过期但在宽限期内),签发新 Token。
* 仅更新 created_at 与 refresh_count;iat 保持不变以限制整条链的总生命周期。
*
* 宽限期:iat + keep_time * refresh_grace_multiplier
* 例:keep_time=3600, multiplier=2 → 首次签发后 2 小时内可刷新
* 刷新上限:refresh_count < max_refresh
* 限流:5 次 / 5 分钟 / IP
* Token 来源:Authorization: Bearer 优先,回退 Cookie q4_token
*
* 响应:{ success, data:{ token, refresh_count, refreshes_remaining } }
*/
http::response<http::string_body> handleRefreshToken(
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());
/* IP 速率限制:5 次 / 300 秒 */
std::string client_ip = "direct";
auto it_host = req.base().find("X-Forwarded-For");
if (it_host != req.base().end())
client_ip = std::string(it_host->value());
if (!_REFRESH_TOKEN_RATE.check_and_record(client_ip, 5, 300))
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: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;
}
}
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 (refresh_count >= max_refresh)
THROW_EX_CUSTOM(HttpException, 4005, "Refresh limit exceeded. Please re-login.",
400, "/refresh_token");
/* 宽限期检查:iat + keep_time * grace_multiplier > now */
auto iat_tp = LocalTime::parse(iat_str);
if (!iat_tp)
THROW_EX_CUSTOM(HttpException, 4004, "Bad token iat", 400, "/refresh_token");
auto now = std::chrono::system_clock::now();
auto grace_expire = *iat_tp + std::chrono::seconds(keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
if (now > grace_expire)
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:iat 不变,created_at 更新,refresh_count++ */
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
json::value jv_new = {
{"user", username},
{"iat", iat_str},
{"created_at", LocalTime::getCurrent()},
{"keep_time", keep_time},
{"is_admin", is_admin},
{"refresh_count", refresh_count + 1},
{"max_refresh", max_refresh}
};
std::string new_data = json::serialize(jv_new);
std::string new_auth = Base64::base64_encode(new_data);
std::string new_sign = _PRIV.Sign(new_data);
std::string new_token = new_auth + "_" + new_sign;
/* Set-Cookie */
{
std::string ck = "q4_token=" + new_token +
"; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=" +
std::to_string(keep_time);
res.set("Set-Cookie", ck);
}
logs().info("gateway", "Token refreshed", std::nullopt,
json::serialize(json::value{
{"user", username},
{"ip", client_ip},
{"refresh_count", refresh_count + 1},
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
}));
json::value jv_rr = {
{"success", true},
{"data", json::object{
{"token", new_token},
{"refresh_count", refresh_count + 1},
{"refreshes_remaining", max_refresh - (refresh_count + 1)}
}}
};
res.set("Connection", "close");
res.result(http::status::ok);
res.set(http::field::content_type, "application/json");
res.body() = json::serialize(jv_rr);
res.prepare_payload();
return res;
}
/* 代理 captcha 图形验证码:GET /captcha/image
*
* captcha 服务为 pymsg/TCP(:8009),浏览器无法直接访问,
@@ -1108,6 +1286,8 @@ http::response<http::string_body> handleSendEmailCode(
// 调 smtp 服务(pymsg/TCP :8008)发送验证码
bool sent = false;
int retry_after = 0; // smtp 同邮箱发送冷却的剩余秒数,0 表示非冷却拒绝
std::string smtp_err; // 冷却拒绝时 smtp 返回的提示文案(仅冷却场景透传客户端)
try {
pmc::net::MsgClient client("127.0.0.1", 8008, "gateway");
json::value jv_req = {
@@ -1117,14 +1297,31 @@ http::response<http::string_body> handleSendEmailCode(
std::string resp = client.call(json::serialize(jv_req), _TIMEOUT_SMTP_SEND);
auto jv_r = json::parse(resp);
auto& obj_r = jv_r.as_object();
if (obj_r.at("success").as_bool())
sent = obj_r.at("data").as_object().at("sent").as_bool();
if (obj_r.at("success").as_bool()) {
auto& data = obj_r.at("data").as_object();
sent = data.at("sent").as_bool();
if (!sent) {
auto it_ra = data.find("retry_after");
if (it_ra != data.end() && it_ra->value().is_int64())
retry_after = static_cast<int>(it_ra->value().as_int64());
auto it_err = data.find("error");
if (it_err != data.end() && it_err->value().is_string())
smtp_err = std::string(it_err->value().as_string());
}
}
} catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 3102, "Mail service unavailable", 502, "/register/send_code");
}
if (!sent)
if (!sent) {
// 同邮箱发送冷却:按限流语义返回 429 并透传 smtp 提示文案;
// 其余发送失败(含 SMTP 内部错误细节)仍用通用文案,不向外暴露
if (retry_after > 0)
THROW_EX_CUSTOM(HttpException, 3103,
smtp_err.empty() ? "Too many code requests. Please try again later." : smtp_err,
429, "/register/send_code");
THROW_EX_CUSTOM(HttpException, 3103, "Failed to send verification code", 500, "/register/send_code");
}
logs().info("gateway", "验证码邮件发送成功: " + email, std::nullopt,
json::serialize(json::value{{"ip", client_ip}}));
+199 -2
View File
@@ -1,2 +1,199 @@
# UniServe_libcrypt
UniServe Framework Cryption Library
# libcrypt — UniServe 加密库
C++20 轻量级加密库,基于 OpenSSL 3.x EVP API 封装。提供 AES-256-CBC 加解密、Base64 编解码、SHA-256 哈希、RSA 密钥管理与加解密/签名验证。
命名空间 `qing`,库文件 `libus_crypt.so`,头文件安装至 `/usr/include/us/`。被 `gateway` 的 Token 签发/验证和密码哈希直接依赖。
## 架构
```
qing::Crypto_Basic (基类: bytes_to_hex / hex_to_bytes / CRYPTO_CHECK)
├── AES_CBC AES-256-CBC 对称加密(随机密钥+IV / 外部密钥)
├── Base64 Base64 编解码(OpenSSL BIO 链)
├── SHA256 SHA-256 哈希与验证
└── MyRSA RSA 非对称密钥
├── Generator 密钥对生成(2048-bit,PKCS#8 PEM 输出)
├── Private_Key 私钥加载、OAEP 解密、PKCS1_v1_5/PSS 签名
└── Public_Key 公钥加载、OAEP 加密、PKCS1_v1_5/PSS 验签
```
所有加密类均使用 OpenSSL 3.x EVP 高层 API。`bytes_to_hex` / `hex_to_bytes` 在 `Crypto_Basic` 中实现(查表法),编译进 `libus_crypt.so`。加解密类为纯头文件(header-only),需链接时额外加 `-lssl -lcrypto`。
## 类与 API
### Crypto_Basic — 基类
提供两个静态工具方法和异常类型,不直接实例化。
| 成员 | 说明 |
|------|------|
| `bytes_to_hex(data)` | 字节向量 → 十六进制字符串(查表法) |
| `hex_to_bytes(hex)` | 十六进制字符串 → 字节向量(大小写兼容) |
| `CryptoExp` | 异常类,继承 `std::runtime_error` |
| `CRYPTO_CHECK(cond, msg)` | 宏,条件为 false 时抛出 `std::runtime_error` |
### AES_CBC — AES-256-CBC 对称加密
```cpp
AES_CBC aes; // 自动生成随机 32B 密钥 + 16B IV
AES_CBC aes(key_vec, iv_vec); // 使用外部密钥和 IV
std::string hex = aes.encrypt(text); // 加密,返回 hex 密文
std::string raw = aes.decrypt(hex); // 解密 hex 密文,返回原文
std::string k = aes.get_key(); // 获取密钥(hex)
std::string v = aes.get_iv(); // 获取 IV(hex)
```
加密输出为 hex 编码的密文,非原始字节。解密接受 hex 输入,返回原始字符串。
### Base64 — Base64 编解码
提供实例方法和静态工具方法两种调用方式。
| 方法 | 说明 |
|------|------|
| `encode(str)` / `encode(vec)` | 编码为 Base64 字符串 |
| `decode_to_string(b64)` | 解码为原始字符串 |
| `decode_to_bytes(b64)` | 解码为字节向量 |
| `base64_encode(str)` (static) | 静态编码工具 |
| `base64_decode_to_string(b64)` (static) | 静态解码工具 |
| `is_valid_base64(str)` (static) | 验证字符集合法性(不依赖 OpenSSL) |
编码不添加换行符(`BIO_FLAGS_BASE64_NO_NL`)。
### SHA256 — SHA-256 哈希
```cpp
SHA256 h;
std::string hex = h.hash("hello"); // 字符串 → hex 哈希
auto bytes = h.hash_bytes("hello"); // 字符串 → 字节哈希
bool ok = h.verify("hello", expected_hex); // 验证
// 静态快捷方式
std::string h = SHA256::sha256("hello");
```
### MyRSA — RSA 非对称密钥
**Generator** — 生成 2048-bit RSA 密钥对(OpenSSL 3.x `OSSL_PARAM` API)。
```cpp
MyRSA::Generator gen;
gen.save_pri_key("private.pem", "password"); // PKCS#8 + AES-256-CBC 加密
gen.save_pub_key("public.pem"); // SubjectPublicKeyInfo
std::string pem = gen.get_public_key_pem(); // 导出 PEM 字符串
```
**Private_Key** — 加载私钥(支持 PKCS#1/PKCS#8,加密或明文)。
```cpp
auto pri = MyRSA::Private_Key("private.pem", true, "password");
std::string raw = pri.Decrypt(cipher_hex); // OAEP 解密
std::string sig = pri.Sign(msg); // PSS 签名(默认)
std::string sig = pri.Sign(msg, RSASignaturePadding::PKCS1_v1_5);
```
> 私有继承自 `Crypto_Basic`,内部使用其工具方法,外部不可将 `Private_Key*` 转为 `Crypto_Basic*`。
**Public_Key** — 加载公钥。
```cpp
auto pub = MyRSA::Public_Key("public.pem");
std::string hex = pub.Encrypt(text); // OAEP 加密
bool ok = pub.Verify(msg, sig_hex); // 验签(默认 PSS)
bool ok = pub.Verify(msg, sig_hex, RSASignaturePadding::PKCS1_v1_5);
```
**签名填充模式**(`RSASignaturePadding` 枚举):
| 值 | 说明 |
|----|------|
| `PSS` | 概率签名方案(默认,更安全) |
| `PKCS1_v1_5` | 传统填充(兼容旧系统) |
## 构建与安装
**前置依赖**:OpenSSL 3.x(开发包 `libssl-dev`),g++ C++20。
```bash
# 一键构建 + 安装(需 sudo)
bash _set.sh
# 或手动
make # 编译 libus_crypt.so
make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/
make clear # 清理构建产物
make uninstall # 卸载
```
使用:
```cpp
#include <us/Crypto_Basic.hpp>
#include <us/AES_CBC.hpp>
#include <us/Base64.hpp>
#include <us/SHA256.hpp>
#include <us/MyRSA.hpp>
```
链接(OpenSSL 符号在 header-only 类中,需显式链接):
```bash
g++ -std=c++20 ... -lus_crypt -lssl -lcrypto
```
## 使用示例
```cpp
#include <us/SHA256.hpp>
#include <us/Base64.hpp>
#include <us/AES_CBC.hpp>
#include <iostream>
int main() {
// SHA-256
std::string hash = qing::SHA256::sha256("hello world");
std::cout << "SHA256: " << hash << std::endl;
// Base64
std::string b64 = qing::Base64::base64_encode("hello");
std::cout << "Base64: " << b64 << std::endl;
// AES-256-CBC
qing::AES_CBC aes;
std::string cipher = aes.encrypt("secret message");
std::string plain = aes.decrypt(cipher);
std::cout << "AES decrypt: " << plain << std::endl;
return 0;
}
```
## 项目结构
```
libcrypt/
├── inc/us/
│ ├── Crypto_Basic.hpp # 基类:hex 转换 + 异常
│ ├── AES_CBC.hpp # AES-256-CBC 加解密(header-only)
│ ├── Base64.hpp # Base64 编解码(header-only)
│ ├── SHA256.hpp # SHA-256 哈希(header-only)
│ └── MyRSA.hpp # RSA 密钥/加解密/签名(header-only)
├── src/
│ └── Crypto_Basic.cpp # bytes_to_hex / hex_to_bytes 实现
├── Makefile
├── _set.sh
└── README.md
```
## 注意事项
- **OpenSSL 版本**:MyRSA 的 `Generator` 使用了 `OSSL_PARAM`、`EVP_PKEY_CTX_new_from_name` 等 OpenSSL 3.x 独占 API,无法在 OpenSSL 1.1.x 下编译。AES/Base64/SHA256 兼容 1.1.x。
- **链接要求**:`libus_crypt.so` 仅含 `bytes_to_hex` 和 `hex_to_bytes`(无 OpenSSL 调用)。所有加密类为 header-only,链接时必须添加 `-lssl -lcrypto`。
- **RSA 密钥长度**:默认 2048-bit,可在 `generate_rsa_key(size_t key_bits)` 中调整。
- **AES 密钥和 IV**:默认构造函数随机生成,每次创建 `AES_CBC` 对象产生不同的密钥对。需要长期存储密钥时使用参数化构造函数传入外部密钥。
- **hex 编码**:`bytes_to_hex` 使用查表法实现,位于 `Crypto_Basic.cpp` 中编译进 `.so`。`SHA256` 不再有私有副本。
- **`Private_Key` 私有继承**:该设计防止 `Private_Key` 被隐式转换为 `Crypto_Basic*`,是刻意为之的安全措施。
## 许可证
MIT License
+1
View File
@@ -2,6 +2,7 @@
#include <openssl/evp.h>
#include <openssl/err.h>
#include <openssl/rand.h>
#include <iomanip>
#include <iostream>
#include <vector>
#include <stdexcept>
+1 -13
View File
@@ -5,9 +5,7 @@
#include <vector>
#include <string>
#include <stdexcept>
#include <iomanip>
#include <sstream>
#include "us/Crypto_Basic.hpp"
#include "Crypto_Basic.hpp"
namespace qing {
@@ -99,15 +97,5 @@ namespace qing {
return digest;
}
/* 辅助函数:字节向量转十六进制字符串 */
static std::string bytes_to_hex(const std::vector<unsigned char>& bytes) {
std::stringstream ss;
ss << std::hex << std::setfill('0');
for (unsigned char byte : bytes) {
ss << std::setw(2) << static_cast<int>(byte);
}
return ss.str();
}
};
}
+606
View File
@@ -0,0 +1,606 @@
# libhttp — C++20 HTTP / WebSocket 网络库
## 1. 模块概览
`libhttp` 是 `micro_servers` 项目的基础网络通信层,封装了 HTTP/HTTPS 服务器、HTTP 客户端(基于 libcurl)、WebSocket 服务器和 WebSocket 客户端四大功能。
以 C++20 标准编写,编译为动态库 `libus_http.so` 和静态库 `libus_http_static.a`,命名空间 `pmc::net`。
| 属性 | 值 |
|----------|-----------------------------|
| 版本 | 0.0.1 |
| 语言标准 | C++20 |
| 许可证 | MIT |
| 框架名 | `us` |
| 库名 | `libus_http` |
| 命名空间 | `pmc::net` |
**项目角色**:`libhttp` 被 `gateway` 模块和 `core` 模块直接依赖,承担所有入站请求的接收、路由、SSL 卸载和 WebSocket 长连接管理。
---
## 2. 文件清单
```
libhttp/
├── .gitignore # 构建产物忽略规则
├── CMakeLists.txt # CMake 构建 (v3.12+)
├── LICENSE # MIT License
├── Makefile # GNU Make 构建
├── _set.sh # 一键编译安装脚本
├── inc/us/
│ ├── HttpClient.hpp # HTTP 客户端 (header-only)
│ ├── HttpServer.hpp # HTTP/HTTPS 服务器声明
│ ├── WebSocketClient.hpp # WebSocket 客户端声明
│ ├── WebSocketClientBase.hpp # WebSocket 客户端抽象基类
│ └── WebSocketServer.hpp # WebSocket 服务器声明
└── src/
├── HttpServer.cpp # HTTP/HTTPS 服务器实现
├── WebSocketClient.cpp # WebSocket 客户端实现
└── WebSocketServer.cpp # WebSocket 服务器实现
```
---
## 3. 外部依赖
| 依赖 | 用途 | 备注 |
|------------------|-----------------------------------|---------------------------|
| Boost 1.91+ | Asio (I/O)、Beast (HTTP/WS)、Algorithms | 默认 `BOOST_ROOT=C:/local/boost_1_91_0`,可通过环境变量覆盖 |
| OpenSSL | TLS/SSL 加密传输 | 通过 Boost.Asio.SSL 调用 |
| libcurl | HTTP 客户端请求 | 仅 `HttpClient.hpp` 依赖 |
| pthread (Unix) | 多线程 | CMake 自动链接 |
| ws2_32 (Windows) | Winsock | CMake 自动链接 |
---
## 4. 构建
### 4.1 GNU Make
```bash
# 编译 (仅 HttpServer.o → libus_http.so)
make
# 清理
make clear
# 安装到 /lib 和 /usr/include/us/
make install
# 卸载
make uninstall
```
> **注意**:Makefile 第 17 行默认只链接 `HttpServer.o`。如需 WebSocket 支持,需取消注释 `WebSocketServer.o` 和 `WebSocketClient.o`。
### 4.2 CMake
```bash
mkdir build && cd build
cmake .. -DBOOST_ROOT=/path/to/boost
cmake --build .
cmake --install . --prefix /usr/local
```
CMake 会同时产出 `libus_http.so` (动态) 和 `libus_http_static.a` (静态)。
### 4.3 一键脚本
```bash
bash _set.sh # make clear → make → make install
```
---
## 5. API 参考
### 5.1 HttpServer — HTTP/HTTPS 服务器
**头文件**: `<us/HttpServer.hpp>`
**实现**: `src/HttpServer.cpp`
#### 5.1.1 类型别名
```cpp
using HttpRequestHandler = std::function<http::response<http::string_body>(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)>;
using MiddlewareHandler = std::function<bool(
const http::request<http::string_body>& req,
http::response<http::string_body>& res,
const std::unordered_map<std::string, std::string>& params)>;
```
- **`HttpRequestHandler`**: 路由处理函数,接收 Boost.Beast 请求对象和已解析的查询参数,返回 HTTP 响应。
- **`MiddlewareHandler`**: 中间件函数,返回 `false` 表示拦截(短路后续处理),`true` 表示放行。
#### 5.1.2 构造函数
| 签名 | 说明 |
|----------------------------------------------------------------------------------------|------------------------|
| `HttpServer(address, port, threads = 1)` | 纯 HTTP 模式 |
| `HttpServer(address, port, cert_file, key_file, threads = 1)` | HTTPS 模式(自动启用 SSL) |
- `address`: 监听地址,如 `"0.0.0.0"`
- `port`: 监听端口
- `threads`: 工作线程数(即 `io_context` 线程池大小)
- `cert_file` / `key_file`: PEM 格式证书和私钥路径
#### 5.1.3 路由注册
```cpp
void get(const std::string& path, HttpRequestHandler handler);
void post(const std::string& path, HttpRequestHandler handler);
void put(const std::string& path, HttpRequestHandler handler);
void del(const std::string& path, HttpRequestHandler handler);
void options(const std::string& path, HttpRequestHandler handler);
void use(MiddlewareHandler middleware);
```
- 路径支持通配符:`*` (任意字符) 和 `?` (单个字符),自动编译为 `std::regex`(大小写不敏感)。
- 匹配优先级:精确匹配 > 通配符匹配。
- 请求方法字符串对应:`"GET"`, `"POST"`, `"PUT"`, `"DELETE"`, `"OPTIONS"`。
#### 5.1.4 中间件
```cpp
void use(MiddlewareHandler middleware);
```
中间件在路由匹配之前执行,按注册顺序依次调用。任意中间件返回 `false` 时,请求处理结束,响应直接返回客户端。
#### 5.1.5 静态文件服务
```cpp
void setStaticDirectory(const std::string& path);
```
设置静态资源根目录。当请求路径未匹配任何路由时,自动尝试从该目录读取文件。支持 MIME 类型自动识别:`.html`, `.css`, `.js`, `.json`, `.png`, `.jpg/.jpeg`。
#### 5.1.6 OPTIONS 预检请求
当收到 `OPTIONS` 请求时:
1. 优先查找自定义 `OPTIONS` 路由处理器
2. 若未注册自定义处理器且请求包含 `Origin` 头,自动生成 CORS 响应
- 自动检测该路径下已注册的方法(GET/POST/PUT/DELETE/OPTIONS)
- 返回 `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, `Access-Control-Max-Age`
#### 5.1.7 SSL 配置
```cpp
void setPrivateKeyPassword(const std::string& password);
```
- SSL 上下文使用 TLS 协议,禁用 SSLv2/SSLv3
- 仅允许 ECDHE/DHE 密钥交换的强密码套件:`ECDHE+AESGCM:ECDHE+CHACHA20:DHE+AESGCM:DHE+CHACHA20:!aNULL:!MD5:!DSS`
- 支持 PEM 加密私钥密码
#### 5.1.8 生命周期
```cpp
void start(); // 启动服务器(非阻塞,线程池跑 io_context)
void stop(); // 停止服务器(关闭所有会话,join 线程)
void run(); // 启动并在当前线程运行 io_context(阻塞)
bool isRunning() const;
```
#### 5.1.9 调试
```cpp
void showStatus() const; // 打印服务器状态和已注册路由
```
内嵌类 `HttpServer::debug` 提供 `print_all_header_fields()` 静态方法,打印请求的全部头字段。
#### 5.1.10 内部架构
```
HttpServer
├── Listener (内部类)
│ └── acceptor_ (tcp::acceptor)
│ └── async_accept → Session 实例
└── Session (内部类,enable_shared_from_this)
├── HTTP: socket_ (tcp::socket)
├── HTTPS: ssl_stream_ (asio::ssl::stream<tcp::socket>)
├── strand_ 保证线程安全
└── 读取 → handleRequest() → 写入 → [keep-alive 则继续读取]
```
---
### 5.2 HttpClient — HTTP 客户端
**头文件**: `<us/HttpClient.hpp>` (header-only,无独立 .cpp)
**依赖**: libcurl
#### 5.2.1 枚举 `HttpMethod`
```cpp
enum class HttpMethod { GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS };
```
#### 5.2.2 公共方法
```cpp
HttpClient(); // 初始化 CURL 句柄,失败抛 runtime_error
~HttpClient(); // 清理 CURL 和 header 链表
void set_header(const std::string& header);
void clear_headers();
const std::map<std::string, std::string>& get_response_headers() const;
long get_response_code() const;
```
流式请求(通过回调函数接收响应体,保留原始字节):
```cpp
void request_stream_sync(HttpMethod method, const std::string& url,
const std::string& data,
std::function<void(const std::string&)> callback);
// 便捷方法
void get_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
void post_stream_sync(const std::string& url, const std::string& data,
std::function<void(const std::string&)> callback);
void put_stream_sync(const std::string& url, const std::string& data,
std::function<void(const std::string&)> callback);
void delete_stream_sync(const std::string& url, std::function<void(const std::string&)> callback);
```
**特点**:
- 同步阻塞调用
- 响应体通过 `CURLOPT_WRITEFUNCTION` 直接追加到 `std::string`,**保留所有换行符和特殊字符**
- 超时设置:连接超时 10 秒,总超时 30 秒
- 每次请求调用 `curl_easy_reset` 重置状态
---
### 5.3 WebSocketServer — 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`
---
## 6. 使用示例
### 6.1 基本 HTTP 服务器
```cpp
#include <us/HttpServer.hpp>
#include <iostream>
int main() {
pmc::net::HttpServer server("0.0.0.0", 8080, 4);
server.get("/hello", [](const auto& req, const auto& params) {
http::response<http::string_body> res{http::status::ok, req.version()};
res.set(http::field::content_type, "text/plain");
res.body() = "Hello, world!";
res.prepare_payload();
return res;
});
server.post("/echo", [](const auto& req, const auto& params) {
http::response<http::string_body> res{http::status::ok, req.version()};
res.set(http::field::content_type, "application/json");
res.body() = req.body();
res.prepare_payload();
return res;
});
server.start(); // 非阻塞启动
std::cout << "Server running on port " << server.getPort() << std::endl;
server.isRunning(); // 阻塞直到手动 stop()
}
```
### 6.2 HTTPS 服务器
```cpp
pmc::net::HttpServer server("0.0.0.0", 443,
"/etc/ssl/certs/server.crt",
"/etc/ssl/private/server.key",
4);
server.setPrivateKeyPassword("mysecret");
server.start();
```
### 6.3 中间件 + 静态文件
```cpp
pmc::net::HttpServer server("0.0.0.0", 8080);
// 日志中间件
server.use([](const auto& req, auto&, const auto&) {
std::cout << "[" << req.method_string() << "] " << req.target() << std::endl;
return true; // 继续处理
});
// 认证中间件
server.use([](const auto& req, auto& res, const auto&) {
auto auth = req.find(http::field::authorization);
if (auth == req.end()) {
res.result(http::status::unauthorized);
res.body() = "Unauthorized";
res.prepare_payload();
return false; // 拦截
}
return true;
});
server.setStaticDirectory("/var/www/html");
server.start();
```
### 6.4 HTTP 客户端
```cpp
pmc::net::HttpClient client;
client.set_header("Authorization: Bearer token123");
client.set_header("Content-Type: application/json");
client.post_stream_sync("https://api.example.com/data",
R"({"key":"value"})",
[](const std::string& body) {
std::cout << "Response: " << body << std::endl;
});
long status = client.get_response_code();
auto& headers = client.get_response_headers();
```
### 6.5 WebSocket 服务器
```cpp
pmc::net::WebSocketServer ws_server(9000, 2);
ws_server.onConnect([](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) {
std::cout << "[" << clientId << "]: " << message << std::endl;
ws_server.broadcast(clientId + ": " + message);
});
ws_server.onDisconnect([](const std::string& clientId) {
std::cout << "Client disconnected: " << clientId << std::endl;
});
ws_server.start();
```
### 6.6 WebSocket 客户端
```cpp
pmc::net::WebSocketClient ws_client("localhost", "9000", "/chat");
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));
}
```
---
## 7. 请求处理流程
```
客户端 → TCP 连接
├─ HTTP: Session::doRead()
└─ HTTPS: Session::doSSLHandshake() → Session::doRead()
↓
HttpServer::handleRequest(req)
├─ 解析 target_path + query params
├─ 执行中间件链 (MiddlewareHandler[])
│ └─ 任意中间件返回 false → 短路返回
├─ OPTIONS 请求?
│ ├─ 有自定义处理器 → 执行
│ └─ 有 Origin → 自动生成 CORS 响应
├─ 路由匹配 (精确匹配 → 通配符匹配)
│ └─ 命中 → 执行 HttpRequestHandler
├─ 路由未命中 + static_directory_ 非空
│ └─ 尝试读取静态文件 → 自动设 Content-Type
└─ 全部未命中 → 404
```
---
## 8. 设计要点
### 8.1 线程模型
- **HttpServer**: 每个工作线程独立运行 `io_context::run()`,所有异步操作通过 `strand` 序列化,同一 Session 的读写不会并发冲突
- **WebSocketServer**: 同 HttpServer,工作线程池跑 `io_context`
- **WebSocketClient**: 单 IO 线程,所有异步回调在该线程中执行
- **HttpClient**: 同步阻塞,无额外线程
### 8.2 PIMPL 模式
`WebSocketClient` 和 `WebSocketServer` 使用 PIMPL 隐藏 Boost 依赖,保持头文件轻量。
### 8.3 会话管理
- `HttpServer` 通过 `weak_ptr<Session>` 跟踪所有活跃会话,`stop()` 时遍历清理
- `WebSocketServer` 通过 `unordered_map<string, shared_ptr<Session>>` + mutex 管理
### 8.4 发送队列
`WebSocketServer::Session` 和 `WebSocketClient::Impl` 均采用 `std::deque` 消息队列,确保消息按序发送且不会因并发写入冲突。
---
## 9. 安全特性
| 特性 | 实现位置 |
|---------------------------|------------------------------|
| SSL/TLS 加密 | HttpServer SSL 构造函数 |
| 禁止 SSLv2/SSLv3 | `initSSLContext()` |
| 仅强密码套件 (ECDHE/DHE) | `initSSLContext()` 密码列表 |
| 禁用 NULL/MD5/DSS | `initSSLContext()` 密码列表 |
| 加密私钥密码支持 | `setPrivateKeyPassword()` |
| CORS 自动处理 | `handleRequest()` OPTIONS 分支 |
---
## 10. 与其他模块的关系
```
micro_servers/
├── gateway/ ──依赖──→ libhttp (HttpServer 接收外部请求)
├── core/ ──依赖──→ libhttp (HTTP 服务基础能力)
└── libhttp/ ──依赖──→ Boost, OpenSSL, libcurl
```
- **gateway** 使用 `pmc::net::HttpServer` 作为 API 网关入口,处理 JWT 认证、令牌刷新、请求转发
- **core** 模块使用 libhttp 提供的 HTTP 服务能力
---
## 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 客户端实现
```
+194 -9
View File
@@ -1,14 +1,199 @@
# UniServe_libth
UniServe Framework Thread Library.
# libth — UniServe 线程库
# Dependancy Chain
It should be C++17.
C++20 轻量级多线程控制库,为 UniServe Framework 提供线程抽象与状态机驱动的执行模型。由 `core`(PMC 进程管理器)的 `HttpTask` 和 `ProcessTask` 直接依赖。
# Install Method
Use UniServe Generate Installation Script.
命名空间 `qing`,库文件 `libus_th.so`,头文件安装至 `/usr/include/us/`。
``` bash
bash _set.sh
## 架构
```
┌──────────┐ ┌───────────────────────────────┐
│ ITask │ │ Thread │
│ (接口) │ │ 持有 Fsm + std::thread │
│ start() │ │ │
│ stop() │ │ set_stop / set_wake / │
│ isRunning│ │ set_loop / set_clear │
└────┬─────┘ │ Activate / WaitStart / │
│ 实现 │ WaitClose │
▼ │ │
HttpTask │ main(): "回字形" 执行循环 │
ProcessTask └──────────────┬────────────────┘
(位于 core) │ 持有
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌──────────┐
│ Fsm │ │ Cv_wait │ │ Thread │
│ 状态机 │ │ 条件等待 │ │ .cpp │
│ SHUT/STOP│ │ Wait() │ │ 实现文件 │
│ START/ │ │ WakeCv() │ │ │
│ RUNNING │ │ │ │ │
└──────────┘ └────────────┘ └──────────┘
```
# ENJOY!
## 类与 API
### ITask — 任务接口
纯虚基类,定义可运行任务的契约。下游模块(core 中的 HttpTask、ProcessTask)继承实现。
```cpp
class ITask {
virtual void start() = 0; // 启动任务
virtual void stop() = 0; // 停止任务
virtual bool isRunning() = 0; // 查询运行状态
};
```
### Fsm — 线程安全有限状态机
4 个状态:`SHUT(-1)` → `STOP(0)` → `START(1)` → `RUNNING(2)`。Thread 通过 Fsm 驱动其执行流程。
| 方法 | 说明 |
|------|------|
| `check()` | 线程安全地读取当前状态 |
| `set(Stat)` | 加锁设置状态 + 唤醒阻塞线程 |
| `set_without_lock(Stat)` | 不加锁设置(用于临界区内) |
| `set_without_notify(Stat)` | 加锁但不唤醒条件变量 |
| `suspend()` | 阻塞当前线程直至状态变更 |
初始状态为 `STOP`。
### Thread — 线程抽象
基于回调组合(composition over inheritance),持有 `Fsm` 和 `std::thread`。执行模型为"回字形"循环:
```
while (状态 != SHUT) {
① STOP: 循环调用 StopEvent 回调(空闲轮询)
② START: 单次调用 WakeEvent 回调(启动)
③ RUNNING:循环调用 LoopEvent 回调(工作循环)
④ 退出后: 调用 ClearEvent 回调(清理)
}
异常捕获:loop 内抛出 std::exception 自动回退到 STOP,进程不崩溃。
```
**生命周期:两阶段构造。** 构造函数只初始化成员,`Activate()` 才真正创建 `std::thread`——避免线程在对象构造完成前启动的竞态条件。
| 方法 | 说明 |
|------|------|
| `set_stop(f_t)` | 设置 STOP 状态回调 |
| `set_wake(f_t)` | 设置 START 状态回调 |
| `set_loop(f_t)` | 设置 RUNNING 状态回调 |
| `set_clear(f_t)` | 设置清理回调 |
| `Activate()` | 创建 std::thread 并启动 main() |
| `WaitStart()` | 唤醒线程并阻塞至其进入 RUNNING |
| `WaitClose()` | 关闭线程并 join 等待退出 |
| `wake()` | 状态 → START(不可持有锁,否则死锁) |
| `stop()` | 状态 → STOP |
| `shut()` | 状态 → SHUT |
| `run()` | 状态 → RUNNING |
| `check()` | 查询当前状态 |
| `suspend()` | 阻塞等待状态变更 |
回调类型:`using f_t = std::function<void(Thread&)>;`
### Cv_wait — 条件变量等待工具
简单的单次等待-唤醒工具,用于线程间同步。
| 方法 | 说明 |
|------|------|
| `Wait()` | 阻塞当前线程至 WakeCv() 被调用 |
| `WakeCv()` | 唤醒所有等待线程 |
> **已知限制**:仅支持单次等待-唤醒周期,不可复用。缺少超时机制,可能永久阻塞。代码中标注建议改用 `std::future`/`std::promise`。core 的 `main.cpp` 使用此类。
## 构建与安装
```bash
# 一键构建 + 安装(需 sudo)
bash _set.sh
# 或手动执行
make # 编译 libus_th.so
make install # 安装头文件到 /usr/include/us/ + .so 到 /lib/
make uninstall # 卸载
make clear # 清理构建产物
```
安装后使用:
```cpp
#include <us/Thread.hpp>
#include <us/Fsm.hpp>
#include <us/ITask.hpp>
#include <us/Cv_wait.hpp>
```
链接:`g++ -std=c++20 ... -lus_th`
## 使用示例
```cpp
#include <us/Thread.hpp>
#include <iostream>
int main() {
qing::Thread worker;
bool done = false;
// STOP 状态回调:空闲等待
worker.set_stop([&](qing::Thread& t) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
});
// START 状态回调:一次性初始化
worker.set_wake([&](qing::Thread& t) {
std::cout << "worker started" << std::endl;
t.run(); // 进入 RUNNING
});
// RUNNING 状态回调:工作循环
worker.set_loop([&](qing::Thread& t) {
std::cout << "working..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
done = true;
t.stop(); // 回到 STOP
});
// 清理回调
worker.set_clear([](qing::Thread& t) {
std::cout << "cycle cleaned up" << std::endl;
});
worker.Activate();
worker.WaitStart(); // 阻塞至 RUNNING
while (!done) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
worker.WaitClose();
return 0;
}
```
## 依赖
- C++20(`g++ --std=c++20`)
- 纯标准库,无第三方依赖
- 线程库需链接 `-pthread`
## 设计要点
- **回调组合优于继承**。2025 年 6 月从派生改为持有 `std::function` 回调,Thread 不再需要子类化。
- **两阶段构造**。构造与 `Activate()` 分离,防止 `std::thread` 在对象构造完成前访问未初始化的成员。
- **异常安全**。main() 循环捕获 `std::exception` 后回退到 STOP 而非崩溃。注意:catch 后继续运行可能造成内存泄漏。
- **wake() 不可加锁**。`WaitStart()` 先持有 `mtx_th` 再调用 `wake()`,若 `wake()` 内部也尝试获取同一锁会造成死锁。
- **Fsm 提供两套条件变量**。Fsm 内部有一套 `cv` 用于 `suspend()`,Thread 自身另有一套 `cv` 用于 `WaitStart()`/`WaitClose()`,互不干扰。
## 注意事项
- Cv_wait 仅支持单次使用,复用时 `flag` 残留为 `true` 会导致 `Wait()` 立即返回而不阻塞。
- Fsm 的 `suspend()` 等待的是 Fsm 内部的 `ready` 标志,由 `set()` 触发;Thread 的 `WaitStart()` 等待的是 Thread 内部的 `ready`,由 `run()`/`stop()`/`shut()` 触发。两者独立。
- `Activate()` 不可重复调用(内部有空指针检查),但不会报错,静默返回。
- 析构函数为虚函数,允许安全的多态删除。
## 许可证
MIT License
+1 -1
View File
@@ -28,7 +28,7 @@ public:
inline void WakeCv() /* 唤醒线程 */
{
{
std::lock_guard<std::mutex> lcok(mtx);
std::lock_guard<std::mutex> lock(mtx);
flag = true;
}
cv.notify_all();
+856
View File
@@ -0,0 +1,856 @@
# libtry 模块技术手册
> 文档版本:2026-07-30
> 代码版本:try.c(~741 行)
> 语言:C(嵌入式解释器)
> 定位:教学级脚本解释器,用于操作界面 shell
---
## 一、模块概述
### 1.1 定位
**libtry** 是一个基于 C 语言手写的**教学级解释器**,魔改自 GitHub 上的 tryC 项目。用于解析 `.try` 脚本文件,设计目标是作为**操作界面的 shell**。
### 1.2 特点
| 特性 | 说明 |
|------|------|
| 手写解释器 | 仅 741 行实现完整的词法/语法/执行引擎 |
| 零依赖 | 仅依赖标准 C 库 + `console.h` 输出接口 |
| 宽字符支持 | 使用 `wchar_t`,支持中文/多语言脚本 |
| 递归下降 | 递归下降语法分析器,边解析边执行 |
| 嵌入式设计 | 提供 `init_tryc`/`enqueue_tryc`/`eval_tryc`/`free_tryc` 四个核心 API |
### 1.3 与微服务体系的关系
| 属性 | 说明 |
|------|------|
| 模块关系 | **独立底层库**,不直接与任何微服务交互 |
| 可能用途 | 作为前端 UI 的脚本解释器,或嵌入式脚本引擎 |
| 启动方式 | 被其他模块(可能是 gateway 或 static)动态加载调用 |
---
## 二、技术架构
### 2.1 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 上层应用 │
│ (gateway / static / 其他模块) │
└─────────────────────────┬───────────────────────────────────┘
│ 调用 API
┌─────────────────────────▼───────────────────────────────────┐
│ libtry 解释器 │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 词法分析器 (next) │ │
│ │ 逐字符扫描 → 识别 token → 更新符号表 │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ │ token 流 │
│ ┌──────────────────────────▼──────────────────────────────┐ │
│ │ 递归下降语法分析器 │ │
│ │ statement() / expression() / factor() / term() │ │
│ │ 边解析边执行(解释器模式) │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────┐ │
│ │ 符号表 │ │
│ │ symtab[128] — 符号栈(变量/函数/数组) │ │
│ │ levelNum — 嵌套层级,支持作用域 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────▼───────────────────────────────────┐
│ 执行结果输出 │
│ printtovga() / 控制台输出 │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 执行流程
```
init_tryc() 初始化
│
├── memset(symtab, 0, ...) 清零符号表
├── 预注册关键字 (array, func, else, if, return, while, print, puts, read)
└── malloc(128) 分配源代码缓冲区
│
enqueue_tryc(wc) 追加字符(交互式输入)
│
└── src[top_src] = wc; src[++top_src] = L'\0';
│
eval_tryc() 执行
│
├── next() 获取第一个 token
├── while (token != L'\0') { statement(); } 逐条执行
└── 重置缓冲区
│
free_tryc() 释放资源
│
└── free(p_src);
```
### 2.3 词法分析流程
```
next() 函数:
│
├── 跳过空白符 (L' ', L'\t')
├── 跳过注释 (# 开头到换行)
├── 识别标识符 (字母/数字/下划线/中文)
│ ├── 在符号表中搜索
│ │ ├── 找到:根据 type 返回对应 token (Sym, FuncSym, ArraySym, Str)
│ │ └── 未找到:创建新符号,类型为 Void
├── 识别数字 (整数 + 小数)
├── 识别字符字面量 ('a')
├── 识别字符串字面量 ("hello")
├── 识别运算符 (=, ==, !=, <, >, <=, >=, ||, &&)
└── 识别标点符号 (;, ,, +, -, *, /, (, ), {, }, [, ])
```
---
## 三、代码结构
### 3.1 文件结构
```
libtry/
├── try.c # 核心解释器实现(~741 行)
├── t.try # 示例脚本(斐波那契数列)
├── test/ # 测试目录
│ ├── t_assign_basic.try # 赋值测试
│ ├── t_function_basic.try # 函数测试
│ └── t_output_basic.try # 输出测试
├── .gitignore
└── README.robot.md # 本文档
```
### 3.2 try.c 函数索引
| 函数 | 行号 | 功能 |
|------|------|------|
| `init_tryc()` | L650 | 初始化解释器,预注册关键字 |
| `free_tryc()` | L672 | 释放源代码缓冲区 |
| `enqueue_tryc(wc)` | L677 | 追加一个字符到缓冲区 |
| `eval_tryc()` | L687 | 解析并执行当前缓冲区脚本 |
| `next()` | L85 | 词法分析,获取下一个 token |
| `match(tk)` | L289 | 匹配并消费期望 token |
| `statement()` | L466 | 解析并执行语句 |
| `expression()` | L362 | 解析加减法表达式 |
| `term()` | L311 | 解析乘除法表达式 |
| `factor()` | L326 | 解析因子(变量/常量/函数调用/数组) |
| `function()` | L616 | 执行函数调用 |
| `boolexp()` | L378 | 解析布尔表达式 |
| `boolAND()` | L423 | 解析 AND 表达式(短路) |
| `boolOR()` | L438 | 解析 OR 表达式(短路) |
| `clear(level)` | L269 | 清理指定层级的符号 |
| `clearThis()` | L280 | 清理单个符号的动态内存 |
| `skipStatments()` | L453 | 跳过 if/else 未执行分支 |
| `skipBoolExpr()` | L413 | 跳过短路布尔表达式 |
| `__exit()` | L702 | 错误处理(死循环挂起) |
| `main()` | L710 | 文件执行模式(仅 `_DEBUG` 编译) |
### 3.3 核心数据结构
#### 符号表(symStruct)
```c
typedef struct symStruct {
int type; // 类型:Num, Char, Str, Array, Func
wchar_t name[16]; // 符号名(MAXNAMESIZE)
double value; // 值或数组/字符串长度
union {
wchar_t *funcp; // 函数指针或字符串内容
struct symStruct* list; // 数组元素指针
} pointer;
int levelNum; // 嵌套层级(作用域)
} symbol;
```
#### Token 类型枚举
```c
enum {
Num = 128, // 数字
Char, // 字符字面量
Str, // 字符串
Array, // array 关键字
Func, // func 关键字
Else, // else 关键字
If, // if 关键字
Return, // return 关键字
While, // while 关键字
Print, // print 关键字
Puts, // puts 关键字
Read, // read 关键字
Assign, // = 赋值
OR, // ||
AND, // &&
Equal, // ==
Sym, // 变量符号
FuncSym, // 函数符号
ArraySym, // 数组符号
Void, // 未初始化
Nequal, // !=
LessEqual, // <=
GreatEqual // >=
};
```
#### 全局变量
| 变量 | 类型 | 说明 |
|------|------|------|
| `symtab[128]` | `symbol[]` | 符号表栈(SYMTABSIZE) |
| `symPointer` | `int` | 符号表栈指针 |
| `currentlevel` | `int` | 当前嵌套层级 |
| `src` | `wchar_t*` | 当前扫描位置 |
| `old_src` | `wchar_t*` | 行首位置(调试用) |
| `p_src` | `wchar_t*` | 源代码缓冲区起始 |
| `top_src` | `int` | 缓冲区写入位置 |
| `token` | `int` | 当前 token 类型 |
| `token_val` | `union tokenValue` | 当前 token 值 |
| `return_val` | `double` | 函数返回值 |
| `compileState` | `int` | 编译状态(debug/run) |
---
## 四、API 接口
### 4.1 初始化与销毁
#### init_tryc()
```c
int init_tryc(void);
```
**功能**:初始化解释器
- 清零符号表
- 预注册 8 个关键字(array, func, else, if, return, while, print, puts, read)
- 分配 128 字节宽字符缓冲区
**返回值**:
- `0`:成功
- `-1`:内存分配失败
#### free_tryc()
```c
void free_tryc(void);
```
**功能**:释放源代码缓冲区
### 4.2 输入与执行
#### enqueue_tryc(wc)
```c
int enqueue_tryc(wchar_t wc);
```
**功能**:追加一个字符到源代码缓冲区(用于交互式输入)
**参数**:
- `wc`:要追加的宽字符
**返回值**:
- `0`:成功
- `-1`:缓冲区已满
#### eval_tryc()
```c
void eval_tryc(void);
```
**功能**:解析并执行当前缓冲区中的脚本
**说明**:执行完成后会重置缓冲区。
### 4.3 典型使用示例
#### 交互式模式
```c
#include "try.c"
int main() {
init_tryc();
// 交互式输入
wchar_t input;
while (read_input(&input)) {
enqueue_tryc(input);
if (input == L'\n') {
eval_tryc(); // 遇到换行执行
}
}
free_tryc();
return 0;
}
```
#### 文件执行模式
```c
#include "try.c"
int main(int argc, char **argv) {
init_tryc();
// 读取文件
FILE *file = fopen(argv[1], "r");
wchar_t wc;
int i = 0;
while ((wc = fgetwc(file)) != WEOF) {
src[i++] = wc;
}
src[i] = L'\0';
fclose(file);
// 执行
eval_tryc();
clear(0); // 清理全局符号
free_tryc();
return 0;
}
```
---
## 五、语言特性
### 5.1 数据类型
| 类型 | 说明 | 示例 |
|------|------|------|
| `Num` | 数字(整数或浮点) | `a = 5;`, `b = 3.14;` |
| `Char` | 单个字符 | `c = 'A';` |
| `Str` | 字符串(宽字符) | `s = "hello";` |
| `Array` | 数组(动态分配) | `array arr(15);` |
| `Func` | 函数 | `func name{...};` |
### 5.2 变量与作用域
#### 变量声明
变量通过首次赋值自动声明:
```
x = 5; // 声明并初始化数字变量
name = "hello"; // 声明并初始化字符串变量
flag = 'A'; // 声明并初始化字符变量
```
#### 作用域
- 全局变量:在顶层声明
- 局部变量:在函数内声明,函数结束后自动销毁
- 符号表使用栈结构,`levelNum` 标识嵌套层级
### 5.3 运算符
#### 算术运算符
| 运算符 | 说明 | 示例 |
|--------|------|------|
| `+` | 加法 | `a + b` |
| `-` | 减法 | `a - b` |
| `*` | 乘法 | `a * b` |
| `/` | 除法 | `a / b` |
#### 比较运算符
| 运算符 | 说明 | 示例 |
|--------|------|------|
| `>` | 大于 | `a > b` |
| `<` | 小于 | `a < b` |
| `>=` | 大于等于 | `a >= b` |
| `<=` | 小于等于 | `a <= b` |
| `==` | 等于 | `a == b` |
| `!=` | 不等于 | `a != b` |
#### 逻辑运算符
| 运算符 | 说明 | 示例 |
|--------|------|------|
| `\|\|` | 逻辑或(短路) | `a \|\| b` |
| `&&` | 逻辑与(短路) | `a && b` |
| `!` | 逻辑非 | `!a` |
#### 赋值运算符
| 运算符 | 说明 | 示例 |
|--------|------|------|
| `=` | 赋值 | `a = 5;` |
### 5.4 控制流
#### if/else 语句
```
if (condition) {
// true 分支
}
else {
// false 分支
}
```
#### while 循环
```
while (condition) {
// 循环体
}
```
#### return 语句
```
func name{
return(value);
};
```
### 5.5 函数
#### 函数定义
```
func function_name{
// 函数体
return(return_value);
};
```
**特性**:
- 支持递归调用
- 支持参数传递(通过符号表)
- `return` 语句会终止函数执行并返回值
#### 函数调用
```
result = function_name(arg1, arg2);
```
### 5.6 数组
#### 数组声明
```
array arr(size);
```
- `size` 必须是正整数
- 数组元素初始化为 0
- 数组元素类型为 `Num`(double)
#### 数组访问与赋值
```
arr[0] = 10;
value = arr[i];
```
### 5.7 输入输出
#### print()
```
print(expression);
```
**功能**:输出数字
**示例**:
```
print(42); // 输出: 42.000000
print(3.14); // 输出: 3.140000
print(arr[0]); // 输出数组元素
```
#### puts()
```
puts("string");
```
**功能**:输出字符串
**示例**:
```
puts("Hello, World!");
```
#### read()
```
read(variable);
```
**功能**:读取输入(当前实现返回 -1)
### 5.8 注释
```
# This is a comment
```
注释从 `#` 开始到行尾结束。
---
## 六、示例脚本
### 6.1 斐波那契数列
```
# Fibonacci sequence
func fun{
if(x <= 2){
return(1);
}
y = 0;
x = x - 1;
y = fun(x);
x = x - 1;
return(y + fun(x));
};
# 计算 1-15 的斐波那契数列并存入数组
array arr(15);
x = 0;
while(x<15){
arr[x] = fun(x) + 1000;
x = x + 1;
}
puts("Fibonacci sequence:");
i = 0;
while(i < 15){
print(arr[i]);
i=i+1;
}
```
### 6.2 基础赋值
```
a = 5;
b = "ok";
print(5);
puts(b);
```
### 6.3 基础函数
```
func f_test{
return(0);
};
r = f_test();
print(r);
```
### 6.4 基础输出
```
print(1);
puts("1");
```
---
## 七、已知限制
### 🔴 严重问题
| ID | 位置 | 描述 | 影响 |
|----|------|------|------|
| L1 | [L20](file:///Users/qingsong/micro_servers/libtry/try.c#L20) | **缓冲区大小固定**:`POOLSIZE = 128` 字节,无法修改 | 长脚本将溢出 |
| L2 | [L702](file:///Users/qingsong/micro_servers/libtry/try.c#L702) | **`__exit()` 死循环**:`while(1);` 错误时直接挂死程序 | 无法恢复 |
| L3 | [L186](file:///Users/qingsong/micro_servers/libtry/try.c#L186) | **字符串内存泄漏**:`malloc` 分配的字符串在重新赋值时未释放 | 长期运行泄漏 |
| L4 | [L528](file:///Users/qingsong/micro_servers/libtry/try.c#L528) | **数组越界**:无越界检查 | 未定义行为 |
### 🟡 中等问题
| ID | 位置 | 描述 |
|----|------|------|
| L5 | [L598](file:///Users/qingsong/micro_servers/libtry/try.c#L598) | **`puts` 实现存疑**:原 `wprintf` 被注释,改用 `printtovga` |
| L6 | [L518](file:///Users/qingsong/micro_servers/libtry/try.c#L518) | **`clearThis()` 被注释**:作者注释说"不知为何需要注释了才能跑" |
| L7 | [L706](file:///Users/qingsong/micro_servers/libtry/try.c#L706) | **文件模式仅限 `_DEBUG`**:正常构建时 `main()` 不编译 |
| L8 | [L103](file:///Users/qingsong/micro_servers/libtry/try.c#L103) | **栈缓冲区溢出风险**:`nameBuffer[16]` 但标识符长度限制由 `MAXNAMESIZE=16` 控制 |
| L9 | [L21](file:///Users/qingsong/micro_servers/libtry/try.c#L21) | **符号表大小固定**:`SYMTABSIZE = 128` 个符号 |
### 🟢 轻微问题
| ID | 描述 |
|----|------|
| L10 | 错误处理过于简单,`match()` 失败直接 `__exit(-1)` |
| L11 | 大量被注释的调试代码(`//wprintf(...)`) |
| L12 | 全局变量过多(`symtab`, `token`, `token_val`, `src`, `old_src`, `currentlevel` 等) |
| L13 | 无并发支持,非线程安全 |
| L14 | 无单元测试 |
### 修复建议
#### 优先级 1(安全相关)
1. **修复数组越界**:在 [L528](file:///Users/qingsong/micro_servers/libtry/try.c#L528) 添加检查
```c
if (index < 0 || index >= s->value) {
printtovga(L"Array index out of bounds\n");
__exit(-1);
}
```
2. **修复 `__exit()`**:改为返回错误码
```c
static int error_code = 0;
static void __exit(int code) {
error_code = code;
// 不挂起,让调用者处理错误
}
```
3. **修复字符串内存泄漏**:在赋值前释放旧字符串
```c
else if (token == Str) {
if (s->pointer.funcp != NULL) {
free(s->pointer.funcp); // 释放旧字符串
}
s->pointer.funcp = (wchar_t*)token_val.ptr;
s->type = Str;
match(Str);
}
```
#### 优先级 2(功能增强)
4. **动态缓冲区**:将 `POOLSIZE` 改为可配置或动态扩容
5. **错误回调**:添加错误回调函数,让调用者处理错误
6. **符号表扩容**:允许 `SYMTABSIZE` 动态增长
---
## 八、开发指南
### 8.1 添加新关键字
在 [init_tryc()](file:///Users/qingsong/micro_servers/libtry/try.c#L650) 中添加:
```c
// 1. 在 enum 中添加新 token 类型
enum {
Num = 128, Char, Str, Array, Func,
Else, If, Return, While, Print, Puts, Read,
Assign, OR, AND, Equal, Sym, FuncSym, ArraySym, Void,
Nequal, LessEqual, GreatEqual,
NewKeyword // 新增
};
// 2. 在 init_tryc() 中注册
src = L"array func else if return while print puts read new_keyword";
for (i = Array; i <= NewKeyword; ++i) {
next();
symtab[symPointer - 1].type = i;
}
// 3. 在 statement() 中添加处理逻辑
else if (token == NewKeyword) {
match(NewKeyword);
// 处理逻辑
}
```
### 8.2 添加新运算符
在 [next()](file:///Users/qingsong/micro_servers/libtry/try.c#L85) 中添加识别逻辑:
```c
// 例如:+= 运算符
else if (token == L'+') {
if (*src == L'=') {
src++;
token = AddAssign; // 新增 token 类型
}
}
```
在 [statement()](file:///Users/qingsong/micro_servers/libtry/try.c#L466) 中添加执行逻辑。
### 8.3 自定义输出
修改 `printtovga()` 的实现,或添加可配置的输出回调:
```c
// 函数指针类型
typedef void (*output_func)(const wchar_t*);
// 全局输出函数
static output_func current_output = default_output;
// 设置输出函数
void set_output_func(output_func func) {
current_output = func;
}
```
### 8.4 构建说明
#### 编译器要求
- C 编译器(GCC/Clang)
- 支持 C99 标准
- 支持宽字符(`wchar.h`)
#### 编译选项
```bash
# 基本编译
gcc -c try.c -o try.o -std=c99
# 调试模式(启用 main 函数)
gcc -c try.c -o try.o -std=c99 -D_DEBUG
# 优化编译
gcc -c try.c -o try.o -std=c99 -O2
```
#### 链接使用
```bash
# 创建测试程序
cat > test_main.c << 'EOF'
#include <stdio.h>
#include "try.h"
int main() {
init_tryc();
// 注入脚本
wchar_t script[] = L"print(42);";
for (int i = 0; script[i] != L'\0'; i++) {
enqueue_tryc(script[i]);
}
eval_tryc();
free_tryc();
return 0;
}
EOF
# 编译链接
gcc test_main.c try.o -o test_try -std=c99
./test_try
```
---
## 九、测试
### 9.1 现有测试
| 测试文件 | 内容 |
|----------|------|
| `test/t_assign_basic.try` | 变量赋值与输出 |
| `test/t_function_basic.try` | 函数定义与调用 |
| `test/t_output_basic.try` | print/puts 输出 |
| `t.try` | 斐波那契数列(复杂示例) |
### 9.2 运行测试
```bash
# 编译(调试模式)
gcc try.c -o try_interp -std=c99 -D_DEBUG
# 执行测试脚本
./try_interp test/t_assign_basic.try
./try_interp test/t_function_basic.try
./try_interp test/t_output_basic.try
./try_interp t.try
```
### 9.3 添加新测试
在 `test/` 目录下创建 `.try` 文件:
```
# test/test_new_feature.try
# 描述新功能
a = 10;
b = 20;
c = a + b;
print(c);
```
---
## 十、版本历史
| 版本 | 日期 | 主要变更 |
|------|------|---------|
| 当前 | 2025-08-27 | 增加宽字符支持,修复 Str 匹配 BUG,使用 longjmp 替代 exit() |
| 初始 | — | 魔改自 tryC 项目,手写简易解释器 |
### 修改记录(代码注释)
| 日期 | 作者 | 变更 |
|------|------|------|
| 2025-08-27 | qing | 增加宽字符支持 |
| 2025-08-27 | qing | 修复 Str 漏匹配的 BUG |
| 2025-08-27 | qing | 注明内存泄漏风险 |
| 2025-08-27 | qing | 使用 `longjmp()` 替代 `exit()` |
| 2026-07-07 | qing | 增加 `clearThis()` 函数,用于释放变量 |
---
## 附录
### A. 完整关键字列表
```
array, func, else, if, return, while, print, puts, read
```
### B. Token 类型完整列表
```
Num(128), Char(129), Str(130), Array(131), Func(132),
Else(133), If(134), Return(135), While(136), Print(137), Puts(138), Read(139),
Assign(140), OR(141), AND(142), Equal(143), Sym(144), FuncSym(145), ArraySym(146), Void(147),
Nequal(148), LessEqual(149), GreatEqual(150)
```
### C. 限制常量
| 常量 | 值 | 说明 |
|------|-----|------|
| `POOLSIZE` | 128 | 源代码缓冲区大小(字节) |
| `SYMTABSIZE` | 128 | 符号表大小 |
| `MAXNAMESIZE` | 16 | 标识符最大长度 |
| `RETURNFLAG` | DBL_MAX | return 语句标记 |
### D. 参考资源
- [Crafting Interpreters](https://craftinginterpreters.com/) — 解释器编写经典教程
- [Writing an Interpreter in Go](https://interpreterbook.com/) — Go 实现的解释器教程
- [tryC (GitHub)](https://github.com/) — 原始项目(已魔改)
---
*本文档基于 try.c 源码分析生成,供开发团队参考。使用前请评估已知限制。*
+764
View File
@@ -0,0 +1,764 @@
# pymsg 模块技术手册
> 文档版本:2026-07-30
> 代码版本:0.1.0
> 语言:Python 3.9+(核心)、C++(gateway 集成)、Go(waf 集成)
> 定位:跨语言 TCP 消息帧协议库
---
## 一、模块概述
### 1.1 定位
**pymsg** 是一个极简跨语言 TCP 消息帧协议库,基于长度前缀(4 字节大端 uint32)+ UTF-8 载荷。为微服务体系提供轻量级 RPC 通信层。
### 1.2 设计哲学
| 原则 | 说明 |
|------|------|
| 零依赖 | 仅使用 Python stdlib,无需任何第三方包 |
| 极简协议 | 4 字节头 + 载荷,实现只需 20 行代码 |
| 跨语言 | Python/C++/Go 三端实现完全兼容 |
| 二进制安全 | 长度前缀帧,JSON 中的换行符无需转义 |
| fail-safe | 日志推送在 db 不可用时静默丢弃,不影响业务 |
### 1.3 与微服务体系的关系
pymsg 是微服务的**核心通信层**:
```
┌──────────────┐ pymsg ┌──────────────┐
│ dnfs:8006 │ ───────→│ db:8005 │
│ (Python) │ 短连接 │ (Python) │
└──────────────┘ RPC └──────────────┘
┌──────────────┐ pymsg ┌──────────────┐
│ gateway:8443 │ ───────→│ db:8005 │
│ (C++20) │ 短连接 │ │
└──────────────┘ RPC └──────────────┘
┌──────────────┐ pymsg ┌──────────────┐
│ waf:443 │ ───────→│ db:8005 │
│ (Go) │ 短连接 │ │
└──────────────┘ RPC └──────────────┘
```
---
## 二、技术架构
### 2.1 协议格式
```
┌────────────────────────┬──────────────────────────┐
│ 4 字节(大端序) │ N 字节(UTF-8) │
│ uint32 载荷字节长度 │ JSON / 文本载荷 │
└────────────────────────┴──────────────────────────┘
```
### 2.2 架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 微服务调用方 │
│ (dnfs / gateway / waf / static / smtp / captcha) │
└─────────────────────────┬───────────────────────────────────┘
│ MsgClient.call() / send()
┌─────────────────────────▼───────────────────────────────────┐
│ pymsg 协议层 │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 底层协议函数 │ │
│ │ send(sock, data) → [4字节长度头][UTF-8载荷] │ │
│ │ recv(sock) → 读4字节→解析长度→读对应字节→返回 │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────┐ │
│ │ MsgClient 客户端 │ │
│ │ send(msg) — 短连接,发送即忘(带 name 前缀) │ │
│ │ call(msg) — 短连接,RPC 请求-应答 │ │
│ │ listen(cb) — 长连接,回调模式 │ │
│ └──────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────┐ │
│ │ MsgServer 服务端 │ │
│ │ start(on_message) — 启动监听 │ │
│ │ reply(sock, msg) — 回复客户端 │ │
│ │ broadcast(msg) — 广播到所有客户端 │ │
│ │ stop() — 停止服务 │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ TCP
┌─────────────────────────▼───────────────────────────────────┐
│ db:8005 服务端 │
│ MsgServer + 路由分发 │
└─────────────────────────────────────────────────────────────┘
```
### 2.3 消息流
#### RPC 请求-应答
```
调用方 服务端 (db)
────── ──────────
MsgClient.call(json_str)
│
├── socket.connect(127.0.0.1:8005)
├── send(sock, json_str)
│ ├── payload = json_str.encode("utf-8")
│ ├── header = struct.pack("!I", len(payload))
│ └── sock.sendall(header + payload)
│
├── recv(sock) ←── on_message(sock, addr, text)
│ ├── sock.recv(4) ├── json.loads(text)
│ ├── length = struct.unpack("!I", header)[0]
│ ├── sock.recv(length) ├── handler(req)
│ └── payload.decode("utf-8") └── server.reply(sock, response)
│ ├── send(sock, response)
├── close(sock) │ └── sock.sendall(header + payload)
└── return response ←── recv(sock) 返回 response
```
#### 日志推送
```
各服务 (gateway/static/smtp) db:8005
───────────────────────────── ──────────
log_client.push(entry)
│
├── 入内存队列 (ch, 容量 256)
│
├── 后台 goroutine 批量 flush
│ ├── 满 50 条 或 2 秒超时
│ └── 逐条 MsgClient.call(json)
│
└── (同 RPC 流程,fail-safe:失败静默丢弃)
```
---
## 三、代码结构
### 3.1 文件结构
```
pymsg/
├── pymsg/
│ └── __init__.py # 核心实现(~370 行)
├── README.md # 用户文档
├── pyproject.toml # 项目配置
└── .gitignore
```
### 3.2 跨语言实现位置
| 语言 | 文件 | 说明 |
|------|------|------|
| Python | [pymsg/__init__.py](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py) | 主实现 |
| C++ | [gateway/inc/Msg.hpp](file:///Users/qingsong/micro_servers/gateway/inc/Msg.hpp) | gateway 使用 |
| Go | [waf/internal/logclient/logclient.go](file:///Users/qingsong/micro_servers/waf/internal/logclient/logclient.go) | waf 使用 |
| Python (黑盒) | [tests/blackbox/helpers.py](file:///Users/qingsong/micro_servers/tests/blackbox/helpers.py) | 纯 stdlib 测试实现 |
### 3.3 代码索引
#### Python 实现(__init__.py)
| 函数/类 | 行号 | 功能 |
|---------|------|------|
| `send(sock, data, *, timeout=None)` | L48 | 底层发送 |
| `recv(sock, *, timeout=None)` | L72 | 底层接收 |
| `_recv_exact(sock, n)` | L103 | 精确读 n 字节 |
| `_timeout(sock, seconds)` | L34 | 超时 context manager |
| `TimeoutError` | L30 | 超时异常 |
| `MsgClient` | L120 | TCP 客户端 |
| `MsgClient.__init__` | L136 | 构造器 |
| `MsgClient.connect()` | L144 | 建立长连接 |
| `MsgClient.send(msg)` | L150 | 短连接发送(带 name 前缀) |
| `MsgClient.call(msg, *, timeout=None)` | L163 | 短连接 RPC |
| `MsgClient.listen(on_message)` | L193 | 长连接监听 |
| `MsgClient.stop()` | L220 | 停止监听 |
| `MsgServer` | L234 | TCP 服务端 |
| `MsgServer.start(on_message)` | L259 | 启动服务 |
| `MsgServer.broadcast(msg, except_sock)` | L296 | 广播 |
| `MsgServer.reply(sock, msg)` | L321 | 回复 |
| `MsgServer.stop()` | L329 | 停止服务 |
#### C++ 实现(Msg.hpp)
| 函数/类 | 行号 | 功能 |
|---------|------|------|
| `MsgException` | L47 | 基础异常 |
| `MsgTimeoutException` | L52 | 超时异常 |
| `MsgConnectionException` | L57 | 连接异常 |
| `writeUint32BE(buf, val)` | L72 | 4 字节大端编码 |
| `readUint32BE(buf)` | L79 | 4 字节大端解码 |
| `setSockTimeout(sockfd, seconds)` | L88 | 设置 socket 超时 |
| `recvExact(sockfd, buf, n)` | L99 | 精确读 n 字节 |
| `sendAll(sockfd, data, len)` | L121 | 完整发送 |
| `openConnection(addr, port, timeout)` | L139 | 打开短连接 |
| `pmsg_send(sockfd, data)` | L174 | 协议发送 |
| `pmsg_recv(sockfd)` | L188 | 协议接收 |
| `MsgClient` | L210 | C++ 客户端 |
| `MsgClient.connect()` | L220 | 长连接 |
| `MsgClient.send(msg)` | L228 | 短连接发送 |
| `MsgClient.call(msg, timeout)` | L248 | 短连接 RPC |
| `MsgClient.listen(on_message)` | L269 | 长连接监听 |
| `MsgClient.stop()` | L311 | 停止 |
#### Go 实现(logclient.go)
| 函数 | 行号 | 功能 |
|------|------|------|
| `pymsgCall(payload)` | L226 | 发送并接收一条 pymsg 消息 |
| `Client.sendOne(entry, ts)` | L179 | 发送单条日志 |
| `Client.send(batch)` | L171 | 批量发送 |
| `Client.flushLoop()` | L125 | 后台 flush goroutine |
| `Push(service, level, message, extra)` | L82 | 全局推送 |
---
## 四、API 参考
### 4.1 底层函数
#### send(sock, data, *, timeout=None)
```python
def send(sock: socket.socket, data: str, *, timeout: float | None = None) -> None
```
**功能**:在已连接的 socket 上发送一条长度前缀帧消息
- 将字符串编码为 UTF-8
- 前面加上 4 字节大端长度头
- 调用 `sock.sendall()` 发送
**参数**:
- `sock`:已连接的 socket 对象
- `data`:要发送的字符串
- `timeout`:可选超时(秒),超时抛出 `TimeoutError`
#### recv(sock, *, timeout=None)
```python
def recv(sock: socket.socket, *, timeout: float | None = None) -> str
```
**功能**:从已连接的 socket 接收一条长度前缀帧消息
- 读取 4 字节头解出长度
- 读取指定字节数
- 返回解码后的 UTF-8 字符串
**参数**:
- `sock`:已连接的 socket 对象
- `timeout`:可选超时(秒)
**返回值**:解码后的字符串
**异常**:
- `ConnectionError`:连接在完整消息接收前断开
- `TimeoutError`:操作超时
#### TimeoutError
```python
class TimeoutError(Exception)
```
`send` 或 `recv` 超过指定超时时抛出的异常。
---
### 4.2 MsgClient(客户端)
#### 构造
```python
client = MsgClient(addr: str, port: int, name: str)
```
**参数**:
- `addr`:服务端地址或 IP
- `port`:服务端端口
- `name`:客户端标识(`send()` 模式下以 `"name: message"` 格式发送)
#### 方法
| 方法 | 模式 | 说明 |
|------|------|------|
| `connect()` | 长连接 | 创建持久 TCP 连接(供 `listen()` 使用) |
| `send(msg)` | 短连接 | 发送即忘,消息带 `name: msg` 前缀 |
| `call(msg, timeout=10.0)` | 短连接 | RPC 模式,阻塞等待回复 |
| `listen(on_message)` | 长连接 | 阻塞当前线程,持续接收消息 |
| `stop()` | — | 中断 `listen()`,可从另一线程调用 |
#### 使用示例
```python
from pymsg import MsgClient
# RPC 调用(短连接)
client = MsgClient("127.0.0.1", 8005, "my-service")
resp = client.call('{"_route":"execute","action":"select","table":"Users"}', timeout=10.0)
print(resp) # {"success": true, "data": [...]}
# 发送即忘
client.send("hello")
# 长连接监听
def on_message(text):
print(f"收到: {text}")
client.connect()
client.listen(on_message) # 阻塞,直到 stop()
```
---
### 4.3 MsgServer(服务端)
#### 构造
```python
server = MsgServer(addr: str, port: int)
```
**参数**:
- `addr`:监听地址(`0.0.0.0` 表示所有网卡)
- `port`:监听端口
#### 方法
| 方法 | 说明 |
|------|------|
| `start(on_message)` | 启动服务器(阻塞),回调签名:`on_message(sock, addr, text)` |
| `reply(sock, msg)` | 向指定客户端回复 |
| `broadcast(msg, except_sock=None)` | 广播到所有客户端(可排除某个 socket) |
| `stop()` | 停止服务(可从另一线程调用) |
#### 回调签名
```python
def on_message(sock: socket.socket, addr: tuple, text: str):
"""
Args:
sock: 发送消息的客户端套接字(用于 reply)
addr: (ip, port) 元组
text: 解码后的字符串消息
"""
server.reply(sock, "ack")
```
#### 使用示例
```python
from pymsg import MsgServer
def on_message(sock, addr, text):
print(f"[{addr}] {text}")
server.reply(sock, "ack")
server = MsgServer("0.0.0.0", 8005)
server.start(on_message) # 阻塞
```
---
### 4.4 异常速查
| 异常 | 触发条件 |
|------|----------|
| `pymsg.TimeoutError` | `send()` 或 `recv()` 超过 `timeout` |
| `ConnectionError`(内置) | 完整消息接收前 socket 断开 |
| `ConnectionRefusedError`(内置) | `MsgClient.call()` 或 `send()` 无法连接目标 |
| `RuntimeError` | `MsgClient.listen()` 在 `connect()` 之前调用 |
---
## 五、使用场景
### 5.1 场景一:数据库服务(db:8005)
db 服务通过 pymsg 暴露通用 CRUD 接口:
```python
import json
from pymsg import MsgServer
class DBServer:
def __init__(self):
self._server = MsgServer("127.0.0.1", 8005)
self._routes = {"execute": self._handle_execute}
def start(self):
self._server.start(self._dispatch)
def _dispatch(self, sock, addr, text):
req = json.loads(text)
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._server.reply(sock,
json.dumps({"success": False, "error": "未知路由"}))
return
try:
result = handler(req)
self._server.reply(sock,
json.dumps({"success": True, "data": result}))
except Exception as e:
self._server.reply(sock,
json.dumps({"success": False, "error": str(e)}))
def _handle_execute(self, data):
# 通过 SQLAlchemy 执行 SQL,返回 dict 列表
pass
```
### 5.2 场景二:RPC 调用(dnfs → db)
```python
import json
from pymsg import MsgClient
def _call_dbv2(payload):
client = MsgClient("127.0.0.1", 8005, "dnfs")
resp = client.call(json.dumps(payload), timeout=10.0)
return json.loads(resp)
# 创建笔记
result = _call_dbv2({
"_route": "execute",
"action": "insert",
"table": "Notes",
"values": {"title": "会议记录", "content": "..."}
})
```
### 5.3 场景三:异步日志推送(log_client)
```python
import json
from pymsg import MsgClient
def _send(batch):
for entry in batch:
client = MsgClient("127.0.0.1", 8005, "gateway-log")
client.call(json.dumps({
"_route": "execute",
"action": "insert",
"table": "Logs",
"values": {
"ts": now,
"service": entry["service"],
"level": entry["level"],
"message": entry["message"],
}
}), timeout=5)
```
### 5.4 场景四:日志推送(Go 实现)
```go
// waf/internal/logclient/logclient.go
func (c *Client) sendOne(entry Entry, ts string) {
req := dbV2Request{
Route: "execute",
Action: "insert",
Table: "Logs",
Values: values,
}
data, _ := json.Marshal(req)
resp, err := pymsgCall(data) // 短连接 RPC
if err != nil {
return // fail-safe
}
}
func pymsgCall(payload []byte) ([]byte, error) {
conn, err := net.DialTimeout("tcp", "127.0.0.1:8005", 5*time.Second)
// ... 写 4 字节头 + 载荷,读回复
}
```
---
## 六、跨语言实现对照
### 6.1 协议层
| 功能 | Python | C++ | Go |
|------|--------|-----|-----|
| 4 字节编码 | `struct.Struct("!I")` | `writeUint32BE` (手动位移) | `binary.BigEndian.PutUint32` |
| 4 字节解码 | `struct.unpack("!I", header)` | `readUint32BE` (手动位移) | `binary.BigEndian.Uint32` |
| 超时设置 | `_timeout` context manager | `setSockTimeout` (setsockopt) | `net.DialTimeout` + `SetDeadline` |
| 安全上限 | 无 | 64 MiB | 无 |
### 6.2 客户端对照
| 操作 | Python | C++ | Go |
|------|--------|-----|-----|
| 构造 | `MsgClient(addr, port, name)` | `MsgClient(addr, port, name)` | — |
| 长连接 | `connect()` | `connect()` | — |
| 发送即忘 | `send(msg)` 短连接 + `name:` 前缀 | `send(msg)` 短连接 + `name:` 前缀 | — |
| RPC 调用 | `call(msg, timeout)` 短连接 | `call(msg, timeout)` 短连接 | `pymsgCall(payload)` 短连接 |
| 长监听 | `listen(on_message)` | `listen(on_message)` | — |
| 停止 | `stop()` | `stop()` | — |
### 6.3 服务端对照
| 操作 | Python | C++ | Go |
|------|--------|-----|-----|
| 启动 | `start(on_message)` | — | — |
| 回复 | `reply(sock, msg)` | `pmsg_send(fd, msg)` | — |
| 广播 | `broadcast(msg, except_sock)` | — | — |
| 停止 | `stop()` | — | — |
### 6.4 从头实现协议
仅需四步:
```
1. 将字符串编码为 UTF-8 字节
2. 将字节长度打包为 4 字节大端无符号整数
3. 写入 头 + 载荷 到 socket
4. 接收端先读 4 字节解出长度,再读对应字节数,解码为 UTF-8
```
---
## 七、线程安全
| 场景 | 安全性 | 说明 |
|------|--------|------|
| `MsgServer.stop()` 跨线程调用 | ✅ 安全 | 设置原子标志并关闭监听 socket |
| `MsgServer.reply()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
| `MsgServer.broadcast()` 在回调内调用 | ✅ 安全 | 同一线程内调用 |
| `MsgClient.stop()` 跨线程调用 | ✅ 安全 | 可中断 `listen()` |
| `send()`/`recv()` 多线程共享 socket | ⚠️ 不安全 | 调用方需自行串行化 |
| `MsgServer` 并发调用 broadcast/reply | ⚠️ 不支持 | 仅在 `on_message` 回调内(同一线程)安全 |
---
## 八、性能特性
### 8.1 性能特点
| 特性 | 说明 |
|------|------|
| 零拷贝 | 长度前缀帧避免数据复制 |
| 短连接复用 | `call()` 每次新建连接,但调用完成立即释放 |
| fail-safe | 日志推送失败静默丢弃,不阻塞业务 |
| 批量缓冲 | 日志推送:50 条或 2 秒自动 flush |
### 8.2 连接模型
| 模式 | 连接数 | 延迟 | 适用场景 |
|------|--------|------|---------|
| `send()` | 每次 1 个 | 中 | 低频通知 |
| `call()` | 每次 1 个 | 中 | RPC 调用 |
| `listen()` | 持久 1 个 | 低 | 高频消息流 |
---
## 九、已知限制
| ID | 描述 | 严重等级 | 影响范围 |
|----|------|---------|---------|
| L1 | **无消息校验**:协议无 CRC/哈希,传输损坏无法检测 | 🟡 Medium | 所有消息 |
| L2 | **无认证**:任何人可连接 8005 端口,无鉴权机制 | 🔴 High | db 服务 |
| L3 | **无加密**:明文传输,无 TLS/加密保护 | 🔴 High | 所有服务 |
| L4 | **Python 版无安全上限**:C++ 版有 64 MiB 上限,Python 版未检查 | 🟡 Medium | Python 客户端 |
| L5 | **无重试机制**:连接失败直接抛异常,无自动重试 | 🟢 Low | 所有调用 |
| L6 | **无版本号**:协议升级无兼容路径 | 🟢 Low | 长期维护 |
| L7 | **无心跳**:长连接无 keep-alive,可能被防火墙切断 | 🟡 Medium | listen 模式 |
| L8 | **全局阻塞**:`MsgServer.start()` 阻塞主线程,需另开线程 | 🟢 Low | 服务端 |
### 建议改进
1. **L2/L3**:在防火墙层面限制 8005 端口仅允许本机连接(当前部署已这样做)
2. **L4**:在 Python `recv()` 中添加消息大小上限检查
3. **L7**:在 `listen()` 循环中添加 ping/pong 机制
4. **L5**:在 `call()` 中添加简单重试(1-2 次)
---
## 十、设计考量
### 10.1 为什么用长度前缀帧?
| 方案 | 优点 | 缺点 |
|------|------|------|
| **长度前缀帧** ✅ | 二进制安全、实现简单、无需流式解析 | 无 |
| 换行分隔 | 简单 | JSON 中换行需转义、缓冲区管理复杂 |
| HTTP | 成熟、有工具 | 头部开销大、需额外依赖 |
| Protobuf | 高效、类型安全 | 需额外依赖、跨语言实现复杂 |
### 10.2 为什么用短连接?
| 考虑 | 说明 |
|------|------|
| 简单性 | 短连接实现最简洁,无连接管理开销 |
| 可靠性 | 每次调用都是独立连接,单个请求失败不影响其他 |
| 性能 | 本地回环(`127.0.0.1`)下 TCP 连接建立开销很小 |
| 可测试性 | 易于模拟各种网络故障 |
### 10.3 协议设计取舍
| 取舍 | 选择 | 原因 |
|------|------|------|
| 头大小 | 4 字节 | 支持最大 4 GiB 消息,足够使用 |
| 字节序 | 大端 | 网络标准序,跨语言一致 |
| 编码 | UTF-8 | 支持中文/多语言,与 JSON 兼容 |
| 版本号 | 无 | 保持最简,升级时可扩展 |
---
## 十一、开发指南
### 11.1 安装
```bash
# 开发模式
cd pymsg
pip install -e .
# 作为本地依赖
# 在其他项目的 requirements.txt 中:
# ../pymsg
```
### 11.2 添加新功能
#### 添加消息校验(CRC)
```python
import zlib
def send_with_crc(sock, data):
payload = data.encode("utf-8")
crc = zlib.crc32(payload) & 0xFFFFFFFF
# [4字节长度][4字节CRC][UTF-8载荷]
header = _HEADER.pack(len(payload) + 4)
crc_bytes = struct.pack("!I", crc)
sock.sendall(header + crc_bytes + payload)
```
#### 添加心跳机制
```python
import time
def listen_with_heartbeat(self, on_message, interval=30):
last_recv = time.time()
while self._running:
# 发送心跳
if time.time() - last_recv > interval:
send(self._sock, "PING")
# 接收消息
text = recv(self._sock, timeout=1.0)
if text == "PONG":
continue
last_recv = time.time()
on_message(text)
```
### 11.3 扩展协议版本
```python
# 在头中添加版本号
_VERSION = 1 # 1 字节版本号
def send_v2(sock, data):
payload = data.encode("utf-8")
header = struct.pack("!BI", _VERSION, len(payload))
sock.sendall(header + payload)
def recv_v2(sock):
header = _recv_exact(sock, 5)
version, length = struct.unpack("!BI", header)
if version != _VERSION:
raise ValueError(f"Unsupported protocol version: {version}")
payload = _recv_exact(sock, length)
return payload.decode("utf-8")
```
### 11.4 构建与测试
```bash
# Python 测试
cd pymsg
python -c "
from pymsg import MsgClient, MsgServer, send, recv
# ... 单元测试
"
# 黑盒测试
cd tests/blackbox
python test_runner.py
```
---
## 十二、版本历史
| 版本 | 日期 | 主要变更 |
|------|------|---------|
| 0.1.0 | 当前 | 初始版本:核心协议、MsgClient、MsgServer、跨语言实现 |
---
## 附录
### A. 协议规范(RFC 风格)
```
Protocol: pymsg v0
Frame Format:
┌──────────┬──────────┬──────────────────┐
│ 4 bytes │ N bytes │ │
├──────────┼──────────┼──────────────────┤
│ uint32 │ UTF-8 │ │
│ big-end │ string │ │
│ payload │ payload │ │
│ length │ │ │
└──────────┴──────────┴──────────────────┘
Encoding: UTF-8
Byte Order: Big-Endian (network byte order)
Max Message Size: 2^32 - 1 bytes (theoretical)
Timeout: Optional, specified per operation
Modes:
- Short Connection (send/call): connect → send → recv → close
- Long Connection (listen): connect → loop(recv → callback)
```
### B. 错误码映射
| 异常 | 来源 | 场景 |
|------|------|------|
| `TimeoutError` | pymsg | 操作超过指定超时 |
| `ConnectionError` | Python | 连接在接收前断开 |
| `ConnectionRefusedError` | Python | 目标服务不可达 |
| `RuntimeError` | Python | 在 `connect()` 前调用 `listen()` |
| `MsgTimeoutException` | C++ | C++ 版超时 |
| `MsgConnectionException` | C++ | C++ 版连接异常 |
### C. 参考实现清单
| 语言 | 文件 | 类型 |
|------|------|------|
| Python | [pymsg/__init__.py](file:///Users/qingsong/micro_servers/pymsg/pymsg/__init__.py) | 主实现 |
| Python | [tests/blackbox/helpers.py](file:///Users/qingsong/micro_servers/tests/blackbox/helpers.py) | 测试客户端 |
| C++ | [gateway/inc/Msg.hpp](file:///Users/qingsong/micro_servers/gateway/inc/Msg.hpp) | gateway 客户端 |
| Go | [waf/internal/logclient/logclient.go](file:///Users/qingsong/micro_servers/waf/internal/logclient/logclient.go) | waf 客户端 |
### D. 参考资源
- [Beej's Guide to Network Programming](https://beej.us/guide/bgnet/) — Socket 编程经典
- [The SO_REUSEADDR socket option](https://man7.org/linux/man-pages/man2/socket.2.html)
- [Python socket module](https://docs.python.org/3/library/socket.html)
---
*本文档基于 pymsg 源码分析生成,供开发团队参考。*
+165
View File
@@ -0,0 +1,165 @@
# smtp — 邮箱验证码服务
基于自研 [pymsg](../pymsg/) 协议(TCP 长度前缀帧 + JSON)的邮箱验证码服务,通过 SMTP_SSL 发送 6 位数字验证码,支持发送冷却和防暴力枚举。
服务由 `core`(PMC)的 `init.txt` 拉起,监听 `127.0.0.1:8008`(TCP),仅允许本机调用。`gateway` 在 `/register` 流程中经 pymsg 调用本服务完成邮箱验证。
## 架构
```
gateway :8443 (注册流程)
│ pymsg TCP
▼
smtp :8008 (MsgServer)
│ SMTP_SSL
▼
外部 SMTP 服务器 (163/QQ 等)
│
▼
用户邮箱(收验证码)
```
## 路由
服务基于 pymsg 协议,每条消息为完整 JSON,`_route` 字段指定路由。
| _route | 说明 |
|--------|------|
| `send_email` | 生成 6 位数字验证码,通过 SMTP_SSL 发送至指定邮箱 |
| `verify_email` | 校验用户输入的验证码,一次性消费 |
| `health` | 健康检查 |
响应格式:
```json
{"success": true, "data": {...}}
{"success": false, "error": "..."}
```
### 1. send_email — 发送验证码
**请求**:
```json
{"_route": "send_email", "email": "user@example.com"}
```
可选字段 `subject` / `content` 覆盖默认标题与正文,`content` 中 `{code}` 会被替换为验证码。
**响应**:
| 响应 data | 触发条件 |
|-----------|----------|
| `{"sent": true, "ttl": 600}` | 发送成功,TTL 为有效期(秒) |
| `{"sent": false, "error": "invalid email"}` | 邮箱格式无效 |
| `{"sent": false, "error": "SMTP 服务未配置"}` | .env 缺少 SMTP 配置 |
| `{"sent": false, "retry_after": N, "error": "发送过于频繁,请 N 秒后重试"}` | 同一邮箱在冷却期内 |
**安全机制**:
- 发送冷却:同一邮箱两次成功发送的最小间隔 `SEND_INTERVAL`(60 秒)。冷却期内请求直接拒绝,不覆盖已有验证码。
- 发送失败回滚:SMTP 发送失败时立即删除刚写入的验证码,避免"码已存但邮件没到"的不可用状态。
### 2. verify_email — 校验验证码
**请求**:
```json
{"_route": "verify_email", "email": "user@example.com", "code": "123456"}
```
兼容旧字段名 `input_captcha`。
**响应**(均为 `success: true`,业务结果在 `data` 中区分):
| 响应 data | 触发条件 |
|-----------|----------|
| `{"valid": true}` | 验证码正确(已消费) |
| `{"valid": false, "error": "email and code required"}` | 缺少字段 |
| `{"valid": false, "error": "invalid or expired code"}` | 验证码错误、过期或不存在 |
| `{"valid": false, "invalidated": true, "error": "验证码连续错误 5 次,已失效,请重新获取"}` | 连续校验失败达上限 |
> 验证码一经正确校验即从内存中删除(一次性消费),防重放。同一验证码连续校验失败达 `MAX_VERIFY_ATTEMPTS`(5 次)即作废,防止对 6 位数字码的在线枚举。
### 3. health — 健康检查
**请求**:`{"_route": "health"}`
**响应**:`{"success": true, "data": {"status": "ok", "service": "smtp"}}`
## 配置
通过 `.env` 文件配置 SMTP 连接信息,支持 4 种格式:
```bash
# KEY=VALUE(推荐)
SMTP_SERVER=smtp.163.com
SMTP_PORT=465
SMTP_USER=your@163.com
__PASSWORD=your_smtp_password
# 也支持 KEY: VALUE / KEY VALUE / export KEY=...
MAIL_FROM=dnfs@qsont.me # 可选,发件人身份;默认跟随 SMTP_USER
```
| 变量 | 必填 | 说明 |
|------|------|------|
| `SMTP_SERVER` | 是 | SMTP 服务器地址 |
| `SMTP_PORT` | 否 | SMTP 端口,默认 465 |
| `SMTP_USER` | 是 | 邮箱登录账号 |
| `__PASSWORD` | 是 | 邮箱密码/授权码(兼容别名 `SMTP_PASSWORD`) |
| `MAIL_FROM` | 否 | 发件人身份,默认同 SMTP_USER。多数服务商强制 From 头与登录账号一致 |
服务启动时检查必填配置项,缺失时打印警告日志但不阻止启动(`send_email` 路由会返回配置错误)。
## 数据模型与验证码生命周期
- **存储**:进程内存字典 `code_store`,键为 email,值为 `{code, expires_at, attempts}`。
- **冷却记录**:`send_history` 字典,键为 email,值为上次成功发送的时间戳。
- **生成**:`send_email` 成功时写入 `code_store`,记录 `expires_at = now + CODE_TTL`(600 秒)。
- **消费**:`verify_email` 校验通过时 `del code_store[email]`,不可再次使用。
- **过期**:超时后 `verify_email` 自动删除并返回 `invalid or expired code`。`send_email` 时惰性清理过期项。
- **作废**:连续校验失败达 5 次后删除验证码,返回 `invalidated: true`。
- **发送失败回滚**:SMTP 异常时立即 `pop` 刚写入的 `code_store` 条目。
## 运行
```bash
# 初始化(创建 venv + 安装依赖)
bash _set.sh
# 启动
bash _run.sh
# 或直接运行
python3 smtp_v2.py --addr 127.0.0.1 --port 8008
```
## 集成关系
- 由 `core` 通过 `init.txt` 拉起的 `bash _run.sh` 守护运行。
- `gateway` 在 `/register` 时经 pymsg 调 `send_email` 发送验证码,调 `verify_email` 校验。需在 gateway 的 `example.ini` 中启用邮箱验证功能。
- 日志通过 `log_client.py` 异步批量推送至 db:8005 的 Logs 表(缓冲 50 条 / 2 秒)。
## 项目结构
```
smtp/
├── smtp_v2.py # 服务主程序(pymsg 协议,当前有效版本)
├── smtp.py # 旧版 Flask HTTP 实现(已废弃,仅作参考)
├── log_client.py # 集中日志推送 SDK
├── _set.sh # 初始化脚本(创建 venv + 安装依赖)
├── _run.sh # 启动脚本(exec python3 smtp_v2.py)
├── requirements.txt # Python 依赖(pymsg、nuitka)
└── .gitignore # 忽略 __pycache__ / .env / venv
```
## 注意事项
- 验证码存储在进程内存中,服务重启后全部失效;不支持多实例水平扩展。
- 默认仅监听 `127.0.0.1`,仅供本机调用;浏览器侧不直接访问本服务。
- 旧版 `smtp.py`(Flask HTTP 实现)已废弃,`_run.sh` 当前启动的是 `smtp_v2.py`。旧版存在无冷却、无失败回滚、无防枚举等安全问题,且 `verify_email` 在 email 不存在时有 KeyError 风险,不应在生产环境使用。
- 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,`MAIL_FROM` 仅当服务商允许自定义发件身份时才起作用。
- 验证码为纯 6 位数字,有效期 600 秒。
## 许可证
MIT License
+2 -1
View File
@@ -2,4 +2,5 @@
cd "$(dirname "$0")" || exit
source ./venv/bin/activate
#exec ./smtp.bin
exec python3 smtp.py
sleep 2
exec python3 smtp_v2.py
+1 -1
View File
@@ -4,4 +4,4 @@ python3 -m venv venv
#sudo chmod +x .env/bin/activate
source venv/bin/activate
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
#nuitka --standalone smtp.py
#nuitka --standalone smtp_v2.py
+66 -227
View File
@@ -1,245 +1,84 @@
"""
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
#!/usr/bin/env python3
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
请求示例::
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
{"_route": "health"} → 健康检查
响应格式(与 dbv2 / captcha 一致)::
{"success": true, "data": {...}}
{"success": false, "error": "..."}
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
"""
import json
import os
import random
import smtplib
import threading
import time
from flask import Flask, request, jsonify
from email.mime.text import MIMEText
# 手动加载 .env(支持 KEY=VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
_env_loaded = 0
if os.path.isfile(_env_path):
with open(_env_path, "r") as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#"):
continue
if _line.startswith("export "):
_line = _line[len("export "):].strip()
# 支持 KEY=VALUE 与 KEY VALUE 两种写法
if "=" in _line:
_key, _, _val = _line.partition("=")
else:
_parts = _line.split(None, 1)
_key = _parts[0]
_val = _parts[1] if len(_parts) > 1 else ""
_key = _key.strip()
_val = _val.strip().strip("\"'")
if _key and _key not in os.environ:
os.environ[_key] = _val
_env_loaded += 1
else:
print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}")
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
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
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]] = {}
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
SMTP_USER = os.environ.get("SMTP_USER", "")
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
@app.route("/send_email", methods=["POST"])
CODE_LEN = 6
CODE_TTL = 60 # 验证码有效期(秒)
MAIL_FROM = "dnfs@qsont.me"
DEFAULT_SUBJECT = "DNFS 邮箱验证"
DEFAULT_CONTENT = "DNFS: 您的邮箱验证码为 {code},一分钟内有效。"
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
code_store = {} # email -> {'code': str, 'expires_at': float}
STORE_LOCK = threading.Lock()
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()
# ═══════════════════════════════════════════════════════════════════════
# SmtpServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
@app.route("/verify_email", methods=["POST"])
class SmtpServer:
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
def verify_email():
data = request.get_json()
def __init__(self, addr="127.0.0.1", port=8008):
self.addr = addr
self.port = port
self._server = MsgServer(addr, port)
for i in list(captchas.keys()):
if int(captchas[i][1]) < int(time.time()):
del captchas[i]
# 路由表
self._routes = {
"send_email": self._handle_send_email,
"verify_email": self._handle_verify_email,
"health": self._handle_health,
}
email: str = data.get("email")
input_captcha: str = data.get("input_captcha")
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
# 只报缺失的 key 名,绝不报值
missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER),
("SMTP_USER", SMTP_USER),
("__PASSWORD", SMTP_PASSWORD)) if not v]
logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}"
f"(.env 加载 {_env_loaded} 项),send_email 将始终失败")
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req)
self._reply(sock, True, data=result)
except Exception as e:
logs.error("smtp", f"路由 {route} 处理异常: {e}")
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
def _handle_send_email(self, req):
"""生成 6 位数字验证码,存入内存(60s),经 SMTP_SSL 发送邮件。
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
会被替换为验证码。
"""
email = str(req.get("email", "")).strip()
if not email or "@" not in email:
return {"sent": False, "error": "invalid email"}
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
return {"sent": False, "error": "SMTP 服务未配置"}
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
subject = str(req.get("subject") or DEFAULT_SUBJECT)
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
with STORE_LOCK:
code_store[email] = {'code': code, 'expires_at': time.time() + CODE_TTL}
# 惰性清理过期项
now = time.time()
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
for k in expired:
del code_store[k]
message = MIMEText(content, "plain", "UTF-8")
message["From"] = MAIL_FROM
message["To"] = email
message["Subject"] = subject
# server 作用域限定在 try 内;连接失败时不会误调 quit()
try:
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_USER, [email], message.as_string())
except Exception as err:
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
with STORE_LOCK:
code_store.pop(email, None)
logs.error("smtp", f"{email}: 验证码发送失败({err})")
return {"sent": False, "error": f"验证码发送失败:{err}"}
logs.info("smtp", f"{email}: 验证码发送成功({code})")
return {"sent": True, "ttl": CODE_TTL}
def _handle_verify_email(self, req):
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。"""
email = str(req.get("email", "")).strip()
# 兼容旧字段 input_captcha
code = str(req.get("code") or req.get("input_captcha") or "").strip()
if not email or not code:
return {"valid": False, "error": "email and code required"}
with STORE_LOCK:
entry = code_store.get(email)
if entry and entry['expires_at'] < time.time():
del code_store[email]
entry = None
if entry and entry['code'] == code:
# 验证通过:一次性消费,防重放
del code_store[email]
valid = True
else:
valid = False
if valid:
logs.info("smtp", f"{email}: 验证通过")
return {"valid": True}
if captchas[email][0] == input_captcha:
logs.info("smtp", f"{email}: 验证通过")
return jsonify({"success": "验证通过"}), 200
else:
logs.info("smtp", f"{email}: 验证失败")
return {"valid": False, "error": "invalid or expired code"}
return jsonify({"error": "验证失败"}), 400
def _handle_health(self, _req):
return {"status": "ok", "service": "smtp"}
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8008, help="监听端口")
args = parser.parse_args()
server = SmtpServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
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)
+289
View File
@@ -0,0 +1,289 @@
"""
smtp — 基于 pymsg 协议(MsgServer)的邮箱验证码服务。
替代原 Flask HTTP 接口,通过 TCP 长度前缀帧收发 JSON。
协议:每条消息为完整 JSON,``_route`` 字段指定路由名。
请求示例::
{"_route": "send_email", "email": "user@example.com"} → 生成并发送验证码
{"_route": "verify_email", "email": "...", "code": "123456"} → 校验(一次性消费)
{"_route": "health"} → 健康检查
响应格式(与 dbv2 / captcha 一致)::
{"success": true, "data": {...}}
{"success": false, "error": "..."}
SMTP 账号经 .env 配置:SMTP_SERVER / SMTP_PORT / SMTP_USER / __PASSWORD
(__PASSWORD 兼容别名 SMTP_PASSWORD)。
"""
import json
import os
import random
import smtplib
import threading
import time
from email.mime.text import MIMEText
# 手动加载 .env(支持 KEY=VALUE / KEY: VALUE / KEY VALUE / export KEY=... 格式,容忍空行/注释/引号)
_env_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
_env_loaded = 0
if os.path.isfile(_env_path):
with open(_env_path, "r") as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#"):
continue
if _line.startswith("export "):
_line = _line[len("export "):].strip()
# 支持 KEY=VALUE / KEY: VALUE / KEY VALUE 三种写法
if "=" in _line:
_key, _, _val = _line.partition("=")
elif ":" in _line:
_key, _, _val = _line.partition(":")
else:
_parts = _line.split(None, 1)
_key = _parts[0]
_val = _parts[1] if len(_parts) > 1 else ""
_key = _key.strip()
_val = _val.strip().strip("\"'")
if _key and _key not in os.environ:
os.environ[_key] = _val
_env_loaded += 1
else:
print(f"[smtp] 警告: 未找到 .env 文件: {_env_path}")
from log_client import logs
# pymsg 导入
import sys as _sys
_sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from pymsg import MsgServer # noqa: E402
# ── 配置 ────────────────────────────────────────────────────────────────
SMTP_SERVER = os.environ.get("SMTP_SERVER", "")
SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
SMTP_USER = os.environ.get("SMTP_USER", "")
# 历史 .env 使用 __PASSWORD,允许 SMTP_PASSWORD 作为别名
SMTP_PASSWORD = os.environ.get("__PASSWORD") or os.environ.get("SMTP_PASSWORD", "")
CODE_LEN = 6
CODE_TTL = 600 # 验证码有效期(秒)
SEND_INTERVAL = 60 # 同一邮箱两次成功发送的最小间隔(秒)
MAX_VERIFY_ATTEMPTS = 5 # 同一验证码最大校验失败次数,达到即作废(防枚举 6 位数字码)
# 多数邮箱服务商(163/QQ 等)强制 From 头与登录账号一致,故默认跟随 SMTP_USER;
# 仅当服务商允许自定义发件身份时才在 .env 里显式配 MAIL_FROM 覆盖
MAIL_FROM = os.environ.get("MAIL_FROM") or SMTP_USER
DEFAULT_SUBJECT = "DNFS 邮箱验证"
# 有效期文案由 CODE_TTL 派生,改 TTL 不用同步改这里({{code}} 是 f-string 转义)
DEFAULT_CONTENT = f"DNFS: 您的邮箱验证码为 {{code}},{CODE_TTL // 60} 分钟内有效。"
code_store = {} # email -> {'code': str, 'expires_at': float, 'attempts': int}
send_history = {} # email -> 上次成功发送的时间戳(发送冷却用)
STORE_LOCK = threading.Lock()
# ═══════════════════════════════════════════════════════════════════════
# SmtpServer — 主服务类
# ═══════════════════════════════════════════════════════════════════════
class SmtpServer:
"""基于 pymsg 的邮箱验证码服务,通过 TCP 长连接收发 JSON。"""
def __init__(self, addr="127.0.0.1", port=8008):
self.addr = addr
self.port = port
self._server = MsgServer(addr, port)
# 路由表
self._routes = {
"send_email": self._handle_send_email,
"verify_email": self._handle_verify_email,
"health": self._handle_health,
}
# ── 启动 / 停止 ──────────────────────────────────────────────────
def start(self) -> None:
"""启动服务(阻塞当前线程)。"""
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
# 只报缺失的 key 名,绝不报值
missing = [k for k, v in (("SMTP_SERVER", SMTP_SERVER),
("SMTP_USER", SMTP_USER),
("__PASSWORD", SMTP_PASSWORD)) if not v]
logs.error("smtp", f"SMTP 配置缺失: {','.join(missing)}"
f"(.env 加载 {_env_loaded} 项),send_email 将始终失败")
print(f"[smtp] 警告: SMTP 配置缺失: {','.join(missing)}(.env 加载 {_env_loaded} 项)")
print(f"[smtp] 监听 tcp://{self.addr}:{self.port}")
logs.info("smtp", f"开始监听 tcp://{self.addr}:{self.port}")
self._server.start(self._dispatch)
def stop(self) -> None:
"""从另一线程停止服务。"""
self._server.stop()
# ── 消息调度 -------------------------------------------------------
def _dispatch(self, sock, addr, text):
"""解析 JSON 中的 _route,分发给对应的 handler,并回复结果。"""
try:
req = json.loads(text)
except json.JSONDecodeError as e:
self._reply(sock, False, error=f"JSON 解析失败: {e}")
return
route = req.pop("_route", "")
handler = self._routes.get(route)
if handler is None:
self._reply(sock, False, error=f"未知路由: {route}")
return
try:
result = handler(req)
self._reply(sock, True, data=result)
except Exception as e:
logs.error("smtp", f"路由 {route} 处理异常: {e}")
self._reply(sock, False, error=str(e))
def _reply(self, sock, success, data=None, error=None):
resp = {"success": success}
if data is not None:
resp["data"] = data
if error is not None:
resp["error"] = error
self._server.reply(sock, json.dumps(resp, ensure_ascii=False))
# ═══════════════════════════════════════════════════════════════════
# 路由处理
# ═══════════════════════════════════════════════════════════════════
def _handle_send_email(self, req):
"""生成 6 位数字验证码,存入内存(TTL 600s),经 SMTP_SSL 发送邮件。
可选字段 subject / content 覆盖默认标题与正文,content 中 {code}
会被替换为验证码。同一邮箱 SEND_INTERVAL 秒内仅允许成功发送一次,
冷却期内的请求直接拒绝且不覆盖已发出的有效验证码。
"""
email = str(req.get("email", "")).strip()
if not email or "@" not in email:
return {"sent": False, "error": "invalid email"}
if not all([SMTP_SERVER, SMTP_USER, SMTP_PASSWORD]):
logs.error("smtp", f"{email}: SMTP 配置缺失,无法发送")
return {"sent": False, "error": "SMTP 服务未配置"}
# 发送冷却:同一邮箱 SEND_INTERVAL 秒内仅放行一次;发送失败不计入,
# 用户可立即重试
with STORE_LOCK:
remain = SEND_INTERVAL - (time.time() - send_history.get(email, 0.0))
if remain > 0:
wait = int(remain) + 1
logs.info("smtp", f"{email}: 发送冷却中,剩余 {wait}s,已拒绝")
return {"sent": False, "retry_after": wait,
"error": f"发送过于频繁,请 {wait} 秒后重试"}
code = "".join(str(random.randint(0, 9)) for _ in range(CODE_LEN))
subject = str(req.get("subject") or DEFAULT_SUBJECT)
content = str(req.get("content") or DEFAULT_CONTENT).replace("{code}", code)
with STORE_LOCK:
code_store[email] = {'code': code,
'expires_at': time.time() + CODE_TTL,
'attempts': 0}
# 惰性清理过期项
now = time.time()
expired = [k for k, v in code_store.items() if v['expires_at'] < now]
for k in expired:
del code_store[k]
message = MIMEText(content, "plain", "UTF-8")
message["From"] = MAIL_FROM
message["To"] = email
message["Subject"] = subject
# server 作用域限定在 try 内;连接失败时不会误调 quit()
try:
with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server:
server.login(SMTP_USER, SMTP_PASSWORD)
server.sendmail(SMTP_USER, [email], message.as_string())
except Exception as err:
# 发送失败:作废刚写入的验证码,避免"码已存但邮件没到"的不可用状态
with STORE_LOCK:
code_store.pop(email, None)
logs.error("smtp", f"{email}: 验证码发送失败({err})")
return {"sent": False, "error": f"验证码发送失败:{err}"}
# 发送成功:记录冷却起点,并惰性清理已失效的冷却记录
with STORE_LOCK:
send_history[email] = time.time()
stale = [k for k, v in send_history.items()
if time.time() - v > SEND_INTERVAL]
for k in stale:
del send_history[k]
logs.info("smtp", f"{email}: 验证码发送成功({code})")
return {"sent": True, "ttl": CODE_TTL}
def _handle_verify_email(self, req):
"""校验验证码,验证通过即一次性消费;不存在的 email 安全返回 invalid。
同一验证码连续校验失败达 MAX_VERIFY_ATTEMPTS 次即作废删除(响应带
invalidated 标记),防止对 6 位数字码的在线枚举。
"""
email = str(req.get("email", "")).strip()
# 兼容旧字段 input_captcha
code = str(req.get("code") or req.get("input_captcha") or "").strip()
if not email or not code:
return {"valid": False, "error": "email and code required"}
invalidated = False
with STORE_LOCK:
entry = code_store.get(email)
if entry and entry['expires_at'] < time.time():
del code_store[email]
entry = None
if entry and entry['code'] == code:
# 验证通过:一次性消费,防重放
del code_store[email]
valid = True
else:
valid = False
if entry:
# 仅对已发出且未过期的验证码计失败次数;达到上限即作废
entry['attempts'] = entry.get('attempts', 0) + 1
if entry['attempts'] >= MAX_VERIFY_ATTEMPTS:
del code_store[email]
invalidated = True
if valid:
logs.info("smtp", f"{email}: 验证通过")
return {"valid": True}
if invalidated:
logs.info("smtp", f"{email}: 连续校验失败 {MAX_VERIFY_ATTEMPTS} 次,验证码已作废")
return {"valid": False, "invalidated": True,
"error": f"验证码连续错误 {MAX_VERIFY_ATTEMPTS} 次,已失效,请重新获取"}
logs.info("smtp", f"{email}: 验证失败")
return {"valid": False, "error": "invalid or expired code"}
def _handle_health(self, _req):
return {"status": "ok", "service": "smtp"}
# ═══════════════════════════════════════════════════════════════════════
# 入口
# ═══════════════════════════════════════════════════════════════════════
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="smtp — pymsg 邮箱验证码服务")
parser.add_argument("--addr", default="127.0.0.1", help="监听地址")
parser.add_argument("--port", type=int, default=8008, help="监听端口")
args = parser.parse_args()
server = SmtpServer(args.addr, args.port)
try:
server.start()
except KeyboardInterrupt:
server.stop()
+50 -5
View File
@@ -7,18 +7,23 @@
## 架构
```
gateway :443 (反向代理)
waf :443 (TLS 终止 + 安全规则)
│
▼
gateway :8443 (反向代理 + 认证)
│
▼
static :9200 (Flask + CORS)
│
└── public/ 托管目录
├── index.html 社区首页
├── login.html 登录
├── login.html 登录(默认首页)
├── register.html 注册
├── index.html 社区首页
├── forgot.html 忘记密码
├── dnfs.html 树状笔记(调用 dnfs:8006)
├── __view.html 文件浏览器(系统文件,不在列表中显示)
├── allcolor.html 调色板
├── LICENSE.html 许可证页面
├── css/ theme.css(统一主题)+ view.css
├── js/ dnfs.js / login.js / register.js / validation.js / view.js
├── games/ HTML 小游戏合集
@@ -27,15 +32,46 @@ static :9200 (Flask + CORS)
## 路由
### 核心路由
| 路径 | 说明 |
|------|------|
| `GET /` | 社区首页(index.html),不存在时回退 `__view.html` |
| `GET /` | 登录页(login.html),如不存在回退 `__view.html` |
| `GET /<path>` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 |
| `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 |
| `GET /video/<path>` | 视频/音频流媒体(HTTP Range 206 分段) |
| `GET /<path>?download=true` | 强制下载 |
| `GET /health` | 健康检查 |
### 内部工具路由
| 路径 | 说明 |
|------|------|
| `POST /api/hash` | SHA256 密码哈希 |
| `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) |
| `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) |
### 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 验证码代理转发 |
### dnfs 代理路由
| 路径 | 说明 |
|------|------|
| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs:8006) |
| `POST /add_note` | 新增笔记(代理至 dnfs:8006) |
| `POST /delete_note` | 删除笔记(代理至 dnfs:8006) |
| `POST /update_note` | 更新笔记(代理至 dnfs:8006) |
| `POST /whoami` | 当前用户身份查询(代理至 dnfs:8006) |
以上 5 个 dnfs 代理路由均支持 HttpOnly Cookie 模式:当请求携带 `q4_token` Cookie 时,自动转为 `Authorization: Bearer` 头转发。
## 功能
- 文件浏览:目录导航、面包屑、实时搜索(`/` 或 `Ctrl+F`)、文件统计
@@ -43,6 +79,8 @@ static :9200 (Flask + CORS)
- 深色模式:`css/theme.css` 统一主题,自动适配系统偏好
- 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内
- Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定)
- 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头
- 集中日志:`log_client.py` 通过 pymsg TCP 异步批量推送日志至 db:8005 的 Logs 表
## 配置
@@ -60,6 +98,11 @@ root = ./public
enabled = false
cert_file = cert.pem
key_file = key.pem
[cors]
# 允许跨域的 Origin 白名单(逗号分隔)
# 注意:生产环境请勿包含 localhost / 127.0.0.1 / 通配符 *
allowed_origins = https://qsont.me,https://yhdet.top
```
## 依赖
@@ -67,7 +110,9 @@ key_file = key.pem
```
flask>=2.3.0,<3.0.0
flask-cors>=4.0.0,<5.0.0
nuitka # 可选,编译为独立二进制
waitress>=2.1.2,<4.0.0 # 生产级 WSGI 服务器(非 SSL 模式默认使用)
pymsg # 集中日志推送(log_client 依赖)
nuitka # 可选,编译为独立二进制
```
## 运行
+17 -2
View File
@@ -474,6 +474,22 @@ def proxy_update_note():
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/whoami', methods=['POST', 'OPTIONS'])
def proxy_whoami():
if request.method == "OPTIONS": return _cors(make_response("", 204))
try:
data = request.get_json(force=True, silent=True) or {}
h = {"Content-Type": "application/json"}
if "Authorization" in request.headers: h["Authorization"] = request.headers["Authorization"]
elif request.cookies.get("q4_token"): h["Authorization"] = f"Bearer {request.cookies['q4_token']}" # HttpOnly Cookie 模式:Cookie 转 Bearer
req = _urlreq.Request(f"{DNFS_URL}/whoami", data=_json.dumps(data).encode(), headers=h, method="POST")
with _urlreq.urlopen(req, timeout=15) as r:
return _cors(make_response(r.read().decode(), 200, {"Content-Type": "application/json"}))
except Exception as e:
logs.warning("static", "dnfs whoami 代理失败", extra={"error": str(e)})
return _cors(make_response(_json.dumps({"error": str(e)}), 502, {"Content-Type": "application/json"}))
@app.route('/api/proxy_send_code', methods=['POST'])
def proxy_send_code():
from flask import request, jsonify
@@ -506,8 +522,7 @@ def serve_view():
"""根路径:优先返回 index.html(社区首页),不存在时回退到 __view.html(文件浏览)"""
if not FILESYSTEM_ENABLED:
return "文件系统未启用", 403
#index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
index_path = os.path.join(HTML_ROOT_DIR, 'login.html')
index_path = os.path.join(HTML_ROOT_DIR, 'index.html')
if os.path.exists(index_path):
return send_file(index_path, mimetype='text/html')
view_path = os.path.join(HTML_ROOT_DIR, VIEW_FILE)
+1
View File
@@ -47,6 +47,7 @@
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</footer>
<script src="js/validation.js"></script>
<script src="js/view.js"></script>
</body>
</html>
+52 -6
View File
@@ -365,6 +365,33 @@ input[type="button"]:disabled {
font-weight: 600;
}
/* ===== User Panel (dnfs 左上角用户信息,镜像 minimap 样式) ===== */
.userpanel {
position: fixed;
left: 1rem;
top: 1rem;
z-index: 100;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 80vh;
overflow-y: auto;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-md);
background: rgba(253, 246, 238, 0.72);
border: 1px solid var(--border-light);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-md);
transition: background 0.2s, border-color 0.2s;
}
@media (prefers-color-scheme: dark) {
.userpanel {
background: rgba(30, 23, 18, 0.72);
border-color: var(--border);
}
}
/* ===== Minimap (dnfs) ===== */
.minimap {
position: fixed;
@@ -392,8 +419,17 @@ input[type="button"]:disabled {
border-color: var(--border);
}
}
.minimap:empty { display: none; }
.minimap .tag {
.minimap:empty, .userpanel:empty { display: none; }
/* userpanel 纯展示项(用户名/角色):不可点击,无手型光标与 hover 变色 */
.userpanel .tag.no-hover {
cursor: default;
}
.userpanel .tag.no-hover:hover {
color: var(--fg-secondary);
background: rgba(232, 133, 58, 0.08);
border-color: transparent;
}
.minimap .tag, .userpanel .tag {
display: inline-block;
max-width: 140px;
overflow: hidden;
@@ -416,18 +452,18 @@ input[type="button"]:disabled {
border-radius: 4px;
}
.minimap::-webkit-scrollbar-thumb:hover { background: var(--muted); }
.minimap .tag:hover {
.minimap .tag:hover, .userpanel .tag:hover {
color: var(--accent-dark);
background: var(--accent-soft);
border-color: var(--accent);
}
.minimap .tag.back {
.minimap .tag.back, .userpanel .tag.back {
color: var(--accent);
background: var(--accent-soft);
border-color: var(--accent);
margin-bottom: 0.25rem;
}
.minimap .tag.fold-btn {
.minimap .tag.fold-btn, .userpanel .tag.fold-btn {
color: var(--fg-secondary);
background: transparent;
border: 1px solid var(--border);
@@ -438,7 +474,7 @@ input[type="button"]:disabled {
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.minimap .tag.fold-btn:hover {
.minimap .tag.fold-btn:hover, .userpanel .tag.fold-btn:hover {
color: var(--accent-dark);
background: var(--accent-soft);
border-color: var(--accent);
@@ -447,6 +483,16 @@ input[type="button"]:disabled {
/* ===== Node Form (dnfs) ===== */
.node-form { margin-top: 1rem; }
.node-form .form-actions { margin-top: 0.75rem; }
.node-form #input-note-title {
padding: 0.8rem 1rem;
font-size: 1rem;
}
.node-form #input-note-content {
padding: 0.9rem 1rem;
font-size: 0.95rem;
min-height: 120px;
line-height: 1.7;
}
/* ===== Markdown Content ===== */
code {
+2
View File
@@ -9,6 +9,7 @@
</head>
<body>
<div class="box">
<div id="userpanel" class="userpanel"></div>
<div id="minimap" class="minimap"></div>
<div id="movebar" class="movebar"></div>
<div class="recommend-list" id="recommend-list"></div>
@@ -25,6 +26,7 @@
<!--<script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script>-->
<script src="js/markdown-it-12_0_0.js"></script>
<script src="js/validation.js"></script>
<script src="js/token_manager.js"></script>
<script src="js/dnfs.js"></script>
</div>
<footer class="footer">
+107 -30
View File
@@ -19,6 +19,11 @@ async function getNode(id) {
});
const responseText = await res.text();
// WAF 拦截:弹提示并中止,不能落入 catch(其会误跳登录页)
if (window.WafTip && WafTip.isBlocked(res, responseText)) {
alert(WafTip.MESSAGE);
return;
}
let result;
try {
@@ -48,8 +53,9 @@ async function getNode(id) {
//}
} catch (err) {
alert(err.message || '接口请求失败,请检查后端服务是否启动');
// 获取节点失败(非 JSON 响应 / HTTP 错误 / 网络异常),统一跳转登录页
console.error('获取节点请求异常:', err);
location.href = '/login.html';
} finally {
}
}
@@ -77,6 +83,7 @@ async function addNode(pid, title, content) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
@@ -136,6 +143,7 @@ async function deleteNode(id) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
@@ -187,6 +195,7 @@ async function deleteChild(id) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
@@ -245,6 +254,7 @@ async function updateNode(id, pid, title, content) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
@@ -300,6 +310,7 @@ async function moveNode(id, pid, title, content) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
@@ -438,7 +449,9 @@ var recommend_list = document.getElementById('recommend-list');
var delete_note = document.getElementById('delete-note');
var minimap = document.getElementById("minimap");
var movebar = document.getElementById("movebar");
var is_expand = true;
/* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */
var PANEL_EXPAND_MIN_WIDTH = 768;
var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH;
var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示当前是新增模式 */
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
@@ -559,39 +572,44 @@ function load_notes(main_node) {
sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count));
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
// 卡片操作按钮:编辑 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
let act_div = document.createElement('div');
act_div.className = "card-actions";
let edit_btn = document.createElement('button');
edit_btn.className = "act-btn act-edit";
edit_btn.innerHTML = ICON_EDIT + "编辑";
edit_btn.addEventListener('click', function(event) {
event.stopPropagation();
startEdit(item);
});
let move_btn = document.createElement('button');
move_btn.className = "act-btn act-move";
move_btn.innerHTML = ICON_MOVE + "移动";
move_btn.addEventListener('click', function(event) {
event.stopPropagation();
startMove(item);
});
let del_btn = document.createElement('button');
del_btn.className = "act-btn act-del";
del_btn.innerHTML = ICON_TRASH + "删除";
del_btn.addEventListener('click', function(event) {
event.stopPropagation();
deleteChild(item.id);
});
act_div.appendChild(edit_btn);
act_div.appendChild(move_btn);
act_div.appendChild(del_btn);
// 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
// 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定,
// 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note);
// 未登录或无权时不渲染整个按钮组
ttl.appendChild(wrt_div);
ttl.appendChild(sts_div);
let ctt = document.createElement('p');
renderMarkdown(ctt, item.content);
card.appendChild(ctt)
card.appendChild(act_div);
if (item.can_operate === true) {
let act_div = document.createElement('div');
act_div.className = "card-actions";
let edit_btn = document.createElement('button');
edit_btn.className = "act-btn act-edit";
edit_btn.innerHTML = ICON_EDIT + "编辑";
edit_btn.addEventListener('click', function(event) {
event.stopPropagation();
startEdit(item);
});
let move_btn = document.createElement('button');
move_btn.className = "act-btn act-move";
move_btn.innerHTML = ICON_MOVE + "移动";
move_btn.addEventListener('click', function(event) {
event.stopPropagation();
startMove(item);
});
let del_btn = document.createElement('button');
del_btn.className = "act-btn act-del";
del_btn.innerHTML = ICON_TRASH + "删除";
del_btn.addEventListener('click', function(event) {
event.stopPropagation();
deleteChild(item.id);
});
act_div.appendChild(edit_btn);
act_div.appendChild(move_btn);
act_div.appendChild(del_btn);
card.appendChild(act_div);
}
recommend_list.appendChild(card)
recommend_list.appendChild(document.createElement('br'))
}
@@ -648,5 +666,64 @@ function loadMap(children_json) {
}
}
/* by qing — 用户信息面板:左上角,默认折叠;展示用户名 / 角色 / 退出登录。
* 身份来自 dnfs /whoami(HttpOnly Cookie 模式下 JS 无法读 Token,由后端解 payload 返回) */
var userpanel = document.getElementById("userpanel");
var userpanel_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; // 与 minimap 同策略:窄屏默认折叠
var current_user = null;
async function loadUserPanel() {
try {
const res = await fetch('/whoami', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: '{}'
});
if (!res.ok) return;
current_user = await res.json();
renderUserPanel();
} catch (e) {
console.error('获取用户信息失败:', e);
}
}
function renderUserPanel() {
if (current_user == null) return;
userpanel.replaceChildren();
let fold = document.createElement("div");
fold.className = "tag fold-btn";
if (userpanel_expand) {
fold.textContent = "v 折叠";
} else {
fold.textContent = "> " + (current_user.login ? current_user.user : "未登录");
}
fold.addEventListener("click", () => {
userpanel_expand = !userpanel_expand;
renderUserPanel();
});
userpanel.appendChild(fold);
if (!userpanel_expand || !current_user.login) return;
let name_tag = document.createElement("div");
name_tag.className = "tag no-hover";
name_tag.textContent = current_user.user;
userpanel.appendChild(name_tag);
let role_tag = document.createElement("div");
role_tag.className = "tag no-hover";
role_tag.textContent = current_user.is_admin ? "管理员" : "普通用户";
userpanel.appendChild(role_tag);
let logout_tag = document.createElement("div");
logout_tag.className = "tag back";
logout_tag.textContent = "退出登录";
logout_tag.addEventListener("click", async () => {
try { await fetch('/logout', {method: 'POST'}); } catch (e) {}
location.href = '/login.html';
});
userpanel.appendChild(logout_tag);
}
getNode(0).then(load_notes);
getNode(0).then(loadMap)
loadUserPanel();
+6
View File
@@ -22,6 +22,11 @@ async function refreshCaptcha() {
captchaToken = '';
try {
const res = await fetch('/captcha/image', { cache: 'no-store' });
if (res.status === 403) { // WAF 拦截:body 非图片,提示后待用户点击重试
captchaImg.removeAttribute('src');
captchaImg.alt = '验证码加载被安全规则拦截,点击重试';
return;
}
if (!res.ok) throw new Error('captcha load failed');
captchaToken = res.headers.get('X-Captcha-Token') || '';
const blob = await res.blob();
@@ -126,6 +131,7 @@ form.addEventListener('submit', async function(e) {
body: jsonBody
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok) throw new Error(result.message || '登录失败');
+28 -2
View File
@@ -10,6 +10,26 @@ const codeTip = document.getElementById('codeTip');
const submitButton = document.getElementById('formsub');
let codeTarget = ''; // 已发送验证码的目标邮箱(验证码本体由服务端校验,前端不经手)
let cooldownTimer = null; // 重发倒计时(与服务端同邮箱 60s 发送冷却对齐)
// 发送成功后进入倒计时,避免用户触发服务端同邮箱 60s 发送冷却
function startResendCooldown(seconds) {
clearInterval(cooldownTimer);
let remain = seconds;
getCodeButton.disabled = true;
getCodeButton.value = `${remain}s 后重发`;
cooldownTimer = setInterval(function () {
remain -= 1;
if (remain <= 0) {
clearInterval(cooldownTimer);
cooldownTimer = null;
getCodeButton.disabled = false;
getCodeButton.value = '获取验证码';
} else {
getCodeButton.value = `${remain}s 后重发`;
}
}, 1000);
}
form.addEventListener('reset', function () {
clearTipStyle();
@@ -114,28 +134,33 @@ getCodeButton.addEventListener('click', async function () {
codeTip.textContent = '验证码发送中…';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-yellow');
let httpStatus = 0;
try {
const res = await fetch('/register/send_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
httpStatus = res.status;
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try { result = JSON.parse(responseText); } catch { throw new Error('服务器响应格式异常'); }
if (!res.ok || !result.success) throw new Error(result.message || '验证码发送失败');
codeTarget = email;
codeInput.value = '';
codeTip.textContent = '验证码已发送至邮箱,请查收(60 秒内有效)';
codeTip.textContent = '验证码已发送至邮箱,请查收(10 分钟内有效)';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-green');
startResendCooldown(60);
} catch (err) {
codeTip.textContent = err.message || '验证码发送失败,请稍后重试';
codeTip.classList.remove('tip-red', 'tip-yellow', 'tip-green');
codeTip.classList.add('tip-red');
if (httpStatus === 429) startResendCooldown(60); // 命中冷却/限流,倒计时对齐
} finally {
getCodeButton.disabled = false;
if (!cooldownTimer) getCodeButton.disabled = false;
}
});
@@ -166,6 +191,7 @@ form.addEventListener('submit', async function (e) {
});
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
let result;
try {
+118
View File
@@ -0,0 +1,118 @@
/**
* Token 生命周期管理器
* - 定时静默刷新:在 Token 80% 生命周期时提前调 /refresh_token
* - 全局 fetch 拦截器:收到 401 先尝试刷新一次,成功则重试原请求,失败则原样返回 401
* - 依赖:gateway POST /refresh_token(Cookie q4_token 自动携带)
* - 无侵入:与 dnfs.js 完全解耦
*/
let _tokenExpireAt = null;
let _refreshTimer = null;
const REFRESH_ADVANCE = 0.8; // 在 80% 生命周期时提前刷新
const REFRESH_MIN_GAP = 60; // 两次刷新最少间隔(秒)
const RETRY_COOLDOWN = 15; // 刷新失败后冷却期(秒)
/* ---------- 过期时间估算:通过 /whoami 获取 iat + keep_time ---------- */
async function estimateExpiry() {
try {
const res = await _rawFetch('/whoami', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!res.ok) return false;
const data = await res.json();
if (!data.login) return false;
// 优先 iat(首次签发时间,刷新不变),回退 created_at(兼容旧 Token)
const issueStr = data.iat || data.created_at;
const keepTime = data.keep_time;
if (!issueStr || !keepTime) return false;
const issueDate = new Date(issueStr.replace(' ', 'T') + 'Z');
if (isNaN(issueDate.getTime())) return false;
_tokenExpireAt = issueDate.getTime() + keepTime * 1000;
scheduleRefresh();
return true;
} catch (e) {
console.warn('TokenManager: 过期时间估算失败', e.message);
return false;
}
}
/* ---------- 定时刷新 ---------- */
function scheduleRefresh() {
clearTimeout(_refreshTimer);
if (!_tokenExpireAt) return;
const remainingSec = (_tokenExpireAt - Date.now()) / 1000;
if (remainingSec <= 0) {
// Token 已过期,立即尝试刷新
tryRefresh();
return;
}
// 80% 位置触发,但至少等 REFRESH_MIN_GAP 秒
const delaySec = Math.max(remainingSec * (1 - REFRESH_ADVANCE), REFRESH_MIN_GAP);
_refreshTimer = setTimeout(tryRefresh, delaySec * 1000);
}
async function tryRefresh() {
try {
const res = await _rawFetch('/refresh_token', { method: 'POST' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
if (data.message) console.warn('TokenManager: 刷新被拒 -', data.message);
// 冷却后重新估算
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
return;
}
const data = await res.json();
if (data.success && data.data) {
console.log(`TokenManager: 已刷新 (第${data.data.refresh_count}次, 剩余${data.data.refreshes_remaining}次)`);
}
await estimateExpiry();
} catch (e) {
console.warn('TokenManager: 刷新网络异常', e.message);
_refreshTimer = setTimeout(() => estimateExpiry(), RETRY_COOLDOWN * 1000);
}
}
/* ---------- 全局 fetch 401 拦截器 ---------- */
const _rawFetch = window.fetch;
window.fetch = async function (url, options) {
let resp = await _rawFetch(url, options);
// 仅处理 401;/refresh_token 自身不重试防循环
if (resp.status !== 401 || (typeof url === 'string' && url.includes('/refresh_token'))) {
return resp;
}
try {
const refreshRes = await _rawFetch('/refresh_token', { method: 'POST' });
if (!refreshRes.ok) return resp;
const data = await refreshRes.json();
if (!data.success) return resp;
console.log('TokenManager: 401 拦截 → 刷新成功,重试原请求');
await estimateExpiry();
// Cookie 已由 gateway Set-Cookie 自动更新,直接重试
return await _rawFetch(url, options);
} catch (e) {
console.warn('TokenManager: 401 刷新异常', e.message);
}
return resp;
};
/* ---------- 启动 ---------- */
estimateExpiry();
+12
View File
@@ -68,3 +68,15 @@
window.FormValidators = { username, password, email };
}());
/* WAF 拦截识别:waf 命中规则时返回 403 + 纯文本 "blocked by WAF"
* (见 waf/internal/proxy/proxy.go block());gateway 的 403(Target-Url
* 白名单拒绝)body 为 JSON 错误消息,不含此特征,不会误判。 */
window.WafTip = {
MESSAGE: '请求被 WAF 安全规则拦截,请检查输入内容',
isBlocked(res, responseText) {
return res.status === 403
&& typeof responseText === 'string'
&& responseText.indexOf('blocked by WAF') !== -1;
}
};
+3 -1
View File
@@ -138,7 +138,9 @@ async function loadData() {
try {
const url = `/api/filelist?path=${encodeURIComponent(currentPath)}`;
const res = await fetch(url);
const data = await res.json();
const responseText = await res.text();
if (window.WafTip && WafTip.isBlocked(res, responseText)) throw new Error(WafTip.MESSAGE);
const data = JSON.parse(responseText);
if (data.success) {
items = data.items || [];
render();
+1 -1
View File
@@ -7,7 +7,7 @@ smtp 黑盒用户模拟测试 — 发送邮箱验证码 → 校验。
3. 用空参数校验 → 应报错
注意: SMTP 服务需要配置真实的邮件服务器。若服务不可达或邮件发送失败,
相关测试将标记为 SKIP。验证码有效期为 60 秒。
相关测试将标记为 SKIP。验证码有效期为 10 分钟(600 秒)。
"""
import json
+60 -19
View File
@@ -22,7 +22,7 @@
| 列出所有规则 | `./waf.bin -list` |
| 测试输入 | `./waf.bin -check "/?id=1' OR '1'='1"` |
| 热加载(不重启) | `kill -HUP $(pgrep waf)` |
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` |
| 临时禁用某文件 | `mv rules/local/x.conf x.conf.bak && kill -HUP $(pgrep waf)` (引擎仅加载 `.conf` 后缀) |
### 规则文件组织
@@ -32,7 +32,7 @@ rules/
├── local/ # 本地自定义规则(ID 段 100000-199999)
│ ├── custom.conf # 示例:SQL注入/XSS/路径遍历/命令注入/扫描器UA
│ └── exceptions.conf # 误报白名单(SecRuleRemoveById / ctl:ruleRemoveById)
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取)
└── crs/ # OWASP CRS(不入库,由 _fetch_crs.sh 拉取,可选)
├── coraza.conf # CRS 入口(请求体处理器初始化 + tx.crs_setup_version)
├── LICENSE # Apache-2.0
└── rules/*.conf # CRS 规则文件
@@ -54,8 +54,8 @@ rules/
| ID 段 | 用途 |
|-------|------|
| 100000-199999 | 本地自定义规则 |
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义) |
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改) |
| 200000-200006 | CRS 请求体处理器初始化(XML/JSON,由 `crs/coraza.conf` 定义,仅当启用 CRS 时存在) |
| 900000-999999 | OWASP CRS 规则(由 `crs/rules/` 提供,不手改,仅当启用 CRS 时存在) |
### 规则语法(SecLang)
@@ -99,7 +99,7 @@ rules_dir: "./rules"
|------|--------|------|
| `listen` | `:443` | 监听地址 |
| `tls_cert` / `tls_key` | — | TLS 证书与私钥路径 |
| `upstream` | — | gateway 地址,**必填** |
| `upstream` | — | gateway 地址,**必填**(启动时硬性校验,缺失将导致进程退出) |
| `rules_dir` | `./rules` | 规则根目录 |
## 目录结构
@@ -112,8 +112,10 @@ waf/
├── internal/
│ ├── engine/
│ │ └── engine.go # coraza 封装:加载/热加载/列举/单测/输入检测
│ ├── logclient/
│ │ └── logclient.go # Go 版 pymsg 日志客户端(异步批量推送)
│ └── proxy/
│ └── proxy.go # ReverseProxy + WAF 事务接入
│ └── proxy.go # ReverseProxy + WAF 事务接入 + 安全响应头注入
├── rules/
│ ├── waf.conf
│ └── local/
@@ -156,25 +158,61 @@ bash /opt/micro_servers/waf/_run.sh
并把 `gateway` 改为内部 HTTP 端口(不再监听 443)。
## 命中日志
## 安全响应头
命中规则时输出 JSON 一行,便于 `jq` 分析(见 [proxy.go:120-137](internal/proxy/proxy.go#L120)):
WAF 在边缘层统一注入安全响应头([proxy.go:54-77](internal/proxy/proxy.go#L54-L77) `setSecurityHeaders`),覆盖所有出站响应(包括正常转发、命中拦截、上游 502)。下游服务(static/dnfs 等)的同名头会被 WAF 覆盖,避免 CSP 绕过。
```json
{"ts":"2026-07-25T10:00:00Z","action":"block","rule_id":100001,"ip":"1.2.3.4","method":"GET","path":"/","query":"id=1'+OR+'1'='1","ua":"curl/8.0","cost_ms":0}
```
| Header | 值 | 说明 |
|--------|----|------|
| X-Frame-Options | SAMEORIGIN | 允许本站 iframe 嵌套(登录页条例弹窗),防跨站点击劫持 |
| X-Content-Type-Options | nosniff | — |
| X-XSS-Protection | 1; mode=block | 现代浏览器已废弃,仅兼容旧浏览器 |
| Referrer-Policy | strict-origin-when-cross-origin | — |
| Permissions-Policy | camera=(), microphone=(), geolocation=() | — |
| Strict-Transport-Security | max-age=15552000 | HSTS 半年期,**不含子域** |
| Content-Security-Policy | default-src 'self'; script-src 'self' 'unsafe-inline'; ... | 暂保留 `'unsafe-inline'` 以兼容 forgot.html 与 games/*.html 的内联脚本 |
放行请求时输出一行 pass 日志(见 [proxy.go:115](internal/proxy/proxy.go#L115)):
## 拦截日志
```
[proxy] pass ip=1.2.3.4:54321 method=GET path=/ status=200 rule=- cost=1.2ms
命中规则时 `block()` 经 logclient(Go 版 pymsg 客户端,见 [internal/logclient/](internal/logclient/))异步批量推送到 db:8005 的 `Logs` 表(与其他服务的集中日志同链路;标准日志已静默,stdout/journalctl 中看不到拦截记录):
- **规则命中拦截**(block 触发):`service=waf`、`level=WARNING`
- **规则加载/解析错误**(`LogError` 回调触发,引擎初始化/热加载时的语义错误):`service=waf`、`level=ERROR`
`block()` 推送的日志 `extra` 字段(JSON 字符串):
| 字段 | 类型 | 说明 |
|------|------|------|
| `rule_id` | int | 命中规则 ID |
| `ip` | string | `RemoteAddr` 原始值,含端口号(如 `1.2.3.4:54321`) |
| `method` | string | 请求方法 |
| `path` | string | 请求路径 |
| `query` | string | 查询字符串 |
| `ua` | string | User-Agent |
| `cost_ms` | int64 | 处理耗时(毫秒) |
fail-safe:db 不可用时日志静默丢弃,不影响拦截动作本身。
查询最近的拦截记录:
```bash
sqlite3 /opt/micro_servers/db/database.db \
"SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='WARNING' ORDER BY id DESC LIMIT 20;"
```
聚合统计命中规则:
```bash
journalctl -u Nidus -o cat | grep '"action":"block"' | \
jq -r '.rule_id' | sort | uniq -c | sort -rn
sqlite3 /opt/micro_servers/db/database.db \
"SELECT json_extract(extra,'\$.rule_id') AS rule, COUNT(*) FROM Logs \
WHERE service='waf' AND level='WARNING' GROUP BY rule ORDER BY 2 DESC;"
```
查看规则加载/解析错误:
```bash
sqlite3 /opt/micro_servers/db/database.db \
"SELECT ts, message, extra FROM Logs WHERE service='waf' AND level='ERROR' ORDER BY id DESC LIMIT 20;"
```
## 启用 OWASP CRS(可选)
@@ -210,26 +248,29 @@ kill -HUP $(pgrep waf)
## 请求处理流程
[proxy.go:57-117](internal/proxy/proxy.go#L57) `ServeHTTP`:
[proxy.go:81-138](internal/proxy/proxy.go#L81) `ServeHTTP`:
1. `ProcessConnection` + `ProcessURI` + 添加请求头(含 Host)
2. `ProcessRequestHeaders` → 命中即 `block` 返回 403
3. 读全量请求体 → `ReadRequestBodyFrom` → `ProcessRequestBody` → 命中即 block
4. `httputil.ReverseProxy.ServeHTTP` 转发到 gateway
5. 捕获响应头 → `ProcessResponseHeaders`
6. `ModifyResponse` 统一注入安全响应头(覆盖下游同名头)
Director 改写([proxy.go:36-46](internal/proxy/proxy.go#L36)):剥离 `RemoteAddr` 端口,设置 `X-Forwarded-For` 与 `X-Forwarded-Proto: https`,供 gateway 的 RateLimiter 取真实客户端 IP。
ErrorHandler([proxy.go:48-51](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,不暴露内部堆栈,仅服务端 log。
ErrorHandler([proxy.go:48-53](internal/proxy/proxy.go#L48)):upstream 不可达时返回 502,同时注入安全响应头,不暴露内部堆栈,仅服务端 log。
## 已知限制
- 请求体全量读入内存检测,10MB 上限(`SecRequestBodyLimit`)。大文件上传场景需调大或加白名单。
- **响应体检测未启用**:仅 `ProcessResponseHeaders`,未 `ProcessResponseBody`(缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- **响应头检测已启用**(`ProcessResponseHeaders`),**响应体检测未启用**(未 `ProcessResponseBody`,缓冲整个响应延迟成本高)。SQL 报错回显等响应体泄漏检测缺失。
- 限流/黑白名单未实现,可后续作为 coraza 规则或独立中间件加。
- `http.Server` 未设 `ReadTimeout` / `WriteTimeout` / `IdleTimeout`,存在慢连接攻击风险。
- TLS 仅设 `MinVersion: TLS1.2`,未限定 cipher suites,允许弱加密套件。
- `X-Forwarded-For` 直接覆盖原值,未按 RFC 7239 追加;当前 gateway 也只取第一个 IP,无实际影响,但语义不严格。
- CSP 仍含 `'unsafe-inline'`(为兼容 forgot.html 与 games/*.html 的内联脚本),待前端清理后可收紧。
- HSTS 已启用(半年期,不含子域),站点全量 HTTPS 覆盖后可按需收紧。
## 依赖