diff --git a/BotOps/README.md b/BotOps/README.md index 6b1c119..3de9e43 100644 --- a/BotOps/README.md +++ b/BotOps/README.md @@ -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 # 底部状态栏(连接状态) ``` diff --git a/README.md b/README.md index 79bf3fa..4403c6e 100755 --- a/README.md +++ b/README.md @@ -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 ` 热加载规则;`waf -list` 列规则;`waf -test ` 校验语法;`waf -check ""` 测试单条输入。 - 配置见 [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),实现全链路汇聚,无需引入额外中间件。 diff --git a/aibox/README.md b/aibox/README.md index 9ae1e2e..3b3e313 100644 --- a/aibox/README.md +++ b/aibox/README.md @@ -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": "" } } ``` diff --git a/aibox/README.robot.md b/aibox/README.robot.md new file mode 100644 index 0000000..278cece --- /dev/null +++ b/aibox/README.robot.md @@ -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) + +--- + +*本文档由代码审查工具生成,供开发团队参考。* diff --git a/aibox/_run.sh b/aibox/_run.sh index 27c87e0..b465f80 100644 --- a/aibox/_run.sh +++ b/aibox/_run.sh @@ -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 $@ diff --git a/aibox/main.py b/aibox/aibox.py similarity index 99% rename from aibox/main.py rename to aibox/aibox.py index 469b245..9c9604f 100644 --- a/aibox/main.py +++ b/aibox/aibox.py @@ -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` 工具向用户提问,以便获得更准确的指引。 + + ## 第四步:执行任务或回答问题 根据搜索结果,选择合适的工具或直接回答。 【重要提醒】 diff --git a/bot4cj/README.robot.md b/bot4cj/README.robot.md new file mode 100644 index 0000000..7ce9f1b --- /dev/null +++ b/bot4cj/README.robot.md @@ -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 链表 + ↓ +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 diff --git a/captcha/README.md b/captcha/README.md index 0b388f4..674e16c 100644 --- a/captcha/README.md +++ b/captcha/README.md @@ -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 ``` diff --git a/core/docs/CODE_REVIEW_LOG_20260730.md b/core/docs/CODE_REVIEW_LOG_20260730.md new file mode 100644 index 0000000..6f4b252 --- /dev/null +++ b/core/docs/CODE_REVIEW_LOG_20260730.md @@ -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 `) + 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>` 作为内部存储,但无互斥锁保护。`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 + +--- + +*本日志由自动化代码审查工具生成,供开发团队参考。修复建议需结合实际场景评估。* diff --git a/db/README.md b/db/README.md index 3f52b8e..8f57728 100644 --- a/db/README.md +++ b/db/README.md @@ -201,9 +201,10 @@ logs.push("gateway", "WARNING", "登录失败次数超限", ``` 客户端特性: -- 纯标准库实现,异步非阻塞:内存队列 + 后台线程批量推送 +- 无第三方依赖(仅依赖 pymsg 项目内置库),异步非阻塞:内存队列 + 后台线程批量推送 - fail-safe:db 不可用时静默丢弃,绝不影响业务 - 缓冲批量:队列满 50 条或超时 2 秒时自动 flush +- 长度保护:`message` 上限 8192 字节、`extra` 上限 4096 字节,超长自动截断 **db 自身**:`_log_internal()` 直接写库,无网络往返(防递归)。 diff --git a/db/_set.sh b/db/_set.sh index 68c83b2..d78295f 100644 --- a/db/_set.sh +++ b/db/_set.sh @@ -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 diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index d317e2c..c06f7c4 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -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 diff --git a/gateway/README.md b/gateway/README.md index dee5208..66b643d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -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,与其他步骤一致)。 diff --git a/gateway/_run.sh b/gateway/_run.sh index 172a051..dcbe665 100644 --- a/gateway/_run.sh +++ b/gateway/_run.sh @@ -1,4 +1,4 @@ #!/bin/bash cd "$(dirname "$0")" || exit -sleep 1 +sleep 2 exec ./build/gateway.out -i example.ini #1>/dev/null diff --git a/gateway/example.ini b/gateway/example.ini index f6fbd24..9221437 100644 --- a/gateway/example.ini +++ b/gateway/example.ini @@ -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 白名单(分号分隔) diff --git a/gateway/src/main.cpp b/gateway/src/main.cpp index e6b1989..70a5be1 100644 --- a/gateway/src/main.cpp +++ b/gateway/src/main.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -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 @@ -301,6 +302,10 @@ http::response handleLogout( const http::request& req, const std::unordered_map& params); +http::response handleRefreshToken( + const http::request& req, + const std::unordered_map& params); + /* 代理 captcha 图形验证码:pymsg 调 captcha 服务,返回 PNG 图片 */ http::response handleCaptchaImage( const http::request& req, @@ -324,7 +329,10 @@ static std::string _DEFT_URL; static std::vector _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 _CORS_ORIGINS; // CORS 允许的 Origin 白名单,可由 [CORS] allowed_origins 配置 +static std::vector _ADMIN_USERS; // 管理员账号白名单,可由 [Admin] users 配置(分号分隔),登录时写入 Token payload 的 is_admin /* instance items */ static std::unique_ptr _SERVE; @@ -333,6 +341,7 @@ static std::unique_ptr _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("Proxy.opened", ""), ';'); _DB_PATH = cf.get("Database.path", ""); _TOKEN_KEEP_TIME = cf.get("Token.keep_time", 3600); + _TOKEN_REFRESH_GRACE_MULTIPLIER = cf.get("Token.refresh_grace_multiplier", 2); + _TOKEN_MAX_REFRESH = cf.get("Token.max_refresh", 10); _CORS_ORIGINS = split(cf.get("CORS.allowed_origins", ""), ';'); + _ADMIN_USERS = split(cf.get("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 { + 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 { return wrapperHttp(req, params, @@ -867,10 +884,18 @@ http::response 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 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 handleRefreshToken( + const http::request& req, + const std::unordered_map& params) +{ + http::response 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 _ */ + 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(obj.at("user")); + std::string iat_str = json::value_to(obj.at("iat")); + int keep_time = json::value_to(obj.at("keep_time")); + int refresh_count = json::value_to(obj.at("refresh_count")); + int max_refresh = json::value_to(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 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 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(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}})); diff --git a/libcrypt/README.md b/libcrypt/README.md index 404df36..861fafa 100644 --- a/libcrypt/README.md +++ b/libcrypt/README.md @@ -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 +#include +#include +#include +#include +``` + +链接(OpenSSL 符号在 header-only 类中,需显式链接): + +```bash +g++ -std=c++20 ... -lus_crypt -lssl -lcrypto +``` + +## 使用示例 + +```cpp +#include +#include +#include +#include + +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 diff --git a/libcrypt/inc/us/AES_CBC.hpp b/libcrypt/inc/us/AES_CBC.hpp index 6ed2d29..3b55c31 100644 --- a/libcrypt/inc/us/AES_CBC.hpp +++ b/libcrypt/inc/us/AES_CBC.hpp @@ -2,6 +2,7 @@ #include #include #include +#include #include #include #include diff --git a/libcrypt/inc/us/SHA256.hpp b/libcrypt/inc/us/SHA256.hpp index c46efe2..99a9cb1 100644 --- a/libcrypt/inc/us/SHA256.hpp +++ b/libcrypt/inc/us/SHA256.hpp @@ -5,9 +5,7 @@ #include #include #include -#include -#include -#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& bytes) { - std::stringstream ss; - ss << std::hex << std::setfill('0'); - for (unsigned char byte : bytes) { - ss << std::setw(2) << static_cast(byte); - } - return ss.str(); - } }; } diff --git a/libhttp/README.robot.md b/libhttp/README.robot.md new file mode 100644 index 0000000..cf6ef54 --- /dev/null +++ b/libhttp/README.robot.md @@ -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 服务器 + +**头文件**: `` +**实现**: `src/HttpServer.cpp` + +#### 5.1.1 类型别名 + +```cpp +using HttpRequestHandler = std::function( + const http::request& req, + const std::unordered_map& params)>; + +using MiddlewareHandler = std::function& req, + http::response& res, + const std::unordered_map& 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) + ├── strand_ 保证线程安全 + └── 读取 → handleRequest() → 写入 → [keep-alive 则继续读取] +``` + +--- + +### 5.2 HttpClient — HTTP 客户端 + +**头文件**: `` (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& 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 callback); + +// 便捷方法 +void get_stream_sync(const std::string& url, std::function callback); +void post_stream_sync(const std::string& url, const std::string& data, + std::function callback); +void put_stream_sync(const std::string& url, const std::string& data, + std::function callback); +void delete_stream_sync(const std::string& url, std::function callback); +``` + +**特点**: +- 同步阻塞调用 +- 响应体通过 `CURLOPT_WRITEFUNCTION` 直接追加到 `std::string`,**保留所有换行符和特殊字符** +- 超时设置:连接超时 10 秒,总超时 30 秒 +- 每次请求调用 `curl_easy_reset` 重置状态 + +--- + +### 5.3 WebSocketServer — WebSocket 服务器 + +**头文件**: `` +**实现**: `src/WebSocketServer.cpp` + +#### 5.3.1 回调类型 + +```cpp +using WebSocketMessageHandler = std::function sendCallback)>; + +using WebSocketConnectHandler = std::function; + +using WebSocketDisconnectHandler = std::function; +``` + +#### 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`),保证消息顺序 +- 客户端 ID 由全局原子计数器自动生成,格式 `"client_N"` +- 线程安全:客户端列表受 `clientsMutex_` 保护 +- 消息处理器支持路径路由(按 `path` 键索引) + +**内部架构**: + +``` +WebSocketServer +├── Impl +│ ├── io_context +│ ├── Listener (内部类) → tcp::acceptor +│ └── threads_ +└── Session (内部类,enable_shared_from_this) + ├── websocket::stream + ├── sendQueue_ (std::deque) + └── 读取循环 → onMessage 回调 → sendCallback 写回 +``` + +--- + +### 5.4 WebSocketClient — WebSocket 客户端 + +**头文件**: ``, `` +**实现**: `src/WebSocketClient.cpp` + +#### 5.4.1 抽象基类 `WebSocketClientBase` + +定义统一接口: + +```cpp +using MessageCallback = std::function; +using ErrorCallback = std::function; +using ConnectCallback = std::function; +using CloseCallback = std::function; + +// 回调设置 +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 +#include + +int main() { + pmc::net::HttpServer server("0.0.0.0", 8080, 4); + + server.get("/hello", [](const auto& req, const auto& params) { + http::response 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 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` 跟踪所有活跃会话,`stop()` 时遍历清理 +- `WebSocketServer` 通过 `unordered_map>` + 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 客户端实现 +``` + diff --git a/libth/README.md b/libth/README.md index e087cbe..33a0cc0 100644 --- a/libth/README.md +++ b/libth/README.md @@ -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;` + +### 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 +#include +#include +#include +``` + +链接:`g++ -std=c++20 ... -lus_th` + +## 使用示例 + +```cpp +#include +#include + +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 diff --git a/libth/inc/us/Cv_wait.hpp b/libth/inc/us/Cv_wait.hpp index 40886f6..1dad774 100644 --- a/libth/inc/us/Cv_wait.hpp +++ b/libth/inc/us/Cv_wait.hpp @@ -28,7 +28,7 @@ public: inline void WakeCv() /* 唤醒线程 */ { { - std::lock_guard lcok(mtx); + std::lock_guard lock(mtx); flag = true; } cv.notify_all(); diff --git a/libtry/README.robot.md b/libtry/README.robot.md new file mode 100644 index 0000000..5722e88 --- /dev/null +++ b/libtry/README.robot.md @@ -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 +#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 源码分析生成,供开发团队参考。使用前请评估已知限制。* diff --git a/pymsg/README.robot.md b/pymsg/README.robot.md new file mode 100644 index 0000000..d94d743 --- /dev/null +++ b/pymsg/README.robot.md @@ -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 源码分析生成,供开发团队参考。* diff --git a/smtp/README.md b/smtp/README.md new file mode 100644 index 0000000..c94a2d6 --- /dev/null +++ b/smtp/README.md @@ -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 diff --git a/smtp/_run.sh b/smtp/_run.sh index 3133126..0811c15 100755 --- a/smtp/_run.sh +++ b/smtp/_run.sh @@ -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 diff --git a/smtp/_set.sh b/smtp/_set.sh index 5c79af3..1eb7202 100755 --- a/smtp/_set.sh +++ b/smtp/_set.sh @@ -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 diff --git a/smtp/smtp.py b/smtp/smtp.py index 2028ab3..ea45cd6 100644 --- a/smtp/smtp.py +++ b/smtp/smtp.py @@ -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) \ No newline at end of file diff --git a/smtp/smtp_v2.py b/smtp/smtp_v2.py new file mode 100644 index 0000000..de9edde --- /dev/null +++ b/smtp/smtp_v2.py @@ -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() diff --git a/static/README.md b/static/README.md index fea5bfc..003ea13 100644 --- a/static/README.md +++ b/static/README.md @@ -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 /` | 静态文件服务;目录返回 `__view.html` 由前端拉取列表 | | `GET /api/filelist?path=` | 文件列表 API,过滤 `__` 前缀系统文件 | | `GET /video/` | 视频/音频流媒体(HTTP Range 206 分段) | | `GET /?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 # 可选,编译为独立二进制 ``` ## 运行 diff --git a/static/fsys.py b/static/fsys.py index 4ced9a8..3c90403 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -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) diff --git a/static/public/__view.html b/static/public/__view.html index 1690cd7..c29f08e 100644 --- a/static/public/__view.html +++ b/static/public/__view.html @@ -47,6 +47,7 @@ 蜀ICP备2024107064号-2 + diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 1e4ccfe..b19d08a 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -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 { diff --git a/static/public/dnfs.html b/static/public/dnfs.html index 56d0f4e..bc4c659 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -9,6 +9,7 @@
+
@@ -25,6 +26,7 @@ +